在线排盘系统架构:历法计算、规则引擎与SVG渲染

发布时间:2026/10/25 2:14:11
在线排盘系统架构:历法计算、规则引擎与SVG渲染

简介:在线排盘系统面向需要在线完成版面布局与图形设计的开发者、设计师及远程协作团队,核心在于用浏览器替代桌面软件,把编辑、预览、同步与多人协作集中到同一套Web应用中。其知识跨度覆盖前端界面构建、实时数据同步、云端文件存储、权限与版本管理、图像资源加载优化及第三方API接入等环节,适合作为理解在线设计工具整体架构的综合性参考。资源包约20.29MB,文件总数显示为0、类型明细暂未提供,实际内容以解压结果为准。页面显示已有235人学习或下载,说明该主题在在线排盘、协同设计方向具有一定关注度。对读者而言,可借助资源梳理在线排盘系统的功能边界与技术组成,理解实时同步、云存储、协作权限、性能优化和安全传输等关键点如何落到一个Web设计工具中,并据此规划功能模块、技术选型与协作流程。

1. 从「差一分钟」的报错说起:在线排盘系统到底在算什么

前阵子接手一个在线排盘系统的二次开发,bug 报得很怪:同一个生日,手机和电脑算出来的时柱不一样。翻日志才发现,前端把datetime-local的原始字符串原样丢给后端,后端按服务器本地时区解析,一台机器在东八区、一台跑在 UTC,时柱整整挪了一位。在线排盘系统看着像个「填生日、出结果」的页面,底层其实是两套引擎叠在一起:一套历法引擎负责把「公历时间 + 出生地经度」换算成真太阳时、节气、四柱干支;一套规则引擎在这些干支上推十神、大运流年、十二宫星曜;最后交给渲染层画成一张能看、能导出、能转发的盘。它适合想接手这类工具做二次开发的人,也适合想拆一套「纯计算 + 可视化」架构的读者——计算部分干净到可以单测,渲染部分独立到可以换框架。

2. 分层与选型:一次排盘请求要穿过哪几层

2.1 把历法内核拆成独立的纯计算包

这类项目最容易走歪的地方,是把干支换算、节气判定直接写进 Controller 里,用的时候随手new Date()取当前时区。第一次改需求要加「农历输入」时,整个链路就崩了。我的做法是先把计算层剥出来做成一个零依赖的包,它不读环境变量、不连数据库、不发网络请求,输入一个普通对象,输出一个普通对象。

// packages/engine/src/index.ts // 纯函数入口:同样的输入永远得到同样的输出,方便单测和快照回归 export interface BirthInput { /** 本地挂钟时间,形如 1990-05-12T08:30:00,不带时区后缀 */ localDateTime: string; /** IANA 时区名,决定上面那个挂钟时间怎么还原成 UTC */ timeZone: string; /** 出生地经度,东经为正、西经为负,用于真太阳时校正 */ longitude: number; /** 性别,只影响大运顺逆排,不影响四柱本身 */ gender: 'male' | 'female'; /** 输入历法,农历需要先做一次转换再进入主干 */ calendar: 'solar' | 'lunar'; } export interface ChartResult { pillars: { year: Pillar; month: Pillar; day: Pillar; hour: Pillar }; solarTime: { utc: string; trueSolar: string; offsetMinutes: number }; luck: { startAge: number; cycles: LuckCycle[] }; palaces?: Palace[]; // 紫微盘才有,八字盘返回 undefined } export function buildChart(input: BirthInput): ChartResult;

localDateTime故意不带时区后缀,是因为用户的出生时间是「墙上钟看到的时间」,它本身就是当地口径,硬塞一个Z反而会错。timeZone单独传,是为了让「1990 年当地有没有夏令时」这件事由时区数据库回答,而不是靠开发猜。longitude单独传而不是用城市名反查,是因为很多老用户的出生地只能精确到县,城市级别的经纬度误差在时柱边界上足够翻车。

这一步做完,同一个 engine 就同时能跑在三个地方:服务端做批量导出和定时任务;浏览器里做「输入即预览」,用户还没点提交就能看到草稿盘;打包进桌面壳或小程序里做离线试算。后面加需求,只需要动这一个包。

2.2 请求链路:从表单提交到星盘返回

