Просмотр исходного кода

feat(docs): 添加用户行为日志与动态查询功能设计文档

- 新增用户行为日志表 t_user_activity_log 的建表 SQL 文件
- 设计并实现了 ActivityType 和 RelatedEntityType 枚举类型
- 定义了 UserActivityLog 实体类及相关的 VO 类
- 编写了 UserActivityLogService 接口及其实现逻辑
- 实现了医生和管理员的动态查询权限控制
- 集成 AOP 方式自动记录用户活动日志
- 提供 Controller 层接口用于动态查询
- 添加了 JsonUtils 工具类使用说明
- 更新 pom.xml 添加 spring-boot-starter-aop 依赖
mcbaiyun 1 месяц назад
Родитель
Сommit
889677514f
18 измененных файлов с 1957 добавлено и 0 удалено
  1. 26 0
      docs/DB/t_user_activity_log.sql
  2. 935 0
      docs/New/用户行为日志与动态查询功能设计文档.md
  3. 5 0
      pom.xml
  4. 223 0
      src/main/java/work/baiyun/chronicdiseaseapp/aspect/UserActivityLogAspect.java
  5. 112 0
      src/main/java/work/baiyun/chronicdiseaseapp/controller/UserActivityLogController.java
  6. 99 0
      src/main/java/work/baiyun/chronicdiseaseapp/enums/ActivityType.java
  7. 53 0
      src/main/java/work/baiyun/chronicdiseaseapp/enums/RelatedEntityType.java
  8. 41 0
      src/main/java/work/baiyun/chronicdiseaseapp/handler/ActivityTypeTypeHandler.java
  9. 41 0
      src/main/java/work/baiyun/chronicdiseaseapp/handler/RelatedEntityTypeTypeHandler.java
  10. 10 0
      src/main/java/work/baiyun/chronicdiseaseapp/mapper/UserActivityLogMapper.java
  11. 49 0
      src/main/java/work/baiyun/chronicdiseaseapp/model/po/UserActivityLog.java
  12. 27 0
      src/main/java/work/baiyun/chronicdiseaseapp/model/vo/PatientActivityQueryRequest.java
  13. 24 0
      src/main/java/work/baiyun/chronicdiseaseapp/model/vo/UserActivityPageResponse.java
  14. 36 0
      src/main/java/work/baiyun/chronicdiseaseapp/model/vo/UserActivityResponse.java
  15. 34 0
      src/main/java/work/baiyun/chronicdiseaseapp/service/UserActivityLogService.java
  16. 5 0
      src/main/java/work/baiyun/chronicdiseaseapp/service/UserBindingService.java
  17. 226 0
      src/main/java/work/baiyun/chronicdiseaseapp/service/impl/UserActivityLogServiceImpl.java
  18. 11 0
      src/main/java/work/baiyun/chronicdiseaseapp/service/impl/UserBindingServiceImpl.java

+ 26 - 0
docs/DB/t_user_activity_log.sql

