11package com .involutionhell .backend .chat .repository ;
22
3+ import com .involutionhell .backend .common .error .AccessDeniedBusinessException ;
34import java .sql .Types ;
45import java .util .List ;
56import java .util .Optional ;
@@ -53,17 +54,23 @@ public Optional<ChatOwner> lookupOwner(String chatId) {
5354 * 第二次带着真实 userId 过来时应该把之前的 NULL 覆盖掉;但如果这次匿名、
5455 * 上次已经登录了,不能把 userId 擦掉——所以用 COALESCE(EXCLUDED.userId, "Chat"."userId")
5556 * 的语义:新值优先,新值为 NULL 时保留旧值。
57+ *
58+ * WHERE 子句(fix #27 TOCTOU):归属校验在 SQL 层原子完成——ON CONFLICT
59+ * 命中时,只有 owner 兼容(NULL 或相同 userId)才允许 UPDATE。不兼容时
60+ * affected rows = 0,直接抛 AccessDeniedBusinessException,Message 不插入。
61+ * 消除了 controller 层 lookupOwner 与 saveTurn 之间的竞态窗口。
5662 */
5763 @ Override
5864 @ Transactional
5965 public void saveTurn (String chatId , Long userId , String userMessage , String assistantMessage ) {
60- jdbc .update (
66+ int rows = jdbc .update (
6167 """
6268 INSERT INTO "Chat" (id, "userId", "createdAt", "updatedAt")
6369 VALUES (?, ?, NOW(), NOW())
6470 ON CONFLICT (id) DO UPDATE SET
6571 "userId" = COALESCE(EXCLUDED."userId", "Chat"."userId"),
6672 "updatedAt" = NOW()
73+ WHERE "Chat"."userId" IS NULL OR "Chat"."userId" = EXCLUDED."userId"
6774 """ ,
6875 ps -> {
6976 ps .setString (1 , chatId );
@@ -74,6 +81,10 @@ ON CONFLICT (id) DO UPDATE SET
7481 }
7582 });
7683
84+ if (rows == 0 ) {
85+ throw new AccessDeniedBusinessException ("不允许写入他人的 chat 历史" );
86+ }
87+
7788 if (userMessage != null && !userMessage .isBlank ()) {
7889 insertMessage (chatId , "user" , userMessage );
7990 }
0 commit comments