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
+52 -5
View File
@@ -71,6 +71,7 @@ import {
loadGeneralDailyPanchangaContext,
type GeneralDailyPanchangaContext,
} from "@/lib/general-daily-panchanga";
import { consultationHistoryFromStoredMessages } from "@/lib/consultation-session-history";
import { z } from "zod";
export const runtime = "nodejs";
@@ -94,6 +95,7 @@ const chatRequestMetadataSchema = z.object({
}),
)
.max(20)
.optional()
.default([]),
});
@@ -143,6 +145,11 @@ const consultationCompletionSchema = z.object({
error_code: z.string().nullable().optional(),
});
const consultationQuestionAppendSchema = z.object({
success: z.boolean(),
error_code: z.string().nullable().optional(),
});
function first<T>(value: T | T[]): T {
return Array.isArray(value) ? value[0] : value;
}
@@ -273,7 +280,7 @@ export async function POST(request: Request) {
const { data: chatSession, error: chatSessionError } = await supabase
.from("chat_sessions")
.select("id,model_id,model_config_version,session_type")
.select("id,model_id,model_config_version,session_type,messages")
.eq("id", parsed.data.sessionId)
.eq("user_id", user.id)
.maybeSingle();
@@ -329,11 +336,11 @@ export async function POST(request: Request) {
const consultationTheme = parsed.data.theme;
const visibleQuestion = parsed.data.question;
// Client `history` stays in the request schema for old bundles and is not read.
const storedHistory = consultationHistoryFromStoredMessages(chatSession.messages);
const userControlledPrompt = [
parsed.data.question,
...parsed.data.history
.filter((message) => message.role === "user")
.map((message) => message.text),
...storedHistory.filter((message) => message.role === "user").map((message) => message.text),
].join("\n");
if (blocksPromptExtraction(userControlledPrompt)) {
return NextResponse.json(
@@ -504,6 +511,46 @@ export async function POST(request: Request) {
}
}
let appendedQuestion: { success: boolean; error_code?: string | null };
try {
appendedQuestion = await retryDetachedSettlement(async () => {
const { data, error } = await accounting.rpc("append_consultation_question", {
p_user_id: userId,
p_request_id: requestId,
p_session_id: sessionId,
p_question_message: { role: "user", text: visibleQuestion },
});
const parsedAppend = consultationQuestionAppendSchema.safeParse(first(data ?? []));
if (error || !parsedAppend.success) {
throw new CreditRpcError(error?.message || "invalid_question_append_response");
}
return parsedAppend.data;
});
} catch {
await cancel();
return NextResponse.json(
{ error: "暂时无法保存问题", message: "请稍后重试。" },
{ status: 503 },
);
}
if (!appendedQuestion.success) {
await cancel();
if (appendedQuestion.error_code === "session_full") {
return NextResponse.json(
{
error: "这段对话已写满",
message: "这段对话已写满,开个新对话继续吧",
code: "session_full",
},
{ status: 409 },
);
}
return NextResponse.json(
{ error: "暂时无法保存问题", message: "请稍后重试。" },
{ status: 503 },
);
}
const usageStartedAt = Date.now();
async function usagePayload(usage: Promise<{ inputTokens?: number; outputTokens?: number }>) {
const resolved = await usage;
@@ -1044,7 +1091,7 @@ export async function POST(request: Request) {
}
try {
const { history } = parsed.data;
const history = storedHistory;
const name = prepared.serverChart?.name ?? prepared.declaredWindow?.name ?? parsed.data.name;
const consultationMode: ConsultationBirthTimeMode = prepared.consultationMode;
const generalDailyContext = shouldLoadGeneralDailyPanchanga({
@@ -238,7 +238,7 @@ export async function POST(request: Request) {
const { data: chatSession, error: chatSessionError } = await supabase
.from("chat_sessions")
.select("id,messages,session_type,model_id,model_config_version,agentic_rectification_case_id")
.select("id,session_type,model_id,model_config_version,agentic_rectification_case_id")
.eq("id", sessionId)
.eq("user_id", userId)
.maybeSingle();
+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 });