diff --git a/my-project-backend/pom.xml b/my-project-backend/pom.xml index 085e7e8..57211a5 100644 --- a/my-project-backend/pom.xml +++ b/my-project-backend/pom.xml @@ -94,6 +94,12 @@ springdoc-openapi-starter-webmvc-ui 2.1.0 + + + io.minio + minio + 8.3.9 + diff --git a/my-project-backend/src/main/java/com/example/config/MinioConfiguration.java b/my-project-backend/src/main/java/com/example/config/MinioConfiguration.java new file mode 100644 index 0000000..3c568b2 --- /dev/null +++ b/my-project-backend/src/main/java/com/example/config/MinioConfiguration.java @@ -0,0 +1,28 @@ +package com.example.config; + +import io.minio.MinioClient; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Slf4j +@Configuration +public class MinioConfiguration { + + @Value("${spring.minio.endpoint}") + String endpoint; + @Value("${spring.minio.username}") + String username; + @Value("${spring.minio.password}") + String password; + + @Bean + public MinioClient minioClient(){ + log.info("Init minio client..."); + return MinioClient.builder() + .endpoint(endpoint) + .credentials(username, password) + .build(); + } +} diff --git a/my-project-backend/src/main/java/com/example/config/SecurityConfiguration.java b/my-project-backend/src/main/java/com/example/config/SecurityConfiguration.java index e61d41d..2db8779 100644 --- a/my-project-backend/src/main/java/com/example/config/SecurityConfiguration.java +++ b/my-project-backend/src/main/java/com/example/config/SecurityConfiguration.java @@ -54,6 +54,7 @@ public class SecurityConfiguration { return http .authorizeHttpRequests(conf -> conf .requestMatchers("/api/auth/**", "/error").permitAll() + .requestMatchers("/images/**").permitAll() .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll() .anyRequest().hasAnyRole(Const.ROLE_DEFAULT) ) diff --git a/my-project-backend/src/main/java/com/example/controller/ImageController.java b/my-project-backend/src/main/java/com/example/controller/ImageController.java new file mode 100644 index 0000000..c746109 --- /dev/null +++ b/my-project-backend/src/main/java/com/example/controller/ImageController.java @@ -0,0 +1,35 @@ +package com.example.controller; + +import com.example.entity.RestBean; +import com.example.service.ImageService; +import com.example.utils.Const; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; + +@Slf4j +@RestController +@RequestMapping("/api/image") +public class ImageController { + + @Resource + ImageService service; + + @PostMapping("/avatar") + public RestBean uploadAvatar(@RequestParam("file") MultipartFile file, + @RequestAttribute(Const.ATTR_USER_ID) int id) throws IOException { + if(file.getSize() > 1025 * 100) + return RestBean.failure(400, "头像图片不能大于100KB"); + log.info("正在进行头像上传操作..."); + String url = service.uploadAvatar(file, id); + if(url != null) { + log.info("头像上传成功,大小: " + file.getSize()); + return RestBean.success(url); + } else { + return RestBean.failure(400, "头像上传失败,请联系管理员!"); + } + } +} diff --git a/my-project-backend/src/main/java/com/example/controller/ObjectController.java b/my-project-backend/src/main/java/com/example/controller/ObjectController.java new file mode 100644 index 0000000..3573c38 --- /dev/null +++ b/my-project-backend/src/main/java/com/example/controller/ObjectController.java @@ -0,0 +1,46 @@ +package com.example.controller; + +import com.example.entity.RestBean; +import com.example.service.ImageService; +import io.minio.errors.ErrorResponseException; +import jakarta.annotation.Resource; +import jakarta.servlet.ServletOutputStream; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@Slf4j +@RestController +public class ObjectController { + + @Resource + ImageService service; + + @GetMapping("/images/avatar/**") + public void imageFetch(HttpServletRequest request, HttpServletResponse response) throws Exception { + this.fetchImage(request, response); + } + + private void fetchImage(HttpServletRequest request, HttpServletResponse response) throws Exception { + String imagePath = request.getServletPath().substring(7); + ServletOutputStream stream = response.getOutputStream(); + if(imagePath.length() <= 13) { + response.setStatus(404); + stream.println(RestBean.failure(404, "Not found").toString()); + } else { + try { + service.fetchImageFromMinio(stream, imagePath); + response.setHeader("Cache-Control", "max-age=2592000"); + } catch (ErrorResponseException e) { + if(e.response().code() == 404) { + response.setStatus(404); + stream.println(RestBean.failure(404, "Not found").toString()); + } else { + log.error("从Minio获取图片出现异常: "+e.getMessage(), e); + } + } + } + } +} diff --git a/my-project-backend/src/main/java/com/example/entity/dto/Account.java b/my-project-backend/src/main/java/com/example/entity/dto/Account.java index 3fbfa1f..9d3729c 100644 --- a/my-project-backend/src/main/java/com/example/entity/dto/Account.java +++ b/my-project-backend/src/main/java/com/example/entity/dto/Account.java @@ -22,5 +22,6 @@ public class Account implements BaseData { String password; String email; String role; + String avatar; Date registerTime; } diff --git a/my-project-backend/src/main/java/com/example/entity/vo/response/AccountVO.java b/my-project-backend/src/main/java/com/example/entity/vo/response/AccountVO.java index ca187f1..8c4169e 100644 --- a/my-project-backend/src/main/java/com/example/entity/vo/response/AccountVO.java +++ b/my-project-backend/src/main/java/com/example/entity/vo/response/AccountVO.java @@ -9,5 +9,6 @@ public class AccountVO { String username; String email; String role; + String avatar; Date registerTime; } diff --git a/my-project-backend/src/main/java/com/example/filter/RequestLogFilter.java b/my-project-backend/src/main/java/com/example/filter/RequestLogFilter.java index 4ab82b6..4ef1199 100644 --- a/my-project-backend/src/main/java/com/example/filter/RequestLogFilter.java +++ b/my-project-backend/src/main/java/com/example/filter/RequestLogFilter.java @@ -29,7 +29,7 @@ public class RequestLogFilter extends OncePerRequestFilter { @Resource SnowflakeIdGenerator generator; - private final Set ignores = Set.of("/swagger-ui", "/v3/api-docs"); + private final Set ignores = Set.of("/swagger-ui", "/v3/api-docs", "/images"); @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { diff --git a/my-project-backend/src/main/java/com/example/service/ImageService.java b/my-project-backend/src/main/java/com/example/service/ImageService.java new file mode 100644 index 0000000..2e0ced9 --- /dev/null +++ b/my-project-backend/src/main/java/com/example/service/ImageService.java @@ -0,0 +1,11 @@ +package com.example.service; + +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.io.OutputStream; + +public interface ImageService { + String uploadAvatar(MultipartFile file, int id) throws IOException; + void fetchImageFromMinio(OutputStream stream, String image) throws Exception; +} diff --git a/my-project-backend/src/main/java/com/example/service/impl/AccountServiceImpl.java b/my-project-backend/src/main/java/com/example/service/impl/AccountServiceImpl.java index 89b4f4c..700f875 100644 --- a/my-project-backend/src/main/java/com/example/service/impl/AccountServiceImpl.java +++ b/my-project-backend/src/main/java/com/example/service/impl/AccountServiceImpl.java @@ -99,7 +99,7 @@ public class AccountServiceImpl extends ServiceImpl impl if(this.existsAccountByUsername(username)) return "该用户名已被他人使用,请重新更换"; String password = passwordEncoder.encode(info.getPassword()); Account account = new Account(null, info.getUsername(), - password, email, Const.ROLE_DEFAULT, new Date()); + password, email, Const.ROLE_DEFAULT, null, new Date()); if(!this.save(account)) { return "内部错误,注册失败"; } else { diff --git a/my-project-backend/src/main/java/com/example/service/impl/ImageServiceImpl.java b/my-project-backend/src/main/java/com/example/service/impl/ImageServiceImpl.java new file mode 100644 index 0000000..05474f3 --- /dev/null +++ b/my-project-backend/src/main/java/com/example/service/impl/ImageServiceImpl.java @@ -0,0 +1,62 @@ +package com.example.service.impl; + +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.example.entity.dto.Account; +import com.example.mapper.AccountMapper; +import com.example.service.ImageService; +import io.minio.GetObjectArgs; +import io.minio.GetObjectResponse; +import io.minio.MinioClient; +import io.minio.PutObjectArgs; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.compress.utils.IOUtils; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.io.OutputStream; +import java.util.UUID; + +@Slf4j +@Service +public class ImageServiceImpl implements ImageService { + + @Resource + MinioClient client; + + @Resource + AccountMapper mapper; + + @Override + public void fetchImageFromMinio(OutputStream stream, String image) throws Exception { + GetObjectArgs args = GetObjectArgs.builder() + .bucket("study") + .object(image) + .build(); + GetObjectResponse response = client.getObject(args); + IOUtils.copy(response, stream); + } + + @Override + public String uploadAvatar(MultipartFile file, int id) throws IOException { + String imageName = UUID.randomUUID().toString().replace("-", ""); + imageName = "/avatar/" + imageName; + PutObjectArgs args = PutObjectArgs.builder() + .bucket("study") + .stream(file.getInputStream(), file.getSize(), -1) + .object(imageName) + .build(); + try { + client.putObject(args); + if(mapper.update(null, Wrappers.update() + .eq("id", id).set("avatar", imageName)) > 0) { + return imageName; + } else + return null; + } catch (Exception e) { + log.error("图片上传出现问题: "+ e.getMessage(), e); + return null; + } + } +} diff --git a/my-project-backend/src/main/resources/application-dev.yml b/my-project-backend/src/main/resources/application-dev.yml index c3fb294..0649710 100644 --- a/my-project-backend/src/main/resources/application-dev.yml +++ b/my-project-backend/src/main/resources/application-dev.yml @@ -39,3 +39,7 @@ spring: origin: '*' credentials: false methods: '*' + minio: + endpoint: 'http://localhost:9000' + username: 'minio' + password: 'password' diff --git a/my-project-backend/src/main/resources/application-prod.yml b/my-project-backend/src/main/resources/application-prod.yml index 984d8f4..4498287 100644 --- a/my-project-backend/src/main/resources/application-prod.yml +++ b/my-project-backend/src/main/resources/application-prod.yml @@ -43,3 +43,7 @@ spring: origin: '*' credentials: false methods: '*' + minio: + endpoint: 'http://localhost:9000' + username: 'minio' + password: 'password' diff --git a/my-project-frontend/src/net/index.js b/my-project-frontend/src/net/index.js index 8f66d44..bdbd510 100644 --- a/my-project-frontend/src/net/index.js +++ b/my-project-frontend/src/net/index.js @@ -99,4 +99,4 @@ function unauthorized() { return !takeAccessToken() } -export { post, get, login, logout, unauthorized } +export { post, get, login, logout, unauthorized, accessHeader } diff --git a/my-project-frontend/src/store/index.js b/my-project-frontend/src/store/index.js index 40217ac..c678217 100644 --- a/my-project-frontend/src/store/index.js +++ b/my-project-frontend/src/store/index.js @@ -1,4 +1,5 @@ import { defineStore } from "pinia"; +import axios from "axios"; export const useStore = defineStore('general', { state: () => { @@ -7,8 +8,16 @@ export const useStore = defineStore('general', { username: '', email: '', role: '', + avatar: null, registerTime: null } } + }, getters: { + avatarUrl() { + if(this.user.avatar) + return `${axios.defaults.baseURL}/images${this.user.avatar}` + else + return 'https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png' + } } }) diff --git a/my-project-frontend/src/views/IndexView.vue b/my-project-frontend/src/views/IndexView.vue index cce2b5a..20c7591 100755 --- a/my-project-frontend/src/views/IndexView.vue +++ b/my-project-frontend/src/views/IndexView.vue @@ -59,7 +59,7 @@ function userLogout() { {{store.user.email}} - + diff --git a/my-project-frontend/src/views/settings/UserSetting.vue b/my-project-frontend/src/views/settings/UserSetting.vue index 925c926..5d193cb 100644 --- a/my-project-frontend/src/views/settings/UserSetting.vue +++ b/my-project-frontend/src/views/settings/UserSetting.vue @@ -4,8 +4,9 @@ import Card from "@/components/Card.vue"; import {Message, Refresh, Select, User} from "@element-plus/icons-vue"; import {useStore} from "@/store"; import {computed, reactive, ref} from "vue"; -import {get, post} from "@/net"; +import {accessHeader, get, post} from "@/net"; import {ElMessage} from "element-plus"; +import axios from "axios"; const store = useStore() @@ -29,7 +30,7 @@ const emailForm = reactive({ const validateUsername = (rule, value, callback) => { if (value === '') { callback(new Error('请输入用户名')) - } else if(!/^[a-zA-Z0-9\u4e00-\u9fa5]+$/.test(value)){ + } else if (!/^[a-zA-Z0-9\u4e00-\u9fa5]+$/.test(value)) { callback(new Error('用户名不能包含特殊字符,只能是中文/英文')) } else { callback() @@ -37,10 +38,10 @@ const validateUsername = (rule, value, callback) => { } const rules = { username: [ - { validator: validateUsername, trigger: ['blur', 'change'] }, - { min: 2, max: 10, message: '用户名的长度必须在2-10个字符之间', trigger: ['blur', 'change'] }, + {validator: validateUsername, trigger: ['blur', 'change']}, + {min: 2, max: 10, message: '用户名的长度必须在2-10个字符之间', trigger: ['blur', 'change']}, ], email: [ - { required: true, message: '请输入邮件地址', trigger: 'blur' }, + {required: true, message: '请输入邮件地址', trigger: 'blur'}, {type: 'email', message: '请输入合法的电子邮件地址', trigger: ['blur', 'change']} ] } @@ -52,7 +53,7 @@ const loading = reactive({ function saveDetails() { baseFormRef.value.validate(isValid => { - if(isValid) { + if (isValid) { loading.base = true post('/api/user/save-details', baseForm, () => { ElMessage.success('用户信息保存成功') @@ -66,6 +67,7 @@ function saveDetails() { } }) } + get('/api/user/details', data => { baseForm.username = store.user.username baseForm.gender = data.gender @@ -80,18 +82,19 @@ get('/api/user/details', data => { const coldTime = ref(0) const isEmailValid = ref(true) const onValidate = (prop, isValid) => { - if(prop === 'email') + if (prop === 'email') isEmailValid.value = isValid } + function sendEmailCode() { emailFormRef.value.validate(isValid => { - if(isValid) { + if (isValid) { coldTime.value = 60 get(`/api/auth/ask-code?email=${emailForm.email}&type=modify`, () => { ElMessage.success(`验证码已成功发送到邮箱:${emailForm.email},请注意查收`) const handle = setInterval(() => { coldTime.value-- - if(coldTime.value === 0) { + if (coldTime.value === 0) { clearInterval(handle) } }, 1000) @@ -102,9 +105,10 @@ function sendEmailCode() { } }) } + function modifyEmail() { emailFormRef.value.validate(isValid => { - if(isValid) { + if (isValid) { post('/api/user/modify-email', emailForm, () => { ElMessage.success('邮件修改成功') store.user.email = emailForm.email @@ -113,94 +117,124 @@ function modifyEmail() { } }) } + +function beforeAvatarUpload(rawFile) { + if (rawFile.type !== 'image/jpeg' && rawFile.type !== 'image/png') { + ElMessage.error('头像只能是 JPG/PNG 格式的') + return false + } else if(rawFile.size / 1024 > 100) { + ElMessage.error('头像大小不能大于 100KB') + return false + } + return true +} + +function uploadSuccess(response){ + ElMessage.success('头像上传成功') + store.user.avatar = response.data +} - - - - - - - - - - 男 - 女 - - - - - - - - - - - - - - - - 保存用户信息 - - - - - - - - - - - - - - - - {{ coldTime > 0 ? `请稍后 ${coldTime} 秒` : '获取验证码'}} - - - - - - 更新电子邮件 - - - + + + + + + + + + + 男 + 女 + + + + + + + + + + + + + + + + 保存用户信息 + + + + + + + + + + + + + + + + + {{ coldTime > 0 ? `请稍后 ${coldTime} 秒` : '获取验证码' }} + + + + + + 更新电子邮件 + + + + + + + + + + + + 修改头像 + + + 你好, {{ store.user.username }} + + + + {{ desc || '这个用户很懒,没有填写个人简介~' }} + + + + 账号注册时间: {{ registerTime }} + 欢迎加入我们的学习论坛! + + + - - - - - - 你好, {{store.user.username}} - - - - {{ desc || '这个用户很懒,没有填写个人简介~'}} - - - - 账号注册时间: {{registerTime}} - 欢迎加入我们的学习论坛! - - - -