fix(consult): preserve streaming across disconnects
Staging Backend Quality Gate / validate (push) Successful in 12m47s
Staging Backend Quality Gate / publish (push) Successful in 31m1s

This commit is contained in:
Jesse_Chen
2026-08-08 21:34:03 +08:00
parent c16c9bfbde
commit 32b683ebef
9 changed files with 1529 additions and 179 deletions
@@ -0,0 +1,110 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { runCreditRpc } from "@/lib/consultation-billing";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import { createServerSupabaseClient } from "@/lib/supabase/server";
export const runtime = "nodejs";
const idSchema = z.string().uuid();
const reservedLeaseMs = 15 * 60 * 1000;
// ponytail: Staging MVP ceiling—without a queue/worker, status lazily refunds reservations after this lease.
function reservationLeaseExpired(updatedAt: unknown, now = Date.now()) {
if (typeof updatedAt !== "string") return false;
const timestamp = Date.parse(updatedAt);
return Number.isFinite(timestamp) && now - timestamp >= reservedLeaseMs;
}
export async function GET(request: Request) {
let supabase: Awaited<ReturnType<typeof createServerSupabaseClient>>;
try {
supabase = await createServerSupabaseClient();
} catch {
return NextResponse.json({ error: "服务尚未配置" }, { status: 503 });
}
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const params = new URL(request.url).searchParams;
const sessionIdValue = params.get("sessionId");
const requestIdValue = params.get("requestId");
const activeLookup = sessionIdValue === null && requestIdValue === null;
if (!activeLookup && (sessionIdValue === null || requestIdValue === null)) {
return NextResponse.json({ error: "咨询请求格式不正确" }, { status: 400 });
}
const sessionId = sessionIdValue === null ? null : idSchema.safeParse(sessionIdValue);
const requestId = requestIdValue === null ? null : idSchema.safeParse(requestIdValue);
if ((sessionId && !sessionId.success) || (requestId && !requestId.success)) {
return NextResponse.json({ error: "咨询请求格式不正确" }, { status: 400 });
}
let accounting: ReturnType<typeof createAdminSupabaseClient>;
try {
accounting = createAdminSupabaseClient();
} catch {
return NextResponse.json({ error: "服务尚未配置" }, { status: 503 });
}
let query = accounting
.from("consultation_requests")
.select("request_id,session_id,status,response_message,updated_at")
.eq("user_id", user.id);
if (activeLookup) {
query = query.eq("status", "reserved").order("created_at", { ascending: false }).limit(1);
} else {
query = query
.eq("session_id", sessionId!.data)
.eq("request_id", requestId!.data);
}
const { data, error } = await query.maybeSingle();
if (error) {
return NextResponse.json({ error: "暂时无法读取咨询状态" }, { status: 503 });
}
if (!data || typeof data.session_id !== "string") {
return NextResponse.json({ error: "咨询请求不存在" }, { status: 404 });
}
let statusData = data;
if (statusData.status === "reserved" && reservationLeaseExpired(statusData.updated_at)) {
let cancellation;
try {
cancellation = await runCreditRpc(
accounting,
"cancel_consultation_credit",
user.id,
statusData.request_id,
);
} catch {
return NextResponse.json({ error: "暂时无法回收超时咨询" }, { status: 503 });
}
if (!cancellation.success && cancellation.error_code !== "request_completed") {
return NextResponse.json({ error: "暂时无法回收超时咨询" }, { status: 503 });
}
const { data: settledData, error: settledError } = await accounting
.from("consultation_requests")
.select("request_id,session_id,status,response_message,updated_at")
.eq("user_id", user.id)
.eq("session_id", statusData.session_id)
.eq("request_id", statusData.request_id)
.maybeSingle();
if (settledError || !settledData || typeof settledData.session_id !== "string") {
return NextResponse.json({ error: "暂时无法读取咨询状态" }, { status: 503 });
}
statusData = settledData;
}
return NextResponse.json({
requestId: statusData.request_id,
sessionId: statusData.session_id,
status: statusData.status,
responseMessage: statusData.response_message,
updatedAt: statusData.updated_at,
});
}