Skip to content

Add chinese-divination: Plum Blossom divination Mini App - #13

Open
weekbin wants to merge 2 commits into
MiniMax-AI:mainfrom
weekbin:feat/chinese-divination
Open

weekbin wants to merge 2 commits into
MiniMax-AI:mainfrom
weekbin:feat/chinese-divination

Conversation

@weekbin

@weekbin weekbin commented Oct 3, 2026 •

Copy link
Copy Markdown

是什么

梅花易数占卜 Mini App。时间、数字、铜钱摇卦、每日一卦四种起法,按体用生克、京房八宫、纳支、
旬空月破、爻之合与爻之刑等传统规则断吉凶,把结论落到所问的那件事上,给应期与宜忌。
内置六十四卦全文(卦辞、彖传、384 条爻辞与 384 条小象传)与干支时辰历法。

两种用法:打开页面起卦;或在对话里由 Agent 经 MCP 端点主动起卦并讲解。

包目录 plugins/weekbin/chinese-divination/,插件 ID chinese-divination,版本 1.1.0。

安全与边界

  • 不联网。 Node 进程无任何出站请求,不调模型 API,不 spawn 子进程;页面不加载任何远程资源、
    字体或脚本。唯一网络行为是 MCP 端点在 Host 分配的回环地址上监听,且只接受 POST。
  • 不读包目录以外的文件。 只读 miniapp/client/index.html、本包 Node 载荷,以及
    context.dataDir/readings.json(卦历,凡碰到卦历的请求都会读它)。
  • 入参一律校验。 按 id 取卦必须匹配 [A-Za-z0-9-]{1,80},于是写得进去的条目一定删得掉;
    起卦的起法名与数值上下界在进引擎之前就校验完。
  • 存卦只收 { id, note }。 那个 id 必须命中本进程刚算出来的那一卦(服务端保留最近
    500 卦),所以落进 readings.json 的永远是引擎自己的产物,页面往里塞不了任何别的东西。
    note 是唯一自由文本,上限 2000 字。同一 id 存两次回 409——两条同 id 的记录会让删一条连带
    删另一条。代价是一卦要在起卦的同一次会话里存入(服务端重启后不记得它),这一点已写进
    README 的 Data & access。
  • 落盘原子。 临时文件写入后改名,中断不留半份文件;Windows 上被杀毒扫描或索引器占住导致
    EPERM/EACCES/EBUSY 时退避重试四次。解析不出的落盘文件被改名另存为
    readings.json.corrupt-<时间戳> 而非删除,手写批注仍可找回。
  • 回环防护。 请求的 Host 头,以及存在时的 Origin 头,都必须是回环名。Host 挡 DNS
    rebinding(攻击者域名解析到 127.0.0.1 后浏览器视作同源),Origin 挡直接打端口的跨源简单请求。
    被拒时返回 403 且不回显请求方。
  • 日志不带路径。 只记错误码,不记 error.message——Node 的 fs 报错会把完整绝对路径连同
    操作系统用户名写进 message,而 dataDir 按契约是不透明的,那串路径不该跟着日志离开进程
    (日志会被贴进 issue、传进工单、上传)。
  • 无密钥。 不读也不持有任何凭据,无 Host connector 访问。
  • 页面渲染。 卦历里的所问与批注用 textContent 渲染,其余插值一律过 escapeHtml。
    客户端不写 localStorage / cookie / IndexedDB。

测试环境与结果

三个平台均已实跑:Windows、macOS、Linux。 三边都装好后经 Agent 打开,页面渲染正常,四个分区
可操作,掷钱、卦历与 MCP 各条路径一并查过。

macOS 那一轮是脚本化的,每一步都记在 README:

  • 单元测试 248 项,全部通过。 在九个时区下各跑一遍:America/New_York、UTC、
    Asia/Shanghai、Pacific/Kiritimati、Pacific/Apia、America/Los_Angeles、
    Pacific/Honolulu、Pacific/Chatham(UTC+12:45)、Australia/Eucla(UTC+8:45),
    另加 LC_ALL=C LANG=C 极窄 locale 一遍,每种组合都是 248 通过 0 失败。
  • 两条守卫让时间依赖不会再悄悄回来。 这套测试已经两次栽在日期上(一次 MCP 起卦跟着
    当天走,一次绝对时刻在不同时区落进不同的卦),于是把两类写法禁进了测试里:禁止
    new Date('…') 绝对时刻(castByTime / castDaily 取的是本机时区的时辰与日柱),禁止
    不写明理由就碰裸时钟;每一次 MCP 调用都必须显式给 now。
  • HTTP 层是真起服务打的。 测试用 start(context) 起真服务、走真 socket,覆盖页面、全部起法、
    卦历增删查、MCP 全路径、dispose 幂等,以及本轮修的每一处注入面。
  • 路径审计 0 问题。 可移植路径规则、Windows 保留设备名、UTF-8 无 BOM 无 CRLF、无仅大小写
    不同的文件、每条相对 import 都能在磁盘上找到;最坏形态的 dataDir 全路径 112 字符,
    离 MAX_PATH 260 还远。
  • 仓库门禁 npm run check 55/55 通过;npm run validate 对本包报 OK(0 错误 0 警告)。

