fix(chat): make the server the only writer of session messages
Independent Staging Quality Gate / validate (push) Has been cancelled
Independent Staging Quality Gate / publish (push) Has been cancelled

List GET no longer ships transcripts; consult appends questions after reserve and ignores client history so dual-tab last-write-wins cannot erase messages.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-01 19:51:19 +08:00
co-authored by Cursor
parent b98777aa5c
commit b6989c3eea
23 changed files with 871 additions and 150 deletions
+65 -7
View File
@@ -3,17 +3,66 @@ import { createServerSupabaseClient } from "@/lib/supabase/server";
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
import {
ChatSessionBodyTooLargeError,
chatSessionMetadataPatchSchema,
chatSessionModelPatchSchema,
chatSessionWriteSchema,
extractChatSessionMetadataPatch,
readChatSessionJson,
} from "@/lib/chat-session-write-contract";
import { logIgnoredSessionMessages } from "@/lib/chat-session-observability";
import { consumeUserRequestRateLimit } from "@/lib/request-rate-limit";
type RouteContext = { params: Promise<{ id: string }> };
const sessionSelect = "id,title,theme,model_id,messages,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,updated_at";
const sessionIdPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
function payloadHasMessages(payload: unknown): payload is { messages: unknown } {
return Boolean(payload && typeof payload === "object" && !Array.isArray(payload) && "messages" in payload);
}
function ignoredMessageCount(payload: { messages: unknown }) {
return Array.isArray(payload.messages) ? payload.messages.length : 0;
}
function metadataUpdateValues(payload: unknown): Record<string, unknown> | null {
const parsed = chatSessionMetadataPatchSchema.safeParse(extractChatSessionMetadataPatch(payload));
if (!parsed.success) return null;
return { ...parsed.data, updated_at: new Date().toISOString() };
}
export async function GET(_request: Request, context: RouteContext) {
try {
const { id } = await context.params;
if (!sessionIdPattern.test(id)) {
return NextResponse.json({ error: "聊天记录不存在或已被删除" }, { status: 404 });
}
const supabase = await createServerSupabaseClient();
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
const { data, error } = await supabase
.from("chat_sessions")
.select(sessionSelect)
.eq("id", id)
.eq("user_id", user.id)
.maybeSingle();
if (error) return NextResponse.json({ error: "聊天记录暂时无法读取" }, { status: 500 });
if (!data) return NextResponse.json({ error: "聊天记录不存在或已被删除" }, { status: 404 });
return NextResponse.json({ session: data });
} catch (error) {
if (isSupabaseConfigurationError(error)) {
return NextResponse.json({ error: "数据库尚未配置", code: "DATABASE_NOT_CONFIGURED" }, { status: 503 });
}
return NextResponse.json({ error: "聊天记录暂时无法读取" }, { status: 500 });
}
}
export async function PATCH(request: Request, context: RouteContext) {
try {
const { id } = await context.params;
if (!sessionIdPattern.test(id)) {
return NextResponse.json({ error: "聊天记录不存在或已被删除" }, { status: 404 });
}
const supabase = await createServerSupabaseClient();
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
@@ -26,14 +75,23 @@ export async function PATCH(request: Request, context: RouteContext) {
}, { status: 429, headers: { "Retry-After": String(limited.retryAfterSeconds) } });
}
const payload = await readChatSessionJson(request);
const fullWrite = chatSessionWriteSchema.safeParse(payload);
if (payloadHasMessages(payload)) {
logIgnoredSessionMessages(id, user.id, ignoredMessageCount(payload));
}
const metadataValues = metadataUpdateValues(payload);
const modelPatch = chatSessionModelPatchSchema.safeParse(payload);
let values: Record<string, unknown>;
if (fullWrite.success) {
values = fullWrite.data;
} else if (modelPatch.success) {
values = modelPatch.data;
} else {
const fullWrite = chatSessionWriteSchema.safeParse(payload);
let values: Record<string, unknown> | null = metadataValues;
if (!values && fullWrite.success) {
values = metadataUpdateValues(fullWrite.data);
}
if (!values && modelPatch.success) {
values = { ...modelPatch.data, updated_at: new Date().toISOString() };
}
if (!values && payloadHasMessages(payload)) {
return NextResponse.json({ ok: true });
}
if (!values) {
return NextResponse.json({ error: "聊天记录格式不正确" }, { status: 400 });
}
const { data, error } = await supabase
+5 -2
View File
@@ -4,6 +4,8 @@ import { consumeUserRequestRateLimit } from "@/lib/request-rate-limit";
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
import { createServerSupabaseClient } from "@/lib/supabase/server";
const SESSION_LIST_COLUMNS = "id,title,theme,model_id,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,updated_at";
export async function GET() {
try {
const supabase = await createServerSupabaseClient();
@@ -11,7 +13,7 @@ export async function GET() {
if (authError || !user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
const { data, error } = await supabase
.from("chat_sessions")
.select("id,title,theme,model_id,messages,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,updated_at")
.select(SESSION_LIST_COLUMNS)
.eq("user_id", user.id)
.order("updated_at", { ascending: false });
if (error) return NextResponse.json({ error: "聊天记录暂时无法读取" }, { status: 500 });
@@ -39,11 +41,12 @@ export async function POST(request: Request) {
}
const parsed = chatSessionCreateSchema.safeParse(await readChatSessionJson(request));
if (!parsed.success) return NextResponse.json({ error: "聊天记录格式不正确" }, { status: 400 });
const { id, ...values } = parsed.data;
const { id, updated_at: _ignoredClientClock, ...values } = parsed.data;
const { error } = await supabase.from("chat_sessions").insert({
id,
user_id: user.id,
...values,
updated_at: new Date().toISOString(),
});
if (error) return NextResponse.json({ error: "聊天记录暂时无法同步" }, { status: 500 });
return NextResponse.json({ ok: true }, { status: 201 });