运维 CLI
站内给站长 / 管理员用的维护工具。两种用法:
- 交互式(推荐):
npm run cli—— 进菜单,每一步都有提示,不需要背命令- 命令式:
npm run cli -- <命令> [参数]—— 给脚本 / CI / 明确知道自己要做什么时
npm run cli # 交互式:菜单向导
npm run cli -- --help # 全部命令一览
npm run cli -- stats overview # 命令式:看一眼站点状态
未迁移:旧版 CLI 的
import-blogs(历史博客导入;正文早已存BlogContent表)。需要时另写专用脚本(参考scripts/compensate-unclaimed-fortunes.mjs)。
已迁移为flask fish compensatefish compensate(见「小鱼干」一节)—— 语义有一处与历史实现有意不同,那里写明了原因。
一、交互式模式
不带参数、且在真正的终端里跑,就会进菜单。菜单用的是 inquirer 的纵向列表 (方向键选、回车确认),不是输编号:
? 聪明山 运维台 (Use arrow keys)
❯ 用户 7 条命令
角色 7 条命令
博客 4 条命令
评论 4 条命令
云剪贴板 4 条命令
投票 4 条命令
图床 3 条命令
邀请码 3 条命令
小鱼干 8 条命令
审计日志 1 条命令
申诉 2 条命令
站点概览 1 条命令
OAuth 应用 4 条命令
审计身份:owner cms 切换
查看全部命令与用法 --help
退出
选中分组后列的是命令原名(不是「搜博客」这类改写过的说法),每项后面跟着该命令的摘要:
? 评论 (Use arrow keys)
❯ ← 返回上一步
✕ 取消本次操作
comment search 搜评论(含已删)
comment show 评论详情
comment restore 恢复被删除的评论
comment delete 删除评论
接着按该命令的参数逐个提问。自由文本提示都带导航后缀;select 类参数同样是方向键列表:
关键词(评论正文 / 作者用户名 / 所属文章标题;留空 = 最近一页)(:b 返回上一步,:q 回主菜单)› _
状态 1. 全部(含已删除) 2. 仅未删除 3. 仅已删除
写操作在动手前会弹确认框,逐项列出具体将发生什么(目标、字段级变更、后果), 而不是笼统的「确定吗」:
──────────────────────── 即将执行 ────────────────────────
命令:comment restore
执行者:owner cms(审计 admin_id = e43de295-…)
评论 id …
原因 …
──────────────────────────────────────────────────────
评论作者:alice
所属文章:《构建报错排查》
正文预览:这个问题报错在第三行…
变更:BlogComment.isDeleted → false;重算文章评论计数与最后评论时间。
本次操作会写入审计日志(内部留痕,不进 /audit 公示页)。
──────────────────────────────────────────────────────
确认执行? (y/N) › _
几个要点:
- 不用背命令,也不用背 ID。 凡是「选某个实体」的参数(文章 / 评论 / 剪贴板 / 投票 / 图片 / 用户 / 申诉),向导都让你输关键词 → 从结果里挑。
- 导航约定:选命令的二级菜单、以及 select 类参数,前两项固定是
「← 返回上一步 / ✕ 取消本次操作」;自由文本提示后写着
(:b 返回上一步,:q 回主菜单)。 顶层分组菜单没有返回项(它已经是最外层),也没有字母快捷键 —— 全靠方向键。 - 退回上一题会带出原答案当默认值,不用重敲;与当前选择无关的题根本不问 —— 例如密码来源选了「生成随机密码」,就不会再问新密码(改回去的话先前填的那个也会作废)。
Ctrl-C只取消当前操作、回主菜单,不退出整个工具;也不会留下半完成的写入。- 非交互(管道 / CI)下不会进菜单,而是打印帮助后退出 —— 绝不会挂在 stdin 上等输入。
二、全局参数
| 参数 | 作用 |
|---|---|
--json |
结构化输出。stdout 只出一个 JSON 对象,人读内容与提示改走 stderr,所以 | jq 永远是干净的。隐含非交互 |
--yes / -y |
跳过危险操作的二次确认(非交互场景必须显式加上) |
--as <username> |
指定审计身份(默认取库内最早的站长) |
--no-color |
关闭颜色(也遵循 NO_COLOR 环境变量;非 TTY 自动关闭) |
--help / -h |
顶层帮助;放在命令后则是该命令的详细帮助 |
三、退出码
沿用历史 CLI 的 click 风格(退出码约定):
| 退出码 | 含义 |
|---|---|
0 |
成功(包括用户主动取消 —— 取消不是错误) |
1 |
参数或用户错误(用户名不存在、amount 不合法、业务规则拒绝——余额不足也走这一档) |
2 |
本地事务失败(真故障:不是你的输入问题。本地没有任何变更,可稍后重试) |
npm run cli -- fish grant alice 100
case $? in
1) echo "被拒绝(参数非法 / 余额不足)—— 重试没用,先修正输入" ;;
2) echo "本地事务失败 — 未做任何变更,稍后重试;上面那行「原因」是要查的东西" ;;
esac
2这个号当年是留给「站外账户微服务同步失败」的。账户搬进站内后那个场景不存在了, 号码保留、含义改成「本地事务失败」——0/1/2是脚本契约,别因为「没有 503 了」 就把它删掉(服务层那三档的对应关系见docs/architecture.md§6.3)。
四、确认与审计
危险操作需要确认
| 危险级别 | 命令 | 行为 |
|---|---|---|
| 破坏性 | 角色变更 · 用户禁言 · 强制下线 · 重置密码 · 站长建号 · 文章/评论/剪贴板/投票的删除与恢复 · 图床恢复 · 申诉裁决 | 终端里弹「即将执行」确认屏;非交互必须加 --yes |
| 不可逆 | invite revoke(物理删除邀请码行) · fish compensate(群发 core+) |
同上,且确认屏会额外标注「不可恢复」 |
| 安全 | 各类检索 / 查看 / stats overview / fish grant、fish deduct / OAuth 应用管理 |
不确认 |
确认屏会列出具体将发生什么(目标、字段级变更、后果、是否通知对方),而不是笼统的「确定吗」。
鱼干为什么基本不在危险集里:写路径就是一次本地事务(余额与流水同事务提交), 要么整体生效、要么整体回滚,没有「钱动了一半」的中间态;每一笔都留在
fish_transactions流水里可查可核。失败的两种结局都是干净的(业务拒绝 = 没写,真故障 = 整体滚回)—— 再加一道确认只会让docs/里的示例不能直接粘贴执行。
fish compensate是唯一的例外,而且它要确认的理由不是「怕写坏账」(写路径本身就是 要么全成、要么整体回滚),是规模:一条命令改的是全部 core+ 用户的余额,敲错一个 数量级就得再发一轮反向补偿才能拉平(fish deduct一次只能扣一个人)。所以它标irreversible。
审计身份
多数写操作会记一条审计日志。审计主体:
--as <username>指定的用户;否则- 库内最早的站长(
createdAt升序);都没有则 - 直接报错,绝不伪造一个主体。
为什么不能伪造:admin_action_logs.admin_id 与 user_bans.admin_id 都是指向
users.id 的真实外键,而且审计日志的全部意义就在于「这是谁做的」。
后台写下的日志是「内部日志」
CLI 写下的每一条审计日志都落 visibility='internal':不进 /audit(顶栏「日志」
那张公示页),但照常进库 —— 用 audit log --visibility internal(或 all)查得到。
为什么不干脆不记:那样等于把审计链自己剪断 —— 哪天要查「这个人的角色是谁改的、 谁在什么时候禁的言」,就只能靠猜。为了公示页干净而丢掉这个,不值。
用之前要知道的一个副作用:用户申诉的对象就是公示页上的那条日志(申诉入口挂在 日志详情页上),公示页看不到 = 对方无法申诉。所以:
- 处罚真实用户(禁言 / 删文章 / 删评论)而还想留申诉渠道的,用网页后台的对应 入口 —— 那里写的是公开日志,确认屏上也会把这点写出来。
- CLI 更适合「我自己知道后果」的操作:建号、重置密码、修数据、批量补偿。
⚠️ 例外 —— 这些写操作根本不写审计日志,
audit log里也找不到:fish grant/fish deduct/fish compensate/oauth create-app/oauth disable-app/oauth enable-app/invite generate。鱼干那三条是现状,不是结构约束:当年不写是因为写路径是三段结构(本地事务 + 远端 HTTP + 补偿事务),
logAdminAction挤进去会占满 SQLite 写锁;现在只剩一次本地 事务,那条理由已经不成立。要不要补上是一次独立的决定 —— 在那之前,它们留下的凭据 是fish_transactions流水(fish compensate另有按批次派生的幂等登记行,一条 / 人)。
两个由此而来的限制
- 不能修改自己的角色(
setRole的既有规则)。站点只有一个站长时,他不能用 CLI 把自己降级 —— 要么--as指定另一个站长,要么先加一个。 - 不能重置自己的密码(自助改密走网页端,要验原密码)。CLI 能免原密码重置自己 等于把「会话劫持」做成了一条命令。
五、命令清单
角色管理
角色体系:user → core → admin → owner。权限分档:涉及 admin / owner 的
任何方向都只有站长能做;user ↔ core 归管理员。
| 命令 | 作用 |
|---|---|
promote-admin <username> |
提升为管理员 |
demote-admin <username> |
撤销管理员(降为 core) |
promote-core <username> |
提升为核心用户 |
demote-core <username> |
撤销核心用户(降为 user) |
promote-owner <username> |
提升为站长 |
demote-owner <username> |
撤销站长(保留管理员) |
role set <username> <role> |
统一入口,交互式向导用的就是它 |
已经是目标角色时打印「提示:xxx 已是…」并退出码 0(不是错误)—— 脚本据此判定有没有真的改。
用户
| 命令 | 作用 |
|---|---|
user search [关键词] |
按用户名 / 邮箱搜 |
user show <username> |
详情:角色 / 禁言 / 鱼干余额 / 文章数 / 评论数 |
user reset-password <username> [generate|manual] [--password <新密码>] --reason <原因> |
重置密码(旧会话全部失效)。manual 模式必须给 --password(≥8 位);generate 模式不给,给了会报错(不是静默忽略) |
user ban <username> --hours N --reason <原因> |
禁言 |
user unban <username> [--reason <原因>] |
解除禁言 |
user force-logout <username> [--reason <原因>] |
强制下线(比禁言轻一档) |
user create <username> --password <初始密码> [--email <邮箱>] [--reason <原因>] |
新建账号:站长专属,不经过人机验证与邀请码,角色直接是 core(见下) |
# 重置密码:默认生成 16 位随机密码,只显示一次
npm run cli -- user reset-password alice --reason "用户申诉邮箱被盗" --yes
# 新密码:xK9mP2qL7vN4wR8t
新密码永远不会进审计日志 —— /audit 是公开页。
user create(站长建号)
生产机到 Cloudflare 的出口被墙,Turnstile 的服务端校验不可用。站长继续开着验证码 (等于关闭匿名注册以防批量注册),改用这条命令手动给认可的人开号。管理后台的 用户管理 → 新建用户是同一个功能的网页入口,规则完全一致。
# 不填邮箱 → 自动合成 <用户名>@users.invalid(RFC 2606 保留 TLD,永不投递)
npm run cli -- user create alice --password 'Hunter2Hunter2' --reason "朋友,验证码过不去" --yes
# 有真实邮箱就填上,别用占位邮箱
npm run cli -- user create bob --password 'Hunter2Hunter2' --email bob@example.com --yes
- 不消耗邀请码 —— 角色直接给
core,不走「邀请码升级」那条路。 - 密码与邮箱都不会进审计日志:
/audit是公开页,那里只记create_user这个动作。 - 邮箱留空的账号收不到任何邮件(当前站内也没有邮件功能,但别指望它能用来找回)。
- 建号遇到意外故障时这条命令退出码 2(和
fish grant一样:那是「本地事务失败」 那一档,不是你的输入问题),且本地不会留下半截用户 —— 建号走的是与网页注册同一个 内核:用户行在一个事务里写入,任何异常都整体回滚。
内容检索与恢复
这一组是这套工具存在的主要理由:站内此前既没有评论 / 剪贴板搜索,也没有任何 「找回被误删内容」的入口(除了申诉通过时的副作用)。
| 命令 | 作用 |
|---|---|
blog search [关键词] [--status all|active|deleted] |
搜文章,含正文 |
blog show <id> / blog restore <id> / blog delete <id> --reason <原因> |
查看 / 恢复 / 删除 |
comment search [关键词] [--blog <文章id>] [--status …] |
搜评论 |
comment show <id> / comment restore <id> [--reason <原因>] / comment delete <id> [--reason <原因>] |
查看 / 恢复 / 删除。处理他人评论时 --reason 必填;动自己的评论可不填 |
clip search [关键词] [--status …] [--publicity …] |
搜云剪贴板(含私有) |
clip show <id> [--full] / clip restore <id> / clip delete <id> [--reason <原因>] |
查看 / 恢复 / 删除。--reason 可选 |
vote search / vote show / vote restore [--reason <原因>] / vote delete --reason <原因> |
投票同上。⚠️ 删除必填原因,恢复可不填 —— 两者不对称 |
image search / image show / image restore |
图床(没有物理删除)。⚠️ image restore 是破坏性操作,终端会弹确认屏,非交互必须 --yes |
几处不显然的行为:
- 检索默认含已删除(
--status默认all)。想只看活着的加--status active。 - 搜文章默认搜正文(标题 / 描述 / 正文 / 作者 / 精确 id)。网页后台仍是只搜标题 —— 那条服务是 opt-in 的,网页行为没有变。
- 列表不显示正文:剪贴板正文上限 5 万字,一页 20 行就是近一兆。正文用
clip show单条看。 image show会报告磁盘文件是否还在。软删只翻标志位、不删文件,但文件可能被手工 清理过 —— 恢复一条文件已不在的记录,页面上会是坏图,这件事必须在动手之前看到。- 恢复评论时会提醒
status ≠ approved的情况:isDeleted与status是两个正交的 闸门,只翻前者的话评论恢复了也不会出现在评论区。 - 每条 search 都支持
--keyword/-q和--page(关键词也可以写成位置参数, 如blog search 报错栈;翻页默认第 1 页)。上表为省版面只写了位置形式。 这两个参数在向导里也会问,不用记。
# 典型流程:找回一篇被误删的文章
npm run cli -- blog search 报错栈 --status deleted
npm run cli -- blog restore 2b7ec270-be9c-4283-b1a2 --reason "作者申诉,误删" --yes
邀请码
| 命令 | 作用 |
|---|---|
invite generate [-n N] |
生成(12 位 base62,填了即升 core) |
invite list [--filter all|unused|used] |
列出 |
invite revoke <码|ID> |
撤销未使用的码 |
invite revoke 是本工具里唯一破「永不物理删除」的地方 —— InviteCode 没有软删列,
撤销只能是 DELETE。因此收窄到最小:
- 未使用的码才删(后果只是持码人注册不了,这正是「撤销」的语义)
- 已使用的码会被拒绝:
used_by是「谁邀请了谁」的唯一记录,删了就永久丢失 - 审计日志里只记数字 ID,绝不记码值 ——
/audit是公开页,码就是注册凭证
真要撤销已使用的码,那需要给 InviteCode 加 is_revoked 列 + 手写迁移,是另一件事。
小鱼干
| 命令 | 作用 |
|---|---|
fish grant <username> <amount> [-d 说明] |
赠送(一次本地事务) |
fish deduct <username> <amount> [-d 说明] |
扣减(一次本地事务) |
fish balance <username> |
查余额 |
fish compensate <amount> [--batch-id ID] [--dry-run] |
给全部 core+ 群发补偿(逐人原子) |
fish credential-list <username> |
列出某用户的鱼干只读凭据(不含明文与哈希) |
fish credential-revoke <id> |
吊销一张只读凭据(立即失效,幂等) |
fish webhooks [username] |
列出回调地址与投递积压(留空列全部) |
fish webhook-retry |
立刻重投待发回调(不等退避) |
fish webhook-rekey |
把存量回调签名密钥搬到专用加密密钥(迁移期一次性,可重复跑) |
fish liquidate |
立刻扫一轮练手盘的杠杆仓强平(不等定时器) |
关于只读凭据:站外机器人 / 银行用它查余额与流水(Authorization: Bearer),
查不了钱也动不了钱,可单独吊销,改密码不会作废它。凭据由用户自己在站内签发
(/fish/api,签发要再输一次密码),CLI 这两条是运维侧的口子 —— 只在用户不配合、
或凭据泄露但联系不上本人时用。凭据不物理删除,只标记 revokedAt。
关于回调:钱到账时主动通知商户(/fish/api 上自助配置)。投递是
at-least-once —— 商户可能收到重复回调,靠 header 里的 X-Raricy-Delivery 去重。
失败会自动重试(指数退避,约 10s/1m/5m/30m/2h/6h),耗尽后置 dead。
fish webhooks 里那一列死信就是「商户没收到通知」的那几笔,要提醒它拉流水对账。
⚠️ 失败到判死不会自动停用地址 —— 悄悄停掉全部回调是典型的静默失效: 商户以为还在收通知,其实早就没了。要停由商户自己在页面上停。
关于 fish webhook-rekey:回调签名密钥是加密落库的。搬之前,加密钥匙由
SECRET_KEY(会话 JWT 的签名密钥)派生 —— 换一次会话密钥,商户就再也收不到回调,
而站点这边只在投递日志里看到失败。这条命令把存量密文重封到专用钥匙
FISH_ENCRYPTION_KEY 上(只换封装、不换密钥本身:商户手里那把签名密钥不变,
验签代码一行都不用改)。.env 里同时有这两把钥匙时跑一次即可,跑完 SECRET_KEY
就只是会话密钥了。逐行判状态(已搬过的原样跳过),所以中断、部分完成、
或「当时解不开后来补上了钥匙」都能靠再跑一次收敛;两把钥匙都解不开的行它不动,
会列出来要求人工查证。npm run diagnose 段 4 报的就是「还剩几条没搬」。
关于 fish liquidate:练手盘的杠杆仓有一个强平引擎在跑(常驻后台循环,默认
每 15 秒扫一轮,见架构 §6.13)。这条命令是它的手动那一份 —— 引擎被关掉过一段时间、
或刚重启完想立刻补一轮时用。判断、结算价、副作用与引擎逐条相同:现价穿过那行上
存着的爆仓价就结清,结算价用爆仓价(持户亏光投入、不会亏穿),不写鱼干流水
(实发恒为 0)。也可以与定时器同时跑,不会重复结清。
⚠️ 它不可撤销(只把仓位置成 liquidated,没有撤销路径),而且引擎被关掉往往
正是因为行情源不可信 —— 那时手工跑等于拿一份可能有问题的价去结清别人的仓位。
amount 是正整数,单位是整个小鱼干。⚠️ 这是 CLI 刻意收窄的,别当成全站口径 ——
网页与 API 那一侧收最多 4 位小数(鱼干在库内的最小刻度是 0.0001,见
docs/architecture.md §6.3)。运维发钱按整数走是为了少一个手滑的小数点。写路径就是一次本地事务(余额与流水同事务
提交):要么整体生效、要么整体回滚,没有中间态,也绝不静默成功。两种失败各自对应一个
退出码:业务拒绝(余额不足、amount 非法)→ 1;本地事务失败(真故障)→ 2,
此时本地没有任何变更,稍后重试即可。(ACCOUNT_SERVICE_INTERNAL_TOKEN 等四个
ACCOUNT_SERVICE_* 变量已废除,CLI 不会再读它们 —— 见 docs/deploy.md §12。)
崩在写入中间怎么办:不需要怎么办 —— 崩在事务提交前等于整体回滚,崩在提交后等于整体
生效(SQLite 自己保证),不存在「余额动了、流水没写」这种要人工收敛的状态,也没有对应的
重放命令。唯一要人工查证的是迁移前遗留的账目:fish compensate 命中的 blocked 那
几位,见下。
群发补偿 fish compensate
给全部 core+ 用户(core / admin / owner)发放同样数量。
非核心账号一分不发。 鱼干在站内的赚取渠道(签到翻牌、投喂分成)全在 core 门槛之后,
给 user 角色空投等于「注册就有鱼干」,与这套口径直接冲突;何况这条命令一次改的是全站
余额,多发的人越多、回滚成本越高。被禁言者照发 —— 补偿是系统行为,与个人当前状态无关,
禁言只停发言权、不没收财产。取的是当前角色,所以曾降权的账号会被跳过。
npm run cli -- fish compensate 10 --dry-run # 先看计划,不动账
npm run cli -- fish compensate 10 -d "故障补偿" # 交互式会弹确认屏
npm run cli -- fish compensate 10 -d "故障补偿" --yes # 脚本 / 非交互
失败语义是「逐人原子」,不是「全有或全无」。 每人独立走一次本地事务(余额 + 流水 + 幂等登记一起提交),所以中途失败不回滚已经发出去的部分:前 300 人拿到了,后面的 没有。没发成的那几位,他们那笔事务整体回滚,对他们等价于没发生 —— 用同一个批次 ID 续跑接着发。
逐人而不是「一个大事务发给所有人」是刻意的:一锤子的大事务要么全成要么全败,拿不到
「发到哪了」,也就没有续跑可言;而且它会把 SQLite 写锁一次性占满整批(上千人的写入,
期间全站写路径排队等锁)。详见 src/lib/fish-compensate.ts 头部。
历史注记:迁移前的实现是「一个大事务发给所有人」,逐人是刻意换掉的形状。当年它还有 第二条理由 ——「远端 HTTP 不能放进 SQLite 事务」;账户搬进站内后那条约束消失了(现在 只有一次本地事务,也没有第二个存储要对账),形状仍然保留,理由如上。
续跑——用同一个批次 ID 重跑,已发放的会自动跳过:
# 批次 ID 在开跑前就打印出来(进程被杀也找得回),失败时退出码 2 的消息里也带完整续跑命令
npm run cli -- fish compensate 10 --batch-id 3f9a2c81d0b4 --yes
去重靠的是由批次派生的确定性幂等键(comp-{sha256('compensate-{batchId}-{userId}-{amount}')[:16]},
派生算法与迁移前的实现逐字节相同 —— 当年用某个批次 ID 只跑了一半的批次,现在传同一个
ID 就能接着跑)。这不是锦上添花,是必须的:若只是「重跑一遍」,同一批人会实打实再拿
一次(这笔钱是真的发出去了,不是账面误差)。
迁移前遗留的非 synced 行会被跳过并警告,不会重新发放。 新写入的登记行一律是
synced(登记与发放同事务提交),所以命中的只可能是当年「本地已提交、远端那半笔状态
不明」的欠账。这些用户必须人工查证 —— 重发会在本地实打实叠加一笔;收敛它们的那条
重放命令已随账户服务一起撤销,代码里没有、也不该有自动重放。传 --batch-id 续跑时,
确认屏会把这批人的数量列出来。
审计日志与申诉
| 命令 | 作用 |
|---|---|
audit log [--user <用户名>] [--action <动作>] [--since 7d] [--visibility …] |
检索审计日志 |
appeal list [--status pending|accepted|rejected|all] |
列出申诉 |
appeal decide <id> accept|reject [--note <说明>] |
裁决(通过时会自动撤回原处罚) |
与公开的 /audit 页不同,audit log 看得到 visibility 非 public 的内部日志,
也没有 30 天窗口 —— 排查陈年问题要能翻到任意久之前。
appeal decide 通过时会尽力撤回原操作:
| 原操作 | 撤回动作 |
|---|---|
ban_user |
自动解除禁言 |
delete_blog |
恢复文章(ignore=false) |
delete_comment |
恢复评论并回补文章评论计数 |
| 其它 | 不撤回任何数据(确认屏会写明) |
不能裁决「针对自己」的申诉 —— 申诉是对管理权力的制衡,自裁会让这道闸失效。
站点概览
| 命令 | 作用 |
|---|---|
stats overview |
人数 / 内容量 / 已删内容 / 待审申诉 |
交互式向导打开后,这一屏是了解「现在站点什么状态」最快的入口。
OAuth 2.0 应用
注册和管理第三方应用(client_id / client_secret / 回调 URI)。协议细节见 docs/oauth.md。
oauth create-app <name> [--owner <username>] [--homepage URL] [-d 说明] --redirect-uri URI [...]
oauth list-apps
oauth disable-app <id|client_id>
oauth enable-app <id|client_id>
npm run cli -- oauth create-app "cattca-game" \
--homepage "https://cattca.example.com" \
-d "CattCa 站点的用户绑定" \
--redirect-uri "https://cattca.example.com/oauth/callback"
# client_id: AbCdEf123...
# client_secret: XyZ_987... ← 仅此一次,请立即复制
⚠️ client_secret 仅此一次显示。命令行若被记录(shell history / CI 日志),
请同步清理;推荐写到 secrets manager 而不是明文文件。
头像框
发放 / 收回 / 盘点用户的头像框。用户侧只能在 /settings 决定「戴哪个」,
而「有没有资格戴」由这里决定 —— 另一条授予路径是鱼干商城(用户花鱼干租,
1 鱼干 / 天,见 docs/architecture.md §6.14),本域命令不受它影响:
frame grant 照旧能发任何一款框,包括在售的那款。
frame grant <username> <key> [--days N] [-r 说明]
frame revoke <username> <key> [-r 说明]
frame list [username] [--keys]
npm run cli -- frame grant alice sakura --days 30 -r "中秋活动"
# 已发放:alice ← 「樱花」
# 到期:2026-10-20T12:00:00.000Z
npm run cli -- frame list alice
# alice 樱花(sakura) 有效 到期:2026-10-20 12:00:00 来源:cli 佩戴中
npm run cli -- frame list --keys # ★ 素材体检
# sakura 樱花 素材:有 透明通道:有 18244 字节 租金:—
# fishblue 鱼干蓝 素材:有 透明通道:有 6953 字节 租金:1 鱼干/天
租金:是鱼干商城的在售价(鱼干 / 天);—= 不零售,只能由这里发放。 它同时是「哪些框在卖」的清单 —— 定价改在src/lib/frame-refs.ts的FrameDef.rentPerDay,改了这里立刻跟着变。
语义要点
- 授予 ≠ 装备。发下去只是给了资格,戴不戴是用户自己的事(
/settings)。 - 幂等且只延长不缩短:已持有时取「原到期」与「新到期」的较晚者。已经是永久的,
再发 30 天仍是永久(空操作)。要缩短或收回,用
frame revoke。 - 收回会顺带摘下:正戴着这个框时,收回会连同装备状态一起清掉(同一事务)。 没持有过、或已经收回过的,照样成功(幂等)。
- 到期是懒判定:限时框过期后,库里那两列留着过期的值、不做任何清理,
判定时恒为「不显示」。所以想让某人下周失效,用
frame grant --days 7,别用 revoke。 - 退役一个框:把
src/lib/frame-refs.ts里那条的retired置 true, 不要从FRAME_KEYS里删 —— 删了用户就摘不掉它了(面板认不出那一行)。
⚠️ 素材缺失只警告,不算失败
授权是写库的,素材在 public/static/frames/<key>.png(随代码入库,不是运行时数据)。
授权成功但盘上没有那张图时,本域命令只打黄色警告、退出码仍是 0 ——
那时全站都不会显示这个框,而页面不会有任何报错。frame list --keys 是
唯一能主动发现这件事的地方。
frame list --keys 还会报「没有透明通道」——那种素材会盖住用户的脸,
是全站一起坏的一种。
六、注意事项
- 跑 CLI 的机器是权限边界。CLI 不校验「执行者本身是不是站长」(权限分档在服务层,
见
src/lib/admin-user-service.ts的setRole)。生产环境务必把跑该命令的服务器 shell 列入堡垒机白名单。 - Windows 终端的中文显示:若控制台代码页不是 UTF-8(
chcp 65001可切), 中文会显示成乱码。这是终端环境问题,工具本身无法代劳。 - 交互式模式需要真正的 TTY。在 IDE 的内嵌终端、某些 CI 环境中
isTTY可能为 false,此时会自动退化成打印帮助 —— 属预期行为,用命令式即可。
改了代码之后的手工冒烟
npm run cli # 进菜单,走一遍「搜到被删评论 → 恢复」
npm run cli -- stats overview # 概览能出数
npm run cli -- blog search <正文里的词> --status deleted
npm run cli -- blog restore <id> --yes # 再 search 确认已恢复
npm run cli -- audit log --action restore_blog # 审计里有,且主体是真实站长
npm run cli -- fish compensate 1 --dry-run --yes # 只列计划、不动账(鱼干那一组还能用)
在向导里按一次 Ctrl-C,确认它回到菜单而不是退出、也没留下半完成的写入。
七、相关脚本
| 脚本 | 与 CLI 的关系 |
|---|---|
scripts/check-secrets.mjs |
检测密钥与生产数据有没有进版本库 |
scripts/diagnose-deploy.mjs |
部署前自检(运行时版本 / .env / 数据库 / 密钥) |
scripts/compensate-unclaimed-fortunes.mjs |
一次性补偿「已签到未翻牌」的鱼干记录 |
scripts/cli.ts |
CLI 入口;命令声明在 scripts/cli/registry.ts |
八、给维护者:加一条命令
- 在
scripts/cli/commands/<域>.ts里加一个CommandSpec, - 挂进
scripts/cli/registry.ts的COMMANDS。
--help 与交互式向导会自动跟上 —— scripts/cli/wizard.ts 不需要改一行,因为它就是
拿注册表的元数据当脚本用的。tests/unit/cli-registry.test.ts 会守住注册表自身的完整性
(命名、位置参数序号、flag 不与全局冲突、危险命令必须写 describe),
tests/unit/cli-guards.test.ts 会守住三条硬约定(--help 不加载 Prisma、时间戳只用一把钟、
不许顶层 await —— scripts/ 按 CJS 语义执行)。