@@ -0,0 +1,26 @@
+-- 用户行为日志表 (t_user_activity_log)
+-- 遵循项目数据库规范,使用 MyBatis-Plus ASSIGN_ID 雪花算法生成主键
+-- 公共字段由 BaseEntity 和 CustomMetaObjectHandler 自动填充
+
+CREATE TABLE `t_user_activity_log` (
+  `id` bigint(20) NOT NULL COMMENT '主键ID',
+  `user_id` bigint(20) NOT NULL COMMENT '操作用户ID',
+  `activity_type` varchar(50) NOT NULL COMMENT '活动类型',
+  `activity_description` text NOT NULL COMMENT '活动描述',
+  `related_entity_type` varchar(50) DEFAULT NULL COMMENT '相关实体类型',
+  `related_entity_id` bigint(20) DEFAULT NULL COMMENT '相关实体ID',
+  `metadata` text DEFAULT NULL COMMENT '元数据(JSON格式)',
+  `ip_address` varchar(45) DEFAULT NULL COMMENT '操作IP地址',
+  `user_agent` text COMMENT '用户代理字符串',
+  `version` int(11) DEFAULT '0' COMMENT '版本号(乐观锁)',
+  `create_user` bigint(20) DEFAULT NULL COMMENT '创建者ID',
+  `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `update_user` bigint(20) DEFAULT NULL COMMENT '更新者ID',
+  `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  `remark` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注',
+  PRIMARY KEY (`id`),
+  KEY `idx_user_id` (`user_id`),
+  KEY `idx_activity_type` (`activity_type`),
+  KEY `idx_create_time` (`create_time`),
+  KEY `idx_related_entity_type` (`related_entity_type`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户行为日志表';

+ 935 - 0
docs/New/用户行为日志与动态查询功能设计文档.md

@@ -0,0 +1,935 @@
+# 用户行为日志与动态查询功能设计文档
+
+## 1. 功能概述
+
+用户行为日志与动态查询功能旨在记录系统中所有用户的关键行为操作,包括所有后端接口的调用(如患者上传数据、医生确认复诊、管理员管理操作等),并基于日志数据为医生提供患者动态查询服务。该功能允许记录用户活动,并根据用户角色提供差异化的查询权限:
+- 医生可查询绑定患者的特定动态(患者健康数据更新、健康档案更新、复诊申请更新)。
+- 管理员可查询所有用户的动态(包括患者、医生、家属、管理员的活动)。
+
+该功能遵循项目数据库规范(参见 `docs/OLD/DevRule/07-数据库规范.md`)和API设计规范(参见 `docs/OLD/DevRule/03-API设计规范.md`),确保日志记录的完整性和查询的安全性。
+
+## 2. 数据库设计
+
+### 2.1 用户行为日志表 (t_user_activity_log)
+
+根据项目数据库表设计规范,创建用户行为日志表,用于记录所有用户的关键操作,包括所有接口调用:
+
+| 字段名 | 类型 | 描述 |
+|--------|------|------|
+| id | BIGINT(20) | 主键ID,使用雪花算法(MyBatis-Plus `ASSIGN_ID`) |
+| user_id | BIGINT(20) | 操作用户ID,外键(关联 t_user_info) |
+| activity_type | VARCHAR(50) | 活动类型(枚举值,如 BLOOD_GLUCOSE_UPLOAD, FOLLOW_UP_CONFIRM 等) |
+| activity_description | TEXT | 活动描述(简要说明操作内容) |
+| related_entity_type | VARCHAR(50) | 相关实体类型(可选,如 BLOOD_GLUCOSE, HEALTH_RECORD, FOLLOW_UP) |
+| related_entity_id | BIGINT(20) | 相关实体ID(可选,用于关联具体记录) |
+| metadata | JSON | 元数据(存储额外信息,如数值、状态变更等,JSON格式) |
+| ip_address | VARCHAR(45) | 操作IP地址 |
+| user_agent | TEXT | 用户代理字符串 |
+| version | INT(11) | 版本号(乐观锁) |
+| create_user | BIGINT(20) | 创建者ID(同 user_id) |
+| create_time | DATETIME | 创建时间,默认值CURRENT_TIMESTAMP |
+| update_user | BIGINT(20) | 更新者ID |
+| update_time | DATETIME | 更新时间,默认值CURRENT_TIMESTAMP,更新时自动设置为当前时间 |
+| remark | VARCHAR(255) | 备注 |
+
+遵循的数据库规范要点:
+- 使用 `BIGINT` 主键并通过雪花算法生成(MyBatis-Plus `ASSIGN_ID`)。
+- 公共字段(`version`、`create_user`、`create_time`、`update_user`、`update_time`)由 `BaseEntity` 与 `CustomMetaObjectHandler` 自动填充。
+- 添加索引:`user_id`、`activity_type`、`create_time`、`related_entity_type`。
+- 数据迁移建议使用 Flyway/Liquibase(脚本命名遵循 `V{version}__{description}.sql`)。
+
+示例建表:
+```
+CREATE TABLE `t_user_activity_log` (
+  `id` bigint(20) NOT NULL COMMENT '主键ID',
+  `user_id` bigint(20) NOT NULL COMMENT '操作用户ID',
+  `activity_type` varchar(50) NOT NULL COMMENT '活动类型',
+  `activity_description` text NOT NULL COMMENT '活动描述',
+  `related_entity_type` varchar(50) DEFAULT NULL COMMENT '相关实体类型',
+  `related_entity_id` bigint(20) DEFAULT NULL COMMENT '相关实体ID',
+  `metadata` json DEFAULT NULL COMMENT '元数据(JSON格式)',
+  `ip_address` varchar(45) DEFAULT NULL COMMENT '操作IP地址',
+  `user_agent` text COMMENT '用户代理字符串',
+  `version` int(11) DEFAULT '0' COMMENT '版本号(乐观锁)',
+  `create_user` bigint(20) DEFAULT NULL COMMENT '创建者ID',
+  `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `update_user` bigint(20) DEFAULT NULL COMMENT '更新者ID',
+  `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  `remark` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注',
+  PRIMARY KEY (`id`),
+  KEY `idx_user_id` (`user_id`),
+  KEY `idx_activity_type` (`activity_type`),
+  KEY `idx_create_time` (`create_time`),
+  KEY `idx_related_entity_type` (`related_entity_type`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户行为日志表';
+```
+
+备注:对外响应的 `id` 建议以字符串形式返回以避免前端 JS 精度丢失(参见 `docs/OLD/DevRule/03-API设计规范.md` 的长整型 ID 传输策略)。
+
+## 3. 枚举类型设计
+
+### 3.1 活动类型枚举 (ActivityType.java)
+
+```java
+package work.baiyun.chronicdiseaseapp.enums;
+
+import com.baomidou.mybatisplus.annotation.EnumValue;
+
+/**
+ * 用户活动类型枚举(涵盖所有接口调用)
+ */
+public enum ActivityType {
+    // 患者健康数据相关
+    BLOOD_GLUCOSE_UPLOAD("BLOOD_GLUCOSE_UPLOAD", "血糖数据上传"),
+    BLOOD_PRESSURE_UPLOAD("BLOOD_PRESSURE_UPLOAD", "血压数据上传"),
+    HEART_RATE_UPLOAD("HEART_RATE_UPLOAD", "心率数据上传"),
+    PHYSICAL_DATA_UPLOAD("PHYSICAL_DATA_UPLOAD", "体格数据上传"),
+    
+    // 患者档案相关
+    HEALTH_RECORD_UPDATE("HEALTH_RECORD_UPDATE", "健康档案更新"),
+    
+    // 用药相关
+    MEDICATION_CREATE("MEDICATION_CREATE", "用药记录创建"),
+    MEDICATION_UPDATE("MEDICATION_UPDATE", "用药记录更新"),
+    
+    // 提醒相关
+    REMINDER_SAVE("REMINDER_SAVE", "提醒设置保存"),
+    REMINDER_DELETE("REMINDER_DELETE", "提醒设置删除"),
+    
+    // 复诊相关(患者)
+    FOLLOW_UP_CREATE("FOLLOW_UP_CREATE", "复诊请求创建"),
+    FOLLOW_UP_UPDATE("FOLLOW_UP_UPDATE", "复诊记录更新"),
+    
+    // 医生复诊操作
+    FOLLOW_UP_CONFIRM("FOLLOW_UP_CONFIRM", "医生确认复诊"),
+    FOLLOW_UP_CANCEL("FOLLOW_UP_CANCEL", "医生取消复诊"),
+    FOLLOW_UP_COMPLETE("FOLLOW_UP_COMPLETE", "医生完成复诊"),
+    
+    // 用户绑定相关
+    USER_BINDING_CREATE("USER_BINDING_CREATE", "用户绑定创建"),
+    USER_BINDING_DELETE("USER_BINDING_DELETE", "用户绑定删除"),
+    
+    // 头像相关
+    AVATAR_UPLOAD("AVATAR_UPLOAD", "头像上传"),
+    
+    // 地理位置相关
+    GEO_QUERY("GEO_QUERY", "地理位置查询"),
+    
+    // 药品管理相关(管理员/医生)
+    MEDICINE_CREATE("MEDICINE_CREATE", "药品信息创建"),
+    MEDICINE_UPDATE("MEDICINE_UPDATE", "药品信息更新"),
+    MEDICINE_LIST("MEDICINE_LIST", "药品信息查询"),
+    
+    // 微信相关
+    WECHAT_GET_OPENID("WECHAT_GET_OPENID", "获取微信openid"),
+    
+    // 其他通用操作(可扩展)
+    DATA_QUERY("DATA_QUERY", "数据查询"),
+    DATA_UPDATE("DATA_UPDATE", "数据更新"),
+    DATA_DELETE("DATA_DELETE", "数据删除");
+
+    @EnumValue
+    private final String code;
+    private final String description;
+
+    ActivityType(String code, String description) {
+        this.code = code;
+        this.description = description;
+    }
+
+    public String getCode() { 
+        return code; 
+    }
+
+    public String getDescription() { 
+        return description; 
+    }
+
+    @Override
+    public String toString() { 
+        return description; 
+    }
+
+    public static ActivityType fromCode(String code) {
+        if (code == null) return null;
+        for (ActivityType type : ActivityType.values()) {
+            if (type.code.equals(code)) return type;
+        }
+        throw new work.baiyun.chronicdiseaseapp.exception.CustomException(
+            work.baiyun.chronicdiseaseapp.enums.ErrorCode.PARAMETER_ERROR.getCode(),
+            "Unknown ActivityType code: " + code);
+    }
+}
+```
+
+### 3.2 相关实体类型枚举 (RelatedEntityType.java)
+
+```java
+package work.baiyun.chronicdiseaseapp.enums;
+
+import com.baomidou.mybatisplus.annotation.EnumValue;
+
+/**
+ * 相关实体类型枚举
+ */
+public enum RelatedEntityType {
+    BLOOD_GLUCOSE("BLOOD_GLUCOSE", "血糖数据"),
+    BLOOD_PRESSURE("BLOOD_PRESSURE", "血压数据"),
+    HEART_RATE("HEART_RATE", "心率数据"),
+    PHYSICAL_DATA("PHYSICAL_DATA", "体格数据"),
+    HEALTH_RECORD("HEALTH_RECORD", "健康档案"),
+    MEDICATION("MEDICATION", "用药记录"),
+    REMINDER("REMINDER", "提醒设置"),
+    FOLLOW_UP("FOLLOW_UP", "复诊记录"),
+    USER_BINDING("USER_BINDING", "用户绑定"),
+    AVATAR("AVATAR", "头像"),
+    MEDICINE("MEDICINE", "药品信息"),
+    USER_INFO("USER_INFO", "用户信息");
+
+    @EnumValue
+    private final String code;
+    private final String description;
+
+    RelatedEntityType(String code, String description) {
+        this.code = code;
+        this.description = description;
+    }
+
+    public String getCode() { 
+        return code; 
+    }
+
+    public String getDescription() { 
+        return description; 
+    }
+
+    @Override
+    public String toString() { 
+        return description; 
+    }
+
+    public static RelatedEntityType fromCode(String code) {
+        if (code == null) return null;
+        for (RelatedEntityType type : RelatedEntityType.values()) {
+            if (type.code.equals(code)) return type;
+        }
+        throw new work.baiyun.chronicdiseaseapp.exception.CustomException(
+            work.baiyun.chronicdiseaseapp.enums.ErrorCode.PARAMETER_ERROR.getCode(),
+            "Unknown RelatedEntityType code: " + code);
+    }
+}
+```
+
+### 3.3 枚举类型处理器
+
+参考复诊文档,为 ActivityType 和 RelatedEntityType 创建相应的 TypeHandler(ActivityTypeTypeHandler.java 和 RelatedEntityTypeTypeHandler.java),实现与数据库的映射。
+
+## 2. 数据库设计
+
+### 2.1 用户行为日志表 (t_user_activity_log)
+
+根据项目数据库表设计规范,创建用户行为日志表,用于记录所有用户的关键操作:
+
+| 字段名 | 类型 | 描述 |
+|--------|------|------|
+| id | BIGINT(20) | 主键ID,使用雪花算法(MyBatis-Plus `ASSIGN_ID`) |
+| user_id | BIGINT(20) | 操作用户ID,外键(关联 t_user_info) |
+| activity_type | VARCHAR(50) | 活动类型(枚举值,如 DATA_UPLOAD, PROFILE_UPDATE, FOLLOW_UP_UPDATE 等) |
+| activity_description | TEXT | 活动描述(简要说明操作内容) |
+| related_entity_type | VARCHAR(50) | 相关实体类型(可选,如 BLOOD_GLUCOSE, HEALTH_RECORD, FOLLOW_UP) |
+| related_entity_id | BIGINT(20) | 相关实体ID(可选,用于关联具体记录) |
+| metadata | JSON | 元数据(存储额外信息,如数值、状态变更等,JSON格式) |
+| ip_address | VARCHAR(45) | 操作IP地址 |
+| user_agent | TEXT | 用户代理字符串 |
+| version | INT(11) | 版本号(乐观锁) |
+| create_user | BIGINT(20) | 创建者ID(同 user_id) |
+| create_time | DATETIME | 创建时间,默认值CURRENT_TIMESTAMP |
+| update_user | BIGINT(20) | 更新者ID |
+| update_time | DATETIME | 更新时间,默认值CURRENT_TIMESTAMP,更新时自动设置为当前时间 |
+| remark | VARCHAR(255) | 备注 |
+
+遵循的数据库规范要点:
+- 使用 `BIGINT` 主键并通过雪花算法生成(MyBatis-Plus `ASSIGN_ID`)。
+- 公共字段(`version`、`create_user`、`create_time`、`update_user`、`update_time`)由 `BaseEntity` 与 `CustomMetaObjectHandler` 自动填充。
+- 添加索引:`user_id`、`activity_type`、`create_time`、`related_entity_type`。
+- 数据迁移建议使用 Flyway/Liquibase(脚本命名遵循 `V{version}__{description}.sql`)。
+
+示例建表:
+```
+CREATE TABLE `t_user_activity_log` (
+  `id` bigint(20) NOT NULL COMMENT '主键ID',
+  `user_id` bigint(20) NOT NULL COMMENT '操作用户ID',
+  `activity_type` varchar(50) NOT NULL COMMENT '活动类型',
+  `activity_description` text NOT NULL COMMENT '活动描述',
+  `related_entity_type` varchar(50) DEFAULT NULL COMMENT '相关实体类型',
+  `related_entity_id` bigint(20) DEFAULT NULL COMMENT '相关实体ID',
+  `metadata` json DEFAULT NULL COMMENT '元数据(JSON格式)',
+  `ip_address` varchar(45) DEFAULT NULL COMMENT '操作IP地址',
+  `user_agent` text COMMENT '用户代理字符串',
+  `version` int(11) DEFAULT '0' COMMENT '版本号(乐观锁)',
+  `create_user` bigint(20) DEFAULT NULL COMMENT '创建者ID',
+  `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `update_user` bigint(20) DEFAULT NULL COMMENT '更新者ID',
+  `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  `remark` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注',
+  PRIMARY KEY (`id`),
+  KEY `idx_user_id` (`user_id`),
+  KEY `idx_activity_type` (`activity_type`),
+  KEY `idx_create_time` (`create_time`),
+  KEY `idx_related_entity_type` (`related_entity_type`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户行为日志表';
+```
+
+备注:对外响应的 `id` 建议以字符串形式返回以避免前端 JS 精度丢失(参见 `docs/OLD/DevRule/03-API设计规范.md` 的长整型 ID 传输策略)。
+
+## 3. 枚举类型设计
+
+### 3.1 活动类型枚举 (ActivityType.java)
+
+```java
+package work.baiyun.chronicdiseaseapp.enums;
+
+import com.baomidou.mybatisplus.annotation.EnumValue;
+
+/**
+ * 用户活动类型枚举
+ */
+public enum ActivityType {
+    // 患者健康数据相关
+    BLOOD_GLUCOSE_UPLOAD("BLOOD_GLUCOSE_UPLOAD", "血糖数据上传"),
+    BLOOD_PRESSURE_UPLOAD("BLOOD_PRESSURE_UPLOAD", "血压数据上传"),
+    HEART_RATE_UPLOAD("HEART_RATE_UPLOAD", "心率数据上传"),
+    PHYSICAL_DATA_UPLOAD("PHYSICAL_DATA_UPLOAD", "体格数据上传"),
+    
+    // 患者档案相关
+    HEALTH_RECORD_UPDATE("HEALTH_RECORD_UPDATE", "健康档案更新"),
+    
+    // 用药相关
+    MEDICATION_CREATE("MEDICATION_CREATE", "用药记录创建"),
+    MEDICATION_UPDATE("MEDICATION_UPDATE", "用药记录更新"),
+    
+    // 提醒相关
+    REMINDER_SAVE("REMINDER_SAVE", "提醒设置保存"),
+    REMINDER_DELETE("REMINDER_DELETE", "提醒设置删除"),
+    
+    // 复诊相关
+    FOLLOW_UP_CREATE("FOLLOW_UP_CREATE", "复诊请求创建"),
+    FOLLOW_UP_UPDATE("FOLLOW_UP_UPDATE", "复诊记录更新"),
+    
+    // 其他活动(管理员可见)
+    USER_BINDING_CREATE("USER_BINDING_CREATE", "用户绑定创建"),
+    USER_BINDING_DELETE("USER_BINDING_DELETE", "用户绑定删除"),
+    AVATAR_UPLOAD("AVATAR_UPLOAD", "头像上传"),
+    GEO_QUERY("GEO_QUERY", "地理位置查询");
+
+    @EnumValue
+    private final String code;
+    private final String description;
+
+    ActivityType(String code, String description) {
+        this.code = code;
+        this.description = description;
+    }
+
+    public String getCode() { 
+        return code; 
+    }
+
+    public String getDescription() { 
+        return description; 
+    }
+
+    @Override
+    public String toString() { 
+        return description; 
+    }
+
+    public static ActivityType fromCode(String code) {
+        if (code == null) return null;
+        for (ActivityType type : ActivityType.values()) {
+            if (type.code.equals(code)) return type;
+        }
+        throw new work.baiyun.chronicdiseaseapp.exception.CustomException(
+            work.baiyun.chronicdiseaseapp.enums.ErrorCode.PARAMETER_ERROR.getCode(),
+            "Unknown ActivityType code: " + code);
+    }
+}
+```
+
+### 3.2 相关实体类型枚举 (RelatedEntityType.java)
+
+```java
+package work.baiyun.chronicdiseaseapp.enums;
+
+import com.baomidou.mybatisplus.annotation.EnumValue;
+
+/**
+ * 相关实体类型枚举
+ */
+public enum RelatedEntityType {
+    BLOOD_GLUCOSE("BLOOD_GLUCOSE", "血糖数据"),
+    BLOOD_PRESSURE("BLOOD_PRESSURE", "血压数据"),
+    HEART_RATE("HEART_RATE", "心率数据"),
+    PHYSICAL_DATA("PHYSICAL_DATA", "体格数据"),
+    HEALTH_RECORD("HEALTH_RECORD", "健康档案"),
+    MEDICATION("MEDICATION", "用药记录"),
+    REMINDER("REMINDER", "提醒设置"),
+    FOLLOW_UP("FOLLOW_UP", "复诊记录"),
+    USER_BINDING("USER_BINDING", "用户绑定"),
+    AVATAR("AVATAR", "头像");
+
+    @EnumValue
+    private final String code;
+    private final String description;
+
+    RelatedEntityType(String code, String description) {
+        this.code = code;
+        this.description = description;
+    }
+
+    public String getCode() { 
+        return code; 
+    }
+
+    public String getDescription() { 
+        return description; 
+    }
+
+    @Override
+    public String toString() { 
+        return description; 
+    }
+
+    public static RelatedEntityType fromCode(String code) {
+        if (code == null) return null;
+        for (RelatedEntityType type : RelatedEntityType.values()) {
+            if (type.code.equals(code)) return type;
+        }
+        throw new work.baiyun.chronicdiseaseapp.exception.CustomException(
+            work.baiyun.chronicdiseaseapp.enums.ErrorCode.PARAMETER_ERROR.getCode(),
+            "Unknown RelatedEntityType code: " + code);
+    }
+}
+```
+
+### 3.3 枚举类型处理器
+
+参考复诊文档,为 ActivityType 和 RelatedEntityType 创建相应的 TypeHandler(ActivityTypeTypeHandler.java 和 RelatedEntityTypeTypeHandler.java),实现与数据库的映射。
+
+## 4. 实体类设计
+
+### 4.1 PO实体类 (UserActivityLog.java)
+
+```java
+package work.baiyun.chronicdiseaseapp.model.po;
+
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableName;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import work.baiyun.chronicdiseaseapp.enums.ActivityType;
+import work.baiyun.chronicdiseaseapp.enums.RelatedEntityType;
+
+import java.time.LocalDateTime;
+
+@Schema(description = "用户行为日志表")
+@TableName("t_user_activity_log")
+@Data
+@EqualsAndHashCode(callSuper = false)
+public class UserActivityLog extends BaseEntity {
+    @Schema(description = "操作用户ID")
+    @TableField("user_id")
+    private Long userId;
+
+    @Schema(description = "活动类型")
+    @TableField(value = "activity_type", typeHandler = work.baiyun.chronicdiseaseapp.handler.ActivityTypeTypeHandler.class)
+    private ActivityType activityType;
+
+    @Schema(description = "活动描述")
+    @TableField("activity_description")
+    private String activityDescription;
+
+    @Schema(description = "相关实体类型")
+    @TableField(value = "related_entity_type", typeHandler = work.baiyun.chronicdiseaseapp.handler.RelatedEntityTypeTypeHandler.class)
+    private RelatedEntityType relatedEntityType;
+
+    @Schema(description = "相关实体ID")
+    @TableField("related_entity_id")
+    private Long relatedEntityId;
+
+    @Schema(description = "元数据(JSON格式)")
+    @TableField("metadata")
+    private String metadata; // 使用 String 存储 JSON
+
+    @Schema(description = "操作IP地址")
+    @TableField("ip_address")
+    private String ipAddress;
+
+    @Schema(description = "用户代理字符串")
+    @TableField("user_agent")
+    private String userAgent;
+}
+```
+
+## 5. VO设计
+
+### 5.1 动态查询请求VO (PatientActivityQueryRequest.java)
+
+```java
+package work.baiyun.chronicdiseaseapp.model.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import work.baiyun.chronicdiseaseapp.enums.ActivityType;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+@Schema(description = "患者动态查询请求")
+@Data
+public class PatientActivityQueryRequest extends BaseQueryRequest {
+    @Schema(description = "患者用户ID(医生查询时必填)")
+    private Long patientUserId;
+
+    @Schema(description = "活动类型列表(可选,用于过滤特定动态)")
+    private List<ActivityType> activityTypes;
+
+    @Schema(description = "开始时间")
+    private LocalDateTime startTime;
+
+    @Schema(description = "结束时间")
+    private LocalDateTime endTime;
+}
+```
+
+### 5.2 动态响应VO (UserActivityResponse.java)
+
+```java
+package work.baiyun.chronicdiseaseapp.model.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import work.baiyun.chronicdiseaseapp.enums.ActivityType;
+import work.baiyun.chronicdiseaseapp.enums.RelatedEntityType;
+
+import java.time.LocalDateTime;
+
+@Schema(description = "用户活动响应")
+@Data
+public class UserActivityResponse {
+    @Schema(description = "活动ID")
+    private String id;
+
+    @Schema(description = "操作用户ID")
+    private Long userId;
+
+    @Schema(description = "活动类型")
+    private ActivityType activityType;
+
+    @Schema(description = "活动描述")
+    private String activityDescription;
+
+    @Schema(description = "相关实体类型")
+    private RelatedEntityType relatedEntityType;
+
+    @Schema(description = "相关实体ID")
+    private Long relatedEntityId;
+
+    @Schema(description = "元数据")
+    private Object metadata; // 反序列化为对象
+
+    @Schema(description = "操作时间")
+    private LocalDateTime createTime;
+}
+```
+
+### 5.3 动态分页响应VO (UserActivityPageResponse.java)
+
+继承分页响应结构,包含 records 列表。
+
+## 6. Service层设计
+
+### 6.1 UserActivityLogService 接口
+
+```java
+package work.baiyun.chronicdiseaseapp.service;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import work.baiyun.chronicdiseaseapp.model.vo.PatientActivityQueryRequest;
+import work.baiyun.chronicdiseaseapp.model.vo.UserActivityResponse;
+
+/**
+ * 用户活动日志服务接口
+ */
+public interface UserActivityLogService {
+    /**
+     * 记录用户活动
+     */
+    void logActivity(Long userId, ActivityType activityType, String description, 
+                    RelatedEntityType relatedEntityType, Long relatedEntityId, 
+                    Object metadata, String ipAddress, String userAgent);
+
+    /**
+     * 医生查询绑定患者的动态(仅限特定类型)
+     */
+    Page<UserActivityResponse> queryPatientActivitiesForDoctor(PatientActivityQueryRequest request);
+
+    /**
+     * 管理员查询所有用户动态
+     */
+    Page<UserActivityResponse> queryAllActivities(PatientActivityQueryRequest request);
+}
+```
+
+### 6.2 UserActivityLogServiceImpl 实现
+
+实现日志记录和查询逻辑,包括权限校验(医生只能查绑定患者,管理员查所有)。使用 `JsonUtils` 工具类处理 `metadata` 字段的JSON序列化/反序列化。
+
+```java
+package work.baiyun.chronicdiseaseapp.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import work.baiyun.chronicdiseaseapp.mapper.UserActivityLogMapper;
+import work.baiyun.chronicdiseaseapp.model.po.UserActivityLog;
+import work.baiyun.chronicdiseaseapp.model.vo.PatientActivityQueryRequest;
+import work.baiyun.chronicdiseaseapp.model.vo.UserActivityResponse;
+import work.baiyun.chronicdiseaseapp.service.UserActivityLogService;
+import work.baiyun.chronicdiseaseapp.service.UserBindingService;
+import work.baiyun.chronicdiseaseapp.enums.ActivityType;
+import work.baiyun.chronicdiseaseapp.enums.RelatedEntityType;
+import work.baiyun.chronicdiseaseapp.enums.PermissionGroup;
+import work.baiyun.chronicdiseaseapp.util.JsonUtils;
+import work.baiyun.chronicdiseaseapp.util.SecurityUtils;
+
+import java.util.List;
+
+@Service
+public class UserActivityLogServiceImpl implements UserActivityLogService {
+
+    @Autowired
+    private UserActivityLogMapper userActivityLogMapper;
+
+    @Autowired
+    private UserBindingService userBindingService;
+
+    @Override
+    public void logActivity(Long userId, ActivityType activityType, String description, 
+                            RelatedEntityType relatedEntityType, Long relatedEntityId, 
+                            Object metadata, String ipAddress, String userAgent) {
+        UserActivityLog log = new UserActivityLog();
+        log.setUserId(userId);
+        log.setActivityType(activityType);
+        log.setActivityDescription(description);
+        log.setRelatedEntityType(relatedEntityType);
+        log.setRelatedEntityId(relatedEntityId);
+        // 使用 JsonUtils 序列化 metadata 为 JSON 字符串
+        log.setMetadata(JsonUtils.toJson(metadata));
+        log.setIpAddress(ipAddress);
+        log.setUserAgent(userAgent);
+        
+        userActivityLogMapper.insert(log);
+    }
+
+    @Override
+    public Page<UserActivityResponse> queryPatientActivitiesForDoctor(PatientActivityQueryRequest request) {
+        Long currentUserId = SecurityUtils.getCurrentUserId();
+        PermissionGroup role = SecurityUtils.getCurrentUserRole();
+        
+        // 权限校验:医生需绑定患者
+        if (!PermissionGroup.DOCTOR.equals(role)) {
+            throw new RuntimeException("无权限");
+        }
+        
+        // 校验绑定关系
+        boolean isBound = userBindingService.checkBinding(currentUserId, request.getPatientUserId());
+        if (!isBound) {
+            throw new RuntimeException("未绑定该患者");
+        }
+        
+        // 过滤特定活动类型(患者健康数据、健康档案、复诊)
+        List<ActivityType> allowedTypes = List.of(
+            ActivityType.BLOOD_GLUCOSE_UPLOAD, ActivityType.HEALTH_RECORD_UPDATE, 
+            ActivityType.FOLLOW_UP_CREATE, ActivityType.FOLLOW_UP_UPDATE
+        );
+        
+        QueryWrapper<UserActivityLog> wrapper = new QueryWrapper<>();
+        wrapper.eq("user_id", request.getPatientUserId())
+               .in("activity_type", allowedTypes)
+               .orderByDesc("create_time");
+        
+        Page<UserActivityLog> page = userActivityLogMapper.selectPage(
+            new Page<>(request.getCurrent(), request.getSize()), wrapper);
+        
+        // 转换为响应VO,使用 JsonUtils 反序列化 metadata
+        List<UserActivityResponse> responses = page.getRecords().stream().map(log -> {
+            UserActivityResponse resp = new UserActivityResponse();
+            resp.setId(String.valueOf(log.getId()));
+            resp.setUserId(log.getUserId());
+            resp.setActivityType(log.getActivityType());
+            resp.setActivityDescription(log.getActivityDescription());
+            resp.setRelatedEntityType(log.getRelatedEntityType());
+            resp.setRelatedEntityId(log.getRelatedEntityId());
+            // 使用 JsonUtils 反序列化 metadata
+            resp.setMetadata(JsonUtils.fromJson(log.getMetadata(), Object.class));
+            resp.setCreateTime(log.getCreateTime());
+            return resp;
+        }).collect(Collectors.toList());
+        
+        Page<UserActivityResponse> resultPage = new Page<>();
+        resultPage.setRecords(responses);
+        resultPage.setTotal(page.getTotal());
+        return resultPage;
+    }
+
+    @Override
+    public Page<UserActivityResponse> queryAllActivities(PatientActivityQueryRequest request) {
+        PermissionGroup role = SecurityUtils.getCurrentUserRole();
+        
+        // 权限校验:仅管理员
+        if (!PermissionGroup.ADMIN.equals(role)) {
+            throw new RuntimeException("无权限");
+        }
+        
+        QueryWrapper<UserActivityLog> wrapper = new QueryWrapper<>();
+        if (request.getUserId() != null) {
+            wrapper.eq("user_id", request.getUserId());
+        }
+        wrapper.orderByDesc("create_time");
+        
+        Page<UserActivityLog> page = userActivityLogMapper.selectPage(
+            new Page<>(request.getCurrent(), request.getSize()), wrapper);
+        
+        // 转换为响应VO
+        List<UserActivityResponse> responses = page.getRecords().stream().map(log -> {
+            UserActivityResponse resp = new UserActivityResponse();
+            // ... 类似上述代码
+            resp.setMetadata(JsonUtils.fromJson(log.getMetadata(), Object.class));
+            return resp;
+        }).collect(Collectors.toList());
+        
+        Page<UserActivityResponse> resultPage = new Page<>();
+        resultPage.setRecords(responses);
+        resultPage.setTotal(page.getTotal());
+        return resultPage;
+    }
+}
+```
+
+## 7. Controller层设计
+
+### 7.1 UserActivityLogController
+
+```java
+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.tags.Tag;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.responses.ApiResponses;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.Schema;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.MediaType;
+import org.springframework.web.bind.annotation.*;
+import work.baiyun.chronicdiseaseapp.common.R;
+import work.baiyun.chronicdiseaseapp.model.vo.PatientActivityQueryRequest;
+import work.baiyun.chronicdiseaseapp.model.vo.UserActivityResponse;
+import work.baiyun.chronicdiseaseapp.service.UserActivityLogService;
+import work.baiyun.chronicdiseaseapp.enums.ErrorCode;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@RestController
+@RequestMapping("/user-activity")
+@Tag(name = "用户活动日志", description = "用户行为日志与动态查询相关接口")
+public class UserActivityLogController {
+
+    private static final Logger logger = LoggerFactory.getLogger(UserActivityLogController.class);
+
+    @Autowired
+    private UserActivityLogService userActivityLogService;
+
+    @Operation(summary = "医生查询患者动态", description = "医生查询绑定患者的健康相关动态")
+    @ApiResponses(value = {
+        @ApiResponse(responseCode = "200", description = "成功查询患者动态",
+            content = @Content(mediaType = "application/json",
+                schema = @Schema(implementation = work.baiyun.chronicdiseaseapp.model.vo.UserActivityPageResponse.class))),
+        @ApiResponse(responseCode = "403", description = "无权限访问",
+            content = @Content(mediaType = "application/json",
+                schema = @Schema(implementation = Void.class)))
+    })
+    @PostMapping(path = "/query-patient-activities", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
+    public R<?> queryPatientActivities(@RequestBody PatientActivityQueryRequest req) {
+        try {
+            Page<UserActivityResponse> page = userActivityLogService.queryPatientActivitiesForDoctor(req);
+            work.baiyun.chronicdiseaseapp.model.vo.UserActivityPageResponse vo = new work.baiyun.chronicdiseaseapp.model.vo.UserActivityPageResponse();
+            vo.setRecords(page.getRecords());
+            vo.setTotal(page.getTotal());
+            vo.setSize(page.getSize());
+            vo.setCurrent(page.getCurrent());
+            vo.setPages(page.getPages());
+            return R.success(200, "ok", vo);
+        } catch (Exception e) {
+            logger.error("query patient activities failed", e);
+            return R.fail(ErrorCode.SYSTEM_ERROR.getCode(), ErrorCode.SYSTEM_ERROR.getMessage());
+        }
+    }
+
+    @Operation(summary = "管理员查询所有用户动态", description = "管理员查询所有用户的活动日志")
+    @ApiResponses(value = {
+        @ApiResponse(responseCode = "200", description = "成功查询所有动态",
+            content = @Content(mediaType = "application/json",
+                schema = @Schema(implementation = work.baiyun.chronicdiseaseapp.model.vo.UserActivityPageResponse.class))),
+        @ApiResponse(responseCode = "403", description = "无权限访问",
+            content = @Content(mediaType = "application/json",
+                schema = @Schema(implementation = Void.class)))
+    })
+    @PostMapping(path = "/query-all-activities", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
+    public R<?> queryAllActivities(@RequestBody PatientActivityQueryRequest req) {
+        try {
+            Page<UserActivityResponse> page = userActivityLogService.queryAllActivities(req);
+            work.baiyun.chronicdiseaseapp.model.vo.UserActivityPageResponse vo = new work.baiyun.chronicdiseaseapp.model.vo.UserActivityPageResponse();
+            vo.setRecords(page.getRecords());
+            vo.setTotal(page.getTotal());
+            vo.setSize(page.getSize());
+            vo.setCurrent(page.getCurrent());
+            vo.setPages(page.getPages());
+            return R.success(200, "ok", vo);
+        } catch (Exception e) {
+            logger.error("query all activities failed", e);
+            return R.fail(ErrorCode.SYSTEM_ERROR.getCode(), ErrorCode.SYSTEM_ERROR.getMessage());
+        }
+    }
+}
+```
+
+## 8. 权限控制
+
+- **医生权限**:通过 UserBindingService 校验医生与患者的绑定关系,仅允许查询绑定患者的特定动态(健康数据、健康档案、复诊相关)。
+- **管理员权限**:通过角色校验(PermissionGroup.ADMIN),允许查询所有用户的动态。
+- 在 Service 层实现权限逻辑,避免在 Controller 层硬编码。
+
+## 9. 日志记录集成
+
+日志记录应在所有Controller的方法中集成,以记录所有用户的接口调用。使用AOP(面向切面编程)或直接在方法中调用Service来实现。
+
+### 9.1 AOP方式(推荐)
+
+创建Aspect类(UserActivityLogAspect.java)来自动记录所有接口调用:
+
+```java
+package work.baiyun.chronicdiseaseapp.aspect;
+
+import org.aspectj.lang.ProceedingJoinPoint;
+import org.aspectj.lang.annotation.Around;
+import org.aspectj.lang.annotation.Aspect;
+import org.aspectj.lang.annotation.Pointcut;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+import work.baiyun.chronicdiseaseapp.service.UserActivityLogService;
+import work.baiyun.chronicdiseaseapp.enums.ActivityType;
+import work.baiyun.chronicdiseaseapp.enums.RelatedEntityType;
+import work.baiyun.chronicdiseaseapp.util.SecurityUtils;
+
+@Aspect
+@Component
+public class UserActivityLogAspect {
+
+    @Autowired
+    private UserActivityLogService userActivityLogService;
+
+    @Pointcut("execution(* work.baiyun.chronicdiseaseapp.controller.*.*(..)) && !execution(* work.baiyun.chronicdiseaseapp.controller.UserActivityLogController.*(..))")
+    public void controllerMethods() {}
+
+    @Around("controllerMethods()")
+    public Object logActivity(ProceedingJoinPoint joinPoint) throws Throwable {
+        Long userId = SecurityUtils.getCurrentUserId();
+        if (userId == null) {
+            return joinPoint.proceed();
+        }
+
+        // 获取方法信息
+        String methodName = joinPoint.getSignature().getName();
+        String className = joinPoint.getTarget().getClass().getSimpleName();
+        
+        // 映射活动类型(可根据方法名或注解动态确定)
+        ActivityType activityType = mapMethodToActivityType(className, methodName);
+        if (activityType == null) {
+            return joinPoint.proceed();
+        }
+
+        Object result = joinPoint.proceed();
+
+        // 记录日志
+        String description = generateDescription(activityType, joinPoint.getArgs());
+        userActivityLogService.logActivity(userId, activityType, description, 
+            null, null, joinPoint.getArgs(), null, null);
+
+        return result;
+    }
+
+    private ActivityType mapMethodToActivityType(String className, String methodName) {
+        // 根据Controller类名和方法名映射活动类型
+        switch (className) {
+            case "BloodGlucoseDataController":
+                if ("add".equals(methodName)) return ActivityType.BLOOD_GLUCOSE_UPLOAD;
+                break;
+            case "FollowUpController":
+                if ("update".equals(methodName)) return ActivityType.FOLLOW_UP_UPDATE;
+                break;
+            // 添加更多映射
+        }
+        return null; // 不记录或使用通用类型
+    }
+
+    private String generateDescription(ActivityType type, Object[] args) {
+        // 根据活动类型生成描述
+        return type.getDescription();
+    }
+}
+```
+
+### 9.2 手动集成方式
+
+在现有Controller方法中手动添加日志记录调用,例如在FollowUpController的update方法中:
+
+```java
+// 在 FollowUpController.update() 方法中添加
+userActivityLogService.logActivity(currentUserId, ActivityType.FOLLOW_UP_UPDATE, 
+    "医生更新复诊记录: " + req.getStatus(), RelatedEntityType.FOLLOW_UP, id, 
+    req, ipAddress, userAgent);
+```
+
+类似地,在所有Controller方法中添加相应的日志记录调用,包括医生确认复诊、管理员操作等。
+
+在AOP方式中,也可集成JsonUtils处理metadata。
+
+### 9.3 注意事项
+- 日志记录应异步处理(使用@Async),避免阻塞主业务。
+- 对于敏感操作,可在日志中脱敏处理。
+- 确保日志记录不影响接口性能。
+
+## 10. 测试考虑
+
+- **单元测试**:测试 Service 的日志记录和查询逻辑。
+- **集成测试**:测试权限控制和数据分页。
+- **性能测试**:评估日志表查询性能,考虑索引优化。
+
+## 12. JsonUtils 工具类使用
+
+功能中使用 `JsonUtils.java` 工具类处理 `metadata` 字段的JSON序列化/反序列化:
+
+- **序列化**:在记录日志时,将 `metadata` 对象转换为JSON字符串存储到数据库。
+- **反序列化**:在查询动态时,将JSON字符串转换为对象返回给前端。
+
+示例:
+- 存储:`log.setMetadata(JsonUtils.toJson(metadata));`
+- 读取:`resp.setMetadata(JsonUtils.fromJson(log.getMetadata(), Object.class));`
+
+这确保了复杂对象的灵活存储和检索。</content>
+<parameter name="filePath">d:\慢病APP\DevAll\apiserver-springboot\docs\New\用户行为日志与动态查询功能设计文档.md

+ 5 - 0
pom.xml

@@ -46,6 +46,11 @@
             <groupId>org.springframework.boot</groupId>
             <artifactId>spring-boot-starter-web</artifactId>
         </dependency>
+        <!-- AOP 依赖 -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-aop</artifactId>
+        </dependency>
         <!-- 参数校验依赖 -->
         <dependency>
             <groupId>org.springframework.boot</groupId>

+ 223 - 0
src/main/java/work/baiyun/chronicdiseaseapp/aspect/UserActivityLogAspect.java

@@ -0,0 +1,223 @@
+package work.baiyun.chronicdiseaseapp.aspect;
+
+import jakarta.servlet.http.HttpServletRequest;
+import org.aspectj.lang.ProceedingJoinPoint;
+import org.aspectj.lang.annotation.Around;
+import org.aspectj.lang.annotation.Aspect;
+import org.aspectj.lang.annotation.Pointcut;
+import org.aspectj.lang.reflect.MethodSignature;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+import org.springframework.web.context.request.RequestContextHolder;
+import org.springframework.web.context.request.ServletRequestAttributes;
+import work.baiyun.chronicdiseaseapp.enums.ActivityType;
+import work.baiyun.chronicdiseaseapp.enums.RelatedEntityType;
+import work.baiyun.chronicdiseaseapp.service.UserActivityLogService;
+import work.baiyun.chronicdiseaseapp.util.SecurityUtils;
+
+import java.lang.reflect.Method;
+
+/**
+ * 用户活动日志AOP切面
+ * 自动记录所有Controller方法的调用
+ */
+@Aspect
+@Component
+public class UserActivityLogAspect {
+
+    @Autowired
+    private UserActivityLogService userActivityLogService;
+
+    /**
+     * 定义切点:拦截所有Controller方法,但排除登录相关接口
+     */
+    @Pointcut("execution(* work.baiyun.chronicdiseaseapp.controller..*(..)) && !execution(* work.baiyun.chronicdiseaseapp.controller.WeChatController.getOpenid(..))")
+    public void controllerMethods() {}
+
+    /**
+     * 环绕通知:记录用户活动
+     */
+    @Around("controllerMethods()")
+    public Object logActivity(ProceedingJoinPoint joinPoint) throws Throwable {
+        // 获取请求信息
+        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
+        if (attributes == null) {
+            // 非HTTP请求,直接执行
+            return joinPoint.proceed();
+        }
+
+        HttpServletRequest request = attributes.getRequest();
+        String ipAddress = getClientIp(request);
+        String userAgent = request.getHeader("User-Agent");
+
+        // 获取当前用户信息
+        Long userId = null;
+        try {
+            userId = SecurityUtils.getCurrentUserId();
+        } catch (Exception e) {
+            // 用户未登录,不记录活动日志
+            return joinPoint.proceed();
+        }
+        if (userId == null) {
+            // 未登录用户,不记录
+            return joinPoint.proceed();
+        }
+
+        // 获取方法信息
+        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
+        Method method = signature.getMethod();
+        String methodName = method.getName();
+        String className = method.getDeclaringClass().getSimpleName();
+
+        // 推断活动类型
+        ActivityType activityType = inferActivityType(methodName, className);
+
+        // 推断相关实体类型和ID(简化处理,可根据需要扩展)
+        RelatedEntityType relatedEntityType = inferRelatedEntityType(className);
+        Long relatedEntityId = extractEntityId(joinPoint.getArgs());
+
+        // 构建描述
+        String description = buildDescription(className, methodName);
+
+        // 记录活动(异步处理,避免影响业务逻辑)
+        try {
+            userActivityLogService.logActivity(
+                userId,
+                activityType,
+                description,
+                relatedEntityType,
+                relatedEntityId,
+                null, // metadata暂时为空,可根据需要扩展
+                ipAddress,
+                userAgent
+            );
+        } catch (Exception e) {
+            // 记录日志失败不影响业务逻辑
+            System.err.println("记录用户活动日志失败: " + e.getMessage());
+        }
+
+        // 执行原方法
+        return joinPoint.proceed();
+    }
+
+    /**
+     * 获取客户端真实IP
+     */
+    private String getClientIp(HttpServletRequest request) {
+        String ip = request.getHeader("X-Forwarded-For");
+        if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
+            ip = request.getHeader("Proxy-Client-IP");
+        }
+        if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
+            ip = request.getHeader("WL-Proxy-Client-IP");
+        }
+        if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
+            ip = request.getHeader("HTTP_CLIENT_IP");
+        }
+        if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
+            ip = request.getHeader("HTTP_X_FORWARDED_FOR");
+        }
+        if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
+            ip = request.getRemoteAddr();
+        }
+        // 如果是多个IP,取第一个
+        if (ip != null && ip.contains(",")) {
+            ip = ip.split(",")[0].trim();
+        }
+        return ip;
+    }
+
+    /**
+     * 根据方法名和类名推断活动类型
+     */
+    private ActivityType inferActivityType(String methodName, String className) {
+        String combined = (className + "." + methodName).toLowerCase();
+
+        if (combined.contains("bloodglucose") && combined.contains("add")) {
+            return ActivityType.BLOOD_GLUCOSE_UPLOAD;
+        } else if (combined.contains("bloodglucose") && combined.contains("update")) {
+            return ActivityType.BLOOD_GLUCOSE_UPDATE;
+        } else if (combined.contains("bloodglucose") && combined.contains("delete")) {
+            return ActivityType.BLOOD_GLUCOSE_DELETE;
+        } else if (combined.contains("bloodpressure") && combined.contains("add")) {
+            return ActivityType.BLOOD_PRESSURE_UPLOAD;
+        } else if (combined.contains("heartrate") && combined.contains("add")) {
+            return ActivityType.HEART_RATE_UPLOAD;
+        } else if (combined.contains("physical") && combined.contains("add")) {
+            return ActivityType.PHYSICAL_DATA_UPLOAD;
+        } else if (combined.contains("healthrecord") && combined.contains("create")) {
+            return ActivityType.HEALTH_RECORD_CREATE;
+        } else if (combined.contains("healthrecord") && combined.contains("update")) {
+            return ActivityType.HEALTH_RECORD_UPDATE;
+        } else if (combined.contains("medication") && combined.contains("create")) {
+            return ActivityType.MEDICATION_CREATE;
+        } else if (combined.contains("medicine") && combined.contains("create")) {
+            return ActivityType.MEDICINE_CREATE;
+        } else if (combined.contains("followup") && combined.contains("create")) {
+            return ActivityType.FOLLOW_UP_CREATE;
+        } else if (combined.contains("followup") && combined.contains("update")) {
+            return ActivityType.FOLLOW_UP_UPDATE;
+        } else if (combined.contains("followup") && combined.contains("confirm")) {
+            return ActivityType.FOLLOW_UP_CONFIRM;
+        } else if (combined.contains("userbinding") && combined.contains("create")) {
+            return ActivityType.USER_BINDING_CREATE;
+        } else if (combined.contains("avatar") && combined.contains("upload")) {
+            return ActivityType.AVATAR_UPLOAD;
+        } else if (combined.contains("login")) {
+            return ActivityType.USER_LOGIN;
+        } else if (combined.contains("logout")) {
+            return ActivityType.USER_LOGOUT;
+        } else {
+            return ActivityType.OTHER_OPERATION;
+        }
+    }
+
+    /**
+     * 根据类名推断相关实体类型
+     */
+    private RelatedEntityType inferRelatedEntityType(String className) {
+        if (className.toLowerCase().contains("bloodglucose")) {
+            return RelatedEntityType.BLOOD_GLUCOSE;
+        } else if (className.toLowerCase().contains("bloodpressure")) {
+            return RelatedEntityType.BLOOD_PRESSURE;
+        } else if (className.toLowerCase().contains("heartrate")) {
+            return RelatedEntityType.HEART_RATE;
+        } else if (className.toLowerCase().contains("physical")) {
+            return RelatedEntityType.PHYSICAL_DATA;
+        } else if (className.toLowerCase().contains("healthrecord")) {
+            return RelatedEntityType.HEALTH_RECORD;
+        } else if (className.toLowerCase().contains("medication")) {
+            return RelatedEntityType.MEDICATION;
+        } else if (className.toLowerCase().contains("followup")) {
+            return RelatedEntityType.FOLLOW_UP;
+        } else if (className.toLowerCase().contains("binding")) {
+            return RelatedEntityType.USER_BINDING;
+        } else if (className.toLowerCase().contains("avatar")) {
+            return RelatedEntityType.AVATAR;
+        } else if (className.toLowerCase().contains("medicine")) {
+            return RelatedEntityType.MEDICINE;
+        } else {
+            return RelatedEntityType.USER_INFO; // 默认使用用户信息
+        }
+    }
+
+    /**
+     * 从方法参数中提取实体ID(简化实现,可根据实际需求扩展)
+     */
+    private Long extractEntityId(Object[] args) {
+        // 简化:假设第一个Long类型的参数是ID
+        for (Object arg : args) {
+            if (arg instanceof Long) {
+                return (Long) arg;
+            }
+        }
+        return null;
+    }
+
+    /**
+     * 构建活动描述
+     */
+    private String buildDescription(String className, String methodName) {
+        return className + "." + methodName;
+    }
+}

