Skip to content

Commit 863590f

Browse files
committed
fix(chinese-divination): 同一 id 不再出现两份,时间依赖连根拔掉
针对 #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 失败。
1 parent b357734 commit 863590f

9 files changed

Lines changed: 504 additions & 73 deletions

File tree

‎plugins/weekbin/chinese-divination/README.md‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ English | [简体中文](README.zh-CN.md)
55
A Chinese classical divination Mini App. It casts hexagrams with the Plum Blossom method
66
(梅花易数), reads the result through the classical 体用生克 rules, and ships a searchable
77
library of all sixty-four hexagrams plus a ganzhi almanac. Everything runs locally; the app
8-
makes no network requests and reads nothing outside its own package.
8+
makes no network requests. The only file it reads outside its own directory is its own
9+
reading log, inside the private `dataDir` the Host creates for it.
910

1011
The interface is in Chinese. To get going, read the **Usage guide** below; the derivation rules
1112
and capability disclosures follow it.
@@ -320,7 +321,7 @@ Chinese is the AI's job. See "A note on use" below.
320321

321322
**Where do my questions go?**
322323
Nowhere. Saved castings only touch `context.dataDir` on your own machine. The app does not go
323-
online and reads nothing outside its package.
324+
online, and the one file it reads outside its own directory is that same reading log.
324325

325326
## What it does
326327

@@ -347,9 +348,16 @@ to "打开灵签易占".
347348
really closes the listener and is idempotent, that no log line carries the `dataDir` path or the
348349
operating-system user name, and that the `dataDir` afterwards holds nothing but the reading log.
349350
macOS, Node 22.23.2: all 83 checks pass.
350-
- The suite is run under four timezones spanning UTC-8 to UTC+14 (`America/New_York`, `UTC`,
351-
`Asia/Shanghai`, `Pacific/Kiritimati`) and under `LC_ALL=C LANG=C`. 228 pass, 0 fail in every
352-
combination, so nothing here depends on the machine's clock zone or locale.
351+
- The suite is run under nine timezones spanning UTC-8 to UTC+14 (`America/New_York`, `UTC`,
352+
`Asia/Shanghai`, `Pacific/Kiritimati`, `Pacific/Apia`, `America/Los_Angeles`,
353+
`Pacific/Honolulu`, `Pacific/Chatham`, `Australia/Eucla` — the last two sit on UTC+12:45 and
354+
UTC+8:45) and under `LC_ALL=C LANG=C`. 248 pass, 0 fail in every combination.
355+
- Two guards keep it that way, because the suite has twice broken on a date: no test may build a
356+
moment from an absolute instant (`new Date('…')`), since `castByTime` and `castDaily` take the
357+
hour and the day pillar from the machine's own zone, and no test may reach for the bare clock
358+
without saying `real-clock:` and why. Every MCP call has to pass a `now` explicitly — the two
359+
cast results follow the day's ganzhi, which is exactly how an assertion about 出伏 or 日破 once
360+
started passing or failing depending on the day it was run.
353361
- A path audit over the package reports no problems: every path segment is ASCII and matches the
354362
portable-path rule, no segment collides with a Windows reserved device name, every text file is
355363
UTF-8 with no BOM and no CRLF, no two files differ only by case, every relative import resolves to

‎plugins/weekbin/chinese-divination/README.zh-CN.md‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
[English](README.md) | 简体中文
44

55
中国传统文化占卜 Mini App。以梅花易数起卦,按体用生克断吉凶,内置六十四卦全文与干支历法。
6-
全部计算在本机完成:不联网,不读取包目录以外的任何文件。
6+
全部计算在本机完成:不联网;包目录以外只读一个文件——Host 为它建的那个私有 `dataDir` 里的卦历。
77

88
界面为中文。想直接上手,看下面的**使用指南**;想看推演口径与能力披露,往下翻。
99

