Преглед на файлове

feat(user): 实现用户头像本地上传与获取功能

- 新增头像配置类 `AvatarProperties`,支持自定义存储路径、文件大小和类型限制
- 创建 `UserAvatarController` 提供上传和获取头像接口,路径为 `/user/avatar/upload` 和 `/user/avatar/{userId}`
- 实现 `UserAvatarService` 处理头像保存、文件校验、数据库更新及日志记录
- 增加工具类 `FileUtils` 用于文件扩展名提取、类型检查和目录创建
- 在 `application.yml` 中添加 avatar 相关配置项
- 控制器与服务层集成安全校验、防路径穿越、异常处理及 Swagger 文档注解
- 编写单元测试覆盖上传成功与获取头像的基本场景
mcbaiyun преди 1 месец
родител
ревизия
cbfe4a6451

+ 45 - 0
docs/DevDesign/用户头像本地上传与获取接口设计-项目改动概要.md

@@ -0,0 +1,45 @@
+# 项目头像上传与获取功能开发概要
+
+## 1. 配置文件调整
+- 在 `src/main/resources/application.yml` 增加如下配置:
+
+```yaml
+avatar:
+  root-path: D:/avatar-storage/
+  max-size: 2MB
+  allowed-types: jpg,png,jpeg,webp
+```
+
+## 2. Controller 层
+- 新增 `UserAvatarController`,实现:
+  - `POST /user/avatar/upload`:接收 MultipartFile,校验类型/大小,仅允许当前用户上传,调用 Service 处理,返回 `R<String>`。
+  - `GET /user/avatar/{userId}`:根据 userId 查询头像相对路径,返回图片流,找不到返回默认图片。
+- 接口需加 Swagger 注解,响应格式与项目一致。
+
+## 3. Service 层
+- 新增 `UserAvatarService` 及实现类,负责:
+  - 头像存储路径生成、文件校验、保存本地、数据库 avatar 字段更新。
+  - 获取头像文件流,处理异常和默认头像。
+  - 日志记录(info/error,含 userId、文件名、IP)。
+
+## 4. Model/VO 层
+- `UserInfo` 实体已含 avatar 字段,无需变更。
+- 如有头像相关 VO,userId 字段类型需为 String。
+
+## 5. 工具类/配置类
+- 如有需要,增加头像相关配置类、文件工具类(如路径拼接、类型校验等)。
+
+## 6. 日志与安全
+- 上传/更新/异常操作按规范记录日志。
+- 上传接口需校验当前用户,仅允许本人操作。
+- 获取接口需防止路径穿越。
+
+## 7. 测试
+- 增加单元测试和集成测试,覆盖文件校验、路径生成、接口兼容性等。
+
+## 8. 文档与Swagger
+- 补充接口注解,确保 Swagger 文档完整,泛型返回类型文档化。
+
+---
+
+如需详细开发步骤可进一步细化。

+ 176 - 0
docs/DevDesign/用户头像本地上传与获取接口设计.md