+ 112 - 0
src/main/java/work/baiyun/chronicdiseaseapp/controller/UserActivityLogController.java

@@ -0,0 +1,112 @@
+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.tags.Tag;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.responses.ApiResponses;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.Schema;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.MediaType;
+import org.springframework.web.bind.annotation.*;
+import work.baiyun.chronicdiseaseapp.common.R;
+import work.baiyun.chronicdiseaseapp.model.vo.PatientActivityQueryRequest;
+import work.baiyun.chronicdiseaseapp.model.vo.UserActivityResponse;
+import work.baiyun.chronicdiseaseapp.service.UserActivityLogService;
+import work.baiyun.chronicdiseaseapp.enums.ErrorCode;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@RestController
+@RequestMapping("/user-activity")
+@Tag(name = "用户活动日志", description = "用户行为日志与动态查询相关接口")
+public class UserActivityLogController {
+
+    private static final Logger logger = LoggerFactory.getLogger(UserActivityLogController.class);
+
+    @Autowired
+    private UserActivityLogService userActivityLogService;
+
+    @Operation(summary = "医生查询患者动态", description = "医生查询绑定患者的健康相关动态")
+    @ApiResponses(value = {
+        @ApiResponse(responseCode = "200", description = "成功查询患者动态",
+            content = @Content(mediaType = "application/json",
+                schema = @Schema(implementation = work.baiyun.chronicdiseaseapp.model.vo.UserActivityPageResponse.class))),
+        @ApiResponse(responseCode = "403", description = "无权限访问",
+            content = @Content(mediaType = "application/json",
+                schema = @Schema(implementation = Void.class)))
+    })
+    @PostMapping(path = "/query-patient-activities", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
+    public R<?> queryPatientActivities(@RequestBody PatientActivityQueryRequest req) {
+        try {
+            Page<UserActivityResponse> page = userActivityLogService.queryPatientActivitiesForDoctor(req);
+            work.baiyun.chronicdiseaseapp.model.vo.UserActivityPageResponse vo = new work.baiyun.chronicdiseaseapp.model.vo.UserActivityPageResponse();
+            vo.setRecords(page.getRecords());
+            vo.setTotal(page.getTotal());
+            vo.setSize(page.getSize());
+            vo.setCurrent(page.getCurrent());
+            vo.setPages(page.getPages());
+            return R.success(200, "ok", vo);
+        } catch (Exception e) {
+            logger.error("query patient activities failed", e);
+            return R.fail(ErrorCode.SYSTEM_ERROR.getCode(), ErrorCode.SYSTEM_ERROR.getMessage());
+        }
+    }
+
+    @Operation(summary = "管理员查询所有用户动态", description = "管理员查询所有用户的活动日志")
+    @ApiResponses(value = {
+        @ApiResponse(responseCode = "200", description = "成功查询所有动态",
+            content = @Content(mediaType = "application/json",
+                schema = @Schema(implementation = work.baiyun.chronicdiseaseapp.model.vo.UserActivityPageResponse.class))),
+        @ApiResponse(responseCode = "403", description = "无权限访问",
+            content = @Content(mediaType = "application/json",
+                schema = @Schema(implementation = Void.class)))
+    })
+    @PostMapping(path = "/query-all-activities", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
+    public R<?> queryAllActivities(@RequestBody PatientActivityQueryRequest req) {
+        try {
+            Page<UserActivityResponse> page = userActivityLogService.queryAllActivities(req);
+            work.baiyun.chronicdiseaseapp.model.vo.UserActivityPageResponse vo = new work.baiyun.chronicdiseaseapp.model.vo.UserActivityPageResponse();
+            vo.setRecords(page.getRecords());
+            vo.setTotal(page.getTotal());
+            vo.setSize(page.getSize());
+            vo.setCurrent(page.getCurrent());
+            vo.setPages(page.getPages());
+            return R.success(200, "ok", vo);
+        } catch (Exception e) {
+            logger.error("query all activities failed", e);
+            return R.fail(ErrorCode.SYSTEM_ERROR.getCode(), ErrorCode.SYSTEM_ERROR.getMessage());
+        }
+    }
+
+    @Operation(summary = "医生查询所有绑定患者动态", description = "医生查询所有绑定患者的健康相关动态")
+    @ApiResponses(value = {
+        @ApiResponse(responseCode = "200", description = "成功查询绑定患者动态",
+            content = @Content(mediaType = "application/json",
+                schema = @Schema(implementation = work.baiyun.chronicdiseaseapp.model.vo.UserActivityPageResponse.class))),
+        @ApiResponse(responseCode = "403", description = "无权限访问",
+            content = @Content(mediaType = "application/json",
+                schema = @Schema(implementation = Void.class)))
+    })
+    @PostMapping(path = "/query-bound-patients-activities", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
+    public R<?> queryBoundPatientsActivities(@RequestBody(required = false) PatientActivityQueryRequest req) {
+        try {
+            // 如果请求体为空,创建默认请求对象
+            if (req == null) {
+                req = new PatientActivityQueryRequest();
+            }
+            Page<UserActivityResponse> page = userActivityLogService.queryBoundPatientsActivitiesForDoctor(req);
+            work.baiyun.chronicdiseaseapp.model.vo.UserActivityPageResponse vo = new work.baiyun.chronicdiseaseapp.model.vo.UserActivityPageResponse();
+            vo.setRecords(page.getRecords());
+            vo.setTotal(page.getTotal());
+            vo.setSize(page.getSize());
+            vo.setCurrent(page.getCurrent());
+            vo.setPages(page.getPages());
+            return R.success(200, "ok", vo);
+        } catch (Exception e) {
+            logger.error("query bound patients activities failed", e);
+            return R.fail(ErrorCode.SYSTEM_ERROR.getCode(), ErrorCode.SYSTEM_ERROR.getMessage());
+        }
+    }
+}

+ 99 - 0
src/main/java/work/baiyun/chronicdiseaseapp/enums/ActivityType.java

@@ -0,0 +1,99 @@
+package work.baiyun.chronicdiseaseapp.enums;
+
+import com.baomidou.mybatisplus.annotation.EnumValue;
+
+/**
+ * 用户活动类型枚举(涵盖所有接口调用)
+ */
+public enum ActivityType {
+    // 患者健康数据相关
+    BLOOD_GLUCOSE_UPLOAD("BLOOD_GLUCOSE_UPLOAD", "血糖数据上传"),
+    BLOOD_GLUCOSE_UPDATE("BLOOD_GLUCOSE_UPDATE", "血糖数据更新"),
+    BLOOD_GLUCOSE_DELETE("BLOOD_GLUCOSE_DELETE", "血糖数据删除"),
+    BLOOD_PRESSURE_UPLOAD("BLOOD_PRESSURE_UPLOAD", "血压数据上传"),
+    HEART_RATE_UPLOAD("HEART_RATE_UPLOAD", "心率数据上传"),
+    PHYSICAL_DATA_UPLOAD("PHYSICAL_DATA_UPLOAD", "体格数据上传"),
+
+    // 患者档案相关
+    HEALTH_RECORD_CREATE("HEALTH_RECORD_CREATE", "健康档案创建"),
+    HEALTH_RECORD_UPDATE("HEALTH_RECORD_UPDATE", "健康档案更新"),
+
+    // 用药相关
+    MEDICATION_CREATE("MEDICATION_CREATE", "用药记录创建"),
+    MEDICATION_UPDATE("MEDICATION_UPDATE", "用药记录更新"),
+
+    // 提醒相关
+    REMINDER_SAVE("REMINDER_SAVE", "提醒设置保存"),
+    REMINDER_DELETE("REMINDER_DELETE", "提醒设置删除"),
+
+    // 复诊相关(患者)
+    FOLLOW_UP_CREATE("FOLLOW_UP_CREATE", "复诊请求创建"),
+    FOLLOW_UP_UPDATE("FOLLOW_UP_UPDATE", "复诊记录更新"),
+
+    // 医生复诊操作
+    FOLLOW_UP_CONFIRM("FOLLOW_UP_CONFIRM", "医生确认复诊"),
+    FOLLOW_UP_CANCEL("FOLLOW_UP_CANCEL", "医生取消复诊"),
+    FOLLOW_UP_COMPLETE("FOLLOW_UP_COMPLETE", "医生完成复诊"),
+
+    // 用户绑定相关
+    USER_BINDING_CREATE("USER_BINDING_CREATE", "用户绑定创建"),
+    USER_BINDING_DELETE("USER_BINDING_DELETE", "用户绑定删除"),
+
+    // 头像相关
+    AVATAR_UPLOAD("AVATAR_UPLOAD", "头像上传"),
+
+    // 用户认证相关
+    USER_LOGIN("USER_LOGIN", "用户登录"),
+    USER_LOGOUT("USER_LOGOUT", "用户登出"),
+
+    // 其他操作
+    OTHER_OPERATION("OTHER_OPERATION", "其他操作"),
+
+    // 地理位置相关
+    GEO_QUERY("GEO_QUERY", "地理位置查询"),
+
+    // 药品管理相关(管理员/医生)
+    MEDICINE_CREATE("MEDICINE_CREATE", "药品信息创建"),
+    MEDICINE_UPDATE("MEDICINE_UPDATE", "药品信息更新"),
+    MEDICINE_LIST("MEDICINE_LIST", "药品信息查询"),
+
+    // 微信相关
+    WECHAT_GET_OPENID("WECHAT_GET_OPENID", "获取微信openid"),
+
+    // 其他通用操作(可扩展)
+    DATA_QUERY("DATA_QUERY", "数据查询"),
+    DATA_UPDATE("DATA_UPDATE", "数据更新"),
+    DATA_DELETE("DATA_DELETE", "数据删除");
+
+    @EnumValue
+    private final String code;
+    private final String description;
+
+    ActivityType(String code, String description) {
+        this.code = code;
+        this.description = description;
+    }
+
+    public String getCode() {
+        return code;
+    }
+
+    public String getDescription() {
+        return description;
+    }
+
+    @Override
+    public String toString() {
+        return description;
+    }
+
+    public static ActivityType fromCode(String code) {
+        if (code == null) return null;
+        for (ActivityType type : ActivityType.values()) {
+            if (type.code.equals(code)) return type;
+        }
+        throw new work.baiyun.chronicdiseaseapp.exception.CustomException(
+            work.baiyun.chronicdiseaseapp.enums.ErrorCode.PARAMETER_ERROR.getCode(),
+            "Unknown ActivityType code: " + code);
+    }
+}

+ 53 - 0
src/main/java/work/baiyun/chronicdiseaseapp/enums/RelatedEntityType.java

@@ -0,0 +1,53 @@
+package work.baiyun.chronicdiseaseapp.enums;
+
+import com.baomidou.mybatisplus.annotation.EnumValue;
+
+/**
+ * 相关实体类型枚举
+ */
+public enum RelatedEntityType {
+    BLOOD_GLUCOSE("BLOOD_GLUCOSE", "血糖数据"),
+    BLOOD_PRESSURE("BLOOD_PRESSURE", "血压数据"),
+    HEART_RATE("HEART_RATE", "心率数据"),
+    PHYSICAL_DATA("PHYSICAL_DATA", "体格数据"),
+    HEALTH_RECORD("HEALTH_RECORD", "健康档案"),
+    MEDICATION("MEDICATION", "用药记录"),
+    REMINDER("REMINDER", "提醒设置"),
+    FOLLOW_UP("FOLLOW_UP", "复诊记录"),
+    USER_BINDING("USER_BINDING", "用户绑定"),
+    AVATAR("AVATAR", "头像"),
+    MEDICINE("MEDICINE", "药品信息"),
+    USER_INFO("USER_INFO", "用户信息");
+
+    @EnumValue
+    private final String code;
+    private final String description;
+
+    RelatedEntityType(String code, String description) {
+        this.code = code;
+        this.description = description;
+    }
+
+    public String getCode() {
+        return code;
+    }
+
+    public String getDescription() {
+        return description;
+    }
+
+    @Override
+    public String toString() {
+        return description;
+    }
+
+    public static RelatedEntityType fromCode(String code) {
+        if (code == null) return null;
+        for (RelatedEntityType type : RelatedEntityType.values()) {
+            if (type.code.equals(code)) return type;
+        }
+        throw new work.baiyun.chronicdiseaseapp.exception.CustomException(
+            work.baiyun.chronicdiseaseapp.enums.ErrorCode.PARAMETER_ERROR.getCode(),
+            "Unknown RelatedEntityType code: " + code);
+    }
+}

+ 41 - 0
src/main/java/work/baiyun/chronicdiseaseapp/handler/ActivityTypeTypeHandler.java

@@ -0,0 +1,41 @@
+package work.baiyun.chronicdiseaseapp.handler;
+
+import org.apache.ibatis.type.BaseTypeHandler;
+import org.apache.ibatis.type.JdbcType;
+import org.apache.ibatis.type.MappedJdbcTypes;
+import org.apache.ibatis.type.MappedTypes;
+import work.baiyun.chronicdiseaseapp.enums.ActivityType;
+
+import java.sql.CallableStatement;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+
+@MappedJdbcTypes(JdbcType.VARCHAR)
+@MappedTypes(ActivityType.class)
+public class ActivityTypeTypeHandler extends BaseTypeHandler<ActivityType> {
+
+    @Override
+    public void setNonNullParameter(PreparedStatement ps, int i, ActivityType parameter, JdbcType jdbcType)
+            throws SQLException {
+        ps.setString(i, parameter.getCode());
+    }
+
+    @Override
+    public ActivityType getNullableResult(ResultSet rs, String columnName) throws SQLException {
+        String code = rs.getString(columnName);
+        return code == null ? null : ActivityType.fromCode(code);
+    }
+
+    @Override
+    public ActivityType getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
+        String code = rs.getString(columnIndex);
+        return code == null ? null : ActivityType.fromCode(code);
+    }
+
+    @Override
+    public ActivityType getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
+        String code = cs.getString(columnIndex);
+        return code == null ? null : ActivityType.fromCode(code);
+    }
+}

+ 41 - 0
src/main/java/work/baiyun/chronicdiseaseapp/handler/RelatedEntityTypeTypeHandler.java

@@ -0,0 +1,41 @@
+package work.baiyun.chronicdiseaseapp.handler;
+
+import org.apache.ibatis.type.BaseTypeHandler;
+import org.apache.ibatis.type.JdbcType;
+import org.apache.ibatis.type.MappedJdbcTypes;
+import org.apache.ibatis.type.MappedTypes;
+import work.baiyun.chronicdiseaseapp.enums.RelatedEntityType;
+
+import java.sql.CallableStatement;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+
+@MappedJdbcTypes(JdbcType.VARCHAR)
+@MappedTypes(RelatedEntityType.class)
+public class RelatedEntityTypeTypeHandler extends BaseTypeHandler<RelatedEntityType> {
+
+    @Override
+    public void setNonNullParameter(PreparedStatement ps, int i, RelatedEntityType parameter, JdbcType jdbcType)
+            throws SQLException {
+        ps.setString(i, parameter.getCode());
+    }
+
+    @Override
+    public RelatedEntityType getNullableResult(ResultSet rs, String columnName) throws SQLException {
+        String code = rs.getString(columnName);
+        return code == null ? null : RelatedEntityType.fromCode(code);
+    }
+
+    @Override
+    public RelatedEntityType getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
+        String code = rs.getString(columnIndex);
+        return code == null ? null : RelatedEntityType.fromCode(code);
+    }
+
+    @Override
+    public RelatedEntityType getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
+        String code = cs.getString(columnIndex);
+        return code == null ? null : RelatedEntityType.fromCode(code);
+    }
+}