@@ -303,9 +303,14 @@ README 的安装说明)。重启 MiniMax Code 后对 Agent 说「打开灵签
303303
非回环名——这一项走裸 HTTP 请求驱动,因为 `fetch` 会把调用方给的 `Host` 静默丢掉;还断言
304304
`dispose` 真的关掉监听且可重复调用、日志里没有 `dataDir` 路径也没有操作系统用户名、跑完之后
305305
`dataDir` 里只剩卦历文件。macOS,Node 22.23.2:83 项全过。
306-
- 同一套测试在四个时区下各跑一遍(`America/New_York`、`UTC`、`Asia/Shanghai`、`Pacific/Kiritimati`,
307-
跨 UTC-8 到 UTC+14),另加 `LC_ALL=C LANG=C` 极窄 locale 一遍。每种组合都是 228 通过、0 失败,
308-
即此处不依赖本机时区与 locale。
306+
- 同一套测试在九个时区下各跑一遍(`America/New_York`、`UTC`、`Asia/Shanghai`、`Pacific/Kiritimati`、
307+
`Pacific/Apia`、`America/Los_Angeles`、`Pacific/Honolulu`、`Pacific/Chatham`、`Australia/Eucla`,
308+
跨 UTC-8 到 UTC+14;后两个分别在 UTC+12:45 与 UTC+8:45),另加 `LC_ALL=C LANG=C` 极窄 locale 一遍。
309+
每种组合都是 248 通过、0 失败。
310+
- 两条守卫让它一直是这样,因为这套测试已经在日期上栽过两次:测试不许用绝对时刻
311+
(`new Date('…')`)构造时刻——`castByTime` 与 `castDaily` 取的是本机时区的时辰与日柱;
312+
也不许碰真时钟而不写明 `real-clock:` 与理由。每一次 MCP 调用都必须显式给 `now`——
313+
起卦结果跟着日辰走,断出伏或日破的断言就是这么一度跟着跑测试的当天翻面的。
309314
- 一份路径审计扫全包,0 问题:每个路径段都是 ASCII 且符合可移植路径规则,没有段撞 Windows 保留
310315
设备名,全部文本文件 UTF-8、无 BOM、无 CRLF,没有两个文件只差大小写,每条相对 import 都能在磁盘
311316
上找到,最坏形态的 `dataDir` 全路径 112 字符,离 `MAX_PATH` 还远。

‎plugins/weekbin/chinese-divination/miniapp/client/index.html‎

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -860,6 +860,16 @@
860860
align-items: center;
861861
}
862862

863+
/* 存卦那行的可见提示。存不下时这里要说得出来,不能只往 live region 里写。 */
864+
.save-status {
865+
font-size: 13px;
866+
color: var(--text-muted);
867+
}
868+
869+
.save-status.err {
870+
color: var(--seal);
871+
}
872+
863873
input[type='text'],
864874
input[type='number'],
865875
textarea {
@@ -2020,11 +2030,10 @@ <h2>卦历</h2>
20202030
</main>
20212031

20222032
<footer class="disclaimer">
2023-
<strong>内容由 AI 生成,仅供娱乐,无实际预测功能。</strong>
20242033
<p>
2025-
本应用实现的是中国传统占筮方法,依据梅花易数的体用生克、旺衰与取象规则推演,
2026-
所有文字由 AI 生成。卦象不构成任何建议、预测或决策依据,不应作为医疗、法律、财务
2027-
等重要决定的参考。娱乐之外,请以自身判断与专业意见为准。
2034+
本应用实现的是中国传统占筮方法,依据梅花易数的体用生克、旺衰与取象规则推演。
2035+
<strong>内容由 AI 生成,仅供娱乐,无实际预测功能。卦象不构成任何建议、预测或决策依据,不应作为医疗、法律、财务等重要决定的参考。</strong>
2036+
娱乐之外,请以自身判断与专业意见为准。
20282037
</p>
20292038
</footer>
20302039
</div>
@@ -2062,7 +2071,12 @@ <h2>卦历</h2>
20622071
});
20632072
if (!response.ok) {
20642073
const detail = await response.json().catch(() => ({}));
2065-
throw new Error(detail.message || `请求失败(${response.status})`);
2074+
const error = new Error(detail.message || `请求失败(${response.status})`);
2075+
// 带上状态码与错误码,调用方才分得清「这一卦存不了了」(终态)与
2076+
// 「这条已经存过了」(还能改批注重试)——两者对按钮的处理完全不同。
2077+
error.status = response.status;
2078+
error.code = detail.error || '';
2079+
throw error;
20662080
}
20672081
return response.json();
20682082
}
@@ -3294,16 +3308,21 @@ <h2>卦历</h2>
32943308
const saveBtn = document.createElement('button');
32953309
saveBtn.className = 'primary';
32963310
saveBtn.textContent = '存入卦历';
3297-
saveBtn.addEventListener('click', () => saveReading(reading, saveBtn, noteInput));
3298-
save.append(noteInput, saveBtn);
3311+
// 可见的存卦状态。原先失败只往 live region 里写,屏幕上什么都不出现。
3312+
const saveStatus = document.createElement('span');
3313+
saveStatus.className = 'save-status';
3314+
saveStatus.setAttribute('aria-live', 'polite');
3315+
saveBtn.addEventListener('click', () => saveReading(reading, saveBtn, noteInput, saveStatus));
3316+
save.append(noteInput, saveBtn, saveStatus);
32993317
card.append(save);
33003318

