Skip to content

Commit 981a3a3

Browse files
authored
feat(chat): /api/chat/sessions/save 替代前端 Prisma 直连 (#13)
背景: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 本接口。
1 parent 6dd5efd commit 981a3a3

6 files changed

Lines changed: 200 additions & 0 deletions

File tree

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package com.involutionhell.backend.chat.controller;
2+
3+
import cn.dev33.satoken.stp.StpUtil;
4+
import com.involutionhell.backend.chat.dto.ChatTurnSaveRequest;
5+
import com.involutionhell.backend.chat.repository.ChatHistoryRepository;
6+
import com.involutionhell.backend.common.api.ApiResponse;
7+
import org.springframework.web.bind.annotation.PostMapping;
8+
import org.springframework.web.bind.annotation.RequestBody;
9+
import org.springframework.web.bind.annotation.RequestMapping;
10+
import org.springframework.web.bind.annotation.RestController;
11+
12+
/**
13+
* /api/chat/sessions/save —— 保存一次 AI 对话回合(替代前端原 Prisma 直连)。
14+
*
15+
* 鉴权:匿名允许(SaTokenConfigure 里放行本路径)。原 Prisma 实现就是匿名也写,
16+
* chat.userId 允许 NULL;保持语义一致,避免前端切流量时未登录用户聊天历史丢失。
17+
* 如果登录了,用 sa-token 取 userId 关联;没登录就 NULL。
18+
*
19+
* 为什么单独放一个 controller 而不是塞进 OpenAiController:OpenAiController 管
20+
* 流式代理,这里管持久化,职责不同;而且这条路径要对匿名开放,和 OpenAI 代理
21+
* 的登录要求不一致,拆开配 SaToken 拦截规则更清晰。
22+
*/
23+
@RestController
24+
@RequestMapping("/api/chat/sessions")
25+
public class ChatHistoryController {
26+
27+
private final ChatHistoryRepository chatHistoryRepository;
28+
29+
public ChatHistoryController(ChatHistoryRepository chatHistoryRepository) {
30+
this.chatHistoryRepository = chatHistoryRepository;
31+
}
32+
33+
@PostMapping("/save")
34+
public ApiResponse<Void> save(@RequestBody ChatTurnSaveRequest req) {
35+
if (req == null || req.chatId() == null || req.chatId().isBlank()) {
36+
return ApiResponse.fail("chatId 不能为空");
37+
}
38+
39+
// StpUtil.isLogin() 对匿名请求返回 false 而不是抛异常——配合 SaToken
40+
// 拦截器在 SaTokenConfigure 里 notMatch 放行本路径,才能真正对匿名生效。
41+
Long userId = StpUtil.isLogin() ? StpUtil.getLoginIdAsLong() : null;
42+
43+
chatHistoryRepository.saveTurn(
44+
req.chatId(),
45+
userId,
46+
req.userMessage(),
47+
req.assistantMessage());
48+
49+
return ApiResponse.okMessage("saved");
50+
}
51+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package com.involutionhell.backend.chat.dto;
2+
3+
/**
4+
* POST /api/chat/sessions/save 的请求体。
5+
*
6+
* 由前端 app/api/chat/route.ts 的 streamText onFinish 回调在一次 AI 回合结束后
7+
* 调用,把 chat 会话记录 + 本轮 user 消息 + 本轮 assistant 消息一次性塞给后端持
8+
* 久化。合并成一次请求而不是三次是为了:
9+
* 1. 少两次网络往返(onFinish 阻塞流返回对用户体感没影响,但链路越短越抗抖动)
10+
* 2. 后端一个事务,避免 chat 写成功但消息丢的错峰状态
11+
*
12+
* 字段语义:
13+
* - chatId:前端 crypto.randomUUID() 生成,首次为新会话、后续为已有会话
14+
* - userMessage:本轮用户输入的纯文本(从 UIMessage.parts 拼接出);空/缺省
15+
* 表示本轮无用户输入(比如从旧 chatId 恢复状态时),跳过插入
16+
* - assistantMessage:本轮 AI 返回的纯文本;空表示流式失败或空响应,跳过插入
17+
*/
18+
public record ChatTurnSaveRequest(
19+
String chatId,
20+
String userMessage,
21+
String assistantMessage
22+
) {}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package com.involutionhell.backend.chat.repository;
2+
3+
/**
4+
* AI 对话历史持久化接口。
5+
*
6+
* 为什么一个 repository 同时操作 Chat 和 Message:一次聊天回合的持久化(chat
7+
* upsert + user msg + assistant msg)是强业务原子性的——要么一起落库,要么
8+
* 不写。把三次写放在同一个 repository 里的 @Transactional 方法里,避免出现
9+
* "chat 有记录但消息丢了" 的错峰状态;拆成两个 repository 反而要在上层协调。
10+
*/
11+
public interface ChatHistoryRepository {
12+
13+
/**
14+
* 原子地持久化一个聊天回合:
15+
* 1. chat 不存在则创建、存在则刷新 updatedAt 和 userId(匿名 → 登录迁移场景)
16+
* 2. userMessage 非空时插入一条 user role 的消息
17+
* 3. assistantMessage 非空时插入一条 assistant role 的消息
18+
*
19+
* 整个调用处于同一事务,任何一步异常都会回滚前面已写入的行。
20+
*
21+
* @param chatId 会话 ID,前端用 crypto.randomUUID() 生成,TEXT 主键
22+
* @param userId sa-token 登录态拿到的 user_accounts.id,匿名时传 null
23+
* @param userMessage 本轮用户消息;null 或空字符串时跳过插入
24+
* @param assistantMessage 本轮 AI 回复;null 或空字符串时跳过插入
25+
*/
26+
void saveTurn(String chatId, Long userId, String userMessage, String assistantMessage);
27+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package com.involutionhell.backend.chat.repository;
2+
3+
import java.sql.Types;
4+
import java.util.UUID;
5+
import org.springframework.jdbc.core.JdbcTemplate;
6+
import org.springframework.stereotype.Repository;
7+
import org.springframework.transaction.annotation.Transactional;
8+
9+
/**
10+
* 基于 Spring JDBC 的 Chat / Message 表读写实现。
11+
*
12+
* 表结构由 Prisma 历史管理,列名是驼峰并加了双引号("userId"、"chatId"、
13+
* "createdAt"),PostgreSQL 下这些名字都区分大小写,所有 SQL 里必须保留双
14+
* 引号——漏一个就会报 column "userid" does not exist。
15+
*/
16+
@Repository
17+
public class JdbcChatHistoryRepository implements ChatHistoryRepository {
18+
19+
private final JdbcTemplate jdbc;
20+
21+
public JdbcChatHistoryRepository(JdbcTemplate jdbc) {
22+
this.jdbc = jdbc;
23+
}
24+
25+
/**
26+
* 为什么用 ON CONFLICT 做 upsert 而不是先 select 再 insert:
27+
* 1. 单次 round-trip,少一次 SQL 调用
28+
* 2. 并发两个 tab 同一 chatId 时不会先后撞主键冲突报 500
29+
*
30+
* 为什么 userId 用 COALESCE:匿名请求 → 后续登录请求会复用同一个 chatId,
31+
* 第二次带着真实 userId 过来时应该把之前的 NULL 覆盖掉;但如果这次匿名、
32+
* 上次已经登录了,不能把 userId 擦掉——所以用 COALESCE(EXCLUDED.userId, "Chat"."userId")
33+
* 的语义:新值优先,新值为 NULL 时保留旧值。
34+
*/
35+
@Override
36+
@Transactional
37+
public void saveTurn(String chatId, Long userId, String userMessage, String assistantMessage) {
38+
jdbc.update(
39+
"""
40+
INSERT INTO "Chat" (id, "userId", "createdAt", "updatedAt")
41+
VALUES (?, ?, NOW(), NOW())
42+
ON CONFLICT (id) DO UPDATE SET
43+
"userId" = COALESCE(EXCLUDED."userId", "Chat"."userId"),
44+
"updatedAt" = NOW()
45+
""",
46+
ps -> {
47+
ps.setString(1, chatId);
48+
if (userId == null) {
49+
ps.setNull(2, Types.INTEGER);
50+
} else {
51+
ps.setInt(2, userId.intValue());
52+
}
53+
});
54+
55+
if (userMessage != null && !userMessage.isBlank()) {
56+
insertMessage(chatId, "user", userMessage);
57+
}
58+
if (assistantMessage != null && !assistantMessage.isBlank()) {
59+
insertMessage(chatId, "assistant", assistantMessage);
60+
}
61+
}
62+
63+
private void insertMessage(String chatId, String role, String content) {
64+
jdbc.update(
65+
"""
66+
INSERT INTO "Message" (id, "chatId", role, content, "createdAt")
67+
VALUES (?, ?, ?, ?, NOW())
68+
""",
69+
UUID.randomUUID().toString(),
70+
chatId,
71+
role,
72+
content);
73+
}
74+
}

src/main/java/com/involutionhell/backend/common/config/SaTokenConfigure.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ public void addInterceptors(InterceptorRegistry registry) {
3737
// /api/events/{id}/interest 感兴趣接口需要登录,由 @SaCheckLogin 在方法级别兜底。
3838
// /api/admin/events/** 不放行,走 @SaCheckRole("admin") 校验。
3939
.notMatch("/api/events", "/api/events/*")
40+
.notMatch("/api/chat/sessions/save") // AI 对话持久化(匿名 / 登录都写,登录时自动关联 userId)
4041
.check(r -> StpUtil.checkLogin()); // 未登录抛出 NotLoginException
4142
})).addPathPatterns("/**");
4243
}

