feat(reports): add professional reference export

This commit is contained in:
Jesse_Chen
2026-09-04 00:19:28 +08:00
parent c2f23131f7
commit 3b09bbbee0
11 changed files with 715 additions and 48 deletions
+5
View File
@@ -335,6 +335,11 @@ Text release is paced, not animated: the frame buffer commits at most once per a
- **States:** default, hover, active, focus, disabled, loading, fallback notice.
- **Visibility:** the initial cards, one per consultation domain, remain visible while the user types a custom question. Clicking a card starts that consultation immediately instead of filling the composer; the cards leave once the session receives its first user message.
### Personal report centre
- A ready report keeps “查看报告” as the primary document action and may add the quieter “专业参考版(导出)” action beside it. The reference action downloads Markdown through the authenticated same-origin report route; it is absent for generating and failed records.
- Export work uses the shared inline spinner inside the initiating button, reports a short row-local error, and never changes the stored report or starts a second writing flow.
### Product entrypoint card
- **Structure:** the homepage daily-reading and birth-time cards are single native-button targets stretched across their article surface. Content remains semantic card copy. On two-column viewports a compact action label and arrow sit at the trailing edge; on a stacked homepage they sit under the supporting line, leading-aligned, so a short label like “深入看今日” does not float to the opposite corner from “开始新的生时校正”.
@@ -0,0 +1,128 @@
import { NextResponse } from "next/server";
import {
checkSameOrigin,
resolveAllowedReportOrigins,
} from "@/lib/personal-report-entitlement";
import { createSupabasePersonalReportService } from "@/lib/personal-report-service";
import {
ACCOUNT_BIRTH_SELECT,
globalBirthProfileFromAccountRow,
} from "@/lib/server-owned-birth-profile";
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 jyotishApiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200";
type RouteContext = { params: Promise<{ reportId: string }> };
type ProfessionalReferenceResponse = Readonly<{
format?: unknown;
markdown?: unknown;
}>;
function birthPayload(row: unknown): 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 ?? "");
if (
!dateMatch
|| !timeMatch
|| profile.latitude === null
|| profile.longitude === null
|| profile.timezoneOffset === null
) {
return null;
}
return {
year: Number.parseInt(dateMatch[1], 10),
month: Number.parseInt(dateMatch[2], 10),
day: Number.parseInt(dateMatch[3], 10),
hour: Number.parseInt(timeMatch[1], 10),
minute: Number.parseInt(timeMatch[2], 10),
lat: profile.latitude,
lon: profile.longitude,
tz: profile.timezoneOffset,
ayanamsa: profile.ayanamsa,
format: "markdown",
packs: ["full"],
};
}
function upstreamError(status: number, retryAfter: string | null) {
return NextResponse.json(
{ error: status === 429 ? "专业参考版生成繁忙,请稍后重试" : "专业参考版暂时无法生成" },
{
status,
headers: retryAfter ? { "Retry-After": retryAfter } : undefined,
},
);
}
export async function POST(request: Request, context: RouteContext) {
try {
const supabase = await createServerSupabaseClient();
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const originDecision = checkSameOrigin(
request.url,
request.headers.get("origin"),
resolveAllowedReportOrigins(process.env),
request.headers,
);
if (!originDecision.ok) {
return NextResponse.json({ error: "跨站请求已拒绝" }, { status: 403 });
}
const { reportId } = await context.params;
if (!uuidPattern.test(reportId)) {
return NextResponse.json({ error: "报告不存在" }, { status: 404 });
}
const report = await createSupabasePersonalReportService(supabase).getOwnedById(user.id, reportId);
if (!report) {
return NextResponse.json({ error: "报告不存在" }, { status: 404 });
}
if (report.status !== "ready") {
return NextResponse.json({ error: "报告尚未完成" }, { status: 409 });
}
const { data: profileRow, error: profileError } = await supabase
.from("profiles")
.select(ACCOUNT_BIRTH_SELECT)
.eq("id", user.id)
.maybeSingle();
const payload = profileError ? null : birthPayload(profileRow);
if (!payload) {
return NextResponse.json({ error: "出生资料不完整" }, { status: 422 });
}
const upstream = await fetch(`${jyotishApiBase}/api/professional_report_reference`, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify(payload),
cache: "no-store",
});
if (!upstream.ok) {
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()) {
return upstreamError(502, null);
}
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 });
}
}
@@ -8,6 +8,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { GeneratePersonalReportButton } from "./generate-personal-report-button";
import { Button } from "@/components/ui/button";
import { useVisibilityAwarePoll } from "@/hooks/use-visibility-aware-poll";
import { downloadMarkdownReport } from "@/lib/consultation-report-export";
const LIST_POLL_INTERVAL_MS = 3000;
@@ -85,6 +86,8 @@ function StatusIcon({ status }: { status: ReportListItem["status"] }) {
export function PersonalReportCenter() {
const [state, setState] = useState<CenterState>({ phase: "loading", reports: [] });
const [exportingReportId, setExportingReportId] = useState<string | null>(null);
const [exportError, setExportError] = useState<{ reportId: string; message: string } | null>(null);
const cancelled = useRef(false);
const load = useCallback(async (showLoading = false) => {
@@ -136,6 +139,31 @@ export function PersonalReportCenter() {
[state.reports],
);
const downloadProfessionalReference = useCallback(async (reportId: string) => {
setExportingReportId(reportId);
setExportError(null);
try {
const response = await fetch(`/api/reports/${encodeURIComponent(reportId)}/professional-reference`, {
method: "POST",
credentials: "same-origin",
headers: { Accept: "application/json" },
});
const result: unknown = await response.json().catch(() => null);
const payload = result && typeof result === "object" ? result as Record<string, unknown> : {};
if (!response.ok || payload.format !== "markdown" || typeof payload.markdown !== "string") {
throw new Error(typeof payload.error === "string" ? payload.error : "专业参考版暂时无法生成");
}
downloadMarkdownReport("个人专业参考版", payload.markdown);
} catch (error) {
setExportError({
reportId,
message: error instanceof Error ? error.message : "专业参考版暂时无法生成",
});
} finally {
setExportingReportId(null);
}
}, []);
if (state.phase === "unauthorized") {
return (
<main className="report-center-shell report-center-message">
@@ -201,9 +229,25 @@ export function PersonalReportCenter() {
<small>{formatDate(report.createdAt)} · {report.depth} · {report.themes.join(" / ") || "综合主题"}</small>
</div>
{report.status === "ready" ? (
<Button render={<Link href={`/reports/${encodeURIComponent(report.id)}`} />} nativeButton={false} variant="outline">
</Button>
<div className="flex flex-wrap items-center justify-end gap-2 max-[720px]:justify-start">
<Button render={<Link href={`/reports/${encodeURIComponent(report.id)}`} />} nativeButton={false} variant="outline">
</Button>
<Button
type="button"
variant="ghost"
disabled={exportingReportId === report.id}
onClick={() => void downloadProfessionalReference(report.id)}
>
{exportingReportId === report.id ? <InlineSpinner size={14} /> : null}
</Button>
{exportError?.reportId === report.id ? (
<span className="basis-full text-right text-sm text-destructive max-[720px]:text-left" role="alert">
{exportError.message}
</span>
) : null}
</div>
) : report.status === "generating" ? (
<Button render={<Link href={`/reports/${encodeURIComponent(report.id)}`} />} nativeButton={false} variant="ghost">
@@ -279,3 +279,21 @@ test("legacy consultation Markdown export is untouched and still works", () => {
assert.match(pageSource, /consultation-report-export/);
assert.doesNotMatch(pageSource, /consultationReportMarkdown[\s\S]{0,200}生成个人报告/);
});
test("ready reports expose the professional Markdown reference export only in the ready branch", () => {
assert.match(reportCenterSource, /专业参考版(导出)/);
assert.match(reportCenterSource, /professional-reference/);
assert.match(reportCenterSource, /downloadMarkdownReport\("个人专业参考版", payload\.markdown\)/);
const readyActionStart = reportCenterSource.indexOf(
'{report.status === "ready" ? (',
);
const generatingActionStart = reportCenterSource.indexOf(
') : report.status === "generating" ? (',
readyActionStart,
);
assert.ok(readyActionStart >= 0 && generatingActionStart > readyActionStart);
const readyBranch = reportCenterSource.slice(readyActionStart, generatingActionStart);
assert.match(readyBranch, /专业参考版(导出)/);
assert.doesNotMatch(reportCenterSource.slice(generatingActionStart), /专业参考版(导出)/);
});
@@ -0,0 +1,39 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const routeSource = readFileSync(
new URL("../src/app/api/reports/[reportId]/professional-reference/route.ts", import.meta.url),
"utf8",
);
test("professional reference route authenticates, checks origin, owner and ready status", () => {
assert.match(routeSource, /createServerSupabaseClient/);
assert.match(routeSource, /supabase\.auth\.getUser\(\)/);
assert.match(routeSource, /checkSameOrigin/);
assert.match(routeSource, /resolveAllowedReportOrigins/);
assert.match(routeSource, /getOwnedById\(user\.id, reportId\)/);
assert.match(routeSource, /report\.status !== "ready"/);
assert.match(routeSource, /status: 401/);
assert.match(routeSource, /status: 403/);
assert.match(routeSource, /status: 404/);
assert.match(routeSource, /status: 409/);
});
test("birth data stays server-owned and the route calls only the public Python export", () => {
assert.match(routeSource, /\.from\("profiles"\)/);
assert.match(routeSource, /select\(ACCOUNT_BIRTH_SELECT\)/);
assert.match(routeSource, /globalBirthProfileFromAccountRow/);
assert.match(routeSource, /\/api\/professional_report_reference/);
assert.match(routeSource, /format: "markdown"/);
assert.match(routeSource, /packs: \["full"\]/);
assert.doesNotMatch(routeSource, /request\.json\(/);
assert.doesNotMatch(routeSource, /mastra|writer|billing|personal_report_sections/i);
assert.doesNotMatch(routeSource, /\.insert\(|\.update\(|\.delete\(/);
});
test("busy upstream responses preserve 429 and Retry-After", () => {
assert.match(routeSource, /upstream\.status/);
assert.match(routeSource, /upstream\.headers\.get\("retry-after"\)/);
assert.match(routeSource, /"Retry-After": retryAfter/);
});