Quellcode durchsuchen

feat(news): 实现资讯管理功能模块

- 新增资讯信息表 t_news_info 及其对应的实体类 NewsInfo
- 创建资讯管理控制器 NewsController,提供 CRUD 和图片上传接口
- 实现资讯图片上传服务 NewsImageService,支持文件类型和大小校验
- 添加资讯图片访问控制器 NewsImageController,用于图片资源加载
- 新增资讯相关 DTO 类 CreateNewsRequest、UpdateNewsRequest 和 NewsVO
- 配置资讯图片存储路径及限制参数 NewsImageProperties
- 实现资讯服务 NewsServiceImpl,包括资讯创建、更新、删除、查询逻辑
- 支持资讯列表分页查询和根据关键字搜索
- 实现资讯内容中第一张图片作为封面图的提取逻辑
- 添加操作日志记录和异常处理机制
- 完善 Swagger 文档标注,确保接口文档清晰完整
- 实现医生权限控制,确保只有医生可发布和管理资讯
- 增加前端 ID 精度处理,避免 Long 类型在前端显示错误
- 提供 Markdown 内容解析与展示支持
mcbaiyun vor 4 Wochen
Ursprung
Commit
32222bc763

+ 17 - 0
docs/DB/t_news_info.txt

@@ -0,0 +1,17 @@
+CREATE TABLE `t_news_info` (
+  `id` bigint(20) NOT NULL COMMENT '主键ID',
+  `version` int(11) DEFAULT '0' COMMENT '乐观锁版本',
+  `create_user` bigint(20) DEFAULT NULL COMMENT '创建者',
+  `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `update_user` bigint(20) DEFAULT NULL COMMENT '更新者',
+  `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  `remark` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注',
+  `title` varchar(30) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '资讯标题',
+  `content` text COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'Markdown内容',
+  `summary` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '资讯概要',
+  `author_id` bigint(20) NOT NULL COMMENT '医生ID',
+  `publish_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '发布时间',
+  PRIMARY KEY (`id`),
+  KEY `idx_author_id` (`author_id`),
+  KEY `idx_title` (`title`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='资讯信息表';

+ 238 - 0
docs/Dev/modules/Processing/资讯管理功能设计文档.md

@@ -0,0 +1,238 @@
+# 资讯管理功能设计文档
+
+> 本文档遵循《03-API设计规范》《06-日志和错误处理规范》《07-数据库规范》《08-安全规范》《Swagger泛型返回类型字段信息不显示问题解决方案》《前端ID精度丢失问题解决方案》等项目规范。
+
+## 概述
+为提升用户健康知识水平,系统需支持医生发布和管理资讯,患者和家属浏览资讯。资讯采用Markdown格式,支持图片展示,图片存储于本地磁盘,路径可配置,支持文件大小和类型限制,仅依赖MySQL+Spring Boot+本地存储。
+
+## 背景和目标
+- 背景:当前APP缺乏健康资讯功能,用户无法获取权威的慢病相关知识。
+- 目标:实现资讯的发布、管理和浏览功能,支持Markdown编辑、图片上传,接口风格与现有API统一。
+
+## 需求与约束
+1. 认证:接口需走Token认证(AuthInterceptor),依赖`currentUserId`,未认证返回401。
+2. 存储:资讯内容存储于MySQL,图片文件存储于本地磁盘,根路径通过配置文件指定。
+3. 文件限制:资讯图片仅允许图片类型(jpg/png/jpeg/webp),最大文件大小(如5MB)可配置,超限或非法类型返回400。
+4. 数据库:新增资讯相关表,存储标题、内容、概要、医生ID等。
+5. 访问控制:医生可发布和管理资讯,患者和家属可浏览。
+6. API兼容:接口风格与现有API一致,所有响应均为`R<T>`格式,状态码、错误码、消息体与规范一致。
+7. 日志审计:所有操作需用SLF4J记录info级日志,异常需error级,内容含userId、操作等。
+8. Swagger文档:所有接口需用`@ApiResponse`+`@Content`+`@Schema`标注,确保泛型返回类型文档化。
+9. ID精度:所有响应中的ID字段在VO层转为String,避免前端精度丢失。
+
+## 设计要点(高层)
+1. Controller层新增接口:
+   - 创建资讯:`POST /news`,参数包含标题、内容、概要等。
+   - 更新资讯:`PUT /news/{id}`。
+   - 删除资讯:`DELETE /news/{id}`。
+   - 获取资讯列表:`GET /news/list`,支持分页、搜索。
+   - 获取资讯详情:`GET /news/{id}`。
+   - 上传资讯图片:`POST /news/upload-image`,返回图片相对路径。
+2. Service层实现资讯CRUD、图片上传、Markdown解析、数据库操作、异常处理、日志记录等逻辑。新建NewsImageService(类似UserAvatarService)处理图片上传,使用NewsImageProperties配置。资讯查询时需联动UserInfoMapper查询医生用户信息,填充authorName(昵称)。
+3. 图片存储参考头像实现,使用 `NewsImageProperties` 配置,路径为 `news-images/{医生ID}/{timestamp}.{ext}`。
+
+## 接口清单
+
+| 接口路径 | 方法 | 描述 | 权限要求 | 请求体/参数 | 响应 |
+|----------|------|------|----------|-------------|------|
+| `/news` | POST | 创建资讯 | 医生 | CreateNewsRequest (title, content, summary) | R<String> (资讯ID) |
+| `/news/{id}` | PUT | 更新资讯 | 医生 | UpdateNewsRequest (title, content, summary) | R<String> (资讯ID) |
+| `/news/{id}` | DELETE | 删除资讯 | 医生 | 路径参数: id | R<Void> |
+| `/news/{id}` | GET | 获取资讯详情 | 患者/家属 | 路径参数: id | R<NewsVO> |
+| `/news/list` | GET | 获取资讯列表(分页) | 患者/家属 | 查询参数: page, size | R<Page<NewsVO>> |
+| `/news/upload-image` | POST | 上传资讯图片 | 医生 | MultipartFile (file) | R<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)))
+})
+@PostMapping("/news")
+public R<String> createNews(@RequestBody CreateNewsRequest request) {
+  // 校验参数、保存资讯、返回ID(String格式)
+}
+```
+
+### 上传资讯图片接口
+
+```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)))
+})
+@PostMapping(value = "/news/upload-image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
+public R<String> uploadImage(@RequestPart("file") MultipartFile file) {
+  // 校验文件、保存图片、返回相对路径
+}
+```
+
+### 获取资讯列表接口
+
+```java
+@Operation(summary = "获取资讯列表", description = "分页获取资讯列表")
+@ApiResponses(value = {
+  @ApiResponse(responseCode = "200", description = "获取成功",
+    content = @Content(mediaType = "application/json",
+      schema = @Schema(implementation = R.class)))
+})
+@GetMapping("/news/list")
+public R<Page<NewsVO>> getNewsList(@RequestParam(defaultValue = "1") int page,
+                                   @RequestParam(defaultValue = "10") int size) {
+  // 返回分页列表,ID转为String
+}
+```
+
+## 配置示例(application.yml)
+
+```yaml
+news-image:
+  root-path: D:/news-images/
+  max-size: 5MB
+  allowed-types: jpg,png,jpeg,webp
+```
+
+## 数据库字段设计与兼容性
+
+### 资讯表 (t_news_info)
+- id: BIGINT PRIMARY KEY AUTO_INCREMENT(雪花算法生成,响应时转为String)
+- title: VARCHAR(30) NOT NULL, 资讯标题
+- content: TEXT NOT NULL, Markdown内容
+- summary: VARCHAR(50), 资讯概要
+- author_id: BIGINT NOT NULL, 医生ID
+- publish_time: DATETIME DEFAULT CURRENT_TIMESTAMP, 发布时间
+- create_time: DATETIME DEFAULT CURRENT_TIMESTAMP(BaseEntity字段)
+- update_time: DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP(BaseEntity字段)
+- create_user: BIGINT(BaseEntity字段,创建者ID,转为String)
+- update_user: BIGINT(BaseEntity字段,更新者ID,转为String)
+- version: INT DEFAULT 0(BaseEntity字段,乐观锁版本号)
+- remark: VARCHAR(255)(BaseEntity字段,可选备注)
+
+注:实体类继承BaseEntity,自动包含上述公共字段。id、create_user、update_user在JSON响应中通过ToStringSerializer转为String。
+
+## 数据传输对象设计
+
+### 请求VO
+
+#### CreateNewsRequest(创建资讯请求)
+```java
+@Schema(description = "创建资讯请求")
+public class CreateNewsRequest {
+    @Schema(description = "资讯标题", required = true, maxLength = 30)
+    @NotBlank(message = "标题不能为空")
+    @Size(max = 30, message = "标题长度不能超过30字符")
+    private String title;
+
+    @Schema(description = "资讯内容(Markdown格式)", required = true)
+    @NotBlank(message = "内容不能为空")
+    private String content;
+
+    @Schema(description = "资讯概要", maxLength = 50)
+    @Size(max = 50, message = "概要长度不能超过50字符")
+    private String summary;
+}
+```
+
+#### UpdateNewsRequest(更新资讯请求)
+```java
+@Schema(description = "更新资讯请求")
+public class UpdateNewsRequest {
+    @Schema(description = "资讯标题", required = true, maxLength = 30)
+    @NotBlank(message = "标题不能为空")
+    @Size(max = 30, message = "标题长度不能超过30字符")
+    private String title;
+
+    @Schema(description = "资讯内容(Markdown格式)", required = true)
+    @NotBlank(message = "内容不能为空")
+    private String content;
+
+    @Schema(description = "资讯概要", maxLength = 50)
+    @Size(max = 50, message = "概要长度不能超过50字符")
+    private String summary;
+}
+```
+
+### 响应VO
+
+#### NewsVO(资讯信息响应)
+```java
+@Schema(description = "资讯信息")
+public class NewsVO {
+    @Schema(description = "资讯ID", example = "1234567890123456789")
+    private String id;
+
+    @Schema(description = "资讯标题")
+    private String title;
+
+    @Schema(description = "资讯概要")
+    private String summary;
+
+    @Schema(description = "资讯内容(Markdown格式)")
+    private String content;
+
+    @Schema(description = "作者ID", example = "1234567890123456789")
+    private String authorId;
+
+    @Schema(description = "作者姓名(从UserInfo.nickname获取)")
+    private String authorName;
+
+    @Schema(description = "发布时间", example = "2023-12-01 10:00:00")
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private LocalDateTime publishTime;
+
+    @Schema(description = "封面图片URL(从内容中提取的第一张图片)")
+    private String coverImage;
+}
+```
+
+注:所有ID字段在VO中定义为String类型,避免前端精度丢失。时间字段使用@JsonFormat格式化。authorName通过注入UserInfoMapper查询UserInfo.nickname填充。
+
+### 联动查询设计
+资讯模块涉及跨表查询用户信息,为填充NewsVO中的authorName字段:
+- **注入依赖**:NewsServiceImpl注入UserInfoMapper。
+- **查询逻辑**:查询资讯列表或详情时,根据news.author_id调用userInfoMapper.selectById(authorId),获取UserInfo对象。
+- **字段映射**:将userInfo.getNickname()赋值给newsVO.setAuthorName()。
+- **异常处理**:若用户信息不存在,authorName设为空字符串或默认值,避免查询失败。
+- **性能优化**:对于列表查询,可批量查询用户信息,或使用缓存减少数据库访问。
+
+## 权限与安全
+- 创建/更新/删除接口仅医生角色可操作。
+- 浏览接口公开,但需认证。
+- 图片上传防路径穿越,文件名唯一。
+
+## 日志与审计
+- 操作日志:`[NewsOperation] userId={}, action={}, newsId={}`
+- 异常日志:`[NewsError] userId={}, error={}`
+- 使用SLF4J,日志级别INFO/ERROR。
+- ID在日志中以字符串记录。
+
+## 测试建议
+1. 单元测试:CRUD逻辑、图片上传校验。
+2. 集成测试:接口联调、Markdown解析。
+3. 性能测试:列表分页、图片访问。
+
+## 兼容性与前端要求
+- 前端需支持Markdown编辑器、图片上传。
+- 列表显示标题、封面、概要。
+- 详情页解析Markdown展示。
+- 响应中ID字段为String。
+
+## 迁移/变更管理
+- 先实现后端接口,测试通过后前端接入。
+- 图片存储路径可配置。

+ 3 - 0
docs/Dev/modules/复诊管理功能设计文档.md

@@ -1,5 +1,8 @@
+<!-- 注意:本功能模块已经被移除! -->
+
 # 复诊管理功能设计文档
 
+
 ## 1. 功能概述
 
 复诊管理功能旨在为患者和医生提供一个便捷的复诊预约和管理平台。该功能允许患者发起复诊请求,医生审核并安排复诊时间,双方可以查看和管理复诊记录。

+ 0 - 0
docs/Dev/modules/Processing/PatientReminderTaskDesign.md → docs/Dev/modules/病人提醒模块设计文档.md


+ 43 - 0
src/main/java/work/baiyun/chronicdiseaseapp/config/NewsImageProperties.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 = "news-image")
+public class NewsImageProperties {
+
+    /** 根路径,绝对路径 */
+    private String rootPath;
+
+    /** 最大文件大小 */
+    private DataSize maxSize = DataSize.ofMegabytes(5);
+
+    /** 允许的类型,逗号分隔 */
+    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;
+    }
+}

