feat: add global asynchronous report center
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user