Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
221 lines
6.9 KiB
TypeScript
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) };
|
|
}
|
|
}
|