+ 155 - 0
src/main/java/work/baiyun/chronicdiseaseapp/controller/NewsController.java

@@ -0,0 +1,155 @@
+package work.baiyun.chronicdiseaseapp.controller;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+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 io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.servlet.http.HttpServletRequest;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.MediaType;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+import work.baiyun.chronicdiseaseapp.common.R;
+import work.baiyun.chronicdiseaseapp.enums.ErrorCode;
+import work.baiyun.chronicdiseaseapp.enums.PermissionGroup;
+import work.baiyun.chronicdiseaseapp.exception.CustomException;
+import work.baiyun.chronicdiseaseapp.model.vo.CreateNewsRequest;
+import work.baiyun.chronicdiseaseapp.model.vo.NewsVO;
+import work.baiyun.chronicdiseaseapp.model.vo.UpdateNewsRequest;
+import work.baiyun.chronicdiseaseapp.service.NewsImageService;
+import work.baiyun.chronicdiseaseapp.service.NewsService;
+import work.baiyun.chronicdiseaseapp.util.SecurityUtils;
+
+import java.util.HashMap;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/news")
+@Tag(name = "资讯管理", description = "资讯管理相关接口")
+public class NewsController {
+
+    private static final Logger logger = LoggerFactory.getLogger(NewsController.class);
+
+    @Autowired
+    private NewsService newsService;
+
+    @Autowired
+    private NewsImageService newsImageService;
+
+    @Operation(summary = "创建资讯", description = "医生创建新资讯")
+    @ApiResponses(value = {
+        @ApiResponse(responseCode = "200", 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)))
+    })
+    @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
+    public R<?> createNews(@RequestBody CreateNewsRequest request) {
+        try {
+            // 仅医生可发布
+            if (!PermissionGroup.DOCTOR.equals(SecurityUtils.getCurrentUserRole())) {
+                return R.fail(ErrorCode.FORBIDDEN.getCode(), ErrorCode.FORBIDDEN.getMessage());
+            }
+            String id = newsService.createNews(request);
+            Map<String, String> data = new HashMap<>();
+            data.put("id", id);
+            return R.success(200, "创建成功", data);
+        } catch (CustomException e) {
+            logger.error("create news failed", e);
+            return R.fail(e.getCode(), e.getMessage());
+        } catch (Exception e) {
+            logger.error("create news failed", e);
+            return R.fail(ErrorCode.SYSTEM_ERROR.getCode(), ErrorCode.SYSTEM_ERROR.getMessage());
+        }
+    }
+
+    @Operation(summary = "更新资讯", description = "医生更新资讯")
+    @PutMapping(path = "/{id}", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
+    public R<?> updateNews(@PathVariable Long id, @RequestBody UpdateNewsRequest req) {
+        try {
+            if (!PermissionGroup.DOCTOR.equals(SecurityUtils.getCurrentUserRole())) {
+                return R.fail(ErrorCode.FORBIDDEN.getCode(), ErrorCode.FORBIDDEN.getMessage());
+            }
+            req.setId(id);
+            String nid = newsService.updateNews(req);
+            Map<String, String> data = new HashMap<>();
+            data.put("id", nid);
+            return R.success(200, "更新成功", data);
+        } catch (CustomException e) {
+            logger.error("update news failed", e);
+            return R.fail(e.getCode(), e.getMessage());
+        } catch (Exception e) {
+            logger.error("update news failed", e);
+            return R.fail(ErrorCode.SYSTEM_ERROR.getCode(), ErrorCode.SYSTEM_ERROR.getMessage());
+        }
+    }
+
+    @Operation(summary = "删除资讯", description = "医生删除资讯")
+    @DeleteMapping(path = "/{id}", produces = MediaType.APPLICATION_JSON_VALUE)
+    public R<?> deleteNews(@PathVariable Long id) {
+        try {
+            if (!PermissionGroup.DOCTOR.equals(SecurityUtils.getCurrentUserRole())) {
+                return R.fail(ErrorCode.FORBIDDEN.getCode(), ErrorCode.FORBIDDEN.getMessage());
+            }
+            newsService.deleteNews(id);
+            return R.success(200, "删除成功");
+        } catch (CustomException e) {
+            logger.error("delete news failed", e);
+            return R.fail(e.getCode(), e.getMessage());
+        } catch (Exception e) {
+            logger.error("delete news failed", e);
+            return R.fail(ErrorCode.SYSTEM_ERROR.getCode(), ErrorCode.SYSTEM_ERROR.getMessage());
+        }
+    }
+
+    @Operation(summary = "获取资讯详情", description = "获取资讯详情")
+    @GetMapping(path = "/{id}", produces = MediaType.APPLICATION_JSON_VALUE)
+    public R<?> getNews(@PathVariable Long id) {
+        try {
+            NewsVO vo = newsService.getNewsById(id);
+            return R.success(200, "ok", vo);
+        } catch (CustomException e) {
+            logger.error("get news failed", e);
+            return R.fail(e.getCode(), e.getMessage());
+        } catch (Exception e) {
+            logger.error("get news failed", e);
+            return R.fail(ErrorCode.SYSTEM_ERROR.getCode(), ErrorCode.SYSTEM_ERROR.getMessage());
+        }
+    }
+
+    @Operation(summary = "获取资讯列表", description = "分页获取资讯列表")
+    @GetMapping(path = "/list", produces = MediaType.APPLICATION_JSON_VALUE)
+    public R<?> listNews(@RequestParam(defaultValue = "1") int page,
+                         @RequestParam(defaultValue = "10") int size,
+                         @RequestParam(required = false) String keyword) {
+        try {
+            Page<NewsVO> p = newsService.listNews(page, size, keyword);
+            // 使用 Page 的标准返回结构
+            return R.success(200, "ok", p);
+        } catch (Exception e) {
+            logger.error("list news failed", e);
+            return R.fail(ErrorCode.SYSTEM_ERROR.getCode(), ErrorCode.SYSTEM_ERROR.getMessage());
+        }
+    }
+
+    @Operation(summary = "上传资讯图片", description = "上传资讯中使用的图片")
+    @PostMapping(value = "/upload-image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
+    public R<?> uploadImage(@RequestPart("file") MultipartFile file, HttpServletRequest request) {
+        try {
+            Long uid = SecurityUtils.getCurrentUserId();
+            String relative = newsImageService.saveNewsImage(file, uid, request);
+            return R.success(200, "上传成功", relative);
+        } catch (CustomException e) {
+            logger.error("upload news image failed", e);
+            return R.fail(e.getCode(), e.getMessage());
+        } catch (Exception e) {
+            logger.error("upload news image failed", e);
+            return R.fail(ErrorCode.SYSTEM_ERROR.getCode(), ErrorCode.SYSTEM_ERROR.getMessage());
+        }
+    }
+}

+ 51 - 0
src/main/java/work/baiyun/chronicdiseaseapp/controller/NewsImageController.java

@@ -0,0 +1,51 @@
+package work.baiyun.chronicdiseaseapp.controller;
+
+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.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import jakarta.servlet.http.HttpServletRequest;
+import org.springframework.web.bind.annotation.RestController;
+import work.baiyun.chronicdiseaseapp.service.NewsImageService;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+@RestController
+public class NewsImageController {
+
+    private static final Logger logger = LoggerFactory.getLogger(NewsImageController.class);
+
+    @Autowired
+    private NewsImageService newsImageService;
+
+    @GetMapping(path = "/news/image/**")
+    public ResponseEntity<Resource> getNewsImage(HttpServletRequest request) {
+        String uri = request.getRequestURI();
+        // uri like /news/image/{relativePath}
+        String base = "/news/image/";
+        String path = uri.startsWith(base) ? uri.substring(base.length()) : uri;
+        Resource resource = newsImageService.loadNewsImageAsResource(path);
+        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();
+        }
+    }
+}

