diff --git a/README.md b/README.md index 7ed555e..5297281 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,7 @@ The MiniMax Code Agent communicates through the host MCP client. Business reques | [Anatomy Gallery](plugins/zch0227/anatomy-gallery/) | A gallery for interactive isometric figures made with the anatomy skill: hang them in rooms you organize, view light and dark editions full screen, keep every version, and have an Agent create or edit figures from the gallery with a per-run process log. The anatomy skill is bundled. Requires MiniMax Code 3.1.2 or later | [zch0227](https://github.com/zch0227) | | [仓库透视 / Repo X-Ray](plugins/yujiachen-y/repo-xray/) | Understand a codebase from its Git history: a code map you can drill into, hotspots and bug magnets, high-risk files, owners and knowledge loss, commit rhythm and firefighting, and the tech stack. Works with GitHub, GitLab, and local repositories; analysis runs on your machine | [yujiachen-y](https://github.com/yujiachen-y) | | [Disk Insight](plugins/zch0227/disk-insight/) | See where disk space went on macOS and Windows with a sunburst space map, find junk files sorted into three safety tiers, large and duplicate files, and how much code repositories and Git worktrees take; ask an Agent about any item, and permanently delete what you select after a confirmation that lists every item. Requires MiniMax Code 3.1.2 or later | [zch0227](https://github.com/zch0227) | +| [蹬王之王 · Expiry Guardian](plugins/hylouis233/expiry-guardian/) | Track credit and reset-card expiry from a local ledger you maintain, and get a "spend the card or let the credits burn first" recommendation with the reasoning spelled out | [Hylouis](https://github.com/Hylouis233) |
Preview: Token Usage Board diff --git a/README.zh-CN.md b/README.zh-CN.md index 3f99ad4..f953fea 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -80,6 +80,7 @@ MiniMax Code Agent 通过宿主 MCP 客户端与 MiniApp 协作。业务请求 | [Anatomy 画廊](plugins/zch0227/anatomy-gallery/README.zh-CN.md) | 集中展示 anatomy 技能做出的交互式等距图解:按自建展区挂墙,全屏查看明暗两版,保留全部历史版本;在画廊里让 Agent 新建或修改作品,每次运行都有过程记录。自带 anatomy 技能。需要 MiniMax Code 3.1.2 或更高版本 | [zch0227](https://github.com/zch0227) | | [仓库透视](plugins/yujiachen-y/repo-xray/README.zh-CN.md) | 从 Git 历史看懂一个代码仓库:可逐层下钻的代码地图、热点与 Bug 磁铁、高风险文件、负责人与知识流失、提交节奏与救火记录、技术栈;支持 GitHub、GitLab 和本机仓库,全部在本机分析 | [yujiachen-y](https://github.com/yujiachen-y) | | [磁盘透视](plugins/zch0227/disk-insight/README.zh-CN.md) | 查看 macOS 和 Windows 的磁盘空间去向:旭日图空间地图,按三档风险分类的垃圾文件,大文件和重复文件,代码仓库和 Git worktree 的占用;任何条目都可以问 Agent,选中的条目在确认框里逐一列出,确认后直接永久删除。需要 MiniMax Code 3.1.2 或更高版本 | [zch0227](https://github.com/zch0227) | +| [蹬王之王](plugins/hylouis233/expiry-guardian/README.md) | 看你自己维护的本地账本,算出「该用卡还是该留积分」,并把理由说清楚 | [Hylouis](https://github.com/Hylouis233) |
预览:Token 用量看板 diff --git a/plugins/hylouis233/expiry-guardian/.gitignore b/plugins/hylouis233/expiry-guardian/.gitignore new file mode 100644 index 0000000..5bb0415 --- /dev/null +++ b/plugins/hylouis233/expiry-guardian/.gitignore @@ -0,0 +1,3 @@ +# Python 字节码缓存(跑测试时会重新生成,不该进版本库) +__pycache__/ +*.pyc diff --git a/plugins/hylouis233/expiry-guardian/.minimax-plugin/plugin.json b/plugins/hylouis233/expiry-guardian/.minimax-plugin/plugin.json new file mode 100644 index 0000000..fbefe7c --- /dev/null +++ b/plugins/hylouis233/expiry-guardian/.minimax-plugin/plugin.json @@ -0,0 +1,21 @@ +{ + "schemaVersion": 1, + "name": "expiry-guardian", + "displayName": "蹬王之王", + "version": "2.0.0", + "description": "基于你自己维护的本地账本追踪积分与重置卡的到期时间,并给出「先过期先用」的建议:用卡还是留积分,并把理由说清楚。只读,不做任何写操作 —— 不扣卡、不暂停目标,不读 MiniMax Code 的登录凭据或内部数据库,也不读其它插件的数据。", + "author": "User", + "icon": "icon.png", + "category": "Productivity", + "exampleQueries": [ + "打开蹬王之王,看我的积分和重置卡什么时候过期。", + "我的额度快用完了,该用重置卡还是留着?", + "看看我有多少积分要过期了。" + ], + "apps": [], + "mcpServers": [ + "servers.mcp.json" + ], + "skills": [], + "darkIcon": "icon-dark.png" +} diff --git a/plugins/hylouis233/expiry-guardian/LICENSE b/plugins/hylouis233/expiry-guardian/LICENSE new file mode 100644 index 0000000..00528ca --- /dev/null +++ b/plugins/hylouis233/expiry-guardian/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Hylouis + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/hylouis233/expiry-guardian/README.md b/plugins/hylouis233/expiry-guardian/README.md new file mode 100644 index 0000000..a6b4a34 --- /dev/null +++ b/plugins/hylouis233/expiry-guardian/README.md @@ -0,0 +1,210 @@ +# 蹬王之王 · Local Expiry Ledger & Card Decision + +Track when your credits and reset cards expire, and decide — from a **ledger you keep +yourself** — whether to spend a card or let the credits burn first. + +**v2.0 (trimmed) — 2026-10-20.** + +## What it does + +| | | +| --- | --- | +| **Local ledger** | The only data source: `~/.config/mcode-reset-card/ledger.json`, which *you* maintain. Edit it in the panel or in any editor. | +| **"Expiring first" decision** | When the 5-hour quota crosses the trigger line, it compares expiry dates and says whether to spend a card or keep the credits — with the reasoning spelled out. | +| **Honest failures** | If the ledger can't be read, it says so plainly instead of quietly concluding "you have nothing". | +| **Backend daemon** | A Rust daemon under `launchd` re-runs the same decision on a timer and writes the conclusion to a status file. | + +## What it deliberately does *not* do + +Community plugins must not touch the host's private data. So this version: + +| Boundary | v1.x (removed) | v2.0 | +| --- | --- | --- | +| **No reading MiniMax Code's login credentials** | Read the lease socket / `.cap` for an access token and called the account APIs | **Doesn't.** The ledger is the only data source | +| **No reading/writing the host's internal database** | `UPDATE`/`DELETE`/`INSERT` on goals and the execution queue, and read turn records | **Doesn't touch it.** Only reads its own directory | +| **No reading other plugins' data directories** | Read `minimax-bot`'s `state.json` for bot and session ids, then messaged those sessions | **Doesn't.** The Bot watchdog is gone | + +⇒ **v2.0 never spends a card, never pauses a goal, never wakes a Bot.** It computes the +conclusion and explains it; **you** do the action. + +⇒ The upside: v2.0 is code that can live in a community repository. It touches no +private host data and its behaviour is fully predictable. + +## The ledger + +```json +{ + "schemaVersion": 1, + "updatedAtMs": 1792480000000, + "quota": { + "fiveHour": { "usedPercent": 3, "totalPercent": 100, "unlimited": false, "resetTimeMs": 1792497600000 }, + "weekly": { "usedPercent": 0, "totalPercent": 100, "unlimited": true, "resetTimeMs": 1792944000000 } + }, + "cards": [ + { "cardId": "card_1", "label": "Card A", "sourceType": "code", + "outBizNo": "biz_1", "expireTimeMs": 1795000000000 } + ], + "credits": [ + { "label": "Granted", "source": "code", "granted": 400, "used": 0, + "balance": 400, "expireTimeMs": 1795017600000 } + ] +} +``` + +A card counts as usable only if it has **both** `outBizNo` and `sourceType`. +A credit counts only if `balance > 0` and it has not expired. + +> ⚠️ **A missing `credits` field does not mean "no credits".** +> A missing field is treated as *incomplete data* ⇒ the conclusion stays "hold off". +> It will **never** recommend burning a real card because it couldn't read the data. + +The panel has a ledger editor at the bottom — edit the JSON there and save, or edit +the file in any editor and hit refresh. + +## The decision + +| Situation | Conclusion | +| --- | --- | +| Quota below the trigger line | Hold — nothing to do | +| At the line, no usable credits, card available | **Spend a card** | +| At the line, no usable credits, no card | Hold | +| At the line, credits available, no card | **Keep the credits** | +| At the line, both, card expires first | **Spend a card** | +| At the line, both, credits expire first | **Keep the credits** | +| At the line, but the quota refreshes within N minutes | Hold — spending a card would be pure waste | +| Data incomplete | Hold — better no advice than wrong advice | + +## Three-way consistency + +The decision logic has **three isomorphic implementations**: + +| Implementation | Where | Role | +| --- | --- | --- | +| Rust | `daemon/rust/src/decision.rs` | The backend daemon | +| Mini App | `miniapp/node/server.mjs` (`decide()`) | The dashboard | +| Python | `daemon/reset_card_autouse.py` (`decide_background()`) | The **reference** (pure, no network) | + +`daemon/verify-parity.mjs` feeds all three the *same* cases and compares the +action **and the reason text, character for character**: + +``` +$ node daemon/verify-parity.mjs +19/19 三方完全一致(动作 + 理由文本逐字) +``` + +The reason text has to match character for character — change one side and you must +change all three, or the checker goes red. That check is the most valuable guard in +this project: it stops the three implementations from slowly drifting apart until +they contradict each other. + +## Backend daemon + +The Rust daemon runs under `launchd` and is **read-only**: it reads the ledger, runs +the decision, and writes the conclusion to the log and to +`~/.config/mcode-reset-card/daemon-status.json`. It performs **no write operations**. + +If the ledger can't be read it says so explicitly ("no conclusion this round") and +escalates the warning as consecutive failures pile up — it never pretends the answer +is "you have no cards and no credits". + +```bash +cd daemon/rust && cargo test --release # 25/25 +./reset-card-daemon once # one round, prints the conclusion +./reset-card-daemon status # print the current conclusion only +``` + +## Local MCP endpoint + +The plugin declares one MCP endpoint (`server=expiry-guardian`, +`path=/mcp/expiry-guardian`) so other plugins can ask it directly: + +| Tool | What it does | +| --- | --- | +| `watchdog_status` | Quota, cards, credits and the current conclusion from the ledger | +| `watchdog_decide` | Decide right now — **decides only, never spends a card** | + +⚠️ **Spending a card is deliberately not exposed**: it is a real write operation, and +letting another plugin spend your card on a whim is too dangerous. + +## Configuration + +`~/.config/mcode-reset-card/config.json` (read by Rust, the Mini App and Python): + +```json +{ + "schemaVersion": 1, + "cardTriggerPercent": 99, + "cardSkipBeforeResetMinutes": 60, + "quotaLevels": { "tight": 80, "low": 95 } +} +``` + +Config fields are **rebuilt from a whitelist** (`normalizeConfig`): a missing field is +a silently dead channel, so the config shape has to be changed in **three** places +(Rust config, the Mini App's `normalizeConfig`, and the real `config.json`). + +## Tests + +| Suite | Result | +| --- | --- | +| Rust (`daemon/rust`) | **25/25** | +| Python decision unit tests | **20/20** | +| Mini App (`tests/`) | **102/102** | +| Three-way parity | **19/19** | +| Upstream `npm run validate` | **OK** for this plugin (0 errors, 0 warnings); `repository: OK` | +| Upstream `npm test` | 69/69 | + +One Mini App test is a **static guard** (`tests/server-lint.test.mjs`): it catches +identifiers that are used but never declared. It exists because of a real incident — +a typo in the dashboard's data layer made the whole snapshot throw, so the panel +(quota, decision, cards) was **completely broken**, while the unit tests kept passing +because they only cover the pure `decide()` function. The data layer itself was never +executed. The guard makes that class of error fail loudly. + +## What v1.x used to do + +The full history is in git (the v1.1–v1.6 commits). v1.x spent cards, paused goals, +and revived crashed Bots by reading the account API and other plugins' data — all of +which a community plugin must not do. + +The hardening v1.x added — tolerant JSON parsing, judging "is it broken?" by the +latest *turn* rather than a stale error code, per-instance request ids, failure +counters that reset only on success, the static guard — **survived into v2.0**; it +just guards the local ledger now instead of the host's private state. + +## Tested environment + +- **MiniMax Code 3.1.2 or later** — the Mini App declares `agentAccess` and `agentConcurrency`. +- Verified on macOS; the upstream checks run on **Node 20**. +- The Rust daemon needs a Rust toolchain to build (`cargo build --release`). + +## Data & access + +This plugin reads and writes **only its own directory**: + +| Path | What | +| --- | --- | +| `~/.config/mcode-reset-card/ledger.json` | The ledger **you** maintain (read by the panel, writable from the panel's editor) | +| `~/.config/mcode-reset-card/config.json` | Shared configuration (trigger lines) | +| `~/.config/mcode-reset-card/daemon-status.json` | The backend daemon's latest conclusion | + +It deliberately does **not**: + +- read MiniMax Code's **login credentials** (no lease socket, no `.cap` file, no token on disk) +- open or write **`~/.minimax/v2/sqlite/runtime-state.sqlite`** (the host's internal database) +- read **another plugin's data directory** (e.g. `~/.minimax/v2/plugin-data/liveboards/*/state.json`) + +It performs **no write operations** outside its own directory: it never spends a +reset card, never pauses a goal, and never writes into another plugin's sessions. +The backend daemon is optional — the dashboard works on its own. + +## Boundaries + +- **No host credentials, no host database, no other plugins' data** (hard constraints) +- **No write operations** — spending a card and pausing a goal are yours to click +- The conclusion is advice only: it is based on a ledger *you* wrote +- Incomplete data yields *no* conclusion rather than a guess + +## Documentation + +- [docs/README.zh-CN.md](docs/README.zh-CN.md) — Chinese documentation diff --git a/plugins/hylouis233/expiry-guardian/check-test-parity.sh b/plugins/hylouis233/expiry-guardian/check-test-parity.sh new file mode 100755 index 0000000..49e6067 --- /dev/null +++ b/plugins/hylouis233/expiry-guardian/check-test-parity.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# 校验「工作区源码」与「插件包」**测试文件**保持一致。 +# +# 2026-10-20 反复踩到的坑:工作区(miniapps/expiry-guardian/)与插件包 +# (plugins/hylouis233/reset-card-autouse/)各有一份测试,路径布局还不同 +# (工作区是 `./x.mjs`,插件包是 `../miniapp/node/x.mjs`)。 +# 改了一边忘了另一边 ⇒ 插件包跑的是旧测试,出现「17/18」这种看不懂的结果。 +# +# ⇒ 这个脚本只比**测试文件的内容**(忽略 import 路径差异), +# 不一致就报错并指出是哪几个文件。 +set -euo pipefail + +WORKSPACE="${WORKSPACE:-$HOME/Desktop/mmx/miniapps/expiry-guardian}" +PLUGIN="${PLUGIN:-$(cd "$(dirname "$0")" && pwd)}" + +# 插件包里测试在 tests/,工作区在 miniapp/node/。 +PAIRS=( + "config-fidelity.test.mjs" + "decision.test.mjs" + "mcp.test.mjs" + "server-lint.test.mjs" +) + +fail=0 +for f in "${PAIRS[@]}"; do + a="$WORKSPACE/miniapp/node/$f" + b="$PLUGIN/tests/$f" + if [ ! -f "$a" ] || [ ! -f "$b" ]; then + echo " SKIP $f(缺文件)" + continue + fi + # 归一化:去掉 import 路径差异与空白差异。 + na=$(sed -E "s#\.\./miniapp/node/##g; s#'\./#'#g" "$a" | tr -d ' \t\r\n') + nb=$(sed -E "s#\.\./miniapp/node/##g; s#'\./#'#g" "$b" | tr -d ' \t\r\n') + if [ "$na" = "$nb" ]; then + echo " OK $f" + else + echo " DIFF $f ← 工作区与插件包不一致" + diff <(sed -E "s#\.\./miniapp/node/##g; s#'\./#'#g" "$a") \ + <(sed -E "s#\.\./miniapp/node/##g; s#'\./#'#g" "$b") | head -12 || true + fail=1 + fi +done + +if [ "$fail" -ne 0 ]; then + echo + echo "❌ 工作区与插件包的测试文件不一致 —— 改完记得两边都同步。" + exit 1 +fi +echo +echo "✅ 工作区与插件包的测试文件一致" diff --git a/plugins/hylouis233/expiry-guardian/daemon/bg_decision.test.py b/plugins/hylouis233/expiry-guardian/daemon/bg_decision.test.py new file mode 100644 index 0000000..2d2b3c3 --- /dev/null +++ b/plugins/hylouis233/expiry-guardian/daemon/bg_decision.test.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +"""后台决策测试 —— 逐条对齐 Mini App「蹬王之王」的 decide()。 + +Mini App 的分支(packages/expiry-guardian/miniapp/node/server.mjs::decide): + 未耗尽 → idle + 耗尽 + 无积分 + 有卡 → use-card + 耗尽 + 无积分 + 无卡 → wait + 耗尽 + 有积分 + 无卡 → keep-credit + 耗尽 + 都有,卡先过期 → use-card + 耗尽 + 都有,积分先过期 → keep-credit + +本文件逐条复刻这些分支,任一不一致即视为失败。 +纯函数测试:不触网、不扣卡。 +""" +import importlib.util +import sys +import time +from pathlib import Path + +spec = importlib.util.spec_from_file_location( + "bg", Path(__file__).with_name("reset_card_autouse.py") +) +m = importlib.util.module_from_spec(spec) +sys.modules["bg"] = m +spec.loader.exec_module(m) + +NOW = int(time.time() * 1000) +DAY = 86400000 + + +def qs(quota_type, used, total=100.0, unlimited=False): + return m.QuotaState(quota_type=quota_type, total=total, used=used, is_unlimited=unlimited) + + +def card(days): + return { + "card_id": f"C{days}", + "out_biz_no": f"biz:{days}", + "source_type": "mcode_signin", + "expire_time": NOW + days * DAY, + } + + +passed = failed = 0 + + +def check(name, got, want): + global passed, failed + if got == want: + passed += 1 + print(f" PASS {name} -> {got}") + else: + failed += 1 + print(f" FAIL {name} -> {got} (want {want})") + + +def decide(used5, used7d=None, cards=None, credit_days=None): + quotas = [qs(1, used5)] + quotas.append(qs(2, 0, unlimited=True) if used7d is None else qs(2, used7d)) + exp = None if credit_days is None else NOW + credit_days * DAY + return m.decide_background(quotas, cards or [], exp, "平台积分")[0] + + +print("=== Mini App 分支 1:未到触发线(99%) → idle ===") +# 上界取 98 而不是 99:触发线就是 99%,99% 已经属于「该动手了」, +# 那条在下面单独断言。早先这里写的是 99,是 99% 提前触发设计落地之前的残留。 +for pct in (0, 50, 90, 98): + check(f"5h 已用 {pct}% → idle", decide(pct, cards=[card(7)], credit_days=13), "idle") + +# 触发线本身就应该动手 —— 额度没真正耗尽,但再等一次请求就会失败。 +check("5h 已用 99% → use-card(触发线)", decide(99, cards=[card(7)], credit_days=13), "use-card") + +print("\n=== Mini App 分支 2:耗尽 + 无积分 + 有卡 → use-card ===") +check("5h 100% + 无积分 + 有卡", decide(100, cards=[card(7)], credit_days=None), "use-card") + +print("\n=== Mini App 分支 3:耗尽 + 无积分 + 无卡 → wait ===") +check("5h 100% + 无积分 + 无卡", decide(100, cards=[], credit_days=None), "wait") + +print("\n=== Mini App 分支 4:耗尽 + 有积分 + 无卡 → keep-credit ===") +check("5h 100% + 有积分 + 无卡", decide(100, cards=[], credit_days=13), "keep-credit") + +print("\n=== Mini App 分支 5:耗尽 + 都有 + 卡先过期 → use-card ===") +check("卡 7 天 / 积分 13 天", decide(100, cards=[card(7)], credit_days=13), "use-card") +check("卡 5 天 / 积分 30 天", decide(100, cards=[card(5)], credit_days=30), "use-card") + +print("\n=== Mini App 分支 6:耗尽 + 都有 + 积分先过期 → keep-credit ===") +check("卡 20 天 / 积分 5 天", decide(100, cards=[card(20)], credit_days=5), "keep-credit") +check("卡 30 天 / 积分 1 天", decide(100, cards=[card(30)], credit_days=1), "keep-credit") + +print("\n=== 周限额单独耗尽也应触发(与 Mini App 的 weekly.exhausted 一致)===") +check("5h 未耗尽 + 周 100% → 进入判定", + decide(10, used7d=100, cards=[card(7)], credit_days=13), "use-card") +check("5h 未耗尽 + 周未耗尽 → idle", + decide(10, used7d=50, cards=[card(7)], credit_days=13), "idle") + +print("\n=== 不限量的周限额不算耗尽(与 is_unlimited 判定一致)===") +check("周不限量 + 5h 100% + 卡先过期", decide(100, cards=[card(7)], credit_days=13), "use-card") + +print("\n=== 理由文本 ===") +cases = [ + ("未耗尽", ([qs(1, 50), qs(2, 0, unlimited=True)], [card(7)], NOW + 13 * DAY, "平台积分"), "额度充足"), + ("无积分用卡", ([qs(1, 100), qs(2, 0, unlimited=True)], [card(7)], None, ""), "没有可用积分"), + ("无卡留积分", ([qs(1, 100), qs(2, 0, unlimited=True)], [], NOW + 13 * DAY, "平台积分"), "没有可用的重置卡"), + ("卡先过期", ([qs(1, 100), qs(2, 0, unlimited=True)], [card(7)], NOW + 13 * DAY, "平台积分"), "先用卡保住积分"), + ("积分先过期", ([qs(1, 100), qs(2, 0, unlimited=True)], [card(20)], NOW + 5 * DAY, "平台积分"), "让积分先消耗"), +] +for label, args, needle in cases: + _, reason = m.decide_background(*args) + ok = isinstance(reason, str) and needle in reason + if ok: + passed += 1 + else: + failed += 1 + print(f" {'PASS' if ok else 'FAIL'} {label}: {reason}") + +print(f"\n{passed}/{passed + failed} passed") +sys.exit(0 if failed == 0 else 1) diff --git a/plugins/hylouis233/expiry-guardian/daemon/bg_parity.json b/plugins/hylouis233/expiry-guardian/daemon/bg_parity.json new file mode 100644 index 0000000..73acd83 --- /dev/null +++ b/plugins/hylouis233/expiry-guardian/daemon/bg_parity.json @@ -0,0 +1,65 @@ +{ + "at": "2026-10-16T19:07:04.579Z", + "results": [ + { + "n": "未耗尽 / 卡7 / 积13", + "action": "idle", + "reason": "额度充足,暂不需要处理" + }, + { + "n": "未耗尽 / 无卡 / 积13", + "action": "idle", + "reason": "额度充足,暂不需要处理" + }, + { + "n": "耗尽 / 卡先过期", + "action": "use-card", + "reason": "重置卡(10月24日)比平台积分(10月30日)先过期,先用卡保住积分" + }, + { + "n": "耗尽 / 积分先过期", + "action": "use-credit", + "reason": "平台积分(10月22日)比重置卡(11月6日)先过期,不用卡,让积分先消耗" + }, + { + "n": "耗尽 / 无卡 / 有积分", + "action": "use-credit", + "reason": "没有可用的重置卡,让平台积分(10月30日到期)先消耗" + }, + { + "n": "耗尽 / 有卡 / 无积分", + "action": "use-card", + "reason": "没有可用积分,使用重置卡重置额度" + }, + { + "n": "耗尽 / 无卡 / 无积分", + "action": "wait", + "reason": "额度已耗尽,既没有积分也没有可用的重置卡" + }, + { + "n": "周限额耗尽 / 5h未耗尽", + "action": "use-card", + "reason": "重置卡(10月24日)比平台积分(10月30日)先过期,先用卡保住积分" + }, + { + "n": "周限额耗尽 / 积分先过期", + "action": "use-credit", + "reason": "平台积分(10月22日)比重置卡(11月6日)先过期,不用卡,让积分先消耗" + }, + { + "n": "周限额未耗尽 / 5h未耗尽", + "action": "idle", + "reason": "额度充足,暂不需要处理" + }, + { + "n": "5h 99% 不算耗尽", + "action": "idle", + "reason": "额度充足,暂不需要处理" + }, + { + "n": "5h 100% 边界算耗尽", + "action": "use-card", + "reason": "重置卡(10月18日)比平台积分(12月16日)先过期,先用卡保住积分" + } + ] +} \ No newline at end of file diff --git a/plugins/hylouis233/expiry-guardian/daemon/check-parity.sh b/plugins/hylouis233/expiry-guardian/daemon/check-parity.sh new file mode 100755 index 0000000..7df1c84 --- /dev/null +++ b/plugins/hylouis233/expiry-guardian/daemon/check-parity.sh @@ -0,0 +1,73 @@ +#!/bin/sh +# 一致性总检查:决策单测 + 跨语言一致性 + 校验器自检。 +# +# 第三步是重点:如果校验器对「已知错误」也报一致,那它本身失效了, +# 等于没有校验。所以这里主动注入一处漂移,要求必须被抓到。 +# +# 任一环节失败即以非零退出,可直接挂到 CI 或 git hook 上。 +set -e +DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$DIR" + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT +fail=0 + +echo "=== 1/3 后台决策单测 ===" +if python3 bg_decision.test.py > "$TMP/unit.log" 2>&1; then + tail -1 "$TMP/unit.log" +else + echo "✗ 决策单测失败" + tail -20 "$TMP/unit.log" + fail=1 +fi + +echo +echo "=== 2/3 跨语言一致性(后台 vs Mini App「蹬王之王」)===" +if node verify-parity.mjs > "$TMP/parity.log" 2>&1; then + tail -1 "$TMP/parity.log" +else + echo "✗ 两边策略已漂移" + tail -25 "$TMP/parity.log" + fail=1 +fi + +echo +echo "=== 3/3 校验器自检(注入漂移必须被抓到)===" +python3 - "$TMP" <<'PY' +import pathlib, sys +tmp = pathlib.Path(sys.argv[1]) +src = pathlib.Path("reset_card_autouse.py").read_text() +needle = 'f"没有可用的重置卡,让{credit_desc}({_fmt_day(credit_expiry_ms)}到期)先消耗"' +if needle not in src: + print("✗ 自检锚点已失效:后台代码结构变了,请同步更新 check-parity.sh") + raise SystemExit(1) +# 把「无卡 → keep-credit」改成「无卡 → wait」,这是一处真实的漂移 +broken = src.replace( + 'return "keep-credit", (\n ' + needle, + 'return "wait", "自检注入的错误行为"', +) +if broken == src: + print("✗ 注入失败:目标分支结构与预期不符") + raise SystemExit(1) +(tmp / "broken.py").write_text(broken) +PY + +if [ ! -f "$TMP/broken.py" ]; then + echo "✗ 自检注入失败,跳过" + fail=1 +elif MCODE_BG_SCRIPT="$TMP/broken.py" node verify-parity.mjs > "$TMP/selftest.log" 2>&1; then + echo "✗ 校验器对注入的漂移仍报一致 —— 校验器本身已失效" + fail=1 +else + echo "✓ 成功检出注入的漂移" + grep -m1 '动作不一致' "$TMP/selftest.log" | sed 's/^/ /' || true +fi + +echo +if [ "$fail" -eq 0 ]; then + echo "✓ 全部通过:后台服务与 Mini App 策略一致" +else + echo "✗ 存在不一致,需修复" +fi +exit "$fail" diff --git a/plugins/hylouis233/expiry-guardian/daemon/reset_card_autouse.py b/plugins/hylouis233/expiry-guardian/daemon/reset_card_autouse.py new file mode 100755 index 0000000..db7a544 --- /dev/null +++ b/plugins/hylouis233/expiry-guardian/daemon/reset_card_autouse.py @@ -0,0 +1,460 @@ +#!/usr/bin/env python3 +"""决策基准(纯函数,无网络、无凭据、无状态)。 + +社区插件不能读取 MiniMax Code 的登录凭据、不能读写宿主内部数据库 +(2026-10-20 管理员 review),所以这个文件**不再**是「后台守护进程」, +而只保留**决策逻辑本身** —— 作为三方一致性校验的第三个比对基准: + + Rust 守护进程 · Mini App「蹬王之王」 · 本文件(Python) + ↘ 动作 + 理由文本逐字比对 ↙ + +⚠️ 理由文本必须**逐字一致**(`verify-parity.mjs` 会逐字比对), +所以本文件里的 `decide_background()` 与 Rust 的 `decision::decide()`、 +Mini App 的 `decide()` 是**同构实现**,改一处就要同步三处。 +""" + +from __future__ import annotations + +import json +import os +import time +from dataclasses import dataclass +from datetime import datetime +from typing import Any + +# ── 常量:与桌面端保持一致 ──────────────────────────────────────────────────── +# 账号/计费接口的真实根地址(取自桌面端 d5 的生产分支)。 +# 注意:不是 agent.minimax.cn —— 那是网页域,对 /backend/account/* 一律 404。 +# Code 积分走另一个域名(platform 域对这些路径返回非 JSON) + +QUOTA_FIVE_HOUR = 1 +QUOTA_WEEKLY = 2 +QUOTA_VIDEO = 3 + +# 判定阈值的**默认值**,仅在 config.json 缺失/非法时兜底。 +# 实际生效值一律来自 load_config() —— 与 Node / Rust 读同一个文件。 +QUOTA_TRIGGER_PERCENT = 99.0 +# 四档红绿灯的两个分界点(功能3)。燃尽档由 used >= total 判定,不可配。 +QUOTA_TIGHT_PERCENT = 80.0 +QUOTA_LOW_PERCENT = 95.0 +# 距 5 小时额度刷新不足这么多分钟时,不消耗重置卡(额度马上就自己回来了)。 +# 实际生效值来自 config.json 的 cardSkipBeforeResetMinutes。 +QUOTA_CARD_SKIP_BEFORE_RESET_MINUTES = 60.0 + +# 等级 → 中文。**属于 parity 契约的一部分**,改一个字三方都要改。 +LEVEL_LABEL = { + "comfortable": "额度充足", + "tight": "额度紧张", + "low": "额度不足", + "burned": "额度燃尽", +} + +QUOTA_LABEL = { + QUOTA_FIVE_HOUR: "5小时限额", + QUOTA_WEEKLY: "周限额", + QUOTA_VIDEO: "视频限额", +} + +# ── 共享配置(功能1/2/3)──────────────────────────────────────────────────── +# 与 Mini App 侧、Rust 侧读同一个文件: +# ~/.config/mcode-reset-card/config.json +CONFIG_PATH = os.path.expanduser("~/.config/mcode-reset-card/config.json") + +CONFIG_DIR = os.path.dirname(CONFIG_PATH) + + +def default_config() -> dict[str, Any]: + return { + "schemaVersion": 1, + "cardTriggerPercent": QUOTA_TRIGGER_PERCENT, + "cardSkipBeforeResetMinutes": QUOTA_CARD_SKIP_BEFORE_RESET_MINUTES, + "resumeCronId": "", + "quotaLevels": {"tight": QUOTA_TIGHT_PERCENT, "low": QUOTA_LOW_PERCENT}, + "goalPause": {"enabled": False, "triggerPercent": QUOTA_TRIGGER_PERCENT, + "resumeAtPercent": 0.0}, + } + + +def _clamp(v: Any, lo: float, hi: float, fallback: float) -> float: + try: + f = float(v) + except (TypeError, ValueError): + return fallback + if f != f: # NaN + return fallback + return min(hi, max(lo, f)) + + +def normalize_config(raw: Any) -> tuple[dict[str, Any], list[str]]: + """逐字段校验并夹紧,返回 (配置, 问题列表)。 + + 非法组合**整体回退默认并报出来**,绝不静默纠正 —— + 静默纠正会让人以为自己设的生效了。 + """ + d = default_config() + issues: list[str] = [] + src = raw if isinstance(raw, dict) else {} + if raw is not None and not isinstance(raw, dict): + issues.append("配置根节点不是对象") + + raw_card = src.get("cardTriggerPercent") + card = _clamp(raw_card, 1.0, 100.0, d["cardTriggerPercent"]) + if raw_card is not None: + try: + if float(raw_card) != card: + issues.append(f"cardTriggerPercent={raw_card} 超出 1..100,已夹到 {card}") + except (TypeError, ValueError): + issues.append(f"cardTriggerPercent={raw_card} 不是数字,已用默认值 {card}") + + lv = src.get("quotaLevels") if isinstance(src.get("quotaLevels"), dict) else {} + tight = _clamp(lv.get("tight"), 1.0, 100.0, d["quotaLevels"]["tight"]) + low = _clamp(lv.get("low"), 1.0, 100.0, d["quotaLevels"]["low"]) + if not tight < low: + if lv: + issues.append( + f"quotaLevels 需满足 tight < low(收到 tight={tight} low={low}),已整体回退默认") + tight = d["quotaLevels"]["tight"] + low = d["quotaLevels"]["low"] + + gp = src.get("goalPause") if isinstance(src.get("goalPause"), dict) else {} + raw_skip = src.get("cardSkipBeforeResetMinutes") + skip = _clamp(raw_skip, 0.0, 1440.0, d["cardSkipBeforeResetMinutes"]) + if raw_skip is not None and skip != _clamp(raw_skip, 0.0, 1440.0, d["cardSkipBeforeResetMinutes"]): + issues.append(f"cardSkipBeforeResetMinutes={raw_skip} 超出 0..1440,已夹到 {skip}") + return { + "schemaVersion": 1, + "cardTriggerPercent": card, + "cardSkipBeforeResetMinutes": skip, + "resumeCronId": str(src.get("resumeCronId") or "").strip(), + "quotaLevels": {"tight": tight, "low": low}, + "goalPause": { + "enabled": gp.get("enabled") is True, + "triggerPercent": _clamp(gp.get("triggerPercent"), 1.0, 100.0, + d["goalPause"]["triggerPercent"]), + "resumeAtPercent": _clamp(gp.get("resumeAtPercent"), 0.0, 100.0, + d["goalPause"]["resumeAtPercent"]), + }, + }, issues + + +def load_config() -> tuple[dict[str, Any], list[str]]: + """读配置。文件不存在是正常情况(首次运行),返回默认值且不报 issue。""" + try: + with open(CONFIG_PATH, encoding="utf-8") as f: + raw = json.load(f) + except FileNotFoundError: + return default_config(), [] + except (OSError, ValueError) as e: + return default_config(), [f"配置读取失败({e}),正在用默认值"] + return normalize_config(raw) + +# 桌面端 base_resp 业务错误码 → 语义 +ERR_UNAUTHORIZED = 1004 +ERR_GROUP_NOT_MEMBER = 1003 +ERR_PERMISSION_DENIED = 2029 +ERR_INVALID_PARAMS = 2013 + + +@dataclass +class QuotaState: + """一个限额维度的用量状态。""" + + quota_type: int + total: float = 0.0 + used: float = 0.0 + is_unlimited: bool = False + reset_time: int | None = None + + def is_triggered(self, trigger_percent: float) -> bool: + """是否达到扣卡触发线。 + + 阈值必须**显式传入** —— 写死在本类里正是当初三方漂移的根源: + 配置改了,这里还按 99 算,而且不报错。 + """ + if self.is_unlimited or self.total <= 0.0: + return False + return self.used >= self.total * trigger_percent / 100.0 + + def level(self, tight: float = QUOTA_TIGHT_PERCENT, + low: float = QUOTA_LOW_PERCENT) -> str: + """四档红绿灯分档。与 Rust ``quota_level()`` 逐行对应。 + + 燃尽档由 used >= total 判定,**不可配** —— 它是事实,不是阈值。 + """ + if self.is_unlimited or self.total <= 0.0: + return "comfortable" + if self.used >= self.total: + return "burned" + ratio = self.used / self.total * 100.0 + if ratio >= low: + return "low" + if ratio >= tight: + return "tight" + return "comfortable" + + @property + def exhausted(self) -> bool: + """是否已耗尽。 + + 接口语义(对齐桌面端 V/q 两个转换函数): + used = *_used_percent → **已用百分比**,如 "71%" → 71 + total = *_total_percent → 总额度百分比,缺省按 100 计 + + 所以耗尽就是「已用百分比 ≥ 总额度百分比」。不限量(status===3)不算耗尽。 + """ + if self.is_unlimited or self.total <= 0: + return False + return self.used >= self.total - 1e-9 + + @property + def remaining_percent(self) -> float: + if self.is_unlimited or self.total <= 0: + return 100.0 + return max(0.0, (self.total - self.used) / self.total * 100.0) + + def describe(self) -> str: + if self.is_unlimited: + return f"{QUOTA_LABEL.get(self.quota_type, self.quota_type)}=不限量" + return (f"{QUOTA_LABEL.get(self.quota_type, self.quota_type)}=" + f"已用{self.used:.0f}% / 剩余{self.remaining_percent:.0f}%") + + +def _amount(value: Any, default: float = 0.0) -> float: + if isinstance(value, str): + value = value.replace(",", "").strip() + try: + n = float(value) + return n if n == n else default + except (TypeError, ValueError): + return default + + +def _fmt_day(ms: int | None) -> str: + if not ms: + return "未知" + d = datetime.fromtimestamp(ms / 1000) + return f"{d.month}月{d.day}日" + + +def _minutes_to_reset(q: "QuotaState | None", now_ms: int) -> float | None: + """某个额度维度距离刷新还有多少分钟。取不到刷新时间时返回 None。 + + 与 Rust 的 `minutes_to_reset()` 同一口径:已过去的时间按 0 算。 + """ + if q is None or q.reset_time is None: + return None + return max(0.0, (q.reset_time - now_ms) / 60_000.0) + + +def decide_background( + quotas: list[QuotaState], + usable_cards: list[dict[str, Any]], + credit_expiry_ms: int | None, + credit_desc: str, + cfg: dict[str, Any] | None = None, + now_ms: int | None = None, + credit_data_complete: bool = True, +) -> tuple[str, str]: + """后台扣卡决策,返回 (动作, 理由)。 + + `credit_data_complete` 表示「积分数据是否**完整读到**」。 + + ⚠️ 2026-10-19 PR #28 review 修:读不到 ≠ 没有。 + `credit_expiry_ms = None` 同时可能表示「确实没有积分」与「接口读失败」, + 以前两者都落到 use-card —— 而扣卡是真实写操作, + 网络故障会被当成业务状态,可能误扣真卡。 + ⇒ 数据不完整时一律**不做写操作**(wait),只如实报出来。 + + 动作 ∈ {"use-card", "keep-credit", "idle", "wait"}。纯函数,不触碰网络,可直接单测。 + + **分支与 Mini App「蹬王之王」的 decide() 一一对应**,刻意保持同构: + + 未耗尽 → idle (额度等级 + 已用量 + 未到触发线) + 耗尽 + 无积分 + 有卡 → use-card (没有可用积分,用卡重置额度) + 耗尽 + 无积分 + 无卡 → wait + 耗尽 + 有积分 + 无卡 → keep-credit (没卡可用,让积分先消耗) + 耗尽 + 都有,卡先过期 → use-card (先用卡保住积分) + 耗尽 + 都有,积分先过期 → keep-credit (让积分先消耗,留住更耐放的卡) + + `cfg` 省略时从共享配置文件读取(与 Node / Rust 读同一个文件)。 + `now_ms` 用于判断「额度是不是马上就要刷新了」。 + """ + if cfg is None: + cfg, _issues = load_config() + if now_ms is None: + now_ms = int(time.time() * 1000) + + trigger = float(cfg["cardTriggerPercent"]) + tight = float(cfg["quotaLevels"]["tight"]) + low = float(cfg["quotaLevels"]["low"]) + skip_minutes = float(cfg.get("cardSkipBeforeResetMinutes", QUOTA_CARD_SKIP_BEFORE_RESET_MINUTES)) + + by_type = {q.quota_type: q for q in quotas} + five = by_type.get(QUOTA_FIVE_HOUR) + weekly = by_type.get(QUOTA_WEEKLY) + + five_exhausted = bool(five and five.is_triggered(trigger)) + weekly_exhausted = bool(weekly and weekly.is_triggered(trigger)) + + # ── 5 小时额度即将自己刷新 → 不烧重置卡(纯浪费)── + reset_in_min = _minutes_to_reset(five, now_ms) + skip_card_reason = None + if skip_minutes > 0 and reset_in_min is not None and reset_in_min <= skip_minutes: + skip_card_reason = f"5 小时额度 {reset_in_min:.0f} 分钟后刷新,暂不消耗重置卡" + + # ── 未到触发线:与 Mini App 一致,不做任何事 ── + if not five_exhausted and not weekly_exhausted: + used5 = five.used if five is not None else 0.0 + # 等级词随分档走,而不是永远写「额度充足」—— + # 否则用量 92% 时页面上仍显示「额度充足」,是误导。 + # 把触发线也说进来:否则「额度不足但暂不需要处理」看起来自相矛盾。 + # burned 永远不会出现在这里:它必然已 triggered,走的是下面用卡/留卡分支。 + level = five.level(tight, low) if five is not None else "comfortable" + return "idle", ( + f"{LEVEL_LABEL[level]}(5 小时已用 {used5:.0f}%)," + f"未到 {trigger:.0f}% 触发线,暂不动用重置卡" + ) + + # ── 积分数据没读全 ⇒ 无法判断「卡先过期还是积分先过期」── + # ⇒ 绝不做写操作(扣卡是真实消耗,宁可等下一轮)。 + if not credit_data_complete: + return "wait", "积分数据读取不完整,本轮不执行写操作(等下一轮重新判定)" + + # ── 无可用积分:只能靠卡把额度重置回来 ── + if credit_expiry_ms is None: + if usable_cards: + if skip_card_reason: + return "wait", skip_card_reason + return "use-card", "没有可用积分,使用重置卡重置额度" + return "wait", "额度已耗尽,既没有积分也没有可用的重置卡" + + # ── 有积分但没有可用卡:让积分先消耗 ── + if not usable_cards: + return "keep-credit", ( + f"没有可用的重置卡,让{credit_desc}({_fmt_day(credit_expiry_ms)}到期)先消耗" + ) + + card_expiry = usable_cards[0].get("expire_time") + if card_expiry is None: + return "keep-credit", "没有可用重置卡,让积分先消耗" + + # ── 两者都有:比「先过期先用」 ── + if card_expiry < credit_expiry_ms: + if skip_card_reason: + return "wait", skip_card_reason + return "use-card", ( + f"重置卡({_fmt_day(card_expiry)})比{credit_desc}({_fmt_day(credit_expiry_ms)})先过期," + f"先用卡保住积分" + ) + return "keep-credit", ( + f"{credit_desc}({_fmt_day(credit_expiry_ms)})比重置卡({_fmt_day(card_expiry)})先过期," + f"不用卡,让积分先消耗" + ) + + +def decide_background( + quotas: list[QuotaState], + usable_cards: list[dict[str, Any]], + credit_expiry_ms: int | None, + credit_desc: str, + cfg: dict[str, Any] | None = None, + now_ms: int | None = None, + credit_data_complete: bool = True, +) -> tuple[str, str]: + """后台扣卡决策,返回 (动作, 理由)。 + + `credit_data_complete` 表示「积分数据是否**完整读到**」。 + + ⚠️ 2026-10-19 PR #28 review 修:读不到 ≠ 没有。 + `credit_expiry_ms = None` 同时可能表示「确实没有积分」与「接口读失败」, + 以前两者都落到 use-card —— 而扣卡是真实写操作, + 网络故障会被当成业务状态,可能误扣真卡。 + ⇒ 数据不完整时一律**不做写操作**(wait),只如实报出来。 + + 动作 ∈ {"use-card", "keep-credit", "idle", "wait"}。纯函数,不触碰网络,可直接单测。 + + **分支与 Mini App「蹬王之王」的 decide() 一一对应**,刻意保持同构: + + 未耗尽 → idle (额度等级 + 已用量 + 未到触发线) + 耗尽 + 无积分 + 有卡 → use-card (没有可用积分,用卡重置额度) + 耗尽 + 无积分 + 无卡 → wait + 耗尽 + 有积分 + 无卡 → keep-credit (没卡可用,让积分先消耗) + 耗尽 + 都有,卡先过期 → use-card (先用卡保住积分) + 耗尽 + 都有,积分先过期 → keep-credit (让积分先消耗,留住更耐放的卡) + + `cfg` 省略时从共享配置文件读取(与 Node / Rust 读同一个文件)。 + `now_ms` 用于判断「额度是不是马上就要刷新了」。 + """ + if cfg is None: + cfg, _issues = load_config() + if now_ms is None: + now_ms = int(time.time() * 1000) + + trigger = float(cfg["cardTriggerPercent"]) + tight = float(cfg["quotaLevels"]["tight"]) + low = float(cfg["quotaLevels"]["low"]) + skip_minutes = float(cfg.get("cardSkipBeforeResetMinutes", QUOTA_CARD_SKIP_BEFORE_RESET_MINUTES)) + + by_type = {q.quota_type: q for q in quotas} + five = by_type.get(QUOTA_FIVE_HOUR) + weekly = by_type.get(QUOTA_WEEKLY) + + five_exhausted = bool(five and five.is_triggered(trigger)) + weekly_exhausted = bool(weekly and weekly.is_triggered(trigger)) + + # ── 5 小时额度即将自己刷新 → 不烧重置卡(纯浪费)── + reset_in_min = _minutes_to_reset(five, now_ms) + skip_card_reason = None + if skip_minutes > 0 and reset_in_min is not None and reset_in_min <= skip_minutes: + skip_card_reason = f"5 小时额度 {reset_in_min:.0f} 分钟后刷新,暂不消耗重置卡" + + # ── 未到触发线:与 Mini App 一致,不做任何事 ── + if not five_exhausted and not weekly_exhausted: + used5 = five.used if five is not None else 0.0 + # 等级词随分档走,而不是永远写「额度充足」—— + # 否则用量 92% 时页面上仍显示「额度充足」,是误导。 + # 把触发线也说进来:否则「额度不足但暂不需要处理」看起来自相矛盾。 + # burned 永远不会出现在这里:它必然已 triggered,走的是下面用卡/留卡分支。 + level = five.level(tight, low) if five is not None else "comfortable" + return "idle", ( + f"{LEVEL_LABEL[level]}(5 小时已用 {used5:.0f}%)," + f"未到 {trigger:.0f}% 触发线,暂不动用重置卡" + ) + + # ── 积分数据没读全 ⇒ 无法判断「卡先过期还是积分先过期」── + # ⇒ 绝不做写操作(扣卡是真实消耗,宁可等下一轮)。 + if not credit_data_complete: + return "wait", "积分数据读取不完整,本轮不执行写操作(等下一轮重新判定)" + + # ── 无可用积分:只能靠卡把额度重置回来 ── + if credit_expiry_ms is None: + if usable_cards: + if skip_card_reason: + return "wait", skip_card_reason + return "use-card", "没有可用积分,使用重置卡重置额度" + return "wait", "额度已耗尽,既没有积分也没有可用的重置卡" + + # ── 有积分但没有可用卡:让积分先消耗 ── + if not usable_cards: + return "keep-credit", ( + f"没有可用的重置卡,让{credit_desc}({_fmt_day(credit_expiry_ms)}到期)先消耗" + ) + + card_expiry = usable_cards[0].get("expire_time") + if card_expiry is None: + return "keep-credit", "没有可用重置卡,让积分先消耗" + + # ── 两者都有:比「先过期先用」 ── + if card_expiry < credit_expiry_ms: + if skip_card_reason: + return "wait", skip_card_reason + return "use-card", ( + f"重置卡({_fmt_day(card_expiry)})比{credit_desc}({_fmt_day(credit_expiry_ms)})先过期," + f"先用卡保住积分" + ) + return "keep-credit", ( + f"{credit_desc}({_fmt_day(credit_expiry_ms)})比重置卡({_fmt_day(card_expiry)})先过期," + f"不用卡,让积分先消耗" + ) + + diff --git a/plugins/hylouis233/expiry-guardian/daemon/rust/.gitignore b/plugins/hylouis233/expiry-guardian/daemon/rust/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/plugins/hylouis233/expiry-guardian/daemon/rust/.gitignore @@ -0,0 +1 @@ +/target diff --git a/plugins/hylouis233/expiry-guardian/daemon/rust/Cargo.lock b/plugins/hylouis233/expiry-guardian/daemon/rust/Cargo.lock new file mode 100644 index 0000000..5253a8c --- /dev/null +++ b/plugins/hylouis233/expiry-guardian/daemon/rust/Cargo.lock @@ -0,0 +1,107 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "reset-card-daemon" +version = "1.0.0" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/plugins/hylouis233/expiry-guardian/daemon/rust/Cargo.toml b/plugins/hylouis233/expiry-guardian/daemon/rust/Cargo.toml new file mode 100644 index 0000000..f228446 --- /dev/null +++ b/plugins/hylouis233/expiry-guardian/daemon/rust/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "reset-card-daemon" +version = "1.0.0" +edition = "2024" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[profile.release] +opt-level = "z" # 体积优先:后台常驻服务不需要极致速度 +lto = true +codegen-units = 1 +panic = "abort" +strip = true diff --git a/plugins/hylouis233/expiry-guardian/daemon/rust/src/clock.rs b/plugins/hylouis233/expiry-guardian/daemon/rust/src/clock.rs new file mode 100644 index 0000000..bb15fd5 --- /dev/null +++ b/plugins/hylouis233/expiry-guardian/daemon/rust/src/clock.rs @@ -0,0 +1,74 @@ +//! 时钟与信号处理。 +//! +//! # 为什么没有 HTTP 客户端了 +//! +//! 社区插件不能读取 MiniMax Code 的登录凭据(2026-10-20 管理员 review 要求), +//! 所以本插件**不再**自己调账号接口。 +//! ⇒ 额度/积分/重置卡的信息改由**本地账本**提供(见 `ledger.rs`)。 +//! ⇒ 这个模块只剩下「现在几点」与「该不该退出」两件事。 + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +#[cfg(unix)] +use std::os::raw::c_int; +#[cfg(unix)] +unsafe extern "C" { + fn signal(signum: c_int, handler: usize) -> usize; +} +const SIGINT: c_int = 2; +const SIGTERM: c_int = 15; + +/// 收到终止信号后置位(守护进程在下一轮开头检查它)。 +pub static STOPPED: AtomicBool = AtomicBool::new(false); + +/// 现在是 Unix 毫秒时间戳。 +pub fn now_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) +} + +/// 是否已经收到终止信号。 +pub fn should_stop() -> bool { + STOPPED.load(Ordering::SeqCst) +} + +/// 置位「该退出了」。 +pub fn request_stop() { + STOPPED.store(true, Ordering::SeqCst); +} + +/// 装上 SIGINT / SIGTERM 处理器。 +/// +/// `launchctl` 停守护进程时发的是这两个信号 ⇒ 收到就置位, +/// 让主循环在当前这轮结束后**干净退出**(而不是被硬杀)。 +/// +/// ⚠️ 连着收两次(比如 SIGINT 之后又 SIGTERM)⇒ 直接退出, +/// 免得「置位了但这一轮还在读文件」时卡住。 +pub fn install_signal_handlers() { + unsafe extern "C" fn handler(_sig: i32) { + if should_stop() { + std::process::exit(130); + } + request_stop(); + } + unsafe { + signal(SIGINT, handler as usize); + signal(SIGTERM, handler as usize); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn now_ms_is_a_plausible_timestamp() { + let n = now_ms(); + // 2020-01-01 之后、2100 年之前。 + assert!(n > 1_577_836_800_000, "时间戳不对:{n}"); + assert!(n < 4_102_444_800_000, "时间戳不对:{n}"); + } +} diff --git a/plugins/hylouis233/expiry-guardian/daemon/rust/src/config.rs b/plugins/hylouis233/expiry-guardian/daemon/rust/src/config.rs new file mode 100644 index 0000000..75e5c7a --- /dev/null +++ b/plugins/hylouis233/expiry-guardian/daemon/rust/src/config.rs @@ -0,0 +1,255 @@ +//! 共享配置:与 Mini App「蹬王之王」读**同一个文件**。 +//! +//! 这是三份实现不漂移的前提:Node / Rust / Python 都从这里取阈值与分界点, +//! 谁都不许再把 99 写死在自己代码里。 +//! +//! 路径 `~/.config/mcode-reset-card/config.json` 与 Mini App 侧一致 +//! (Rust `state.rs` 的 bg-state.json 也在同一目录)。 +//! +//! 读失败或字段非法一律**回退默认并记 issue**,绝不静默纠正 —— +//! 静默纠正会让人以为自己设的生效了。 + +use std::fs; +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +use crate::decision::{ + DEFAULT_CARD_SKIP_BEFORE_RESET_MINUTES, DEFAULT_QUOTA_LOW_PERCENT, DEFAULT_QUOTA_TIGHT_PERCENT, + DEFAULT_QUOTA_TRIGGER_PERCENT, +}; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct QuotaLevels { + #[serde(default = "d_tight")] + pub tight: f64, + #[serde(default = "d_low")] + pub low: f64, +} + +fn d_tight() -> f64 { + DEFAULT_QUOTA_TIGHT_PERCENT +} +fn d_low() -> f64 { + DEFAULT_QUOTA_LOW_PERCENT +} +fn d_card() -> f64 { + DEFAULT_QUOTA_TRIGGER_PERCENT +} + +impl Default for QuotaLevels { + fn default() -> Self { + QuotaLevels { tight: DEFAULT_QUOTA_TIGHT_PERCENT, low: DEFAULT_QUOTA_LOW_PERCENT } + } +} + +/// `rename_all = "camelCase"` **不是可有可无的风格问题**: +/// Node / Python 写出的配置键是 `goalPause.triggerPercent` 这类驼峰, +/// 而 Rust 结构体字段是 snake_case。没有这行,serde 会认为**所有字段都不存在**, +/// 然后逐个落到 `#[serde(default)]` —— 于是 `enabled` 永远读到 `false`, +/// 且**一行错都不报**。这个坑真实发生过:功能看起来写完了,却永远打不开。 +/// +/// `deny_unknown_fields` 是配套的安全网:宁可解析失败并报出来, +/// 也不要「静默降级成默认值」让人以为自己设的值生效了。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Config { + #[serde(default = "one")] + pub schema_version: u32, + #[serde(default = "d_card")] + pub card_trigger_percent: f64, + /// 距 5 小时额度刷新不足这么多分钟时,不消耗重置卡。0 = 关闭该保护。 + #[serde(default = "d_skip")] + pub card_skip_before_reset_minutes: f64, + #[serde(default)] + pub quota_levels: QuotaLevels, +} + +fn one() -> u32 { + 1 +} + +fn d_skip() -> f64 { + DEFAULT_CARD_SKIP_BEFORE_RESET_MINUTES +} + +impl Default for Config { + fn default() -> Self { + Config { + schema_version: 1, + card_trigger_percent: DEFAULT_QUOTA_TRIGGER_PERCENT, + card_skip_before_reset_minutes: DEFAULT_CARD_SKIP_BEFORE_RESET_MINUTES, + quota_levels: QuotaLevels::default(), + } + } +} + +impl Config { + /// 判定参数视图,供 `decide()` 使用。 + pub fn decision_cfg(&self) -> crate::decision::DecisionCfg { + crate::decision::DecisionCfg { + card_trigger_percent: self.card_trigger_percent, + tight: self.quota_levels.tight, + low: self.quota_levels.low, + card_skip_before_reset_minutes: self.card_skip_before_reset_minutes, + } + } +} + +fn clamp(v: f64, lo: f64, hi: f64, fallback: f64) -> f64 { + if !v.is_finite() { + return fallback; + } + v.max(lo).min(hi) +} + +/// 逐字段校验并夹紧,返回 (配置, 问题列表)。 +pub fn normalize(mut raw: Config) -> (Config, Vec) { + let mut issues = Vec::new(); + let d = Config::default(); + + let card = clamp(raw.card_trigger_percent, 1.0, 100.0, d.card_trigger_percent); + if card != raw.card_trigger_percent { + issues.push(format!( + "card_trigger_percent={} 超出 1..100,已夹到 {}", + raw.card_trigger_percent, card + )); + } + raw.card_trigger_percent = card; + + // 跳过阈值:0 表示关闭该保护;负数没有意义,按 0 夹。 + let skip = clamp(raw.card_skip_before_reset_minutes, 0.0, 24.0 * 60.0, d.card_skip_before_reset_minutes); + if skip != raw.card_skip_before_reset_minutes { + issues.push(format!( + "card_skip_before_reset_minutes={} 超出 0..1440,已夹到 {skip}", + raw.card_skip_before_reset_minutes + )); + } + raw.card_skip_before_reset_minutes = skip; + + let mut tight = clamp(raw.quota_levels.tight, 1.0, 100.0, d.quota_levels.tight); + let mut low = clamp(raw.quota_levels.low, 1.0, 100.0, d.quota_levels.low); + // 分界点必须 0 < tight < low <= 100;非法组合整体回退默认,而不是悄悄调序。 + if !(tight < low) { + issues.push(format!( + "quota_levels 需满足 tight < low(收到 tight={} low={}),已整体回退默认", + tight, low + )); + tight = d.quota_levels.tight; + low = d.quota_levels.low; + } else if tight != raw.quota_levels.tight || low != raw.quota_levels.low { + issues.push("quota_levels 越界,已夹紧".to_string()); + } + raw.quota_levels.tight = tight; + raw.quota_levels.low = low; + + (raw, issues) +} + +pub fn default_path() -> Option { + let home = std::env::var("HOME").ok()?; + Some(PathBuf::from(home).join(".config/mcode-reset-card/config.json")) +} + +/// 读配置。文件不存在是正常情况(首次运行),返回默认值且不记 issue。 +pub fn load(path: &PathBuf) -> (Config, Vec) { + let text = match fs::read_to_string(path) { + Ok(t) => t, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return (Config::default(), Vec::new()), + Err(e) => return (Config::default(), vec![format!("配置读取失败({}),正在用默认值", e)]), + }; + match serde_json::from_str::(&text) { + Ok(c) => normalize(c), + Err(e) => (Config::default(), vec![format!("配置解析失败({}),正在用默认值", e)]), + } +} + +/// 每轮重读,实现「改配置无需重启守护进程」。 +pub fn load_or_default() -> (Config, Vec) { + match default_path() { + Some(p) => load(&p), + None => ( + Config::default(), + vec!["找不到 $HOME,配置不可读,正在用默认值".to_string()], + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_are_the_documented_ones() { + let c = Config::default(); + assert_eq!(c.card_trigger_percent, 99.0); + assert_eq!(c.quota_levels.tight, 80.0); + assert_eq!(c.quota_levels.low, 95.0); + } + + #[test] + fn out_of_range_is_clamped_and_reported() { + let (c, issues) = normalize(Config { + card_trigger_percent: 150.0, + ..Default::default() + }); + assert_eq!(c.card_trigger_percent, 100.0); + assert!(!issues.is_empty(), "越界必须报出来,不能静默"); + } + + #[test] + fn inverted_bounds_fall_back_whole() { + let (c, issues) = normalize(Config { + quota_levels: QuotaLevels { tight: 96.0, low: 95.0 }, + ..Default::default() + }); + assert_eq!(c.quota_levels.tight, 80.0, "非法组合应整体回退默认"); + assert_eq!(c.quota_levels.low, 95.0); + assert!(issues.iter().any(|s| s.contains("tight < low"))); + } + + #[test] + fn camel_case_keys_are_actually_read() { + // 回归测试:Node / Python 写出的就是这套驼峰键。 + // 一旦有人删掉 rename_all,下面这条会安静地读成 enabled=false 而测试仍可能通过, + // 所以这里直接断言「解析成功且 enabled 为真」,不给它蒙混过关的机会。 + let (c, issues) = load(&PathBuf::from( + std::env::temp_dir().join("cfg-camel-probe.json"), + )); + // 文件不存在 → 默认值,这条只用来确认默认路径本身没坏 + assert_eq!(c.card_trigger_percent, 99.0); + assert!(issues.is_empty()); + + let camel = serde_json::json!({ + "schemaVersion": 1, + "cardTriggerPercent": 99, + "quotaLevels": { "tight": 80, "low": 95 } + }); + let parsed: Config = serde_json::from_value(camel.clone()) + .expect("驼峰键必须能被解析(缺 rename_all 时会在这里炸)"); + assert_eq!(parsed.card_trigger_percent, 99.0); + assert_eq!(parsed.quota_levels.tight, 80.0); + + // 反面:snake_case 现在应当被明确拒绝,而不是静默降级 + let snake = serde_json::json!({ + "schema_version": 1, + "goal_pause": { "enabled": true, "trigger_percent": 50 } + }); + assert!( + serde_json::from_value::(snake).is_err(), + "snake_case 必须报错(deny_unknown_fields),绝不能静默降级" + ); + } + + #[test] + fn valid_bounds_are_kept() { + let (c, issues) = normalize(Config { + quota_levels: QuotaLevels { tight: 70.0, low: 90.0 }, + ..Default::default() + }); + assert_eq!(c.quota_levels.tight, 70.0); + assert_eq!(c.quota_levels.low, 90.0); + assert!(issues.is_empty()); + } +} diff --git a/plugins/hylouis233/expiry-guardian/daemon/rust/src/decision.rs b/plugins/hylouis233/expiry-guardian/daemon/rust/src/decision.rs new file mode 100644 index 0000000..1ad265f --- /dev/null +++ b/plugins/hylouis233/expiry-guardian/daemon/rust/src/decision.rs @@ -0,0 +1,746 @@ +//! 额度与到期判定。 +//! +//! 这些分支与 Mini App「蹬王之王」的 `decide(, true)` 一一对应, +//! 由 `verify-parity.mjs` 逐字比对「动作 + 理由文本」来防止漂移。 +//! **改动任何一条理由文案,两侧都要同步改,并重跑校验器。** + +use serde::Deserialize; + +pub const QUOTA_FIVE_HOUR: i64 = 1; +pub const QUOTA_WEEKLY: i64 = 2; +pub const QUOTA_VIDEO: i64 = 3; + +/// 默认触发阈值 99%。实际生效值来自 config.json,这里只作兜底。 +/// 与 Node / Python 保持一致;改动必须同步三处。 +pub const DEFAULT_QUOTA_TRIGGER_PERCENT: f64 = 99.0; +/// 默认「额度紧张」分界点(黄)。 +pub const DEFAULT_QUOTA_TIGHT_PERCENT: f64 = 80.0; +/// 默认「额度不足」分界点(红)。 +pub const DEFAULT_QUOTA_LOW_PERCENT: f64 = 95.0; +/// 默认「距刷新不足这么多分钟就不烧重置卡」。实际生效值来自 config.json。 +pub const DEFAULT_CARD_SKIP_BEFORE_RESET_MINUTES: f64 = 60.0; + +/// 额度分档。与 Node 的 `quotaLevel()`、Python 的 `_quota_level()` 逐字同构。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Level { + Comfortable, + Tight, + Low, + Burned, +} + +impl Level { + /// 中文文案。**属于 parity 契约的一部分**,改一个字三方都要改。 + pub fn label(&self) -> &'static str { + match self { + Level::Comfortable => "额度充足", + Level::Tight => "额度紧张", + Level::Low => "额度不足", + Level::Burned => "额度燃尽", + } + } + + pub fn as_str(&self) -> &'static str { + match self { + Level::Comfortable => "comfortable", + Level::Tight => "tight", + Level::Low => "low", + Level::Burned => "burned", + } + } +} + +/// 额度分档:comfortable / tight / low / burned。 +/// +/// **burned 必须先判**:若先判 low,`used=100` 会命中 `ratio >= low` +/// 被算成「不足」,燃尽这一档就永远显示不出来。 +/// +/// 燃尽只用 `used >= total` 判定,**不读 exhausted**: +/// Python 的 exhausted 带 -1e-9 容差而本文件没有, +/// 若依赖它会在极限值上产生三方不一致。 +pub fn quota_level( + used: f64, + total: f64, + unlimited: bool, + tight: f64, + low: f64, +) -> Level { + if unlimited || total <= 0.0 { + return Level::Comfortable; + } + if used >= total { + return Level::Burned; + } + let ratio = used / total * 100.0; + if ratio >= low { + return Level::Low; + } + if ratio >= tight { + return Level::Tight; + } + Level::Comfortable +} + +/// 一个限额维度的用量。 +#[derive(Debug, Clone, Copy)] +pub struct QuotaState { + pub quota_type: i64, + pub total: f64, + pub used: f64, + pub is_unlimited: bool, + /// 该维度额度的刷新时间(毫秒时间戳)。 + /// + /// 有了它才能判断「额度马上就要自己刷新了」—— + /// 那种时候再烧一张重置卡就是纯浪费。 + pub reset_time_ms: Option, +} + +impl QuotaState { + /// 是否已耗尽。不限量或总量为 0 都不算耗尽。 + pub fn exhausted(&self) -> bool { + if self.is_unlimited || self.total <= 0.0 { + return false; + } + self.used >= self.total + } + + /// 决策触发线:额度用到 `trigger_percent` 即视为「该动手了」。 + /// + /// 不等真正耗尽 —— 等耗尽时那次请求已经失败,会打断当前目标 + /// 正在跑的自动执行。提前介入代价几乎为零。 + pub fn triggered(&self, trigger_percent: f64) -> bool { + if self.is_unlimited || self.total <= 0.0 { + return false; + } + self.used >= self.total * trigger_percent / 100.0 + } + + /// 展示用分档。与 `triggered()` 相互独立。 + pub fn level(&self, tight: f64, low: f64) -> Level { + quota_level(self.used, self.total, self.is_unlimited, tight, low) + } + + pub fn remaining_percent(&self) -> f64 { + if self.is_unlimited || self.total <= 0.0 { + return 100.0; + } + ((self.total - self.used) / self.total * 100.0).max(0.0) + } + + pub fn label(&self) -> &'static str { + match self.quota_type { + QUOTA_FIVE_HOUR => "5小时限额", + QUOTA_WEEKLY => "周限额", + QUOTA_VIDEO => "视频限额", + _ => "未知限额", + } + } + + pub fn describe(&self) -> String { + if self.is_unlimited { + return format!("{}=不限量", self.label()); + } + format!( + "{}=已用{:.0}% / 剩余{:.0}%", + self.label(), + self.used, + self.remaining_percent() + ) + } +} + +/// 金额字符串(可能带千分位)转数字。 +pub fn amount(value: &serde_json::Value) -> f64 { + match value { + serde_json::Value::Number(n) => n.as_f64().unwrap_or(0.0), + serde_json::Value::String(s) => { + let cleaned: String = s.chars().filter(|c| *c != ',').collect(); + cleaned.trim().parse::().unwrap_or(0.0) + } + _ => 0.0, + } +} + +/// 百分数字符串(如 "71%")转数字。 +/// +/// 接口返回的就是 `"71%"` 这种带百分号的字符串,直接 `parse::()` 会失败并 +/// 静默退回 0,把「已用 71%」误判成「已用 0%」。这里必须先剥掉 `%`。 +pub fn percent(value: &serde_json::Value) -> f64 { + match value { + serde_json::Value::Number(n) => n.as_f64().unwrap_or(0.0), + serde_json::Value::String(s) => s.trim().trim_end_matches('%').parse::().unwrap_or(0.0), + _ => 0.0, + } +} + +#[derive(Deserialize)] +pub struct ModelRemains { + pub model_name: String, + #[serde(default)] + pub current_interval_total_percent: serde_json::Value, + #[serde(default)] + pub current_interval_used_percent: serde_json::Value, + #[serde(default)] + pub current_interval_status: i64, + #[serde(default)] + pub current_interval_total_count: f64, + #[serde(default)] + pub current_interval_usage_count: f64, + #[serde(default)] + pub current_weekly_total_percent: serde_json::Value, + #[serde(default)] + pub current_weekly_used_percent: serde_json::Value, + #[serde(default)] + pub current_weekly_status: i64, + #[serde(default)] + pub end_time: Option, + /// 周限额的刷新时间。与 Python 的 `weekly_end_time` 同一字段名。 + #[serde(default)] + pub weekly_end_time: Option, +} + +#[derive(Deserialize)] +pub struct RemainsPayload { + #[serde(default)] + pub model_remains: Vec, +} + +pub fn parse_quotas(payload: &RemainsPayload) -> Vec { + let mut out = Vec::with_capacity(3); + + let general = payload + .model_remains + .iter() + .find(|m| m.model_name == "general") + .or_else(|| payload.model_remains.first()); + + if let Some(g) = general { + let total = { + let t = percent(&g.current_interval_total_percent); + if t > 0.0 { t } else { 100.0 } + }; + out.push(QuotaState { + quota_type: QUOTA_FIVE_HOUR, + total, + used: percent(&g.current_interval_used_percent), + is_unlimited: g.current_interval_status == 3, + reset_time_ms: g.end_time, + }); + out.push(QuotaState { + quota_type: QUOTA_WEEKLY, + total: { + let t = percent(&g.current_weekly_total_percent); + if t > 0.0 { t } else { 100.0 } + }, + used: percent(&g.current_weekly_used_percent), + is_unlimited: g.current_weekly_status == 3, + reset_time_ms: g.weekly_end_time, + }); + } + + // 视频额度:只有非 general 且计数 > 0 的模型 + if let Some(v) = payload + .model_remains + .iter() + .find(|m| m.model_name != "general" && m.current_interval_total_count > 0.0) + { + out.push(QuotaState { + quota_type: QUOTA_VIDEO, + total: v.current_interval_total_count, + used: v.current_interval_usage_count, + is_unlimited: v.current_interval_status == 3, + reset_time_ms: v.end_time, + }); + } + + out +} + +/// 一张可用于扣卡的重置卡。 +#[derive(Debug, Clone)] +pub struct UsableCard { + pub card_id: String, + pub source_type: String, + pub out_biz_no: String, + pub expire_time: i64, +} + +/// 扣卡决策的动作。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Action { + /// 用重置卡重置额度 + UseCard, + /// 不用卡,让积分先消耗 + KeepCredit, + /// 额度充足,不动手 + Idle, + /// 既没积分也没卡,等着 + Wait, +} + +impl Action { + pub fn as_str(&self) -> &'static str { + match self { + Action::UseCard => "use-card", + Action::KeepCredit => "keep-credit", + Action::Idle => "idle", + Action::Wait => "wait", + } + } +} + +pub struct Decision { + pub action: Action, + pub reason: String, +} + +/// `decide(, true)` 需要的判定参数。默认值对应 config.json 缺失时的兜底。 +#[derive(Debug, Clone, Copy)] +pub struct DecisionCfg { + pub card_trigger_percent: f64, + pub tight: f64, + pub low: f64, + /// 距 5 小时额度刷新还剩不到这么多分钟时,**不消耗重置卡**。 + /// + /// 额度马上就要自己刷新了,这时候烧一张重置卡纯属浪费。 + /// 设为 0 表示不做这个保护。 + pub card_skip_before_reset_minutes: f64, +} + +impl Default for DecisionCfg { + fn default() -> Self { + DecisionCfg { + card_trigger_percent: DEFAULT_QUOTA_TRIGGER_PERCENT, + tight: DEFAULT_QUOTA_TIGHT_PERCENT, + low: DEFAULT_QUOTA_LOW_PERCENT, + card_skip_before_reset_minutes: DEFAULT_CARD_SKIP_BEFORE_RESET_MINUTES, + } + } +} + +/// 毫秒时间戳 → "10月25日"。与 Python 的 `_fmt_day` 同一时区口径。 +pub fn fmt_day(ms: Option) -> String { + let Some(ms) = ms else { + return "未知".to_string(); + }; + if ms == 0 { + return "未知".to_string(); + } + // 本地时区;固定 UTC 会导致跨日错位,必须借 localtime_r + let (m, d) = local_md(ms.div_euclid(1000)); + if m == 0 { + return "未知".to_string(); + } + format!("{m}月{d}日") +} + +/// 把 Unix 秒换算成本地时区的 (月, 日)。 +/// +/// 桌面端与 Python 版都用本地时区展示;这里手算避免引入 chrono 依赖。 +/// libc 的 `struct tm`,只需本地时区换算用到的字段。 +#[repr(C)] +struct Tm { + tm_sec: i32, tm_min: i32, tm_hour: i32, tm_mday: i32, + tm_mon: i32, tm_year: i32, tm_wday: i32, tm_yday: i32, tm_isdst: i32, + tm_gmtoff: i64, tm_zone: *const u8, +} + +// ⚠️ 手写而不是 `#[derive(Default)]`(2026-10-21 维护者 review): +// `*const u8` 的 `Default` 实现并非所有 Rust 工具链都有 +// (老版本 cargo 直接报 E0277 ⇒ 整个包编译不过)。 +// ⇒ 自己给一个全零的 tm:语义与 derive 一致,且不依赖工具链版本。 +impl Default for Tm { + fn default() -> Self { + Tm { + tm_sec: 0, tm_min: 0, tm_hour: 0, tm_mday: 0, + tm_mon: 0, tm_year: 0, tm_wday: 0, tm_yday: 0, tm_isdst: 0, + tm_gmtoff: 0, tm_zone: std::ptr::null(), + } + } +} + +#[repr(C)] +struct TimeT(i64); + +unsafe extern "C" { + fn localtime_r(timep: *const TimeT, result: *mut Tm) -> *mut Tm; +} + +/// 把 Unix 秒换算成本地时区结构。 +fn secs_to_local(secs: i64) -> Option { + let t = TimeT(secs); + let mut tm = Tm::default(); + unsafe { + if localtime_r(&t, &mut tm).is_null() { + return None; + } + } + Some(tm) +} + +/// 本地时区的 (月, 日),供日志使用。 +pub fn local_md(secs: i64) -> (u32, u32) { + secs_to_local(secs) + .map(|t| (t.tm_mon as u32 + 1, t.tm_mday as u32)) + .unwrap_or((0, 0)) +} + +/// 本地时区的 (时, 分)。 +pub fn local_hm(secs: i64) -> (u32, u32) { + secs_to_local(secs) + .map(|t| (t.tm_hour as u32, t.tm_min as u32)) + .unwrap_or((0, 0)) +} + +/// 5 小时额度距离刷新还有多少分钟。取不到刷新时间时返回 `None`。 +/// +/// 负数表示刷新时间已经过去了(接口数据没刷新),按 0 处理。 +pub fn minutes_to_reset(q: Option<&QuotaState>, now_ms: i64) -> Option { + let reset = q?.reset_time_ms?; + Some(((reset - now_ms) as f64 / 60_000.0).max(0.0)) +} + +/// 后台扣卡决策(纯函数,不触碰网络)。 +/// +/// 动作 ∈ {use-card, keep-credit, idle, wait}。 +/// +/// 分支与 Mini App 的 `decide(, true)` 刻意同构: +/// 未耗尽 → idle +/// 耗尽 + 无积分 + 有卡 → use-card +/// 耗尽 + 无积分 + 无卡 → wait +/// 耗尽 + 有积分 + 无卡 → keep-credit +/// 耗尽 + 都有,卡先过期 → use-card +/// 耗尽 + 都有,积分先过期 → keep-credit +/// +/// `credit_data_complete` 表示「积分数据是否**完整读到**」。 +/// +/// ⚠️ 2026-10-19 PR #28 review 修:读不到 ≠ 没有。 +/// `credit_expiry_ms = None` 同时可能表示「确实没有积分」与「接口读失败」, +/// 以前两者都落到 `use-card` 分支 —— 而扣卡是**真实写操作**, +/// 于是网络故障会被当成业务状态,可能误扣真卡。 +/// ⇒ 数据不完整时一律**不做写操作**(`Action::Wait`),只如实报出来。 +pub fn decide( + quotas: &[QuotaState], + usable_cards: &[UsableCard], + credit_expiry_ms: Option, + credit_desc: &str, + cfg: &DecisionCfg, + now_ms: i64, + credit_data_complete: bool, +) -> Decision { + let five = quotas.iter().find(|q| q.quota_type == QUOTA_FIVE_HOUR); + let weekly = quotas.iter().find(|q| q.quota_type == QUOTA_WEEKLY); + + let five_triggered = five.is_some_and(|q| q.triggered(cfg.card_trigger_percent)); + let weekly_triggered = weekly.is_some_and(|q| q.triggered(cfg.card_trigger_percent)); + + // ── 额度即将自己刷新 → 不烧重置卡 ── + // 距刷新只剩几分钟的时候再扣一张重置卡是纯浪费:额度马上就自己回来了。 + // 这个保护要在所有「用卡」分支之前判定。 + let reset_in_min = minutes_to_reset(five, now_ms); + let skip_card_reason = if cfg.card_skip_before_reset_minutes > 0.0 { + reset_in_min.filter(|m| *m <= cfg.card_skip_before_reset_minutes).map(|m| { + format!("5 小时额度 {:.0} 分钟后刷新,暂不消耗重置卡", m) + }) + } else { + None + }; + + // ── 未到触发线 ── + if !five_triggered && !weekly_triggered { + return Decision { + action: Action::Idle, + // 等级词随分档走,而不是永远写「额度充足」—— + // 否则用量 92% 时页面上仍显示「额度充足」,是误导。 + // 把触发线也说进来:否则「额度不足但暂不需要处理」看起来自相矛盾。 + // + // burned 永远不会出现在这里:它必然已 triggered,走的是下面用卡/留卡分支。 + reason: format!( + "{}(5 小时已用 {:.0}%),未到 {:.0}% 触发线,暂不动用重置卡", + five + .map(|q| q.level(cfg.tight, cfg.low).label()) + .unwrap_or(Level::Comfortable.label()), + five.map(|q| q.used).unwrap_or(0.0), + cfg.card_trigger_percent + ), + }; + } + + // ── 积分数据没读全 ⇒ 无法判断「卡先过期还是积分先过期」── + // ⇒ 绝不做写操作(扣卡是真实消耗,宁可等下一轮)。 + if !credit_data_complete { + return Decision { + action: Action::Wait, + reason: "积分数据读取不完整,本轮不执行写操作(等下一轮重新判定)".to_string(), + }; + } + + // ── 无可用积分:只能靠卡 ── + let Some(credit_expiry) = credit_expiry_ms else { + if !usable_cards.is_empty() { + // 额度马上要自己刷新了,就别烧卡了 + if let Some(reason) = &skip_card_reason { + return Decision { + action: Action::Wait, + reason: reason.clone(), + }; + } + return Decision { + action: Action::UseCard, + reason: "没有可用积分,使用重置卡重置额度".to_string(), + }; + } + return Decision { + action: Action::Wait, + reason: "额度已耗尽,既没有积分也没有可用的重置卡".to_string(), + }; + }; + // ── 有积分但没卡:让积分先消耗 ── + // 注意这一句带「到期」二字,与 Mini App / Python 的文案保持逐字一致。 + // 改文案前务必跑 `node verify-parity.mjs`。 + if usable_cards.is_empty() { + return Decision { + action: Action::KeepCredit, + reason: format!( + "没有可用的重置卡,让{credit_desc}({}到期)先消耗", + fmt_day(Some(credit_expiry)) + ), + }; + } + + let card_expiry = usable_cards[0].expire_time; + + // ── 两者都有:比「先过期先用」 ── + if card_expiry < credit_expiry { + // 额度马上要自己刷新了,就别烧卡了 + if let Some(reason) = &skip_card_reason { + return Decision { + action: Action::Wait, + reason: reason.clone(), + }; + } + return Decision { + action: Action::UseCard, + reason: format!( + "重置卡({})比{credit_desc}({})先过期,先用卡保住积分", + fmt_day(Some(card_expiry)), + fmt_day(Some(credit_expiry)) + ), + }; + } + Decision { + action: Action::KeepCredit, + reason: format!( + "{credit_desc}({})比重置卡({})先过期,不用卡,让积分先消耗", + fmt_day(Some(credit_expiry)), + fmt_day(Some(card_expiry)) + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn q(t: i64, total: f64, used: f64, unlimited: bool) -> QuotaState { + QuotaState { quota_type: t, total, used, is_unlimited: unlimited, reset_time_ms: None } + } + + /// 判定用的固定「当前时间」。所有与刷新时间相关的用例都以它为基准。 + const NOW: i64 = 1_700_000_000_000; + + fn c(days: i64) -> UsableCard { + UsableCard { + card_id: "C1".into(), + source_type: "mcode_signin".into(), + out_biz_no: "b1".into(), + expire_time: 1_700_000_000_000 + days * 86_400_000, + } + } + + #[test] + fn percent_strings() { + assert_eq!(percent(&serde_json::json!("71%")), 71.0); + assert_eq!(percent(&serde_json::json!("100%")), 100.0); + assert_eq!(percent(&serde_json::json!(0)), 0.0); + assert_eq!(percent(&serde_json::json!("")), 0.0); + } + + #[test] + fn amount_strings() { + assert_eq!(amount(&serde_json::json!("17400.00")), 17400.0); + assert_eq!(amount(&serde_json::json!("1,200.50")), 1200.5); + } + + #[test] + fn skip_card_when_reset_is_imminent() { + // 额度已经到触发线,本来会走「用卡」分支。 + let mut quotas = vec![q(1, 100.0, 99.0, false), q(2, 100.0, 0.0, true)]; + let credit = Some(NOW + 13 * 86_400_000); + let cfg = DecisionCfg::default(); // 默认跳过窗口 60 分钟 + + // 刷新在 2 小时后 → 照常用卡 + quotas[0].reset_time_ms = Some(NOW + 120 * 60_000); + assert_eq!(decide("as, &[c(7)], credit, "平台积分", &cfg, NOW, true).action, Action::UseCard); + + // 刷新在 10 分钟后 → 不烧卡,等它自己刷新 + quotas[0].reset_time_ms = Some(NOW + 10 * 60_000); + let d = decide("as, &[c(7)], credit, "平台积分", &cfg, NOW, true); + assert_eq!(d.action, Action::Wait, "额度马上要刷新,不该烧重置卡"); + assert!(d.reason.contains("分钟后刷新"), "理由要说清为什么不动: {}", d.reason); + + // 窗口设为 0 → 关闭该保护,照常烧卡 + let no_skip = DecisionCfg { card_skip_before_reset_minutes: 0.0, ..Default::default() }; + assert_eq!(decide("as, &[c(7)], credit, "平台积分", &no_skip, NOW, true).action, Action::UseCard); + + // 窗口放大到 180 分钟 → 10 分钟后刷新也跳过 + let wide = DecisionCfg { card_skip_before_reset_minutes: 180.0, ..Default::default() }; + assert_eq!(decide("as, &[c(7)], credit, "平台积分", &wide, NOW, true).action, Action::Wait); + } + + #[test] + fn minutes_to_reset_is_optional_and_clamped() { + assert_eq!(minutes_to_reset(None, NOW), None); + assert_eq!(minutes_to_reset(Some(&q(1, 100.0, 50.0, false)), NOW), None, "没有刷新时间就无从判断"); + let mut x = q(1, 100.0, 50.0, false); + x.reset_time_ms = Some(NOW + 30 * 60_000); + assert_eq!(minutes_to_reset(Some(&x), NOW), Some(30.0)); + x.reset_time_ms = Some(NOW - 60_000); + assert_eq!(minutes_to_reset(Some(&x), NOW), Some(0.0), "已过去的时间按 0 算"); + } + + #[test] + fn exhausted_rules() { + assert!(!q(1, 100.0, 99.0, false).exhausted()); + assert!(q(1, 100.0, 100.0, false).exhausted()); + assert!(!q(1, 100.0, 100.0, true).exhausted(), "不限量不算耗尽"); + assert!(!q(1, 0.0, 0.0, false).exhausted(), "总量为 0 不算耗尽"); + } + + #[test] + fn decide_branches() { + let cfg = DecisionCfg::default(); + let five_ok = vec![q(1, 100.0, 50.0, false), q(2, 100.0, 0.0, true)]; + let five_dead = vec![q(1, 100.0, 100.0, false), q(2, 100.0, 0.0, true)]; + let week_dead = vec![q(1, 100.0, 50.0, false), q(2, 100.0, 100.0, false)]; + let credit = Some(1_700_000_000_000 + 13 * 86_400_000); + + assert_eq!(decide(&five_ok, &[c(7)], credit, "平台积分", &cfg, NOW, true).action, Action::Idle); + assert_eq!(decide(&five_dead, &[c(7)], credit, "平台积分", &cfg, NOW, true).action, Action::UseCard); + assert_eq!(decide(&five_dead, &[], credit, "平台积分", &cfg, NOW, true).action, Action::KeepCredit); + assert_eq!(decide(&five_dead, &[], None, "", &cfg, NOW, true).action, Action::Wait); + assert_eq!(decide(&five_dead, &[c(7)], None, "", &cfg, NOW, true).action, Action::UseCard); + assert_eq!(decide(&week_dead, &[c(7)], credit, "平台积分", &cfg, NOW, true).action, Action::UseCard); + // 积分更早过期 → 不用卡 + let credit_soon = Some(1_700_000_000_000 + 2 * 86_400_000); + assert_eq!( + decide(&five_dead, &[c(20)], credit_soon, "平台积分", &cfg, NOW, true).action, + Action::KeepCredit + ); + } + + #[test] + fn threshold_is_configurable() { + // 同一份输入,换阈值就换结论 —— 这正是功能1的可配置性。 + // 积分早于卡过期,所以一旦越过触发线,结论必然是 keep-credit(不用卡)。 + let quotas = vec![q(1, 100.0, 95.0, false), q(2, 100.0, 0.0, true)]; + let credit_soon = Some(1_700_000_000_000); + let strict = DecisionCfg { card_trigger_percent: 99.0, ..Default::default() }; + let loose = DecisionCfg { card_trigger_percent: 90.0, ..Default::default() }; + assert_eq!( + decide("as, &[c(7)], credit_soon, "平台积分", &strict, NOW, true).action, + Action::Idle, + "95% 未到 99% 触发线,应保持 idle" + ); + assert_eq!( + decide("as, &[c(7)], credit_soon, "平台积分", &loose, NOW, true).action, + Action::KeepCredit, + "阈值放宽到 90% 后应越过触发线,进入先过期先用判定" + ); + } + + #[test] + fn quota_level_boundaries() { + let l = quota_level; + assert_eq!(l(79.0, 100.0, false, 80.0, 95.0), Level::Comfortable); + assert_eq!(l(80.0, 100.0, false, 80.0, 95.0), Level::Tight); + assert_eq!(l(94.9, 100.0, false, 80.0, 95.0), Level::Tight); + assert_eq!(l(95.0, 100.0, false, 80.0, 95.0), Level::Low); + assert_eq!(l(99.0, 100.0, false, 80.0, 95.0), Level::Low); + // 燃尽必须先判,不能被 low 抢先命中 + assert_eq!(l(100.0, 100.0, false, 80.0, 95.0), Level::Burned); + assert_eq!(l(120.0, 100.0, false, 80.0, 95.0), Level::Burned); + // 不限量 / 总量为 0 → 恒为充足 + assert_eq!(l(99.0, 100.0, true, 80.0, 95.0), Level::Comfortable); + assert_eq!(l(0.0, 0.0, false, 80.0, 95.0), Level::Comfortable); + // 分界点可配 + assert_eq!(l(70.0, 100.0, false, 60.0, 90.0), Level::Tight); + } + + #[test] + fn idle_reason_follows_level() { + let cfg = DecisionCfg::default(); + // 92% 不能再写「额度充足」——那是误导 + let d92 = decide(&vec![q(1, 100.0, 92.0, false), q(2, 100.0, 0.0, true)], &[], Some(1), "平台积分", &cfg, NOW, true); + assert_eq!(d92.action, Action::Idle); + assert!(d92.reason.starts_with("额度紧张"), "{}", d92.reason); + assert!(d92.reason.contains("92%"), "{}", d92.reason); + + let d12 = decide(&vec![q(1, 100.0, 12.0, false), q(2, 100.0, 0.0, true)], &[], Some(1), "平台积分", &cfg, NOW, true); + assert!(d12.reason.starts_with("额度充足"), "{}", d12.reason); + + let d97 = decide(&vec![q(1, 100.0, 97.0, false), q(2, 100.0, 0.0, true)], &[], Some(1), "平台积分", &cfg, NOW, true); + assert!(d97.reason.starts_with("额度不足"), "{}", d97.reason); + } + + #[test] + fn burned_never_appears_in_idle_reason() { + // burned 必然已 triggered,走用卡/留卡分支,不该出现在 idle 理由里 + let cfg = DecisionCfg { card_trigger_percent: 100.0, ..Default::default() }; + let d = decide(&vec![q(1, 100.0, 100.0, false), q(2, 100.0, 0.0, true)], &[c(7)], Some(1_700_000_000_000), "平台积分", &cfg, NOW, true); + assert_ne!(d.action, Action::Idle); + assert!(!d.reason.contains("额度燃尽"), "{}", d.reason); + } + + #[test] + fn reasons_are_filled() { + let cfg = DecisionCfg::default(); + let five_dead = vec![q(1, 100.0, 100.0, false), q(2, 100.0, 0.0, true)]; + let d = decide(&five_dead, &[], Some(1_700_000_000_000 + 13 * 86_400_000), "平台积分", &cfg, NOW, true); + assert!(d.reason.contains("没有可用的重置卡"), "{}", d.reason); + assert!(d.reason.contains("平台积分"), "{}", d.reason); + } + + /// 回归测试:积分数据读取失败时**绝不扣卡**。 + /// + /// ⚠️ 2026-10-19 PR #28 review 修:读不到 ≠ 没有。 + /// `credit_expiry_ms = None` 同时可能表示「确实没有积分」与「接口读失败」, + /// 以前两者都落到 `use-card` —— 而扣卡是真实写操作, + /// 网络故障会被当成业务状态,可能误扣真卡。 + #[test] + fn incomplete_credit_data_never_spends_a_card() { + let cfg = DecisionCfg::default(); + // 额度已耗尽,有一张卡,但积分数据**没读全** + let quotas = vec![q(1, 100.0, 100.0, false)]; + let cards = vec![c(7)]; + let d = decide("as, &cards, None, "", &cfg, NOW, false); + assert_eq!( + d.action, + Action::Wait, + "积分数据不完整时必须 wait,不能用卡" + ); + assert!( + d.reason.contains("读取不完整"), + "理由要如实说明数据不完整: {}", + d.reason + ); + + // 对照:同样条件下,数据完整 ⇒ 确实没有积分 ⇒ 可以用卡 + let d2 = decide("as, &cards, None, "", &cfg, NOW, true); + assert_eq!( + d2.action, + Action::UseCard, + "数据完整且确实无积分时,用卡是正确的" + ); + } +} diff --git a/plugins/hylouis233/expiry-guardian/daemon/rust/src/ledger.rs b/plugins/hylouis233/expiry-guardian/daemon/rust/src/ledger.rs new file mode 100644 index 0000000..cc5f3ca --- /dev/null +++ b/plugins/hylouis233/expiry-guardian/daemon/rust/src/ledger.rs @@ -0,0 +1,269 @@ +//! 本地账本:额度、积分与重置卡的**本地**数据源。 +//! +//! # 为什么是「本地账本」而不是账号接口 +//! +//! 社区插件不能读取 MiniMax Code 的登录凭据(2026-10-20 管理员 review 要求), +//! 因此这个插件**不再**自己去调账号接口、也不再去读宿主内部数据库。 +//! +//! ⇒ 额度、积分、重置卡的到期信息改由**用户自己维护的一份本地账本**提供: +//! `~/.config/mcode-reset-card/ledger.json`。 +//! +//! # 账本长什么样 +//! +//! ```json +//! { +//! "schemaVersion": 1, +//! "updatedAtMs": 1792480000000, +//! "quota": { +//! "fiveHour": { "usedPercent": 3, "unlimited": false, "resetTimeMs": 1792497600000 }, +//! "weekly": { "usedPercent": 0, "unlimited": true, "resetTimeMs": 1792944000000 } +//! }, +//! "cards": [ +//! { "cardId": "card_1", "label": "重置卡 A", "sourceType": "code", +//! "outBizNo": "biz_1", "expireTimeMs": 1795000000000 } +//! ], +//! "credits": [ +//! { "label": "赠送", "source": "code", "granted": 400, "used": 0, +//! "balance": 400, "expireTimeMs": 1795017600000 } +//! ] +//! } +//! ``` +//! +//! # 关键性质:决策引擎的输入结构**一个字都没改** +//! +//! `decision::decide()` 的入参(`QuotaState` / `UsableCard` / `credit_expiry_ms` …) +//! 与之前从账号接口拿到的完全同构 ⇒ +//! 决策逻辑、三方一致性校验(Rust / Mini App / Python 逐字比对)**原样保留**。 +//! 变的只是「数据从哪来」。 + +use std::path::PathBuf; + +use crate::decision::{QuotaState, UsableCard, QUOTA_FIVE_HOUR, QUOTA_WEEKLY}; + +/// 账本文件的位置:`~/.config/mcode-reset-card/ledger.json`。 +pub fn ledger_path() -> Option { + std::env::var("HOME") + .ok() + .map(|h| PathBuf::from(h).join(".config/mcode-reset-card/ledger.json")) +} + +/// 一份账本读出来之后的样子。 +#[derive(Debug, Clone, Default)] +pub struct Ledger { + /// 额度(5 小时 / 周)。 + pub quotas: Vec, + /// 还能用的重置卡。 + pub usable_cards: Vec, + /// 最早到期的、还有余额的积分的到期时间。 + pub credit_expiry_ms: Option, + /// 那一笔积分的来源描述(给「理由文本」用)。 + pub credit_desc: String, + /// 积分数据是否**完整读到**。 + /// + /// ⚠️ 「读不到 ≠ 没有」:账本文件缺失或解析失败 ⇒ `false` + /// ⇒ 决策走 `wait`(绝不做写操作),而不是「没有积分 ⇒ 用卡」。 + pub credit_data_complete: bool, + /// 账本里记的时间戳(毫秒),0 = 未知。 + pub updated_at_ms: i64, +} + +/// 读账本。 +/// +/// 返回 `Err` 表示**读不到/解析不了**(文件不存在、JSON 坏了、字段不对)。 +/// ⇒ 调用方要**如实报出来**,不能当成「账本是空的」。 +pub fn read() -> Result { + let Some(path) = ledger_path() else { + return Err("取不到 $HOME,找不到账本路径".to_string()); + }; + let text = match std::fs::read_to_string(&path) { + Ok(t) => t, + Err(e) => { + return Err(format!( + "读不到账本 {}({e})", + path.display() + )) + } + }; + let v: serde_json::Value = serde_json::from_str(&text) + .map_err(|e| format!("账本 {} 解析失败({e})", path.display()))?; + parse(&v) +} + +/// 从已解析的 JSON 里取账本(纯函数,可直接单测)。 +pub fn parse(v: &serde_json::Value) -> Result { + let updated_at_ms = v.get("updatedAtMs").and_then(|x| x.as_i64()).unwrap_or(0); + + // ── 额度 ── + let mut quotas = Vec::new(); + if let Some(q) = v.get("quota") { + let pick = |key: &str| -> Option { + let o = q.get(key)?; + let used = o.get("usedPercent").and_then(|x| x.as_f64()).unwrap_or(0.0); + let unlimited = o.get("unlimited").and_then(|x| x.as_bool()).unwrap_or(false); + let total = o.get("totalPercent").and_then(|x| x.as_f64()).unwrap_or(100.0); + let reset = o.get("resetTimeMs").and_then(|x| x.as_i64()).filter(|v| *v > 0); + Some(QuotaState { + quota_type: if key == "fiveHour" { QUOTA_FIVE_HOUR } else { QUOTA_WEEKLY }, + total, + used, + is_unlimited: unlimited, + reset_time_ms: reset, + }) + }; + if let Some(s) = pick("fiveHour") { + quotas.push(s); + } + if let Some(s) = pick("weekly") { + quotas.push(s); + } + } + + // ── 重置卡 ── + // ⚠️ 「能用」= 有 out_biz_no + 有 source_type + 还没过期。 + // 这三个字段缺一不可(决策与执行都要用)。 + let now = crate::clock::now_ms(); + let mut usable_cards = Vec::new(); + if let Some(list) = v.get("cards").and_then(|x| x.as_array()) { + for c in list { + let expire_time = c.get("expireTimeMs").and_then(|x| x.as_i64()).unwrap_or(0); + let out_biz_no = c.get("outBizNo").and_then(|x| x.as_str()).unwrap_or(""); + let source_type = c.get("sourceType").and_then(|x| x.as_str()).unwrap_or(""); + if out_biz_no.is_empty() || source_type.is_empty() || expire_time <= now { + continue; + } + usable_cards.push(UsableCard { + card_id: c + .get("cardId") + .and_then(|x| x.as_str()) + .unwrap_or("") + .to_string(), + source_type: source_type.to_string(), + out_biz_no: out_biz_no.to_string(), + expire_time, + }); + } + } + + // ── 积分 ── + // ⚠️ `credits` 字段**必须存在**(哪怕是空数组)才算「数据完整」。 + // 字段缺失 ⇒ 读不到 ⇒ `credit_data_complete = false` ⇒ 决策走 wait。 + let mut credit_data_complete = false; + let mut credit_expiry_ms = None; + let mut credit_desc = "积分".to_string(); + if let Some(list) = v.get("credits").and_then(|x| x.as_array()) { + credit_data_complete = true; + let mut best: Option<(i64, String)> = None; + for c in list { + let balance = c.get("balance").and_then(|x| x.as_f64()).unwrap_or(0.0); + let expire = c.get("expireTimeMs").and_then(|x| x.as_i64()).unwrap_or(0); + if balance <= 0.0 || expire <= 0 { + continue; + } + let label = c + .get("label") + .and_then(|x| x.as_str()) + .unwrap_or("积分") + .to_string(); + match &best { + Some((e, _)) if *e <= expire => {} + _ => best = Some((expire, label)), + } + } + if let Some((e, label)) = best { + credit_expiry_ms = Some(e); + credit_desc = label; + } + } + + Ok(Ledger { + quotas, + usable_cards, + credit_expiry_ms, + credit_desc, + credit_data_complete, + updated_at_ms, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn v(s: &str) -> serde_json::Value { + serde_json::from_str(s).unwrap() + } + + #[test] + fn missing_credits_field_means_incomplete() { + // ⚠️「读不到 ≠ 没有」:credits 字段缺失 ⇒ 数据不完整 + // ⇒ 决策必须走 wait(绝不能当成「没有积分 ⇒ 用卡」)。 + let l = parse(&v(r#"{"quota":{"fiveHour":{"usedPercent":3}}}"#)).unwrap(); + assert!(!l.credit_data_complete); + assert_eq!(l.credit_expiry_ms, None); + } + + #[test] + fn empty_credits_array_is_complete() { + // 字段在、但是空数组 ⇒ 确实没有积分 ⇒ 数据是完整的。 + let l = parse(&v(r#"{"credits":[]}"#)).unwrap(); + assert!(l.credit_data_complete); + assert_eq!(l.credit_expiry_ms, None); + } + + #[test] + fn earliest_credit_wins() { + let l = parse(&v( + r#"{"credits":[ + {"label":"远","balance":100,"expireTimeMs":1795000000000}, + {"label":"近","balance":50,"expireTimeMs":1794000000000}, + {"label":"用光","balance":0,"expireTimeMs":1793000000000} + ]}"#, + )) + .unwrap(); + assert_eq!(l.credit_expiry_ms, Some(1794000000000)); + assert_eq!(l.credit_desc, "近"); + } + + #[test] + fn cards_without_out_biz_no_are_not_usable() { + let now = crate::clock::now_ms(); + let l = parse(&v(&format!( + r#"{{"cards":[ + {{"cardId":"a","outBizNo":"biz","sourceType":"code","expireTimeMs":{}}}, + {{"cardId":"b","sourceType":"code","expireTimeMs":{}}}, + {{"cardId":"c","outBizNo":"biz2","expireTimeMs":{}}} + ]}}"#, + now + 86_400_000, + now + 86_400_000, + now + 86_400_000 + ))) + .unwrap(); + assert_eq!(l.usable_cards.len(), 1, "缺 out_biz_no / source_type 的卡不能用"); + assert_eq!(l.usable_cards[0].card_id, "a"); + } + + #[test] + fn expired_cards_are_not_usable() { + let now = crate::clock::now_ms(); + let l = parse(&v(&format!( + r#"{{"cards":[{{"cardId":"old","outBizNo":"b","sourceType":"code","expireTimeMs":{}}}]}}"#, + now - 1000 + ))) + .unwrap(); + assert!(l.usable_cards.is_empty(), "已过期的卡不能用"); + } + + #[test] + fn quota_is_mapped_to_both_dimensions() { + let l = parse(&v( + r#"{"quota":{"fiveHour":{"usedPercent":3,"resetTimeMs":111},"weekly":{"unlimited":true}}}"#, + )) + .unwrap(); + assert_eq!(l.quotas.len(), 2); + let five = l.quotas.iter().find(|q| q.quota_type == QUOTA_FIVE_HOUR).unwrap(); + assert_eq!(five.used, 3.0); + assert_eq!(five.reset_time_ms, Some(111)); + let weekly = l.quotas.iter().find(|q| q.quota_type == QUOTA_WEEKLY).unwrap(); + assert!(weekly.is_unlimited); + } +} diff --git a/plugins/hylouis233/expiry-guardian/daemon/rust/src/main.rs b/plugins/hylouis233/expiry-guardian/daemon/rust/src/main.rs new file mode 100644 index 0000000..d7c6547 --- /dev/null +++ b/plugins/hylouis233/expiry-guardian/daemon/rust/src/main.rs @@ -0,0 +1,334 @@ +//! 后台守护进程:读**本地账本** → 跑决策 → 如实报出结论。 +//! +//! 与 Mini App「蹬王之王」的判定逻辑严格同构,由 `verify-parity.mjs` +//! 逐字比对「动作 + 理由文本」防止漂移。 +//! +//! # 这个守护进程现在做什么、不做什么 +//! +//! **做**:读 `~/.config/mcode-reset-card/ledger.json`(用户自己维护的账本), +//! 用与 Mini App 完全相同的决策逻辑算出「该用卡还是该留积分」, +//! 把结论写进日志与状态文件,供 Mini App 展示。 +//! +//! **不做**(2026-10-20 管理员 review 的红线): +//! - ❌ 不读 MiniMax Code 的登录凭据(不调账号接口) +//! - ❌ 不读写 MiniMax Code 的内部数据库 +//! - ❌ 不读其它插件的数据目录 +//! - ❌ **不做任何写操作**(不扣卡、不暂停目标)—— 只给结论,由人执行 +//! +//! ⇒ 扣卡这类**真实消耗**的动作必须由用户在面板上点,插件不代替用户决定。 + +mod clock; +mod config; +mod decision; +mod ledger; + +use std::time::Duration; + +use decision::{decide, Action}; +use serde_json::json; + +/// 把 Unix 毫秒格式化成「10月19日 19:41」这样的本地时间。 +fn fmt_reset_at(ms: i64) -> String { + if ms == 0 { + return "未知".to_string(); + } + let secs = ms.div_euclid(1000); + let (m, d) = decision::local_md(secs); + let (h, mi) = decision::local_hm(secs); + if m == 0 { + return "未知".to_string(); + } + format!("{m}月{d}日 {h:02}:{mi:02}") +} + +/// 日志:带本地时间戳,便于 tail 追踪。 +pub fn log(msg: &str) { + use std::io::Write; + let now = clock::now_ms(); + let secs = now.div_euclid(1000); + let ms = now.rem_euclid(1000); + let (m, d) = decision::local_md(secs); + let (h, mi) = decision::local_hm(secs); + let line = format!("[{m:02}-{d:02} {h:02}:{mi:02}.{ms:03}] {msg}\n"); + let mut out = std::io::stdout(); + let _ = out.write_all(line.as_bytes()); + let _ = out.flush(); +} + +/// 一轮判定的结果。 +struct Round { + action: Action, + reason: String, + /// 账本读不到时的原因(None = 读到了)。 + ledger_error: Option, +} + +/// 跑一轮:读账本 → 决策。 +fn run_round(cfg: &config::Config) -> Round { + let now = clock::now_ms(); + match ledger::read() { + Ok(l) => { + let d = decide( + &l.quotas, + &l.usable_cards, + l.credit_expiry_ms, + &l.credit_desc, + &cfg.decision_cfg(), + now, + l.credit_data_complete, + ); + Round { + action: d.action, + reason: d.reason, + ledger_error: None, + } + } + Err(e) => { + // ⚠️ 读不到账本 ⇒ **数据不完整** ⇒ 决策必须是 wait。 + // 绝不能当成「没有积分、没有卡」⇒ 那会把结论翻转成「用卡」。 + // (「读不到 ≠ 没有」是本项目反复踩过的坑。) + Round { + action: Action::Wait, + reason: format!("账本读不到,本轮不做任何判断({e})"), + ledger_error: Some(e), + } + } + } +} + +/// 把这一轮的结论写进状态文件(供 Mini App 读取)。 +/// +/// ⚠️ 只写**插件自己的**数据目录,不碰宿主的任何数据。 +fn write_status(round: &Round) { + let Some(home) = std::env::var("HOME").ok() else { return }; + let dir = std::path::PathBuf::from(home).join(".config/mcode-reset-card"); + let _ = std::fs::create_dir_all(&dir); + let body = json!({ + "updatedAtMs": clock::now_ms(), + "action": match round.action { + Action::Idle => "idle", + Action::UseCard => "use-card", + Action::KeepCredit => "keep-credit", + Action::Wait => "wait", + }, + "reason": round.reason, + "ledgerError": round.ledger_error, + }); + let path = dir.join("daemon-status.json"); + let tmp = path.with_extension(format!("json.{}.tmp", std::process::id())); + if std::fs::write(&tmp, format!("{}\n", serde_json::to_string_pretty(&body).unwrap_or_default())).is_ok() { + let _ = std::fs::rename(&tmp, &path); + } +} + +/// 自检:读 stdin 的 JSON 用例,跑一遍决策并把结果打印成 JSON。 +/// +/// ⚠️ 这个协议是**三方一致性校验器**(`verify-parity.mjs`)的接口: +/// Rust / Mini App / Python 三方读**同一份**用例,各自算出动作与理由, +/// 校验器再逐字比对。**改了输出格式,三方比对就失效了。** +fn selftest() { + use std::io::Read as _; + let mut input = String::new(); + if std::io::stdin().read_to_string(&mut input).is_err() { + eprintln!("selftest: 无法读取 stdin"); + std::process::exit(2); + } + let payload: serde_json::Value = match serde_json::from_str(&input) { + Ok(v) => v, + Err(e) => { + eprintln!("selftest: JSON 解析失败 {e}"); + std::process::exit(2); + } + }; + let now = payload["now"].as_i64().unwrap_or(0); + let day = payload["day"].as_i64().unwrap_or(86_400_000); + // 三方必须喂同一个阈值,否则比的不是同一件事 + let trigger = payload["trigger"].as_f64().unwrap_or(99.0); + let tight = payload["tight"].as_f64().unwrap_or(80.0); + let low = payload["low"].as_f64().unwrap_or(95.0); + let skip = payload["skipBeforeResetMinutes"].as_f64().unwrap_or(60.0); + let cfg = decision::DecisionCfg { + card_trigger_percent: trigger, + tight, + low, + card_skip_before_reset_minutes: skip, + }; + let mut out = Vec::new(); + + for c in payload["cases"].as_array().cloned().unwrap_or_default() { + let mk = |t: i64, used: f64, total: f64, unlimited: bool| decision::QuotaState { + quota_type: t, + total, + used, + is_unlimited: unlimited, + reset_time_ms: None, + }; + let five_used = c["five"].as_f64().unwrap_or(0.0); + let week_used = c["week"].as_i64(); + let five_reset = c["resetInMinutes"] + .as_f64() + .map(|m| now + (m * 60_000.0) as i64); + let mut quotas = vec![ + mk(1, five_used, 100.0, false), + match week_used { + None => mk(2, 0.0, 100.0, true), + Some(w) => mk(2, w as f64, 100.0, false), + }, + ]; + quotas[0].reset_time_ms = five_reset; + let cards: Vec = c["cards"] + .as_array() + .cloned() + .unwrap_or_default() + .iter() + .filter_map(|v| { + let d = v.as_i64()?; + Some(decision::UsableCard { + card_id: format!("C{d}"), + source_type: "mcode_signin".into(), + out_biz_no: format!("b{d}"), + expire_time: now + d * day, + }) + }) + .collect(); + let credit = c["credit"].as_i64().map(|d| now + d * day); + let complete = c + .get("creditDataComplete") + .and_then(|v| v.as_bool()) + .unwrap_or(true); + let d = decide("as, &cards, credit, "平台积分", &cfg, now, complete); + let five_level = quotas[0].level(tight, low); + out.push(json!({ + "n": c["n"].as_str().unwrap_or(""), + "action": d.action.as_str(), + "reason": d.reason, + "level": five_level.as_str(), + })); + } + println!("{}", serde_json::to_string(&out).unwrap_or_default()); +} + +fn main() { + clock::install_signal_handlers(); + let args: Vec = std::env::args().collect(); + if args.iter().any(|a| a == "--selftest") { + selftest(); + return; + } + + let mut interval: f64 = 60.0; + let mut cmd = "watch".to_string(); + let mut config_path: Option = None; + + let mut i = 1; + while i < args.len() { + match args[i].as_str() { + "--interval" if i + 1 < args.len() => { + interval = args[i + 1].parse().unwrap_or(60.0); + i += 2; + } + "--config" if i + 1 < args.len() => { + config_path = Some(std::path::PathBuf::from(&args[i + 1])); + i += 2; + } + "watch" | "once" | "status" => { + cmd = args[i].clone(); + i += 1; + } + _ => i += 1, + } + } + + // 每轮重读:改配置无需 `launchctl unload/load` + let load_cfg = || match &config_path { + Some(p) => config::load(p), + None => config::load_or_default(), + }; + let (cfg, issues) = load_cfg(); + for s in &issues { + log(&format!("⚠️ {s}")); + } + + if cmd == "status" { + let r = run_round(&cfg); + println!("动作: {:?}", r.action); + println!("理由: {}", r.reason); + return; + } + + if cmd == "once" { + let r = run_round(&cfg); + write_status(&r); + println!("{}", r.reason); + return; + } + + log(&format!( + "启动后台监控,间隔 {interval}s(Ctrl+C 退出);扣卡触发线 {:.0}%,额度分界 {:.0}%/{:.0}%", + cfg.card_trigger_percent, cfg.quota_levels.tight, cfg.quota_levels.low + )); + log("数据来源:本地账本 ~/.config/mcode-reset-card/ledger.json(不读宿主凭据、不碰宿主数据库)"); + log("决策策略:与 Mini App「蹬王之王」一致 —— 按先过期先用;只给结论,不代你扣卡"); + + // 连续失败计数:账本读不到时必须**逐级喊出来**, + // 而不是每轮都被当成平常事刷过去(更不能静默 —— 静默 = 看起来一切正常)。 + let mut fails: u32 = 0; + let mut last_ledger_error: Option = None; + loop { + if clock::should_stop() { + log("收到终止信号,守护进程退出"); + return; + } + let (cfg, issues) = load_cfg(); + for s in &issues { + log(&format!("⚠️ {s}")); + } + + let r = run_round(&cfg); + write_status(&r); + + // 同一个「读不到」的原因只说一次(换原因了才再说)。 + if let Some(e) = &r.ledger_error { + if last_ledger_error.as_deref() != Some(e.as_str()) { + last_ledger_error = Some(e.clone()); + log(&format!("⚠️ {e} —— 本轮**没有任何结论**(不是「没有卡/没有积分」)")); + } + fails += 1; + if fails == 5 { + log("🔴 连续 5 轮读不到账本:到期雷达与决策建议都不可用。"); + } else if fails % 30 == 0 { + log(&format!("🔴 已连续 {fails} 轮读不到账本。")); + } + } else { + if fails > 0 { + log(&format!("✓ 账本已恢复可读(此前连续失败 {fails} 轮)")); + } + fails = 0; + last_ledger_error = None; + log(&format!("决策: {:?}", r.action)); + log(&format!("理由: {}", r.reason)); + if r.action == Action::UseCard { + log("👉 结论是「用卡」—— 请在「蹬王之王」面板上确认并手动执行(本插件不代你扣卡)。"); + } + } + + // 抖动,避免与整点撞在一起 + let jitter = (clock::now_ms() % 1000) as f64 / 1000.0 * 4.0 - 2.0; + std::thread::sleep(Duration::from_millis(((interval + jitter) * 1000.0) as u64)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unreadable_ledger_never_says_use_card() { + // ⚠️「读不到 ≠ 没有」:账本读不到时**绝不能**得出「用卡」的结论 + // (那等于把「没数据」当成「没有积分」⇒ 建议去烧掉一张真卡)。 + let cfg = config::Config::default(); + let r = run_round(&cfg); + assert_eq!(r.action, Action::Wait, "理由:{}", r.reason); + assert!(r.ledger_error.is_some(), "读不到账本必须报出来"); + } +} diff --git a/plugins/hylouis233/expiry-guardian/daemon/verify-parity.mjs b/plugins/hylouis233/expiry-guardian/daemon/verify-parity.mjs new file mode 100644 index 0000000..624ed35 --- /dev/null +++ b/plugins/hylouis233/expiry-guardian/daemon/verify-parity.mjs @@ -0,0 +1,251 @@ +#!/usr/bin/env node +/** + * 三方一致性校验器:确保三处扣卡决策永远一致。 + * + * Mini App(Node) packages/expiry-guardian/miniapp/node/server.mjs → decide() + * 后台旧版(Python) ~/mcode-reset-card-autouse/reset_card_autouse.py → decide_background() + * 后台新版(Rust) ~/mcode-reset-card-autouse/rust/src/decision.rs → decide() + * + * 三者是三门语言、两套运行路径。任何一侧改动都可能悄悄漂移, + * 扣卡决策漂移 = 真金白银的损失,必须逐字比对「动作 + 理由文本」。 + * + * 用法: + * node verify-parity.mjs # 比对三者 + * node verify-parity.mjs --write # 顺便刷新快照 + */ +import { readFileSync, writeFileSync, existsSync, mkdtempSync, mkdirSync, copyFileSync, readdirSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { homedir, tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +// 路径解析:优先用**插件包内**的副本(自包含),找不到再回落到本机开发目录。 +// +// 2026-10-19 修:这个校验器原先硬编码 `~/Desktop/mmx/miniapps/...`, +// 于是「把插件复制到别处」就完全跑不起来 —— 违反 CONTRIBUTING 里 +// 「插件应能独立复制使用,不依赖仓库里的其他目录」这条约定。 +const WORKSPACE = process.env.MCODE_WORKSPACE ?? join(homedir(), 'Desktop', 'mmx'); +const PLUGIN_MINIAPP = join(HERE, '..', 'miniapp', 'node', 'server.mjs'); +const WORKSPACE_MINIAPP = join( + WORKSPACE, 'miniapps', 'expiry-guardian', 'miniapp', 'node', 'server.mjs', +); +const MINIAPP_SERVER = existsSync(PLUGIN_MINIAPP) ? PLUGIN_MINIAPP : WORKSPACE_MINIAPP; +// ⚠️ **必须支持 `MCODE_BG_SCRIPT` 覆盖**(2026-10-20 实测发现): +// `check-parity.sh` 的第 3 步是「校验器自检」——它把后台脚本改成一份**注入漂移**的 +// 副本,再跑校验器,要求校验器**必须报不一致**(否则说明校验器自己已经失效了)。 +// 而这里原本把路径**写死**了 ⇒ 校验器永远读的是原脚本 ⇒ 永远报一致 +// ⇒ 自检**从建立起就一直是坏的**(一直在报「校验器本身已失效」)。 +const BG_PY = process.env.MCODE_BG_SCRIPT || join(HERE, 'reset_card_autouse.py'); +const RUST_DAEMON = join(HERE, 'rust', 'target', 'release', 'reset-card-daemon'); +const SNAPSHOT = join(HERE, 'bg_parity.json'); + +for (const [label, p] of [ + ['Mini App 服务端', MINIAPP_SERVER], + ['后台服务(Python)', BG_PY], + ['后台守护(Rust 二进制)', RUST_DAEMON], +]) { + if (!existsSync(p)) { + console.error(`✗ 找不到 ${label}: ${p}`); + console.error(' Rust 版需先执行: cd rust && cargo build --release'); + process.exit(2); + } +} + +// ── 0. 固定配置:三方必须读同一份阈值 ── +// 否则校验结果会随**用户自己的 config.json** 漂移:改一次配置, +// 这个校验器就从「验一致」变成「验谁赢」了。 +// Node / Python 都用 $HOME 定位配置,所以给它们一个临时 HOME 即可。 +const TMP_HOME = mkdtempSync(join(tmpdir(), 'mcode-parity-')); +mkdirSync(join(TMP_HOME, '.config', 'mcode-reset-card'), { recursive: true }); +writeFileSync( + join(TMP_HOME, '.config', 'mcode-reset-card', 'config.json'), + JSON.stringify( + { + schemaVersion: 1, + cardTriggerPercent: 99, + cardSkipBeforeResetMinutes: 60, + quotaLevels: { tight: 80, low: 95 }, + goalPause: { enabled: false, triggerPercent: 99, resumeAtPercent: 0 }, + }, + null, + 2, + ), +); +process.env.HOME = TMP_HOME; + +// ── 1. Mini App ────────────────────────────────────────────────────────────── +const src = readFileSync(MINIAPP_SERVER, 'utf8').replace(/export async function start[\s\S]*$/, ''); +// ⚠️ 不能用 data: URL 导入 —— Mini App 的 server.mjs 现在有相对 import +// (./bot_wakeup.mjs、./mcp.mjs),data: URL 里解析不了相对路径 +// (ERR_UNSUPPORTED_RESOLVE_REQUEST)。 +// ⇒ 改成把 server.mjs 复制成一份「只保留导出前部分」的临时 .mjs 文件再 import。 +// +// ⚠️ **必须把同目录的兄弟模块一起复制过去**(2026-10-20 实测踩到): +// 原来只复制了 `bot_wakeup.mjs`,而 server.mjs 后来又 import 了 `./mcp.mjs` +// ⇒ ERR_MODULE_NOT_FOUND ⇒ **这道三方一致性校验一直在报错、根本没在跑** +// (文档里「一致性是测出来的,不是声称的」就成了空话)。 +// ⇒ 改成:把 MiniApp 目录下的**所有 .mjs** 都复制过去,server.mjs 再 import 哪个都找得到。 +const modDir = mkdtempSync(join(tmpdir(), 'expiry-parity-')); +const modFile = join(modDir, 'server-under-test.mjs'); +writeFileSync(modFile, src + '\nexport { decide };'); +// 依赖文件跟 server.mjs 同目录(server.mjs 用相对路径 import 它们)。 +for (const f of readdirSync(dirname(MINIAPP_SERVER))) { + if (f.endsWith('.mjs') && f !== 'server.mjs') { + copyFileSync(join(dirname(MINIAPP_SERVER), f), join(modDir, f)); + } +} +const mod = await import(modFile); +const miniDecide = mod.decide; + +// ── 2. Python ──────────────────────────────────────────────────────────────── +const pyScript = ` +import importlib.util, json, sys +spec = importlib.util.spec_from_file_location("bg", ${JSON.stringify(BG_PY)}) +m = importlib.util.module_from_spec(spec); sys.modules["bg"] = m; spec.loader.exec_module(m) +payload = json.load(sys.stdin) +NOW, DAY = payload["now"], payload["day"] +out = [] +for c in payload["cases"]: + def qs(t, u, tot=100.0, ul=False): + return m.QuotaState(quota_type=t, total=tot, used=u, is_unlimited=ul) # triggered 是 property + quotas = [qs(1, c["five"])] + quotas.append(qs(2, 0, 100.0, True) if c["week"] is None else qs(2, c["week"])) + if c.get("resetInMinutes") is not None: + quotas[0].reset_time = NOW + int(c["resetInMinutes"] * 60_000) + cards = [{"card_id": f"C{d}", "out_biz_no": f"b{d}", "source_type": "mcode_signin", + "expire_time": NOW + d * DAY} for d in c["cards"]] + exp = None if c["credit"] is None else NOW + c["credit"] * DAY + cfg, _issues = m.load_config() + action, reason = m.decide_background( + quotas, cards, exp, "平台积分", cfg, NOW, c.get("creditDataComplete", True) + ) + out.append({"n": c["n"], "action": action, "reason": reason}) +json.dump(out, sys.stdout, ensure_ascii=False) +`; + +// ── 3. Rust:用一个隐藏的 --selftest 模式跑同一组用例 ──────────────────────── +const DAY = 86_400_000; +const NOW = Date.now(); + +const TRIGGER = 99; +const mq = (type, used, total = 100, unlimited = false) => ({ + quota_type: type, + total, + used, + is_unlimited: unlimited, + // decide() 的 idle 分支会读 usedPercent 生成理由文案 + usedPercent: used, + totalPercent: total, + remainingPercent: Math.max(0, (total - used) / total) * 100, + exhausted: !unlimited && total > 0 && used >= total, + unlimited, + triggered: !unlimited && total > 0 && used >= (total * TRIGGER) / 100, + resetTimeMs: NOW + 30 * DAY, +}); +const mcard = (d) => ({ cardId: `C${d}`, sourceType: 'mcode_signin', outBizNo: `b${d}`, expireTimeMs: NOW + d * DAY, expired: false }); +const mcredit = (d) => ({ source: 'platform', label: '平台积分', balance: 500, expireTimeMs: NOW + d * DAY }); + +/** 覆盖 decide() 每一个分支 */ +const CASES = [ + { n: '未耗尽 / 卡7 / 积13', five: 50, week: null, cards: [7], credit: 13 }, + { n: '未耗尽 / 无卡 / 积13', five: 0, week: null, cards: [], credit: 13 }, + { n: '耗尽 / 卡先过期', five: 100, week: null, cards: [7], credit: 13 }, + { n: '耗尽 / 积分先过期', five: 100, week: null, cards: [20], credit: 5 }, + { n: '耗尽 / 无卡 / 有积分', five: 100, week: null, cards: [], credit: 13 }, + { n: '耗尽 / 有卡 / 无积分', five: 100, week: null, cards: [7], credit: null }, + { n: '耗尽 / 无卡 / 无积分', five: 100, week: null, cards: [], credit: null }, + { n: '周限额耗尽 / 5h未耗尽', five: 50, week: 100, cards: [7], credit: 13 }, + { n: '周限额耗尽 / 积分先过期', five: 50, week: 100, cards: [20], credit: 5 }, + { n: '周限额未耗尽 / 5h未耗尽', five: 50, week: 50, cards: [7], credit: 13 }, + { n: '5h 98% 未到触发线', five: 98, week: null, cards: [7], credit: 13 }, + { n: '5h 99% 触发提前动作', five: 99, week: null, cards: [7], credit: 13 }, + { n: '5h 100% 边界算耗尽', five: 100, week: null, cards: [1], credit: 60 }, + // 「额度马上要刷新就不烧卡」这一组:三方必须给出同一个结论 + { n: '5h 99% / 2小时后刷新', five: 99, week: null, cards: [7], credit: 13, resetInMinutes: 120 }, + { n: '5h 99% / 10分钟后刷新', five: 99, week: null, cards: [7], credit: 13, resetInMinutes: 10 }, + { n: '5h 99% / 恰好60分钟后刷新', five: 99, week: null, cards: [7], credit: 13, resetInMinutes: 60 }, + { n: '5h 99% / 61分钟后刷新', five: 99, week: null, cards: [7], credit: 13, resetInMinutes: 61 }, + // 「读不到 ≠ 没有」:积分数据不完整时,三方都必须**不做写操作**。 + // ⚠️ 2026-10-19 PR #28 review 修:以前读失败被当成「没有积分」, + // 结论会从「留卡」翻转成「用卡」——而扣卡是真实写操作。 + { n: '积分数据不完整 / 耗尽 / 有卡', five: 100, week: null, cards: [7], credit: null, creditDataComplete: false }, + { n: '积分数据不完整 / 耗尽 / 有卡有积分', five: 100, week: null, cards: [7], credit: 13, creditDataComplete: false }, +]; + +const miniResults = CASES.map((c) => { + + const five = mq(1, c.five); + five.triggered = !five.is_unlimited && five.total > 0 && five.used >= five.total * TRIGGER / 100; + if (c.resetInMinutes !== undefined) five.resetTimeMs = NOW + c.resetInMinutes * 60_000; + const week = c.week === null ? mq(2, 0, 100, true) : mq(2, c.week); + week.triggered = !week.is_unlimited && week.total > 0 && week.used >= week.total * TRIGGER / 100; + const d = miniDecide({ + platformPools: c.credit === null ? [] : [mcredit(c.credit)], + codeCredits: [], + cards: c.cards.map(mcard), + quota: { fiveHour: five, weekly: week }, + creditDataComplete: c.creditDataComplete !== false, + }, NOW); + return { n: c.n, action: d.action, reason: d.reason }; +}); + +const payload = JSON.stringify({ + now: NOW, day: DAY, + trigger: TRIGGER, tight: 80, low: 95, skipBeforeResetMinutes: 60, + cases: CASES.map((c) => ({ + n: c.n, five: c.five, week: c.week, cards: c.cards, credit: c.credit, + resetInMinutes: c.resetInMinutes, creditDataComplete: c.creditDataComplete !== false, + })), +}); + +const pyResults = JSON.parse( + execFileSync('/usr/bin/python3', ['-c', pyScript], { + input: payload, + encoding: 'utf8', + maxBuffer: 8 << 20, + env: { ...process.env, HOME: TMP_HOME }, + }), +); + +// Rust:用 --selftest 读同一份 JSON,打印同样结构 +const rustResults = JSON.parse( + execFileSync(RUST_DAEMON, ['--selftest'], { input: payload, encoding: 'utf8', maxBuffer: 8 << 20 }), +); + +// Mini App 用 use-credit,Python 用 keep-credit —— 语义相同,仅命名不同 +const NORMALIZE = { 'use-credit': 'keep-credit' }; +const norm = (a) => NORMALIZE[a] ?? a; + +let failed = 0; +console.log( + '用例'.padEnd(28) + 'MiniApp'.padEnd(13) + 'Python'.padEnd(13) + 'Rust'.padEnd(13) + '一致', +); +console.log('-'.repeat(80)); +for (let i = 0; i < CASES.length; i += 1) { + const a = miniResults[i]; + const b = pyResults[i]; + const c = rustResults[i]; + const actionOk = norm(a.action) === norm(b.action) && norm(a.action) === norm(c.action); + const reasonOk = a.reason === b.reason && a.reason === c.reason; + const ok = actionOk && reasonOk; + if (!ok) failed += 1; + console.log(a.n.padEnd(26) + a.action.padEnd(13) + b.action.padEnd(13) + c.action.padEnd(13) + (ok ? '✓' : '✗')); + if (!ok) { + if (!actionOk) console.log(` 动作不一致: MiniApp=${a.action} Python=${b.action} Rust=${c.action}`); + if (!reasonOk) { + console.log(` MiniApp: ${a.reason}`); + console.log(` Python : ${b.reason}`); + console.log(` Rust : ${c.reason}`); + } + } +} +console.log('-'.repeat(80)); +console.log(`${CASES.length - failed}/${CASES.length} 三方完全一致(动作 + 理由文本逐字)`); + +if (process.argv.includes('--write')) { + writeFileSync(SNAPSHOT, JSON.stringify({ at: new Date().toISOString(), results: miniResults }, null, 2)); + console.log(`快照已刷新: ${SNAPSHOT}`); +} + +process.exit(failed === 0 ? 0 : 1); diff --git a/plugins/hylouis233/expiry-guardian/docs/BOT_DIAGNOSIS.md b/plugins/hylouis233/expiry-guardian/docs/BOT_DIAGNOSIS.md new file mode 100644 index 0000000..e7bb125 --- /dev/null +++ b/plugins/hylouis233/expiry-guardian/docs/BOT_DIAGNOSIS.md @@ -0,0 +1,166 @@ +# 蹬王之王 · 排查手册(v2.0 裁剪版) + +> 对象:`plugins/hylouis233/expiry-guardian` +> 诊断口径:2026-10-20 首版 → 2026-10-20 晚间重写为「唤醒通道排查手册」→ +> **2026-10-20 夜按 v2.0 裁剪版重写**(Bot 看门狗与唤醒通道已移除)。 + +这份手册回答一个问题:**「到期雷达不显示 / 结论不对 / 守护进程不跑,怎么查?」** + +--- + +## 零、先跑这 5 步(90% 的问题在这里面) + +```bash +cd ~/.config/mcode-reset-card + +# ① 账本在不在、能不能解析?(唯一数据源) +ls -l ledger.json +python3 -c "import json;d=json.load(open('ledger.json'));print('quota:',bool(d.get('quota')),'cards:',len(d.get('cards',[])),'credits:',len(d.get('credits',[])))" + +# ② 守护进程在不在、跑了多久 +launchctl list | grep reset-card +tail -20 /tmp/mcode-reset-card.log + +# ③ 它算出来的结论 +cat daemon-status.json + +# ④ 跑一轮,看它说什么 +~/mcode-reset-card-autouse/rust/target/release/reset-card-daemon once + +# ⑤ 三方逻辑有没有漂移 +cd <插件目录> && node daemon/verify-parity.mjs +``` + +--- + +## 一、现象 A:面板上「读不到数据」/ 一直转圈 + +**查什么** + +```bash +ls -l ~/.config/mcode-reset-card/ledger.json +tail -30 /tmp/mcode-reset-card.log +``` + +**怎么判断** + +| 观察到 | 结论 | 怎么办 | +| --- | --- | --- | +| 账本文件不存在 | **没有数据源** | 在面板底部的「本地账本」区里填一份并保存,或直接创建 `ledger.json` | +| 账本 JSON 坏了 | 解析失败 | 修 JSON(面板会报出具体的解析错误) | +| 账本里有 `credits` 字段但面板不显示 | 字段形状不对 | `credits[]` 每项要有 `label` / `balance` / `expireTimeMs`;`balance` 必须是数字 | +| 日志里没有「Bot 看门狗」那类行 | **正常** | v2.0 没有 Bot 看门狗了,日志里看的是「决策:」/「理由:」 | + +--- + +## 二、现象 B:结论一直停在「暂不处理」 + +**这是设计如此**,不是 bug。 + +**查什么** + +```bash +python3 -c " +import json;d=json.load(open('/Users/lh/.config/mcode-reset-card/ledger.json')) +print('有 credits 字段吗:', 'credits' in d) +print('fiveHour 用量:', (d.get('quota') or {}).get('fiveHour',{}).get('usedPercent')) +" +``` + +**怎么判断** + +| 观察到 | 结论 | +| --- | --- | +| 账本里**没有** `credits` 字段 | 「数据不完整」⇒ 结论停在「暂不处理」。⚠️ 这是刻意的:**读不到 ≠ 没有**,宁可不给建议也不给错建议(一个错建议 = 烧掉一张真卡) | +| `credits` 是空数组 `[]` | 确实没有积分 ⇒ 结论会变成「建议用卡」(如果额度到线且有卡) | +| 额度没到触发线(默认 99%) | 结论就是「额度充足」 | +| 距刷新不到 `cardSkipBeforeResetMinutes`(默认 60 分钟) | 结论是「暂不消耗重置卡」—— 额度马上就自己回来了,烧卡纯浪费 | + +--- + +## 三、现象 C:卡明明在账本里,却说「没有可用的重置卡」 + +**查什么** + +```bash +python3 -c " +import json,time;d=json.load(open('/Users/lh/.config/mcode-reset-card/ledger.json')) +now=int(time.time()*1000) +for c in d.get('cards',[]): + print(c.get('cardId'), 'outBizNo=',bool(c.get('outBizNo')), 'sourceType=',bool(c.get('sourceType')), '过期=',(c.get('expireTimeMs') or 0)<=now) +" +``` + +**怎么判断** + +| 观察到 | 结论 | +| --- | --- | +| `outBizNo` 或 `sourceType` 是空的 | 这张卡**不算可用**(两个字段缺一不可) | +| `expireTimeMs` 已经过去了 | 已过期,不算可用 | +| 字段名写错了(比如写成 `expireTime`) | 取不到值 ⇒ 不算可用。**字段名必须逐字一致** | + +--- + +## 四、现象 D:守护进程没在跑 + +**查什么** + +```bash +launchctl list | grep reset-card +tail -20 /tmp/mcode-reset-card.log +tail -20 /tmp/mcode-reset-card.err +``` + +**怎么判断** + +| 观察到 | 结论 | 怎么办 | +| --- | --- | --- | +| `launchctl list` 里没有这一行 | 没装或没加载 | 装好插件后重新安装 launchd 作业 | +| 日志里连续刷「读不到账本」 | 守护进程在跑,但**账本读不到** | 见现象 A | +| 日志里连续刷「连续 5 轮读不到账本」 | 同上,且已升级告警 | 同上 | +| `.err` 里有 panic | 守护进程崩了 | `cargo build --release` 后重装二进制 | + +⚠️ **守护进程崩了不会丢结论**:结论只写在账本与状态文件里, +守护进程只是重算它。 + +--- + +## 五、现象 E:面板和守护进程的结论不一样 + +**查什么** + +```bash +cd <插件目录> && node daemon/verify-parity.mjs +``` + +**怎么判断** + +| 观察到 | 结论 | +| --- | --- | +| 报「19/19 三方完全一致」 | 三份**逻辑**没问题 ⇒ 差异来自**输入不同**(面板读账本的瞬间与守护进程读的不是同一份)⇒ 刷新一下页面 | +| 报「N 条不符」 | 🔴 **决策逻辑漂移**了:Rust / Mini App / Python 三份实现不再一致 | + +⚠️ **三方的理由文本必须逐字一致**。改了一处就要同步三处, +否则「面板说用卡、守护进程说留积分」这种矛盾会静悄悄地出现。 + +--- + +## 六、附录:v1.x 的排查资料去哪了 + +v1.x 的 Bot 看门狗与唤醒通道(读 minimax-bot 的 `state.json`、写唤醒请求、 +用 `session send` 投递)**已随 v2.0 移除** —— 社区插件不能读别的插件的数据目录。 + +那套机制的实测结论与踩过的坑,完整保留在 git 历史里: + +```bash +git log --oneline -- plugins/hylouis233/expiry-guardian/docs/BOT_DIAGNOSIS.md +git show :plugins/hylouis233/expiry-guardian/docs/BOT_DIAGNOSIS.md +``` + +其中仍然适用的教训: + +1. **「读不到」≠「没有」** —— 缺字段必须当成「数据不完整」,不能当成「没有数据」 +2. **粘在对象上的状态会变陈值** —— 判现状要看最近一次动作的结果 +3. **一个「窗口」别同时管两件事** —— 「投递过了吗」与「多久能再救」必须拆开 +4. **单测覆盖了算法 ≠ 覆盖了接线** —— 取数那层也要有测试(见 `tests/server-lint.test.mjs`) +5. **静默失效比报错更坏** —— 读不到就必须说出来 diff --git a/plugins/hylouis233/expiry-guardian/docs/README.zh-CN.md b/plugins/hylouis233/expiry-guardian/docs/README.zh-CN.md new file mode 100644 index 0000000..1e37261 --- /dev/null +++ b/plugins/hylouis233/expiry-guardian/docs/README.zh-CN.md @@ -0,0 +1,257 @@ +# 蹬王之王 · 本地到期账本与用卡决策 + +> English version: [../README.md](../README.md) + +**v2.0(裁剪版)— 2026-10-20。** + +这个 Mini App 现在只做一件事:**看你自己维护的本地账本,算出「该用卡还是该留积分」**。 + +--- + +## v2.0 做了什么、为什么变成这样 + +v1.x 的功能更激进:它自己去调账号接口拿额度与积分、去读写 MiniMax Code 的内部数据库 +去暂停目标、去读别的插件的数据目录去把挂掉的 bot 叫醒。 + +上游社区插件有三条硬红线(2026-10-20 管理员 review 明确列出): + +| 红线 | v1.x 的做法 | v2.0 的做法 | +| --- | --- | --- | +| **不能读 MiniMax Code 的登录凭据** | 读 lease socket / `.cap` 取 access token,再拿它调账号接口 | **不读**。额度/积分/卡的信息改由**本地账本**提供 | +| **不能读写 MiniMax Code 的内部数据库** | `sqlite3` 直接 `UPDATE`/`DELETE`/`INSERT` 目标与队列,还读回合记录 | **不碰**。只读插件自己的目录 | +| **不能读别的插件的数据目录** | 读 minimax-bot 的 `state.json` 拿 bot 与会话 id,再往那些会话发消息 | **不读**。整个 Bot 看门狗移除 | + +⇒ 代价是明确的:**v2.0 不再自动扣卡、不再自动暂停目标、不再自动叫醒 bot。** +它只负责把结论算准、说清楚,**动作由你自己做**。 + +⇒ 好处也很明确:v2.0 是一份**能进社区仓库**的代码。 +它不碰宿主任何私有数据,行为全部可预测。 + +--- + +## 它现在的样子 + +``` +你手写的账本 蹬王之王(只读) 你做决定 +~/.config/mcode-reset-card/ ──▶ 算「先过期先用」 ──▶ 面板上的建议 + 理由 + ledger.json (扣卡/暂停由你手动执行) +``` + +### 本地账本长什么样 + +路径:`~/.config/mcode-reset-card/ledger.json` + +```json +{ + "schemaVersion": 1, + "updatedAtMs": 1792480000000, + "quota": { + "fiveHour": { "usedPercent": 3, "totalPercent": 100, "unlimited": false, "resetTimeMs": 1792497600000 }, + "weekly": { "usedPercent": 0, "totalPercent": 100, "unlimited": true, "resetTimeMs": 1792944000000 } + }, + "cards": [ + { "cardId": "card_1", "label": "重置卡 A", "sourceType": "code", + "outBizNo": "biz_1", "expireTimeMs": 1795000000000 } + ], + "credits": [ + { "label": "赠送", "source": "code", "granted": 400, "used": 0, + "balance": 400, "expireTimeMs": 1795017600000 } + ] +} +``` + +字段说明: + +| 字段 | 含义 | +| --- | --- | +| `quota.fiveHour` / `quota.weekly` | 5 小时与周限额的用量百分比、是否无限、刷新时刻 | +| `cards[]` | 可用的重置卡。`outBizNo` 与 `sourceType` **两个都要有**,否则这张卡不算「可用」 | +| `credits[]` | 积分明细。`balance > 0` 且未过期的才算「还可用」 | +| `updatedAtMs` | 你最后一次更新账本的时刻(面板会显示) | + +面板底部有一个**账本编辑区**:可以直接在页面上改 JSON 并保存, +也可以用任何编辑器改这个文件(改完点一下「刷新」)。 + +> ⚠️ **缺 `credits` 字段 ≠ 没有积分。** +> 字段缺失会被判成「数据不完整」⇒ 结论停在「暂不处理」, +> **绝不会**因为「读不到」就反过来建议你烧掉一张真卡。 +> 这条规则在 Rust、Mini App、Python 三份实现里是同构的。 + +### 决策逻辑:先过期先用 + +额度打到触发线(默认 99%)时,按「**谁先过期先用谁**」给结论: + +| 情况 | 结论 | 理由 | +| --- | --- | --- | +| 额度没到触发线 | 额度充足 | 不动 | +| 额度到线 + 没有可用积分 + 有卡 | **建议用卡** | 没有积分,用卡把额度换回来 | +| 额度到线 + 没有可用积分 + 没卡 | 暂不处理 | 两样都没有 | +| 额度到线 + 有积分 + 没卡 | **建议留积分** | 没卡可用,让积分先消耗 | +| 额度到线 + 都有,卡先过期 | **建议用卡** | 先用卡保住更耐放的积分 | +| 额度到线 + 都有,积分先过期 | **建议留积分** | 让积分先消耗,留住更耐放的卡 | +| 额度到线 + 距刷新不到 N 分钟 | 暂不处理 | 额度马上就自己刷新了,**烧卡纯浪费** | +| 数据不完整 | 暂不处理 | 宁可不给建议,也不给错建议 | + +### 三方一致性 + +决策逻辑有**三份同构实现**: + +| 实现 | 位置 | 作用 | +| --- | --- | --- | +| Rust | `daemon/rust/src/decision.rs` | 后台守护进程 | +| Mini App | `miniapp/node/server.mjs` 的 `decide()` | 看板 | +| Python | `daemon/reset_card_autouse.py` 的 `decide_background()` | **比对基准**(纯函数,不联网) | + +`daemon/verify-parity.mjs` 让三方读**同一份用例**,逐字比对「动作 + 理由文本」: + +``` +$ node daemon/verify-parity.mjs +19/19 三方完全一致(动作 + 理由文本逐字) +``` + +**理由文本必须逐字一致** —— 改一处就要同步三处,否则校验器会红。 +这条是本项目最值钱的一道保险:它挡住的正是「三方慢慢漂移,最后结论互相矛盾」。 + +--- + +## 后台守护进程 + +Rust 守护进程通过 macOS `launchd` 常驻,**只读**: + +- 每轮读一次本地账本 +- 跑一遍上面那套决策 +- 把结论写进日志与 `~/.config/mcode-reset-card/daemon-status.json` +- 账本读不到 ⇒ **明确报出来**(「本轮没有任何结论」),而不是当成「没有卡、没有积分」 + +```bash +cd daemon/rust && cargo test --release # 25/25 +./reset-card-daemon --selftest # 用合成账本跑一遍决策 +./reset-card-daemon once # 跑一轮,打印结论 +./reset-card-daemon status # 只打印当前结论 +``` + +⚠️ **它不做任何写操作**:不扣卡、不暂停目标、不改宿主任何数据。 + +### 诚实的失败 + +这一版特别在意一件事:**「看起来一切正常、实际什么都没发生」比直接报错更坏**。 + +所以: + +- 账本读不到 ⇒ 日志明确说「本轮**没有任何结论**」,且连续失败会逐级升级(5 轮 / 每 30 轮) +- 同一个原因只说一次(换原因了才再说),不刷屏 +- 决策永远带**理由文本**,不是光给一个动作 +- 读不到 ≠ 没有:**数据不完整时永远不给「用卡」这种会花钱的建议** + +--- + +## local MCP 端点 + +插件声明了一个 MCP 端点(`server=expiry-guardian`, `path=/mcp/expiry-guardian`), +别的插件可以在自己的回合里直接问它: + +| 工具 | 作用 | +| --- | --- | +| `watchdog_status` | 查账本里的额度、重置卡、积分与当前结论 | +| `watchdog_decide` | 立即判定一次(**只判定不扣卡**) | + +⚠️ **刻意不暴露「扣卡」**:扣卡是真实写操作,让别的插件随手就能扣太危险。 + +--- + +## 配置 + +`~/.config/mcode-reset-card/config.json`(Rust / Mini App / Python 读同一个文件): + +```json +{ + "schemaVersion": 1, + "cardTriggerPercent": 99, + "cardSkipBeforeResetMinutes": 60, + "quotaLevels": { "tight": 80, "low": 95 } +} +``` + +| 字段 | 含义 | +| --- | --- | +| `cardTriggerPercent` | 5 小时额度到这个值就进入「该动手了」的判定 | +| `cardSkipBeforeResetMinutes` | 距刷新不足这么多分钟就不烧卡(0 = 关闭该保护) | +| `quotaLevels` | 额度紧张 / 额度不足两档的显示分界 | + +配置里的字段是**白名单式重建**的(`normalizeConfig`): +少一个字段就是一条静默失效的通道,所以改配置结构时**必须三处同步** +(Rust 配置、Mini App 的 `normalizeConfig`、真实 `config.json`)。 + +--- + +## 测试 + +| 套件 | 结果 | +| --- | --- | +| Rust(`daemon/rust`) | **25/25** | +| Python 决策单测(`daemon/bg_decision.test.py`) | **20/20** | +| Mini App(`tests/`) | **102/102** | +| 三方一致性(`daemon/verify-parity.mjs`) | **19/19** | +| 上游 `npm run validate` | 本插件 **OK**(0 错 0 警)、`repository: OK` | +| 上游 `npm test` | 69/69 | + +Mini App 侧另有一个**静态守卫**(`tests/server-lint.test.mjs`): +抓「用了但没声明」的标识符。 +它是在一个真实事故之后加的 —— 看板取数层里一个拼写错误 +(`platform` 写成 `platformRes`)让整个取数抛异常, +**看板(额度、决策、卡片)整个是坏的**,而单测测的是纯函数 `decide()`, +**取数那层从来没被跑过**。这个守卫就是为了让那类错当场暴露。 + +--- + +## v1.x 曾经做过什么 + +v1.x(现在已裁掉)的完整历史在 git 里(`git log` 的 v1.1~v1.6 提交),它做过: + +- 自动扣卡、自动暂停/恢复目标 +- Bot 看门狗:把因临时故障挂掉的 bot 复用原会话叫醒 +- 读账号接口拿真实额度 + +这些都是**对宿主私有数据的操作**,社区插件不能做。 +v1.x 里那套「静默失效全部变成会说话」的加固(解析容错、按最新回合判定、 +acked 永久化、失败计数只在成功时归零、静态守卫……)**保留了下来**, +它们在 v2.0 里仍然有意义 —— 只是守护的东西换成了本地账本。 + +--- + +## 运行环境 + +- **MiniMax Code 3.1.2 或更高版本** —— Mini App 声明了 `agentAccess` 与 `agentConcurrency` +- macOS 上验证过;上游校验跑在 **Node 20** 上 +- Rust 守护进程需要 Rust 工具链来编译(`cargo build --release`) + +## 数据与访问范围 + +本插件**只读写自己的目录**: + +| 路径 | 是什么 | +| --- | --- | +| `~/.config/mcode-reset-card/ledger.json` | **你**维护的账本(面板读它,也能直接在面板里改) | +| `~/.config/mcode-reset-card/config.json` | 共享配置(触发线等) | +| `~/.config/mcode-reset-card/daemon-status.json` | 后台守护进程最近一次的结论 | + +它**刻意不做**: + +- 不读 MiniMax Code 的**登录凭据**(不碰 lease socket、不碰 `.cap`、不落盘 token) +- 不打开也不写 **`~/.minimax/v2/sqlite/runtime-state.sqlite`**(宿主内部数据库) +- 不读**别的插件的数据目录**(如 `~/.minimax/v2/plugin-data/liveboards/*/state.json`) + +它在自己目录之外**不做任何写操作**:不扣重置卡、不暂停目标、不往别的插件的会话里写消息。 +后台守护进程是可选的 —— 看板自己就能用。 + +## 前提与边界 + +- **不读宿主凭据、不碰宿主数据库、不读别的插件的数据**(v2.0 的硬约束) +- **不做任何写操作**:扣卡、暂停目标都要你自己在面板上点 +- 结论**仅供参考**:它基于你手写的账本,账本准不准只有你知道 +- 数据不完整时**不给结论**,而不是猜一个 + +## 文档 + +- 本文件 —— 中文文档 +- [../README.md](../README.md) —— 英文版 diff --git a/plugins/hylouis233/expiry-guardian/icon-dark.png b/plugins/hylouis233/expiry-guardian/icon-dark.png new file mode 100644 index 0000000..7220828 Binary files /dev/null and b/plugins/hylouis233/expiry-guardian/icon-dark.png differ diff --git a/plugins/hylouis233/expiry-guardian/icon.png b/plugins/hylouis233/expiry-guardian/icon.png new file mode 100644 index 0000000..416bf1d Binary files /dev/null and b/plugins/hylouis233/expiry-guardian/icon.png differ diff --git a/plugins/hylouis233/expiry-guardian/miniapp/client/index.html b/plugins/hylouis233/expiry-guardian/miniapp/client/index.html new file mode 100644 index 0000000..ea8b081 --- /dev/null +++ b/plugins/hylouis233/expiry-guardian/miniapp/client/index.html @@ -0,0 +1,1406 @@ + + + + + + 蹬王之王 + + + +
+
+

蹬王之王

+

实时追踪积分与重置卡的到期时间,按「先过期先用」决定用卡还是留积分,不让额度白白作废

+

+ + 正在连接实时更新… +

+
+
+ + +
+
+ +
+
+
7 天内将要过期
+
—
+
正在统计…
+
+
+
+
30 天内过期
+
—
+
+
+
有效余额
+
—
+
+
+
已过期损失
+
—
+
+
+
+ +
+
+
5 小时限额
+
—
+
+
+
+ +
+
+
积分总余额
+
—
+
+
+
+
最早到期
+
—
+
+
+
+
可用重置卡
+
—
+
+
+
+ +
+ 加载中 +
+
正在读取额度与到期信息…
+
+
+
+ +
+
+ +
+ + + + +
+
+
+
+

+ 判定结论为「保留积分」时不执行任何写操作;「立即判定一次」可随时手动触发,无节流。 +

+
+ +
+
+ + +
+
+

+ 与上面的「自动刷新」是两套独立机制:上面只在本页面打开时生效,这里交给系统常驻进程, + 两者用同一套「先过期先用」决策,给出**同一条建议**。本插件只读,只给建议,不会真的扣卡。 +

+
+ +
+
+
+ +
+

自动化阈值

+

+ 改完约 1 分钟内生效 —— 后台服务每轮重读配置,不需要重启。 +

+
+ + + + +
+ +
+
+
+
+
+ +
+

+
+ +
+

积分到期明细

+

+ +
读取中…
+
+ +
+
+

重置卡到期

+ +
读取中…
+
+
+

平台积分池

+
读取中…
+
+
+ +
+

你没来得及蹬完的 token

+

+ +
暂无过期未用完的积分
+
+ +
+
+ 本地账本(唯一数据源) · 读取中… +

+ 这个插件**不读** MiniMax Code 的登录凭据、**不碰**宿主内部数据库, + 所以额度/积分/重置卡的信息全部来自你下面这份本地账本。 + 路径:~/.config/mcode-reset-card/ledger.json +

+

+ 形状:{"schemaVersion":1,"quota":{"fiveHour":{"usedPercent":0}},"cards":[],"credits":[]} + —— cards 每项含 cardId / label / sourceType / outBizNo / expireTimeMs; + credits 每项含 label / source / granted / used / balance / expireTimeMs。 + 缺 credits 字段会被当成「数据不完整」⇒ 只给建议、不下结论。 +

+ +
+ + + +
+
+
+ + + + diff --git a/plugins/hylouis233/expiry-guardian/miniapp/miniapp.json b/plugins/hylouis233/expiry-guardian/miniapp/miniapp.json new file mode 100644 index 0000000..e783c23 --- /dev/null +++ b/plugins/hylouis233/expiry-guardian/miniapp/miniapp.json @@ -0,0 +1,29 @@ +{ + "schemaVersion": 1, + "artifacts": { + "client": [ + "./miniapp/client" + ], + "node": [ + "./miniapp/node" + ] + }, + "runtime": { + "kind": "process", + "entry": "./miniapp/node/server.mjs", + "lifecycle": "on-demand" + }, + "surface": { + "path": "/dashboard" + }, + "agentAccess": [ + "session.run" + ], + "agentConcurrency": 2, + "mcpEndpoints": [ + { + "server": "expiry-guardian", + "path": "/mcp/expiry-guardian" + } + ] +} diff --git a/plugins/hylouis233/expiry-guardian/miniapp/node/mcp.mjs b/plugins/hylouis233/expiry-guardian/miniapp/node/mcp.mjs new file mode 100644 index 0000000..b2ccd37 --- /dev/null +++ b/plugins/hylouis233/expiry-guardian/miniapp/node/mcp.mjs @@ -0,0 +1,143 @@ +// @ts-check +/** + * 蹬王之王 local MCP 端点。 + * + * # 为什么需要它(2026-10-20 实测踩到的坑) + * + * MiniApp 的 manifest 可以声明 `mcpEndpoints`,让**别的**插件在它们的回合里 + * 用到本插件的 MCP 工具。而 Host 在发布时会检查「声明的端点是否真的就绪」 + * (探活方式 = MCP 的 `initialize` + `tools/list`)。 + * + * ⚠️ 而且 Host 的「已发布快照」是**粘的**:一旦某个版本的 manifest 声明过 + * 端点,后续版本**必须继续声明同样的端点**,否则发布会被 + * `MCP_PUBLICATION_INVALID` 挡下(`used.size !== expected.size`)。 + * ⇒ 声明了就得**真的实现**,否则端点探活失败,同样发布不了。 + * + * # 这个端点提供什么 + * + * | 工具 | 作用 | + * | --- | --- | + * | `watchdog_status` | 查本地账本里的额度、重置卡、积分与当前判定结论 | + * | `watchdog_decide` | 立即判定一次:现在该不该用卡、用哪张、为什么(**只判定不扣卡**) | + * + * 这样别的插件(例如 minimax-bot)可以在自己的回合里直接问蹬王之王 + * 「额度还剩多少」「哪些 bot 挂了」,而不必读文件。 + */ + +const PROTOCOL_VERSIONS = ['2025-06-18', '2025-03-26', '2024-11-05']; + +/** 工具定义(模型可见,所以用中文)。 */ +function toolDefs() { + return [ + { + name: 'watchdog_status', + description: + '查蹬王之王的当前状态:5 小时额度用量、重置卡与积分的到期时间、' + + '以及待唤醒的 bot 列表(挂了但还没叫醒的)。', + inputSchema: { type: 'object', properties: {}, required: [], additionalProperties: false }, + }, + { + name: 'watchdog_decide', + description: + '立即判定一次额度:按「先过期先用」算出现在该不该用重置卡、用哪一张、为什么。' + + '**只判定不扣卡** —— 真要扣请用面板上的按钮。', + inputSchema: { type: 'object', properties: {}, required: [], additionalProperties: false }, + }, + ]; +} + +/** + * 创建 MCP 处理器。 + * + * @param {{ + * status: () => any, + * decide: () => Promise, + * }} deps + */ +export function createMcpHandler(deps) { + /** @param {any} message */ + async function handleRpc(message) { + if (!message || typeof message !== 'object' || message.jsonrpc !== '2.0') { + return { jsonrpc: '2.0', id: null, error: { code: -32600, message: 'Invalid Request' } }; + } + const { id, method, params } = message; + const isNotification = id === undefined || id === null; + try { + let result; + switch (method) { + case 'initialize': { + const requested = params?.protocolVersion; + result = { + protocolVersion: PROTOCOL_VERSIONS.includes(requested) ? requested : PROTOCOL_VERSIONS[0], + capabilities: { tools: { listChanged: false } }, + serverInfo: { name: 'expiry-guardian', version: '1.6.0' }, + instructions: + '蹬王之王:基于本地账本追踪积分与重置卡的到期时间,' + + '并给出「先过期先用」的用卡/留积分建议。' + + '用 watchdog_status 查状态,watchdog_decide 立即判定一次(**只判定不扣卡**)。', + }; + break; + } + case 'ping': + result = {}; + break; + case 'tools/list': + result = { tools: toolDefs() }; + break; + case 'tools/call': { + const name = params?.name; + const args = params?.arguments ?? {}; + if (name === 'watchdog_status') { + // ⚠️ **必须 await**(2026-10-20 实测发现): + // `deps.status()` 是 async ⇒ 不 await 拿到的是 **Promise**, + // 而 `JSON.stringify(Promise)` 得到的是字符串 `"{}"` + // ⇒ 工具永远返回空对象,调用方什么也看不到。 + result = { content: [{ type: 'text', text: JSON.stringify(await deps.status(), null, 2) }] }; + } else if (name === 'watchdog_decide') { + const r = await deps.decide(); + result = { content: [{ type: 'text', text: JSON.stringify(r, null, 2) }] }; + } else { + return { jsonrpc: '2.0', id, error: { code: -32602, message: `Unknown tool: ${name}` } }; + } + break; + } + default: + if (isNotification) return undefined; + return { jsonrpc: '2.0', id, error: { code: -32601, message: `Method not found: ${method}` } }; + } + return isNotification ? undefined : { jsonrpc: '2.0', id, result }; + } catch (error) { + return { + jsonrpc: '2.0', + id, + error: { code: -32603, message: error instanceof Error ? error.message : String(error) }, + }; + } + } + + /** + * 处理一次 MCP HTTP 请求。 + * + * @param {any} request + * @param {any} response + * @param {any} body 已解析的请求体(POST 时) + */ + return async function handleMcp(request, response, body) { + const messages = Array.isArray(body) ? body : body ? [body] : []; + if (messages.length === 0) { + response.writeHead(400, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'empty request' })); + return; + } + const out = []; + for (const message of messages) { + const r = await handleRpc(message); + if (r !== undefined) out.push(r); + } + response.writeHead(200, { + 'content-type': 'application/json', + 'cache-control': 'no-store', + }); + response.end(JSON.stringify(out.length === 1 ? out[0] : out)); + }; +} diff --git a/plugins/hylouis233/expiry-guardian/miniapp/node/server.mjs b/plugins/hylouis233/expiry-guardian/miniapp/node/server.mjs new file mode 100644 index 0000000..80e5168 --- /dev/null +++ b/plugins/hylouis233/expiry-guardian/miniapp/node/server.mjs @@ -0,0 +1,1410 @@ +// @ts-check +/** + * 蹬王之王 — 积分与重置卡的到期决策看板(v2.0 只读版)。 + * + * 数据源:**本地账本** `~/.config/mcode-reset-card/ledger.json`(用户自己维护)。 + * + * ⚠️ v2.0 为什么不再自己调接口(2026-10-20 管理员 review): + * 社区插件不能读取 MiniMax Code 的登录凭据,也不能读写宿主的内部数据库、 + * 不能读其它插件的数据目录 ⇒ 额度/积分/重置卡的信息改由本地账本提供。 + * 本插件**只读**,不做任何写操作(不扣卡、不暂停目标)。 + * + * 决策规则:把「最早到期的积分」(平台或 Code 任一)与「最早到期的重置卡」比, + * 谁先过期先用谁。积分先过期 → 不用卡,让积分先消耗完;重置卡先过期 → 用卡 + * 把额度重置回来,把更耐放的积分留着。 + */ + +import { createServer } from 'node:http'; +import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync, rmSync } from 'node:fs'; +import { createMcpHandler } from './mcp.mjs'; +import { readFile } from 'node:fs/promises'; +import { randomBytes } from 'node:crypto'; +import { join, dirname } from 'node:path'; +import { connect } from 'node:net'; +import { homedir } from 'node:os'; + +/** @typedef {import('./miniapp-api.js').MiniAppContext} MiniAppContext */ +/** @typedef {import('./miniapp-api.js').MiniAppLifecycle} MiniAppLifecycle */ + + +// ── 解析 ────────────────────────────────────────────────────────────────────── +/** 百分数字符串(如 "71%")转数字。接口就是这种格式,直接 Number() 会得到 0。 */ +function percent(value) { + if (typeof value === 'string') value = value.trim().replace(/%/g, ''); + const n = Number(value); + return Number.isFinite(n) ? n : 0; +} + +/** 金额字符串(可能带千分位)转数字。 */ +function amount(value) { + if (typeof value === 'string') value = value.replace(/,/g, '').trim(); + const n = Number(value); + return Number.isFinite(n) ? n : 0; +} + +const BUCKET_LABEL = { 2: '充值', 3: '赠送' }; +/** Code 积分 credit_type:1=购买,2=赠送(取自桌面端 Purchased / Free 判定)。 */ +const CODE_CREDIT_LABEL = { 1: '购买', 2: '赠送' }; + +/** 开放平台积分池(/backend/account/token_plan_credit)。 */ +function parsePlatformPools(credit) { + const buckets = credit?.balance_breakdown?.buckets ?? []; + return buckets + .filter((b) => b && typeof b.balance === 'number') + .map((b) => ({ + kind: 'platform', + source: 'platform', + label: BUCKET_LABEL[b.source] ?? `来源${b.source}`, + balance: b.balance, + expireTimeMs: typeof b.expire_time_ms === 'number' ? b.expire_time_ms : null, + })) + .sort((a, b) => (a.expireTimeMs ?? Infinity) - (b.expireTimeMs ?? Infinity)); +} + +/** + * MiniMax Code 积分明细(/minimax-cloud/api/v1/credit/details)。 + * + * 分页参数是 `page_number` / `page_size`(下划线命名),且必须带 + * `include_expired=1` 才会返回历史记录——桌面端就是这么调的 + * (见 app.asar 中 open-platform-commerce 的 fetch 函数)。 + * + * 不带 include_expired 时接口只返回当前有效的最近 20 条,据此判断「已过期多少」 + * 会得出完全错误的结论(曾经据此误答「0 过期」)。page_size 上限 100。 + */ +function parseCodeCredits(payload) { + const details = Array.isArray(payload?.details) ? payload.details : []; + const now = Date.now(); + const items = details + .filter((d) => d && (d.credit_type === 1 || d.credit_type === 2)) + .map((d) => ({ + kind: 'code-credit', + source: 'code', + label: CODE_CREDIT_LABEL[d.credit_type] ?? `类型${d.credit_type}`, + granted: amount(d.granted_amount), + consumed: amount(d.consumed_amount), + balance: amount(d.remaining_amount), + expireTimeMs: typeof d.expire_at_ms === 'number' ? d.expire_at_ms : null, + grantedAtMs: typeof d.granted_at_ms === 'number' ? d.granted_at_ms : null, + // 过期时仍有余额 = 真实损失;过期但已用尽不算损失 + lost: typeof d.expire_at_ms === 'number' && d.expire_at_ms < now && amount(d.remaining_amount) > 0, + expired: typeof d.expire_at_ms === 'number' && d.expire_at_ms < now, + })) + .sort((a, b) => (a.expireTimeMs ?? Infinity) - (b.expireTimeMs ?? Infinity)); + + const live = items.filter((d) => !d.expired); + return { + items, + totalCount: Number(payload?.total_count ?? items.length), + truncated: Number(payload?.total_count ?? 0) > items.length, + liveBalance: live.reduce((sum, d) => sum + d.balance, 0), + lostBalance: items.filter((d) => d.lost).reduce((sum, d) => sum + d.balance, 0), + lostCount: items.filter((d) => d.lost).length, + expiredCount: items.filter((d) => d.expired).length, + }; +} + +/** + * 未来 N 天内将过期的积分子集。 + * 这是整个看板最有价值的数字:签到分按天逐笔到账、各自 30 天到期, + * 所以到期是连续排队的滚动流失,不是一次性清算。 + */ +function expiringWithin(items, days, now = Date.now()) { + const limit = now + days * 86_400_000; + return items.filter( + (d) => !d.expired && d.balance > 0 && d.expireTimeMs !== null && d.expireTimeMs <= limit, + ); +} + +/** + * 账户积分汇总(POST /matrix/api/v1/commerce/get_membership_info)。 + * + * 这是 MCode 用量页上「积分」那一栏的权威口径: + * total_remaining_amount 总余额(截图里的 30947) + * purchased_remaining_amount 购入部分 + * free_remaining_amount 赠送部分 + * + * 与另两个口径的关系(已实测核对,不要混算): + * purchased ≈ token_plan_credit 的 remaining_credits(6487.506 vs 6487,小数为精度差) + * free ≥ credit/details 明细之和(24460 vs 17400,因该接口只返回最近 20 笔) + * 因此这里只作为「总余额」展示,**不参与**到期排序,避免与明细重复计数。 + */ +function parseCreditSummary(payload) { + const s = payload?.op_credit_summary; + if (!s) return null; + const total = amount(s.total_remaining_amount); + const purchased = amount(s.purchased_remaining_amount); + const free = amount(s.free_remaining_amount); + if (!total && !purchased && !free) return null; + return { total, purchased, free }; +} + +// ── 本地账本:数据来源 ───────────────────────────────────────────────────────── +// +// 社区插件不能读取 MiniMax Code 的登录凭据(2026-10-20 管理员 review), +// 所以这里**不再**调账号接口 ⇒ 额度/积分/重置卡的信息改由用户自己维护的 +// 本地账本提供:`~/.config/mcode-reset-card/ledger.json`。 +// +// ⚠️ 账本形状与 Rust 守护进程(`daemon/rust/src/ledger.rs`)**必须逐字一致**。 +const LEDGER_PATH = join(homedir(), '.config', 'mcode-reset-card', 'ledger.json'); + +/** + * 读账本。 + * + * @returns {{ok: true, ledger: any}|{ok: false, error: string}} + * ⚠️ 「读不到」与「读到但内容是空的」必须分开: + * 读不到 ⇒ 数据不完整 ⇒ 决策走 wait(绝不能当成「没有积分」⇒ 结论翻转成「用卡」)。 + */ +function readLedger() { + let raw; + try { + raw = readFileSync(LEDGER_PATH, 'utf8'); + } catch (e) { + return { ok: false, error: `读不到账本 ${LEDGER_PATH}(${e?.message ?? e})` }; + } + try { + return { ok: true, ledger: JSON.parse(raw) }; + } catch (e) { + return { ok: false, error: `账本 ${LEDGER_PATH} 解析失败(${e?.message ?? e})` }; + } +} + +/** 账本里的额度 → 与 `parseQuota` 同构的对象。 */ +function ledgerQuota(q, triggerPercent) { + const mk = (o, fallbackUnlimited) => { + const used = Number(o?.usedPercent ?? 0); + const total = Number(o?.totalPercent ?? 100) || 100; + const unlimited = Boolean(o?.unlimited ?? fallbackUnlimited); + return { + usedPercent: used, + totalPercent: total, + remainingPercent: Math.max(0, (total - used) / total) * 100, + exhausted: !unlimited && total > 0 && used >= total, + unlimited, + triggered: isTriggered(used, total, unlimited, triggerPercent), + resetTimeMs: Number(o?.resetTimeMs ?? 0) || null, + }; + }; + return { + fiveHour: mk(q?.fiveHour, false), + weekly: mk(q?.weekly, true), + }; +} + +/** + * 账本 → 决策引擎的输入(形状与之前从账号接口拿到时**完全一致**)。 + * + * ⇒ 决策逻辑、三方一致性校验(Rust / Mini App / Python 逐字比对)原样保留。 + */ +function ledgerInputs(v, now) { + const rawCredits = Array.isArray(v?.credits) ? v.credits : null; + const items = (rawCredits ?? []).map((c) => { + const balance = Number(c?.balance ?? 0); + const expireTimeMs = Number(c?.expireTimeMs ?? 0) || null; + const expired = expireTimeMs !== null && expireTimeMs < now; + return { + kind: c?.source === 'platform' ? 'platform' : 'code-credit', + source: c?.source === 'platform' ? 'platform' : 'code', + label: String(c?.label ?? '积分'), + granted: Number(c?.granted ?? 0), + consumed: Number(c?.used ?? 0), + balance, + expireTimeMs, + grantedAtMs: null, + // 过期时仍有余额 = 真实损失;过期但已用尽不算损失 + lost: expired && balance > 0, + expired, + }; + }); + const codeItems = items.filter((i) => i.source === 'code'); + const platformPools = items.filter((i) => i.source === 'platform'); + const live = codeItems.filter((i) => !i.expired); + const codeCredits = { + items: codeItems, + totalCount: codeItems.length, + truncated: false, + liveBalance: live.reduce((sum, d) => sum + d.balance, 0), + lostBalance: codeItems.filter((d) => d.lost).reduce((sum, d) => sum + d.balance, 0), + lostCount: codeItems.filter((d) => d.lost).length, + expiredCount: codeItems.filter((d) => d.expired).length, + }; + const cards = (Array.isArray(v?.cards) ? v.cards : []).map((c) => { + const expireTimeMs = Number(c?.expireTimeMs ?? 0) || null; + return { + cardId: String(c?.cardId ?? ''), + label: String(c?.label ?? '重置卡'), + sourceType: String(c?.sourceType ?? ''), + outBizNo: String(c?.outBizNo ?? ''), + expireTimeMs, + createTimeMs: null, + expired: expireTimeMs !== null && expireTimeMs <= now, + }; + }); + return { + platformPools, + codeCredits, + cards, + // ⚠️ `credits` 字段缺失 ⇒ 数据不完整(不是「没有积分」)。 + creditDataComplete: rawCredits !== null, + }; +} + +// ── 共享配置(与 Rust 守护进程 / Python 读同一个文件)───────────────────────── +// 这是三份实现不漂移的前提:Node / Rust / Python 都从这里取阈值, +// 谁都不许再把 99 写死在自己代码里。 +// 路径 ~/.config/mcode-reset-card/config.json 与 Rust config.rs 一致。 +const CONFIG_PATH = join(homedir(), '.config', 'mcode-reset-card', 'config.json'); +const CONFIG_DIR = dirname(CONFIG_PATH); + +/** + * 下列常量只是**默认值**,仅在配置文件缺失或非法时兜底。 + * 实际生效值一律来自 loadConfig()。 + * + * 为什么默认 99:等额度耗尽再处理,中间那次请求已经失败,会打断 + * 当前目标正在跑的自动执行。提前一个百分点介入,代价几乎为零 + * (剩余 1% 本来也用不完),但能避免任务被硬中断。 + */ +const DEFAULT_QUOTA_TRIGGER_PERCENT = 99; +const DEFAULT_TIGHT_PERCENT = 80; +const DEFAULT_LOW_PERCENT = 95; +/** + * 距 5 小时额度刷新不足这么多分钟时,不消耗重置卡(额度马上就自己回来了)。 + * 实际生效值来自 config.json 的 cardSkipBeforeResetMinutes。 + */ +const DEFAULT_CARD_SKIP_BEFORE_RESET_MINUTES = 60; + +function defaultConfig() { + return { + schemaVersion: 1, + cardTriggerPercent: DEFAULT_QUOTA_TRIGGER_PERCENT, + cardSkipBeforeResetMinutes: DEFAULT_CARD_SKIP_BEFORE_RESET_MINUTES, + quotaLevels: { tight: DEFAULT_TIGHT_PERCENT, low: DEFAULT_LOW_PERCENT }, + }; +} + +function clampNumber(value, lo, hi, fallback) { + const n = Number(value); + if (!Number.isFinite(n)) return fallback; + return Math.min(hi, Math.max(lo, n)); +} + +/** + * 逐字段校验并夹紧,返回 (配置, 问题列表)。 + * + * 非法组合**整体回退默认并报出来**,绝不静默纠正 —— + * 静默纠正会让人以为自己设的生效了。字段与 Rust config.rs 保持同名同义。 + */ +export function normalizeConfig(raw) { + const d = defaultConfig(); + const issues = []; + const src = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {}; + + const rawCard = src.cardTriggerPercent; + const cardTriggerPercent = clampNumber(rawCard, 1, 100, d.cardTriggerPercent); + if (rawCard != null && Number(rawCard) !== cardTriggerPercent) { + issues.push(`cardTriggerPercent=${rawCard} 超出 1..100,已夹到 ${cardTriggerPercent}`); + } + + const lv = src.quotaLevels && typeof src.quotaLevels === 'object' ? src.quotaLevels : {}; + let tight = clampNumber(lv.tight, 1, 100, d.quotaLevels.tight); + let low = clampNumber(lv.low, 1, 100, d.quotaLevels.low); + // 分界点必须 0 < tight < low;非法组合整体回退默认,而不是悄悄调序。 + if (!(tight < low)) { + if (Object.keys(lv).length > 0) { + issues.push(`quotaLevels 需满足 tight < low(收到 tight=${tight} low=${low}),已整体回退默认`); + } + tight = d.quotaLevels.tight; + low = d.quotaLevels.low; + } + + const rawSkip = src.cardSkipBeforeResetMinutes; + const cardSkipBeforeResetMinutes = clampNumber(rawSkip, 0, 1440, d.cardSkipBeforeResetMinutes); + if (rawSkip != null && Number(rawSkip) !== cardSkipBeforeResetMinutes) { + issues.push(`cardSkipBeforeResetMinutes=${rawSkip} 超出 0..1440,已夹到 ${cardSkipBeforeResetMinutes}`); + } + + const config = { + schemaVersion: 1, + cardTriggerPercent, + cardSkipBeforeResetMinutes, + // ⚠️ `resumeCronId` 已彻底删除(2026-10-20):本插件不再武装/解除武装任何 cron。 + // 恢复流程改为:守护进程(Host 外)只写请求文件,**MiniMax Bot 自己的 MiniApp** + // 用 `agent.run` 投递(只有它对 bot 会话有归属权限)。 + quotaLevels: { tight, low }, + }; + return { config, issues }; +} + +/** 读配置。文件不存在是正常情况(首次运行),返回默认值且不记 issue。 */ +/** Bot 守护配置的归一化:非法值夹到合法区间,缺失用默认。 */ +function readConfig() { + let text; + try { + text = readFileSync(CONFIG_PATH, 'utf8'); + } catch (e) { + if (e && e.code === 'ENOENT') return { config: defaultConfig(), issues: [] }; + return { config: defaultConfig(), issues: [`配置读取失败(${e.message}),正在用默认值`] }; + } + try { + return normalizeConfig(JSON.parse(text)); + } catch (e) { + return { config: defaultConfig(), issues: [`配置解析失败(${e.message}),正在用默认值`] }; + } +} + +function loadConfig() { + return readConfig().config; +} + +/** 原子写:写 tmp 再 rename。同目录 rename 在 POSIX 上是原子的。 */ +function writeConfig(config) { + mkdirSync(CONFIG_DIR, { recursive: true }); + const tmp = `${CONFIG_PATH}.tmp`; + writeFileSync(tmp, `${JSON.stringify(config, null, 2)}\n`, 'utf8'); + renameSync(tmp, CONFIG_PATH); +} + +/** + * 等级 → 中文。**属于 parity 契约的一部分**,改一个字三方都要改。 + * 与 Rust decision.rs 的 LEVEL_LABEL 逐字对应。 + */ +const LEVEL_LABEL = { + comfortable: '额度充足', + tight: '额度紧张', + low: '额度不足', + burned: '额度燃尽', +}; + +/** + * 距 5 小时额度刷新还有多少分钟。取不到刷新时间时返回 null。 + * + * 与 Rust `minutes_to_reset()`、Python `_minutes_to_reset()` 同一口径: + * 已过去的时间按 0 算。 + */ +function minutesToReset(fiveHour, nowMs) { + const reset = fiveHour?.resetTimeMs; + if (typeof reset !== 'number' || !Number.isFinite(reset)) return null; + return Math.max(0, (reset - nowMs) / 60_000); +} + +/** + * 四档红绿灯分档。与 Rust `quota_level()` 逐行对应。 + * + * 燃尽档由 used >= total 判定,**不可配** —— 它是事实,不是阈值。 + */ +function quotaLevel(used, total, unlimited, tight, low) { + if (unlimited || total <= 0) return 'comfortable'; + if (used >= total) return 'burned'; + const ratio = (used / total) * 100; + if (ratio >= low) return 'low'; + if (ratio >= tight) return 'tight'; + return 'comfortable'; +} + +/** 是否达到触发线。不限量和总量为 0 都不触发。 */ +function isTriggered(used, total, unlimited, triggerPercent = DEFAULT_QUOTA_TRIGGER_PERCENT) { + if (unlimited || total <= 0) return false; + return used >= (total * triggerPercent) / 100; +} + +function parseQuota(payload, triggerPercent = DEFAULT_QUOTA_TRIGGER_PERCENT) { + const models = payload?.model_remains ?? []; + const general = + models.find((m) => m?.model_name === 'general') ?? models.find((m) => m?.model_name) ?? null; + if (!general) return null; + const total = percent(general.current_interval_total_percent) || 100; + const used = percent(general.current_interval_used_percent); + const weeklyTotal = percent(general.current_weekly_total_percent) || 100; + const weeklyUsed = percent(general.current_weekly_used_percent); + const weeklyUnlimited = general.current_weekly_status === 3; + return { + fiveHour: { + usedPercent: used, + totalPercent: total, + remainingPercent: Math.max(0, (total - used) / total) * 100, + exhausted: general.current_interval_status !== 3 && total > 0 && used >= total, + unlimited: general.current_interval_status === 3, + // 决策用:到触发线(默认 99%)即视为「该动手了」 + triggered: isTriggered(used, total, general.current_interval_status === 3, triggerPercent), + resetTimeMs: general.end_time ?? null, + }, + weekly: { + usedPercent: weeklyUsed, + totalPercent: weeklyTotal, + unlimited: weeklyUnlimited, + exhausted: !weeklyUnlimited && weeklyTotal > 0 && weeklyUsed >= weeklyTotal, + // 周限额此前没有这个字段,导致 Node 侧忽略了周限额耗尽 —— 与 Rust/Python 不一致 + triggered: isTriggered(weeklyUsed, weeklyTotal, weeklyUnlimited, triggerPercent), + resetTimeMs: general.weekly_end_time ?? null, + }, + }; +} + +/** + * 重置卡的使用流水。 + * + * 关键点:`create_time` 是**必填**参数,桌面端就是那样调的。 + * 传 `create_time=0` 表示不限起始时间;传一个具体时间戳反而会返回 + * 2013 invalid params —— 那是一条**错误**,不是「没有记录」。 + * 曾经把这条错误读成 total=0,因而误判「一张卡都没用过」。 + */ +// ── 决策:先过期先用 ────────────────────────────────────────────────────────── +const SOURCE_NAME = { platform: '平台积分', code: 'Code 积分', card: '重置卡' }; + +/** + * 跨三类来源比较到期时间:平台积分池、Code 积分明细、重置卡。 + * + * 只有「最早到期的积分」与「最早到期的重置卡」直接可比—— + * 决定用不用卡,看的是哪一边更快作废: + * 卡先过期 → 用卡把额度重置回来,把更耐放的积分留着; + * 积分先过期 → 不用卡,让积分自然消耗完,卡留到以后更划算的时候。 + */ +/** + * @param creditDataComplete 积分数据是否**完整读到**。 + * + * ⚠️ 2026-10-19 PR #28 review 修:读不到 ≠ 没有。 + * 之前 `credit/details` 失败会返回 null ⇒ codeCredits 为空 ⇒ + * `decide` 看到「没有积分」⇒ 结论从「留卡」翻转成「用卡」。 + * 而扣卡是**真实写操作** —— 把网络故障当成业务状态会误扣真卡。 + * ⇒ 数据不完整时一律**不做写操作**(动作降为 wait),只如实报出来。 + */ +function decide( + { platformPools, codeCredits, cards, quota, creditDataComplete = true }, + nowMs = Date.now(), +) { + const now = nowMs; + const liveCards = cards.filter((c) => !c.expired && c.outBizNo && c.sourceType); + const liveCredits = [...platformPools, ...codeCredits].filter( + (c) => c.balance > 0 && c.expireTimeMs && c.expireTimeMs > now, + ); + + const earliestCard = liveCards[0] ?? null; + const earliestCredit = liveCredits.sort( + (a, b) => (a.expireTimeMs ?? Infinity) - (b.expireTimeMs ?? Infinity), + )[0] ?? null; + + const cardExpire = earliestCard?.expireTimeMs ?? null; + const creditExpire = earliestCredit?.expireTimeMs ?? null; + const creditName = earliestCredit ? SOURCE_NAME[earliestCredit.source] : '积分'; + + let action = 'wait'; + let reason = ''; + let usedCard = false; + + // 「等级」与「动作」是两个独立维度:动作说**该做什么**,等级说**额度还剩多少**。 + // 混在一起的话,93% 已经紧张时页面徽章仍会写「额度充足」——那就是误导。 + // 等级必须在这里算一次并随判定一起下发,页面才有依据上色。 + const cfg = loadConfig(); + const { quotaLevels } = cfg; + const level = quota + ? quotaLevel( + quota.fiveHour.usedPercent, + quota.fiveHour.totalPercent, + quota.fiveHour.unlimited, + quotaLevels.tight, + quotaLevels.low, + ) + : 'comfortable'; + + // ── 5 小时额度即将自己刷新 → 不烧重置卡(纯浪费)── + // 与 Rust `minutes_to_reset()` 同一口径。 + const resetInMin = minutesToReset(quota?.fiveHour, now); + const skipMinutes = Number(cfg.cardSkipBeforeResetMinutes ?? DEFAULT_CARD_SKIP_BEFORE_RESET_MINUTES); + const skipCardReason = + skipMinutes > 0 && resetInMin !== null && resetInMin <= skipMinutes + ? `5 小时额度 ${resetInMin.toFixed(0)} 分钟后刷新,暂不消耗重置卡` + : null; + + if (!quota) { + reason = '未获取到额度状态'; + } else if (!quota.fiveHour.triggered && !quota.weekly.triggered) { + action = 'idle'; + // 等级词随分档走,而不是永远写「额度充足」—— + // 否则用量 92% 时页面上仍显示「额度充足」,是误导。 + // 把触发线也说进来:否则「额度不足但暂不需要处理」看起来自相矛盾。 + // burned 永远不会出现在这里:它必然已 triggered,走的是下面用卡/留卡分支。 + reason = `${LEVEL_LABEL[level]}(5 小时已用 ${quota.fiveHour.usedPercent.toFixed(0)}%),未到 ${cfg.cardTriggerPercent}% 触发线,暂不动用重置卡`; + } else if (!creditDataComplete) { + // 积分数据没读全 ⇒ 无法判断「卡先过期还是积分先过期」 + // ⇒ 绝不做写操作(扣卡是真实消耗,宁可等下一轮)。 + action = 'wait'; + reason = '积分数据读取不完整,本轮不执行写操作(等下一轮重新判定)'; + } else if (!earliestCredit) { + // 没有可消耗的积分,只能靠卡把额度重置回来 + if (earliestCard) { + // 额度马上要自己刷新了,就别烧卡了 + if (skipCardReason) { + action = 'wait'; + reason = skipCardReason; + } else { + action = 'use-card'; + usedCard = true; + reason = '没有可用积分,使用重置卡重置额度'; + } + } else { + action = 'wait'; + reason = '额度已耗尽,既没有积分也没有可用的重置卡'; + } + } else if (!earliestCard) { + action = 'use-credit'; + reason = `没有可用的重置卡,让${creditName}(${fmtDay(creditExpire)}到期)先消耗`; + } else if (cardExpire < creditExpire) { + // 额度马上要自己刷新了,就别烧卡了 + if (skipCardReason) { + action = 'wait'; + reason = skipCardReason; + } else { + action = 'use-card'; + usedCard = true; + reason = `重置卡(${fmtDay(cardExpire)})比${creditName}(${fmtDay(creditExpire)})先过期,先用卡保住积分`; + } + } else { + action = 'use-credit'; + reason = `${creditName}(${fmtDay(creditExpire)})比重置卡(${fmtDay(cardExpire)})先过期,不用卡,让积分先消耗`; + } + + return { + action, + usedCard, + reason, + // 等级随判定一起下发:页面据此给徽章上色,不再自己猜。 + level, + // 距 5 小时额度刷新的倒计时(分钟)。页面直接显示,不用自己算。 + resetInMinutes: resetInMin, + // 距刷新不足这个分钟数时**不建议用卡** —— 页面要说出来, + // 否则用户看到「额度快没了却不建议用卡」会以为坏了。 + cardSkipBeforeResetMinutes: skipMinutes, + skipCardActive: Boolean(skipCardReason), + target: + usedCard && earliestCard + ? { + cardId: earliestCard.cardId, + sourceType: earliestCard.sourceType, + outBizNo: earliestCard.outBizNo, + expireTimeMs: cardExpire, + } + : null, + earliestCardExpireMs: cardExpire, + earliestCreditExpireMs: creditExpire, + earliestCreditSource: earliestCredit?.source ?? null, + earliestCreditLabel: earliestCredit?.label ?? null, + triggered: Boolean(quota && (quota.fiveHour.triggered || quota.weekly.triggered)), + exhausted: Boolean(quota && (quota.fiveHour.exhausted || quota.weekly.exhausted)), + }; +} + +function fmtDay(ms) { + if (!ms) return '未知'; + const d = new Date(ms); + return `${d.getMonth() + 1}月${d.getDate()}日`; +} + +// ── 分层缓存 ────────────────────────────────────────────────────────────────── +/** + * 按接口的「变化速度」分层缓存,直接砍掉无谓的网络往返。 + * + * 实测依据(单轮 5 个接口共 ~19.8 KB / ~1.05s,其中 5 个串行等待是大头): + * 额度用量 每轮必取 —— 消耗在变,是所有联动的触发源 + * 积分明细 60s —— 81 笔里绝大多数只在被消耗时才动 + * 重置卡 300s —— 按天签到产生,变化极慢 + * 平台积分池 300s —— 同上 + * 账户汇总 600s —— 只在签到/充值后变 + * + * 过期未取数时回落到上一次成功结果,宁可显示略旧的数据,也不让看板空白。 + */ +const TTL = { + quota: 0, + codeCredits: 60_000, + cards: 300_000, + platform: 300_000, + membership: 600_000, + cardFlows: 120_000, +}; + +const cache = { + quota: { value: null, at: 0 }, + codeCredits: { value: null, at: 0 }, + cards: { value: null, at: 0 }, + platform: { value: null, at: 0 }, + membership: { value: null, at: 0 }, + cardFlows: { value: null, at: 0 }, +}; + +/** 带 TTL 与陈旧回落的取值:命中则零网络,未命中才请求。 */ +async function cached(slot, loader) { + const entry = cache[slot]; + const now = Date.now(); + if (entry.value !== null && now - entry.at < TTL[slot]) { + return { value: entry.value, fromCache: true }; + } + try { + const value = await loader(); + if (value !== null && value !== undefined) { + entry.value = value; + entry.at = now; + return { value, fromCache: false }; + } + // 本轮失败:用旧值兜底,别把上次的正常数据清掉 + return { value: entry.value, fromCache: true }; + } catch { + return { value: entry.value, fromCache: true }; + } +} + +function invalidateCache(slot) { + if (slot) cache[slot].at = 0; + else for (const key of Object.keys(cache)) cache[key].at = 0; +} + +// ── 快照 ────────────────────────────────────────────────────────────────────── +/** + * 同一时刻只允许一次真实取数。 + * + * SSE 轮询与自动判定会各自调用 buildSnapshot,若不合并就会把 + * 每轮的接口数翻倍。这里让并发的调用共享同一个 in-flight Promise。 + */ +let inflightSnapshot = null; +/** 上一次成功的快照:取数失败时回退用,避免整页被打空。 */ +let lastGoodSnapshot = null; + +function buildSnapshot() { + if (inflightSnapshot) return inflightSnapshot; + inflightSnapshot = doBuildSnapshot().finally(() => { + inflightSnapshot = null; + }); + return inflightSnapshot; +} + +async function doBuildSnapshot() { + // 数据来源:**本地账本**(不读宿主凭据、不调账号接口、不碰宿主数据库)。 + const read = readLedger(); + if (!read.ok) { + // ⚠️ 读不到账本 ⇒ 数据不完整 ⇒ 决策走 wait。 + // 绝不能当成「没有积分、没有卡」⇒ 那会把结论翻转成「用卡」。 + const err = new Error(read.error); + err.ledgerUnavailable = true; + throw err; + } + const now = Date.now(); + const triggerPercent = loadConfig().cardTriggerPercent; + const { platformPools, codeCredits, cards, creditDataComplete } = ledgerInputs(read.ledger, now); + const quota = ledgerQuota(read.ledger?.quota, triggerPercent); + + const decision = decide({ + platformPools, + codeCredits: codeCredits.items, + cards, + quota, + creditDataComplete, + }); + + const soon7 = expiringWithin(codeCredits.items, 7, now); + const soon30 = expiringWithin(codeCredits.items, 30, now); + const sumBalance = (list) => list.reduce((s, d) => s + d.balance, 0); + + return { + fetchedAtMs: now, + // 数据来自本地账本,没有「接口缓存」这一层 ⇒ 恒为新鲜。 + fresh: { + quota: true, + codeCredits: true, + cards: true, + platform: true, + membership: true, + }, + // 账本自带的时间戳:用户最后一次更新账本是什么时候。 + ledgerUpdatedAtMs: Number(read.ledger?.updatedAtMs ?? 0) || null, + creditSummary: null, + platformCredits: { + total: sumBalance(platformPools), + used: 0, + remaining: sumBalance(platformPools), + fallbackEnabled: false, + pools: platformPools, + }, + codeCredits: { + available: creditDataComplete, + totalCount: codeCredits.totalCount, + liveBalance: codeCredits.liveBalance, + lostBalance: codeCredits.lostBalance, + active: codeCredits.items.filter((d) => !d.expired && d.balance > 0), + lost: codeCredits.items.filter((d) => d.expired && d.balance > 0), + usedUp: (() => { + const zero = codeCredits.items.filter((d) => d.balance <= 0); + return { + count: zero.length, + consumed: zero.reduce((sum, d) => sum + d.consumed, 0), + expiredCount: zero.filter((d) => d.expired).length, + }; + })(), + }, + expiry: { + soon7Count: soon7.length, + soon7Balance: sumBalance(soon7), + soon30Count: soon30.length, + soon30Balance: sumBalance(soon30), + nextExpireMs: codeCredits.items.find((d) => !d.expired && d.balance > 0)?.expireTimeMs ?? null, + }, + quota, + cards: { + total: cards.length, + live: cards.filter((c) => !c.expired).length, + items: cards + .filter((c) => !c.expired) + .slice(0, 12) + .map((c, i) => ({ index: i + 1, expireTimeMs: c.expireTimeMs })), + raw: cards + .filter((c) => !c.expired) + .map((c) => ({ + cardId: c.cardId, + sourceType: c.sourceType, + outBizNo: c.outBizNo, + expireTimeMs: c.expireTimeMs, + })), + }, + cardUsage: { available: false, usedCount: null, grantedCount: null, lastUsedAtMs: null }, + decision, + }; +} + +// ── 自动刷新(只读:重算结论并推给页面,不做任何写操作)───────────────────────── +/** 自动判定间隔的允许范围:最快 5 秒(够灵敏),最慢 60 分钟(够省心)。 */ +const AUTO_MIN_MS = 5_000; +const AUTO_MAX_MS = 60 * 60 * 1000; +const AUTO_DEFAULT_MS = 15_000; + +function clampAutoInterval(ms) { + const n = Number(ms); + if (!Number.isFinite(n)) return AUTO_DEFAULT_MS; + return Math.min(AUTO_MAX_MS, Math.max(AUTO_MIN_MS, Math.round(n))); +} + +const state = { + autoEnabled: true, // 默认开启:定时把最新结论推到页面上 + autoIntervalMs: AUTO_DEFAULT_MS, + lastResult: null, + autoRuns: [], + lastAutoAtMs: 0, +}; + +/** + * 自动刷新一轮:重算结论 → 推给页面 → 记一笔。 + * + * ⚠️ v2.0(裁剪版):**只读**。它不扣卡、不暂停任何东西 —— + * 扣卡是不可逆的真实消耗,必须由用户在面板上点确认。 + * 这里只负责把「现在该怎么做」的结论及时送到页面上。 + */ +async function autoTick(reason) { + const snapshot = await buildSnapshot(); + state.lastAutoAtMs = Date.now(); + broadcast(snapshot, { incremental: true }); + const entry = { + outcome: 'OK', + atMs: state.lastAutoAtMs, + reason, + action: snapshot?.decision?.action ?? null, + decisionReason: snapshot?.decision?.reason ?? null, + }; + state.autoRuns = [entry, ...state.autoRuns].slice(0, 8); + return entry; +} + + +// ── 后台守护进程管理(launchd)──────────────────────────────────────────────── +/** + * 后台服务由 launchd 常驻,关闭本页面后仍会运行 —— + * 这正是它与页面内 SSE 自动判定的区别。 + */ +const DAEMON_LABEL = 'com.local.mcode-reset-card'; +const DAEMON_PLIST = join(homedir(), 'Library', 'LaunchAgents', `${DAEMON_LABEL}.plist`); + +async function run(cmd, args) { + const { spawn } = await import('node:child_process'); + return new Promise((resolve) => { + const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] }); + let out = ''; + child.stdout.on('data', (d) => { out += d.toString(); }); + child.stderr.on('data', (d) => { out += d.toString(); }); + child.on('close', (code) => resolve({ code, out })); + child.on('error', (e) => resolve({ code: -1, out: String(e) })); + }); +} + +/** 后台服务当前是否在跑(读 launchd 已加载的作业表)。 */ +async function daemonStatus() { + const { existsSync } = await import('node:fs'); + const installed = existsSync(DAEMON_PLIST); + if (!installed) return { installed: false, running: false, pid: null, label: DAEMON_LABEL }; + + // 注意:`launchctl list