feat(report): add dense personal report appendix

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
jesse-ux
2026-09-23 10:42:17 +08:00
co-authored by Claude Code
parent a7f82dc2a6
commit bbd96d3b6b
44 changed files with 48833 additions and 50 deletions
@@ -0,0 +1,48 @@
import { NextResponse } from "next/server";
import { LONGFORM_APPENDIX_TABLE, parseLongformAppendixRow } from "@/lib/personal-report-longform-appendix";
import { checkSameOrigin, resolveAllowedReportOrigins } from "@/lib/personal-report-entitlement";
import { createSupabasePersonalReportService } from "@/lib/personal-report-service";
import { releaseRawReportAppendix } from "@/lib/personal-report-raw-appendix";
import { readLongformSnapshot, type LongformSnapshotReader } from "@/lib/personal-report-longform-snapshot";
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
import { createServerSupabaseClient } from "@/lib/supabase/server";
export const runtime = "nodejs";
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const privateHeaders = { "Cache-Control": "private, no-store", "X-Content-Type-Options": "nosniff" };
const json = (body: unknown, status = 200) => NextResponse.json(body, { status, headers: privateHeaders });
type RouteContext = { params: Promise<{ reportId: string }> };
/** Cache-only attachment: no birth input, recalculation, or alternate subject selection. */
export async function POST(request: Request, context: RouteContext) {
try {
const supabase = await createServerSupabaseClient();
const { data: { user }, error } = await supabase.auth.getUser();
if (error || !user) return json({ error: "请先登录" }, 401);
const origin = checkSameOrigin(request.url, request.headers.get("origin"), resolveAllowedReportOrigins(process.env), request.headers);
if (!origin.ok) return json({ error: "此处无法下载,请回到报告页重试。" }, 403);
const { reportId } = await context.params;
if (!uuidPattern.test(reportId)) return json({ error: "报告不存在" }, 404);
const report = await createSupabasePersonalReportService(supabase).getOwnedById(user.id, reportId);
if (!report) return json({ error: "报告不存在" }, 404);
if (report.status !== "ready") return json({ error: "报告尚未完成" }, 409);
const result = await supabase.from(LONGFORM_APPENDIX_TABLE)
.select("report_id,user_id,request_id,status,markdown,content_sha256,attempt_count,last_error_code")
.eq("report_id", reportId).eq("user_id", user.id).eq("request_id", report.requestId)
.maybeSingle();
const cached = result.error ? null : parseLongformAppendixRow(result.data);
if (!cached || cached.reportId !== reportId || cached.userId !== user.id || cached.requestId !== report.requestId
|| cached.status !== "ready" || !cached.markdown) {
return json({ error: "这份报告暂无原始附录。" }, 410);
}
const stored = await readLongformSnapshot(supabase as unknown as LongformSnapshotReader, { reportId, userId: user.id, requestId: report.requestId });
if (stored && (stored.status !== "ready" || stored.snapshot.markdown !== cached.markdown)) {
return json({ error: "原始附录暂时无法下载,请稍后重试。" }, 503);
}
return json(releaseRawReportAppendix(cached.markdown, stored?.status === "ready" ? stored.snapshot.readerDashaApplicability : undefined));
} catch (error) {
return json({ error: "原始附录暂时无法下载,请稍后重试。" }, isSupabaseConfigurationError(error) ? 503 : 500);
}
}
+3 -2
View File
@@ -1,4 +1,5 @@
import { NextResponse } from "next/server";
import { LONGFORM_SNAPSHOT_SECTION_ID } from "@/lib/personal-report-longform-snapshot";
import { resolveMissingBirthTimezoneOffset } from "@/lib/birth-profile-timezone";
import { runConsultationWorkflow } from "@/mastra";
import { createPersonalReportAgent } from "@/mastra/personal-report";
@@ -108,14 +109,14 @@ export async function GET() {
if (failed.length > 0) {
const { data: sections, error: sectionError } = await supabase
.from("personal_report_sections")
.select("request_id, status, last_error_code")
.select("request_id, section_id, status, last_error_code")
.eq("user_id", user.id)
.in("request_id", failed.map((report) => report.requestId));
if (sectionError) throw sectionError;
const grouped = new Map<string, Array<{ status: string; lastErrorCode: string | null }>>();
for (const row of Array.isArray(sections) ? sections : []) {
const requestId = typeof row.request_id === "string" ? row.request_id : "";
if (!requestId) continue;
if (!requestId || row.section_id === LONGFORM_SNAPSHOT_SECTION_ID) continue;
const list = grouped.get(requestId) ?? [];
list.push({
status: typeof row.status === "string" ? row.status : "pending",
+8
View File
@@ -1,6 +1,14 @@
@import "tailwindcss";
@import "tw-animate-css";
.personal-report-fact-tables { margin-block: 2rem; min-width: 0; }
.personal-report-fact-tables details { margin-block: 1rem; padding: 1rem; border: 1px solid var(--color-border); border-radius: 0.5rem; }
.personal-report-fact-tables summary { cursor: pointer; min-height: 2.75rem; }
.personal-report-fact-tables table { width: 100%; border-collapse: collapse; }
.personal-report-fact-tables th, .personal-report-fact-tables td { padding: 0.5rem; text-align: start; border-bottom: 1px solid var(--color-border); overflow-wrap: anywhere; }
@media print { .personal-report-fact-tables details { break-inside: auto; } }
@theme inline {
--color-background: var(--color-canvas);
--color-foreground: var(--color-ink);