+ 10 - 0
src/main/java/work/baiyun/chronicdiseaseapp/mapper/NewsInfoMapper.java

@@ -0,0 +1,10 @@
+package work.baiyun.chronicdiseaseapp.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Mapper;
+import work.baiyun.chronicdiseaseapp.model.po.NewsInfo;
+
+@Mapper
+public interface NewsInfoMapper extends BaseMapper<NewsInfo> {
+
+}

+ 40 - 0
src/main/java/work/baiyun/chronicdiseaseapp/model/po/NewsInfo.java

@@ -0,0 +1,40 @@
+package work.baiyun.chronicdiseaseapp.model.po;
+
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.time.LocalDateTime;
+
+@Schema(description = "资讯信息表")
+@TableName("t_news_info")
+@Data
+@EqualsAndHashCode(callSuper = false)
+public class NewsInfo extends BaseEntity {
+
+    @Schema(description = "资讯标题")
+    @TableField("title")
+    private String title;
+
+    @Schema(description = "资讯内容(Markdown)")
+    @TableField("content")
+    private String content;
+
+    @Schema(description = "资讯概要")
+    @TableField("summary")
+    private String summary;
+
+    @JsonSerialize(using = ToStringSerializer.class)
+    @Schema(description = "作者ID")
+    @TableField("author_id")
+    private Long authorId;
+
+    @Schema(description = "发布时间")
+    @TableField("publish_time")
+    private LocalDateTime publishTime;
+
+}