两条平台相关的设计决定,由静态核查托底:路径一律用 node:path 拼;无 __dirname(ESM 里不存在),
import.meta.url 只在测试文件里以 new URL(相对, import.meta.url) + fileURLToPath 出现,即
ESM 跨平台的正解,且测试文件在运行时载荷之外。

尚未核对的一项: 760px 断点以下的窄屏版式,三个平台都未在实机看过。收窄内容区替代不了——
那两处容器查询反应的是区块自身宽度而非视口宽度,只有真实窄视口才走得到那条路。

几个值得一提的实现选择

  • 起卦 id 带进程内计数器。 数字起卦只取决于两个数,同一对数在同一秒内起两卦会得到逐字节
    相同的种子,所以种子里掺一个进程内递增的计数。哈希转 36 进制有 6 位也有 7 位,这里补零而
    不截断——slice 砍掉的正是计数改动的那一位,3000 组里会撞 1228 次。存卦时「这个 id 已经
    存过」的判断与写入在同一个排队任务里,不会因并发漏判。
  • 起卦有在途闸。 铸卦动画要持续四秒多,期间所有起法按钮都可点,普通双击会发出两次起卦请求。
    现在 cast() 在第一个 await 之前同步置位 casting 并锁住触发器,解锁按状态计算而不是
    清空 disabled——成卦解卦 另有「满六次才能点」的规则,一刀切会留下一个看得见却点不动的按钮。
  • 掷钱按钮只有一处写 disabled。 原先 renderToss() 算完可用态,末尾又无条件全部启用,
    两者互相覆盖,六次之后两个按钮同时可点。现在可用态集中在 syncTossButtons()。
  • 摇卦由谁掷是调用方的决定。 页面把六次结果收齐了传上来,少一次就报错——用户看得见每次的
    点数,悄悄替他重掷会与推演日志对不上;MCP 背后没有人替用户掷,它自己掷六次。这条岔路有
    专门的测试钉住。
  • MCP 的事类交给 Agent 判。 页面没有「替用户读问题的对象」,只能用关键词表;Agent 读过
    用户原话,由它填 topic 更准。九类之外的值直接报错而不是静默忽略——静默忽略会让 Agent
    以为自己已经定过了。
  • 免责声明只有一个出处。 工具返回、MCP instructions 与 SKILL 里那一句逐字取同一个常量,
    有断言钉住。
  • 起卦入参只有一处实现。 页面与 MCP 共用 cast-params.mjs,上限值、起法名与提示语不再各写一份。

README 只讲这个包做什么、怎么用;推演规则在 docs/derivation.md,逐轮的开发与测试记录在
docs/testing.md(中英各两份)。

许可

MIT,见 LICENSE。

@MyPrototypeWhat MyPrototypeWhat left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

感谢提交!这个包的规则实现和测试都很扎实。我在本地拉了分支,npm run check 通过,本包报 OK;另外写了个临时脚本起 start(context),实际调用了接口。有几处想请你看一下:

需要修改

  1. 卦历写入接口对页面传来的 reading 基本不校验(miniapp/node/server.mjs:283-290,写入是 store.mjs:68-71 的 ...reading 原样落盘;详见行内评论)。docs/security.md 要求 "Validate every request parameter"。我实测复现了四种情况:记录存得进去却删不掉、question 没有截断、hexagram: null 让整个卦历页读取失败、同 id 记录被一起删掉。有回环守卫,影响有限,但还是建议收紧。
  2. 卦库「以此卦起一卦」起出来的不是这一卦(miniapp/client/index.html:3415,详见行内评论)。遍历 64 卦验证,只有 2 卦能起回自己。这条路径也没有走 cast() 的在途闸。
  3. 有一条单测依赖当天日期(tests/divination.test.mjs:3170,详见行内评论)。今天(10-08)跑是 227/228。

建议(可选)

  • README 的 "Data & access" 写的是 "No … path outside the package is read",第 8 行也写了 "reads nothing outside its own package"。但 store 实际会读 dataDir/readings.json,可以在 Files read 里补上。
  • README.md 有 1721 行,而 CONTRIBUTING 希望是简短的 README。推演规则和测试过程记录(比如第 1330 行起的变异测试说明)可以挪到 docs/。
  • 免责声明目前有三个版本:SKILL.md:55 要求「不得改写」的那句、工具返回的 DISCLAIMER(divination-http.mjs:106-107)、MCP instructions(第 512 行)。Agent 会拿到互相矛盾的要求,建议统一成一个常量。
  • 页面路由的 castFrom / toBoundedInteger(server.mjs:302-328)和 MCP 的 callTool / toInteger(divination-http.mjs:124-133, 299-322)是两套类似的逻辑;120、1000000000 和 SERVER_INFO 的版本号也各写了一份。可以合并成一个共用函数和一组常量。
  • handleMcpRequest 支持批量数组(divination-http.mjs:543),但 readJsonBody 会拒绝数组(server.mjs:345),实测批量请求返回 400 invalid_body。可以删掉这段,或者在入口放行数组。
  • index.html:2376 的回退写法 el('question').trim() 应该是 .value.trim()。目前所有调用都传了 question,所以还没触发。

再次感谢,有问题随时交流~

