feat(report): generate grounded reports with Mastra
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Stable error/failure codes for the personal report API. Kept in a pure
|
||||
* dependency-free module (no filesystem, no path, no crypto imports) so route
|
||||
* handlers that only need codes never pull the skill-snapshot scanner or any
|
||||
* other generation logic into their bundle/trace.
|
||||
*/
|
||||
|
||||
export const REPORT_STABLE_CODES = {
|
||||
profileIncomplete: "profile_incomplete",
|
||||
birthTimeNotUsable: "birth_time_not_usable",
|
||||
generationInProgress: "report_generation_in_progress",
|
||||
rateLimited: "report_rate_limited",
|
||||
calculationUnavailable: "calculation_unavailable",
|
||||
modelUnavailable: "model_unavailable",
|
||||
schemaInvalid: "report_schema_invalid",
|
||||
guardRejected: "report_guard_rejected",
|
||||
notFound: "report_not_found",
|
||||
requestConflict: "report_request_conflict",
|
||||
exportDisabled: "report_export_disabled",
|
||||
invalidRequest: "invalid_request",
|
||||
resourceForbidden: "report_resource_forbidden",
|
||||
generationFailed: "report_generation_failed",
|
||||
} as const;
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Personal report export entitlement — independent from the "spend 1 credit"
|
||||
* consultation RPC. Capability key: report.export.personal.
|
||||
*
|
||||
* Staging free policy: login-only, single concurrent generation per user,
|
||||
* daily limit read from environment configuration (never hardcoded in UI).
|
||||
* If the feature is later priced, a reserve/refund flow plugs in behind the
|
||||
* same interface; this module stays billing-agnostic.
|
||||
*/
|
||||
|
||||
export const REPORT_EXPORT_PERSONAL_CAPABILITY_KEY = "report.export.personal";
|
||||
|
||||
export const REPORT_FEATURE_ENV = "PERSONAL_REPORT_ENABLED";
|
||||
export const REPORT_DAILY_LIMIT_ENV = "PERSONAL_REPORT_DAILY_LIMIT";
|
||||
export const REPORT_ALLOWED_ORIGINS_ENV = "PERSONAL_REPORT_ALLOWED_ORIGINS";
|
||||
|
||||
/**
|
||||
* Server-side default when the env variable is absent. The UI must never
|
||||
* hardcode this number; it is configurable per deployment.
|
||||
*/
|
||||
export const DEFAULT_PERSONAL_REPORT_DAILY_LIMIT = 5;
|
||||
|
||||
export type Environment = Readonly<Record<string, string | undefined>>;
|
||||
|
||||
export function readPersonalReportDailyLimit(environment: Environment): number {
|
||||
const raw = environment[REPORT_DAILY_LIMIT_ENV]?.trim();
|
||||
if (!raw) return DEFAULT_PERSONAL_REPORT_DAILY_LIMIT;
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_PERSONAL_REPORT_DAILY_LIMIT;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function isPersonalReportFeatureEnabled(environment: Environment): boolean {
|
||||
return environment[REPORT_FEATURE_ENV]?.trim() === "true";
|
||||
}
|
||||
|
||||
export function resolveAllowedReportOrigins(environment: Environment): readonly string[] {
|
||||
return (environment[REPORT_ALLOWED_ORIGINS_ENV] ?? "")
|
||||
.split(",")
|
||||
.map((value) => value.trim())
|
||||
.filter((value) => value.length > 0);
|
||||
}
|
||||
|
||||
export type SameOriginDecision = Readonly<
|
||||
{ ok: true } | { ok: false; code: "cross_origin_forbidden" }
|
||||
>;
|
||||
|
||||
/**
|
||||
* Same-origin check for report APIs. An absent Origin header (curl, server
|
||||
* tests, same-origin fetch from the browser never sends Origin for GET but
|
||||
* does for POST) is accepted; a matching request origin is accepted; a
|
||||
* configured trusted-proxy/test allowlist is accepted; anything else is
|
||||
* rejected.
|
||||
*/
|
||||
export function checkSameOrigin(
|
||||
requestUrl: string | URL,
|
||||
originHeader: string | null,
|
||||
allowedOrigins: readonly string[],
|
||||
): SameOriginDecision {
|
||||
const origin = originHeader?.trim();
|
||||
if (!origin) return { ok: true };
|
||||
let requestOrigin: string;
|
||||
try {
|
||||
requestOrigin = new URL(requestUrl).origin;
|
||||
} catch {
|
||||
return { ok: false, code: "cross_origin_forbidden" };
|
||||
}
|
||||
if (origin === requestOrigin) return { ok: true };
|
||||
if (allowedOrigins.includes(origin)) return { ok: true };
|
||||
return { ok: false, code: "cross_origin_forbidden" };
|
||||
}
|
||||
|
||||
export type PersonalReportEntitlementResult = Readonly<
|
||||
| { allowed: true }
|
||||
| { allowed: false; code: "report_export_disabled"; httpStatus: 403 }
|
||||
| { allowed: false; code: "report_generation_in_progress"; httpStatus: 409 }
|
||||
| { allowed: false; code: "report_rate_limited"; httpStatus: 429 }
|
||||
>;
|
||||
|
||||
export type PersonalReportEntitlementDeps = Readonly<{
|
||||
userId: string;
|
||||
featureEnabled: boolean;
|
||||
dailyLimit: number;
|
||||
countGenerating: (userId: string) => Promise<number>;
|
||||
countCreatedToday: (userId: string) => Promise<number>;
|
||||
}>;
|
||||
|
||||
export async function checkPersonalReportEntitlement(
|
||||
deps: PersonalReportEntitlementDeps,
|
||||
): Promise<PersonalReportEntitlementResult> {
|
||||
if (!deps.featureEnabled) {
|
||||
return { allowed: false, code: "report_export_disabled", httpStatus: 403 };
|
||||
}
|
||||
const generating = await deps.countGenerating(deps.userId);
|
||||
if (generating > 0) {
|
||||
return { allowed: false, code: "report_generation_in_progress", httpStatus: 409 };
|
||||
}
|
||||
const createdToday = await deps.countCreatedToday(deps.userId);
|
||||
if (createdToday >= deps.dailyLimit) {
|
||||
return { allowed: false, code: "report_rate_limited", httpStatus: 429 };
|
||||
}
|
||||
return { allowed: true };
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,508 @@
|
||||
/**
|
||||
* 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 type {
|
||||
ReportAgentPort,
|
||||
ReportEvidencePacket,
|
||||
} from "@/mastra/personal-report";
|
||||
import {
|
||||
buildReportEvidencePacket,
|
||||
computeRequestFingerprint,
|
||||
generatePersonalReport,
|
||||
type GeneratePersonalReportResult,
|
||||
type SkillSnapshot,
|
||||
} from "./personal-report-generation";
|
||||
import { REPORT_STABLE_CODES } from "./personal-report-codes";
|
||||
import { checkSameOrigin } from "./personal-report-entitlement";
|
||||
import type {
|
||||
CreateGeneratingInput,
|
||||
CreateGeneratingResult,
|
||||
PersonalReportRecord,
|
||||
PersonalReportService,
|
||||
} from "./personal-report-service-core";
|
||||
|
||||
const reportRequestThemes = z.enum(["career", "marriage", "wealth", "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"),
|
||||
themes: z.array(reportRequestThemes).min(1).max(6).default(["career", "marriage", "wealth", "timing"]),
|
||||
}).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 USABLE_BIRTH_TIME_STATUSES = new Set(["accepted", "confirmed"]);
|
||||
|
||||
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 reportView(row: PersonalReportRecord) {
|
||||
return {
|
||||
id: row.id,
|
||||
requestId: row.requestId,
|
||||
reportType: row.reportType,
|
||||
presentationMode: row.presentationMode,
|
||||
status: row.status,
|
||||
failureCode: row.failureCode,
|
||||
createdAt: row.createdAt,
|
||||
completedAt: row.completedAt,
|
||||
};
|
||||
}
|
||||
|
||||
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[];
|
||||
userId: string | null;
|
||||
rawBody: unknown;
|
||||
profile: unknown | 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 }> | null;
|
||||
runWorkflow: (input: ConsultationInput) => Promise<unknown>;
|
||||
createAgent: (model: Readonly<{ id: string }>) => ReportAgentPort;
|
||||
skillSnapshot: SkillSnapshot;
|
||||
now?: () => Date;
|
||||
}>;
|
||||
|
||||
export async function resolveReportCreate(deps: ReportCreateCoreDeps): Promise<ReportRouteResponse> {
|
||||
// Same-origin gate first (CSRF), then auth.
|
||||
const originDecision = checkSameOrigin(deps.requestUrl, deps.origin, deps.allowedOrigins);
|
||||
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 birthTimeStatus = text(profile.birth_time_status);
|
||||
const activeBirthTime = text(profile.active_birth_time);
|
||||
const birthDate = parseBirthDate(profile.birth_date);
|
||||
const birthClock = parseClockMinutes(activeBirthTime);
|
||||
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 (!birthTimeStatus || !USABLE_BIRTH_TIME_STATUSES.has(birthTimeStatus)) {
|
||||
return {
|
||||
status: 422,
|
||||
body: { error: "出生时间尚未达到可用状态", code: REPORT_STABLE_CODES.birthTimeNotUsable },
|
||||
};
|
||||
}
|
||||
if (!birthDate || !birthClock || 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,
|
||||
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 createInput: CreateGeneratingInput = {
|
||||
userId,
|
||||
requestId: payload.requestId,
|
||||
requestFingerprint: fingerprint,
|
||||
reportType: payload.reportType,
|
||||
presentationMode: payload.presentationMode,
|
||||
requestedThemes: payload.themes,
|
||||
sessionId: payload.sessionId ?? null,
|
||||
chartProfileId: payload.chartProfileId ?? null,
|
||||
skillSourceCommit: deps.skillSnapshot.sourceCommit,
|
||||
skillSnapshotSha256: deps.skillSnapshot.sha256,
|
||||
};
|
||||
const begun: CreateGeneratingResult = await deps.persistence.createGenerating(createInput);
|
||||
if (begun.kind === "generation_in_progress") {
|
||||
return {
|
||||
status: 409,
|
||||
body: { error: "已有报告正在生成中", code: REPORT_STABLE_CODES.generationInProgress },
|
||||
};
|
||||
}
|
||||
if (begun.kind === "request_conflict") {
|
||||
return {
|
||||
status: 409,
|
||||
body: { error: "请求内容与已有记录不一致", code: REPORT_STABLE_CODES.requestConflict },
|
||||
};
|
||||
}
|
||||
if (begun.kind === "replayed") {
|
||||
return replayOrConflict(begun.record, fingerprint);
|
||||
}
|
||||
const row = begun.record;
|
||||
|
||||
// Real workflow evidence (main chain), never mock/example/random data.
|
||||
if (!deps.model) {
|
||||
await deps.persistence.markFailed(userId, row.id, REPORT_STABLE_CODES.modelUnavailable);
|
||||
return {
|
||||
status: 502,
|
||||
body: { error: "报告模型暂不可用", code: REPORT_STABLE_CODES.modelUnavailable },
|
||||
};
|
||||
}
|
||||
|
||||
const workflowTheme = payload.reportType === "personal_thematic"
|
||||
? payload.themes[0]
|
||||
: "general";
|
||||
const workflowInput: ConsultationInput = {
|
||||
year: birthDate.year,
|
||||
month: birthDate.month,
|
||||
day: birthDate.day,
|
||||
hour: birthClock.hour,
|
||||
minute: birthClock.minute,
|
||||
lat: latitude,
|
||||
lon: longitude,
|
||||
tz: timezoneOffset,
|
||||
city: birthPlaceLabel,
|
||||
question: `请生成我的个人${payload.reportType === "personal_full" ? "综合" : "主题"}报告(主题:${payload.themes.join("、")})`,
|
||||
theme: workflowTheme,
|
||||
entryMode: "direct_chart",
|
||||
};
|
||||
|
||||
let workflow: unknown;
|
||||
try {
|
||||
workflow = await deps.runWorkflow(workflowInput);
|
||||
} catch {
|
||||
await deps.persistence.markFailed(userId, row.id, REPORT_STABLE_CODES.calculationUnavailable);
|
||||
return {
|
||||
status: 502,
|
||||
body: { error: "排盘引擎暂不可用", code: REPORT_STABLE_CODES.calculationUnavailable },
|
||||
};
|
||||
}
|
||||
const workflowRecord = record(workflow);
|
||||
if (!workflowRecord || workflowRecord.success !== true || !record(workflowRecord.chart)) {
|
||||
await deps.persistence.markFailed(userId, row.id, REPORT_STABLE_CODES.calculationUnavailable);
|
||||
return {
|
||||
status: 502,
|
||||
body: { error: "排盘引擎未返回可用星盘", code: REPORT_STABLE_CODES.calculationUnavailable },
|
||||
};
|
||||
}
|
||||
|
||||
let packet: ReportEvidencePacket;
|
||||
try {
|
||||
packet = buildReportEvidencePacket({
|
||||
workflow,
|
||||
subject: {
|
||||
displayName,
|
||||
birthTimeStatus: birthTimeStatus === "confirmed" ? "confirmed" : "accepted",
|
||||
birthPlaceLabel,
|
||||
},
|
||||
requestedThemes: payload.themes,
|
||||
reportType: payload.reportType,
|
||||
presentationMode: payload.presentationMode,
|
||||
candidateRange: birthTimeStatus === "accepted"
|
||||
? { start: activeBirthTime ?? "", end: activeBirthTime ?? "" }
|
||||
: null,
|
||||
skillSnapshot: deps.skillSnapshot,
|
||||
});
|
||||
} catch (error) {
|
||||
// Real evidence could not support an honest report: fail closed, never
|
||||
// generate an empty or sample-backed report.
|
||||
if (error instanceof Error && error.name === "ReportEvidenceInsufficientError") {
|
||||
await deps.persistence.markFailed(userId, row.id, REPORT_STABLE_CODES.calculationUnavailable);
|
||||
return {
|
||||
status: 422,
|
||||
body: { error: "排盘证据不足以生成诚实报告", code: REPORT_STABLE_CODES.calculationUnavailable },
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const result: GeneratePersonalReportResult = await generatePersonalReport({
|
||||
reportId: row.id,
|
||||
packet,
|
||||
agent: deps.createAgent(deps.model),
|
||||
now: deps.now,
|
||||
});
|
||||
|
||||
if (result.status === "failed") {
|
||||
await deps.persistence.markFailed(userId, row.id, 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);
|
||||
return {
|
||||
status: 201,
|
||||
body: { report: reportView(readyRow), reportDocument: readyRow.reportDocument },
|
||||
};
|
||||
} catch {
|
||||
await deps.persistence.markFailed(userId, row.id, REPORT_STABLE_CODES.schemaInvalid);
|
||||
return {
|
||||
status: 422,
|
||||
body: { error: "报告未通过合同校验", code: REPORT_STABLE_CODES.schemaInvalid },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export type ReportReadCoreDeps = Readonly<{
|
||||
requestUrl: string;
|
||||
origin: string | null;
|
||||
allowedOrigins: readonly string[];
|
||||
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 };
|
||||
}>;
|
||||
|
||||
export async function resolveReportRead(deps: ReportReadCoreDeps): Promise<ReportRouteResponse> {
|
||||
const originDecision = checkSameOrigin(deps.requestUrl, deps.origin, deps.allowedOrigins);
|
||||
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 },
|
||||
};
|
||||
}
|
||||
if (row.status === "ready") {
|
||||
// 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.
|
||||
const validated = deps.validateReadyDocument(row.reportDocument);
|
||||
if (!validated.ok) {
|
||||
return {
|
||||
status: 422,
|
||||
body: {
|
||||
error: "报告内容未通过合同校验",
|
||||
code: REPORT_STABLE_CODES.schemaInvalid,
|
||||
report: reportView(row),
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: 200,
|
||||
body: { report: reportView(row), reportDocument: validated.document },
|
||||
};
|
||||
}
|
||||
return { status: 200, body: { report: reportView(row) } };
|
||||
}
|
||||
|
||||
export type ReportDeleteCoreDeps = Readonly<{
|
||||
requestUrl: string;
|
||||
origin: string | null;
|
||||
allowedOrigins: readonly string[];
|
||||
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);
|
||||
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 } };
|
||||
}
|
||||
Reference in New Issue
Block a user