+ 25 - 0
src/main/java/work/baiyun/chronicdiseaseapp/model/vo/CreateNewsRequest.java

@@ -0,0 +1,25 @@
+package work.baiyun.chronicdiseaseapp.model.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+
+@Schema(description = "创建资讯请求")
+@Data
+public class CreateNewsRequest {
+
+    @Schema(description = "资讯标题", required = true, maxLength = 30)
+    @NotBlank(message = "标题不能为空")
+    @Size(max = 30, message = "标题长度不能超过30字符")
+    private String title;
+
+    @Schema(description = "资讯内容(Markdown格式)", required = true)
+    @NotBlank(message = "内容不能为空")
+    private String content;
+
+    @Schema(description = "资讯概要", maxLength = 50)
+    @Size(max = 50, message = "概要长度不能超过50字符")
+    private String summary;
+}

+ 37 - 0
src/main/java/work/baiyun/chronicdiseaseapp/model/vo/NewsVO.java

@@ -0,0 +1,37 @@
+package work.baiyun.chronicdiseaseapp.model.vo;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+@Schema(description = "资讯信息响应")
+@Data
+public class NewsVO {
+
+    @Schema(description = "资讯ID", example = "1234567890123456789")
+    private String id;
+
+    @Schema(description = "资讯标题")
+    private String title;
+
+    @Schema(description = "资讯概要")
+    private String summary;
+
+    @Schema(description = "资讯内容(Markdown格式)")
+    private String content;
+
+    @Schema(description = "作者ID", example = "1234567890123456789")
+    private String authorId;
+
+    @Schema(description = "作者姓名(从UserInfo.nickname获取)")
+    private String authorName;
+
+    @Schema(description = "发布时间", example = "2023-12-01 10:00:00")
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private LocalDateTime publishTime;
+
+    @Schema(description = "封面图片URL(从内容中提取的第一张图片)")
+    private String coverImage;
+}

