
简介在线排盘系统面向需要在线完成版面布局与图形设计的开发者、设计师及远程协作团队核心在于用浏览器替代桌面软件把编辑、预览、同步与多人协作集中到同一套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) 纯计算通常 520ms 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校验经度落在 -180180性别只允许两个枚举值其余一律拒绝而不是默认成 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只放类型和 schemarender只依赖sharedapi和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. 规则引擎与星盘渲染十二宫数据怎么变成一张 SVG4.1 宫位与星曜的数据建模紫微盘的核心是十二宫每宫挂一个地支、一个宫名命宫、兄弟、夫妻……、一组星曜。数据模型不要用嵌套对象用扁平数组加索引渲染和测试都省事。// packages/engine/src/ziwei/types.ts export interface Palace { /** 011以寅宫为 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 … 火六局6lunarDay 为农历日 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 得到配合viewBox0 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不允许「默认东八区」这种兜底。坑 21988 年夏天出生的用户时柱普遍偏一位。现象是集中在某几个年份夏季的用户报错。 原因是那段年份实行过夏令时用户填的是挂钟时间实际标准时比它慢一小时代码里用了固定的时区偏移量。 解决是改用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 -- --updatefalse第三条命令不要随手加--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里任何一行都会先跑一遍快照再提交宁可多花三十秒也不想半夜被「盘错了」的消息叫醒。希望帮到你。本文还有配套的精品资源点击获取