Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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) {
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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.

Suggested change
public ApiResponse<Void> save(@RequestBody ChatTurnSaveRequest req) {
public ApiResponse<Void> save(@RequestBody(required = false) ChatTurnSaveRequest req) {

Copilot uses AI. Check for mistakes.
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
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
req.userMessage(),
req.assistantMessage());
Comment on lines +41 to +47
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.

return ApiResponse.okMessage("saved");
}
Comment on lines +49 to +50
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
}
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
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
ps.setNull(2, Types.INTEGER);
} else {
ps.setInt(2, userId.intValue());
ps.setNull(2, Types.BIGINT);
} else {
ps.setLong(2, userId);

Copilot uses AI. Check for mistakes.
}
});

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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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("/**");
}
Expand Down
25 changes: 25 additions & 0 deletions src/main/resources/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"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).

Suggested change
"userId" INTEGER,
"userId" BIGINT,

Copilot uses AI. Check for mistakes.
"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");