问题发现

在做功能自查时发现:调用健康陪诊、健康干预、家属辅诊这三个 Agent 的 DELETE /sessions/{sessionId} 接口,返回 200 但会话并没有真正被删除。再次调用 GET /sessions 列表,数据还在。

问题定位

跟踪 ChatSessionServiceImpl.deleteSession() 方法:

public void deleteSession(String sessionId) {
    Path file = findSessionFile(sessionId);
    if (file != null && Files.exists(file)) {
        Files.delete(file);
    }
}

逻辑本身没问题,问题在 findSessionFile:

private Path findSessionFile(String sessionId) {
    String[] subDirs = {"medication-safety", "companion"};
    for (String sub : subDirs) {
        Path file = Path.of(storageDir, sub, sessionId + ".json");
        if (Files.exists(file)) {
            return file;
        }
    }
    return null;  // 找不到就返回null,deleteSession静默跳过
}

根因:这个方法是最早写的,当时只有 2 个 Agent。后来新增了 health-companion、health-intervention、family-assist 三个 Agent,但忘了更新这个目录列表。

同样的 bug 也影响了 getSession()——如果前端传了一个属于健康陪诊的 sessionId,这个方法也找不到对应文件,会抛"会话不存在"异常。但因为实际使用中用户很少跨 Agent 传 sessionId,所以 getSession 的问题没被先发现。

决策:修补还是重构

方案 A:修补 —— 把 subDirs 数组补全为 5 个目录。

  • 优点:改动最小,1 行代码
  • 缺点:下次新增 Agent 还会忘;文件存储本身有其他问题

方案 B:重构 —— 迁移到数据库存储。

  • 优点:彻底消除按目录查找的模式;支持按用户筛选、分页;部署不依赖磁盘
  • 缺点:改动面较大,涉及实体类、Service、各 Agent 调用处

选择了方案 B。理由:这个项目的其他数据(用药计划、健康数据、提醒记录)全部存在 PostgreSQL 中,对话记录单独用文件存储本身就是技术债。趁这次修 bug 一并还掉。

实现方案

表结构

-- 会话表
CREATE TABLE chat_session (
    id BIGSERIAL PRIMARY KEY,
    session_id VARCHAR(64) NOT NULL UNIQUE,  -- 前端使用的标识
    agent_type VARCHAR(32) NOT NULL,          -- 5种Agent类型
    mode VARCHAR(32),                         -- 子模式
    title VARCHAR(200),                       -- 首条消息摘要
    elderly_id BIGINT,                        -- 归属哪个老人
    family_id BIGINT,                         -- 归属哪个家属
    created_at TIMESTAMP DEFAULT now(),
    updated_at TIMESTAMP DEFAULT now()
);

-- 消息表
CREATE TABLE chat_message (
    id BIGSERIAL PRIMARY KEY,
    session_id VARCHAR(64) NOT NULL,
    role VARCHAR(16) NOT NULL,       -- user 或 assistant
    content TEXT NOT NULL,
    created_at TIMESTAMP DEFAULT now(),
    CONSTRAINT fk_msg_session FOREIGN KEY (session_id)
        REFERENCES chat_session(session_id) ON DELETE CASCADE
);

ON DELETE CASCADE 是这次改造的核心——删除会话时消息自动级联删除,不需要应用层操心一致性。

实体类改造

两个实体类加上 MyBatis-Plus 注解。关键变化:

ChatSession:

  • 新增数据库主键 id
  • 新增 elderlyId、familyId,为后续按用户查询铺路
  • messages 标记 @TableField(exist = false)——这是个内存字段,不映射到数据库列

ChatMessage:

  • 新增数据库主键 id
  • 新增 sessionId 做关联
  • timestamp 改名 createdAt,与全项目命名统一

Service 重写