Comment on lines +283 to +290
if (typeof reading.hexagram !== 'object' || typeof reading.id !== 'string') {
sendJson(response, 400, { error: 'invalid_reading' });
return;
}
const saved = await store.save(
/** @type {any} */ (reading),
clamp(body.note, MAX_NOTE),
);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里只检查了 typeof reading.hexagram === 'object' 和 typeof reading.id === 'string',然后 store.save 会把 ...reading 原样写进 readings.json。我用脚本起了 start(context) 实测:

  • id 为 'bad id!' 的记录返回 201,可以存进去。但删除路由(第 228 行)只认 [A-Za-z0-9-]{1,80},DELETE 返回 404,页面上永远删不掉。
  • 5000 字的 question 没有按 MAX_QUESTION = 120 截断,附带的额外字段(约 40 KB)也原样落盘。
  • { id: 'n1', hexagram: null } 也能通过(typeof null === 'object')。之后卦历页 index.html:3548 读 entry.hexagram.name 会抛错,整个列表显示「读取失败」。
  • 同一条 reading 存两次,会得到两条同 id 的记录,删一条会两条一起删掉。

建议二选一:

  • /cast 时由服务端暂存生成的 reading(按 id 索引),/history 只接受 { id, note };
  • 或者至少:用和路由一样的正则校验 id、拒绝重复 id、字段白名单、question 截断、要求 hexagram 是非 null 对象。

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

采纳第一个选项。POST /history 现在收 { id, note },没有别的字段。

那个 id 必须命中本进程刚算出来的那一卦——/cast 算完就把结果留在一个 Map 里(保留最近 500 卦,超出挤掉最老的一卦),存卦时按 id 取。落进 readings.json 的因此永远是引擎自己的产物,页面改不动它。你演示的四种情况逐条变成断言:

  • 伪造 id('bad id!')→ 400,且不落盘
  • 整份 reading 回传(老契约)→ 400,且不落盘
  • 超长 note → 截到 2000;question 是引擎那一份,长度不超过 120;额外字段一律进不来
  • 同一 id 存两次 → 409,不会产生第二条同 id 的记录

hexagram: null 那条现在从根上不存在了:落盘的卦是服务端自己算的,toSummary 拿到的必然是有 name 的卦对象。

另外把 id 正则抽成一个常量,删除路由和存卦校验共用同一份——这样「写得进去」与「删得掉」不可能再分叉。

代价我写进了 README 的 Data & access:一卦要在起卦的同一次会话里存入,服务端重启后不记得它。实际使用中碰不到,因为卦只渲染在结果页上,而结果页要靠这个服务撑着。

相关测试:存卦只认 id,页面回传的整份 reading 一律不收、落盘的批注与所问都有长度上限,条目只可能来自引擎、同一 id 存两次会被挡下,删一条不会连带删另一条。

