import { parseReportCandidateRange, type ReportCandidateClockRange } from "./report-candidate-range"; /** * 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 { effectiveBirthDate } from "./effective-birth-date"; 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 { deriveSectionProgressState, type ReportSectionProgress, } from "./personal-report-progress"; import type { ReportBillingPort } from "./personal-report-billing"; import { checkSameOrigin } from "./personal-report-entitlement"; import { projectOrdinaryReportDocument, projectOrdinaryReportMarkdown, } from "./report-public-projection"; 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; export type ReportRouteResponse = Readonly<{ status: number; body: Record }>; export type ReportServicePort = Pick< PersonalReportService, | "getByUserAndRequestId" | "createGenerating" | "completeReady" | "markFailed" | "getOwnedById" | "deleteOwned" >; type JsonRecord = Record; 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 | null, sections?: readonly ReportSectionProgress[] | 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 } : {}), ...(sections && sections.length > 0 ? { sections } : {}), ...(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: projectOrdinaryReportDocument(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; userId: string | null; rawBody: unknown; profile: unknown | null; loadCandidateRange?: () => Promise; checkSessionOwned: (sessionId: string) => Promise; checkChartProfileOwned: (chartProfileId: string) => Promise; featureEnabled: boolean; dailyLimit: number; counts: Readonly<{ countGenerating: () => Promise; countCreatedToday: () => Promise; }>; persistence: ReportServicePort; model: Readonly<{ id: string; configVersion?: number; inputCostMicrousdPerMillion?: number; outputCostMicrousdPerMillion?: number }> | null; billing?: ReportBillingPort; runWorkflow: (input: ConsultationInput) => Promise; 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; now?: () => Date; }>; function candidateWindow(range: ReportCandidateClockRange | null): ReportCandidateClockRange | null { if (!range) return null; const parsed = parseReportCandidateRange(range); if (!parsed) throw new Error("report_candidate_range_invalid"); return parsed; } export function resolveReportBirthTimeSensitivityInput( profileValue: unknown, birthTimeStatus: ReportSubjectBirthTimeStatus, representativeTime: string, candidateRange: ReportCandidateClockRange | null, ): Partial { void profileValue; if (birthTimeStatus === "confirmed") { return { birth_time_accuracy: "confirmed", representative_time: representativeTime }; } const trustedRange = candidateWindow(candidateRange); if (trustedRange && !trustedRange.candidate_intervals && 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, ...(trustedRange.candidate_intervals ? { candidate_intervals: trustedRange.candidate_intervals } : {}) } } : base; } export async function resolveReportCreate(deps: ReportCreateCoreDeps): Promise { // 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(effectiveBirthDate(profile)); 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 => { // Real workflow evidence (main chain), never mock/example/random data. let sensitivityInput: Partial; 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: projectOrdinaryReportDocument(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; userId: string | null; reportId: string; persistence: Pick; /** * 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; /** * Durable section rows. `sectionId` and `attemptCount` are read here to * derive reader-visible chapter state and never leave the server as-is; * see personal-report-progress.ts for the boundary. */ listSections?: (userId: string, requestId: string) => Promise[]>; loadLongformMarkdown?: (input: Readonly<{ userId: string; reportId: string; }>) => Promise; loadLongformAppendix?: (input: Readonly<{ userId: string; reportId: string; }>) => Promise<{ status: string; lastErrorCode: string | null; markdown: string | null; } | null>; }>; export async function resolveReportRead(deps: ReportReadCoreDeps): Promise { 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; // Section rows back two different readers: the failure summary, and the // chapter progress the waiting screen shows while the run is still going. // A ready report needs neither, so it keeps its single-query read path. const sectionRows = (row.status === "failed" || row.status === "generating") && deps.listSections ? await deps.listSections(deps.userId, row.requestId) : []; const failure = row.status === "failed" ? summarizePersonalReportFailure({ themeCount: row.requestedThemes.length, sections: sectionRows, failureCode: row.failureCode, appendixLastErrorCode: appendix?.lastErrorCode ?? null, }) : null; const sectionProgress: readonly ReportSectionProgress[] | null = row.status === "generating" ? sectionRows.flatMap((section) => ( typeof section.sectionId === "string" && section.sectionId.length > 0 ? [{ id: section.sectionId, state: deriveSectionProgressState(section.status, section.attemptCount ?? 0), }] : [] )) : 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: projectOrdinaryReportMarkdown(markdown), ...(validated.ok && validated.document && typeof validated.document === "object" && "charts" in validated.document ? { calculationCharts: validated.document.charts } : {}), ...(validated.ok && validated.document && typeof validated.document === "object" && "factTables" in validated.document && validated.document.factTables ? { factTables: validated.document.factTables } : {}), ...(validated.ok ? { reportDocument: projectOrdinaryReportDocument(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: projectOrdinaryReportDocument(validated.document) }, }; } return { status: 200, body: { report: reportView(row, job, failure, sectionProgress) } }; } export type ReportDeleteCoreDeps = Readonly<{ requestUrl: string; origin: string | null; allowedOrigins: readonly string[]; requestHeaders?: Pick; userId: string | null; reportId: string; persistence: Pick; }>; export async function resolveReportDelete(deps: ReportDeleteCoreDeps): Promise { 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 } }; }