@@ -0,0 +1,176 @@
+# 用户头像本地上传与获取接口设计
+
+> 本文档遵循《03-API设计规范》《06-日志和错误处理规范》《Swagger泛型返回类型字段信息不显示问题解决方案》《前端ID精度丢失问题解决方案》等项目规范。
+
+## 概述
+为提升用户体验,系统需支持用户上传、更新和获取头像图片,头像文件存储于本地磁盘,路径可配置,支持文件大小和类型限制,仅依赖MySQL+Spring Boot+本地存储。
+
+## 背景和目标
+- 背景:当前仅支持通过字符串方式设置头像,缺乏本地文件上传与访问能力。
+- 目标:实现安全、灵活的头像上传与获取接口,支持本地存储、路径可变、文件大小限制,接口风格与现有API统一。
+
+## 需求与约束
+1. 认证:接口需走Token认证(AuthInterceptor),依赖`currentUserId`,未认证返回401。
+2. 存储:头像文件存储于本地磁盘,根路径通过配置文件(如`application.yml`)指定。
+3. 文件限制:仅允许图片类型(jpg/png/jpeg/webp),最大文件大小(如2MB)可配置,超限或非法类型返回400。
+4. 数据库:`UserInfo.avatar`字段存储头像相对路径。
+5. 访问控制:仅允许用户本人上传/更新头像,获取接口可公开。
+6. API兼容:接口风格与现有API一致,所有响应均为`R<T>`格式,状态码、错误码、消息体与规范一致。
+7. 日志审计:所有上传/更新操作需用SLF4J记录info级日志,异常需error级,内容含userId、文件名、IP等。
+8. Swagger文档:所有接口需用`@ApiResponse`+`@Content`+`@Schema(implementation=...)`标注,确保泛型返回类型文档化。
+9. ID精度:所有响应中的userId等ID字段在VO层转为String,避免前端精度丢失。
+
+## 设计要点(高层)
+1. Controller层新增接口:
+  - 上传/更新头像:`POST /user/avatar/upload`,参数为`MultipartFile`,仅允许当前用户操作。
+  - 获取头像:`GET /user/avatar/{userId}`,userId为字符串,返回图片流。
+2. Service层实现头像存储、路径生成、文件校验、数据库更新、异常处理、日志记录等逻辑。
+3. 配置文件增加头像存储根路径、最大文件大小、允许类型等配置项。
+4. 文件存储结构建议:`{avatarRootPath}/{userId}/{userId}_{timestamp}.{ext}`,防止文件名冲突。
+  - 文件命名策略:建议使用 `{userId}_{timestamp}.{ext}`(如 `123_1597891234567.jpg`)或基于日期格式的时间戳(如 `123_20200820T153012.jpg`)生成上传文件名。
+    - 优点:保证唯一性、防止并发覆盖;便于前端缓存失效控制(每次上传返回不同 URL);也可以直接用于文件历史管理与清理。
+    - 存储示例:
+      - 完整路径:`D:/avatar-storage/123/123_1597891234567.jpg`
+      - avatar 字段(相对路径)存储:`user/123/123_1597891234567.jpg`
+  - 推荐策略:保存新头像后异步删除旧头像(注意合规和备份需求),避免磁盘堆积;若需要保留历史版本,则保留旧文件并在数据库中标记版本。
+5. 上传接口需校验文件类型与大小,超限或非法类型返回400,未认证401,IO异常500。
+6. 获取接口根据数据库记录返回本地文件流,若无头像返回默认图片,路径防穿越。
+7. 日志记录上传、更新、异常操作,包括userId、操作时间、文件名、IP等,按日志规范分级。
+8. 所有接口响应均为`R<T>`格式,错误用`R.fail`,成功用`R.success`。
+9. Swagger注解需覆盖所有状态码,泛型返回类型需用`@Schema(implementation=...)`。
+10. 响应VO中userId等ID字段类型为String。
+
+
+## API设计示例
+
+### 上传/更新头像接口
+
+```java
+@Operation(summary = "上传/更新用户头像", description = "用户上传或更新自己的头像图片")
+@ApiResponses(value = {
+  @ApiResponse(responseCode = "200", description = "上传成功",
+    content = @Content(mediaType = "application/json",
+      schema = @Schema(implementation = R.class))),
+  @ApiResponse(responseCode = "400", description = "参数错误/文件类型或大小不合法",
+    content = @Content(mediaType = "application/json",
+      schema = @Schema(implementation = R.class))),
+  @ApiResponse(responseCode = "401", description = "未认证",
+    content = @Content(mediaType = "application/json",
+      schema = @Schema(implementation = R.class))),
+  @ApiResponse(responseCode = "500", description = "服务器内部错误",
+    content = @Content(mediaType = "application/json",
+      schema = @Schema(implementation = R.class)))
+})
+@PostMapping(value = "/user/avatar/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
+public R<String> uploadAvatar(@RequestPart("file") MultipartFile file, HttpServletRequest request) {
+  String userId = String.valueOf(request.getAttribute("currentUserId"));
+  // 校验文件类型与大小...
+  // 生成保存文件名(示例为 userId + 时间戳):
+  // String originalFilename = file.getOriginalFilename();
+  // String ext = StringUtils.getFilenameExtension(originalFilename); // 或使用 FilenameUtils
+  // String savedFileName = userId + "_" + System.currentTimeMillis() + "." + ext;
+  // String relativePath = "user/" + userId + "/" + savedFileName;
+  // 保存文件到 avatarRootPath + File.separator + relativePath;
+  // 更新数据库:UserInfo.avatar = relativePath;
+  // 可选:删除或异步清理旧文件
+  // 日志示例:logger.info("[AvatarUpload] userId={}, savedFileName={}, ip={}", userId, savedFileName, request.getRemoteAddr());
+  // 错误示例:logger.error("[AvatarUpload] userId={}, error=文件类型不支持", userId);
+}
+```
+
+### 获取头像接口
+
+```java
+@Operation(summary = "获取用户头像", description = "根据用户ID获取头像图片")
+@ApiResponses(value = {
+  @ApiResponse(responseCode = "200", description = "获取成功,返回图片流"),
+  @ApiResponse(responseCode = "404", description = "未找到头像,返回默认图片")
+})
+@GetMapping(value = "/user/avatar/{userId}", produces = MediaType.IMAGE_JPEG_VALUE)
+public ResponseEntity<Resource> getAvatar(@PathVariable String userId) {
+  // ...查找头像文件并返回流...
+}
+```
+
+#### 响应示例(上传成功)
+```json
+{
+  "code": 200,
+  "message": "上传成功",
+  "data": "/user/avatar/user/123/123_1597891234567.jpg"
+}
+```
+
+#### 响应示例(参数错误)
+```json
+{
+  "code": 400,
+  "message": "文件类型不支持",
+  "data": null
+}
+```
+
+## 配置示例(application.yml)
+
+```yaml
+avatar:
+  root-path: D:/avatar-storage/
+  max-size: 2MB
+  allowed-types: jpg,png,jpeg,webp
+```
+
+
+## 数据库字段设计与兼容性
+
+### avatar字段用途
+- `docs/DataBase/t_user_info.txt` 中的 avatar 字段用于存储头像图片的“相对路径”或“文件名”,如 `user/123/avatar.jpg`,不直接存储图片内容或外部URL。
+- 这样既兼容本地存储,也便于后续迁移和扩展。
+
+
+### 统一访问与健壮性
+- 获取头像接口优先查找本地文件(根据avatar字段拼接绝对路径),找不到时可回退到默认头像或兼容历史URL。
+- avatar字段建议varchar(255)或更长,确保能存储完整相对路径。
+- 开发和测试时可手动插入或模拟avatar字段的不同内容(如空、相对路径、异常值),验证接口健壮性。
+
+`UserInfo`表已存在`avatar`字段,无需变更。如需兼容历史数据,可统一迁移旧头像路径。
+
+## 权限与安全
+- 上传/更新接口仅允许当前登录用户操作自己的头像。
+- 获取接口可公开,但需防止路径遍历等安全风险。
+- 文件名、路径需规范化,防止覆盖他人文件。
+- 所有ID字段在响应VO中转为String,避免前端精度丢失。
+
+## 日志与审计
+- 上传/更新/异常操作需用SLF4J记录日志,内容包括userId、文件名、操作时间、IP等,按info/error分级。
+- 日志格式建议:`[AvatarUpload] userId=xxx, fileName=xxx, ip=xxx`。
+  - 建议日志记录 `savedFileName`(含时间戳)和 `relativePath`,以便定位文件与审计:
+    - `logger.info("[AvatarUpload] userId={}, savedFileName={}, relativePath={}, ip={}", userId, savedFileName, relativePath, ip);`
+- 可选:如需合规审计,可建立头像操作日志表。
+
+## 测试建议
+1. 单元测试:覆盖文件类型、大小校验、路径生成(包括userId+timestamp的命名逻辑)、数据库更新等逻辑。
+  - 验证命名规则:同一 userId 连续上传两次应生成不同 `savedFileName`;验证时间戳或日期格式正确。
+  - 验证旧文件处理:上传新头像后老头像是否被删除或保留(依据配置),以及数据库 avatar 字段是否正确地指向新文件。
+2. 集成测试:模拟前端上传、获取流程,验证接口兼容性与安全性。
+3. 性能测试:批量上传、并发获取,评估磁盘IO与接口响应。
+
+## 兼容性与前端要求
+- 前端需以multipart/form-data方式上传头像,文件字段名为`file`。
+- 获取接口返回图片流,前端可直接用于img标签src。
+- 上传成功后返回头像访问URL,便于前端展示。
+
+## 迁移/变更管理
+- 先实现后端接口与配置,测试通过后通知前端接入。
+- 头像存储路径、大小、类型等参数可通过配置灵活调整。
+
+## 参考
+- Spring Boot官方文件上传文档
+- 项目现有认证、响应格式、日志规范
+- docs/DevRule/03-API设计规范.md
+- docs/DevRule/06-日志和错误处理规范.md
+- docs/Swagger泛型返回类型字段信息不显示问题解决方案.md
+- docs/前端ID精度丢失问题解决方案.md
+
+## 责任人
+- 设计/实现:后端开发团队
+- 联调:后端+前端

