From b7e8b02606a1c727b6e9dda520cea419472783df Mon Sep 17 00:00:00 2001 From: Azure <983547216@qq.com> Date: Mon, 22 Jun 2026 12:31:30 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=B8=AA=E4=BA=BA=E4=BF=A1=E6=81=AF?= =?UTF-8?q?=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/config/StaticResourceConfig.java | 25 +++++ .../common/exception/ErrorCode.java | 3 + .../controller/UserController.java | 26 +++++ .../webgamebackend/service/UserService.java | 22 +++++ .../service/impl/UserServiceImpl.java | 96 +++++++++++++++++++ 5 files changed, 172 insertions(+) create mode 100644 src/main/java/com/webgame/webgamebackend/common/config/StaticResourceConfig.java diff --git a/src/main/java/com/webgame/webgamebackend/common/config/StaticResourceConfig.java b/src/main/java/com/webgame/webgamebackend/common/config/StaticResourceConfig.java new file mode 100644 index 0000000..158a612 --- /dev/null +++ b/src/main/java/com/webgame/webgamebackend/common/config/StaticResourceConfig.java @@ -0,0 +1,25 @@ +package com.webgame.webgamebackend.common.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * 静态资源配置 + * + * 将本地 uploads/ 目录映射为 /uploads/** URL,用于头像等用户上传文件的访问。 + */ +@Configuration +public class StaticResourceConfig implements WebMvcConfigurer { + + /** + * 注册静态资源处理器 + * + * /uploads/avatars/xxx.jpg → file:./uploads/avatars/xxx.jpg + */ + @Override + public void addResourceHandlers(ResourceHandlerRegistry registry) { + registry.addResourceHandler("/uploads/**") + .addResourceLocations("file:./uploads/"); + } +} diff --git a/src/main/java/com/webgame/webgamebackend/common/exception/ErrorCode.java b/src/main/java/com/webgame/webgamebackend/common/exception/ErrorCode.java index 8f899f4..cffbe0e 100644 --- a/src/main/java/com/webgame/webgamebackend/common/exception/ErrorCode.java +++ b/src/main/java/com/webgame/webgamebackend/common/exception/ErrorCode.java @@ -21,6 +21,9 @@ public enum ErrorCode { // ========== 用户模块 ========== USER_NOT_FOUND(2001, "用户不存在"), + AVATAR_UPLOAD_FAILED(2002, "头像上传失败"), + AVATAR_INVALID_TYPE(2003, "头像格式不支持,仅支持 JPG、PNG、WebP、GIF"), + AVATAR_TOO_LARGE(2004, "头像文件大小不能超过 2MB"), // ========== 游戏模块 ========== GAME_NOT_FOUND(3001, "游戏不存在或已下架"), diff --git a/src/main/java/com/webgame/webgamebackend/controller/UserController.java b/src/main/java/com/webgame/webgamebackend/controller/UserController.java index c995bc9..c809ea2 100644 --- a/src/main/java/com/webgame/webgamebackend/controller/UserController.java +++ b/src/main/java/com/webgame/webgamebackend/controller/UserController.java @@ -10,6 +10,7 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; /** * 用户控制器 @@ -54,4 +55,29 @@ public class UserController { userService.updateStatus(accountId, request); return ApiResponse.ok(null); } + + /** + * 上传用户头像 + * + * 接收裁剪后的图片文件(JPEG/PNG/WebP/GIF,≤2MB),保存后返回更新后的用户信息。 + * 旧头像文件会被自动删除。 + */ + @PostMapping("/avatar") + public ApiResponse uploadAvatar(@RequestParam("file") MultipartFile file) { + String accountId = StpUtil.getLoginIdAsString(); + UserInfoResponse result = userService.uploadAvatar(accountId, file); + return ApiResponse.ok(result); + } + + /** + * 删除用户头像 + * + * 删除磁盘文件并将 DB 中 avatar 置空,前端会回退显示默认头像。 + */ + @DeleteMapping("/avatar") + public ApiResponse deleteAvatar() { + String accountId = StpUtil.getLoginIdAsString(); + UserInfoResponse result = userService.deleteAvatar(accountId); + return ApiResponse.ok(result); + } } diff --git a/src/main/java/com/webgame/webgamebackend/service/UserService.java b/src/main/java/com/webgame/webgamebackend/service/UserService.java index 0292973..3e320ad 100644 --- a/src/main/java/com/webgame/webgamebackend/service/UserService.java +++ b/src/main/java/com/webgame/webgamebackend/service/UserService.java @@ -3,6 +3,7 @@ package com.webgame.webgamebackend.service; import com.webgame.webgamebackend.common.dto.user.UpdateStatusRequest; import com.webgame.webgamebackend.common.dto.user.UpdateUserInfoRequest; import com.webgame.webgamebackend.common.dto.user.UserInfoResponse; +import org.springframework.web.multipart.MultipartFile; /** * 用户服务接口 @@ -32,4 +33,25 @@ public interface UserService { * @param request 状态更新请求 */ void updateStatus(String accountId, UpdateStatusRequest request); + + /** + * 上传并更新当前登录用户的头像 + * + * 文件会被保存到 uploads/avatars/{accountId}.jpg,并更新 DB 中的 avatar 字段。 + * + * @param accountId 账号 ID + * @param file 头像文件(前端已裁剪为 200×200 JPEG) + * @return 更新后的用户信息 + */ + UserInfoResponse uploadAvatar(String accountId, MultipartFile file); + + /** + * 删除当前登录用户的头像 + * + * 删除磁盘文件,并将 DB 中 avatar 置为 null(前端会回退显示默认头像)。 + * + * @param accountId 账号 ID + * @return 更新后的用户信息 + */ + UserInfoResponse deleteAvatar(String accountId); } diff --git a/src/main/java/com/webgame/webgamebackend/service/impl/UserServiceImpl.java b/src/main/java/com/webgame/webgamebackend/service/impl/UserServiceImpl.java index d3a3297..96c7583 100644 --- a/src/main/java/com/webgame/webgamebackend/service/impl/UserServiceImpl.java +++ b/src/main/java/com/webgame/webgamebackend/service/impl/UserServiceImpl.java @@ -13,6 +13,13 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Set; /** * 用户服务实现 @@ -22,6 +29,15 @@ import org.springframework.transaction.annotation.Transactional; @RequiredArgsConstructor public class UserServiceImpl implements UserService { + /** 头像存储目录(相对于项目工作目录) */ + private static final String AVATAR_DIR = "uploads/avatars"; + /** 允许的头像文件类型 */ + private static final Set ALLOWED_CONTENT_TYPES = Set.of( + "image/jpeg", "image/png", "image/webp", "image/gif" + ); + /** 头像文件大小上限(2MB) */ + private static final long MAX_AVATAR_SIZE = 2 * 1024 * 1024; + private final UserRepository userRepository; @Override @@ -74,4 +90,84 @@ public class UserServiceImpl implements UserService { userRepository.save(user); log.info("[用户服务] 更新在线状态, accountId={}, status={}", accountId, request.status()); } + + @Override + @Transactional + public UserInfoResponse uploadAvatar(String accountId, MultipartFile file) { + // 校验文件类型 + String contentType = file.getContentType(); + if (contentType == null || !ALLOWED_CONTENT_TYPES.contains(contentType)) { + throw new BusinessException(ErrorCode.AVATAR_INVALID_TYPE); + } + + // 校验文件大小 + if (file.getSize() > MAX_AVATAR_SIZE) { + throw new BusinessException(ErrorCode.AVATAR_TOO_LARGE); + } + + UserEntity user = userRepository.findByAccountId(accountId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + try { + // 确保目录存在 + Path avatarDir = Paths.get(AVATAR_DIR); + Files.createDirectories(avatarDir); + + // 删除旧头像文件(如果存在且不是默认 SVG) + deleteOldAvatarFile(user.getAvatar()); + + // 保存新头像:uploads/avatars/{accountId}.jpg + String filename = accountId + ".jpg"; + Path targetPath = avatarDir.resolve(filename); + file.transferTo(targetPath.toFile()); + + // 更新数据库 + String avatarUrl = "/" + AVATAR_DIR + "/" + filename; + user.setAvatar(avatarUrl); + userRepository.save(user); + + log.info("[用户服务] 头像上传成功, accountId={}, path={}", accountId, avatarUrl); + return UserInfoResponse.from(user); + } catch (IOException e) { + log.error("[用户服务] 头像上传失败, accountId={}", accountId, e); + throw new BusinessException(ErrorCode.AVATAR_UPLOAD_FAILED, "头像保存失败,请重试"); + } + } + + @Override + @Transactional + public UserInfoResponse deleteAvatar(String accountId) { + UserEntity user = userRepository.findByAccountId(accountId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + // 删除旧头像文件 + deleteOldAvatarFile(user.getAvatar()); + + // 数据库置空(前端会回退显示默认头像) + user.setAvatar(null); + userRepository.save(user); + + log.info("[用户服务] 头像已删除, accountId={}", accountId); + return UserInfoResponse.from(user); + } + + /** + * 删除旧的用户头像文件 + * + * 仅删除 uploads/avatars/ 下的文件,跳过默认 SVG 和非本地路径。 + * + * @param avatarPath 头像路径,可能为 null + */ + private void deleteOldAvatarFile(String avatarPath) { + if (avatarPath == null || !avatarPath.startsWith("/" + AVATAR_DIR + "/")) { + return; + } + try { + Path oldFile = Paths.get(avatarPath.substring(1)); // 去掉开头的 / + Files.deleteIfExists(oldFile); + log.debug("[用户服务] 旧头像已删除: {}", oldFile); + } catch (IOException e) { + log.warn("[用户服务] 旧头像删除失败: {}", avatarPath, e); + } + } }