33013319
slot.append(card);
33023320
mountClashRibbons(card);
33033321
}
33043322

3305-
async function saveReading(reading, button, noteInput) {
3323+
async function saveReading(reading, button, noteInput, status) {
33063324
button.disabled = true;
3325+
setSaveStatus(status, '存入中…');
33073326
try {
33083327
// 只送 id 与批注,不再把整份 reading 回传给服务端——服务端认的是它自己
33093328
// 刚算出来过的卦。原先整份回传、原样落盘,那是整个包里唯一一处没设防的地方。
@@ -3312,14 +3331,32 @@ <h2>卦历</h2>
33123331
body: JSON.stringify({ id: reading.id, note: noteInput.value }),
33133332
});
33143333
button.textContent = '已存入卦历';
3334+
setSaveStatus(status, '');
33153335
announce('已存入卦历');
33163336
} catch (error) {
3337+
// 失败要给看得见的反馈。原先只 announce(),那写进的是 .sr-only 的 live region,
3338+
// 屏幕上什么也没有,按钮又复原成可点——用户看到的就是「点了没反应」,
3339+
// 再点还是一样。服务重启后这一卦再也存不了,所以那一支连按钮都不还给用户。
3340+
if (error.code === 'unknown_cast') {
3341+
button.textContent = '这一卦存不了了';
3342+
setSaveStatus(status, error.message, true);
3343+
announce(error.message);
3344+
return;
3345+
}
33173346
button.disabled = false;
33183347
button.textContent = '存入卦历';
3348+
setSaveStatus(status, error.message, true);
33193349
announce(error.message);
33203350
}
33213351
}
33223352

3353+
/** 存卦那行的可见提示。aria-live 让读屏软件也念得到。 */
3354+
function setSaveStatus(status, text, isError = false) {
3355+
status.textContent = text;
3356+
status.classList.toggle('err', Boolean(isError));
3357+
status.setAttribute('role', isError ? 'alert' : 'status');
3358+
}
3359+
33233360
/* ---------- 卦库 ---------- */
33243361