try {
const data = await api('/cast', {
method: 'POST',
body: JSON.stringify({ method: 'numbers', question: el('question').value.trim(), upper: item.order, lower: item.order }),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里传的是 upper: item.order, lower: item.order。castByNumbers 是用两个数各自除以 8 取余来定上下卦的,上下用同一个数,得到的永远是上下卦相同的纯卦。我遍历 64 卦验证,只有 2 卦能起回自己,比如 3 水雷屯 → 离为火、2 坤为地 → 兑为泽。

另外,这条路径直接调用 /cast,没有走 cast(),绕过了起卦在途闸,和 PR 说明里「三条起卦路都走 cast()」不一致。

建议服务端加一个按卦 key 起卦的方法(或者由上下卦反推出两个数),并且走 cast()。

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

两条都改了。

起不回自己。 新增 castByHexagram(),取上下卦的先天数(乾一兑二离三震四巽五坎六艮七坤八)当两个数,交给 castByNumbers()。仍然走数字起卦的规矩,起卦依据那一栏照实显示:

第一数 6 除 8 余 6 → 坎卦 | 第二数 4 除 8 余 4 → 震卦 | 动爻 10 除 6 余 4 → 四爻

服务端新增 method: 'hexagram',按卦的 key(自下而上六爻)还原。遍历六十四卦逐卦验证,64/64 起得回自己——之前只有 2 卦。这条断言同时在引擎层和 HTTP 层各跑一遍。

绕开在途闸。 这个按钮现在直接调 cast({ method: 'hexagram', key: item.key, question }),于是和另外三条起卦路一样吃得到在途闸,也才有推演动画与落定的那一拍。断言把「走 cast()」与「不再自己发 /cast 请求」两条都钉住了。

assert.equal(fu.hushen, '丙子水');
assert.equal(fu.feishen, '辛巳火');
assert.equal(fu.flying, '伏来克飞');
assert.equal(fu.emerges.key, '出得来');

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MCP 起卦用的是 new Date()(divination-http.mjs:303),而出伏结论会随日辰变化,所以这条断言取决于跑测试的当天。今天(2026-10-08)跑的结果是 227/228,失败信息为 '出不来' !== '出得来',换了时区也一样。把日期固定到 09-29、10-01、10-03、11-15 再跑,结果都是「出得来」。

建议给 callTool / handleMcpRequest 注入一个时钟,或者在测试里用固定的 now 直接调用 buildReading。

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

按你说的注入时钟。handleMcpRequest({ response, body, now }),now 缺省才取 new Date(),一路传到 callTool 和 divination_almanac。

这条断言现在传 new Date(2026, 8, 29),与日历无关。

另外补了一条反向断言,免得「注入了但没接上」也算通过:同一个 now 两次调用必须给同一卦(structuredContent 与正文都逐字相等),换一个日期解读必须真的不同。divination_almanac 也走同一个 now,历法与起卦看到的是同一个时刻。

顺带说明为什么它当初会跟着当天走:castFromParams 那条路拿到的 now 既决定卦体也决定日辰,而出伏、日辰、旺衰全都挂在日辰上。所以不是某一处结论特殊,是整条链都跟着时钟。

weekbin added a commit to weekbin/MiniMax-Code-MiniApps that referenced this pull request Oct 8, 2026
针对 MiniMax-AI#13 的评审意见。

需要修改:

- 存卦接口不校验 reading(server.mjs)。原先 `POST /history` 收的是页面回传的整份
  reading,原样落盘,是全包唯一没按 `docs/security.md`「Validate every request
  parameter」设防的地方。四种后果都复现过:伪造的 id 存得进去却删不掉(删除路由
  只认 `[A-Za-z0-9-]`)、`question` 能放大到五千字、顺手带上的几十 KB 额外字段照单
  全收、`hexagram: null` 因 `typeof null === 'object'` 过检后让卦历页整个读失败。
  改收 `{ id, note }`:id 必须命中本进程刚算出来的那一卦(保留最近 500 卦),落盘的
  永远是引擎自己的产物;note 是唯一自由文本,上限 2000;同一 id 存两次回 `409`。
  代价是存卦须与起卦同会话——这一点已写进 README 的 Data & access。

- 卦库「以此卦起一卦」起出来的不是这一卦。原先传 `upper: item.order,
  lower: item.order`,同一个数上下都用,取模 8 后上下卦必然相同,64 卦里只有 2 卦
  起得回自己。新增 `castByHexagram()`,取上下卦的先天数(一二三四五六七八)起卦,
  仍走数字起卦的规矩,起卦依据照实显示「第一数 6 除 8 余 6 → 坎卦」。这条路径同时
  改走 `cast()`,于是也吃得到在途闸。

- 一条单测依赖当天日期。`callTool` 内部走 `new Date()`,而出伏、日辰、旺衰全随日期
  变,于是断言跟着跑测试的当天翻面。`handleMcpRequest` 现在接受注入的 `now`。

建议:

- 免责声明收敛成一个常量 `DISCLAIMER`。原先三份措辞各不相同,而 SKILL 要求「不得
  改写」的那句与工具返回的并不相同,Agent 同时拿到矛盾要求。SKILL、instructions
  与每次返回现在逐字取同一常量,有断言钉住。

- 页面与 MCP 的两套起卦校验合并进 `cast-params.mjs`。`castFrom` / `toBoundedInteger`
  与 `callTool` / `toInteger` 不再各自一份,上限值、起法名与提示语只有一处;
  `SERVER_INFO` 的版本号改为取 `PACKAGE_VERSION`,与 `plugin.json` 由断言钉住。

- 删掉 `handleMcpRequest` 的批量分支。`readJsonBody` 拒绝数组,那一支永远走不到
  (实测批量请求是 400 `invalid_body`),MCP 自 2025-03-26 起也不再支持批量。

- `index.html` 里 `el('question').trim()` 改为 `.value.trim()`。元素上没有 `trim`,
  取的是 `undefined.trim()`;当时没触发是因为三条起卦路都显式传了 question。

- README 的 Data & access 补上 `dataDir/readings.json` 这一项读取,并写明新的存卦
  契约与 id 正则。

- README 由 1721 / 1299 行拆为 451 / 389 行,推演规则进 `docs/derivation.md`、
  逐轮的开发与测试记录进 `docs/testing.md`(中英各两份)。拆分按内容锚点切,
  逐行核对过原文每一非空行都在拆后的某个文件里,无内容丢失。

验证:238 项单测通过(新增 10 项,覆盖上述每一处);仓库 `npm run check` 55/55;
`npm run validate` 对本包报 OK;跨平台路径审计 0 问题;UTC-8 到 UTC+14 五个时区与
C locale 下均为 238 通过 0 失败。
梅花易数起卦与解卦。时间、数字、铜钱摇卦、每日一卦四种起法,按体用生克、京房八宫、
纳支、旬空月破、爻之合与爻之刑等传统规则断吉凶,把结论落到所问的那件事上,给应期
与宜忌;内置六十四卦全文(卦辞、彖传、384 条爻辞与 384 条小象传)与干支时辰历法。
既能在页面起卦,也能由 Agent 经 MCP 端点在对话里主动起卦。

安全与边界:

- 全部计算在本机完成:不联网、不调模型、不 spawn 进程
- 入参一律校验:按 id 取卦必须匹配 `[A-Za-z0-9-]{1,80}`,写得进去的条目一定删得掉
- 存卦只收 `{ id, note }`,且那个 id 必须命中本进程刚算出来的那一卦——落进
  `readings.json` 的永远是引擎自己的产物,页面塞不进任何东西;note 是唯一自由文本,
  上限 2000;同一 id 存两次回 `409`
- 状态只写 dataDir,临时文件改名后落盘,坏盘另存而非删除
- 请求只认回环 Host/Origin,挡住 DNS rebinding 与跨源读取;403 不回显请求方
- 日志只记错误码,不记 error.message——fs 报错会把绝对路径与系统用户名带出去
- 页面渲染:卦历的所问与批注走 textContent,其余插值一律过 escapeHtml

平台与验证:

- 三个平台(Windows / macOS / Linux)均已实跑:安装、经 Agent 打开、四个分区走通
- 239 项单测,HTTP 层真起服务走真 socket;UTC-8 到 UTC+14 五个时区与 C locale 下
  均为 239 通过 0 失败
- 路径审计 0 问题:可移植路径、Windows 保留名、UTF-8 无 BOM 无 CRLF、大小写、
  import 解析、最坏 dataDir 路径 112 字符(MAX_PATH 260 内)
- 仓库 `npm run check` 55/55;`npm run validate` 对本包报 OK
- 尚未核对的一项:760px 断点以下的窄屏版式

文档分工:README 只讲这个包做什么、怎么用;推演规则在 `docs/derivation.md`,
逐轮的开发与测试记录在 `docs/testing.md`(中英各两份)。
@weekbin
weekbin force-pushed the feat/chinese-divination branch from ecafc62 to b357734 Compare October 8, 2026 16:09
@weekbin

weekbin commented Oct 8, 2026

Copy link
Copy Markdown
Author

三条「需要修改」都改了,三条行内评论已分别回复。六条建议也一并做了:

  • Data & access 补读取 —— Files read 现在写明除包内外,还读 context.dataDir/readings.json(卦历)。同时把新的存卦契约、id 正则、以及「存卦须同会话」这条副作用都写了进去。
  • README 过长 —— 拆成 1721/1299 → 451/389 行。推演规则进 docs/derivation.md,逐轮的开发与测试记录进 docs/testing.md,中英各两份。拆分按内容锚点做,拆完逐行核对过原文每一非空行都在拆后的某个文件里,无内容丢失。
  • 免责声明三份 —— 收敛成单一常量 DISCLAIMER,SKILL、instructions、每次起卦返回逐字取它,并有断言钉住三处相同。你说得对,那三份措辞确实不同,Agent 会拿到矛盾要求。
  • 两套起卦逻辑 —— 合进新文件 cast-params.mjs。castFrom / toBoundedInteger 与 callTool / toInteger 不再各自一份,上限值、起法名、提示语只有一处。SERVER_INFO 的版本号改取 PACKAGE_VERSION,与 plugin.json 由断言钉住。
  • 批量死分支 —— 删了。readJsonBody 拒绝数组那一支确实永远走不到;MCP 自 2025-03-26 起也不再支持批量。现在数组按无回复处理,有断言。
  • .trim() —— 改成 .value.trim(),并加了断言防止再写回去。

有一点想单独说明,因为它是这次合并逻辑时自己踩到的坑,值得 reviewer 也看一眼:摇卦由谁掷必须留在调用方,不能做成共用函数的兜底。 合并之后 coinSumsFrom 曾把「没传 sums」当成自掷六次——于是页面少传一次也会被悄悄掷满,而用户在推演日志里看到的点数与落出来的卦对不上。现在 MCP 显式调 tossCoinSums(),页面少传就报错,两种行为各有断言。

验证:239 项单测通过(本轮新增 10 项,覆盖上面每一处);仓库 npm run check 55/55;npm run validate 对本包报 OK;跨平台路径审计 0 问题;UTC-8 到 UTC+14 五个时区加 C locale,每种组合都是 239 通过 0 失败。

历史整理成了一条提交——评审后的修改与原始提交合成一条,因为后面两条本质都是在补第一条,叠着看只会更难 review。内容与评审前后的最终状态逐字一致。

@MyPrototypeWhat MyPrototypeWhat left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

谢谢这么细致的一轮修改,逐条都看了,也在本地实际跑过:「以此卦起一卦」64 卦都能起回自己、也走了在途闸;伪造字段、超长文本、hexagram:null、非法 id 都被挡住;免责声明三处一致;批量数组回 400;README 拆分后 validate 0 警告。合并到最新 main 也没有冲突,npm run check 通过。剩下几处,主要都和新的「只凭 id 存卦」约定有关:

需要修改

  1. 同一 id 还是会出现两条,有两个来源(详见行内):
    • server.mjs:324-329 先 store.get 再 store.save,这是两个分开排队的任务。同一 id 并发 POST 5 次,我这边得到 201,201,201,201,409,卦历里有 4 条同 id。把「已存在就不写」放进 save 同一个排队任务里就行。
    • divination.mjs:1875 的 hash.toString(36).slice(0, 6):哈希是 7 位时会砍掉最低位,而计数器 +1 改的正好就是这一位。同一秒内连起两次同样的卦,大约 4 成会拿到同一个 id。在新约定下,后一卦会覆盖缓存里的前一卦:我复现到在「问甲事」那张卡上点存入,落盘的却是「问乙事」,而乙那张再存就是 409。改成不截断(比如 padStart(7, '0'))就好了。
  2. 服务重启后点「存入卦历」没有任何可见反馈。 docs/runtime.md 提到进程可能在两次看页之间被停掉再拉起。这时 /history 回 409 unknown_cast(没有 message),而 saveReading 只调用 announce(写进视觉上隐藏的 live region),按钮又复原成可点。用户看到的就是点了没反应。建议 409 带上一句说明(比如「服务已重启,这一卦无法再存,请重新起一卦」),页面像 cast() 那样给出可见提示。
  3. 有一条测试随本机时区挂掉。 tests/divination.test.mjs 里的 SAMPLES 用的是绝对时刻,第 2055 行又按「;」数动爻条数,而有的象传里本身就带「;」。在 America/Los_Angeles、Pacific/Honolulu 下 #81 稳定失败。改成 new Date(2026, 8, 30, 1, 20) 之后,我这边各时区都是 239/239。

建议(可选)

  • cast-params.mjs:106 掷钱点数按 1–9 收,而提示写的是 6–9;传 1–5 会落到 castByCoins 的普通 Error,MCP 返回里也就没有 recovery。可以直接按 6–9 校验。
  • MCP 这边把原始参数整个交给 castFromParams,所以 schema 里没有的 sums 和 method:'hexagram' 也会被接受:Agent 传了 sums 就不再自己掷,而未知起法的提示里又列出了 hexagram。可以考虑 MCP 一侧 coins 一律自己掷,并只接受 enum 里的起法。
  • Data & access 一节已经写清楚会读 readings.json,但 README.md:8、:323(以及中文版 :6、:284)还是「不读包外任何文件」,顺手统一一下就好。

Comment on lines +324 to +329
if (await store.get(id)) {
sendJson(response, 409, { error: 'already_saved' });
return;
}
const note = clampText(body.note, MAX_NOTE);
const saved = await store.save(reading, note);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里的 store.get 和下面的 store.save 是两个分开排队的任务。并发请求会先把各自的 get 都排完(都是 null),再各自 save。我对同一 id 并发 POST 5 次,得到 201,201,201,201,409,卦历里 4 条同 id,正好就是上面注释想避免的情况。可以把检查挪进 save 那个排队任务里,例如在 entries.unshift(entry) 前加 if (entries.some((e) => e.id === reading.id)) return null;,这里拿到 null 回 409。我本地这样改后,并发 5 次是 201,409,409,409,409,测试照样全过。

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

按你说的合并了,照搬了你给的那句写法。

store.save 现在在它自己的排队任务里先查重再落盘,撞上已存在的 id 返回 null,路由据此回 409。server 侧不再单独调 store.get。

同一 id 并发 POST 五次,现在是 201,409,409,409,409,卦历里一条。测试 同一 id 存五次只有一个成,其余全挡:检查与写入必须在同一个排队任务里 直接断这五个状态码与卦历条数——并发跑,单靠串行断言是钉不住的。

for (let index = 0; index < seed.length; index += 1) {
hash = (hash * 31 + seed.charCodeAt(index)) >>> 0;
}
return `${stamp}-${hash.toString(36).slice(0, 6)}`;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hash 是 32 位,≥ 36^6 时 toString(36) 是 7 位,slice(0, 6) 会把最低位砍掉,而 idSequence +1 主要改变的正是这一位。所以同一秒内两次同样的起卦经常还是同一个 id。我统计了 3000 个不同秒数,每秒连起两次同样的卦,撞了 1228 次。现在 /cast 的缓存按 id 存,撞了以后后一卦会覆盖前一卦:甲卡点存入,落盘的是乙卡的问题。改成 hash.toString(36).padStart(7, '0')(长度仍然符合 [A-Za-z0-9-]{1,80})就不会撞了。

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

改成了 hash.toString(36).padStart(7, '0')。

补零而不是截断是单射的:36 进制里 7 位数的首位不会是 0,所以补出来的 '0xxxxxx' 不可能与真正的 7 位数相同。长度仍是 [A-Za-z0-9-]{1,80}。

测试 id 不再被截断:同一秒内反复起同一卦,id 每次都不同 起了 3000 组,要求 3000 个 id 互不相同,且逐个匹配 ^[0-9]{14}-[a-z0-9]{7}$——这条同时钉住了「不再截断」与「形状仍能被路由取到」两件事。

另外原先钉形状的那条断言上限写的是 {1,6},已跟着改成 {1,7}。

Comment on lines +3316 to +3320
} catch (error) {
button.disabled = false;
button.textContent = '存入卦历';
announce(error.message);
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

服务重启后存卦会回 409 unknown_cast。这里只 announce(写进 .sr-only 的 live region),按钮又复原成「存入卦历」,明眼用户看到的是点了没反应,再点还是一样。建议给一个可见的提示(像 cast() 那样 alert,或在按钮旁边显示文字);服务端 409 也带上 message,比如「服务已重启,这一卦无法再存,请重新起一卦」。unknown_cast 时也可以把按钮置为不可用。

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

两处都改了。

服务端:409 现在带 message——unknown_cast 是「服务已重启,这一卦无法再存,请重新起一卦。」,already_saved 是「这一卦已经在卦历里了。」

页面:api() 现在把 status 与 error 一起挂在 Error 上,页面据此分两种情况处理。存卦那行多了一个看得见的提示元素(.save-status,aria-live,出错时变朱砂色),原先只 announce() 的写法已经不存在。unknown_cast 是终态,按钮直接置为「这一卦存不了了」并且不再还给用户;其余情况按钮复原并显示原因。

测试 存不下时回的话说得出口,页面上也看得见 两头都断:HTTP 层断言 409 带非空 message,页面源码层断言提示元素被挂进 DOM、样式写了两态、api() 带出了状态码与错误码,并且断言「失败时只写 live region」那段老写法已经不在了。

Comment on lines +787 to +792
const SAMPLES = () => [
['每日一卦', castDaily(new Date('2026-09-30T01:20:00+08:00'))],
['时间起卦', castByTime(new Date('2026-09-30T01:20:00+08:00'))],
['数字起卦', castByNumbers(37, 24)],
['铜钱摇卦', castByCoins([7, 8, 7, 8, 9, 6])],
];

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里用的是绝对时刻,castByTime / castDaily 又按本机时区取时辰,所以不同时区得到的卦不同。再加上第 2055 行按「;」数动爻条数,而有的象传里本身就带「;」(比如家人九三),TZ=America/Los_Angeles 和 Pacific/Honolulu 下 #81 会稳定失败。改成 new Date(2026, 8, 30, 1, 20)(和 MCP 那条测试的修法一样)后,我这边各时区都是 239/239。

@weekbin weekbin Oct 10, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这一条我没有只改报告的那一处,把全文件的时间依赖扫了一遍——这类问题在本包已经栽过两次,值得连根拔。

扫出来并改掉的:

  • 9 处绝对时刻 new Date('…')(含 SAMPLES 里那两处,以及另外 7 处同类的),统一换成 AT(...) 构造墙上时间。本地构造器给出的是同一个「墙上时间」,不随 TZ 变。
  • 7 处 MCP 调用没注入时刻。原先 handleMcpRequest 支持了注入 now,但这些调用一处都没用上,全走真时钟。全部补上。
  • 扫遍六十四卦取「头一个真出结果的」那条测试——那段注释本身写的就是「MCP 走的是真实时钟,所以只能暴力搜索躲开日历」。现在固定了时刻,扫出来的是确定的答案。
  • 动爻爻辞那条按「;」数条数的断言也改了。你说的家人九三那条象传确实带「;」,按分隔符数会数多,而那不是实现错、是判据错。改成断「只引了动的那几爻」:动爻逐条在、不动的爻一条都不在(同一卦里两条爻辞原文相同的跳过,那会让「不在」这句跟着不成立)。

防复发的三条守卫:

  1. 禁止 new Date('…') 绝对时刻。
  2. 禁止裸时钟而不写 real-clock: 与理由(当前只有一处用到,就是验「时间起卦用的就是今天」那条——它本就该跟当天走,钉死时刻反而没得验)。
  3. 每一次 MCP 调用都必须给 now,now: 与简写 now, 都认。

验证: 九个时区跑,每种都是 248 通过 0 失败。除你点名的 LA 与 Honolulu 外,还加了 Pacific/Apia、Pacific/Chatham(UTC+12:45)、Australia/Eucla(UTC+8:45)——后两个不是整点时区,最容易藏问题。加 LC_ALL=C LANG=C 极窄 locale 一遍,同样全过。

针对 MiniMax-AI#13 第二轮评审。

需要修改:

- 同一 id 还是会出现两条,两个来源。其一:server.mjs 先 `store.get` 再 `store.save`,
  那是两个分开排队的任务,并发请求会各自把 `get` 排完(都得到 null)再各自 `save`,
  同一 id 并发 POST 五次得到 `201,201,201,201,409` 与四条同 id 记录,而 `remove()` 按
  id 过滤,删一条连带删三条。「这一 id 已经存过」现在由 `store.save` 在它自己的排队
  任务里判断并落盘,两步合一,同一 id 并发五次是 `201,409,409,409,409`。
  其二:`divination.mjs` 的 `hash.toString(36).slice(0, 6)`。32 位哈希转 36 进制有 6 位
  也有 7 位,`slice` 把 7 位那种砍掉了最低位,而种子末尾那个递增计数改的正是最低位,
  于是「同一秒、同一个卦」照旧撞车(实测 3000 组里撞 1228 次)。新约定按 id 取卦,
  撞了之后后一卦会盖掉缓存里的前一卦:在「问甲事」那张卡上点存入,落盘的却是「问乙事」。
  改 `padStart(7, '0')` 不再截断,3000 组 0 撞。

- 服务重启后点「存入卦历」没有任何可见反馈。409 现在带一句人话,页面上多了一处看得见的
  提示(原先只有 `announce()`,写进的是 `.sr-only` 的 live region,屏幕上什么都不出现,
  按钮又复原成可点)。`unknown_cast` 是终态——那一卦再也存不了——于是按钮不再还给用户。
  `api()` 顺带把状态码与错误码带出来,页面才分得清「存不了了」与「已经存过了」。

- 一条测试随本机时区挂掉。这次没有只修报告的那一条:全文件扫了一遍时间依赖,
  九处绝对时刻(`new Date('…')`)换成统一构造的墙上时间(`AT(...)`),另有七处 MCP 调用
  没注入时刻,一并注上。原来那条扫遍六十四卦取「头一个真出结果的」测试,是当年为躲开
  真时钟写的补救,现在固定了时刻,扫出来的是确定的答案。
  与此同时加了三条守卫断言,让这类问题不能再悄悄回来:禁止绝对时刻、禁止没写明理由的
  裸时钟、每一次 MCP 调用都必须给 `now`。另外动爻爻辞那条断言原先按「;」数条数,而
  象传正文里本来就可能带「;」(家人九三),一按分隔符数就数多——改成断「只引了动的
  那几爻」,动爻逐条在、不动的爻一条都不在。

建议:

- 掷钱点数按 6–9 收。原来照抄通用上界收 1–9,1–5 放行后被 `castByCoins` 的普通
  Error 接住,那条路没有 recovery,Agent 只拿得到「必须是 6 到 9」而拿不到该怎么做。
- MCP 按 schema 白名单收参数。原先把 `args` 整个递进 `castFromParams`,schema 之外的
  键也当真:`sums` 会让本该自掷的摇卦改用 Agent 指定的点数,`method: 'hexagram'` 更是
  页面卦库专用的起法。现在 `coins` 一律自掷,起法只在 MCP 对外认的那组里,报错与
  `instructions` 的枚举取同一份常量。
- 页面页脚的免责说明改成逐字包含 `DISCLAIMER` 常量,断言把四处(工具返回、
  instructions、SKILL、页脚)钉在一起。README 首部与 FAQ 里「不读包外任何文件」的说法
  也统一了:包目录以外只读一个文件,就是 Host 建的私有 dataDir 里的卦历。

验证:248 项单测通过(本轮新增 6 项);仓库 `npm run check` 55/55;`npm run validate`
对本包报 OK;跨平台路径审计 0 问题;九个时区(UTC-8 到 UTC+14,含 UTC+12:45 的 Chatham
与 UTC+8:45 的 Eucla)加 C locale,每种组合都是 248 通过 0 失败。
@weekbin

weekbin commented Oct 10, 2026

Copy link
Copy Markdown
Author

三条「需要修改」都改了,行内评论已分别回复。三条建议也一并做了:

  • 掷钱点数 —— 按 6–9 收,越界的带 recovery,告诉 Agent 该怎么改(三个背面 9、三个正面 6)。
  • MCP 参数边界 —— 按 schema 白名单收参数。sums 进不来,所以摇卦一律自掷;method: 'hexagram' 不认,所以页面卦库那一路不会从对话里走。报错与 instructions 的枚举取同一份常量,不会出现「提示说可以、实际做不到」。
  • README 口径 —— 首部与 FAQ 里「不读包外任何文件」统一改成:包目录以外只读一个文件,就是 Host 建的私有 dataDir 里的卦历。

另外补一件评审没提但属于同一类的事:页面页脚的免责声明原先措辞与 DISCLAIMER 常量不完全一致(虽然意思对),现在改成逐字包含,断言把四处钉在一起——工具返回、instructions、SKILL、页脚。查卦与历法两个工具的返回也一并断言带上,因为它们同样会被人转述出去。

关于时间依赖,这次是连根拔的。 你指出的是其中一处,我扫了整个测试文件,结果同类问题有 16 处:9 处绝对时刻、7 处 MCP 调用没注入时刻。其中扫遍六十四卦取「头一个真出结果的」那条,注释里写的正是「MCP 走的是真实时钟,所以只能暴力搜索躲开日历」——那本身就是当年绕开同一个坑的产物,现在有了注入时钟就一并根治了。

为防止第三次,加了三条守卫断言:禁止绝对时刻、禁止没写明理由的裸时钟、每一次 MCP 调用都必须给 now。逐条人肉查总会漏下一条,所以钉进测试里。

验证: 248 项单测通过(本轮新增 6 项,含并发、3000 组 id 撞车、409 可见反馈、MCP 参数白名单);仓库 npm run check 55/55;npm run validate 对本包报 OK;跨平台路径审计 0 问题;九个时区(UTC-8 到 UTC+14,含 UTC+12:45 的 Chatham 与 UTC+8:45 的 Eucla)加 C locale,每种组合 248 通过 0 失败。

这一轮作为独立 commit 追加在后面,没有动上一轮的内容。

@MyPrototypeWhat MyPrototypeWhat left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

谢谢这一轮的修改,我都在本地复跑过:同一 id 并发存 5 次现在是 201,409,409,409,409,卦历里只有一条;id 不截断以后,3000 组同秒同卦 0 撞;服务重启后存卦有可见提示,按钮也锁住了;测试在 LA、Honolulu、Chatham、Eucla 等 12 个时区,加上 13 个伪造日期 × 3 个时刻 × 6 个时区的组合下全部 248/248;MCP 的参数白名单、掷钱 6–9 校验也都确认了。合并到最新 main 没有冲突,npm run check 通过。这边没有需要修改的了,批准。

建议(可选)

  • README.zh-CN.md:284 的 FAQ 还写着「不读包目录外的任何文件」,英文那句已经改了,中文这句顺手统一一下。
  • 存卦遇到 already_saved(比如第一次已经写入、但响应丢了,用户重试)时,页面现在显示红字并把按钮复原成可点,其实可以直接按成功处理,显示「已存入卦历」。index.html:2076 注释里说的「还能改批注重试」也不太对,目前没有改批注的路由。
  • unknown_cast 的提示固定写「服务已重启」,但缓存挤出(超过 500 卦)或 id 没起过时也会走这条,措辞可以放宽一点,比如「这一卦已不在本次会话里(服务可能重启过)」。
  • cast-params.mjs 里 coinSumsFrom 上面叠着两段 JSDoc,前一段关于「页面没传就自己掷六次」的描述已经过时,可以删掉。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants