feat(dasha): 星盘按需取 Yogini,缓存按体系分开

同一人的 Chara、Yogini 和以后的大运键各走各的缓存。页面日期直接用引擎返回的日子。

Co-Authored-By: Grok (agent loop)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jesse-ux
2026-10-22 03:55:35 +08:00
co-authored by Grok (agent loop) Cursor
parent 85567071c7
commit 7a296c7348
8 changed files with 2259 additions and 13 deletions
+3 -1
View File
@@ -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]),
+22 -1
View File
@@ -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> }
| {
+2
View File
@@ -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,不展开";
+36 -9
View File
@@ -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,
+67 -1
View File
@@ -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),
+152
View File
@@ -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</);
});
+7 -1
View File
@@ -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