+ 28 - 0
src/main/java/work/baiyun/chronicdiseaseapp/model/vo/UpdateNewsRequest.java

@@ -0,0 +1,28 @@
+package work.baiyun.chronicdiseaseapp.model.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+
+@Schema(description = "更新资讯请求")
+@Data
+public class UpdateNewsRequest {
+
+    @Schema(description = "资讯ID", required = true)
+    private Long id;
+
+    @Schema(description = "资讯标题", required = true, maxLength = 30)
+    @NotBlank(message = "标题不能为空")
+    @Size(max = 30, message = "标题长度不能超过30字符")
+    private String title;
+
+    @Schema(description = "资讯内容(Markdown格式)", required = true)
+    @NotBlank(message = "内容不能为空")
+    private String content;
+
+    @Schema(description = "资讯概要", maxLength = 50)
+    @Size(max = 50, message = "概要长度不能超过50字符")
+    private String summary;
+}

+ 12 - 0
src/main/java/work/baiyun/chronicdiseaseapp/service/NewsImageService.java

@@ -0,0 +1,12 @@
+package work.baiyun.chronicdiseaseapp.service;
+
+import org.springframework.core.io.Resource;
+import org.springframework.web.multipart.MultipartFile;
+
+import jakarta.servlet.http.HttpServletRequest;
+
+public interface NewsImageService {
+    String saveNewsImage(MultipartFile file, Long currentUserId, HttpServletRequest request);
+
+    Resource loadNewsImageAsResource(String relativePath);
+}

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

