Files
Jyotisha/frontend/src/lib/personal-report-route-core.ts
T
Jesse_ChenandCursor e87c58d6e4 fix(report): persist longform appendix on self-hosted upsert (BUG-576)
Engine calls already returned markdown; the worker failed because local postgres upsert required onConflict and the appendix write omitted it.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 14:19:21 +08:00

785 lines
28 KiB
TypeScript

/**
* Personal report route handlers as a dependency-injected core (no
* next/server, no network, no model): fully executable in unit tests with
* fakes. The API routes are thin adapters that resolve the real production
* dependencies (authenticated Supabase reads, admin-backed persistence,
* existing auth/profile/workflow) and map the returned { status, body } to
* NextResponse.
*
* Ownership is enforced twice: the authenticated client/RLS scopes reads and
* deletes, and the persistence service scopes every query by userId.
*/
import { z } from "zod";
import type { ConsultationInput } from "@/mastra";
import { resolveAyanamsa } from "@/lib/ayanamsa";
import type {
ReportAgentPort,
ReportEvidenceBundleV2,
} from "@/mastra/personal-report";
import {
buildReportEvidenceBundleV2,
computeRequestFingerprint,
generatePersonalReport,
type GeneratePersonalReportResult,
type SkillSnapshot,
} from "./personal-report-generation";
import { REPORT_STABLE_CODES } from "./personal-report-codes";
import { summarizePersonalReportFailure } from "./personal-report-failure-summary";
import type { ReportBillingPort } from "./personal-report-billing";
import { checkSameOrigin } from "./personal-report-entitlement";
import type { PersonalReportJobRecord, PersonalReportJobService } from "./personal-report-job-service-core";
import type {
CreateGeneratingInput,
CreateGeneratingResult,
PersonalReportRecord,
PersonalReportService,
} from "./personal-report-service-core";
const reportRequestThemes = z.enum(["career", "marriage", "wealth", "health", "timing", "general"]);
export const personalReportCreateRequestSchema = z.object({
requestId: z.string().uuid(),
sessionId: z.string().uuid().nullable().optional(),
chartProfileId: z.string().uuid().nullable().optional(),
reportType: z.enum(["personal_full", "personal_thematic"]),
presentationMode: z.enum(["default", "research"]).default("default"),
depth: z.enum(["concise", "standard", "deep", "research"]).default("standard"),
themes: z.array(reportRequestThemes).min(1).max(6).default(["career", "marriage", "wealth", "timing", "health"]),
}).strict();
export type PersonalReportCreateRequest = z.infer<typeof personalReportCreateRequestSchema>;
export type ReportRouteResponse = Readonly<{ status: number; body: Record<string, unknown> }>;
export type ReportServicePort = Pick<
PersonalReportService,
| "getByUserAndRequestId"
| "createGenerating"
| "completeReady"
| "markFailed"
| "getOwnedById"
| "deleteOwned"
>;
type JsonRecord = Record<string, unknown>;
function record(value: unknown): JsonRecord | null {
return value !== null && typeof value === "object" && !Array.isArray(value)
? value as JsonRecord
: null;
}
function text(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function finiteNumber(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
const CONCRETE_REPORTED_SOURCES = new Set(["hospital_record", "family_exact", "approximate"]);
export type ReportSubjectBirthTimeStatus = "reported" | "candidate" | "accepted" | "confirmed";
export function resolveReportBirthClock(profile: JsonRecord): {
clock: { hour: number; minute: number };
status: ReportSubjectBirthTimeStatus;
} | null {
const status = text(profile.birth_time_status);
const source = text(profile.birth_time_source);
if (status === "accepted" || status === "confirmed") {
const clock = parseClockMinutes(profile.active_birth_time);
return clock ? { clock, status } : null;
}
const reported = parseClockMinutes(profile.reported_birth_time);
if (!reported || !source || !CONCRETE_REPORTED_SOURCES.has(source)) return null;
if (status === "candidate") return { clock: reported, status: "candidate" };
if (status === "reported" || status === "assessing" || status === "rectifying") {
return { clock: reported, status: "reported" };
}
return null;
}
function parseClockMinutes(value: unknown): { hour: number; minute: number } | null {
const clock = text(value);
if (!clock) return null;
const match = /^(\d{1,2}):(\d{2})(?::\d{2})?$/.exec(clock);
if (!match) return null;
const hour = Number.parseInt(match[1], 10);
const minute = Number.parseInt(match[2], 10);
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return null;
return { hour, minute };
}
function parseBirthDate(value: unknown): { year: number; month: number; day: number } | null {
const date = text(value);
if (!date) return null;
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date);
if (!match) return null;
const year = Number.parseInt(match[1], 10);
const month = Number.parseInt(match[2], 10);
const day = Number.parseInt(match[3], 10);
if (year < 1900 || year > 2100 || month < 1 || month > 12 || day < 1 || day > 31) return null;
return { year, month, day };
}
export function reportListTimestamp(value: unknown): string {
if (typeof value === "string") {
return Number.isFinite(Date.parse(value)) ? value : "";
}
if (value instanceof Date && Number.isFinite(value.getTime())) return value.toISOString();
return "";
}
export function reportView(
row: PersonalReportRecord,
job?: PersonalReportJobRecord | null,
failure?: ReturnType<typeof summarizePersonalReportFailure> | null,
) {
return {
id: row.id,
requestId: row.requestId,
reportType: row.reportType,
presentationMode: row.presentationMode,
depth: row.depth,
status: row.status,
failureCode: row.failureCode,
createdAt: row.createdAt,
completedAt: row.completedAt,
...(job ? { progressPercent: job.progressPercent, progressPhase: job.progressPhase } : {}),
...(failure?.summary ? { failureSummary: failure.summary } : {}),
...(failure?.innerReason ? { innerReason: failure.innerReason } : {}),
...(failure && failure.lastErrorCodes.length > 0 ? { sectionErrorCodes: failure.lastErrorCodes } : {}),
...(failure?.appendixLastErrorCode ? { appendixLastErrorCode: failure.appendixLastErrorCode } : {}),
};
}
function replayOrConflict(
existing: PersonalReportRecord,
fingerprint: string,
): ReportRouteResponse {
if (existing.requestFingerprint !== fingerprint) {
return {
status: 409,
body: { error: "请求内容与已有记录不一致", code: REPORT_STABLE_CODES.requestConflict },
};
}
if (existing.status === "ready") {
return {
status: 200,
body: { report: reportView(existing), reportDocument: existing.reportDocument },
};
}
if (existing.status === "generating") {
return {
status: 409,
body: { error: "该报告正在生成中", code: REPORT_STABLE_CODES.generationInProgress },
};
}
// A failed record is never silently resurrected: surface the stable
// failure. Retrying requires a new requestId.
return { status: 200, body: { report: reportView(existing) } };
}
export type ReportCreateCoreDeps = Readonly<{
requestUrl: string;
origin: string | null;
allowedOrigins: readonly string[];
requestHeaders?: Pick<Headers, "get" | "has">;
userId: string | null;
rawBody: unknown;
profile: unknown | null;
loadCandidateRange?: () => Promise<Readonly<{ startTime: string; endTime: string }> | null>;
checkSessionOwned: (sessionId: string) => Promise<boolean>;
checkChartProfileOwned: (chartProfileId: string) => Promise<boolean>;
featureEnabled: boolean;
dailyLimit: number;
counts: Readonly<{
countGenerating: () => Promise<number>;
countCreatedToday: () => Promise<number>;
}>;
persistence: ReportServicePort;
model: Readonly<{ id: string; configVersion?: number; inputCostMicrousdPerMillion?: number; outputCostMicrousdPerMillion?: number }> | null;
billing?: ReportBillingPort;
runWorkflow: (input: ConsultationInput) => Promise<unknown>;
createAgent: (model: Readonly<{ id: string }>) => ReportAgentPort;
skillSnapshot: SkillSnapshot;
/** Production supplies the durable queue. Tests may omit it to execute the
* full pipeline inline with deterministic fakes. */
jobs?: Pick<PersonalReportJobService, "enqueue">;
now?: () => Date;
}>;
const candidateClockPattern = /^((?:[01]\d|2[0-3]):[0-5]\d)(?::00(?:\.0+)?)?$/;
function candidateWindow(
range: Readonly<{ startTime: string; endTime: string }> | null,
): { startTime: string; endTime: string } | null {
if (!range) return null;
const startTime = text(range.startTime)?.match(candidateClockPattern)?.[1];
const endTime = text(range.endTime)?.match(candidateClockPattern)?.[1];
if (!startTime || !endTime || startTime > endTime) throw new Error("report_candidate_range_invalid");
return { startTime, endTime };
}
export function resolveReportBirthTimeSensitivityInput(
profileValue: unknown,
birthTimeStatus: ReportSubjectBirthTimeStatus,
representativeTime: string,
candidateRange: Readonly<{ startTime: string; endTime: string }> | null,
): Partial<ConsultationInput> {
void profileValue;
if (birthTimeStatus === "confirmed") {
return { birth_time_accuracy: "confirmed", representative_time: representativeTime };
}
const trustedRange = candidateWindow(candidateRange);
if (trustedRange && trustedRange.startTime === trustedRange.endTime) {
return { birth_time_accuracy: "confirmed", representative_time: representativeTime };
}
const base = {
birth_time_accuracy: birthTimeStatus === "reported" ? "approximate" : "provisional",
representative_time: representativeTime,
} as const;
return trustedRange
? { ...base, candidate_range: { start_time: trustedRange.startTime, end_time: trustedRange.endTime } }
: base;
}
export async function resolveReportCreate(deps: ReportCreateCoreDeps): Promise<ReportRouteResponse> {
// Same-origin gate first (CSRF), then auth.
const originDecision = checkSameOrigin(deps.requestUrl, deps.origin, deps.allowedOrigins, deps.requestHeaders);
if (!originDecision.ok) {
return {
status: 403,
body: { error: "跨域请求被拒绝", code: REPORT_STABLE_CODES.resourceForbidden },
};
}
if (!deps.userId) {
return { status: 401, body: { error: "请先登录" } };
}
const userId = deps.userId;
const parsed = personalReportCreateRequestSchema.safeParse(deps.rawBody);
if (!parsed.success) {
return {
status: 400,
body: { error: "报告请求格式不正确", code: REPORT_STABLE_CODES.invalidRequest },
};
}
const payload = parsed.data;
// Profile truth + birth status. Missing or unusable profile is 422.
const profile = record(deps.profile);
if (!profile) {
return {
status: 422,
body: { error: "请先完善出生资料", code: REPORT_STABLE_CODES.profileIncomplete },
};
}
const birthDate = parseBirthDate(profile.birth_date);
const usableBirth = resolveReportBirthClock(profile);
const latitude = finiteNumber(profile.latitude);
const longitude = finiteNumber(profile.longitude);
const timezoneOffset = finiteNumber(profile.timezone_offset);
const displayName = text(profile.name) ?? "我的报告";
const birthPlaceLabel = text(profile.birth_place_label) ?? "未知出生地";
if (!usableBirth) {
return {
status: 422,
body: {
error: "完整本命报告需要具体出生分钟;生时校正可选,系统不会补造时间",
code: REPORT_STABLE_CODES.birthTimeNotUsable,
},
};
}
const birthClock = usableBirth.clock;
const birthTimeStatus = usableBirth.status;
if (!birthDate || latitude === null || longitude === null
|| timezoneOffset === null) {
return {
status: 422,
body: { error: "出生资料不完整,无法生成报告", code: REPORT_STABLE_CODES.birthTimeNotUsable },
};
}
// Session / chart-profile ownership (when provided).
if (payload.sessionId && !(await deps.checkSessionOwned(payload.sessionId))) {
return {
status: 403,
body: { error: "会话不属于当前用户", code: REPORT_STABLE_CODES.resourceForbidden },
};
}
if (payload.chartProfileId && !(await deps.checkChartProfileOwned(payload.chartProfileId))) {
return {
status: 403,
body: { error: "星盘资料不属于当前用户", code: REPORT_STABLE_CODES.resourceForbidden },
};
}
if (!deps.featureEnabled) {
return {
status: 403,
body: { error: "个人报告功能暂未开放", code: REPORT_STABLE_CODES.exportDisabled },
};
}
// Canonical request fingerprint: payload identity only, requestId excluded.
const fingerprint = computeRequestFingerprint({
reportType: payload.reportType,
presentationMode: payload.presentationMode,
depth: payload.depth,
themes: payload.themes,
sessionId: payload.sessionId ?? null,
chartProfileId: payload.chartProfileId ?? null,
});
// Idempotent replay: an existing row with the same fingerprint returns the
// stored state; a different payload under the same requestId is a 409
// request conflict — never treated as a replay.
const existing = await deps.persistence.getByUserAndRequestId(userId, payload.requestId);
if (existing) {
return replayOrConflict(existing, fingerprint);
}
const generating = await deps.counts.countGenerating();
if (generating > 0) {
return {
status: 409,
body: { error: "已有报告正在生成中", code: REPORT_STABLE_CODES.generationInProgress },
};
}
const createdToday = await deps.counts.countCreatedToday();
if (createdToday >= deps.dailyLimit) {
return {
status: 429,
body: { error: "今日报告生成次数已达上限", code: REPORT_STABLE_CODES.rateLimited },
};
}
const selectedModel = deps.model;
let billingReserved = false;
if (deps.billing && selectedModel) {
let authorization;
try {
authorization = await deps.billing.reserve({ userId, requestId: payload.requestId, modelId: selectedModel.id });
} catch {
return {
status: 503,
body: { error: "暂时无法确认报告点数", code: REPORT_STABLE_CODES.billingUnavailable },
};
}
if (!authorization.success) {
const insufficient = authorization.reason === REPORT_STABLE_CODES.insufficientCredits;
return {
status: insufficient ? 402 : 503,
body: {
error: insufficient ? "报告点数不足" : "暂时无法扣除报告点数",
message: authorization.reason ?? "请稍后重试。",
code: insufficient ? REPORT_STABLE_CODES.insufficientCredits : REPORT_STABLE_CODES.fairUseLimited,
...(authorization.retry_after_seconds === null ? {} : { retryAfterSeconds: authorization.retry_after_seconds }),
},
};
}
billingReserved = true;
}
const releaseBilling = async (reason: string) => {
if (!billingReserved || !deps.billing) return;
billingReserved = false;
try { await deps.billing.release({ userId, requestId: payload.requestId, reason }); } catch { /* release is best effort; the RPC is idempotent */ }
};
const createInput: CreateGeneratingInput = {
userId,
requestId: payload.requestId,
requestFingerprint: fingerprint,
reportType: payload.reportType,
presentationMode: payload.presentationMode,
depth: payload.depth,
requestedThemes: payload.themes,
sessionId: payload.sessionId ?? null,
chartProfileId: payload.chartProfileId ?? null,
skillName: deps.skillSnapshot.name,
skillVersion: deps.skillSnapshot.version,
skillSourceCommit: deps.skillSnapshot.sourceCommit,
skillSnapshotSha256: deps.skillSnapshot.sha256,
};
let begun: CreateGeneratingResult;
try {
begun = await deps.persistence.createGenerating(createInput);
} catch (error) {
await releaseBilling(REPORT_STABLE_CODES.generationFailed);
throw error;
}
if (begun.kind === "generation_in_progress") {
await releaseBilling(REPORT_STABLE_CODES.generationInProgress);
return {
status: 409,
body: { error: "已有报告正在生成中", code: REPORT_STABLE_CODES.generationInProgress },
};
}
if (begun.kind === "request_conflict") {
await releaseBilling(REPORT_STABLE_CODES.requestConflict);
return {
status: 409,
body: { error: "请求内容与已有记录不一致", code: REPORT_STABLE_CODES.requestConflict },
};
}
if (begun.kind === "replayed") {
return replayOrConflict(begun.record, fingerprint);
}
const row = begun.record;
if (!selectedModel) {
await deps.persistence.markFailed(userId, row.id, REPORT_STABLE_CODES.modelUnavailable);
return {
status: 502,
body: { error: "报告模型暂不可用", code: REPORT_STABLE_CODES.modelUnavailable },
};
}
const finishGeneration = async (): Promise<ReportRouteResponse> => {
// Real workflow evidence (main chain), never mock/example/random data.
let sensitivityInput: Partial<ConsultationInput>;
try {
sensitivityInput = resolveReportBirthTimeSensitivityInput(
profile,
birthTimeStatus,
`${String(birthClock.hour).padStart(2, "0")}:${String(birthClock.minute).padStart(2, "0")}`,
birthTimeStatus === "confirmed" || !deps.loadCandidateRange
? null
: await deps.loadCandidateRange(),
);
} catch {
await deps.persistence.markFailed(userId, row.id, REPORT_STABLE_CODES.calculationUnavailable);
await releaseBilling(REPORT_STABLE_CODES.calculationUnavailable);
return {
status: 502,
body: { error: "出生时间可信区间不可用", code: REPORT_STABLE_CODES.calculationUnavailable },
};
}
const workflowInputs: ConsultationInput[] = payload.themes.map((theme) => ({
year: birthDate.year,
month: birthDate.month,
day: birthDate.day,
hour: birthClock.hour,
minute: birthClock.minute,
lat: latitude,
lon: longitude,
tz: timezoneOffset,
city: birthPlaceLabel,
ayanamsa: resolveAyanamsa(profile),
question: `请为个人报告计算 ${theme} 主题证据`,
theme,
entryMode: "direct_chart",
...sensitivityInput,
}));
const workflows: { theme: string; workflow: unknown }[] = [];
try {
for (const workflowInput of workflowInputs) {
workflows.push({
theme: workflowInput.theme ?? "general",
workflow: await deps.runWorkflow(workflowInput),
});
}
} catch {
await deps.persistence.markFailed(userId, row.id, REPORT_STABLE_CODES.calculationUnavailable);
await releaseBilling(REPORT_STABLE_CODES.calculationUnavailable);
return {
status: 502,
body: { error: "排盘引擎暂不可用", code: REPORT_STABLE_CODES.calculationUnavailable },
};
}
const hasUsableBaseChart = workflows.some(({ workflow }) => {
const workflowRecord = record(workflow);
return workflowRecord?.success === true && Boolean(record(workflowRecord.chart));
});
if (!hasUsableBaseChart) {
await deps.persistence.markFailed(userId, row.id, REPORT_STABLE_CODES.calculationUnavailable);
await releaseBilling(REPORT_STABLE_CODES.calculationUnavailable);
return {
status: 502,
body: { error: "排盘引擎未返回可用星盘", code: REPORT_STABLE_CODES.calculationUnavailable },
};
}
let bundle: ReportEvidenceBundleV2;
try {
bundle = buildReportEvidenceBundleV2({
workflows,
subject: {
displayName,
birthTimeStatus,
birthPlaceLabel,
},
requestedThemes: payload.themes,
reportType: payload.reportType,
presentationMode: payload.presentationMode,
skillSnapshot: deps.skillSnapshot,
});
} catch (error) {
// D1/base evidence failure closes the whole report. A thematic evidence
// gap is represented inside the Bundle as a blocked section instead.
if (error instanceof Error && error.name === "ReportEvidenceInsufficientError") {
await deps.persistence.markFailed(userId, row.id, REPORT_STABLE_CODES.calculationUnavailable);
await releaseBilling(REPORT_STABLE_CODES.calculationUnavailable);
return {
status: 422,
body: { error: "排盘证据不足以生成诚实报告", code: REPORT_STABLE_CODES.calculationUnavailable },
};
}
throw error;
}
const result: GeneratePersonalReportResult = await generatePersonalReport({
reportId: row.id,
bundle,
depth: payload.depth,
agent: deps.createAgent(selectedModel),
now: deps.now,
});
if (result.status === "failed") {
await deps.persistence.markFailed(userId, row.id, result.failureCode);
await releaseBilling(result.failureCode);
return {
status: 422,
body: {
error: result.failureCode === REPORT_STABLE_CODES.guardRejected
? "报告未通过确定性校验"
: "报告内容未通过结构校验",
code: result.failureCode,
},
};
}
try {
const readyRow = await deps.persistence.completeReady(userId, row.id, result.document);
if (deps.billing) {
const settled = await deps.billing.complete({
userId, requestId: payload.requestId,
usage: {
actualModelId: selectedModel.id,
modelConfigVersion: selectedModel.configVersion,
inputTokens: result.usage?.inputTokens ?? 0,
outputTokens: result.usage?.outputTokens ?? 0,
durationMs: 0,
cache: result.usage?.cache,
},
});
if (!settled) throw new Error("report billing settlement failed");
billingReserved = false;
}
return {
status: 201,
body: { report: reportView(readyRow), reportDocument: readyRow.reportDocument },
};
} catch {
await deps.persistence.markFailed(userId, row.id, REPORT_STABLE_CODES.schemaInvalid);
await releaseBilling(REPORT_STABLE_CODES.schemaInvalid);
return {
status: 422,
body: { error: "报告未通过合同校验", code: REPORT_STABLE_CODES.schemaInvalid },
};
}
};
if (deps.jobs) {
try {
const enqueued = await deps.jobs.enqueue({
userId,
requestId: payload.requestId,
requestFingerprint: fingerprint,
});
if (enqueued.kind === "request_conflict") {
await deps.persistence.markFailed(userId, row.id, REPORT_STABLE_CODES.schemaInvalid);
await releaseBilling(REPORT_STABLE_CODES.requestConflict);
return {
status: 409,
body: { error: "请求内容与已有任务不一致", code: REPORT_STABLE_CODES.requestConflict },
};
}
if (enqueued.kind === "active_job_exists") {
await deps.persistence.markFailed(userId, row.id, REPORT_STABLE_CODES.generationInProgress);
await releaseBilling(REPORT_STABLE_CODES.generationInProgress);
return {
status: 409,
body: { error: "已有报告任务正在处理中", code: REPORT_STABLE_CODES.generationInProgress },
};
}
return {
status: 202,
body: { report: reportView(row), jobId: enqueued.job.id },
};
} catch {
await deps.persistence.markFailed(userId, row.id, REPORT_STABLE_CODES.schemaInvalid);
await releaseBilling(REPORT_STABLE_CODES.generationFailed);
return {
status: 503,
body: { error: "报告任务暂时无法入队", code: REPORT_STABLE_CODES.generationFailed },
};
}
}
return finishGeneration();
}
export type ReportReadCoreDeps = Readonly<{
requestUrl: string;
origin: string | null;
allowedOrigins: readonly string[];
requestHeaders?: Pick<Headers, "get" | "has">;
userId: string | null;
reportId: string;
persistence: Pick<PersonalReportService, "getOwnedById">;
/**
* Canonical server re-validation of a stored ready document (defense in
* depth: a polluted DB row must never reach the browser). Production wires
* safeParseServerReportDocument; tests inject fakes or the real parser.
*/
validateReadyDocument: (
document: unknown,
) => { ok: true; document: unknown } | { ok: false };
jobs?: Pick<PersonalReportJobService, "getOwnedByRequestId">;
listSections?: (userId: string, requestId: string) => Promise<readonly Readonly<{
status: string;
lastErrorCode: string | null;
}>[]>;
loadLongformMarkdown?: (input: Readonly<{
userId: string;
reportId: string;
}>) => Promise<string | null>;
loadLongformAppendix?: (input: Readonly<{
userId: string;
reportId: string;
}>) => Promise<{
status: string;
lastErrorCode: string | null;
markdown: string | null;
} | null>;
}>;
export async function resolveReportRead(deps: ReportReadCoreDeps): Promise<ReportRouteResponse> {
const originDecision = checkSameOrigin(deps.requestUrl, deps.origin, deps.allowedOrigins, deps.requestHeaders);
if (!originDecision.ok) {
return {
status: 403,
body: { error: "跨域请求被拒绝", code: REPORT_STABLE_CODES.resourceForbidden },
};
}
if (!deps.userId) {
return { status: 401, body: { error: "请先登录" } };
}
const row = await deps.persistence.getOwnedById(deps.userId, deps.reportId);
if (!row) {
return {
status: 404,
body: { error: "报告不存在", code: REPORT_STABLE_CODES.notFound },
};
}
const job = deps.jobs ? await deps.jobs.getOwnedByRequestId(deps.userId, row.requestId) : null;
const appendix = deps.loadLongformAppendix
? await deps.loadLongformAppendix({ userId: deps.userId, reportId: deps.reportId })
: null;
const failure = row.status === "failed"
? summarizePersonalReportFailure({
themeCount: row.requestedThemes.length,
sections: deps.listSections ? await deps.listSections(deps.userId, row.requestId) : [],
failureCode: row.failureCode,
appendixLastErrorCode: appendix?.lastErrorCode ?? null,
})
: null;
if (row.status === "ready") {
const markdownFromAppendix = appendix?.status === "ready" && appendix.markdown?.trim()
? appendix.markdown
: null;
const markdown = markdownFromAppendix
?? (deps.loadLongformMarkdown
? await deps.loadLongformMarkdown({ userId: deps.userId, reportId: deps.reportId })
: undefined);
if (typeof markdown === "string" && markdown.trim()) {
const validated = deps.validateReadyDocument(row.reportDocument);
return {
status: 200,
body: {
report: reportView(row, job),
longformMarkdown: markdown,
...(validated.ok ? { reportDocument: validated.document } : {}),
},
};
}
// Re-validate the stored document through the canonical server parse
// before it is allowed to leave the server; an invalid stored document is
// surfaced as a stable failure WITHOUT the document body. When the MD
// loader is wired and the appendix is missing, the client shows the
// legacy placeholder instead of the five-chapter document.
const validated = deps.validateReadyDocument(row.reportDocument);
if (deps.loadLongformMarkdown || deps.loadLongformAppendix) {
return {
status: 200,
body: { report: reportView(row, job), longformMarkdown: null },
};
}
if (!validated.ok) {
return {
status: 422,
body: {
error: "报告内容未通过合同校验",
code: REPORT_STABLE_CODES.schemaInvalid,
report: reportView(row, job),
},
};
}
return {
status: 200,
body: { report: reportView(row, job), reportDocument: validated.document },
};
}
return { status: 200, body: { report: reportView(row, job, failure) } };
}
export type ReportDeleteCoreDeps = Readonly<{
requestUrl: string;
origin: string | null;
allowedOrigins: readonly string[];
requestHeaders?: Pick<Headers, "get" | "has">;
userId: string | null;
reportId: string;
persistence: Pick<PersonalReportService, "getOwnedById" | "deleteOwned">;
}>;
export async function resolveReportDelete(deps: ReportDeleteCoreDeps): Promise<ReportRouteResponse> {
const originDecision = checkSameOrigin(deps.requestUrl, deps.origin, deps.allowedOrigins, deps.requestHeaders);
if (!originDecision.ok) {
return {
status: 403,
body: { error: "跨域请求被拒绝", code: REPORT_STABLE_CODES.resourceForbidden },
};
}
if (!deps.userId) {
return { status: 401, body: { error: "请先登录" } };
}
const row = await deps.persistence.getOwnedById(deps.userId, deps.reportId);
if (!row) {
return {
status: 404,
body: { error: "报告不存在", code: REPORT_STABLE_CODES.notFound },
};
}
const removed = await deps.persistence.deleteOwned(deps.userId, row.id);
if (!removed) {
return {
status: 404,
body: { error: "报告不存在", code: REPORT_STABLE_CODES.notFound },
};
}
return { status: 200, body: { ok: true } };
}