feat(report): ship full-mode longform appendix beside the five-chapter report
Independent Staging Quality Gate / validate (push) Failing after 9m41s
Independent Staging Quality Gate / publish (push) Has been skipped

Web export now calls the same full pack as the long skill report and caches an owner-only Markdown download. Appendix failure stays unavailable and does not change the main report status.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-05 11:10:44 +08:00
co-authored by Cursor
parent 18a5a48843
commit bab0718700
42 changed files with 6289 additions and 88 deletions
@@ -1,21 +1,31 @@
import { NextResponse } from "next/server";
import {
LONGFORM_APPENDIX_TABLE,
nextLongformAppendixState,
parseLongformAppendixRow,
} from "@/lib/personal-report-longform-appendix";
import {
checkSameOrigin,
resolveAllowedReportOrigins,
} from "@/lib/personal-report-entitlement";
import { createSupabasePersonalReportService } from "@/lib/personal-report-service";
import { loadReportCandidateRange } from "@/lib/report-candidate-range";
import {
ACCOUNT_BIRTH_SELECT,
globalBirthProfileFromAccountRow,
} from "@/lib/server-owned-birth-profile";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
import { createServerSupabaseClient } from "@/lib/supabase/server";
export const runtime = "nodejs";
export const maxDuration = 300;
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const jyotishApiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200";
const APPENDIX_SELECT =
"report_id,user_id,request_id,status,markdown,content_sha256,attempt_count,last_error_code";
type RouteContext = { params: Promise<{ reportId: string }> };
@@ -24,7 +34,19 @@ type ProfessionalReferenceResponse = Readonly<{
markdown?: unknown;
}>;
function birthPayload(row: unknown): Record<string, unknown> | null {
function utcToday(): string {
return new Date().toISOString().slice(0, 10);
}
function birthPayload(
row: unknown,
extras: Readonly<{
today: string;
targetYear: number;
candidateRange: { start_time: string; end_time: string } | null;
birthTimeAccuracy: "confirmed" | "provisional";
}>,
): Record<string, unknown> | null {
const profile = globalBirthProfileFromAccountRow(row);
const dateMatch = /^(\d{4})-(\d{2})-(\d{2})$/.exec(profile.date ?? "");
const timeMatch = /^(\d{2}):(\d{2})$/.exec(profile.time ?? "");
@@ -37,8 +59,9 @@ function birthPayload(row: unknown): Record<string, unknown> | null {
) {
return null;
}
const year = Number.parseInt(dateMatch[1], 10);
return {
year: Number.parseInt(dateMatch[1], 10),
year,
month: Number.parseInt(dateMatch[2], 10),
day: Number.parseInt(dateMatch[3], 10),
hour: Number.parseInt(timeMatch[1], 10),
@@ -49,19 +72,36 @@ function birthPayload(row: unknown): Record<string, unknown> | null {
ayanamsa: profile.ayanamsa,
format: "markdown",
packs: ["full"],
today: extras.today,
target_year: extras.targetYear,
age: extras.targetYear - year,
birth_time_accuracy: extras.birthTimeAccuracy,
...(extras.candidateRange ? { candidate_range: extras.candidateRange } : {}),
};
}
function unavailableResponse() {
return NextResponse.json({ error: "全量数据附录暂不可用" }, { status: 503 });
}
function upstreamError(status: number, retryAfter: string | null) {
return NextResponse.json(
{ error: status === 429 ? "专业参考版生成繁忙,请稍后重试" : "专业参考版暂时无法生成" },
{ error: status === 429 ? "全量数据附录生成繁忙,请稍后重试" : "全量数据附录暂不可用" },
{
status,
status: status === 429 ? 429 : 503,
headers: retryAfter ? { "Retry-After": retryAfter } : undefined,
},
);
}
function tryAdminClient() {
try {
return createAdminSupabaseClient();
} catch {
return null;
}
}
export async function POST(request: Request, context: RouteContext) {
try {
const supabase = await createServerSupabaseClient();
@@ -93,16 +133,63 @@ export async function POST(request: Request, context: RouteContext) {
return NextResponse.json({ error: "报告尚未完成" }, { status: 409 });
}
const appendixRead = await supabase
.from(LONGFORM_APPENDIX_TABLE)
.select(APPENDIX_SELECT)
.eq("report_id", reportId)
.maybeSingle();
const cached = appendixRead.error ? null : parseLongformAppendixRow(appendixRead.data);
if (cached?.status === "ready" && cached.markdown) {
return NextResponse.json({ format: "markdown", markdown: cached.markdown });
}
if (cached?.status === "unavailable") {
return unavailableResponse();
}
const { data: profileRow, error: profileError } = await supabase
.from("profiles")
.select(ACCOUNT_BIRTH_SELECT)
.eq("id", user.id)
.maybeSingle();
const payload = profileError ? null : birthPayload(profileRow);
const admin = tryAdminClient();
const range = admin
? await loadReportCandidateRange(admin, { userId: user.id })
: null;
const today = utcToday();
const payload = profileError ? null : birthPayload(profileRow, {
today,
targetYear: Number(today.slice(0, 4)),
candidateRange: range ? { start_time: range.startTime, end_time: range.endTime } : null,
birthTimeAccuracy: range && range.startTime !== range.endTime ? "provisional" : "confirmed",
});
if (!payload) {
return NextResponse.json({ error: "出生资料不完整" }, { status: 422 });
}
const persistAppendix = async (input: {
successMarkdown?: string | null;
errorCode?: string | null;
}) => {
if (!admin || !report.requestId) return;
const next = nextLongformAppendixState({
current: cached,
successMarkdown: input.successMarkdown,
errorCode: input.errorCode,
});
await admin.from(LONGFORM_APPENDIX_TABLE).upsert({
report_id: reportId,
user_id: user.id,
request_id: report.requestId,
status: next.status,
markdown: next.markdown,
content_sha256: next.contentSha256,
attempt_count: next.attemptCount,
last_error_code: next.lastErrorCode,
generated_at: next.status === "ready" ? new Date().toISOString() : null,
updated_at: new Date().toISOString(),
});
};
const upstream = await fetch(`${jyotishApiBase}/api/professional_report_reference`, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
@@ -110,19 +197,24 @@ export async function POST(request: Request, context: RouteContext) {
cache: "no-store",
});
if (!upstream.ok) {
await persistAppendix({
errorCode: upstream.status === 429 ? "upstream_busy" : "upstream_unavailable",
}).catch(() => undefined);
return upstreamError(upstream.status, upstream.headers.get("retry-after"));
}
const result = await upstream.json().catch(() => null) as ProfessionalReferenceResponse | null;
if (result?.format !== "markdown" || typeof result.markdown !== "string" || !result.markdown.trim()) {
await persistAppendix({ errorCode: "empty_markdown" }).catch(() => undefined);
return upstreamError(502, null);
}
await persistAppendix({ successMarkdown: result.markdown }).catch(() => undefined);
return NextResponse.json({ format: "markdown", markdown: result.markdown });
} catch (error) {
if (isSupabaseConfigurationError(error)) {
return NextResponse.json({ error: "数据库尚未配置" }, { status: 503 });
}
console.error("professional_report_reference_failed", error instanceof Error ? error.name : "UnknownError");
return NextResponse.json({ error: "专业参考版暂时无法生成" }, { status: 500 });
return NextResponse.json({ error: "全量数据附录暂不可用" }, { status: 500 });
}
}