feat(reports): add professional reference export
This commit is contained in:
@@ -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">
|
||||
查看进度
|
||||
|
||||
Reference in New Issue
Block a user