Files
Jyotisha/frontend/src/lib/synastry-full-create.ts
T
jesse-uxGrok (agent loop)Cursor
57ee79cc5f fix(synastry): let the same pair start another report
A finished match used to reopen the old report. Starting again now writes a new one and charges again; a report still being written is reused.

Co-Authored-By: Grok (agent loop)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 23:08:21 +08:00

330 lines
13 KiB
TypeScript

import { createHash, randomUUID } from "node:crypto";
import { authorizeUsage, releaseUsage } from "@/lib/consultation-billing";
import type { DataQueryBuilder } from "@/lib/db/data-client";
import { consultationSubjectFailureResponse } from "@/lib/consultation-subject-resolver";
import { FeaturePricingError, resolveFeaturePricing } from "@/lib/feature-pricing";
import { MARRIAGE_MATCH_RULES_VERSION, marriageMatchFingerprint } from "@/lib/marriage-match-service";
import { SYNASTRY_ORDER_SECTION } from "@/lib/synastry-display-order";
import { loadLanguageModelCatalog } from "@/lib/model-catalog";
import { REPORT_STABLE_CODES } from "@/lib/personal-report-codes";
import { readPersonalReportDailyLimit } from "@/lib/personal-report-entitlement";
import { createSupabasePersonalReportJobService } from "@/lib/personal-report-job-service";
import { reportView } from "@/lib/personal-report-route-core";
import {
createSupabasePersonalReportService,
type PersonalReportRecord,
} from "@/lib/personal-report-service";
import { isProductEnabled } from "@/lib/product-access";
import { consumeUserRateLimitSet, userRateLimitMessages } from "@/lib/request-rate-limit";
import { ConsultationSubjectError, isChartSubjectId, loadSubjectBirth } from "@/lib/subject-birth";
const SKILL_SNAPSHOT_SHA256 = "5b12d7a51dfecfdfbc29c33263204f5069d79efd71a056c75fddb8151b78b329";
export type SynastryCreateResponse = {
status: number;
body: Record<string, unknown>;
headers?: Record<string, string>;
};
type Row = Record<string, unknown>;
// The shared builder type, not a local stub: a method the self-hosted client
// lacks must fail type-checking here (BUG-1399).
type Query = DataQueryBuilder;
function subjectId(value: unknown): string | null {
if (value === "self") return "self";
return typeof value === "string" && isChartSubjectId(value) ? value : null;
}
function pairOf(leftId: string, rightId: string, leftFingerprint: string, rightFingerprint: string) {
if (leftId <= rightId) {
return { low: leftId, high: rightId, fingerprintLow: leftFingerprint, fingerprintHigh: rightFingerprint };
}
return { low: rightId, high: leftId, fingerprintLow: rightFingerprint, fingerprintHigh: leftFingerprint };
}
function profileId(subject: string): string | null {
return subject === "self" ? null : subject;
}
function selectReports(admin: unknown, columns: string): Query {
return (admin as { from(table: string): { select(columns: string): Query } }).from("personal_reports").select(columns);
}
type OpenedPair = {
userId: string;
subjectA: string;
subjectB: string;
pair: ReturnType<typeof pairOf>;
};
async function openSynastryPair(input: Readonly<{
userId: string | null;
supabase: unknown;
subjectA: unknown;
subjectB: unknown;
}>): Promise<{ response: SynastryCreateResponse } | OpenedPair> {
if (!input.userId) return { response: { status: 401, body: { error: "请先登录" } } };
if (!await isProductEnabled("compatibility") || !await isProductEnabled("report_center")) {
return { response: { status: 503, body: { error: "合盘服务暂未开放", code: "compatibility_product_disabled" } } };
}
const subjectA = subjectId(input.subjectA ?? "self");
const subjectB = subjectId(input.subjectB);
if (!subjectA || !subjectB) return { response: { status: 400, body: { error: "请选择两个人", code: "subject_required" } } };
if (subjectA === subjectB) return { response: { status: 400, body: { error: "请选择两个不同的人", code: "same_subject" } } };
let rowA: Record<string, unknown>;
let rowB: Record<string, unknown>;
try {
const [personA, personB] = await Promise.all([
loadSubjectBirth(input.supabase, input.userId, subjectA),
loadSubjectBirth(input.supabase, input.userId, subjectB),
]);
if (!personA.chartable || !personB.chartable) {
return { response: { status: 422, body: { error: "请先完善出生资料", code: REPORT_STABLE_CODES.profileIncomplete } } };
}
rowA = personA.row;
rowB = personB.row;
} catch (error) {
if (error instanceof ConsultationSubjectError) {
const failure = consultationSubjectFailureResponse(error);
return { response: { status: failure.status, body: failure.body } };
}
throw error;
}
return {
userId: input.userId,
subjectA,
subjectB,
pair: pairOf(subjectA, subjectB, marriageMatchFingerprint(rowA), marriageMatchFingerprint(rowB)),
};
}
function pairQuery(admin: unknown, pair: OpenedPair["pair"], userId: string) {
return selectReports(admin, "id,request_id,status,created_at,completed_at")
.eq("user_id", userId)
.eq("report_type", "synastry_full")
.eq("subject_low", pair.low)
.eq("subject_high", pair.high)
.eq("fingerprint_low", pair.fingerprintLow)
.eq("fingerprint_high", pair.fingerprintHigh)
.eq("synastry_rules_version", MARRIAGE_MATCH_RULES_VERSION);
}
export async function readSynastryPairState(input: Readonly<{
userId: string | null;
supabase: unknown;
admin: Parameters<typeof createSupabasePersonalReportService>[0];
subjectA: unknown;
subjectB: unknown;
}>): Promise<SynastryCreateResponse> {
const opened = await openSynastryPair(input);
if ("response" in opened) return opened.response;
const found = await pairQuery(input.admin, opened.pair, opened.userId).in("status", ["ready", "generating"]);
if (found.error) throw new Error(found.error.message ?? "synastry pair lookup failed");
const rows = Array.isArray(found.data) ? found.data as Row[] : [];
return {
status: 200,
body: {
readyCount: rows.filter((row) => row.status === "ready").length,
generating: rows.some((row) => row.status === "generating"),
},
};
}
export async function createSynastryFullReport(input: Readonly<{
userId: string | null;
body: unknown;
supabase: unknown;
admin: Parameters<typeof createSupabasePersonalReportService>[0];
}>): Promise<SynastryCreateResponse> {
const body = input.body && typeof input.body === "object" ? input.body as Record<string, unknown> : {};
const opened = await openSynastryPair({
userId: input.userId,
supabase: input.supabase,
subjectA: body.subjectA ?? "self",
subjectB: body.subjectB ?? body.with,
});
if ("response" in opened) return opened.response;
const { userId, subjectA, subjectB, pair } = opened;
const requestFingerprint = createHash("sha256").update(JSON.stringify({
...pair,
rules: MARRIAGE_MATCH_RULES_VERSION,
})).digest("hex");
const existing = await pairQuery(input.admin, pair, userId)
.eq("status", "generating")
.limit(1)
.maybeSingle();
if (existing.error) throw new Error(existing.error.message ?? "synastry lookup failed");
if (existing.data?.status === "generating") {
return {
status: 202,
body: { report: reportView(stubRecord(userId, existing.data)), reused: true },
};
}
const inFlight = await selectReports(input.admin, "id")
.eq("user_id", userId)
.eq("status", "generating")
.limit(1)
.maybeSingle();
if (inFlight.error) throw new Error(inFlight.error.message ?? "synastry inflight lookup failed");
if (inFlight.data) {
return { status: 409, body: { error: "已有报告正在生成中", code: REPORT_STABLE_CODES.generationInProgress } };
}
const todayStart = new Date();
todayStart.setHours(0, 0, 0, 0);
const today = await selectReports(input.admin, "created_at").eq("user_id", userId).gte("created_at", todayStart.toISOString());
if (today.error) throw new Error(today.error.message ?? "synastry daily lookup failed");
if ((today.data ?? []).length >= readPersonalReportDailyLimit(process.env)) {
return { status: 429, body: { error: "今日报告生成次数已达上限", code: REPORT_STABLE_CODES.rateLimited } };
}
const catalog = await loadLanguageModelCatalog();
const model = catalog.models.find((entry) => entry.id === catalog.defaultModelId) ?? null;
if (!model) return { status: 502, body: { error: "报告模型暂不可用", code: REPORT_STABLE_CODES.modelUnavailable } };
const limited = consumeUserRateLimitSet(["reportGenerateHour"], userId);
if (!limited.ok) {
return {
status: 429,
headers: { "Retry-After": String(limited.retryAfterSeconds) },
body: { error: userRateLimitMessages.reportGenerate, code: "rate_limited", retryAfterSeconds: limited.retryAfterSeconds },
};
}
const requestId = randomUUID();
let reserved = false;
try {
const pricing = await resolveFeaturePricing(input.admin as never, "synastry.full", model.id);
const authorization = await authorizeUsage(input.admin as never, {
userId,
requestId,
featureKey: "synastry.full",
requestedModelId: model.id,
creditCost: pricing.credit_cost,
});
if (!authorization.success) {
const insufficient = authorization.reason === REPORT_STABLE_CODES.insufficientCredits;
return {
status: insufficient ? 402 : 503,
body: {
error: insufficient ? "报告点数不足" : "暂时无法扣除报告点数",
code: insufficient ? REPORT_STABLE_CODES.insufficientCredits : REPORT_STABLE_CODES.fairUseLimited,
},
};
}
reserved = true;
} catch (error) {
if (error instanceof FeaturePricingError) {
return {
status: 503,
body: {
error: "计费配置不可用",
message: "当前服务的计费配置尚未完成,请联系支持人员,本次不会扣点。",
code: "pricing_configuration_unavailable",
},
};
}
return { status: 503, body: { error: "暂时无法确认报告点数", code: REPORT_STABLE_CODES.billingUnavailable } };
}
const release = async (reason: string) => {
if (!reserved) return;
reserved = false;
try { await releaseUsage(input.admin as never, userId, requestId, reason); } catch { /* the RPC is idempotent */ }
};
const persistence = createSupabasePersonalReportService(input.admin);
const includesSelf = subjectA === "self" || subjectB === "self";
const chartProfileId = includesSelf ? null : profileId(pair.low);
const partnerChartProfileId = includesSelf ? (profileId(pair.low) ?? profileId(pair.high)) : profileId(pair.high);
let created;
try {
created = await persistence.createGenerating({
userId,
requestId,
requestFingerprint,
reportType: "synastry_full",
presentationMode: "default",
depth: "standard",
chartProfileId,
partnerChartProfileId,
subjectLow: pair.low,
subjectHigh: pair.high,
fingerprintLow: pair.fingerprintLow,
fingerprintHigh: pair.fingerprintHigh,
synastryRulesVersion: MARRIAGE_MATCH_RULES_VERSION,
skillName: "jyotish-full-synastry-reading",
skillVersion: "1.0.0",
skillSnapshotSha256: SKILL_SNAPSHOT_SHA256,
});
} catch (error) {
await release(REPORT_STABLE_CODES.generationFailed);
throw error;
}
if (created.kind !== "created") {
await release(REPORT_STABLE_CODES.generationInProgress);
return { status: 409, body: { error: "已有报告正在生成中", code: REPORT_STABLE_CODES.generationInProgress } };
}
const orderSaved = await (input.admin as {
from(table: string): {
insert(row: Record<string, unknown>): Promise<{ error: { message?: string } | null }>;
};
}).from("personal_report_sections").insert({
user_id: userId,
request_id: requestId,
section_id: SYNASTRY_ORDER_SECTION,
status: "ready",
payload: { first: subjectA, second: subjectB },
attempt_count: 1,
max_attempts: 1,
});
if (orderSaved.error) {
await persistence.markFailed(userId, created.record.id, REPORT_STABLE_CODES.generationFailed);
await release(REPORT_STABLE_CODES.generationFailed);
return { status: 503, body: { error: "报告任务暂时无法入队", code: REPORT_STABLE_CODES.generationFailed } };
}
try {
const enqueued = await createSupabasePersonalReportJobService(input.admin as never).enqueue({
userId,
requestId,
requestFingerprint,
});
return { status: 202, body: { report: reportView(created.record), jobId: enqueued.job.id } };
} catch {
await persistence.markFailed(userId, created.record.id, REPORT_STABLE_CODES.generationFailed);
await release(REPORT_STABLE_CODES.generationFailed);
return { status: 503, body: { error: "报告任务暂时无法入队", code: REPORT_STABLE_CODES.generationFailed } };
}
}
function stubRecord(userId: string, row: Row): PersonalReportRecord {
return {
id: String(row.id),
userId,
sessionId: null,
chartProfileId: null,
requestId: String(row.request_id ?? ""),
requestFingerprint: "0".repeat(64),
reportType: "synastry_full",
status: row.status === "ready" ? "ready" : "generating",
schemaVersion: "synastry_report.v1",
presentationMode: "default",
depth: "standard",
requestedThemes: [],
reportDocument: null,
calculationHash: null,
evidenceHash: null,
skillName: "jyotish-full-synastry-reading",
skillVersion: "1.0.0",
skillSourceCommit: null,
skillSnapshotSha256: SKILL_SNAPSHOT_SHA256,
failureCode: null,
createdAt: typeof row.created_at === "string" ? row.created_at : new Date(0).toISOString(),
updatedAt: typeof row.created_at === "string" ? row.created_at : new Date(0).toISOString(),
completedAt: typeof row.completed_at === "string" ? row.completed_at : null,
};
}