HTTP 层只做三件事:校验、归一化、编排。真正的计算一行都不在这里。

// apps/api/src/routes/chart.js fastify.post('/api/v1/chart', { schema: { body: chartBodySchema } }, async (req, reply) => { const input = normalize(req.body); // 1) 把前端各种脏格式收敛成 BirthInput const key = cacheKey(input); // 2) 输入指纹,见 4.4 const cached = await redis.get(key); if (cached) return reply.send(JSON.parse(cached)); const result = buildChart(input); // 3) 纯计算,通常 5~20ms const dto = toViewModel(result); // 4) 补齐前端要的展示字段,不塞进 engine await redis.set(key, JSON.stringify(dto), 'EX', 60 * 60 * 24 * 7); return reply.send(dto); });

normalize里做几件容易被忽略的事:把2024/5/1 8:30这类前端手输格式统一成 ISO;校验经度落在 -180~180;性别只允许两个枚举值,其余一律拒绝而不是默认成 male。toViewModel是刻意的第二层,engine 输出的干支是给程序看的索引和字符,前端要的是「甲木、阳、十神为比肩」这种带标签的结构,两者混在一起以后改字段会牵扯一大片。缓存键包含所有影响结果的参数——时间、时区、经度、性别、历法、算法版本号,少一个就会出现「改了算法,老用户还在看旧盘」的玄学问题。

2.3 目录结构与依赖方向

repo/ ├─ packages/ │ ├─ engine/ # 纯计算:历法、干支、安星,零框架依赖 │ │ ├─ src/time.ts # 时区、真太阳时、均时差 │ │ ├─ src/jieqi.ts # 节气表与边界判定 │ │ ├─ src/pillar.ts # 四柱、十神 │ │ ├─ src/luck.ts # 大运、流年 │ │ └─ src/ziwei/ # 十二宫、安星规则 │ ├─ shared/ # DTO 类型、校验 schema,前后端共用 │ └─ render/ # SVG 星盘渲染,只依赖 shared 的 DTO ├─ apps/ │ ├─ api/ # 路由、缓存、限流、导出任务 │ └─ web/ # 表单、结果页、分享图导出 └─ tests/ └─ golden/ # 已知命例的快照,改算法必跑

依赖方向卡死:engine谁都不依赖,shared只放类型和 schema,render只依赖shared,api和web依赖前三者。这条规则最大的收益是排障——用户说「盘不对」,先看toViewModel之前的原始输出对不对,对就是渲染问题,不对就是计算问题,五分钟能定位到层。

模块职责不该做的事
engine历法换算、四柱、安星读环境变量、连库、拼展示文案
sharedDTO 类型、请求校验放业务规则
render坐标计算、SVG 输出参与任何干支推算
api校验、缓存、编排、导出内联计算公式

3. 历法内核拆解:从一个生日到四柱干支

3.1 时间口径:时区、夏令时与真太阳时

排盘系统里 90% 的「结果不对」都出在这一步。用户给的是挂钟时间,服务器拿到的是字符串,中间夹着时区规则和历史夏令时。东八区在 1986 到 1991 年之间实行过夏令时,那几年夏天出生的用户,挂钟时间比标准时快一小时,不做处理,时柱直接错一位。

// packages/engine/src/time.ts const MINUTES_PER_DEGREE = 4; // 地球自转 1 经度 = 4 分钟 const STANDARD_MERIDIAN = 120; // 东八区标准经线 export function toTrueSolarTime(local: Date, longitude: number, tzOffsetMinutes: number) { // 1) 把挂钟时间还原成 UTC 时间戳 const utcMs = local.getTime() - tzOffsetMinutes * 60_000; // 2) 经度时差:出生地偏离标准经线多少度,就差多少个 4 分钟 const lonCorrection = (longitude - STANDARD_MERIDIAN) * MINUTES_PER_DEGREE; // 3) 均时差:地球轨道偏心率与黄赤交角造成的 ±16 分钟摆动 const eot = equationOfTime(new Date(utcMs)); return { utc: new Date(utcMs).toISOString(), trueSolar: new Date(utcMs + (lonCorrection + eot) * 60_000), offsetMinutes: lonCorrection + eot, // 负数表示比标准时慢 }; }

tzOffsetMinutes不要自己写死 480,用Intl.DateTimeFormat带timeZone反解当天的偏移量,让时区数据库去处理夏令时历史。lonCorrection的符号要和经度一致:出生地在东经 120 度以西,真太阳时就比北京时间慢,四柱的时柱可能往回退一个时辰。equationOfTime一年的振幅在 -14 到 +16 分钟之间,平时看着无所谓,但出生时间正好卡在时辰交界(比如 07:00 整)的人,这十几分钟就是两个完全不同的时柱。

提示:真太阳时校正做不做,行业内本身有分歧。工程上的做法是把它做成可开关的选项,默认开启,同时在结果页标注「已按东经 X 度校正,提前/延后 Y 分钟」,让用户自己判断,别在代码里私自决定。

3.2 节气定月:年柱和月柱的边界

年柱换柱点不是元旦,也不是农历正月初一,而是立春;月柱换柱点是十二个「节」。这一条如果按农历月份去算,跨年那几天出生的用户全错。

// packages/engine/src/jieqi.ts // 节气用太阳视黄经每 15° 一个节点来定义,比查表更适合跨年份 const JIE_NAMES = ['立春','惊蛰','清明','立夏','芒种','小暑', '立秋','白露','寒露','立冬','大雪','小寒']; export function solarTermAt(target: Date): { name: string; at: Date; longitude: number } { // 黄经每 15° 一个节气,立春对应 315° const approx = estimateTermTime(target); // 牛顿迭代修正:视黄经与平黄经之间有章动和光行差,迭代 3 次足够到秒级 let t = approx; for (let i = 0; i < 3; i++) { const delta = normalizeDiff(apparentSolarLongitude(t) - degreeOfNearestTerm(target)); t = new Date(t.getTime() - (delta / 0.9856) * 86400_000); } return { name: matchName(t), at: t, longitude: apparentSolarLongitude(t) }; }

判断某次出生属于哪个月柱,本质是个比较:找到出生时刻前后相邻的两个「节」,落在哪个区间就是哪个月。年柱同理,立春之前算上一年。这段代码的坑在边界:立春当天的时刻是精确到分的,某年立春在 2 月 4 日 04:27,那么 04:26 出生的人年柱还是上一年。用「日期」比较而不是「时刻」比较,就会在每年立春当天产生一批错误数据。我一般会把前后两个节气的精确时刻一起返回,存进结果里,出问题时能一眼看出是不是踩在边界上。

3.3 日柱与时柱:六十甲子和五鼠遁

日柱最稳的做法是锚点加取模,不要用「某年某月某日对应的公式」那种一大串常数的写法,改不动也验不了。

// packages/engine/src/pillar.ts const STEMS = ['甲','乙','丙','丁','戊','己','庚','辛','壬','癸']; const BRANCHES = ['子','丑','寅','卯','辰','巳','午','未','申','酉','戌','亥']; // 锚点日:该日的日柱为甲子(索引 0),所有日期都相对它取模 const ANCHOR_MS = Date.UTC(2000, 0, 7); const DAY_MS = 86_400_000; export function dayPillar(trueSolar: Date) { const dayStart = Date.UTC( trueSolar.getUTCFullYear(), trueSolar.getUTCMonth(), trueSolar.getUTCDate() ); const diff = Math.round((dayStart - ANCHOR_MS) / DAY_MS); const idx = ((diff % 60) + 60) % 60; // 负数取模要补 60,否则跨 2000 年就错 return { stem: STEMS[idx % 10], branch: BRANCHES[idx % 12], index: idx }; } // 五鼠遁:由日干推时干,口诀「甲己还加甲,乙庚丙作初…」 export function hourPillar(dayStemIndex: number, hourBranchIndex: number) { const stemIndex = ((dayStemIndex % 5) * 2 + hourBranchIndex) % 10; return { stem: STEMS[stemIndex], branch: BRANCHES[hourBranchIndex] }; }

两个参数必须注意。第一,trueSolar传进来之前已经做过真太阳时校正,且必须用校正后的日期部分来算日柱,用 UTC 原始日期会在 23:00 之后出生的人身上出错——子时跨日,业内还有「早子时 / 晚子时」两种处理,选一种,写进配置,别两处代码各用各的。第二,diff用Math.round而不是Math.floor,因为夏令时会让某些日期在毫秒层面差一小时,floor会把整天算错。