原来的 ChatSessionServiceImpl:

  • 依赖 ObjectMapper 做 JSON 序列化/反序列化
  • 依赖 java.nio.file 做文件读写
  • @PostConstruct 里创建目录
  • findSessionFile 遍历硬编码目录

新的实现:

  • 依赖 ChatSessionMapper + ChatMessageMapper
  • 无状态,不需要初始化
  • 所有操作通过 LambdaQueryWrapper 精确定位

核心改动——deleteSession:

// 旧:遍历目录找文件,找不到就静默跳过
Path file = findSessionFile(sessionId);
if (file != null && Files.exists(file)) {
    Files.delete(file);
}

// 新:直接按session_id删除,不存在也不报错
chatMessageMapper.delete(
    new LambdaQueryWrapper<ChatMessage>().eq(ChatMessage::getSessionId, sessionId)
);
chatSessionMapper.delete(
    new LambdaQueryWrapper<ChatSession>().eq(ChatSession::getSessionId, sessionId)
);

核心改动——saveSession(增量保存):

原来是每次把整个 session(含所有历史消息)全量序列化写文件。现在改为只插入新增的消息:

long existingCount = chatMessageMapper.selectCount(
    new LambdaQueryWrapper<ChatMessage>().eq(ChatMessage::getSessionId, session.getSessionId())
);
List<ChatMessage> newMessages = allMessages.subList((int) existingCount, allMessages.size());
for (ChatMessage msg : newMessages) {
    msg.setSessionId(session.getSessionId());
    chatMessageMapper.insert(msg);
}

这样一次对话只写 2 条 INSERT(user 消息 + assistant 消息),而不是把整个对话历史重写一遍。

接口签名变更

ChatSessionService 接口从:

ChatSession createSession(String agentType, String mode);

改为:

ChatSession createSession(String agentType, String mode, Long elderlyId, Long familyId);

5 个 Agent 的 resolveSession 方法对应修改,把请求中的 elderlyId(家属辅诊还有 familyId)传进去。这样每个会话都记录了归属用户,后续可以实现"我的对话历史"功能。

影响面分析

本次改动涉及的文件:

文件改动类型说明
sql/chat_session_tables.sql新建建表脚本
entity/ChatSession.java重写加注解、加字段
entity/ChatMessage.java重写加注解、加字段
mapper/ChatSessionMapper.java新建MyBatis-Plus Mapper
mapper/ChatMessageMapper.java新建MyBatis-Plus Mapper
service/ChatSessionService.java修改接口签名变更
service/impl/ChatSessionServiceImpl.java重写文件→数据库
5 个 Agent 的 ServiceImpl修改createSession 调用处
application.yml修改删除 storage-dir 配置

Controller 层完全不需要改动——接口签名和返回结构没变,前端无感知。

验证清单

  • [ ] 执行 SQL 建表
  • [ ] 每个 Agent 创建新对话 → 检查 chat_session 表有记录
  • [ ] 发送消息 → 检查 chat_message 表有 user + assistant 两条记录
  • [ ] 多轮对话 → 消息按时间顺序正确排列
  • [ ] 删除会话 → chat_session 和 chat_message 均被清除
  • [ ] 获取会话列表 → 只返回对应 agent_type 的会话
  • [ ] 获取会话详情 → 包含完整消息历史

经验总结

  1. 硬编码枚举是定时炸弹。subDirs 数组没有和 Agent 注册逻辑关联,新增 Agent 时没有任何编译期或运行期提示需要更新它。
  2. 静默失败比报错更危险。findSessionFile 返回 null 后,deleteSession 直接跳过不报错,用户以为删除成功了。如果当初返回 null 时抛异常,这个 bug 会更早被发现。
  3. 存储方案要和项目其他部分保持一致。项目的其他数据都在 PostgreSQL,对话记录单独用文件是为了"快速实现",但最终还是要还债。
  4. 迁移时顺手把接口签名改对。既然要动 Service 层,顺便把 elderlyId 传进去,比之后再做二次改动成本低。

更多推荐