+ 10 - 0
src/main/java/work/baiyun/chronicdiseaseapp/mapper/UserActivityLogMapper.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.UserActivityLog;
+
+@Mapper
+public interface UserActivityLogMapper extends BaseMapper<UserActivityLog> {
+
+}

+ 49 - 0
src/main/java/work/baiyun/chronicdiseaseapp/model/po/UserActivityLog.java

@@ -0,0 +1,49 @@
+package work.baiyun.chronicdiseaseapp.model.po;
+
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableName;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import work.baiyun.chronicdiseaseapp.enums.ActivityType;
+import work.baiyun.chronicdiseaseapp.enums.RelatedEntityType;
+
+import java.time.LocalDateTime;
+
+@Schema(description = "用户行为日志表")
+@TableName("t_user_activity_log")
+@Data
+@EqualsAndHashCode(callSuper = false)
+public class UserActivityLog extends BaseEntity {
+    @Schema(description = "操作用户ID")
+    @TableField("user_id")
+    private Long userId;
+
+    @Schema(description = "活动类型")
+    @TableField(value = "activity_type", typeHandler = work.baiyun.chronicdiseaseapp.handler.ActivityTypeTypeHandler.class)
+    private ActivityType activityType;
+
+    @Schema(description = "活动描述")
+    @TableField("activity_description")
+    private String activityDescription;
+
+    @Schema(description = "相关实体类型")
+    @TableField(value = "related_entity_type", typeHandler = work.baiyun.chronicdiseaseapp.handler.RelatedEntityTypeTypeHandler.class)
+    private RelatedEntityType relatedEntityType;
+
+    @Schema(description = "相关实体ID")
+    @TableField("related_entity_id")
+    private Long relatedEntityId;
+
+    @Schema(description = "元数据(JSON格式)")
+    @TableField("metadata")
+    private String metadata; // 使用 String 存储 JSON
+
+    @Schema(description = "操作IP地址")
+    @TableField("ip_address")
+    private String ipAddress;
+
+    @Schema(description = "用户代理字符串")
+    @TableField("user_agent")
+    private String userAgent;
+}

