feat(dasha): 星盘按需取 Yogini,缓存按体系分开
同一人的 Chara、Yogini 和以后的大运键各走各的缓存。页面日期直接用引擎返回的日子。 Co-Authored-By: Grok (agent loop) Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Grok (agent loop)
Cursor
parent
85567071c7
commit
7a296c7348
@@ -195,7 +195,7 @@ export const chartViewMahadashaSchema = chartViewPeriodSchema.extend({
|
||||
});
|
||||
|
||||
export const chartViewDashaTrackSchema = z.object({
|
||||
id: z.enum(["vimshottari", "chara"]),
|
||||
id: z.enum(["vimshottari", "chara", "yogini"]),
|
||||
title: z.string().min(1),
|
||||
method: z.string().min(1),
|
||||
currentLabel: z.string().min(1),
|
||||
@@ -280,6 +280,7 @@ export const chartViewOkSchema = z.object({
|
||||
layerFailures: z.object({
|
||||
varga: chartViewFailureStatusSchema.optional(),
|
||||
chara: chartViewFailureStatusSchema.optional(),
|
||||
yogini: chartViewFailureStatusSchema.optional(),
|
||||
western: chartViewFailureStatusSchema.optional(),
|
||||
bhava: chartViewFailureStatusSchema.optional(),
|
||||
transit: chartViewFailureStatusSchema.optional(),
|
||||
@@ -301,6 +302,7 @@ export const chartViewOkSchema = z.object({
|
||||
asOf: isoDate,
|
||||
vimshottari: chartViewDashaTrackSchema,
|
||||
chara: chartViewDashaTrackSchema,
|
||||
yogini: chartViewDashaTrackSchema,
|
||||
dualTrackNote: z.string().min(1),
|
||||
}),
|
||||
western: z.union([chartViewWesternOkSchema, chartViewUnavailableLayerSchema]),
|
||||
|
||||
@@ -2,9 +2,30 @@ export const CHART_VIEW_ENGINE_TIMEOUT_MS = 10_000;
|
||||
export const CHART_VIEW_ENGINE_CACHE_TTL_MS = 5 * 60 * 1_000;
|
||||
const ENGINE_CACHE_MAX_ENTRIES = 200;
|
||||
|
||||
export const CHART_VIEW_LAYERS = ["varga", "chara", "western", "bhava", "transit"] as const;
|
||||
export const CHART_VIEW_LAYERS = ["varga", "chara", "yogini", "western", "bhava", "transit"] as const;
|
||||
export type ChartViewLayer = (typeof CHART_VIEW_LAYERS)[number];
|
||||
|
||||
/**
|
||||
* On-demand dasha columns. A new `/api/dasha` system is one row here plus a
|
||||
* mapper. Chara keeps its own path, so its cache scope stays empty and the
|
||||
* historical key does not move. Every `/api/dasha` row must set `cacheScope`,
|
||||
* because those calls share one path.
|
||||
*/
|
||||
export const CHART_DASHA_SYSTEMS = [
|
||||
{ layer: "chara", dasha: "chara", cacheScope: null, track: "chara", path: "/api/dasha/chara" },
|
||||
{ layer: "yogini", dasha: "yogini", cacheScope: "dasha:yogini", track: "yogini", path: "/api/dasha" },
|
||||
] as const;
|
||||
|
||||
export type ChartDashaSystem = (typeof CHART_DASHA_SYSTEMS)[number];
|
||||
|
||||
export function dashaCacheScope(dashaKey: string): string {
|
||||
return `dasha:${dashaKey}`;
|
||||
}
|
||||
|
||||
export function dashaSystemByLayer(layer: string): ChartDashaSystem | null {
|
||||
return CHART_DASHA_SYSTEMS.find((item) => item.layer === layer) ?? null;
|
||||
}
|
||||
|
||||
export type EngineCallResult =
|
||||
| { readonly status: "ok"; readonly payload: Record<string, unknown> }
|
||||
| {
|
||||
|
||||
@@ -173,6 +173,8 @@ export const ANTARDASHA_DERIVED_NOTE = "分运按标准比例从大运边界推
|
||||
|
||||
export const CHARA_ANTARDASHA_DATE_NOTE = "分运时长来自引擎的十二等分;起止日期按大运边界推算,不是引擎单独返回的日历字段。";
|
||||
|
||||
export const YOGINI_ANTARDASHA_UNVERIFIED_NOTE = "小运未经外部软件核对。";
|
||||
|
||||
export const CHARA_ZERO_YEAR_LABEL = "0 年";
|
||||
|
||||
export const CHARA_ZERO_YEAR_NOTE = "本段时长为 0,不展开";
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
type ChartViewResponse,
|
||||
} from "./chart-view-contract.ts";
|
||||
import {
|
||||
CHART_DASHA_SYSTEMS,
|
||||
dashaCacheScope,
|
||||
enginePayload,
|
||||
logEngineCallFailure,
|
||||
type ChartViewLayer,
|
||||
@@ -94,6 +96,26 @@ function wantedLayers(layers: readonly ChartViewLayer[] | undefined): Set<ChartV
|
||||
return new Set(layers ?? []);
|
||||
}
|
||||
|
||||
/** One row of `CHART_DASHA_SYSTEMS`, or any other `/api/dasha` key passed by a test. */
|
||||
export function chartDashaLayerRequest(dashaKey: string, followUp: Record<string, unknown>): {
|
||||
path: string;
|
||||
body: Record<string, unknown>;
|
||||
cacheScope?: string;
|
||||
} {
|
||||
const system = CHART_DASHA_SYSTEMS.find((item) => item.dasha === dashaKey);
|
||||
if (system && system.path !== "/api/dasha") {
|
||||
return {
|
||||
path: system.path,
|
||||
body: { ...followUp, antardasha: true, mode: "dasha" },
|
||||
};
|
||||
}
|
||||
return {
|
||||
path: "/api/dasha",
|
||||
body: { ...followUp, dasha: dashaKey },
|
||||
cacheScope: system?.cacheScope ?? dashaCacheScope(dashaKey),
|
||||
};
|
||||
}
|
||||
|
||||
async function callEngine(
|
||||
postEngine: ChartViewEnginePost,
|
||||
path: string,
|
||||
@@ -167,20 +189,23 @@ export async function assembleChartView(input: ChartViewLoadInput): Promise<{
|
||||
return enginePayload(result);
|
||||
};
|
||||
const transitDate = input.asOf.slice(0, 10);
|
||||
const [varga, chara, western, bhava, transit] = await Promise.all([
|
||||
const dashaPackets: Record<(typeof CHART_DASHA_SYSTEMS)[number]["track"], Record<string, unknown> | null> = {
|
||||
chara: null,
|
||||
yogini: null,
|
||||
};
|
||||
const dashaReady = Promise.all(CHART_DASHA_SYSTEMS.map(async (system) => {
|
||||
if (!layers.has(system.layer)) return;
|
||||
const request = chartDashaLayerRequest(system.dasha, followUp);
|
||||
const result = await callEngine(input.postEngine, request.path, request.body, request.cacheScope);
|
||||
dashaPackets[system.track] = layerPayload(system.layer)(result);
|
||||
}));
|
||||
const [varga, western, bhava, transit] = await Promise.all([
|
||||
layers.has("varga")
|
||||
? callEngine(input.postEngine, "/api/varga_full", {
|
||||
...followUp,
|
||||
divisions: VARGA_CHIP_ORDER.map((division) => `D${division}`),
|
||||
}).then(layerPayload("varga"))
|
||||
: Promise.resolve(null),
|
||||
layers.has("chara")
|
||||
? callEngine(input.postEngine, "/api/dasha/chara", {
|
||||
...followUp,
|
||||
antardasha: true,
|
||||
mode: "dasha",
|
||||
}).then(layerPayload("chara"))
|
||||
: Promise.resolve(null),
|
||||
layers.has("western")
|
||||
? callEngine(input.postEngine, "/api/western", payload).then(layerPayload("western"))
|
||||
: Promise.resolve(null),
|
||||
@@ -209,12 +234,14 @@ export async function assembleChartView(input: ChartViewLoadInput): Promise<{
|
||||
).then(layerPayload("transit"))
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
await dashaReady;
|
||||
|
||||
try {
|
||||
const body = buildChartView({
|
||||
chart,
|
||||
varga,
|
||||
chara,
|
||||
chara: dashaPackets.chara,
|
||||
yogini: dashaPackets.yogini,
|
||||
western,
|
||||
bhava,
|
||||
transit,
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
VIMSHOTTARI_ORDER,
|
||||
VIMSHOTTARI_YEARS,
|
||||
WESTERN_UNAVAILABLE_COPY,
|
||||
YOGINI_ANTARDASHA_UNVERIFIED_NOTE,
|
||||
ASPECT_ZH,
|
||||
ELEMENT_ZH,
|
||||
MODE_ZH,
|
||||
@@ -58,6 +59,7 @@ export type ChartViewEngineBundle = {
|
||||
chart: Record<string, unknown>;
|
||||
varga: Record<string, unknown> | null;
|
||||
chara: Record<string, unknown> | null;
|
||||
yogini?: Record<string, unknown> | null;
|
||||
western: Record<string, unknown> | null;
|
||||
bhava?: Record<string, unknown> | null;
|
||||
transit?: Record<string, unknown> | null;
|
||||
@@ -700,6 +702,68 @@ function charaTrack(charaPacket: Record<string, unknown> | null, birthDate: stri
|
||||
};
|
||||
}
|
||||
|
||||
function yoginiPieceLabel(yogini: string, planet: string): string {
|
||||
return `${yogini} / ${planetZh(planet)}`;
|
||||
}
|
||||
|
||||
function yoginiTrack(packet: Record<string, unknown> | null, asOf: string) {
|
||||
const rows = Array.isArray(packet?.periods) ? packet.periods : [];
|
||||
const periods = rows.flatMap((entry) => {
|
||||
const row = record(entry);
|
||||
if (!row) return [];
|
||||
const planet = text(row.planet) ?? text(row.lord);
|
||||
const yogini = text(row.yogini);
|
||||
const start = text(row.start_date);
|
||||
const end = text(row.end_date);
|
||||
if (!planet || !yogini || !start || !end) return [];
|
||||
const startIso = isoDay(start);
|
||||
const endIso = isoDay(end);
|
||||
const rawAntar = Array.isArray(row.antardasha) ? row.antardasha : [];
|
||||
const antardashas = rawAntar.flatMap((antar) => {
|
||||
const item = record(antar);
|
||||
if (!item) return [];
|
||||
const antarPlanet = text(item.planet) ?? text(item.lord);
|
||||
const antarYogini = text(item.yogini);
|
||||
const antarStart = text(item.start_date);
|
||||
const antarEnd = text(item.end_date);
|
||||
if (!antarPlanet || !antarYogini || !antarStart || !antarEnd) return [];
|
||||
const subStart = isoDay(antarStart);
|
||||
const subEnd = isoDay(antarEnd);
|
||||
return [{
|
||||
lord: antarPlanet,
|
||||
label: yoginiPieceLabel(antarYogini, antarPlanet),
|
||||
start: subStart,
|
||||
end: subEnd,
|
||||
current: containsDay(subStart, subEnd, asOf),
|
||||
derived: false,
|
||||
}];
|
||||
});
|
||||
return [{
|
||||
lord: planet,
|
||||
label: yoginiPieceLabel(yogini, planet),
|
||||
sign: null,
|
||||
start: startIso,
|
||||
end: endIso,
|
||||
current: containsDay(startIso, endIso, asOf),
|
||||
derived: false,
|
||||
antardashas,
|
||||
}];
|
||||
});
|
||||
const current = periods.find((item) => item.current);
|
||||
const currentAntar = current?.antardashas.find((item) => item.current);
|
||||
const currentLabel = current
|
||||
? `${current.label}${currentAntar ? ` / ${currentAntar.label}` : ""}`
|
||||
: "未落入已返回的时段";
|
||||
return {
|
||||
id: "yogini" as const,
|
||||
title: "Yogini",
|
||||
method: "Yogini",
|
||||
currentLabel,
|
||||
antardashaNote: YOGINI_ANTARDASHA_UNVERIFIED_NOTE,
|
||||
periods,
|
||||
};
|
||||
}
|
||||
|
||||
function unavailableWestern(reason: string): ChartViewUnavailableLayer {
|
||||
return { status: "unavailable", reason, copy: WESTERN_UNAVAILABLE_COPY };
|
||||
}
|
||||
@@ -789,7 +853,7 @@ function mapWestern(raw: Record<string, unknown> | null, ayanamsaDisplay: string
|
||||
}
|
||||
|
||||
export function buildChartView(bundle: ChartViewEngineBundle): ChartViewOk {
|
||||
const { chart, varga, chara, western, bhava, transit, profile, asOf } = bundle;
|
||||
const { chart, varga, chara, yogini = null, western, bhava, transit, profile, asOf } = bundle;
|
||||
const planets = asPlanetMap(chart.planets);
|
||||
const moon = planets.Moon ?? {};
|
||||
const ascendant = record(chart.ascendant);
|
||||
@@ -801,6 +865,7 @@ export function buildChartView(bundle: ChartViewEngineBundle): ChartViewOk {
|
||||
if (vargas.length === 0) throw new Error("vedic_chart_unavailable");
|
||||
const vimshottari = vimshottariTrack(chart, asOf);
|
||||
const charaMapped = charaTrack(chara, profile.date, asOf);
|
||||
const yoginiMapped = yoginiTrack(yogini, asOf);
|
||||
const placeLabel = profile.placeLabel?.trim()
|
||||
|| formatCoord(profile.latitude, profile.longitude);
|
||||
const view = {
|
||||
@@ -839,6 +904,7 @@ export function buildChartView(bundle: ChartViewEngineBundle): ChartViewOk {
|
||||
asOf: isoDay(asOf),
|
||||
vimshottari,
|
||||
chara: charaMapped,
|
||||
yogini: yoginiMapped,
|
||||
dualTrackNote: DASHA_DUAL_TRACK_NOTE,
|
||||
},
|
||||
western: mapWestern(western, ayanamsaDisplay),
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import test from "node:test";
|
||||
|
||||
import { ChartDashaTab } from "../src/components/chart-page/chart-dasha-tab.tsx";
|
||||
import { chartViewOkSchema, type ChartViewOk } from "../src/lib/chart-view-contract.ts";
|
||||
import {
|
||||
chartViewEngineCacheKey,
|
||||
chartViewEngineCachePath,
|
||||
dashaCacheScope,
|
||||
} from "../src/lib/chart-view-engine.ts";
|
||||
import { assembleChartView, chartDashaLayerRequest } from "../src/lib/chart-view-load.ts";
|
||||
import { ANTARDASHA_DERIVED_NOTE, CHARA_ANTARDASHA_DATE_NOTE, YOGINI_ANTARDASHA_UNVERIFIED_NOTE } from "../src/lib/chart-view-labels.ts";
|
||||
|
||||
Object.assign(globalThis, { React });
|
||||
|
||||
const fixtureText = readFileSync(new URL("./fixtures/yogini-dasha-api-1990-01-01.golden.json", import.meta.url), "utf8");
|
||||
const yoginiFixture = JSON.parse(fixtureText) as {
|
||||
sourceCommand: string;
|
||||
ayanamsa: string;
|
||||
node_mode: string;
|
||||
response: Record<string, unknown>;
|
||||
};
|
||||
const golden = JSON.parse(
|
||||
readFileSync(new URL("./fixtures/chart-view-golden.json", import.meta.url), "utf8"),
|
||||
) as { chart: Record<string, unknown>; chara: Record<string, unknown> };
|
||||
|
||||
const profile = {
|
||||
name: "示例",
|
||||
date: "1990-06-15",
|
||||
time: "12:00",
|
||||
placeLabel: "北京",
|
||||
latitude: 39.9042,
|
||||
longitude: 116.4074,
|
||||
timezoneOffset: 8,
|
||||
timezoneId: "Asia/Shanghai",
|
||||
ayanamsa: "raman",
|
||||
birthTimeStatus: "confirmed" as const,
|
||||
};
|
||||
|
||||
const person = {
|
||||
userId: "user-1",
|
||||
subjectId: "self",
|
||||
date: "1990-01-01",
|
||||
time: "12:00",
|
||||
latitude: 28.6139,
|
||||
longitude: 77.209,
|
||||
timezoneOffset: 5.5,
|
||||
ayanamsa: "lahiri",
|
||||
nodeMode: "mean",
|
||||
};
|
||||
|
||||
test("the yogini fixture is a real dasha response, not a hand-built shape", () => {
|
||||
assert.match(yoginiFixture.sourceCommand, /_compute_dasha_system/);
|
||||
assert.match(yoginiFixture.sourceCommand, /dasha=yogini/);
|
||||
assert.equal(yoginiFixture.ayanamsa, "lahiri");
|
||||
assert.equal(yoginiFixture.node_mode, "mean");
|
||||
const periods = yoginiFixture.response.periods;
|
||||
assert.ok(Array.isArray(periods) && periods.length > 0);
|
||||
});
|
||||
|
||||
test("the same person gets a different cache key for each dasha system", () => {
|
||||
const followUp = { date: person.date, time: person.time };
|
||||
const chara = chartDashaLayerRequest("chara", followUp);
|
||||
const yogini = chartDashaLayerRequest("yogini", followUp);
|
||||
const kalachakra = chartDashaLayerRequest("kalachakra", followUp);
|
||||
assert.equal(chara.path, "/api/dasha/chara");
|
||||
assert.equal(chara.cacheScope, undefined);
|
||||
assert.equal(yogini.path, "/api/dasha");
|
||||
assert.equal(yogini.cacheScope, "dasha:yogini");
|
||||
assert.equal(yogini.body.dasha, "yogini");
|
||||
assert.equal(kalachakra.path, "/api/dasha");
|
||||
assert.equal(kalachakra.cacheScope, dashaCacheScope("kalachakra"));
|
||||
assert.equal(kalachakra.body.dasha, "kalachakra");
|
||||
const key = (path: string, cacheScope?: string) => chartViewEngineCacheKey({
|
||||
...person,
|
||||
path: chartViewEngineCachePath(path, cacheScope),
|
||||
});
|
||||
const keys = [
|
||||
key(chara.path, chara.cacheScope),
|
||||
key(yogini.path, yogini.cacheScope),
|
||||
key(kalachakra.path, kalachakra.cacheScope),
|
||||
];
|
||||
assert.equal(new Set(keys).size, 3);
|
||||
assert.notEqual(JSON.stringify(chara.body), JSON.stringify(yogini.body));
|
||||
assert.notEqual(JSON.stringify(yogini.body), JSON.stringify(kalachakra.body));
|
||||
});
|
||||
|
||||
async function yoginiView(): Promise<ChartViewOk> {
|
||||
const scopes: Array<string | undefined> = [];
|
||||
const result = await assembleChartView({
|
||||
userId: "user-1",
|
||||
profile,
|
||||
asOf: "1990-06-01",
|
||||
layers: ["chara", "yogini"],
|
||||
postEngine: async (path, body, cacheScope) => {
|
||||
if (path === "/api/chart") return { status: "ok", payload: golden.chart };
|
||||
if (path === "/api/dasha/chara") return { status: "ok", payload: golden.chara };
|
||||
if (path === "/api/dasha") {
|
||||
scopes.push(cacheScope);
|
||||
assert.equal(body.dasha, "yogini");
|
||||
return { status: "ok", payload: yoginiFixture.response };
|
||||
}
|
||||
return { status: "http_error", path, elapsedMs: 1, httpStatus: 500 };
|
||||
},
|
||||
});
|
||||
assert.deepEqual(scopes, ["dasha:yogini"]);
|
||||
return chartViewOkSchema.parse(result.body);
|
||||
}
|
||||
|
||||
test("a real yogini response maps dates and labels without deriving them", async () => {
|
||||
const view = await yoginiView();
|
||||
const track = view.dasha.yogini;
|
||||
assert.equal(track.id, "yogini");
|
||||
const first = track.periods[0];
|
||||
assert.ok(first);
|
||||
assert.equal(first.label, "Pingala / 太阳");
|
||||
assert.equal(first.derived, false);
|
||||
assert.equal(first.start, "1990-01-01");
|
||||
assert.equal(first.end, "1990-01-12");
|
||||
assert.equal(first.antardashas.length, 1);
|
||||
assert.equal(first.antardashas[0]?.label, "Mangala / 月亮");
|
||||
assert.equal(first.antardashas[0]?.derived, false);
|
||||
assert.equal(first.antardashas[0]?.start, "1990-01-01");
|
||||
const dhanya = track.periods.find((period) => period.label === "Dhanya / 木星");
|
||||
assert.ok(dhanya);
|
||||
assert.equal(dhanya.derived, false);
|
||||
assert.equal(dhanya.antardashas.length, 8);
|
||||
assert.equal(dhanya.antardashas.every((item) => item.derived === false), true);
|
||||
assert.equal(dhanya.current, true);
|
||||
assert.equal(dhanya.antardashas.some((item) => item.current), true);
|
||||
assert.equal(track.antardashaNote, YOGINI_ANTARDASHA_UNVERIFIED_NOTE);
|
||||
});
|
||||
|
||||
test("the yogini note sits under that track only", async () => {
|
||||
const view = await yoginiView();
|
||||
const yogini = renderToStaticMarkup(React.createElement(ChartDashaTab, { view, system: "yogini" }));
|
||||
assert.equal((yogini.match(/小运未经外部软件核对。/g) ?? []).length, 1);
|
||||
assert.match(yogini, /<dt>大运<\/dt>/);
|
||||
assert.match(yogini, /<dt>小运<\/dt>/);
|
||||
assert.match(yogini, /<dt>小运剩余<\/dt><dd>\d+ 个月<\/dd>/);
|
||||
assert.match(yogini, /Dhanya \/ 木星/);
|
||||
assert.match(yogini, new RegExp(ANTARDASHA_DERIVED_NOTE));
|
||||
assert.doesNotMatch(yogini, new RegExp(CHARA_ANTARDASHA_DATE_NOTE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
||||
const chara = renderToStaticMarkup(React.createElement(ChartDashaTab, { view }));
|
||||
assert.equal((chara.match(/小运未经外部软件核对。/g) ?? []).length, 0);
|
||||
assert.match(chara, new RegExp(CHARA_ANTARDASHA_DATE_NOTE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
||||
assert.match(chara, new RegExp(ANTARDASHA_DERIVED_NOTE));
|
||||
assert.match(chara, /aria-selected="true"[^>]*>Chara</);
|
||||
});
|
||||
@@ -94,7 +94,13 @@ test("the chart-view BFF never bills and never calls a model", () => {
|
||||
assert.match(serviceSource, /loadSubjectBirth/);
|
||||
assert.doesNotMatch(serviceSource, /clientBirth|body\.profile/);
|
||||
assert.match(loadSource, /\/api\/chart/);
|
||||
assert.match(loadSource, /\/api\/dasha\/chara/);
|
||||
// 原值: loadSource 匹配 /api/dasha/chara
|
||||
// 新值: 路径写在 CHART_DASHA_SYSTEMS,loadSource 经 chartDashaLayerRequest 取用
|
||||
// 原因: Chara 与 Yogini 共用一张大运层表,Yogini 走 /api/dasha 且缓存 scope 是 dasha:yogini(issue #88)
|
||||
const engineSource = readFileSync(new URL("../src/lib/chart-view-engine.ts", import.meta.url), "utf8");
|
||||
assert.match(engineSource, /\/api\/dasha\/chara/);
|
||||
assert.match(engineSource, /dasha:yogini/);
|
||||
assert.match(loadSource, /chartDashaLayerRequest/);
|
||||
assert.match(loadSource, /\/api\/varga_full/);
|
||||
assert.match(loadSource, /planets/);
|
||||
assert.match(loadSource, /ascendant/);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user