时柱的地支由时辰决定,每两小时一个,23:00 起算子时。这里和真太阳时是联动的:校正后的时间才拿去查时辰,别拿用户输入的挂钟时间查。

3.4 大运流年:顺逆排与起运岁数

大运的排法依赖年干阴阳和性别:阳年男、阴年女顺排,其余逆排。起运岁数按「出生到相邻节气的天数 ÷ 3」折算,三天抵一年,一天抵四个月。

// packages/engine/src/luck.ts const YANG_STEMS = new Set(['甲','丙','戊','庚','壬']); export function buildLuck(opts: { birth: Date; gender: 'male' | 'female'; yearStem: string; nextJie: Date; prevJie: Date; monthIndex: number; }) { const isYangYear = YANG_STEMS.has(opts.yearStem); const forward = (isYangYear && opts.gender === 'male') || (!isYangYear && opts.gender === 'female'); const target = forward ? opts.nextJie : opts.prevJie; const days = Math.abs(target.getTime() - opts.birth.getTime()) / 86_400_000; const startAge = days / 3; // 3 天 = 1 年 return { forward, startAge: Math.round(startAge * 12) / 12, // 保留到月,前端要显示「X 岁 Y 个月起运」 cycles: Array.from({ length: 8 }, (_, i) => { const step = forward ? i + 1 : -(i + 1); return makeCycle((opts.monthIndex + step + 120) % 60); }), }; }

forward一旦算错,整个大运序列是反的,而且前几步看着还挺像,用户很难第一时间发现。所以我会在结果里显式输出forward字段并在界面上标一句「顺排 / 逆排」,出问题时对比资料一眼能看出来。startAge保留到月而不是整数年,是因为起运在两岁以内的盘,差几个月会影响流年对应关系。

4. 规则引擎与星盘渲染:十二宫数据怎么变成一张 SVG

4.1 宫位与星曜的数据建模

紫微盘的核心是十二宫,每宫挂一个地支、一个宫名(命宫、兄弟、夫妻……)、一组星曜。数据模型不要用嵌套对象,用扁平数组加索引,渲染和测试都省事。

// packages/engine/src/ziwei/types.ts export interface Palace { /** 0~11,以寅宫为 0,顺时针递增 */ index: number; branch: string; // 地支名 name: string; // 宫名,命宫/兄弟/… isBodyPalace: boolean; // 是否身宫 majors: MajorStar[]; // 十四主星 minors: MinorStar[]; // 辅星、煞星 decade?: { start: number; end: number }; // 大限区间 }

index用「以寅为 0」而不是「以子为 0」,是因为紫微的安星规则全部从寅宫起算,转换只发生在渲染层。如果建模时用子为 0,每一条安星规则里都要写一次+2的偏移,改一次错一次。decade放进来是为了让渲染层不用再算一遍大限,但它是派生数据,engine 里必须由同一个函数生成,别在两个地方各算一份。星曜用对象而不是纯字符串,是为了挂「庙旺利陷」「四化」这些属性,后面加流年四化时不用改结构。

4.2 安星规则:顺序比算法本身更容易错

紫微的安星是一串有严格先后顺序的步骤:定命宫身宫 → 定五行局 → 起紫微 → 安十四主星 → 安辅星 → 起大限。顺序错了,结果看着像但细节全偏。

// packages/engine/src/ziwei/place.js export function placeZiwei(ju, lunarDay) { // ju 为五行局数(水二局=2 … 火六局=6),lunarDay 为农历日 let n = 1; while (ju * n < lunarDay) n++; // 找到第一个 >= 生日的局数倍数 const base = ju * n; const diff = base - lunarDay; // 差为偶数顺势退,为奇数逆势进,落点以寅宫为 0 位 const pos = diff % 2 === 0 ? (n + diff / 2) : (n - (diff + 1) / 2); return ((pos % 12) + 12) % 12; // 兜住负数 }