+ 43 - 0
src/main/java/work/baiyun/chronicdiseaseapp/config/AvatarProperties.java

@@ -0,0 +1,43 @@
+package work.baiyun.chronicdiseaseapp.config;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+import org.springframework.util.unit.DataSize;
+
+@Component
+@ConfigurationProperties(prefix = "avatar")
+public class AvatarProperties {
+
+    /** 根路径,绝对路径 */
+    private String rootPath;
+
+    /** 最大文件大小 */
+    private DataSize maxSize = DataSize.ofMegabytes(2);
+
+    /** 允许的类型,逗号分隔,不区分大小写,如 jpg,png,jpeg,webp */
+    private String allowedTypes = "jpg,png,jpeg,webp";
+
+    public String getRootPath() {
+        return rootPath;
+    }
+
+    public void setRootPath(String rootPath) {
+        this.rootPath = rootPath;
+    }
+
+    public DataSize getMaxSize() {
+        return maxSize;
+    }
+
+    public void setMaxSize(DataSize maxSize) {
+        this.maxSize = maxSize;
+    }
+
+    public String getAllowedTypes() {
+        return allowedTypes;
+    }
+
+    public void setAllowedTypes(String allowedTypes) {
+        this.allowedTypes = allowedTypes;
+    }
+}

+ 87 - 0
src/main/java/work/baiyun/chronicdiseaseapp/controller/UserAvatarController.java

@@ -0,0 +1,87 @@
+package work.baiyun.chronicdiseaseapp.controller;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.responses.ApiResponses;
+import jakarta.servlet.http.HttpServletRequest;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.core.io.Resource;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+import work.baiyun.chronicdiseaseapp.common.R;
+import work.baiyun.chronicdiseaseapp.service.UserAvatarService;
+import work.baiyun.chronicdiseaseapp.util.SecurityUtils;
+
+import java.nio.file.Files;
+
+@RestController
+public class UserAvatarController {
+
+    private static final Logger logger = LoggerFactory.getLogger(UserAvatarController.class);
+
+    @Autowired
+    private UserAvatarService userAvatarService;
+
+    @Operation(summary = "上传/更新用户头像", description = "用户上传或更新自己的头像图片")
+    @ApiResponses(value = {
+            @ApiResponse(responseCode = "200", description = "上传成功",
+                    content = @Content(mediaType = "application/json", schema = @Schema(implementation = R.class))),
+            @ApiResponse(responseCode = "400", description = "参数错误/文件类型或大小不合法",
+                    content = @Content(mediaType = "application/json", schema = @Schema(implementation = R.class))),
+            @ApiResponse(responseCode = "401", description = "未认证",
+                    content = @Content(mediaType = "application/json", schema = @Schema(implementation = R.class))),
+            @ApiResponse(responseCode = "500", description = "服务器内部错误",
+                    content = @Content(mediaType = "application/json", schema = @Schema(implementation = R.class)))
+    })
+    @PostMapping(value = "/user/avatar/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
+    public R<String> uploadAvatar(@RequestPart("file") MultipartFile file, HttpServletRequest request) {
+        Long userId;
+        try {
+            userId = SecurityUtils.getCurrentUserId();
+        } catch (Exception e) {
+            return R.fail(401, "未认证", null);
+        }
+        try {
+            String relative = userAvatarService.saveAvatar(file, userId, request);
+            return R.success(200, "上传成功", relative);
+        } catch (work.baiyun.chronicdiseaseapp.exception.CustomException e) {
+            logger.error("upload avatar fail: {}", e.getMessage());
+            return R.fail(e.getCode(), e.getMessage(), null);
+        }
+    }
+
+    @Operation(summary = "获取用户头像", description = "根据用户ID获取头像图片")
+    @ApiResponses(value = {
+            @ApiResponse(responseCode = "200", description = "获取成功,返回图片流"),
+            @ApiResponse(responseCode = "404", description = "未找到头像,返回404")
+    })
+    @GetMapping(value = "/user/avatar/{userId}")
+    public ResponseEntity<Resource> getAvatar(@PathVariable String userId) {
+        Resource resource = userAvatarService.loadAvatarAsResource(userId);
+        if (resource == null) {
+            return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
+        }
+        try {
+            String contentType = null;
+            try {
+                contentType = Files.probeContentType(resource.getFile().toPath());
+            } catch (Exception e) {
+                // fallback
+            }
+            HttpHeaders headers = new HttpHeaders();
+            if (contentType != null) headers.set(HttpHeaders.CONTENT_TYPE, contentType);
+            return new ResponseEntity<>(resource, headers, HttpStatus.OK);
+        } catch (Exception e) {
+            logger.error("load resource fails", e);
+            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
+        }
+    }
+}

+ 18 - 0
src/main/java/work/baiyun/chronicdiseaseapp/service/UserAvatarService.java

@@ -0,0 +1,18 @@
+package work.baiyun.chronicdiseaseapp.service;
+
+import org.springframework.core.io.Resource;
+import org.springframework.web.multipart.MultipartFile;
+
+import jakarta.servlet.http.HttpServletRequest;
+
+public interface UserAvatarService {
+    /**
+     * 保存用户头像并返回相对路径(用于存储到 avatar 字段)
+     */
+    String saveAvatar(MultipartFile file, Long currentUserId, HttpServletRequest request);
+
+    /**
+     * 根据 userId 返回头像文件资源,如果没有返回 null
+     */
+    Resource loadAvatarAsResource(String userId);
+}

+ 116 - 0
src/main/java/work/baiyun/chronicdiseaseapp/service/impl/UserAvatarServiceImpl.java

@@ -0,0 +1,116 @@
+package work.baiyun.chronicdiseaseapp.service.impl;
+
+import jakarta.servlet.http.HttpServletRequest;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.core.io.FileSystemResource;
+import org.springframework.core.io.Resource;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.multipart.MultipartFile;
+import work.baiyun.chronicdiseaseapp.config.AvatarProperties;
+import work.baiyun.chronicdiseaseapp.mapper.UserInfoMapper;
+import work.baiyun.chronicdiseaseapp.model.po.UserInfo;
+import work.baiyun.chronicdiseaseapp.service.UserAvatarService;
+import work.baiyun.chronicdiseaseapp.util.FileUtils;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.time.Instant;
+
+@Service
+public class UserAvatarServiceImpl implements UserAvatarService {
+
+    private static final Logger logger = LoggerFactory.getLogger(UserAvatarServiceImpl.class);
+
+    @Autowired
+    private AvatarProperties avatarProperties;
+
+    @Autowired
+    private UserInfoMapper userInfoMapper;
+
+    @Override
+    @Transactional
+    public String saveAvatar(MultipartFile file, Long currentUserId, HttpServletRequest request) {
+        if (file == null || file.isEmpty()) {
+            throw new work.baiyun.chronicdiseaseapp.exception.CustomException(
+                    work.baiyun.chronicdiseaseapp.enums.ErrorCode.PARAMETER_ERROR.getCode(),
+                    "file is empty");
+        }
+        String original = file.getOriginalFilename();
+        String ext = FileUtils.getExtension(original);
+        if (!FileUtils.isAllowedType(ext, avatarProperties.getAllowedTypes())) {
+            logger.error("[AvatarUpload] userId={}, error=非法文件类型 ext={}", currentUserId, ext);
+            throw new work.baiyun.chronicdiseaseapp.exception.CustomException(
+                    work.baiyun.chronicdiseaseapp.enums.ErrorCode.PARAMETER_ERROR.getCode(),
+                    "文件类型不支持");
+        }
+        if (!FileUtils.isValidSize(file.getSize(), avatarProperties.getMaxSize())) {
+            logger.error("[AvatarUpload] userId={}, error=文件大小超限 size={}", currentUserId, file.getSize());
+            throw new work.baiyun.chronicdiseaseapp.exception.CustomException(
+                    work.baiyun.chronicdiseaseapp.enums.ErrorCode.PARAMETER_ERROR.getCode(),
+                    "文件大小超限");
+        }
+        long timestamp = Instant.now().toEpochMilli();
+        String savedFileName = timestamp + "." + ext;
+        String relative = currentUserId + "/" + savedFileName;
+
+        Path root = Paths.get(avatarProperties.getRootPath() == null ? "avatar-storage" : avatarProperties.getRootPath());
+        Path savePath = root.resolve(relative).normalize();
+        try {
+            // 防止路径穿越
+            Path rootReal = root.toAbsolutePath().normalize();
+            if (!savePath.toAbsolutePath().startsWith(rootReal)) {
+                throw new IOException("invalid target path");
+            }
+            FileUtils.ensureDirectory(savePath.getParent());
+            Files.copy(file.getInputStream(), savePath);
+            // 更新数据库
+            UserInfo userInfo = userInfoMapper.selectById(currentUserId);
+            if (userInfo == null) {
+                logger.error("[AvatarUpload] userId={}, error=用户不存在", currentUserId);
+                throw new work.baiyun.chronicdiseaseapp.exception.CustomException(
+                        work.baiyun.chronicdiseaseapp.enums.ErrorCode.USER_NOT_EXIST.getCode(),
+                        work.baiyun.chronicdiseaseapp.enums.ErrorCode.USER_NOT_EXIST.getMessage());
+            }
+            userInfo.setAvatar(relative);
+            userInfoMapper.updateById(userInfo);
+            logger.info("[AvatarUpload] userId={}, savedFileName={}, relativePath={}, ip={}", currentUserId, savedFileName, relative, request.getRemoteAddr());
+            return relative;
+        } catch (IOException e) {
+            logger.error("[AvatarUpload] userId={}, error=IO异常", currentUserId, e);
+                throw new work.baiyun.chronicdiseaseapp.exception.CustomException(
+                    work.baiyun.chronicdiseaseapp.enums.ErrorCode.SYSTEM_ERROR.getCode(),
+                    work.baiyun.chronicdiseaseapp.enums.ErrorCode.SYSTEM_ERROR.getMessage());
+        }
+    }
+
+    @Override
+    public Resource loadAvatarAsResource(String userId) {
+        try {
+            Long uid = Long.parseLong(userId);
+            UserInfo ui = userInfoMapper.selectById(uid);
+            if (ui == null || ui.getAvatar() == null || ui.getAvatar().isEmpty()) {
+                return null;
+            }
+            Path root = Paths.get(avatarProperties.getRootPath() == null ? "avatar-storage" : avatarProperties.getRootPath());
+            Path file = root.resolve(ui.getAvatar()).normalize();
+            Path rootReal = root.toAbsolutePath().normalize();
+            if (!file.toAbsolutePath().startsWith(rootReal)) {
+                logger.error("[AvatarLoad] userId={}, error=路径穿越", userId);
+                return null;
+            }
+            if (!Files.exists(file)) {
+                logger.info("[AvatarLoad] userId={}, file not exists {}", userId, file);
+                return null;
+            }
+            return new FileSystemResource(file.toFile());
+        } catch (NumberFormatException e) {
+            logger.error("[AvatarLoad] invalid userId {}", userId);
+            return null;
+        }
+    }
+}

+ 36 - 0
src/main/java/work/baiyun/chronicdiseaseapp/util/FileUtils.java

@@ -0,0 +1,36 @@
+package work.baiyun.chronicdiseaseapp.util;
+
+import org.springframework.util.unit.DataSize;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Arrays;
+import java.util.Locale;
+
+public class FileUtils {
+
+    public static String getExtension(String filename) {
+        if (filename == null) return "";
+        int dot = filename.lastIndexOf('.');
+        if(dot < 0) return "";
+        return filename.substring(dot + 1).toLowerCase(Locale.ROOT);
+    }
+
+    public static boolean isAllowedType(String ext, String allowList) {
+        if (ext == null || ext.isEmpty()) return false;
+        String[] allowed = allowList.split(",");
+        return Arrays.stream(allowed).map(String::trim).anyMatch(a -> a.equalsIgnoreCase(ext));
+    }
+
+    public static void ensureDirectory(Path dir) throws IOException {
+        if (!Files.exists(dir)) {
+            Files.createDirectories(dir);
+        }
+    }
+
+    public static boolean isValidSize(long fileSize, DataSize max) {
+        if (max == null) return true;
+        return fileSize <= max.toBytes();
+    }
+}

+ 6 - 1
src/main/resources/application.yml

@@ -54,4 +54,9 @@ knife4j:
 # (预览号)
 wechat:
   appid:  wx334b14b3d0bb1547
-  secret:  0b42a3e4becb7817d08e44e91e2824dd
+  secret:  0b42a3e4becb7817d08e44e91e2824dd
+
+avatar:
+  root-path: D:/avatar-storage/
+  max-size: 2MB
+  allowed-types: jpg,png,jpeg,webp

+ 50 - 0
src/test/java/work/baiyun/chronicdiseaseapp/controller/UserAvatarControllerTest.java

@@ -0,0 +1,50 @@
+package work.baiyun.chronicdiseaseapp.controller;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.core.io.ByteArrayResource;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockMultipartFile;
+import org.springframework.web.context.request.RequestContextHolder;
+import org.springframework.web.context.request.ServletRequestAttributes;
+import work.baiyun.chronicdiseaseapp.common.R;
+import work.baiyun.chronicdiseaseapp.service.UserAvatarService;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import org.springframework.core.io.Resource;
+import org.springframework.http.ResponseEntity;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+public class UserAvatarControllerTest {
+
+    @Test
+    public void testUploadAvatar_shouldReturnSuccess() {
+        UserAvatarController controller = new UserAvatarController();
+        UserAvatarService service = mock(UserAvatarService.class);
+        when(service.saveAvatar(any(), any(), any())).thenReturn("200/100000.jpg");
+        org.springframework.test.util.ReflectionTestUtils.setField(controller, "userAvatarService", service);
+
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.setAttribute("currentUserId", 200L);
+        RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request));
+
+        MockMultipartFile file = new MockMultipartFile("file", "avatar.jpg", "image/jpeg", "abc".getBytes());
+        R<String> resp = controller.uploadAvatar(file, request);
+        assertNotNull(resp);
+        assertEquals(200, resp.getCode());
+    }
+
+    @Test
+    public void testGetAvatar_whenFound_shouldReturn200() {
+        UserAvatarController controller = new UserAvatarController();
+        UserAvatarService service = mock(UserAvatarService.class);
+        when(service.loadAvatarAsResource("200")).thenReturn(new ByteArrayResource(new byte[]{1,2,3}));
+        org.springframework.test.util.ReflectionTestUtils.setField(controller, "userAvatarService", service);
+
+        ResponseEntity<Resource> resp = controller.getAvatar("200");
+        assertNotNull(resp);
+        assertEquals(200, resp.getStatusCodeValue());
+    }
+}