import "server-only"; import { BirthProfileTimezoneError } from "./birth-profile-timezone.ts"; import { consumeRequestRateLimit } from "./request-rate-limit.ts"; import { createServerSupabaseClient } from "./supabase/server.ts"; import { ConsultationSubjectError, loadSubjectBirth, subjectIdFromQuery } from "./subject-birth.ts"; import { resolveAyanamsa } from "./ayanamsa.ts"; import { assembleChartView, type ChartViewEnginePost } from "./chart-view-load.ts"; import type { ChartViewProfileInput } from "./chart-view-mapper.ts"; import type { ChartViewResponse } from "./chart-view-contract.ts"; import { prepareChartViewProfile } from "./chart-view-profile-read.ts"; import { chartViewFailureResponse, logChartViewFailure } from "./chart-view-failure.ts"; import type { ChartBirthFailureReason } from "./chart-birth-truth.ts"; import { CHART_VIEW_ENGINE_TIMEOUT_MS, chartViewEngineCacheKey, chartViewEngineCachePath, engineCallFromHttp, engineCallFromThrown, readChartViewEngineCache, writeChartViewEngineCache, type ChartViewLayer, type EngineCallResult, } from "./chart-view-engine.ts"; const jyotishApiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200"; async function postEngine(path: string, body: Record): Promise { const started = Date.now(); try { const response = await fetch(`${jyotishApiBase}${path}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), cache: "no-store", signal: AbortSignal.timeout(CHART_VIEW_ENGINE_TIMEOUT_MS), }); let payload: unknown; try { payload = await response.json(); } catch (error) { const errorName = error instanceof Error ? error.name : "Error"; if (response.ok) { return { status: "bad_payload", path, elapsedMs: Date.now() - started, httpStatus: response.status, errorName, }; } return engineCallFromHttp({ path, elapsedMs: Date.now() - started, httpStatus: response.status, payload: null, }); } return engineCallFromHttp({ path, elapsedMs: Date.now() - started, httpStatus: response.status, payload, }); } catch (error) { return engineCallFromThrown(path, Date.now() - started, error); } } function cachedPostEngine(userId: string, subjectId: string, profile: ChartViewProfileInput): ChartViewEnginePost { const ayanamsa = resolveAyanamsa({ ayanamsa: profile.ayanamsa }); const nodeMode = "mean"; return async (path, body, cacheScope) => { const key = chartViewEngineCacheKey({ userId, subjectId, date: profile.date, time: profile.time, latitude: profile.latitude, longitude: profile.longitude, timezoneOffset: profile.timezoneOffset, ayanamsa, nodeMode, path: chartViewEngineCachePath(path, cacheScope), }); const hit = readChartViewEngineCache(key); if (hit) return { status: "ok", payload: hit }; const result = await postEngine(path, body); if (result.status === "ok") writeChartViewEngineCache(key, result.payload); return result; }; } function failureBody(reason: ChartBirthFailureReason, accountId: string, fingerprint?: string | null): ChartViewResponse { return { ...chartViewFailureResponse(reason), accountId, ...(fingerprint ? { profileFingerprint: fingerprint } : {}), }; } function classifyThrown(error: unknown): ChartBirthFailureReason { if (error instanceof BirthProfileTimezoneError || (error instanceof Error && error.name === "BirthProfileTimezoneError")) { return "timezone_resolver_failure"; } if (error instanceof Error && error.message === "adopted_birth_calculation_incomplete") { return "adopted_calculation_incomplete"; } return "response_schema_failure"; } export async function loadChartView(input: { now?: Date; layers?: readonly ChartViewLayer[]; subjectId?: unknown; } = {}): Promise<{ httpStatus: number; body: ChartViewResponse; }> { const now = input.now ?? new Date(); const supabase = await createServerSupabaseClient(); const { data: { user } } = await supabase.auth.getUser(); if (!user) { return assembleChartView({ userId: null, profile: null, postEngine, asOf: now.toISOString().slice(0, 10), layers: input.layers, }); } const started = Date.now(); try { const limited = consumeRequestRateLimit({ key: `chartView:${user.id}`, limit: 20, windowMs: 60_000, }); if (!limited.ok) { return assembleChartView({ userId: user.id, accountId: user.id, profile: null, postEngine, asOf: now.toISOString().slice(0, 10), rateLimited: true, layers: input.layers, }); } const subjectId = subjectIdFromQuery(input.subjectId); let resolved; try { resolved = await loadSubjectBirth(supabase, user.id, subjectId); } catch (error) { if (error instanceof ConsultationSubjectError && error.code === "subject_incomplete") { return { httpStatus: 200, body: { ...failureBody("profile_incomplete", user.id), subjectId }, }; } if (error instanceof ConsultationSubjectError && error.code === "subject_unavailable") { logChartViewFailure({ reason: "profile_query_error", elapsedMs: Date.now() - started, httpStatus: null, }); return { httpStatus: 200, body: { ...failureBody("profile_query_error", user.id), subjectId }, }; } return { httpStatus: 409, body: { status: "chart_unavailable", billed: false, message: "这份人物资料不存在、已删除,或不属于当前账户。", accountId: user.id, subjectId, }, }; } const prepared = await prepareChartViewProfile({ row: resolved.row }); if (!prepared.ok) { logChartViewFailure({ reason: prepared.failure, elapsedMs: Date.now() - started, httpStatus: 200, }); return { httpStatus: 200, body: { ...failureBody(prepared.failure, user.id, prepared.birth.fingerprint), subjectId: resolved.subjectId }, }; } const assembled = await assembleChartView({ userId: user.id, accountId: user.id, profileFingerprint: prepared.birth.fingerprint, profile: prepared.profile, postEngine: cachedPostEngine(user.id, resolved.subjectId, prepared.profile), asOf: now.toISOString().slice(0, 10), layers: input.layers, }); return { ...assembled, body: { ...assembled.body, subjectId: resolved.subjectId }, }; } catch (error) { const reason = classifyThrown(error); logChartViewFailure({ reason, elapsedMs: Date.now() - started, httpStatus: 200, }); return { httpStatus: 200, body: failureBody(reason, user.id) }; } }