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>
330 lines
13 KiB
TypeScript
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,
|
|
};
|
|
}
|