From d2003d093e93ab0a97df4fc6fd88ea21c90b5c0f Mon Sep 17 00:00:00 2001 From: longsizhuo Date: Fri, 17 Apr 2026 21:13:22 +0000 Subject: [PATCH] =?UTF-8?q?feat(chat):=20/api/chat/sessions/save=20?= =?UTF-8?q?=E6=9B=BF=E4=BB=A3=E5=89=8D=E7=AB=AF=20Prisma=20=E7=9B=B4?= =?UTF-8?q?=E8=BF=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景:2026-04-17 把 Neon 切到自建 Docker PG 后,前端 Next.js 的 Prisma 还 指向 Neon,形成"前端写 Neon 旧库、后端读自建 PG"的脏数据分叉。方案 A: 前端 onFinish 不再直接写 DB,改调后端 API 由后端统一持久化。 - chat/repository/ChatHistoryRepository + JdbcChatHistoryRepository:@Transactional 原子写 chat 表 + user 消息 + assistant 消息;chat 用 ON CONFLICT upsert, 匿名/登录混用同 chatId 时 COALESCE 保留已有 userId,避免被 NULL 覆盖 - chat/controller/ChatHistoryController:POST /api/chat/sessions/save,匿名也 放行(SaTokenConfigure 加 notMatch),登录时自动从 sa-token 取 userId - chat/dto/ChatTurnSaveRequest:一次请求三件事(chatId + userMessage + assistantMessage) - schema.sql:补 "Chat" 和 "Message" DDL,让新部署能直接起库;名字带双引号 保持与 Prisma schema 生成的大小写一致 配套前端 PR:InvolutionHell/involutionhell#301 改 app/api/chat/route.ts 的 onFinish 从 prisma.chat.upsert/message.create 换成 fetch 本接口。 --- .../controller/ChatHistoryController.java | 51 +++++++++++++ .../backend/chat/dto/ChatTurnSaveRequest.java | 22 ++++++ .../repository/ChatHistoryRepository.java | 27 +++++++ .../repository/JdbcChatHistoryRepository.java | 74 +++++++++++++++++++ .../common/config/SaTokenConfigure.java | 1 + src/main/resources/schema.sql | 25 +++++++ 6 files changed, 200 insertions(+) create mode 100644 src/main/java/com/involutionhell/backend/chat/controller/ChatHistoryController.java create mode 100644 src/main/java/com/involutionhell/backend/chat/dto/ChatTurnSaveRequest.java create mode 100644 src/main/java/com/involutionhell/backend/chat/repository/ChatHistoryRepository.java create mode 100644 src/main/java/com/involutionhell/backend/chat/repository/JdbcChatHistoryRepository.java diff --git a/src/main/java/com/involutionhell/backend/chat/controller/ChatHistoryController.java b/src/main/java/com/involutionhell/backend/chat/controller/ChatHistoryController.java new file mode 100644 index 0000000..171462b --- /dev/null +++ b/src/main/java/com/involutionhell/backend/chat/controller/ChatHistoryController.java @@ -0,0 +1,51 @@ +package com.involutionhell.backend.chat.controller; + +import cn.dev33.satoken.stp.StpUtil; +import com.involutionhell.backend.chat.dto.ChatTurnSaveRequest; +import com.involutionhell.backend.chat.repository.ChatHistoryRepository; +import com.involutionhell.backend.common.api.ApiResponse; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * /api/chat/sessions/save —— 保存一次 AI 对话回合(替代前端原 Prisma 直连)。 + * + * 鉴权:匿名允许(SaTokenConfigure 里放行本路径)。原 Prisma 实现就是匿名也写, + * chat.userId 允许 NULL;保持语义一致,避免前端切流量时未登录用户聊天历史丢失。 + * 如果登录了,用 sa-token 取 userId 关联;没登录就 NULL。 + * + * 为什么单独放一个 controller 而不是塞进 OpenAiController:OpenAiController 管 + * 流式代理,这里管持久化,职责不同;而且这条路径要对匿名开放,和 OpenAI 代理 + * 的登录要求不一致,拆开配 SaToken 拦截规则更清晰。 + */ +@RestController +@RequestMapping("/api/chat/sessions") +public class ChatHistoryController { + + private final ChatHistoryRepository chatHistoryRepository; + + public ChatHistoryController(ChatHistoryRepository chatHistoryRepository) { + this.chatHistoryRepository = chatHistoryRepository; + } + + @PostMapping("/save") + public ApiResponse save(@RequestBody ChatTurnSaveRequest req) { + if (req == null || req.chatId() == null || req.chatId().isBlank()) { + return ApiResponse.fail("chatId 不能为空"); + } + + // StpUtil.isLogin() 对匿名请求返回 false 而不是抛异常——配合 SaToken + // 拦截器在 SaTokenConfigure 里 notMatch 放行本路径,才能真正对匿名生效。 + Long userId = StpUtil.isLogin() ? StpUtil.getLoginIdAsLong() : null; + + chatHistoryRepository.saveTurn( + req.chatId(), + userId, + req.userMessage(), + req.assistantMessage()); + + return ApiResponse.okMessage("saved"); + } +} diff --git a/src/main/java/com/involutionhell/backend/chat/dto/ChatTurnSaveRequest.java b/src/main/java/com/involutionhell/backend/chat/dto/ChatTurnSaveRequest.java new file mode 100644 index 0000000..0315c7a --- /dev/null +++ b/src/main/java/com/involutionhell/backend/chat/dto/ChatTurnSaveRequest.java @@ -0,0 +1,22 @@ +package com.involutionhell.backend.chat.dto; + +/** + * POST /api/chat/sessions/save 的请求体。 + * + * 由前端 app/api/chat/route.ts 的 streamText onFinish 回调在一次 AI 回合结束后 + * 调用,把 chat 会话记录 + 本轮 user 消息 + 本轮 assistant 消息一次性塞给后端持 + * 久化。合并成一次请求而不是三次是为了: + * 1. 少两次网络往返(onFinish 阻塞流返回对用户体感没影响,但链路越短越抗抖动) + * 2. 后端一个事务,避免 chat 写成功但消息丢的错峰状态 + * + * 字段语义: + * - chatId:前端 crypto.randomUUID() 生成,首次为新会话、后续为已有会话 + * - userMessage:本轮用户输入的纯文本(从 UIMessage.parts 拼接出);空/缺省 + * 表示本轮无用户输入(比如从旧 chatId 恢复状态时),跳过插入 + * - assistantMessage:本轮 AI 返回的纯文本;空表示流式失败或空响应,跳过插入 + */ +public record ChatTurnSaveRequest( + String chatId, + String userMessage, + String assistantMessage +) {} diff --git a/src/main/java/com/involutionhell/backend/chat/repository/ChatHistoryRepository.java b/src/main/java/com/involutionhell/backend/chat/repository/ChatHistoryRepository.java new file mode 100644 index 0000000..8681863 --- /dev/null +++ b/src/main/java/com/involutionhell/backend/chat/repository/ChatHistoryRepository.java @@ -0,0 +1,27 @@ +package com.involutionhell.backend.chat.repository; + +/** + * AI 对话历史持久化接口。 + * + * 为什么一个 repository 同时操作 Chat 和 Message:一次聊天回合的持久化(chat + * upsert + user msg + assistant msg)是强业务原子性的——要么一起落库,要么 + * 不写。把三次写放在同一个 repository 里的 @Transactional 方法里,避免出现 + * "chat 有记录但消息丢了" 的错峰状态;拆成两个 repository 反而要在上层协调。 + */ +public interface ChatHistoryRepository { + + /** + * 原子地持久化一个聊天回合: + * 1. chat 不存在则创建、存在则刷新 updatedAt 和 userId(匿名 → 登录迁移场景) + * 2. userMessage 非空时插入一条 user role 的消息 + * 3. assistantMessage 非空时插入一条 assistant role 的消息 + * + * 整个调用处于同一事务,任何一步异常都会回滚前面已写入的行。 + * + * @param chatId 会话 ID,前端用 crypto.randomUUID() 生成,TEXT 主键 + * @param userId sa-token 登录态拿到的 user_accounts.id,匿名时传 null + * @param userMessage 本轮用户消息;null 或空字符串时跳过插入 + * @param assistantMessage 本轮 AI 回复;null 或空字符串时跳过插入 + */ + void saveTurn(String chatId, Long userId, String userMessage, String assistantMessage); +} diff --git a/src/main/java/com/involutionhell/backend/chat/repository/JdbcChatHistoryRepository.java b/src/main/java/com/involutionhell/backend/chat/repository/JdbcChatHistoryRepository.java new file mode 100644 index 0000000..f2413bc --- /dev/null +++ b/src/main/java/com/involutionhell/backend/chat/repository/JdbcChatHistoryRepository.java @@ -0,0 +1,74 @@ +package com.involutionhell.backend.chat.repository; + +import java.sql.Types; +import java.util.UUID; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +/** + * 基于 Spring JDBC 的 Chat / Message 表读写实现。 + * + * 表结构由 Prisma 历史管理,列名是驼峰并加了双引号("userId"、"chatId"、 + * "createdAt"),PostgreSQL 下这些名字都区分大小写,所有 SQL 里必须保留双 + * 引号——漏一个就会报 column "userid" does not exist。 + */ +@Repository +public class JdbcChatHistoryRepository implements ChatHistoryRepository { + + private final JdbcTemplate jdbc; + + public JdbcChatHistoryRepository(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + /** + * 为什么用 ON CONFLICT 做 upsert 而不是先 select 再 insert: + * 1. 单次 round-trip,少一次 SQL 调用 + * 2. 并发两个 tab 同一 chatId 时不会先后撞主键冲突报 500 + * + * 为什么 userId 用 COALESCE:匿名请求 → 后续登录请求会复用同一个 chatId, + * 第二次带着真实 userId 过来时应该把之前的 NULL 覆盖掉;但如果这次匿名、 + * 上次已经登录了,不能把 userId 擦掉——所以用 COALESCE(EXCLUDED.userId, "Chat"."userId") + * 的语义:新值优先,新值为 NULL 时保留旧值。 + */ + @Override + @Transactional + public void saveTurn(String chatId, Long userId, String userMessage, String assistantMessage) { + jdbc.update( + """ + INSERT INTO "Chat" (id, "userId", "createdAt", "updatedAt") + VALUES (?, ?, NOW(), NOW()) + ON CONFLICT (id) DO UPDATE SET + "userId" = COALESCE(EXCLUDED."userId", "Chat"."userId"), + "updatedAt" = NOW() + """, + ps -> { + ps.setString(1, chatId); + if (userId == null) { + ps.setNull(2, Types.INTEGER); + } else { + ps.setInt(2, userId.intValue()); + } + }); + + if (userMessage != null && !userMessage.isBlank()) { + insertMessage(chatId, "user", userMessage); + } + if (assistantMessage != null && !assistantMessage.isBlank()) { + insertMessage(chatId, "assistant", assistantMessage); + } + } + + private void insertMessage(String chatId, String role, String content) { + jdbc.update( + """ + INSERT INTO "Message" (id, "chatId", role, content, "createdAt") + VALUES (?, ?, ?, ?, NOW()) + """, + UUID.randomUUID().toString(), + chatId, + role, + content); + } +} diff --git a/src/main/java/com/involutionhell/backend/common/config/SaTokenConfigure.java b/src/main/java/com/involutionhell/backend/common/config/SaTokenConfigure.java index 1e46a7b..50e2c95 100644 --- a/src/main/java/com/involutionhell/backend/common/config/SaTokenConfigure.java +++ b/src/main/java/com/involutionhell/backend/common/config/SaTokenConfigure.java @@ -37,6 +37,7 @@ public void addInterceptors(InterceptorRegistry registry) { // /api/events/{id}/interest 感兴趣接口需要登录,由 @SaCheckLogin 在方法级别兜底。 // /api/admin/events/** 不放行,走 @SaCheckRole("admin") 校验。 .notMatch("/api/events", "/api/events/*") + .notMatch("/api/chat/sessions/save") // AI 对话持久化(匿名 / 登录都写,登录时自动关联 userId) .check(r -> StpUtil.checkLogin()); // 未登录抛出 NotLoginException })).addPathPatterns("/**"); } diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index 5c4fbf9..ca24cf7 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -106,3 +106,28 @@ VALUES NULL, 'project,open-source', 'published') ON CONFLICT (title) DO NOTHING; + +-- Chat / Message:前端 AI 对话历史持久化。 +-- 历史:原 Next.js API route 用 Prisma 直连 Neon 写入;2026-04-17 把 Neon +-- 换成自建 Docker PG,Prisma 留在前端会导致前端写到 Neon 旧库、后端读自建 +-- PG 的脏数据分叉。迁移方案 A:前端 onFinish 改 fetch backend /api/chat/sessions/save, +-- 持久化逻辑统一走后端。表名用 Prisma 风格的 PascalCase + 带引号列名,保留与 +-- 原 Prisma schema 兼容,避免前端在切流量期间拿旧 client 读取时失败。 +CREATE TABLE IF NOT EXISTS "Chat" ( + id TEXT PRIMARY KEY, + "userId" INTEGER, + "createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "updatedAt" TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS "Chat_userId_idx" ON "Chat"("userId"); + +CREATE TABLE IF NOT EXISTS "Message" ( + id TEXT PRIMARY KEY, + "chatId" TEXT NOT NULL REFERENCES "Chat"(id) ON DELETE CASCADE, + role TEXT NOT NULL, + content TEXT NOT NULL, + "createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS "Message_chatId_idx" ON "Message"("chatId");