fix(chat): make the server the only writer of session messages
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:
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user