问题发现

在做功能自查时发现:调用健康陪诊、健康干预、家属辅诊这三个 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-companionhealth-interventionfamily-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
  • 新增 elderlyIdfamilyId,为后续按用户查询铺路
  • 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 传进去,比之后再做二次改动成本低。
Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