@@ -0,0 +1,18 @@
+package work.baiyun.chronicdiseaseapp.service;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import work.baiyun.chronicdiseaseapp.model.vo.CreateNewsRequest;
+import work.baiyun.chronicdiseaseapp.model.vo.NewsVO;
+import work.baiyun.chronicdiseaseapp.model.vo.UpdateNewsRequest;
+
+public interface NewsService {
+    String createNews(CreateNewsRequest request);
+
+    String updateNews(UpdateNewsRequest request);
+
+    void deleteNews(Long id);
+
+    NewsVO getNewsById(Long id);
+
+    Page<NewsVO> listNews(int page, int size, String keyword);
+}

+ 97 - 0
src/main/java/work/baiyun/chronicdiseaseapp/service/impl/NewsImageServiceImpl.java

@@ -0,0 +1,97 @@
+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.NewsImageProperties;
+import work.baiyun.chronicdiseaseapp.service.NewsImageService;
+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 NewsImageServiceImpl implements NewsImageService {
+
+    private static final Logger logger = LoggerFactory.getLogger(NewsImageServiceImpl.class);
+
+    @Autowired
+    private NewsImageProperties newsImageProperties;
+
+    @Override
+    @Transactional
+    public String saveNewsImage(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, newsImageProperties.getAllowedTypes())) {
+            logger.error("[NewsImageUpload] 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(), newsImageProperties.getMaxSize())) {
+            logger.error("[NewsImageUpload] 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 = "news-images/" + (currentUserId == null ? "anonymous" : currentUserId) + "/" + savedFileName;
+
+        Path root = Paths.get(newsImageProperties.getRootPath() == null ? "news-image-storage" : newsImageProperties.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);
+            logger.info("[NewsImageUpload] userId={}, savedFileName={}, relativePath={}, ip={}", currentUserId, savedFileName, relative, request.getRemoteAddr());
+            return relative;
+        } catch (IOException e) {
+            logger.error("[NewsImageUpload] 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 loadNewsImageAsResource(String relativePath) {
+        try {
+            if (relativePath == null || relativePath.isEmpty()) return null;
+            Path root = Paths.get(newsImageProperties.getRootPath() == null ? "news-image-storage" : newsImageProperties.getRootPath());
+            Path file = root.resolve(relativePath).normalize();
+            Path rootReal = root.toAbsolutePath().normalize();
+            if (!file.toAbsolutePath().startsWith(rootReal)) {
+                logger.error("[NewsImageLoad] error=路径穿越, path={}", relativePath);
+                return null;
+            }
+            if (!Files.exists(file)) {
+                logger.warn("[NewsImageLoad] file not exists {}", file);
+                return null;
+            }
+            return new FileSystemResource(file.toFile());
+        } catch (Exception e) {
+            logger.error("[NewsImageLoad] error=加载失败 path={}", relativePath, e);
+            return null;
+        }
+    }
+}

+ 175 - 0
src/main/java/work/baiyun/chronicdiseaseapp/service/impl/NewsServiceImpl.java

@@ -0,0 +1,175 @@
+package work.baiyun.chronicdiseaseapp.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.BeanUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import work.baiyun.chronicdiseaseapp.enums.ErrorCode;
+import work.baiyun.chronicdiseaseapp.exception.CustomException;
+import work.baiyun.chronicdiseaseapp.enums.PermissionGroup;
+import work.baiyun.chronicdiseaseapp.mapper.NewsInfoMapper;
+import work.baiyun.chronicdiseaseapp.mapper.UserInfoMapper;
+import work.baiyun.chronicdiseaseapp.model.po.NewsInfo;
+import work.baiyun.chronicdiseaseapp.model.po.UserInfo;
+import work.baiyun.chronicdiseaseapp.model.vo.CreateNewsRequest;
+import work.baiyun.chronicdiseaseapp.model.vo.NewsVO;
+import work.baiyun.chronicdiseaseapp.model.vo.UpdateNewsRequest;
+import work.baiyun.chronicdiseaseapp.service.NewsService;
+import work.baiyun.chronicdiseaseapp.util.SecurityUtils;
+
+import java.util.*;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+
+@Service
+public class NewsServiceImpl implements NewsService {
+
+    private static final Logger logger = LoggerFactory.getLogger(NewsServiceImpl.class);
+
+    @Autowired
+    private NewsInfoMapper newsInfoMapper;
+
+    @Autowired
+    private UserInfoMapper userInfoMapper;
+
+    @Override
+    public String createNews(CreateNewsRequest request) {
+        if (request == null) {
+            throw new CustomException(ErrorCode.PARAMETER_ERROR.getCode(), "request is null");
+        }
+        Long currentUserId = SecurityUtils.getCurrentUserId();
+        NewsInfo news = new NewsInfo();
+        BeanUtils.copyProperties(request, news);
+        news.setAuthorId(currentUserId);
+        newsInfoMapper.insert(news);
+        logger.info("[News] create news success, id={}, authorId={}", news.getId(), currentUserId);
+        return news.getId().toString();
+    }
+
+    @Override
+    public String updateNews(UpdateNewsRequest request) {
+        if (request == null || request.getId() == null) {
+            throw new CustomException(ErrorCode.PARAMETER_ERROR.getCode(), "id is required");
+        }
+        NewsInfo exist = newsInfoMapper.selectById(request.getId());
+        if (exist == null) {
+            throw new CustomException(ErrorCode.DATA_NOT_FOUND.getCode(), "资讯不存在");
+        }
+        // 权限校验:仅作者本人或管理员可修改
+        Long currentUserId = SecurityUtils.getCurrentUserId();
+        PermissionGroup role = SecurityUtils.getCurrentUserRole();
+        if (!PermissionGroup.SYS_ADMIN.equals(role) && !Objects.equals(currentUserId, exist.getAuthorId())) {
+            throw new CustomException(ErrorCode.FORBIDDEN.getCode(), "无权限修改该资讯");
+        }
+        BeanUtils.copyProperties(request, exist);
+        newsInfoMapper.updateById(exist);
+        logger.info("[News] update news id={} success", exist.getId());
+        return exist.getId().toString();
+    }
+
+    @Override
+    public void deleteNews(Long id) {
+        NewsInfo exist = newsInfoMapper.selectById(id);
+        if (exist == null) {
+            throw new CustomException(ErrorCode.DATA_NOT_FOUND.getCode(), "资讯不存在");
+        }
+        Long currentUserId = SecurityUtils.getCurrentUserId();
+        PermissionGroup role = SecurityUtils.getCurrentUserRole();
+        if (!PermissionGroup.SYS_ADMIN.equals(role) && !Objects.equals(currentUserId, exist.getAuthorId())) {
+            throw new CustomException(ErrorCode.FORBIDDEN.getCode(), "无权限删除该资讯");
+        }
+        newsInfoMapper.deleteById(id);
+        logger.info("[News] delete news id={} success", id);
+    }
+
+    @Override
+    public NewsVO getNewsById(Long id) {
+        NewsInfo news = newsInfoMapper.selectById(id);
+        if (news == null) {
+            throw new CustomException(ErrorCode.DATA_NOT_FOUND.getCode(), "资讯不存在");
+        }
+        NewsVO vo = toNewsVO(news);
+        // 填充作者名
+        if (news.getAuthorId() != null) {
+            UserInfo ui = userInfoMapper.selectById(news.getAuthorId());
+            if (ui != null) vo.setAuthorName(ui.getNickname());
+            else vo.setAuthorName("");
+        }
+        return vo;
+    }
+
+    @Override
+    public Page<NewsVO> listNews(int page, int size, String keyword) {
+        Page<NewsInfo> p = new Page<>(page, size);
+        LambdaQueryWrapper<NewsInfo> wrapper = new LambdaQueryWrapper<>();
+        if (keyword != null && !keyword.trim().isEmpty()) {
+            String k = keyword.trim();
+            wrapper.like(NewsInfo::getTitle, k)
+                    .or()
+                    .like(NewsInfo::getSummary, k);
+        }
+        wrapper.orderByDesc(NewsInfo::getCreateTime);
+        Page<NewsInfo> result = newsInfoMapper.selectPage(p, wrapper);
+
+        List<NewsInfo> records = result.getRecords();
+        if (records == null || records.isEmpty()) {
+            Page<NewsVO> empty = new Page<>();
+            empty.setRecords(Collections.emptyList());
+            return empty;
+        }
+        // 批量获取作者信息
+        Set<Long> authorIds = records.stream().map(NewsInfo::getAuthorId).filter(Objects::nonNull).collect(Collectors.toSet());
+        final Map<Long, String> authorMap = new HashMap<>();
+        if (!authorIds.isEmpty()) {
+            List<UserInfo> users = userInfoMapper.selectBatchIds(new ArrayList<>(authorIds));
+            if (users != null) {
+                authorMap.putAll(users.stream().collect(Collectors.toMap(UserInfo::getId, UserInfo::getNickname)));
+            }
+        }
+
+        List<NewsVO> vos = records.stream().map(r -> {
+            NewsVO vo = toNewsVO(r);
+            if (r.getAuthorId() != null) {
+                vo.setAuthorName(authorMap.getOrDefault(r.getAuthorId(), ""));
+            }
+            return vo;
+        }).collect(Collectors.toList());
+
+        Page<NewsVO> voPage = new Page<>();
+        voPage.setRecords(vos);
+        voPage.setCurrent(result.getCurrent());
+        voPage.setSize(result.getSize());
+        voPage.setTotal(result.getTotal());
+        voPage.setPages(result.getPages());
+        return voPage;
+    }
+
+    private NewsVO toNewsVO(NewsInfo news) {
+        NewsVO vo = new NewsVO();
+        BeanUtils.copyProperties(news, vo);
+        if (news.getId() != null) vo.setId(news.getId().toString());
+        if (news.getAuthorId() != null) vo.setAuthorId(news.getAuthorId().toString());
+        // extract first image
+        vo.setCoverImage(extractFirstImage(news.getContent()));
+        return vo;
+    }
+
+    private static final Pattern IMG_PATTERN = Pattern.compile("!\\[[^\\]]*\\]\\(([^)]+\\.(?:png|jpg|jpeg|webp))\\)", Pattern.CASE_INSENSITIVE);
+
+    private String extractFirstImage(String content) {
+        if (content == null || content.isEmpty()) return null;
+        Matcher m = IMG_PATTERN.matcher(content);
+        if (m.find()) {
+            return m.group(1);
+        }
+        // try <img src="..."> pattern
+        Pattern htmlImg = Pattern.compile("<img[^>]*src=['\"]([^'\"]+\\.(?:png|jpg|jpeg|webp))['\"][^>]*>", Pattern.CASE_INSENSITIVE);
+        Matcher hm = htmlImg.matcher(content);
+        if (hm.find()) return hm.group(1);
+        return null;
+    }
+}

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

@@ -57,6 +57,10 @@ wechat:
   secret:  0b42a3e4becb7817d08e44e91e2824dd
 
 avatar:
-  root-path: D:/avatar-storage/
+  root-path: D:/慢病APP/Temp/avatar-storage/
   max-size: 2MB
+  allowed-types: jpg,png,jpeg,webp
+news-image:
+  root-path: D:/慢病APP/Temp/news-images/
+  max-size: 5MB
   allowed-types: jpg,png,jpeg,webp