用户行为日志与动态查询功能设计文档.md 36 KB

用户行为日志与动态查询功能设计文档

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)。
  • 公共字段(versioncreate_usercreate_timeupdate_userupdate_time)由 BaseEntityCustomMetaObjectHandler 自动填充。
  • 添加索引:user_idactivity_typecreate_timerelated_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)

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)

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)。
  • 公共字段(versioncreate_usercreate_timeupdate_userupdate_time)由 BaseEntityCustomMetaObjectHandler 自动填充。
  • 添加索引:user_idactivity_typecreate_timerelated_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)

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)

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)

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)

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)

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 接口

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序列化/反序列化。

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

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)来自动记录所有接口调用:

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方法中:

// 在 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));

这确保了复杂对象的灵活存储和检索。

d:\慢病APP\DevAll\apiserver-springboot\docs\New\用户行为日志与动态查询功能设计文档.md