+ 27 - 0
src/main/java/work/baiyun/chronicdiseaseapp/model/vo/PatientActivityQueryRequest.java

@@ -0,0 +1,27 @@
+package work.baiyun.chronicdiseaseapp.model.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import work.baiyun.chronicdiseaseapp.enums.ActivityType;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+@Schema(description = "患者动态查询请求")
+@Data
+public class PatientActivityQueryRequest extends BaseQueryRequest {
+    @Schema(description = "患者用户ID(医生查询时必填)")
+    private Long patientUserId;
+
+    @Schema(description = "用户ID(管理员查询时可选,用于过滤特定用户)")
+    private Long userId;
+
+    @Schema(description = "活动类型列表(可选,用于过滤特定动态)")
+    private List<ActivityType> activityTypes;
+
+    @Schema(description = "开始时间")
+    private LocalDateTime startTime;
+
+    @Schema(description = "结束时间")
+    private LocalDateTime endTime;
+}

+ 24 - 0
src/main/java/work/baiyun/chronicdiseaseapp/model/vo/UserActivityPageResponse.java

@@ -0,0 +1,24 @@
+package work.baiyun.chronicdiseaseapp.model.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import java.util.List;
+
+@Schema(description = "用户活动分页响应")
+@Data
+public class UserActivityPageResponse {
+    @Schema(description = "数据列表")
+    private List<UserActivityResponse> records;
+
+    @Schema(description = "总数")
+    private long total;
+
+    @Schema(description = "每页大小")
+    private long size;
+
+    @Schema(description = "当前页码")
+    private long current;
+
+    @Schema(description = "总页数")
+    private long pages;
+}

