feat(report): ship full-mode longform appendix beside the five-chapter report
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:
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user