Files
Jyotisha/frontend/src/lib/chart-view-service.ts
T

221 lines
6.9 KiB
TypeScript

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<string, unknown>): Promise<EngineCallResult> {
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) };
}
}