ju来自命宫干支纳音,lunarDay是农历日,两个输入任何一个错,紫微星位就整体偏移。这里的取模同样要补正,pos为负时不补 12 会得到-3这种下标,在数组里取到undefined,渲染出来就是「某一宫空了」,非常难查。十四主星按紫微与天府的相对位置成对安放,天府的位置由紫微反推,这一步必须放在紫微之后;辅星里有些依赖年干、有些依赖月支、有些依赖时支,我在代码里按「依赖谁就紧跟在谁后面」的顺序写,并且每一步都加断言,确保输入是有效值再往下走。

4.3 SVG 渲染:十二宫布局与响应式

星盘用 SVG 画,比 Canvas 好在可以直接用 CSS 控制字号,导出时也能拿到矢量。布局是经典的四乘四网格,外圈十二格,中间四格放核心信息。

// packages/render/src/layout.js // 十二宫在 4×4 网格外圈顺时针排布,起点固定在左下角(寅宫) const RING = [ [0,3],[1,3],[2,3],[3,3], // 底边:寅卯辰巳 [3,2],[3,1],[3,0], // 右边:午未申 [2,0],[1,0],[0,0], // 顶边:酉戌亥 [0,1],[0,2], // 左边:子丑 ]; export function palaceTransform(index, cell, gap = 2) { const [col, row] = RING[index]; return { x: col * cell + gap, y: row * cell + gap, width: cell - gap * 2, height: cell - gap * 2, }; }

cell由容器宽度除以 4 得到,配合viewBox="0 0 400 400"让整个盘按比例缩放,手机上不会糊。宫名和地支用小字号贴顶,主星居中,辅星排右下,四化标记用右上角的小方块。字体不要依赖系统默认,导出时缺字是高频问题——<text>里显式指定一个随包体积不大的字体,或者在导出前把文字转成路径。

高 DPI 屏上线条发虚是另一个常见问题,根源是viewBox尺寸和 CSS 尺寸不成整数倍,shape-rendering设成geometricPrecision能缓解,但最稳的是把viewBox设成 2 倍并在导出时按设备像素比放大。

4.4 缓存、幂等与导出

排盘是典型的「同输入同输出」,天然适合缓存,但缓存键必须覆盖所有影响结果的参数,包括算法版本。

// apps/api/src/cache-key.js import { createHash } from 'node:crypto'; export const ENGINE_VERSION = 'ziwei-1.4.2'; // 算法一改,这个必须跟着改 export function cacheKey(input) { return 'chart:' + createHash('sha1') .update(JSON.stringify({ t: input.localDateTime, tz: input.timeZone, lon: input.longitude, g: input.gender, cal: input.calendar, v: ENGINE_VERSION, })) .digest('hex'); }

ENGINE_VERSION是后悔药。没有它,改完安星规则上线,老用户刷新还是旧盘,客服会被问爆。缓存时间给七天足够,超时后重新计算结果一致,不会出现「同一用户两次打开不一样」的情况。导出走单独接口,服务端渲染好 SVG 再转图片,不要在前端用html2canvas截图——字体和线条在截图里最容易出事。

注意:导出接口要做限流。星盘图单张不大,但批量导出很容易把内存拉满,尤其是并发转图片的时候。

5. 排查与避坑:五条踩过的血泪记录

5.1 时间口径类:一分钟之差,整盘全变

坑 1:同一生日,两台设备结果不同。现象是用户在小程序里看到时柱为「午」,在网页端变成「巳」,反馈截图对不上。 原因是前端把输入框的字符串直接提交,后端用new Date(str)解析,不同运行环境的默认时区不同;容器里跑 UTC,开发机跑东八区,同一个字符串解析出两个时间戳。 解决办法是把时间字符串的解析权收归normalize:一律按传入的timeZone手动解析年月日时分,绝不依赖new Date(str)的隐式行为;同时把解析后的 UTC 时间戳和校正量写进日志和结果对象,出问题时对比这两个数就能定位。这条改完之后,我还在接口层加了一条断言:输入必须带timeZone,缺失直接 400,不允许「默认东八区」这种兜底。

坑 2:1988 年夏天出生的用户,时柱普遍偏一位。现象是集中在某几个年份夏季的用户报错。 原因是那段年份实行过夏令时,用户填的是挂钟时间,实际标准时比它慢一小时,代码里用了固定的时区偏移量。 解决是改用Intl.DateTimeFormat按时区反解当天的真实偏移,不要写死480。改完顺手加了一组回归用例,覆盖历史上实行过夏令时的年份区间,避免以后换时区数据源又翻车。

