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>
785 lines
28 KiB
TypeScript
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 } };
|
|
}
|