-
Notifications
You must be signed in to change notification settings - Fork 4
feat(chat): /api/chat/sessions/save 替代前端 Prisma 直连 #13
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Void> 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, | ||
|
Comment on lines
+33
to
+45
|
||
| req.userMessage(), | ||
| req.assistantMessage()); | ||
|
Comment on lines
+41
to
+47
|
||
|
|
||
| return ApiResponse.okMessage("saved"); | ||
| } | ||
|
Comment on lines
+49
to
+50
|
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| ) {} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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()); | ||||||||||||||
|
Comment on lines
+49
to
+51
|
||||||||||||||
| ps.setNull(2, Types.INTEGER); | |
| } else { | |
| ps.setInt(2, userId.intValue()); | |
| ps.setNull(2, Types.BIGINT); | |
| } else { | |
| ps.setLong(2, userId); |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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, | ||||||
|
||||||
| "userId" INTEGER, | |
| "userId" BIGINT, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@RequestBodyis required by default, so Spring will throw HttpMessageNotReadableException when the body is missing/invalid and this method won't reach thereq == nullbranch. If you want to consistently return ApiResponse.fail for empty bodies, set@RequestBody(required = false)and/or add a@ControllerAdviceto map JSON parse errors into ApiResponse.