5.2 节气与跨年类

坑 3:立春当天出生的盘,年柱还是上一年。现象是每年立春前后一两天,总会冒出一批「年柱不对」的反馈。 原因是判断年柱时比较的是日期而不是时刻,而立春是精确到分的一个瞬间。 解决是把前后两个节气的精确时刻一起算出来,比较用时间戳;同时在结果里输出jieqiBoundary字段,标明本次出生距离最近的节气有多久。距离小于 24 小时的,前端加一个小提示,让用户确认出生时间是否精确到分。这个改动之后,这类工单基本归零。

5.3 渲染与导出类

坑 4:导出的星盘图手机上字体糊、线条虚。现象是网页上看很清楚,导出成图片后发虚。 原因是前端截图方式导出,且 SVG 的viewBox尺寸与设备像素比不匹配。 解决是导出改走服务端,用同一份render包渲染 SVG,按 2 倍尺寸输出再转图片;字体随包一起加载,避免服务端缺字体导致文字回退成方框。这一条属于典型的「本地好好的,上线就翻车」。

5.4 部署与依赖类

坑 5:算法更新后,老用户看到的还是旧盘。现象是版本上线后,一部分用户清理缓存才看到新结果。 原因是缓存键里没有算法版本号,旧结果一直在有效期里。 解决是把ENGINE_VERSION拼进缓存键,并做成发版脚本的一部分——打 tag 时自动写入。另外给缓存加了命名空间前缀,需要时可以整批失效,不用等七天。

6. 二次开发:把排盘内核做成可复用的服务

6.1 先把输入输出契约抽出来

接手别人的排盘项目,我第一件事不是读算法,而是找「输入输出契约」在哪。如果它散落在各处——表单里一份、路由里一份、数据库里一份——先做的事是把它们收敛到一个shared包里,用 schema 定义清楚。这份契约一旦稳定,engine 就可以被替换、被并行开发、被别的端复用。

# 契约改动后的标准动作:类型 -> 单测 -> 快照 -> 发版 pnpm --filter @chart/shared build pnpm --filter @chart/engine test pnpm --filter @chart/engine test:golden -- --update=false

第三条命令不要随手加--update。快照是回归的唯一保险,改了算法先看差异,差异符合预期再更新,不符合就是改坏了。我吃过一次亏:为了跑通 CI 把--update加进流水线,结果某个安星步骤的偏移错误被快照「认证」了,两周后才发现。

6.2 新盘种的扩展点

加新盘种(比如从八字扩到别的体系)时,不要复制一份engine。正确的切法是按「共享的历法层」和「独有的规则层」拆开:

盘种复用模块需要新增
四柱time、jieqi、pillar、luck十神、藏干
十二宫盘time、jieqi、农历转换宫位、安星、大限
其他衍生盘time、jieqi独立规则目录

共用部分永远只有「时间 → 干支 → 节气」这一段,规则层各自独立,通过ChartResult的可选字段拼接。新增盘种时只实现一个buildXxx(input): XxxResult,在路由层按type分发,渲染层按同一个 DTO 消费。

// apps/api/src/dispatch.ts const builders = { bazi: buildBazi, ziwei: buildZiwei, } as const; export function dispatch(type: keyof typeof builders, input: BirthInput) { const build = builders[type]; if (!build) throw new BadRequest('unsupported chart type'); return build(input); }

6.3 用快照测试锁住结果

最后一招最土也最有效:攒一批已知结果的命例,每次改算法都跑一遍。我一般存成 JSON,字段只保留干支和宫位索引,不存渲染结果——渲染改动太频繁,存了也没法比。断言只用toEqual,不用「大致相等」,因为排盘没有容差的概念,错一位就是错一位。新加规则时,先把这个命例的手工推算值写进注释,再跑快照,两边对上了才提交。

从那以后我每次动engine里任何一行,都会先跑一遍快照再提交,宁可多花三十秒,也不想半夜被「盘错了」的消息叫醒。希望帮到你。

本文还有配套的精品资源,点击获取