feat: add global asynchronous report center
Staging Backend Quality Gate / validate (push) Has been cancelled
Staging Backend Quality Gate / publish (push) Has been cancelled

This commit is contained in:
Codex
2026-08-09 20:24:09 -07:00
parent a17ff258d8
commit 65bcef3001
17 changed files with 1257 additions and 648 deletions
+84 -1
View File
@@ -1,4 +1,4 @@
import { NextResponse } from "next/server";
import { after, NextResponse } from "next/server";
import { runConsultationWorkflow } from "@/mastra";
import { createPersonalReportAgent } from "@/mastra/personal-report";
import { loadLanguageModelCatalog } from "@/lib/model-catalog";
@@ -26,6 +26,19 @@ import { createServerSupabaseClient } from "@/lib/supabase/server";
export const runtime = "nodejs";
export const maxDuration = 120;
const STALE_GENERATION_MS = 15 * 60 * 1000;
const REPORT_LIST_COLUMNS = [
"id",
"request_id",
"report_type",
"presentation_mode",
"requested_themes",
"status",
"failure_code",
"created_at",
"completed_at",
].join(",");
function sanitizedErrorCode(error: unknown): string {
if (error instanceof Error) return error.name;
@@ -36,6 +49,56 @@ function toNextResponse(response: { status: number; body: Record<string, unknown
return NextResponse.json(response.body, { status: response.status });
}
function listReportView(value: unknown) {
const row = value && typeof value === "object" ? value as Record<string, unknown> : {};
return {
id: typeof row.id === "string" ? row.id : "",
requestId: typeof row.request_id === "string" ? row.request_id : "",
reportType: typeof row.report_type === "string" ? row.report_type : "personal_full",
presentationMode: typeof row.presentation_mode === "string" ? row.presentation_mode : "default",
themes: Array.isArray(row.requested_themes)
? row.requested_themes.filter((theme): theme is string => typeof theme === "string")
: [],
status: typeof row.status === "string" ? row.status : "failed",
failureCode: typeof row.failure_code === "string" ? row.failure_code : null,
createdAt: typeof row.created_at === "string" ? row.created_at : "",
completedAt: typeof row.completed_at === "string" ? row.completed_at : null,
};
}
/** Lightweight report-centre index. Report bodies never leave this endpoint. */
export async function GET() {
try {
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("personal_reports")
.select(REPORT_LIST_COLUMNS)
.eq("user_id", user.id)
.order("created_at", { ascending: false })
.limit(20);
if (error) throw error;
return NextResponse.json({
reports: Array.isArray(data) ? data.map(listReportView).filter((report) => report.id) : [],
});
} catch (error) {
if (isSupabaseConfigurationError(error)) {
return NextResponse.json(
{ error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" },
{ status: 503 },
);
}
console.error(`[reports] list failed reason=${sanitizedErrorCode(error)}`);
return NextResponse.json(
{ error: "报告列表暂时无法读取", code: REPORT_STABLE_CODES.generationFailed },
{ status: 500 },
);
}
}
export async function POST(request: Request) {
try {
// Authenticated client: auth, profile, session/chart-profile owner reads.
@@ -50,6 +113,25 @@ export async function POST(request: Request) {
const persistence: PersonalReportService = createSupabasePersonalReportService(admin);
const adminDataClient = createPersonalReportDataClient(admin);
// `after()` is bounded by the route duration and can be interrupted by a
// process restart. Reclaim only rows far beyond that bound so a killed
// task never blocks the user's next explicit generation forever.
if (userId) {
const now = new Date();
const staleBefore = new Date(now.getTime() - STALE_GENERATION_MS).toISOString();
const { error } = await admin
.from("personal_reports")
.update({
status: "failed",
failure_code: REPORT_STABLE_CODES.calculationUnavailable,
updated_at: now.toISOString(),
})
.eq("user_id", userId)
.eq("status", "generating")
.lt("updated_at", staleBefore);
if (error) throw error;
}
let profile: unknown = null;
let profileError: unknown = null;
if (userId) {
@@ -126,6 +208,7 @@ export async function POST(request: Request) {
runWorkflow: (input) => runConsultationWorkflow(input),
createAgent: (model) => createPersonalReportAgent(model as Parameters<typeof createPersonalReportAgent>[0]),
skillSnapshot: resolveSkillSnapshot(),
deferGeneration: (task) => after(task),
};
const response = await resolveReportCreate(deps);