+ 36 - 0
src/main/java/work/baiyun/chronicdiseaseapp/model/vo/UserActivityResponse.java

@@ -0,0 +1,36 @@
+package work.baiyun.chronicdiseaseapp.model.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import work.baiyun.chronicdiseaseapp.enums.ActivityType;
+import work.baiyun.chronicdiseaseapp.enums.RelatedEntityType;
+
+import java.time.LocalDateTime;
+
+@Schema(description = "用户活动响应")
+@Data
+public class UserActivityResponse {
+    @Schema(description = "活动ID")
+    private String id;
+
+    @Schema(description = "操作用户ID")
+    private Long userId;
+
+    @Schema(description = "活动类型")
+    private ActivityType activityType;
+
+    @Schema(description = "活动描述")
+    private String activityDescription;
+
+    @Schema(description = "相关实体类型")
+    private RelatedEntityType relatedEntityType;
+
+    @Schema(description = "相关实体ID")
+    private Long relatedEntityId;
+
+    @Schema(description = "元数据")
+    private Object metadata; // 反序列化为对象
+
+    @Schema(description = "操作时间")
+    private LocalDateTime createTime;
+}

+ 34 - 0
src/main/java/work/baiyun/chronicdiseaseapp/service/UserActivityLogService.java

@@ -0,0 +1,34 @@
+package work.baiyun.chronicdiseaseapp.service;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import work.baiyun.chronicdiseaseapp.enums.ActivityType;
+import work.baiyun.chronicdiseaseapp.enums.RelatedEntityType;
+import work.baiyun.chronicdiseaseapp.model.vo.PatientActivityQueryRequest;
+import work.baiyun.chronicdiseaseapp.model.vo.UserActivityResponse;
+
+/**
+ * 用户活动日志服务接口
+ */
+public interface UserActivityLogService {
+    /**
+     * 记录用户活动
+     */
+    void logActivity(Long userId, ActivityType activityType, String description,
+                    RelatedEntityType relatedEntityType, Long relatedEntityId,
+                    Object metadata, String ipAddress, String userAgent);
+
+    /**
+     * 医生查询绑定患者的动态(仅限特定类型)
+     */
+    Page<UserActivityResponse> queryPatientActivitiesForDoctor(PatientActivityQueryRequest request);
+
+    /**
+     * 管理员查询所有用户动态
+     */
+    Page<UserActivityResponse> queryAllActivities(PatientActivityQueryRequest request);
+
+    /**
+     * 医生查询所有绑定患者的动态
+     */
+    Page<UserActivityResponse> queryBoundPatientsActivitiesForDoctor(PatientActivityQueryRequest request);
+}