33253362
async function loadLibrary() {

‎plugins/weekbin/chinese-divination/miniapp/node/cast-params.mjs‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ export const CAST_METHODS = Object.freeze(['time', 'daily', 'numbers', 'coins',
3333

3434
/** 铜钱摇卦的次数,由古法定的,不给改。 */
3535
const COIN_TOSSES = 6;
36+
/** 三枚铜钱一次投掷的点数范围:三个背面 9,三个正面 6。 */
37+
const COIN_SUM_MIN = 6;
38+
const COIN_SUM_MAX = 9;
3639

3740
/**
3841
* 包版本。`.minimax-plugin/plugin.json` 不在运行时载荷里(载荷只有 `artifacts` 下的
@@ -96,14 +99,33 @@ export function clampText(value, max) {
9699
* @param {unknown} sums
97100
* @returns {number[]}
98101
*/
102+
/**
103+
* 摇卦的六次结果,逐次校验到 6–9。
104+
*
105+
* 收 1–9 是照抄了 `toBoundedInteger` 的通用上界,提示却写着 6–9:传进 1–5 时它放行,
106+
* 随后被 `castByCoins` 的普通 Error 接住——那条路没有 recovery,Agent 拿到的只是
107+
* 「必须是 6 到 9 之间的整数」而没有该怎么做。这里就按真实的点数范围收。
108+
*
109+
* @param {unknown} sums
110+
* @returns {number[]}
111+
*/
99112
function coinSumsFrom(sums) {
100113
if (!Array.isArray(sums) || sums.length !== COIN_TOSSES) {
101114
throw new CastParamError(
102115
`摇卦要 ${COIN_TOSSES} 次掷钱结果。`,
103116
`传 ${COIN_TOSSES} 个 6 到 9 之间的整数。`,
104117
);
105118
}
106-
return sums.map((sum, index) => toBoundedInteger(sum, 9, `第 ${index + 1} 次掷钱结果`));
119+
return sums.map((sum, index) => {
120+
const parsed = toBoundedInteger(sum, COIN_SUM_MAX, `第 ${index + 1} 次掷钱结果`);
121+
if (parsed < COIN_SUM_MIN) {
122+
throw new CastParamError(
123+
`第 ${index + 1} 次掷钱点数是 ${parsed}。`,
124+
`三枚铜钱点数只落在 ${COIN_SUM_MIN} 到 ${COIN_SUM_MAX} 之间:背面三个为 ${COIN_SUM_MAX},正面三个为 ${COIN_SUM_MIN}。`,
125+
);
126+
}
127+
return parsed;
128+
});
107129
}
108130

109131
/**

‎plugins/weekbin/chinese-divination/miniapp/node/divination.mjs‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1872,5 +1872,11 @@ function buildId(hexagram, positions, now) {
18721872
for (let index = 0; index < seed.length; index += 1) {
18731873
hash = (hash * 31 + seed.charCodeAt(index)) >>> 0;
18741874
}
1875-
return `${stamp}-${hash.toString(36).slice(0, 6)}`;
1875+
// padStart 而不是 slice:32 位的 hash 转 36 进制有 6 位也有 7 位(36^6 < 2^32 < 36^7),
1876+
// `slice(0, 6)` 会把 7 位的那种砍掉最低位——而种子末尾那个递增计数改的正是最低位,
1877+
// 于是「同一秒、同一个卦」拿回同一个 id,实测 3000 组里撞 1228 组。
1878+
// 新的存卦约定按 id 取卦,撞了之后后一卦会把前一卦盖掉:在甲那张卡上点存入,
1879+
// 落盘的却是乙那张的问题。补零而不截断是单射的——36 进制里 7 位数的首位不会是 0,
1880+
// 所以补出来的 '0xxxxxx' 与真正的 7 位数不可能相同。长度仍落在 [A-Za-z0-9-]{1,80} 内。
1881+
return `${stamp}-${hash.toString(36).padStart(7, '0')}`;
18761882
}

‎plugins/weekbin/chinese-divination/miniapp/node/mcp/divination-http.mjs‎

Lines changed: 53 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,20 @@ import {
2626
tossCoinSums,
2727
} from '../cast-params.mjs';
2828

29+
/**
30+
* MCP 对外认的起法。`hexagram` 是页面卦库那一路专用的,不进工具 schema。
31+
* 取自 CAST_METHODS 而不是另写一份数组,少一处会漂移的地方。
32+
*/
33+
export const MCP_CAST_METHODS = Object.freeze(
34+
CAST_METHODS.filter((item) => item !== 'hexagram'),
35+
);
36+
37+
/**
38+
* 工具描述里 `method` 的那组枚举,与实际认的起法同一份。schema 与实现各写一份时,
39+
* 会出现「提示说可以、实际做不到」或反过来。
40+
*/
41+
const MCP_CAST_METHOD_ENUM = Object.freeze([...MCP_CAST_METHODS]);
42+
2943
const SERVER_INFO = Object.freeze({ name: 'chinese-divination', version: PACKAGE_VERSION });
3044

3145
/**
@@ -86,7 +100,7 @@ const TOOLS = Object.freeze([
86100
...QUESTION_PROPERTY,
87101
method: {
88102
type: 'string',
89-
enum: CAST_METHODS.filter((item) => item !== 'hexagram'),
103+
enum: MCP_CAST_METHOD_ENUM,
90104
description:
91105
'起法。time=以当下时辰成卦(两小时一换);daily=按今日日期成卦(一天一换);numbers=由你给两个正整数,默念所问之后自行取数,一上卦一下卦;coins=由本工具掷六次铜钱,每次皆不同。用户说「掷铜钱」或要随机时用 coins,说「今天」用 daily。',
92106
default: 'time',
@@ -290,6 +304,35 @@ function plainBlock(reading) {
290304
].filter(Boolean).join('\n');
291305
}
292306

307+
/**
308+
* `divination_cast` 在 schema 里声明的键。白名单而不是黑名单:Agent 多传了什么,
309+
* 这里就不认什么。
310+
*/
311+
const CAST_ARG_KEYS = Object.freeze(['question', 'topic', 'method', 'upper', 'lower']);
312+
313+
/**
314+
* 按白名单收一遍起卦参数,并钉住 method 必须落在 MCP 对外认的那组起法里。
315+
* @param {Record<string, unknown>} args
316+
* @returns {{ method?: string, upper?: unknown, lower?: unknown }}
317+
*/
318+
function pickCastArgs(args) {
319+
/** @type {Record<string, unknown>} */
320+
const picked = {};
321+
for (const key of CAST_ARG_KEYS) {
322+
if (key !== 'question' && key !== 'topic' && args[key] !== undefined) picked[key] = args[key];
323+
}
324+
const method = typeof args.method === 'string' && args.method !== '' ? args.method : 'time';
325+
if (!MCP_CAST_METHODS.includes(method)) {
326+
throw new ToolError(
327+
'INVALID_ARGUMENTS',
328+
`未知的起法:${method}`,
329+
`method 只能是 ${MCP_CAST_METHODS.join('、')}。`,
330+
);
331+
}
332+
picked.method = method;
333+
return picked;
334+
}
335+
293336
/**
294337
* @param {string} name
295338
* @param {Record<string, unknown>} args
@@ -303,16 +346,15 @@ function callTool(name, args, now) {
303346
/** @type {ReturnType<typeof buildReading>} */
304347
let reading;
305348
try {
306-
// 取数与校验只有这一份,页面那条路调的是同一个函数。之前两边各写一份,
307-
// 于是上限值与提示语各自漂移,而「MCP 那份忘了收六次掷钱长度」那类漏项
308-
// 只要漏一次就是一次线上故障。
309-
//
310-
// 摇卦由谁掷是调用方各自的决定,不做成共用函数的兜底:MCP 背后没有人替用户
311-
// 掷,所以它自己掷六次;页面上用户看得见点数并能自己重来,少一次就该报错。
312-
const params = args.method === 'coins' && args.sums === undefined
313-
? { ...args, sums: tossCoinSums() }
314-
: args;
315-
reading = buildReading(castFromParams(params, now), { question, now, topic });
349+
// 先按 schema 收一遍参数,只留下 `divination_cast` 真正声明的那几个。
350+
// 直接把 args 整个递进 castFromParams 的话,Agent 传进来的 schema 之外的键
351+
// 也会被当真:`sums` 会让本该自己掷的摇卦改用 Agent 指定的点数(页面那条路
352+
// 要这个,Agent 背后没有人替用户掷,不要),`method: 'hexagram'` 更是页面
353+
// 卦库专用的起法,不该从对话里走。
354+
const params = pickCastArgs(args);
355+
// 摇卦一律由本工具自己掷:Agent 只说「掷铜钱」,点数是随机的,不该由它指定。
356+
const withCoins = params.method === 'coins' ? { ...params, sums: tossCoinSums() } : params;
357+
reading = buildReading(castFromParams(withCoins, now), { question, now, topic });
316358
} catch (error) {
317359
if (error instanceof CastParamError) {
318360
throw new ToolError('INVALID_ARGUMENTS', error.message, error.recovery);

‎plugins/weekbin/chinese-divination/miniapp/node/server.mjs‎

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -317,16 +317,27 @@ async function handle(request, response, clientEntry, store, cache) {
317317
}
318318
const reading = cache.get(id);
319319
if (!reading) {
320-
sendJson(response, 409, { error: 'unknown_cast' });
321-
return;
322-
}
323-
// 同一 id 存两次会得到两条同 id 的记录,而 remove() 按 id 过滤,删一条连带删另一条。
324-
if (await store.get(id)) {
325-
sendJson(response, 409, { error: 'already_saved' });
320+
// 带上人话:服务重启后这一卦已经不在缓存里,而页面只把错误写进隐藏的
321+
// live region,用户看到的是「点了没反应」。这句话是页面上唯一能显示出来的线索。
322+
sendJson(response, 409, {
323+
error: 'unknown_cast',
324+
message: '服务已重启,这一卦无法再存,请重新起一卦。',
325+
});
326326
return;
327327
}
328+
// 「这一 id 已经存过」由 store.save 在它自己的排队任务里判断并落盘,两步合一。
329+
// 拆成先 store.get 再 store.save 的话,并发的几次请求会各自把 get 排完(都得到
330+
// null)再各自 save,同一个 id 就落盘好几条——而 remove() 按 id 过滤,
331+
// 删一条连带删一串。save 撞车时返回 null,这里据此回 409。
328332
const note = clampText(body.note, MAX_NOTE);
329333
const saved = await store.save(reading, note);
334+
if (saved === null) {
335+
sendJson(response, 409, {
336+
error: 'already_saved',
337+
message: '这一卦已经在卦历里了。',
338+
});
339+
return;
340+
}
330341
sendJson(response, 201, { entry: saved });
331342
return;
332343
}

‎plugins/weekbin/chinese-divination/miniapp/node/store.mjs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,12 +60,20 @@ export class ReadingStore {
6060
}
6161

6262
/**
63+
* 存一条卦。id 撞上已有的那条时返回 `null`——检查与写入在同一个排队任务里,
64+
* 不能拆成先 `get` 再 `save` 两个任务:那样并发的几次请求会各自把 `get` 排完
65+
* (都得到 null),再各自 `save`,于是同一个 id 落盘四条,而 `remove()` 按 id
66+
* 过滤,删一条连带删三条。实测同一 id 并发 POST 五次会得到
67+
* `201,201,201,201,409` 与四条同 id 记录;合进一个任务后是 `201,409,409,409,409`。
68+
*
6369
* @param {import('./divination.mjs').Reading} reading
6470
* @param {string} note
71+
* @returns {Promise<object|null>} null 表示这条 id 已经存过了
6572
*/
6673
async save(reading, note) {
6774
return this.enqueue(async () => {
6875
const entries = await this.readAll();
76+
if (entries.some((entry) => entry.id === reading.id)) return null;
6977
const entry = {
7078
...reading,
7179
note: clampText(note, MAX_NOTE),

0 commit comments

Comments
 (0)