feat(chat): /api/chat/sessions/save 替代前端 Prisma 直连#13
Conversation
背景:Neon → 自建 Docker PG 迁移后,前端 Prisma 还指向 Neon,AI 对话持久
化会写进旧库,和后端读自建 PG 分叉出脏数据。方案 A:把 chat + message 写
入挪到后端统一走,前端 onFinish 只发一次 HTTP。
- 删掉 import { prisma } from "@/lib/db",运行时再无 Prisma 依赖
- onFinish 原来三次 prisma 调用(chat upsert + user 消息 + assistant 消息)
合并成一次 fetch(BACKEND_URL + "/api/chat/sessions/save")
- 后端接口匿名允许,登录时通过 satoken header 关联 userId,行为语义和原
Prisma 版完全一致(匿名写 userId=NULL,登录补挂 userId)
- BACKEND_URL 未配或后端返回非 2xx 时 console.warn 不抛错,保持
"持久化失败不阻塞对话流式返回"的原语义
Vercel AI SDK 流式路径(streamText / convertToModelMessages 等)完全未动,
前端 UX 无感知。
配套后端 PR:InvolutionHell/involutionhell-backend#13
There was a problem hiding this comment.
Pull request overview
为了解决 Neon → 自建 Docker PG 迁移后“前端 Prisma 仍写入旧库”导致的对话历史分叉问题,本 PR 将 AI 对话持久化从前端直连数据库迁移到后端统一落库。
Changes:
- 新增
POST /api/chat/sessions/save,支持匿名/登录态保存单轮对话,并在登录时自动关联userId - 新增
ChatHistoryRepository+JdbcChatHistoryRepository,用事务 +ON CONFLICT实现 Chat 原子 upsert 与消息插入 schema.sql补齐"Chat"/"Message"的建表与索引,便于新环境从零初始化
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| src/main/resources/schema.sql | 新增 "Chat" / "Message" 表结构与索引,支撑后端持久化落库 |
| src/main/java/com/involutionhell/backend/common/config/SaTokenConfigure.java | Sa-Token 拦截白名单放行 /api/chat/sessions/save 以支持匿名写入 |
| src/main/java/com/involutionhell/backend/chat/repository/JdbcChatHistoryRepository.java | JDBC + 事务实现 Chat upsert 与 Message 插入 |
| src/main/java/com/involutionhell/backend/chat/repository/ChatHistoryRepository.java | 抽象持久化接口,定义“单轮对话原子保存”契约 |
| src/main/java/com/involutionhell/backend/chat/dto/ChatTurnSaveRequest.java | 保存接口请求体 DTO(chatId/userMessage/assistantMessage) |
| src/main/java/com/involutionhell/backend/chat/controller/ChatHistoryController.java | 新增保存接口,处理匿名/登录 userId 关联并返回 ApiResponse |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| -- 原 Prisma schema 兼容,避免前端在切流量期间拿旧 client 读取时失败。 | ||
| CREATE TABLE IF NOT EXISTS "Chat" ( | ||
| id TEXT PRIMARY KEY, | ||
| "userId" INTEGER, |
There was a problem hiding this comment.
"Chat"."userId" is defined as INTEGER, but the backend derives userId via StpUtil.getLoginIdAsLong() and user_accounts.id is BIGSERIAL (BIGINT). With INTEGER here you risk overflow/truncation and inconsistent type usage. Consider changing "userId" to BIGINT (and optionally adding a FK to user_accounts(id) if you want referential integrity).
| "userId" INTEGER, | |
| "userId" BIGINT, |
| ps.setNull(2, Types.INTEGER); | ||
| } else { | ||
| ps.setInt(2, userId.intValue()); |
There was a problem hiding this comment.
saveTurn() converts Long userId to int (userId.intValue()) and binds it as Types.INTEGER. This can overflow for BIGSERIAL IDs and also diverges from StpUtil.getLoginIdAsLong(). Bind as BIGINT (setLong / Types.BIGINT) to preserve the full user ID range.
| ps.setNull(2, Types.INTEGER); | |
| } else { | |
| ps.setInt(2, userId.intValue()); | |
| ps.setNull(2, Types.BIGINT); | |
| } else { | |
| ps.setLong(2, userId); |
| } | ||
|
|
||
| @PostMapping("/save") | ||
| public ApiResponse<Void> save(@RequestBody ChatTurnSaveRequest req) { |
There was a problem hiding this comment.
@RequestBody is required by default, so Spring will throw HttpMessageNotReadableException when the body is missing/invalid and this method won't reach the req == null branch. If you want to consistently return ApiResponse.fail for empty bodies, set @RequestBody(required = false) and/or add a @ControllerAdvice to map JSON parse errors into ApiResponse.
| public ApiResponse<Void> save(@RequestBody ChatTurnSaveRequest req) { | |
| public ApiResponse<Void> save(@RequestBody(required = false) ChatTurnSaveRequest req) { |
| Long userId = StpUtil.isLogin() ? StpUtil.getLoginIdAsLong() : null; | ||
|
|
||
| chatHistoryRepository.saveTurn( | ||
| req.chatId(), | ||
| userId, | ||
| req.userMessage(), | ||
| req.assistantMessage()); |
There was a problem hiding this comment.
This endpoint is explicitly anonymous (whitelisted in SaTokenConfigure) and writes arbitrary content to the database, but there are no safeguards like max length checks on userMessage/assistantMessage (or other throttling). That makes it easy to abuse for storage/DB DoS. Consider enforcing reasonable per-field length limits (and rejecting oversized payloads) and/or adding rate limiting for this path.
| @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, |
There was a problem hiding this comment.
New persistence flow (/api/chat/sessions/save + JDBC upsert/insert) doesn't have test coverage. Given the repo already has MockMvc integration tests, consider adding tests for (1) anonymous save succeeds, (2) logged-in save sets userId, and (3) messages are inserted/skipped based on blank content (and update test-schema.sql to include Chat/Message tables for H2).
| return ApiResponse.okMessage("saved"); | ||
| } |
There was a problem hiding this comment.
PR description's curl example says the endpoint should return { "success": true }, but the controller returns ApiResponse.okMessage("saved") which serializes as success/message/data. Either update the PR description/example output, or adjust the response shape/message to match the documented contract.
背景: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 本接口。
9a9c0a6 to
d2003d0
Compare
* feat(admin): /admin/database 页面嵌入 pgAdmin iframe 管理员用一个主站入口进 pgAdmin 做备份/恢复/查表/跑 SQL,不再打开 api.involutionhell.com:8082 这种裸页面。pgAdmin 本身的 UI 风格跟主站不搭, 但用户明确说"管理员不配享受好 UI",优先接通能力。 - 新增 app/admin/database/page.tsx:AdminGuard 兜底权限,iframe src 走 https://api.involutionhell.com/admin/pgadmin/(可由 NEXT_PUBLIC_PGADMIN_URL 覆盖) - /admin 首页加"数据库管理"入口卡片 真实的权限/流量控制在后端 compose + Caddy 那边(见 involutionhell-backend#12): Caddy 反向代理 /admin/pgadmin/* 到 127.0.0.1:8082,剥 X-Frame-Options, 下发 CSP frame-ancestors 放行 involutionhell.com 主域。 * feat(chat): onFinish 改 fetch 后端 /api/chat/sessions/save,不再直连 Prisma 背景:Neon → 自建 Docker PG 迁移后,前端 Prisma 还指向 Neon,AI 对话持久 化会写进旧库,和后端读自建 PG 分叉出脏数据。方案 A:把 chat + message 写 入挪到后端统一走,前端 onFinish 只发一次 HTTP。 - 删掉 import { prisma } from "@/lib/db",运行时再无 Prisma 依赖 - onFinish 原来三次 prisma 调用(chat upsert + user 消息 + assistant 消息) 合并成一次 fetch(BACKEND_URL + "/api/chat/sessions/save") - 后端接口匿名允许,登录时通过 satoken header 关联 userId,行为语义和原 Prisma 版完全一致(匿名写 userId=NULL,登录补挂 userId) - BACKEND_URL 未配或后端返回非 2xx 时 console.warn 不抛错,保持 "持久化失败不阻塞对话流式返回"的原语义 Vercel AI SDK 流式路径(streamText / convertToModelMessages 等)完全未动, 前端 UX 无感知。 配套后端 PR:InvolutionHell/involutionhell-backend#13 * refactor(admin): /admin/database 去掉 iframe,改新标签打开 pgAdmin iframe 嵌入两种嵌法都是坑: - 跨域嵌:pgAdmin session/CSRF cookie 走 SameSite=Lax,子域 iframe POST 不带 cookie,登录永远报 "CSRF session token is missing" - 同源代理嵌:pgAdmin 会发绝对 URL 的重定向(host 是容器自己以为的值), 浏览器跟着跳到 http://localhost:8082 变成 ERR_CONNECTION_REFUSED 管理员不高频用数据库,没必要为了 UI 嵌在主站里搭这些管道。改成一个大按钮, target=_blank 打开 pgAdmin 自己的页面——cookie / CSRF 都在它自己域里, 一切正常工作。 同步删掉上一版临时加的 Next.js /admin/pgadmin/:path* rewrite。 * feat(auth): 登录成功同步 satoken 到 .involutionhell.com cookie 配合后端 /api/admin/pgadmin-check 和 Caddy forward_auth 的整条链:用户直连 api.involutionhell.com/admin/pgadmin/* 时浏览器不会主动发 satoken header, 必须靠 cookie 自动携带。 - 新加 syncTokenCookie(token):登录 / 刷新有效 session / 登出全部打点 localhost 域不写 Domain(浏览器默认绑当前 host); 生产写 Domain=.involutionhell.com 让主域 + 所有子域共享 SameSite=Lax 刚好够——顶层导航 / 子资源 GET 都会带;跨站 POST 不带但我们 也不需要(pgAdmin 的 CSRF 有自己的 cookie) Max-Age=2592000 与 sa-token.timeout 保持一致 - token 无效 / 登出时清掉 cookie,避免 stale 身份残留 服务端配套:InvolutionHell/involutionhell-backend#12 * feat(admin/database): hostname=localhost 时按钮自动指本地 pgAdmin 开发时访问 localhost:3010/admin/database 点按钮会直接打 prod api.involutionhell.com,需要 cookie 但 localhost 登录时 cookie 写不到 .involutionhell.com 域,只能卡 401。 改成客户端挂载后读 window.location.hostname: - localhost / 127.0.0.1 → http://localhost:8082/admin/pgadmin/ (要求开发者先 ssh -L 8082:127.0.0.1:8082 server 引端口) - 其他 → 原来的公网 URL(走 Caddy forward_auth 链) NEXT_PUBLIC_PGADMIN_URL 仍然最高优先级,想覆盖任何时候都能覆盖。 useEffect 里 setState 走 Promise.resolve 异步化,绕开 React "cascading renders" lint 规则。
背景
Neon → 自建 Docker PG 迁移(#12)后,前端 Next.js 的 Prisma 还指向 Neon,
任何 AI 对话持久化都会写进旧库,和后端读的自建 PG 分叉。
方案 A:前端 `onFinish` 不再直连 Prisma,改调本接口,由后端统一持久化。
变更
名字保留 Prisma 风格的双引号大小写,方便新部署从零起库
依赖 / 配套
测试