+ 5 - 0
src/main/java/work/baiyun/chronicdiseaseapp/service/UserBindingService.java

@@ -32,4 +32,9 @@ public interface UserBindingService {
      * 检查绑定关系是否存在
      */
     CheckUserBindingResponse checkUserBinding(CheckUserBindingRequest request);
+
+    /**
+     * 获取医生绑定的所有患者ID列表
+     */
+    java.util.List<Long> getBoundPatientIds(Long doctorUserId);
 }

+ 226 - 0
src/main/java/work/baiyun/chronicdiseaseapp/service/impl/UserActivityLogServiceImpl.java

@@ -0,0 +1,226 @@
+package work.baiyun.chronicdiseaseapp.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import work.baiyun.chronicdiseaseapp.mapper.UserActivityLogMapper;
+import work.baiyun.chronicdiseaseapp.model.po.UserActivityLog;
+import work.baiyun.chronicdiseaseapp.model.vo.PatientActivityQueryRequest;
+import work.baiyun.chronicdiseaseapp.model.vo.UserActivityResponse;
+import work.baiyun.chronicdiseaseapp.model.vo.CheckUserBindingRequest;
+import work.baiyun.chronicdiseaseapp.model.vo.CheckUserBindingResponse;
+import work.baiyun.chronicdiseaseapp.service.UserActivityLogService;
+import work.baiyun.chronicdiseaseapp.service.UserBindingService;
+import work.baiyun.chronicdiseaseapp.enums.ActivityType;
+import work.baiyun.chronicdiseaseapp.enums.RelatedEntityType;
+import work.baiyun.chronicdiseaseapp.enums.PermissionGroup;
+import work.baiyun.chronicdiseaseapp.util.JsonUtils;
+import work.baiyun.chronicdiseaseapp.util.SecurityUtils;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+@Service
+public class UserActivityLogServiceImpl implements UserActivityLogService {
+
+    @Autowired
+    private UserActivityLogMapper userActivityLogMapper;
+
+    @Autowired
+    private UserBindingService userBindingService;
+
+    @Override
+    public void logActivity(Long userId, ActivityType activityType, String description,
+                            RelatedEntityType relatedEntityType, Long relatedEntityId,
+                            Object metadata, String ipAddress, String userAgent) {
+        UserActivityLog log = new UserActivityLog();
+        log.setUserId(userId);
+        log.setActivityType(activityType);
+        log.setActivityDescription(description);
+        log.setRelatedEntityType(relatedEntityType);
+        log.setRelatedEntityId(relatedEntityId);
+        // 使用 JsonUtils 序列化 metadata 为 JSON 字符串
+        log.setMetadata(JsonUtils.toJson(metadata));
+        log.setIpAddress(ipAddress);
+        log.setUserAgent(userAgent);
+
+        userActivityLogMapper.insert(log);
+    }
+
+    @Override
+    public Page<UserActivityResponse> queryPatientActivitiesForDoctor(PatientActivityQueryRequest request) {
+        Long currentUserId = SecurityUtils.getCurrentUserId();
+        PermissionGroup role = SecurityUtils.getCurrentUserRole();
+
+        // 权限校验:医生需绑定患者
+        if (!PermissionGroup.DOCTOR.equals(role)) {
+            throw new RuntimeException("无权限");
+        }
+
+        // 校验绑定关系
+        CheckUserBindingRequest checkRequest = new CheckUserBindingRequest();
+        checkRequest.setPatientUserId(request.getPatientUserId());
+        checkRequest.setBoundUserId(currentUserId);
+        CheckUserBindingResponse checkResponse = userBindingService.checkUserBinding(checkRequest);
+        if (!checkResponse.getExists()) {
+            throw new RuntimeException("未绑定该患者");
+        }
+
+        // 过滤特定活动类型(患者健康数据、健康档案、复诊)
+        List<ActivityType> allowedTypes = List.of(
+            ActivityType.BLOOD_GLUCOSE_UPLOAD, ActivityType.BLOOD_GLUCOSE_UPDATE,
+            ActivityType.BLOOD_GLUCOSE_DELETE, ActivityType.BLOOD_PRESSURE_UPLOAD,
+            ActivityType.HEART_RATE_UPLOAD, ActivityType.PHYSICAL_DATA_UPLOAD,
+            ActivityType.HEALTH_RECORD_CREATE, ActivityType.HEALTH_RECORD_UPDATE,
+            ActivityType.FOLLOW_UP_CREATE, ActivityType.FOLLOW_UP_UPDATE,
+            ActivityType.FOLLOW_UP_CONFIRM
+        );
+
+        // 如果前端指定了活动类型,则在允许的类型中进一步过滤
+        List<ActivityType> queryTypes = allowedTypes;
+        if (request.getActivityTypes() != null && !request.getActivityTypes().isEmpty()) {
+            queryTypes = request.getActivityTypes().stream()
+                .filter(allowedTypes::contains)
+                .collect(Collectors.toList());
+            // 如果过滤后没有匹配的类型,返回空结果
+            if (queryTypes.isEmpty()) {
+                return new Page<>(request.getPageNum(), request.getPageSize());
+            }
+        }
+
+        QueryWrapper<UserActivityLog> wrapper = new QueryWrapper<>();
+        wrapper.eq("user_id", request.getPatientUserId())
+               .in("activity_type", queryTypes)
+               .orderByDesc("create_time");
+
+        Page<UserActivityLog> page = userActivityLogMapper.selectPage(
+            new Page<>(request.getPageNum(), request.getPageSize()), wrapper);
+
+        // 转换为响应VO,使用 JsonUtils 反序列化 metadata
+        List<UserActivityResponse> responses = page.getRecords().stream().map(log -> {
+            UserActivityResponse resp = new UserActivityResponse();
+            resp.setId(String.valueOf(log.getId()));
+            resp.setUserId(log.getUserId());
+            resp.setActivityType(log.getActivityType());
+            resp.setActivityDescription(log.getActivityDescription());
+            resp.setRelatedEntityType(log.getRelatedEntityType());
+            resp.setRelatedEntityId(log.getRelatedEntityId());
+            // 使用 JsonUtils 反序列化 metadata
+            resp.setMetadata(JsonUtils.fromJson(log.getMetadata(), Object.class));
+            resp.setCreateTime(log.getCreateTime());
+            return resp;
+        }).collect(Collectors.toList());
+
+        Page<UserActivityResponse> resultPage = new Page<>();
+        resultPage.setRecords(responses);
+        resultPage.setTotal(page.getTotal());
+        return resultPage;
+    }
+
+    @Override
+    public Page<UserActivityResponse> queryAllActivities(PatientActivityQueryRequest request) {
+        PermissionGroup role = SecurityUtils.getCurrentUserRole();
+
+        // 权限校验:仅管理员
+        if (!PermissionGroup.SYS_ADMIN.equals(role)) {
+            throw new RuntimeException("无权限");
+        }
+
+        QueryWrapper<UserActivityLog> wrapper = new QueryWrapper<>();
+        if (request.getUserId() != null) {
+            wrapper.eq("user_id", request.getUserId());
+        }
+        wrapper.orderByDesc("create_time");
+
+        Page<UserActivityLog> page = userActivityLogMapper.selectPage(
+            new Page<>(request.getPageNum(), request.getPageSize()), wrapper);
+
+        // 转换为响应VO
+        List<UserActivityResponse> responses = page.getRecords().stream().map(log -> {
+            UserActivityResponse resp = new UserActivityResponse();
+            resp.setId(String.valueOf(log.getId()));
+            resp.setUserId(log.getUserId());
+            resp.setActivityType(log.getActivityType());
+            resp.setActivityDescription(log.getActivityDescription());
+            resp.setRelatedEntityType(log.getRelatedEntityType());
+            resp.setRelatedEntityId(log.getRelatedEntityId());
+            resp.setMetadata(JsonUtils.fromJson(log.getMetadata(), Object.class));
+            resp.setCreateTime(log.getCreateTime());
+            return resp;
+        }).collect(Collectors.toList());
+
+        Page<UserActivityResponse> resultPage = new Page<>();
+        resultPage.setRecords(responses);
+        resultPage.setTotal(page.getTotal());
+        return resultPage;
+    }
+
+    @Override
+    public Page<UserActivityResponse> queryBoundPatientsActivitiesForDoctor(PatientActivityQueryRequest request) {
+        Long currentUserId = SecurityUtils.getCurrentUserId();
+        PermissionGroup role = SecurityUtils.getCurrentUserRole();
+
+        // 权限校验:仅医生
+        if (!PermissionGroup.DOCTOR.equals(role)) {
+            throw new RuntimeException("无权限");
+        }
+
+        // 获取医生绑定的所有患者ID
+        List<Long> boundPatientIds = userBindingService.getBoundPatientIds(currentUserId);
+        if (boundPatientIds.isEmpty()) {
+            // 如果没有绑定患者,返回空结果
+            return new Page<>(request.getPageNum(), request.getPageSize());
+        }
+
+        // 过滤特定活动类型(患者健康数据、健康档案、复诊)
+        List<ActivityType> allowedTypes = List.of(
+            ActivityType.BLOOD_GLUCOSE_UPLOAD, ActivityType.BLOOD_GLUCOSE_UPDATE,
+            ActivityType.BLOOD_GLUCOSE_DELETE, ActivityType.BLOOD_PRESSURE_UPLOAD,
+            ActivityType.HEART_RATE_UPLOAD, ActivityType.PHYSICAL_DATA_UPLOAD,
+            ActivityType.HEALTH_RECORD_CREATE, ActivityType.HEALTH_RECORD_UPDATE,
+            ActivityType.FOLLOW_UP_CREATE, ActivityType.FOLLOW_UP_UPDATE,
+            ActivityType.FOLLOW_UP_CONFIRM
+        );
+
+        // 如果前端指定了活动类型,则在允许的类型中进一步过滤
+        List<ActivityType> queryTypes = allowedTypes;
+        if (request.getActivityTypes() != null && !request.getActivityTypes().isEmpty()) {
+            queryTypes = request.getActivityTypes().stream()
+                .filter(allowedTypes::contains)
+                .collect(Collectors.toList());
+            // 如果过滤后没有匹配的类型,返回空结果
+            if (queryTypes.isEmpty()) {
+                return new Page<>(request.getPageNum(), request.getPageSize());
+            }
+        }
+
+        QueryWrapper<UserActivityLog> wrapper = new QueryWrapper<>();
+        wrapper.in("user_id", boundPatientIds)
+               .in("activity_type", queryTypes)
+               .orderByDesc("create_time");
+
+        Page<UserActivityLog> page = userActivityLogMapper.selectPage(
+            new Page<>(request.getPageNum(), request.getPageSize()), wrapper);
+
+        // 转换为响应VO,使用 JsonUtils 反序列化 metadata
+        List<UserActivityResponse> responses = page.getRecords().stream().map(log -> {
+            UserActivityResponse resp = new UserActivityResponse();
+            resp.setId(String.valueOf(log.getId()));
+            resp.setUserId(log.getUserId());
+            resp.setActivityType(log.getActivityType());
+            resp.setActivityDescription(log.getActivityDescription());
+            resp.setRelatedEntityType(log.getRelatedEntityType());
+            resp.setRelatedEntityId(log.getRelatedEntityId());
+            // 使用 JsonUtils 反序列化 metadata
+            resp.setMetadata(JsonUtils.fromJson(log.getMetadata(), Object.class));
+            resp.setCreateTime(log.getCreateTime());
+            return resp;
+        }).collect(Collectors.toList());
+
+        Page<UserActivityResponse> resultPage = new Page<>();
+        resultPage.setRecords(responses);
+        resultPage.setTotal(page.getTotal());
+        return resultPage;
+    }
+}

+ 11 - 0
src/main/java/work/baiyun/chronicdiseaseapp/service/impl/UserBindingServiceImpl.java

@@ -207,4 +207,15 @@ public class UserBindingServiceImpl implements UserBindingService {
         }
         return response;
     }
+
+    @Override
+    public List<Long> getBoundPatientIds(Long doctorUserId) {
+        QueryWrapper<UserBinding> queryWrapper = new QueryWrapper<>();
+        queryWrapper.eq("bound_user_id", doctorUserId)
+                .eq("status", 1); // 只查询有效的绑定关系
+        List<UserBinding> bindings = userBindingMapper.selectList(queryWrapper);
+        return bindings.stream()
+                .map(UserBinding::getPatientUserId)
+                .collect(Collectors.toList());
+    }
 }