src/main/resources/schema.sql

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,3 +106,28 @@ VALUES
106106
NULL,
107107
'project,open-source', 'published')
108108
ON CONFLICT (title) DO NOTHING;
109+
110+
-- Chat / Message:前端 AI 对话历史持久化。
111+
-- 历史:原 Next.js API route 用 Prisma 直连 Neon 写入;2026-04-17 把 Neon
112+
-- 换成自建 Docker PG,Prisma 留在前端会导致前端写到 Neon 旧库、后端读自建
113+
-- PG 的脏数据分叉。迁移方案 A:前端 onFinish 改 fetch backend /api/chat/sessions/save,
114+
-- 持久化逻辑统一走后端。表名用 Prisma 风格的 PascalCase + 带引号列名,保留与
115+
-- 原 Prisma schema 兼容,避免前端在切流量期间拿旧 client 读取时失败。
116+
CREATE TABLE IF NOT EXISTS "Chat" (
117+
id TEXT PRIMARY KEY,
118+
"userId" INTEGER,
119+
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
120+
"updatedAt" TIMESTAMPTZ NOT NULL DEFAULT NOW()
121+
);
122+
123+
CREATE INDEX IF NOT EXISTS "Chat_userId_idx" ON "Chat"("userId");
124+
125+
CREATE TABLE IF NOT EXISTS "Message" (
126+
id TEXT PRIMARY KEY,
127+
"chatId" TEXT NOT NULL REFERENCES "Chat"(id) ON DELETE CASCADE,
128+
role TEXT NOT NULL,
129+
content TEXT NOT NULL,
130+
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW()
131+
);
132+
133+
CREATE INDEX IF NOT EXISTS "Message_chatId_idx" ON "Message"("chatId");

0 commit comments

Comments
 (0)