文档索引

项目架构

聪明山 / raricy.com 的当前架构:Next.js 15 主站 + Prisma 6 + SQLite, 以及同仓、统一启停的实时游戏进程。主站业务和鱼干账目仍在同一进程内自洽。

上一版是 Flask 单体,2026-07 全部替换。本文档只描述现役架构;Flask 历史仅在「迁移史速查」一节列出。

1. 技术栈

层 选型
框架 Next.js 15(App Router)· React 19 · TypeScript 5
ORM Prisma 6(provider=sqlite),开发库 instance/database/dev.db,生产库见部署配置
鉴权 JWT(jose)· 密码哈希与历史 werkzeug 双向互通(用户无需改密)
会话 JWT cookie + User.sessionVersion 失效机制(改密 / 禁言 / 强制下线时递增,旧会话立即全废)
CSRF src/middleware.ts 对状态变更方法校验 Origin/Referer 同源
文件落盘 头像/图床/故事/表情包落 instance/(gitignored,六个子目录见 §3)
鱼干账户 站内余额与账本,同一个 SQLite 事务(见 §6.3)
实时游戏 Node.js + ws 独立进程,共用仓库、lockfile、环境与启停入口;承载 Wand demo
渲染 博客 / 评论 / 故事 — 客户端 marked + DOMPurify(详见 §6.7)
测试 vitest 单测 + Playwright e2e
限频 进程内内存限频(src/lib/rate-limit.ts)
部署 npm + nginx 反代 + systemd(建议)

2. 进程拓扑

浏览器 ── HTTPS ── nginx ── 主站请求 ── Next.js(单实例)── Prisma / SQLite
                       └── /realtime/* ── 实时服务(独立进程)

主站负责页面、账号、权限和所有现行业务数据;鱼干账户已在站内,不再有账户微服务。 主站的限频桶与 SSE 订阅表仍遵循单实例前提,实时进程不共享这些内存对象。 实时服务承载 Wand 的权威移动与多人同步,通过独立服务凭据调用主站消费一次性票据、 复核会话版本与账号状态;它不直接读数据库。统一入口读取主站环境、生成内部凭据, 用一个服务管理单元共同启停两个进程;实时进程不接收主站会话密钥。 启动、认证与接入契约见 docs/realtime.md。ATÅMAS 在浏览器内运行。 instance/ 是 gitignored 的运行时数据目录,部署机器提供真实内容。

3. 目录布局

.
├── src/app/                App Router 页面 + API 路由(详见 §4)
├── src/lib/                业务逻辑层(详见 §5)
├── src/middleware.ts       CSRF 同源校验
├── prisma/
│   ├── schema.prisma       36 表 1:1 映射真实库
│   └── migrations/         含 0_init 基线(已 apply 到 db.db)
├── scripts/                运维/自检/迁移/切换脚本
├── tests/                  vitest 单测 + Playwright e2e
├── docs/                   本文与运维文档;guide/ 为玩家/创作者文档
├── public/                 静态资源(图标 / CSS / favicon)
└── instance/               gitignored: avatars/ database/ frames/ images/ stories/ stickers/ blogs/

4. 路由分布(src/app/)

路径 类型 说明
/ page 导航首页(不列文章)
/login · /register page 认证(登出是 POST /api/auth/logout,没有 GET 路由)
/blog · /blog/upload · /blog/[id]/edit page 博客列表与编辑 —— 一律 core+
/blog/[id] page 文章详情。按身份两种视图:core+ 看成员视图(正文 + 互动 + 评论),访客看纯阅读视图。文章 visibility 为 link / public 时访客可读,internal 时访客落到登录页(见 §6.11)
/explore page 对外公开列表(public 档,与 sitemap 同一个集合)。全站唯一不需要登录的浏览面,访客与非 core 的「博客」入口指向它(见 §6.12)
/api/blogs · /api/blogs/[id] · /api/categories · /api/spider/* API 博客 API + 栏目清单 + 爬虫 API。全部 core+,与 /blog 页面同档(读口含正文搜索那条重活,见 §6.5)。/api/categories 是给发文方查 category_id 的读口(此前只有 /api/admin/categories,机器人无从枚举);发文对外契约见 docs/bot/blog-bot.md
/api/og/blog/[id] API 分享卡片 PNG(OG 图)。无会话档位,逐篇判可见性:只对外可见的文章返回 200,其余与「不存在」同形 404。缓存与 X-Robots-Tag 按档位发(见 §6.11)
/api/auth/authentic · /zhh API + route 邀请码升 core · 邀请码生成(站长)
/fish · /fish/transactions · /api/fish/* page + API 小鱼干面板 + 流水
/fish/market · /api/fish/market/* page + API 鱼干市场:用户间转账(无手续费)+ 鱼干商城(租头像框 §6.14、聊天气泡 §6.18)。POST transfer(支持客户端幂等键)/ GET users(收款人搜索)/ POST balance、POST transactions(站外脚本查询,含 since_id 对账游标)/ POST pay(收银台专用)/ POST rent、POST bubble-rent(装扮租赁,仅会话)。写路径见 §6.3;对外契约见 docs/bot/fish-bot.md(两条装扮租赁不在其中)
/fish/pay page 收银台:站外商户把用户送来付款(?to= &amount= &note= &from= &return=)。参数一律不可信,只做展示;付款必须已登录 + 再输一次密码(step-up),密码只输在本站域名下。不入索引
/fish/collect page 扫码收款页:?to=<用户名>,扫「鱼干收款码」落到这里。与收银台的区别是金额由付款人自己填(静态码不可能带金额)。前端是 /fish/pay 的同一个组件(src/app/fish/PayForm.tsx)的另一个变体,step-up 与幂等键完全共用
/fish/api · /api/fish/tokens/* page + API 机器人接入自助页:签发 / 吊销只读凭据(GET/POST /api/fish/tokens、DELETE /api/fish/tokens/[id])。只认会话、签发要 step-up、只能动自己的。只读凭据的鉴权门在 src/app/api/fish/market/_auth.ts(第三道门,allowReadToken 默认关);签发/校验/吊销在 src/lib/fish-token-service.ts
/fish/trade · /fish/trade/stats · /api/fish/trade/* page + API 鱼干练手盘:投入鱼干买入一个绑定真实加密价格的仓位(core+,签到之外第二条赚取渠道)。POST buy(客户端幂等键)/ POST sell / GET quote(展示行情)/ GET candles(图上那段 K 线,只喂展示)。档位与「专注模式屏蔽练手盘」在两个页面与四个接口六处各判一次,禁言只有 buy 判。只走 https:这六条路径还压在 src/middleware.ts 的协议闸下(回环地址豁免,见 §6.4)。/fish/trade/stats 是只读的个人统计页(总览 + 按标的 / 杠杆拆解 + 持仓快照),没有接口:server component 直连服务层,全部数据来自 market_positions 既有列,不需要迁移。/fish/trade 下方另有一块最近结清(listSettledPositions,逐笔列出平仓 / 爆仓 —— 补上「实发为 0 不写流水」留下的缺口,同样只读仓位表)。见 §6.13
/api/poster/profile/[id] · /api/poster/collect API 画报 / 收款码出图(PNG,仅本人)。渲染管线与四条约束见 §6.8
/notifications · /api/notifications/* page + API 通知中心。其中 GET count 是顶栏指示器的兜底快照(不能删:SSE 有「连着但收不到」的半死状态),GET stream 是实时流(SSE,未登录 401;首帧全量快照 + 之后增量补丁)。推送点纪律与依赖方向见 src/lib/topbar-bus.ts 头部
/vote · /vote/[id] page 投票
/checkin · /api/checkin page + API 每日签到(core+:鱼干的赚取渠道,与投喂/点赞同档)。一个请求签一次到,固定发 CHECKIN_REWARD_FISH 条鱼干。页面与两个方法(GET/POST)各自都要判档位,见 §8。另有一条内容前置条件:名下至少一篇未软删的文章(Blog.ignore = false,删光 = 没发过)—— 与档位是两道独立的门,GET 回 can_check_in: false 不拒、POST 403,见 checkin-service.ts 头部。禁言期间也不能签到:GET 回 can_check_in: false,POST 403,解除或到期后恢复
/clipboard · /clipboard/[id] · /api/clipboard/* page + API 云剪贴板
/image · /image/admin · /api/images/* page + API 图床 + 管理
/audio · /audio/admin · /audio/guide · /api/audio/* page + API 音频床 + 管理。独立配额(不吃图床那份 50MB),见 §6.6/§6.15
/image/i/<id> · /auth/avatar/<id> rewrite 不是路由:历史版本的旧直链,由 next.config.mjs 的 rewrites() 映射到 /api/images/<id>/raw、/api/avatar/<id>。存量正文里写死的就是它们(见 tests/e2e/legacy-urls.spec.ts)
/story · /story/[...path] page 故事合集/阅读
/tool · /tool/<sub> page 工具集(aes / base / hash / hex / html / qp / translate / url / cattca)
/admin/* · /api/admin/* page + API 管理后台(档位分页而异,见 §8)
/audit · /audit/[id] page 审计日志公示 + 申诉。页面(母版 src/app/audit/layout.tsx)、GET /api/audit 与提交申诉同为 core+ —— 「公示」的口径是公示给站内成员,不是对外透明。只列近 30 天、visibility='public' 的日志
/admin/logs page 审计日志运维检索(admin+,页面里 requireAdmin())。与 /audit 是两个东西:看得到内部日志(visibility='internal'),且不设时间窗。刻意不抹 hideTarget(匿名评论的当事人照常显示)—— 这正是它要 admin 档的原因(见 §6.16)
/contact · /privacy · /terms page 联系 / 隐私 / 条款
(无 URL)forbidden.tsx 特殊文件 403 页本身;由 forbidden() 原地渲染,不是 /forbidden 路由
/sitemap.xml · /robots.txt route sitemap.ts / robots.ts
/api/avatar/[id] · /api/images/[id]/raw · /api/audio/[id]/raw API 头像 / 图床 / 音频床原生分发。音频那条支持 Range(206)—— 播放器拖进度条与 Safari 的播放探测都靠它,图床这条没有
/api/audio/admin/[id] API 音频床站长硬删(物理删文件 + 删行)。用户侧那条 DELETE /api/audio/:id 一律软删
/api/frames/[key] API 头像框素材字节(public/static/frames/<key>.png)。刻意匿名 —— 与表情字节路由同性质:素材不属于任何账号、不随会话变化。到期不在这里判、也不该判:到期的是一条引用(谁在戴),不是这些字节(见 §6.14)
/api/users/me/frame API 我的头像框:GET 回持有列表 + 当前装备(都是判定后的结果),PUT 装备 / 换框 / 卸下({ frame_key: string | null })。用户侧唯一的写口,见 §6.14
/u/[id] page 公开用户主页(段名是用户 id(UUID),不是 username)。匿名可达(主页画报的二维码把站外人引到这里):上半截是固定身份条(头像 + 框 / 用户名 / 简介 / 注册日期,role 徽章按查看者分档 —— user-service.getPublicProfile),下半截是用户自己布置的积木墙。墙上只挂本来就对外可读的内容(public / link 文章、isPublic 图与音频),目标降档后整块不渲染;页面 noindex。见 §6.21

5. 业务逻辑层(src/lib/)

按子域分组(每组均与路由 1:1 或 1:多对应):

分组 文件
认证 / 会话 auth.ts · session.ts · password.ts · invite-code.ts · user-service.ts · identicon.ts · avatar.ts(头像字节的唯一解析处:/api/avatar/[id] 与画报共用同一份目录穿越守卫)· site-url.ts(SITE_URL → ALLOWED_ORIGINS 回退链的唯一实现,OAuth 的 userinfo 与画报的二维码前缀共用)· avatar-refs.ts(零依赖:avatarUrl(id) 是全仓唯一拼 /api/avatar/ 的地方。单独一个文件是因为 avatar.ts 拖着 node:fs,客户端组件 import 不了)
数据层 db.ts · db-time.ts · format.ts
博客域 blog-service.ts · feed-service.ts · comment-service.ts · comment-shared.ts · blog-sort-pref.ts · spider-service.ts
@ 提及 mention-shared.ts(评论 / 讨论共用的提取规则与光标搜索边界,零依赖)· mention-service.ts(按文章 / 会话范围搜索可提及用户)
富文本渲染 rich-text.ts(共享管线)· chat-markdown.ts · comment-markdown.ts · blog-markdown.ts · content-refs.ts(评论/讨论那条同步管线:只认 8 位与 10 位,9 位投票与 6 位收藏夹刻意不展开)· favorite-refs.ts([@六位] 卡片:只在博客/剪贴板那条管线生效,见 §6.9)· user-refs.ts([@用户/用户名] 名片:同样只在评论/讨论生效,见 §6.7)· markdown-math.ts · linkify.ts
Markdown 编辑器 md-editor/commands.ts(工具条的事务命令,零依赖:一切改动都走 view.dispatch,于是每步都进撤销历史)· md-editor/draft.ts(本地草稿键与防抖落盘)· md-editor/upload.ts(多文件调度:筛选 / 排队 / 超限拒收;单文件怎么发仍归 image-client.ts)· md-editor/upload-anchors.ts(上传锚点:零宽 widget,位置随事务映射、按选择顺序落位)· md-editor/insert-anchor.ts(「插入引用」面板的落点,同样靠 ChangeDesc.mapPos)· md-editor/resources.ts(资源面板的词汇与插入语法,零 React —— 拼串与解析同源,见 §6.7)。组件在 src/app/components/markdown-editor/(Toolbar.tsx / ResourcePanel.tsx / cm-theme.ts / export.ts)
表情包 sticker-refs.ts([@合集/表情] → 内联 <img>,跑在rich-text.ts 的净化之后)· sticker-service.ts(素材扫盘与三层缓存)· emoji-faces.ts(内置黄脸合集的编译期清单,素材从 npm 包拷进 public/static/emoji/)。安全边界与正则纪律见几者头部;玩家向说明见 docs/guide/表情包使用指南.md
讨论 chat-service.ts · chat-bus.ts(SSE 订阅)/ chat-shared.ts(DTO)· chat-presence.ts(「谁正在看哪个会话」—— 进程内,决定被 @ 时发不发通知)· chat-sidebar-pref.ts · focus-mode.ts
实时传输 sse.ts —— SSE 响应头 / 帧格式 / 重连与背压 / 心跳常量的唯一出处,两条流共用(讨论 chat-bus.ts、顶栏 topbar-bus.ts)。新增 SSE 路由一律 import 它,不要手抄响应头(no-transform 少一个字的后果见该文件头注释)
顶栏指示器 topbar-bus.ts —— 铃铛未读数 + 讨论红点的 SSE 订阅表(推增量补丁,首帧全量快照由路由拼)。推送点纪律(hasSubscriber 同步早退、算值必须在吞异常的 try 内)与依赖方向约束见该文件头部
通知 / 审计 notification-service.ts · broadcast-service.ts · audit-service.ts · admin-appeal-service.ts
投票 / 签到 / 剪贴板 vote-service.ts · checkin-service.ts · clipboard-service.ts
收藏夹 favorite-service.ts(六条不变量见文件头)· favorite-refs.ts([@六位] 的纯逻辑),见 §6.9
保存的消息(私聊) saved-message-service.ts(私聊消息保存箱:不是收藏夹,没有 6 位句柄 / 分享 / 导出 / 复制 / bot 出口),见 §6.20
图床 image-service.ts · image-sniff.ts(零依赖的内容判定:magic bytes / SVG 文本头 / HEIC·AVIF·MOV 的 ftyp 品牌 / 声明归一化 / planImageUpload —— 服务端与浏览器侧共用同一份)· image-upload.ts(服务端:配额 / sharp 压缩 / 落盘 / 落库)· image-client.ts(浏览器侧单文件上传:按字节归一化(HEIC → JPEG)/ XHR / 弱网重试。讨论、评论、博客与剪贴板编辑器共用这一条;编辑器侧的多文件调度另住 md-editor/upload.ts)
音频床 audio-upload.ts(magic bytes 嗅探 + MIME 别名归一化,无压缩)· audio-service.ts(含独立配额聚合)· audio-refs.ts([@音频/<ID>],零 import —— 要被拉进客户端包),见 §6.15
头像框 frame-refs.ts(纯词汇层:白名单 / 解析 / 到期判定 / 租金与在架清单,仅依赖共享租期 shop-refs.ts)· frame-service.ts(素材扫盘 + 持有与装备写路径 + 判定唯一出口;授予内核 grantFrameTx 收调用方的事务,供商城拼原子性)· frame-shop-service.ts(鱼干商城:租框。它不直接写那两列,一律经 grantFrameTx —— F1),见 §6.14
聊天气泡与商城词汇 bubble-refs.ts(白名单、展示与定价)· shop-refs.ts(共享租期)· bubble-service.ts(持有/装备唯一写入者与判定出口)· bubble-shop-service.ts(同事务扣款与发放),见 §6.18
故事 story-service.ts
画报 / 收款码 poster.ts(纯 SVG 构造,含二维码与转义)· poster-render.ts(取数 + 头像 + sharp 光栅化),见 §6.8
小鱼干 fish-service.ts(记账内核 postEntry + 读路径,见 §6.3)· fish-idempotency.ts(哪些操作才登记幂等 —— 判据在文件头)· fish-admin.ts · fish-market-service.ts(用户间转账,见 §6.3)· fish-compensate.ts(fish compensate 群发补偿,只发 core+,见 docs/cli.md 与文件头)· fish-units.ts(单位换算;Blog.fishCount 是例外,见文件头)· fish-webhook-service.ts(收款回调 outbox,见 §6.3)
练手盘 market-service.ts(开平仓:一个事务、没有补偿;开仓的幂等靠 open_key 唯一约束而非独立幂等记录;读口 listOpenPositions / listSettledPositions(最近结清,只读仓位表的终态行))· market-math.ts(结算公式的唯一实现,零依赖 —— 服务端真结算与页面「预计到手」是同一个 settleClose)· market-candles.ts(K 线词汇表:周期白名单 / 根数上限 / 线上形状 / 缓存键,零依赖,服务端与客户端共用)· market-chart.ts(K 线图的纯计算:窗口 / 聚合 / 刻度 / 映射 / 实时并线 —— 零依赖外加 db-time 的一个常量)· market-price.ts(行情源与展示缓存,见 §6.13)· market-stream.ts + market-poll-drainer.ts(喂展示的两个后台循环)· market-stats.ts(统计的纯聚合:终态白名单 / 拆解表分桶 / 持仓浮动盈亏 —— 白名单由调用方传进来,这样单元用例不必拉 Prisma)· market-stats-service.ts(统计的读路径:盈亏只能来自 market_positions,别改成从账本求和,理由见文件头)
OAuth 2.0 oauth.ts(见 docs/oauth.md)
管理域 admin-user-service.ts · admin-blog-service.ts · admin-category-service.ts · admin-comment-service.ts · admin-clipboard-service.ts · admin-vote-service.ts · admin-image-service.ts · admin-stats-service.ts
工具 / 安全 short-id.ts · safe-url.ts · guard.ts · rate-limit.ts · turnstile.ts · upload-quota.ts(上传落库的配额拒绝)· https-guard.ts(协议闸的判据:两区路径清单 / 明文判定 / 跳转目标 —— 零依赖,Edge 中间件直接吃,见 §6.4)
鉴权基建 credential-auth.ts(「用户名+密码」校验,/api/auth/login 与鱼干市场无状态接口共用,限频桶也共用)· request-ip.ts(反代后取真实 IP)
配额白名单 service-accounts.ts(FISH_SERVICE_ACCOUNTS 里的账号走 SERVICE_QUOTA:转账 500/时、5000/天。给「站外银行」这类自动化账号用,撤销即删配置)

上表是穷尽 src/lib/*.ts 的(新增文件记得补一行)—— §6.3、§8 会引用其中若干。 这张表漏过两次(先是讨论子域与 oauth.ts,后是 content-refs.ts / sticker-refs.ts / sticker-service.ts / fish-compensate.ts),症状都是正文引用的文件在本表里查不到。 核对命令:comm -13 <(本表提取的文件名) <(ls src/lib/*.ts | xargs -n1 basename)。

API 端点位于 src/app/api/<group>/<verb>/route.ts,薄层:参数校验 + 权限校验 + 调 src/lib/* + 组装响应。

6. 关键子系统

6.1 认证与会话

  • 密码哈希:src/lib/password.ts 选 scrypt / pbkdf2:sha256,与历史 werkzeug 字节级互通——从上一版实现接手的用户无需改密、完全不感知。
  • 会话:登录成功签发 JWT(jose,HS256),cookie 设 HttpOnly + SameSite=Lax。Secure 由 X-Forwarded-Proto 推断或 COOKIE_SECURE 显式控制。
  • 踢下线:User.sessionVersion 单调递增。session.ts 解析 JWT 后比对当前 user.sessionVersion,不一致则视为失效。 自助与管理员改密都按数据库当前值原子自增,避免密码计算期间覆盖其他撤权操作。 两者成对断开讨论与顶栏 SSE;建流先注册可被踢的订阅,复核数据库版本后才释放私有帧。 讨论还要复核 core+、禁言与「专注模式是否屏蔽大区」,因为角色与大区屏蔽的变化不递增会话版本。
  • 登出:只有 POST /api/auth/logout(base.js 的 window.logout() / LogoutLink 组件)。 清会话是状态变更,不能有 GET 入口 —— GET 会被本人以外的东西发起(浏览器预取视口内的 <Link>、爬虫、第三方页面上的 <img src="…/logout">,而本站刻意允许被 iframe 嵌入), 症状是用户莫名其妙掉线。曾经确实有一个 GET /logout,被 403 页的一个链接踩中过。
  • 入口:登录迁到 core 通过邀请码(注册时填,或注册后走 /api/auth/authentic 验证)。

6.2 数据层

  • Prisma schema:prisma/schema.prisma 与真实库 1:1 映射。改 schema 时手写 prisma/migrations/<n>_<name>/migration.sql 并同步 schema.prisma,然后 npm run migrate -- up 应用(见 docs/deploy.md「修改 schema 后」;生产库禁止 prisma migrate dev / db push)。
  • 时间戳列:INTEGER 毫秒(与 Prisma 默认 SQLite 写入格式对齐)。规整是单向门 —— 历史格式的 YYYY-MM-DD HH:MM:SS 文本 Prisma 解析即抛 500。
  • 时间戳语义:存的是「UTC+8 墙上时间贴 Z 标签」,不是真实 UTC 瞬间(上一版实现 datetime.now() 留下的,normalize 只补 T/Z 不平移)。因此:取当前时刻一律用 src/lib/db-time.ts 的 nowForDb()(= Date.now() + 8h,与全库历史数据同钟);「还剩多久」用 hoursUntil();展示用 ymd/ymdhms 或 getUTC*。禁止无参 new Date()、toLocale*、本地 getter(getHours 等)。混用两把钟的后果全是静默的:禁言到期后多显示 8 小时、当日发文计数跨日错位。由 tests/unit/db-time-guard.test.ts 五条静态守卫强制。
  • Prisma 客户端:单例在 src/lib/db.ts,开发模式 HMR 安全。

6.3 鱼干账户(站内自洽)

账目与业务数据在同一个 SQLite 文件里,每笔鱼干操作就是一次普通事务。 没有补偿事务、没有 outbox、没有跨进程同步 —— 要么全成,要么全不成。

  • 唯一的记账内核 postEntry(src/lib/fish-service.ts):全站唯一改 users.driedFish 的地方,收调用方的 tx,在同一个事务里改余额 + 写一行 fish_transactions。入参是有符号的存储单位(0.0001 鱼干,见 fish-units.ts): 出账走单条带谓词的 UPDATE(driedFish >= need)并在未命中时抛 InsufficientFishError,入账走 increment。addFish 是它的「只加不减」语义壳。 ⚠️ 新增鱼干写路径时不要绕过它自己写 update + create —— 那正是 「余额改了、流水没写」这类静默账目损坏的入口。
  • 六条写路径,全部一个事务:签到(checkin-service.ts:建记录 + 发鱼干 + 写流水 同事务,见 docs/bot/checkin-bot.md)/ 投喂 (feed-service.ts,作者全额到账、无手续费)/ 管理员发扣与补偿 (fish-admin.ts、fish-compensate.ts、历史投喂手续费返还 feed-fee-refund.ts)/ 用户间转账(fish-market-service.ts)/ 练手盘开平仓(market-service.ts)/ 租头像框(frame-shop-service.ts,见 §6.14)。 注册建号不再属于这里 —— 它当年要走一环「在远端建账户」,现在只是建一行 users(初始余额就是列默认值 0),一行鱼干都不写。 ⚠️ 新增写路径时两条硬要求:一是别绕过 postEntry(见上),二是用例末尾调一次 expectLedgerConsistent()(tests/helpers/fish-ledger.ts)。
  • 故障语义:余额不足、参数非法、超出上限是业务结果(400 / 退出码 1); 本地事务本身失败是真故障(500 / 退出码 2)。没有 503 了 —— 那一档原本全部来自「远端账户服务不可达」,而它已经不存在。
  • 幂等(src/lib/fish-idempotency.ts):只有键是确定的操作才登记 —— 同一次操作重跑必须等价于没跑时才登记。
    • 用户间转账带客户端幂等键:同键同参数重发 → 返回原结果(duplicated: true)、 同键不同参数 → 409。记录写在业务写入的同一个事务里,所以「钱动了但键没记」 在结构上不可能发生;并发同键由唯一约束挡下。
    • 群发补偿按 batchId 派生键,批次中断后续跑跳过已发放的人。
    • 历史投喂手续费返还按原作者 / 文章 / 累计实付派生键。逐作者事务内核算 投喂实付 − 作者已收 − 历史补发 − 已返还,写 feed_fee_refund 流水, 已返还计入收入所以重跑欠款归零。包含软删文章及后来降档的作者(归还原收入, 不是新增赚取渠道)。上线先切换免手续费代码,再执行 fish refund-feed-fees, 见 docs/cli.md「小鱼干」一节。
    • 签到 / 投喂 / 管理员单次发扣 / 练手盘开仓 / 注册建号一律不登记 —— 它们的键带随机后缀,登记了也没有去重价值,只会把表撑大。
  • 共享单号 fish_transactions.transfer_id:一笔转账的两条流水(发送方 transfer 负 / 接收方 transfer_receive 正)写同一个值,让收付双方能对上同一笔。 值 = sha256(幂等键)[:16](派生,不是随机)—— 于是同键重放无需额外状态就能 回报原单的单号,也没有第二份会漂移的副本。只覆盖用户间转账:签到 / 投喂 / 赠送 / 补偿都没有对手方,一律 NULL;存量行不回填(无法可靠反推配对,猜错等于把 两笔钱认成一笔)。它是对账句柄,不是凭证 —— 本仓没有「按单号查一笔转账」的 接口,所以知道它并不能读到别人的账。见 prisma/migrations/14_fish_transfer_id 头部。
  • 收银台的 order 参数(/fish/pay):商户给的订单号参与拼幂等键 (makeOrderKeyBase = 收款人哈希 + 订单号,不含金额),于是同一订单号永远算出 同一个键 → 用户付完刷新页面再点不会重复扣款。键里混收款人是为了让「同一付款人给 两家商户用同一个订单号」不撞车;不含金额是为了让「同单号换金额」响亮地 409, 而不是静默变成第二笔。不给 order 时退回随机键基(扫码收款页恒如此, 那里用户要能在同一页里改金额)。
  • 收款回调(webhook):fish-webhook-service.ts 是 outbox —— 投递行与两条流水 同事务写入,投递在事务外。⚠️ 别因为它与记账无关就把它改简单:这是本站 唯一允许的跨进程调用,把钱的事务当成 HTTP 的宿主会让商户的延迟占住 SQLite 写锁。两个 driver:进程内定时器(src/instrumentation.ts → webhook-drainer.ts) 与 fish webhook-retry,靠条件 UPDATE 认领,不会双投。投递是 at-least-once (sending 租约到期会重投),所以接收方必须按 X-Raricy-Delivery 去重。SSRF 防线 (本站唯一一处「服务器去 fetch 用户给的地址」)见 src/lib/webhook-url.ts 头部。 端点连续失败不自动停用 —— 那是静默失效。
  • 读路径:一律读本地 users.driedFish(fish-service.ts 的 getBalance 等), 页面、接口、CLI 无一例外。没有第二个存储可以问 —— 账目对不对只能靠内部一致性 证明,那条不变式是「每人余额 == 他所有流水之和」,测试侧由 tests/helpers/fish-ledger.ts 的 expectLedgerConsistent() 钉住, 任何跑过写路径的用例都应该在末尾调一次。
  • 练手盘的「系统水池」是账外的:开仓是扣用户、平仓是加用户,各自留一条自己的 流水 —— 没有系统账户这一行。于是「无限水池」表现为全站鱼干总量的增减。 ⚠️ 别为了「复式配平」在 users 里虚构一个系统账户行:FishTransaction.userId 是必填外键,那行会长进用户列表与搜索里。成交价必须在下单那一刻现取 (fetchQuote),绝不读展示缓存 —— 这条是它唯一的安全边界,展开见 §6.13。

6.3.1 历史注记:这里曾经跨进程(2026-07 ~ 2026-09)

账户逻辑原在一个站外的 FastAPI 微服务(独立仓库)里,每条写路径都是三段结构: 本地事务提交(含 account_sync_ledger 一行 pending)→ 事务外 HTTP 调远端 → 失败则补偿 事务精确撤销本地写入。读路径当时就已经全在本地,那三个远端读方法只在联调脚本里被 调用过 —— 也就是说它是只写副本。撤销它的理由(按力度排序):

  1. 它换来的失败模式结构上修不掉:远端已成交但响应丢失 → 本地补偿删账 → 用户重试 拿新键 → 远端扣两次,唯一的发现手段是一条 FISH_TRANSFER_SYNC_FAILED 日志; 投喂的 Step1/Step2/退款三态失败会留下 ACCOUNT_RECONCILE_REQUIRED,且本地被干净 回滚、账面上看不出异常。这些不是「少见的 bug」,是「两个存储」的必然产物 —— 搬进同一个事务不是缓解它们,是让它们不存在。
  2. 代价是常驻的:每次鱼干写都是一次 5s 超时的网络依赖,远端抖动 → 全站鱼干功能 集体 503;两套部署、两套密钥、两套备份。
  3. 「远端才是记账事实」这句话没有兑现成任何工具 —— 唯一收敛手段 fish sync-retry 与对账日志靠人盯,npm run diagnose 连 ACCOUNT_SERVICE_* 都不检查。

代价是失去一份独立账目副本(≈ 灾备)。它不值钱:鱼干经济与 users / blogs 在同一个 SQLite 文件里,只从远端恢复鱼干余额也拼不出站 —— 真正的灾备是那个文件的备份。

留下的物理痕迹(都不影响行为,别照字面理解):

痕迹 现状
account_sync_ledger 表名 现在只是幂等记录表,新行一律 status='synced'。语义以 fish-idempotency.ts 头部为准
users.fish_api_key_encrypted 列 远端签发的用户 API Key 密文。没有任何代码读它,列与存量数据留着不删
FISH_ENCRYPTION_KEY / SECRET_KEY 仍然必须正确 —— 回调签名密钥的钥匙:写用前者、读带一段回退到后者的过渡期(见 fish-webhook-service.ts 的「回调签名密钥的钥匙」)。跑完 fish webhook-rekey 之后只剩前者
docs/bot/fish-bank-example.md 站外第三方基于本站对外接口开银行的参考实现,与那个被撤销的微服务无关,照旧有效

清单与判据见 docs/legacy-constraints.md §1.1。

6.4 中间件(CSRF + 协议闸:练手盘与玩具区)

src/middleware.ts 干两件事,判据与说明各在别处(以那两处为准):

一、协议闸 —— 两个区域只走 https:

  • 练手盘:/fish/trade 与它下面的页面 + 四个 /api/fish/trade/* 接口;
  • 玩具区:/game 与它下面的页面(含 /game/quandi/play 那份 iframe 文档)+ 四个 /api/game/*/launch 启动接口。

确证是明文 http 时页面 308 跳到 https、接口回 403 JSON(文案点名所属区域)。 判据是 X-Forwarded-Proto,回环地址(localhost / 127.x / ::1)豁免 —— 判据的全部理由、以及「为什么缺了它 npm run dev 与整个 e2e 会打不开」写在 src/lib/https-guard.ts 文件头。两区的路径清单只有那一张表;tests/unit/https-guard.test.ts 拿文件系统双向对账(新增 /api/game/<x>/launch 忘了进表,那个接口就在明文下照发票据)。

两处容易踩的:

  • 它排在 GET 早退前面。下面那两条 CSRF 规则只看写方法,而这些页面都是 GET —— 把它排到早退之后等于这道闸永远走不到。
  • 它不是「反代才设的头」。没有反代时 Next 自己会按 socket 填这个头(直连明文时 读到的就是 http),所以「头缺失就放行」那条分支在真实服务器上几乎走不到; 真正让本地能跑的是回环豁免,不是「没有信号」。 ⚠️ 推论:nginx 漏配 proxy_set_header X-Forwarded-Proto $scheme 时,Next 会把 「nginx→Next」那段明文连接当成对外协议 ⇒ 连 https 的正常用户也被判成明文、 整个练手盘与玩具区变 403。那不是安全问题而是可用性事故,npm run smoke §1b 专门盯它。

二、CSRF 防护 —— 对状态变更方法(POST/PUT/PATCH/DELETE)校验 Origin / Referer 与对外 Host 同源。

对外 Host 是三源并集,不是优先级回退链:ALLOWED_ORIGINS(显式配置,逗号分隔)、 X-Forwarded-Host(nginx 透传,多值取第一个)、Host(直连)三者全部并入同一个集合, 请求的 Origin / Referer 命中其中任一即放行(src/middleware.ts)。

所以配了 ALLOWED_ORIGINS 并不会让另外两个来源失效;反过来,三者只要有一个与浏览器发来的 Origin 对得上即可,不必配全。

GET/HEAD/OPTIONS 视为安全方法,不校验(协议闸不受这条影响,见上)。

6.5 限频

src/lib/rate-limit.ts,进程内内存桶(单进程语义)。配额表以该文件的 RULES 为唯一权威,本文不复述具体数值(点赞 / 评论 / 投票 / 图床 / 讨论 / 登录…),复述必 drift。

⚠️ RULES 不是全部配额。OAuth 的三条(authorize 30/min/user、token 60/min/clientId、 userinfo 600/min/user)是各 route 里内联的字面量,去 src/app/api/oauth/*/route.ts 找, 不在 RULES 里 —— 见 docs/oauth.md §7。做全站限频审计时最容易漏掉它们。

另有三点不显然的行为:

  • 桶会落盘:随 10 分钟一次的惰性清扫写入 instance/rate-limit-snapshot.json(原子写;RATE_LIMIT_SNAPSHOT_PATH 可覆盖),进程启动时回灌 —— 重启不重置窗口。不落盘的话,一次发版等于给所有人发免刷通行证,也放走进行中的刷量。测试环境不自动回灌,保证确定性。
  • 登录失败预算同步预留:IP 与用户名(小写归一)两个维度在任何异步校验前占位,任一超限即 429;失败将占位转为命中,成功或异常释放。这样并发请求不能共享最后一个名额,成功仍不消耗失败预算。
  • 密码计算另有预算:登录、付款确认、凭据签发与改密共用用户 ID 桶,成功也计数;OAuth 用实际 clientId 的路由档位,同时共享 IP 与全局预算。所有 scrypt/pbkdf2 校验及哈希生成共用 password.ts 的 4 个在途名额,超额立即拒绝、不排队。注册使用独立入口桶。具体速率在 RULES.password*。
  • 规则值与计桶的键是两回事:同一条 RULES.* 可以被多处复用,但各处用自己的键前缀,配额互不相干。已知的有:博客点赞用 like:h:/like:d:,评论点赞复用同样的 likeHourly/likeDaily 数值但键是 comment-like:h:/comment-like:d: —— 分成两个桶是刻意的,共用会让「给评论点赞」顶掉「给文章点赞」的额度。改 RULES 的数值会同时影响两边;只想调一边得另立规则。鱼干市场那三条同理且必须分开:fish-api:(密码,吃 CPU 闸门)/ fish-api:ip: / fish-token:(只读凭据,不跑 scrypt 故不受那道闸门约束)/ fish-token:ip: —— 混用会让便宜的凭据路径蹭掉昂贵的密码路径额度(或反之)。

多实例部署时换 Redis。本站单进程不踩该坑。

6.6 文件落盘

图片与音频的 HTTP 上传入口必须把角色配额传给保存内核。写盘和压缩在数据库事务外; 新行落库与实际用量核验同事务,超额回滚并清理这次新写的文件,防止并发请求共享剩余额度。 软删除仍按既有规则回收配额,两个存储域独立计量。

域 路径 上传入口 读取入口
头像 instance/avatars/<uuid>.png(或 AVATARS_DIR 覆盖) 无上传入口:注册时 avatarPath 留空,头像由读取入口按 id 确定性生成;磁盘上的 .png 只有历史存量文件 src/app/api/avatar/[id]/route.ts(有文件则回放,否则 generateIdenticonSvg 兜底,永不 404)
图床 instance/images/<id><ext>(或 IMAGE_UPLOAD_FOLDER 覆盖) src/lib/image-upload.ts — sharp 压缩 + 配额累计;类型由 image-sniff.ts 的 planImageUpload 按字节判定(HEIC 在浏览器侧转 JPEG) src/app/api/images/[id]/raw/route.ts
音频床 instance/audio/<id><ext>(或 AUDIO_UPLOAD_FOLDER 覆盖) src/lib/audio-upload.ts — MIME 嗅探 + 无压缩 + 独立配额累计 src/app/api/audio/[id]/raw/route.ts(带 Range)
故事 instance/stories/<合集>/<故事>.md|.cattca(或 STORIES_DIR 覆盖) 服务端直接落盘 src/lib/story-service.ts 服务端 marked
表情包 instance/stickers/<合集>/<表情>.{gif,webp,png,jpg,jpeg}(或 STICKERS_DIR 覆盖) 无上传入口:站长直接往目录里拷文件 src/app/api/stickers/[collection]/[name]/route.ts(查扫盘 manifest,见 src/lib/sticker-service.ts)
头像框 public/static/frames/<key>.png(或 FRAMES_DIR 覆盖,平铺一层、只认 PNG) 随代码入库:它是我们自己画的(源码 = scripts/make-frame-demos.mjs),所以不在上面那个 instance/ 底盘里 —— 见本节末与 §6.14 src/app/api/frames/[key]/route.ts(查扫盘 manifest,见 src/lib/frame-service.ts)

磁盘目录必须真实存在(生产用 systemd/Data卷/挂载点),node scripts/check-instance.mjs 一键建好骨架。

表情包那一栏有三点与其余几栏不同,改代码前先看一眼:

  • 它是唯一「没有上传入口」的存储域(头像那个也无入口,但读取是 identicon 兜底)。 素材由站长从外部拷进来,所以目录里没有任何上游校验 —— 图床那条链路的 planImageUpload 在这里不存在。因此 raw 路由必须按字节复核类型并明确拒绝 SVG(detectImageMime + ALLOWED_STICKER_MIME),否则丢一个 <svg onload=…> 进来就是同源存储型 XSS。
  • 查表模型:resolveSticker() 拿 collection / name 只当 map 的 key,永不拼路径 —— 与图床 raw 路由「先查库、再按库里的值拼路径」是同一种安全性来源。 info.json 的 ignore 必须在这里也拦一道,不能只在列表接口过滤。
  • 扫盘有缓存(TTL 5s + 目录时间戳 + 60s 兜底全扫),与 story-service.ts 的无缓存扫盘不同 —— 理由见 sticker-service.ts 的文件头。

头像框那一栏与表情包看着像、关键处相反,别照抄那边的直觉:

  • key 空间在白名单里,目录只提供字节。表情是「目录即 key 空间」(往里丢什么就有 什么),框不是:权威是 frame-refs.ts 的 FRAME_KEYS(源码常量)。所以目录里多出来 的文件一律不被收编,而目录缺席只让框不显示,不会让框不存在 —— 授权、展示名 照常,缺的只是那张图。
  • 只认 PNG。框靠透明通道工作(中间那块必须透出头像),JPEG 没有 alpha、 GIF 的 1 位透明度边缘全是锯齿。收窄到 PNG 顺带把 SVG 那条 XSS 路径关在外面 (ALLOWED_FRAME_MIME 只有 image/png,路由仍按字节复核)。
  • key 先过白名单,才谈得上拼路径。与表情的「只当 map 的 key、永不拼路径」是同一 条安全来源,只是这里多了一层「白名单本身就是手写常量」的保证。

原始字节路由的缓存口径(图床 / 音频床共有)

两条 raw 路由(/api/images/[id]/raw、/api/audio/[id]/raw)都不能发 immutable 长缓存。字节确实按 id 定稿,但存在性会变:删除是软删,同一个 URL 从此返回 404。发 max-age=31536000, immutable 等于告诉浏览器一年别回来问 —— 于是 一张已删除的图会继续出现在「之前访问过这篇博客」的访客屏幕上(线上真发生过,且 服务端一切正常、日志里没有任何异常)。现行口径(常量与判据在零依赖的 src/lib/http-cache.ts):

  • 公开档 public, no-cache + 弱 ETag(由文件 size + mtime + ctime 生成): 每次使用前条件请求,未变化回 304(零字节、不读盘),删了回 404。ETag 用文件元 信息而不是只用 id —— 运维可能直接往 instance/ 里换文件;带上 ctime 是因为 cp -p / rsync -t 会保留 mtime,只靠 size + mtime 时「同尺寸 + 保留 mtime」的 替换会得到同一个 ETag(Linux 上 ctime 无法被拷贝保留;Windows 的 ctime 是创建时间, 那种环境下靠 mtime 兜)。同样的 ETag 不证明同字节 —— 弱验证器只省流量, 删除 / 鉴权一律在验证之前由数据库裁决。⚠️ 音频那条带 Range 的请求不回 304 (见 §6.15):304 只在没有 Range 的请求上出现。
  • 私有档 private, no-store(与以前一致):授权逐请求判,共享缓存命中会绕过鉴权。
  • 404 一律带 Cache-Control: no-store:这两条路由的 404 是「不存在 / 已软删 / 私有图伪装」三合一,还是删图的正常反馈,不能被浏览器或中间缓存记住。

⚠️ 别把它与表情包 / 头像框那两条素材路由的口径混起来:那两条是 public, max-age=86400(刻意不 immutable),判据是「站长手工换文件」;这两条的 判据是「存在性可变」。两种理由不同,别互相照抄。

图床上传的类型判定(内容为准)

「这个文件收不收、按什么类型收」只有一处实现:src/lib/image-sniff.ts 的 planImageUpload(零依赖,Node 与浏览器共用同一份)。

  • 落库的 mime_type 永远来自字节,声明(Content-Type / 扩展名)只当提示。 位图之间的错配(PNG 存成 .jpg、浏览器给空 type、image/jpg 这种别名)照收, 类型与回显文件名按内容纠正 —— 它们不是安全事件,只是名字错了,拒掉只会让用户 对着「文件内容与声明的格式不匹配」怀疑自己。
  • SVG 是唯一收窄的一档:只有「内容与声明都是 SVG」才收。SVG 必须以 Content-Disposition: attachment 下发,而那条分支按落库的 mimeType 判 —— SVG 字节若以位图类型落库,attachment 分支就不触发。当下 nosniff 还兜着一层, 所以「浏览器照着 image/png 去渲染 SVG」现在走不通;但闸门不该只有一道 (换一条下发路径、或加个不设 nosniff 的读口,这个洞就成了同源 XSS)。 反向(位图字节声明 SVG)同样拒:要的是矢量图,不该默默换成位图。
  • HEIC / AVIF / MOV 只识别、不入库:服务端 sharp 的预编译 libvips 只带 AVIF 那一路 解码器(没有 HEVC),收下 HEIC 也压不了、浏览器多半显示不出来。HEIC 由浏览器 用 heic2any 就地转 JPEG 再传(image-client.ts,函数体内动态 import);服务端对 裸 HEIC 只能回一句能行动的提示。实况照片的 .MOV 是视频段,回「这是视频文件…」。 别为了「兼容」把 HEIC 加进白名单 —— 压缩会静默回退原字节,用户拿到一个打不开的 直链(且没有任何报错)。
  • 字节判定有两处,必须同源:服务端路由与 image-client.ts 的上传前校验都调 planImageUpload(同一份实现)。md-editor/upload.ts 的批次闸门只按 type / 后缀筛 (isUploadableImage),它是「一次别丢太多」的用量提示、不看字节 —— 正因如此, 它的口径必须只收确实会发出去的类型(BMP / TIFF / AVIF 不算进体积), 否则一整批合法图片会被它误拒。

已知限制:配额与落盘不是原子的(图床 / 音频床共有)

两条上传链(image-upload / audio-upload 的上层路由)都是「读用量 → 判配额 → 写盘 → 落库」,四步之间没有互斥。后果两条,都是我们接受的:

  • 并发可越过配额:同一账号并发发 N 个请求时,每个都读到同一份旧用量、于是全部 通过 —— 越额量由并发度决定,不是无界的。再叠加「软删即释放配额、但磁盘文件保留」 这条既定政策,反复「传满 → 软删」就能把占用堆上去。限频(图床/音频床各 200/时) 不拦并发,只是把速率压住。
  • 两个方向都会留下不一致:写盘成功而落库失败 → 孤儿文件(不进配额、不进统计、 硬删也够不到,因为没有 id);硬删时先 unlink 再删行失败 → 行在文件没了 (raw 永久 404,配额照占)。

为什么不修:真正的修法要给「读-判-写」加一层互斥(进程内互斥量,或一条 INSERT ... SELECT WHERE sum(...) < quota 的条件插入),那是两个存储域共用 的改动,动的是配额内核;而这两条的影响面是「core+ 用户能占更多磁盘」,不是错账、 不是越权、也不是静默丢数据。运维的观察口:admin-stats-service.getSiteStats 与 cli stats 报的是含已软删的全行口径,那才是磁盘上的真实占用 (页面上那个数只算未软删 —— 两个口径刻意不同,别互相替换)。

6.7 Markdown / 内容渲染

场景 渲染方式 管线
讨论正文 / 评论正文 客户端渲染,同一套管线 rich-text.ts(marked → DOMPurify → 后处理),白名单与链接类名见 chat-markdown.ts / comment-markdown.ts
博客正文 客户端渲染 src/app/components/MarkdownRenderer.tsx(marked + DOMPurify + highlight.js + MathJax + [@…] 内容引用)
故事正文 服务端渲染 src/lib/story-service.ts 的 marked + stripScripts。内容由站长直接写在 instance/stories/,按可信输入处理,不走 DOMPurify / highlight.js
内容引用 [@…] 浏览器渲染时正则替换为剪贴板/投票/图床/收藏夹组件 src/app/components/MarkdownRenderer.tsx 的 ContentRefProcessor(按 id 长度分流:6 位收藏夹 / 8 位剪贴板 / 9 位投票 / 10 位图床)。表情包不在这条管道上。展开到哪一档看 contentRefs(成员 'expand' / 对外 'external',见 §7.3)。两次替换都按区间切片、不按内容 replace;分流扫的是盖过码的副本,所以代码块里的引用一律不展开(指南对读者的承诺)。收藏夹卡片是在主循环之后单独一趟,理由见 §6.10
表情包 [@合集/表情] 浏览器渲染时替换为内联 <img>(仅评论 / 讨论)。两个来源共用这条管道:站长放的图片(/api/stickers/ 字节路由)与内置黄脸(/static/emoji/ 静态素材)—— 后者多叠一个 rich-emoji-ref 类把自己压成文字大小;整条正文只有这一张黄脸时再叠 rich-emoji-solo,尺寸退回表情包那一档(判据是「最终 DOM 里只有这一张」) src/lib/sticker-refs.ts 的 embedStickerRefs,在 rich-text.ts 里紧跟 embedUserRefs 之后调用;单发那一档是 markSoloEmojiFaces(同一文件),在 render() 里最后跑(任何新的 embed* 都必须排在它上面);黄脸清单在 src/lib/emoji-faces.ts
音频 [@音频/<ID>] 浏览器渲染时替换为内联 <audio controls>。两条管线走法不同:评论 / 讨论在净化后建 DOM(那边白名单里没有 audio);博客在源文上直接拼标签串(那边白名单本来就允许 audio),但必须配 maskMarkdownCode —— 否则代码块里会嵌出真播放器。一条正文最多展开 3 个 src/lib/audio-refs.ts 的 embedAudioRefs(DOM)/ collectAudioRefs + replaceAudioRefs(源文)。音频 是保留合集名(表情那条正则带 (?!用户/)(?!音频/) 让开),见 §6.15
用户名片 [@用户/<用户名>] 浏览器渲染时替换为一枚行内名片(<a> 包住「带头像框的头像 + 用户名」,指向 /u/<id>)(仅评论 / 讨论)。认的是用户名而不是 ID,所以多一条异步取数;不算 @ 提及,不发通知 src/lib/user-refs.ts 的 embedUserRefs(纯逻辑 + DOM 构造),数据由 src/app/components/useUserCards.ts 经 RichTextContext 注入,取数口是 GET /api/users/<用户名>(要 core+)。用户 因此是保留合集名(表情那条正则带 (?!用户/) 让开)
工具页 cattca-guide 服务端渲染 marked(仅一次,可信文档)

内容引用的三道上限(常量集中在零依赖的 content-refs.ts,客户端解析器与服务端 对外映射共用同一份,别在别处再写一遍数字):

  • 取数 MAX_REF_FETCHES(50):候选按源文出现顺序去重后取前 50 个 ${type}:${id}(剪贴板 / 投票 / 收藏夹)。图床与音频只拼 URL、不占这个名额 (否则图多的文章会把取数名额饿死);缓存命中也计入(保确定性)。没进候选的引用 连取数请求都不发;已经进候选、只是展开时排不上号的,请求可能已经发出去了 —— 这两件事不要混为一谈。
  • 并发 MAX_REF_CONCURRENCY(4):ContentRefResolver 的实例级闸门(含共享 预览 resolver 的多轮渲染)。只有真会发 HTTP 的才排队 —— 图床与对外视图不发请求、 不占名额。「刷新」后排队未发的陈旧请求不再发出,但仍正常 settle(不挂死)。
  • 总量 MAX_REF_EXPAND_CHARS(500000):整篇展开后的 Markdown 总长度。按出现 顺序整块接受,装不下的那条保留原 token —— 不截断正文、不切 HTML。

替换是单趟切片,所以剪贴板引用不会递归:剪贴板正文里再写的 [@8位] 保持字面量, 自引用 / 双向引用都只展开一层。⚠️ 音频与收藏夹各是合并后单独一趟、跑在已改写的 正文上,不在这条之列 —— 从剪贴板带进来的音频引用会照常变成播放器,收藏夹 token 在原文里也有同一个 ID 时会变成卡片。对外视图的服务端映射(resolvePublicClipRefs, §7.3)另有一档:条数按 MAX_REF_FETCHES、查库并发同样走 MAX_REF_CONCURRENCY、 入映射的正文合计同样封顶 MAX_REF_EXPAND_CHARS。

讨论与评论共用一条管线(rich-text.ts)。两者的威胁模型与防线逐条相同,差别只在 白名单与链接类名(chat-msg__link / comment-link),所以管线唯一、参数由调用方注入。 各写一份的代价不是重复代码,是防线漂移:任一边漏打一个补丁另一边不会知道,而两边 看起来都「有净化」。五道防线(裸 HTML / 伪协议 / 属性注入 / 外链图片 / 无 DOM 降级) 在 rich-text.ts 文件头逐条讲,单测见 tests/unit/{chat,comment}-markdown.test.ts。

两处刻意不参数化,因为它们是安全口径而非样式偏好:

  • <img> 一律不在白名单,![](url) 降级成链接 —— 发图走图床附件(imageId), 不允许正文嵌任意外链图片(跟踪像素 / 访客 IP 泄露 / 混合内容)。
  • 无 DOM(SSR)时一律退回转义纯文本。因此服务端不渲染这两处的正文,前端调用点 (ChatMarkdown / CommentMarkdown)都依赖「列表由客户端拉取、SSR 期零条」这个前提; 若将来改成服务端直出消息/评论,必须改成「挂载后再渲染」的门控写法,否则首帧不一致。

评论的 content_html(服务端转义 + <br>)站内已不再用于渲染 —— 保留给 spider API (外部只读接口,不能因为站内换了渲染方式就被打碎)与无 JS 降级。

评论与讨论的 @ 选人提示共用 MentionInput(嵌在 RichComposer 中):输入 @ 或用户名前缀后,经 GET /api/mentions/users?kind=comment|chat&id=<文章或会话>&q=<前缀> 搜索,最多 8 人;接口需 core+ 且未禁言,再检查文章未软删 / 会话可访问。评论提示列出 core+;讨论大区过滤专注、禁言与静音者,私聊只列未静音的会话成员(正在看会话的人仍可选)。 支持点选、上下键、Enter / Tab 确认与 Escape 关闭;输入法组合输入期间不选人、不提交。 取消旧请求,避免旧前缀或旧会话的响应覆盖当前结果。名片与表情 token 不触发选人提示。

评论 @ 通知在 createComment 成功落库后发送,action 为 评论提及,object 为 blog + 文章 id。提取规则与讨论一致:@用户名 后必须是空白或行尾,精确匹配, 同一条评论同一人只发一次,排除自己;已有「文章评论 / 评论回复」通知的收件人不重复发。 仅通知 core+(评论区始终只向 core+ 开放,文章对外公开也不改变这道门);禁言与专注 不妨碍读评论,所以这两项不排除。匿名时 actorId=null,detail 只写化名。通知失败不回滚 评论、不阻止其他收件人。评论提及通知不受四个 notify_* 开关管辖;名片仍不算提及。

名片那一趟要外挂数据,所以管线多了一个「上下文」参数(RichTextContext)。 异步取数留在 React 层(理由与剪贴板那条相同,见 useResolvedContent.ts 的文件头), RichContentBody 用 useUserCards 取到之后递进 render(content, ctx)。 ⚠️ 由此带来一条必须记住的缓存旁路:正文里含名片 token 时不进渲染缓存 —— 缓存以正文为键,而名片数据会变(换头像框、框到期),同一条正文在数据到达前后是两份 不同的 HTML,走缓存会把第二份吃掉,症状是「名片永远不出现」且不报错。 判据是 USER_REF_PROBE.test(content),见 rich-text.ts。

表情图的 404 降级不在渲染管线里,而在 RichContentBody 的容器上(捕获阶段的事件 委托,监听 error 换回纯文本 token)。原因是那条管线字符串进、字符串出 (render() 最后 return holder.innerHTML):管线里建的 <img> 只是中间产物, 挂在节点上的监听器在序列化那一刻全部丢失,React 那端是浏览器重新解析出来的另一批节点。 资源类 error 事件不冒泡但走捕获,所以容器上一个监听器就够,且跟着容器的生命周期走。 ⚠️ 那个 useEffect 的依赖必须是 [html] —— 组件在 html 为空时 return null, div 会卸载重挂,deps=[] 的监听器永远附不上。

编辑器(博客 / 剪贴板)的预览走的就是上表那台渲染器,不是另写一份近似实现 —— 「预览长什么样」和「发布后长什么样」因此不需要人去对齐,它们本来就是同一个 MarkdownRenderer。两处刻意的差别:

  • contentRefs="expand":五类引用(图床 / 音频 / 剪贴板 / 投票 / 收藏夹)在预览里 就展开成成品。这条档位会真去打三条 core+ 读口(剪贴板正文 / 投票 / 收藏夹卡片; 图床与音频只是拼 raw 地址,不发请求),缓存与失效见 content-ref-resolver.ts —— 所以它只给登录后的编辑器用,对外页面走 'external'(一个请求都不发)。
  • interactive={false}:投票小组件结构照画(用户要看得出这里有个投票),但选项 disabled、且不挂投票处理器。预览里点任何一个投票都不会发出业务写请求。 ⚠️ 新增任何「渲染时顺便做点什么」的引用类型,都要在这条只读档上一起断掉。

预览不展开表情包与用户名片:那两者只挂在评论 / 讨论那条管线上(见上表), 博客正文里它们就是普通文字。这是刻意的,不要为了「统一」把它们接进来 —— 一旦接进来,发一篇日记就顺带获得了两项评论区的功能。

投票数据在预览里只取一次,且与正文同一份快照:预览是防抖重渲染的,而投票 小组件自己会去拉 GET /api/votes/<id> —— 每停一下手就把整块 DOM 换成新的,等于每停 一下就重打一轮,还会先闪一下「加载投票…」。所以预览把这一轮引用解析自己取到的那份 喂给小组件(ContentRefProcessor.preprocessRound 交出来的 entries,与正文里展开 引用用的是同一批结果),voteData 返回 undefined 时才由小组件自己取。⚠️ 不能改成 回头去问会话缓存(resolver.peek):处理器是「先 await 取数、再替换」两段式的, 中间这一段里任何一次 invalidate()(点「刷新引用」、另一次预览渲染起来)都会让缓存 变成空的 —— 正文里的引用静默退成字面量,越慢的接口越容易撞上。⚠️ 这条数据口只 给带会话级 resolver 的调用方(编辑器预览):正文页(blog/[id]、剪贴板详情)不传, 小组件照旧自己拉一条(票数取渲染那一刻的值),行为与换代前逐字一致。重画的代际是 渲染序号而非调用方的 refreshToken —— 后者会让后处理对着上一份 DOM 再跑一次, 那一瞬间缓存刚被作废、取数还在飞,于是反而多打一条。行为由 tests/unit/md-editor-preview-votes.test.ts 与 tests/unit/content-ref-processor.test.ts 钉住(按请求条数、以及「响应落地前缓存被作废」那条时序)。

⚠️ 正文容器上那个 dangerouslySetInnerHTML 的 {__html} 对象必须 memo 住 (MarkdownRenderer.tsx 的 innerHtml)。React 19 是按对象身份 diff 它的 (内联字面量每次渲染都是新对象 → 每次都重写 innerHTML),而后处理(MathJax 的 mjx-container、投票小组件)是在 commit 之后那次效果里做的、看不见 DOM 被重写, 于是不会重跑:切一下视图公式就退回 $$…$$ 原文、投票位空掉,控制台一个字都不报。 按 doc memo 之后「doc 变了才重写」,与效果重跑严格同步(机制与用户动作各有测试, 见 docs/plan/editor-plan.md §7.1.2 第 8 条)。

「插入引用」面板(markdown-editor/ResourcePanel.tsx)

工具条一颗「插入引用」按钮打开一个弹窗,里面五颗标签(图床 / 音频 / 剪贴板 / 投票 / 收藏夹)。一个面板而不是五颗工具条按钮:五类用法完全一样(挑一条 → 插回正文), 拆成五个弹窗只是把同一套加载 / 搜索 / 空 / 失败状态抄五遍。

插入语法与解析同源(md-editor/resources.ts,零 React、可单测):插入串一律由 持有那条正则的模块产出 —— 图片是 md-editor/upload.ts 的 imageMarkdown(与拖拽 上传走同一个函数,两处插出来的 Markdown 逐字节相同),其余是 audioRefToken / contentRefToken / favoriteRefToken。面板自己一个字面量 token 都不拼:拼错一个字符 ([@id] 的长度分流、[@音频/<id>] 的合集名)的表现是正文里留一段方括号原文, 页面照常渲染、谁也不报错。单测因此拿生产正则回验每一个插入串。

权限边界照接口自己的判权,面板不额外放宽也不额外收紧:列表一律是自己的资源; 私有收藏夹列得出来但插不进去(public_id 恒 NULL = 没有对外句柄),行内写清理由 —— 藏掉它会让用户以为自己的收藏夹丢了。401 / 403 是「这一档你用不了」,只显示接口 那句原因、不给重试;网络失败才给「重试」,且失败不进面板自己的结果缓存 (一次抖动不该被钉成永久失败)。⚠️ 这与 content-ref-resolver.ts 的取舍正好相反 (那边失败也缓存)—— 两处都对:那边是每次按键都重渲染的预览,不能每敲一个字就重试; 这边是用户主动打开、只会手动刷新的列表。点当前已选中那颗标签是空操作。

导出 HTML / 打印(markdown-editor/export.ts)取的是此刻的源码,走的就是 上面那台渲染器:离屏容器里现渲一份(ContentRefProcessor → renderBlogMarkdown → enhanceBlogContent(interactive:false) → 摘掉复制按钮 → 补绝对地址),不读预览那棵 防抖 DOM —— 读它就会导出「敲最后一个字之前」的正文,而页面上没有任何提示。 导出件里站内相对地址补成站点绝对地址、#锚点 留在文件自己身上(故意不写 <base>, 它会把锚点也带回原站);样式与媒体仍要联网、仍按站内读口判权(见 docs/plan/editor-plan.md §7.1.1)。地址解析按这一页的地址走(new URL(value, 页面地址)): //cdn… 这类协议相对地址补上本站协议(否则从 file:// 打开会解析成本机上的 file://cdn…),../、./ 与内联 style / 正文里 <style> 的 url(...) 一律照算, 纯 #fragment 的链接摘掉 target="_blank"(导出件要就地跳),解析不出来的畸形值原样留着。

打印的顺序是样式 / 图片 → 布局 → 字体,整段共用一个 3 秒的总上限 (PRINT_READY_MAX_MS,printHtml 一处算出的绝对时刻),到点照打、不吊死。 ⚠️ 字体必须排在布局之后:刚 doc.write 完那一瞬间样式表还没解析、 也就没有 @font-face 和任何字体请求,此时 document.fonts.ready 是个已经兑现的 Promise —— 拿它当「等到了字体」,MathJax 的公式会用回退字体打出错误字形,而页面上 没有任何提示。所以先等 <img> / <link>,再走两帧让布局发生,然后才读当时的 fonts.ready。 ⚠️ 那几帧也必须在总上限之内:requestAnimationFrame 在后台标签页里不回调 (切去别的标签页、或从一个刚被藏起来的页面点打印),裸等它就没有上界 —— print() 永远 不执行,屏幕上只有「点了打印没反应」。所有等待都过 withinDeadline(…, deadline) 与同一 截止竞速(setTimeout 在后台会被节流但一定会 fire)。 连点两下导出 / 打印时第二下给一句明确提示(snapshotBusyRef 挡掉 第二份,但静默早退在用户那边就是「点了没反应」)。

6.8 画报与鱼干收款码(服务端出图)

两种「发出去的图片」,都靠二维码把人带回站内。全部在服务端生成:

二维码指向 入口
个人主页画报 ${SITE_URL}/u/<id> 自己主页的「生成画报」
鱼干收款码 ${SITE_URL}/fish/collect?to=<username> /fish 的「收款码」

管线:src/lib/poster.ts(纯 SVG 构造,不碰库/文件/ sharp)→ src/lib/poster-render.ts(取数 + 头像 + sharp)→ GET /api/poster/{profile/[id],collect}。 sharp(buf, { density: 144 }) 把 750 宽的 SVG 光栅化成 1500 宽的 PNG(文字与二维码是矢量,放大不糊)。 前端因此极薄:预览就是 <img src="/api/poster/...">,下载就是同源 <a download>。

四条约束,改之前先读 src/lib/poster.ts 文件头:

  1. 二维码永远是矢量 <rect>,绝不用文字或 <image>。 它是整张图里唯一不依赖 服务器字体的部分 —— sharp 走 librsvg + fontconfig,服务器缺中文字体时所有文字 都会变豆腐块,那时二维码仍必须能扫。字体要求见 docs/deploy.md, npm run diagnose 第 5 节有探针。
  2. 纠错等级必须是 H。 二维码中心压了 logo(主动挖掉一块),H 级容忍约 30%, 降到 M/Q 就可能「图好看但扫不出来」。
  3. 静默区 ≥ 4 模块、单元尺寸取整。 这两条是踩出来的:内边距写死像素时, 短链接的模块更大、静默区反而不够 4 个模块,解码器直接失败而肉眼完全正常。 现在 cell 由 floor(卡片宽 / (模块数 + 9)) 反推,尺寸全部从 cell 推出来。 tests/unit/poster.test.ts 会把渲染结果真解码一遍来钉住这条。
  4. 所有动态文本转义(escapeXml + 去控制字符)。简介与用户名是用户可控的。

出图前必须检查 siteOrigin() 非空 —— 拼不出绝对 URL 时直接 503,绝不生成相对路径的废码。 SITE_URL 是服务端变量(无 NEXT_PUBLIC_ 前缀),解析链见 src/lib/site-url.ts。

6.9 OAuth 2.0 身份绑定(raricy 作为 IdP)

让外部第三方应用以标准 OAuth 2.0 Authorization Code 模式读取 raricy 用户的基础资料。

三张表(prisma/migrations/1_oauth/migration.sql):

表 角色 PK
oauth_applications 已注册的第三方应用(client_id + scrypt 哈希的 client_secret + JSON 串 redirect_uris + disabledAt 软禁用) 12 字符 base36
oauth_authorization_codes 单次性授权码(10 分钟 TTL) SHA-256 hex
oauth_access_tokens 长效 token(90 天 TTL;支持 revokedAt / lastUsedAt) SHA-256 hex

安全要点:

  • 原始 token / code / client_secret 永不落库:仅存 SHA-256(不可逆)/ scrypt(自带盐)
  • redirect_uri 严格精确匹配(无通配 / 前缀 / 子串)
  • 授权码消费与令牌签发同事务,并实时检查应用启用;解绑同时撤销令牌与未兑换授权码
  • client_secret 与 User.passwordHash 同款哈希(werkzeug 兼容),与 SECRET_KEY 轮换解耦
  • CSRF 中间件豁免 3 个 server-to-server 端点:/api/oauth/token /userinfo /revoke(鉴权由 client_secret / bearer 承担)

核心库与端点:src/lib/oauth.ts(纯函数 + Prisma 调用);6 个 /api/oauth/* 路由 + /oauth/authorize 页 + /admin/oauth 管理页;CLI oauth create-app / list-apps / disable-app / enable-app。

详见 docs/oauth.md。

6.10 收藏夹(favorites)

用户自建的博客合辑。一个用户可有 200 个收藏夹,一个收藏夹可有 1000 篇, 同一篇文章可以同时进同一用户的多个收藏夹(所以唯一约束的粒度是「收藏夹 × 博客」, 不是「用户 × 博客」)。

两张表(prisma/migrations/12_favorites/migration.sql):

表 角色 PK
favorites 收藏夹本体(标题 / 公开或私密 / 6 位对外 ID) UUID4
favorite_items 条目,软删(deleted + deletedAt) 自增 Int

六条不变量(全部集中在 src/lib/favorite-service.ts 的文件头,改动前先读):

  1. publicId 非空 ⟺ isPublic 为真。私密收藏夹的 public_id 恒为 NULL —— 不是「有 ID 但不显示」,而是没有对外句柄。所以二维码 / [@ID] / bot 接口 / 复制链接全都无处可泄,不依赖每个展示点都记得不显示。 ⚠️ 判「对外可见」永远用 isPublic,绝不用 publicId != null。
  2. 一切对外读取都必须过 PUBLIC_FAVORITE_WHERE(isPublic + deleted)—— 含软删判定,否则「永不物理删」等于删掉的收藏夹永远可读。
  3. 所有权守卫照 clipboard-service.getClip 的口径:调用方必须显式传 viewerId, 不给默认放行的参数。
  4. 不继承站长的越权读。getClip 让 owner 角色能读别人的私密剪贴板;收藏夹刻意 不设这个后门 —— 私密收藏夹只有创建者本人,有用例钉住。
  5. 「私密」与「不存在」对外同为 404,不确认存在性。
  6. 复制与导入用显式字段白名单构造新行,绝不 {...source} 展开。

ID 形态。 favorites.id 是 UUID 而不是自增 Int:所有者管理页的路由参数就是它 (/favorite/mine/<uuid>,因为私密收藏夹没有 6 位句柄),自增整数顺序可枚举 —— 那会把「私密不可被他人读」压在「每条路由都记得判所有权」上;UUID 让漏判从越权降级成 无害。6 位数字 ID(public_id)是公开收藏夹的对外句柄,空间仅 10^6,生成时 必须查重(拒绝采样 + 重试 10 次),不能像剪贴板那样不查。

路由:

路径 档位 说明
/favorite · /favorite/mine/[uuid] · /favorite/[publicId] · /favorite/guide core+ 后者是公开分享页,解析走 getPublicFavorite
/api/favorites(GET/POST)· /api/favorites/[id](GET/PATCH/DELETE)· …/items · …/copy · …/export · /api/favorites/import core+ 每条各自判档(§8 的档位阶梯);PATCH 只接受 title,改 isPublic 明确报 400
/api/poster/favorite/[publicId] core+ 分享二维码 PNG,复用 RULES.posterMinute;只按公开句柄查,所以私密收藏夹结构性不可达
/api/spider/favorites/[publicId] core+ 站外机器人的入口,只返回公开且未软删的;spider 系列里唯一有限频的一条(鉴权不替代限频)

[@六位] 引用。 只在博客 / 云剪贴板那条管线生效(MarkdownRenderer 的 ContentRefProcessor),评论与讨论刻意不认(与 9 位投票「只识别不展开」同向)。 纯逻辑在 src/lib/favorite-refs.ts。

三处反直觉的实现细节,改动前务必读:

  • 卡片是成品 HTML,不是「占位 div + data-* + 后处理建 DOM」。因为 BLOG_SANITIZE_OPTIONS 是 ALLOW_DATA_ATTR: false,新加 data-favorite-id 会被 DOMPurify 静默剥掉(占位符消失、卡片永不出现、完全不报错);而 div/ul/li/a 与 class/href 本来就在白名单内。投票之所以必须两段式是因为它可交互,卡片是静态的。
  • 替换在最后单独一趟、且按区间切片,不用 String.replace。卡片里含博客标题 (不可信输入),标题里若正好有 [@8位] 字样,按内容替换会命中插入内容里的那处 —— 正是 content-refs.ts 里 replaceClipboardRef 改写成切片所规避的 bug 类型。
  • 上限是 3 张,且必须是正文的确定性函数(与 MAX_IMAGE_REFS / MAX_CLIPBOARD_REFS 同口径)。

入口(曾经一个都没有 —— /favorite 只能手敲 URL,功能做完却进不去):

  • 工具箱(/tool)→「站务工具」区的「我的收藏夹」卡片,与云剪贴板 / 投票箱并列。 这里刻意不设 coreOnly(同一区里的投票箱设了):它原先挂在顶栏头像菜单里, 那时就写明「不按档位条件渲染(core 以下点进去是就地 403)—— 入口不跟着藏」, 换了个入口位置,这条口径不变。
  • 收藏夹选择器弹窗页脚的「管理收藏夹 →」。

两处都有 tests/e2e/favorite.spec.ts 钉着(页脚那条在 favorite-layout.spec.ts)。

刻意不做的事(都是需求明确要求的,别当成遗漏):不显示任何一篇文章的被收藏数 (Blog 上没有收藏计数列,getBlogDetail 的 select 也不该加);作者不收到收藏 通知(本子系统完全不碰 notification-service / topbar-bus);没有修改 isPublic 的接口(改性质只能靠「复制」,且复制是快照)。

UI 上两个「不算 bug 的临界值」(细节见 docs/frontend-styles.md §7.2,几何由 tests/e2e/favorite-layout.spec.ts 在真视口下断言):

  • 星标未收藏时不亮(跟 currentColor),只在 hover 与已收藏时变黄 —— 与点赞/投喂同一套 状态色手法。别再给 .icon-star-fill 写死 background-color。
  • 返回、互动与管理三组外壳全视口同排且等高 48px,互动按钮高 40px、只显示图标与数字; ≤768px 展开管理时互动胶囊暂时收起。收藏选择器的两颗创建按钮并排,<360px 才上下堆叠。

对外文档:docs/bot/favorite-bot.md(自包含,含限频数值 —— 改 RULES 要同步) 与 docs/guide/收藏夹使用指南.md。

6.11 文章对外可见性与分享(Blog.visibility)

站内其余读口一律 core+(那是熟人社区的前提),但作者可以逐篇决定某一篇是否对外。 三档:

visibility 页面可达性 页面 robots sitemap OG 图 OG 图 X-Robots-Tag
internal(默认) core+ 可读;访客 → 登录页 noindex, nofollow 不进 404 —
link 任何人可读 noindex, nofollow 不进 200 noindex
public 任何人可读 index, follow 进 200 all

这一列只对非 core 的查看者生效 —— core+ 在博客域是全读的,压根不看它。所以对任何 站内入口都是零行为变化,管理员 / 站长也不需要豁免(他们本来就在 core+ 里,是结构性 豁免,服务层里没有也不该有 if (isAdmin))。

判定收在 src/lib/blog-service.ts 的四个具名出口(六条不变量在它的文件头): 不带查看者的读口必须走 getExternallyVisibleBlog() / listIndexableBlogs() / listPublicBlogs();带查看者的走 getBlogDetail(id, viewer)。词汇(三档的名字、白名单、 解析、两张人话表)住在 src/lib/blog-visibility.ts —— 那是个零依赖模块,因为发文 表单与文章卡片都是客户端组件,而 blog-service 拖着 prisma 进不了客户端包。

「对外可读」与「可列举 / 可索引」是两件事:link 读得到,但不进 sitemap、不许索引。 所以判可达用 EXTERNAL_VISIBILITIES,sitemap 用 INDEXABLE_VISIBILITIES,别混用。

五个静态守卫盯着它(都是「删掉就会静默坏掉」的那类):

  • tests/unit/blog-visibility-guard.test.ts —— 不许写 { not: 'internal' } / visibility !== 'internal'。那两种写法在加第四档时会静默把新档一起放出去, 而放出去不可逆。
  • tests/unit/anonymous-read-guard.test.ts 的 getExternallyVisibleBlog —— 它是新的一类守卫(per-object 可见性,不是会话档位)。对外路由必须过它, 台账才认得;它不在 PUBLIC_READ_ROUTES 里,因为那张表的门槛明写 「一旦开始返回用户内容就必须挪走」,而 OG 图恰恰返回用户内容。
  • tests/unit/explore-visibility-guard.test.ts —— 钉「对外列表与 sitemap 必须列同一个 集合」:两个出口的函数体里都必须出现 INDEXABLE_BLOG_WHERE 这个名字(见 §6.12)。
  • tests/unit/blog-visibility-tag.test.ts —— 钉站内列表卡片上「除 internal 外每一档都有 标记」。那两个分支写死了字面量类名(css-tsx-classes 只认字面量),所以加第四档时 不会自动长出来,而作者会把没标记的文章看成「没对外」。
  • tests/route/blog-auth.test.ts 末尾那组 —— OG 图对 internal / 已软删 / 不存在 三者 404 且响应体逐字相同(差一个字就是存在性探针);以及 PATCH /api/blogs/:id 的可见性改动只有作者本人能动(管理员也不行)。

分享卡片(OG 图):/api/og/blog/<id> 是 route handler,不是 opengraph-image.tsx 文件约定(后者不在 anonymous-read-guard 的扫描面内, 而「匿名读口没人知道」正是那个守卫存在的理由)。走 sharp 复用 poster.ts 的构件, 不用 next/og 的 Satori —— Satori 不读系统字体栈,中文要自带字体二进制, 那会凭空造出第二条字体管线;而本站的字体约束已经写在 docs/deploy.md 与 npm run diagnose 的探针里。

⚠️ 它的响应头与三张画报相反,别顺手抄:画报是 private, no-store + noindex (用户自己保存的物料),OG 图是 public, max-age=600 + 按档位发 X-Robots-Tag (要被 CDN 与社交爬虫取)。版式几何由 tests/unit/og-card.test.ts 按最坏情况钉住 —— 初版把标题基线写死,2 行标题 + 2 行摘要时摘要正好压在页脚线上,而「是张合法 PNG」 的断言全绿。

爬虫入口(robots.ts / sitemap.ts 是这件事的两半,必须同进同退): /blog/ 从 disallow 里开口(用 RFC 9309 的最长匹配压过 /blog)、/login 补进 disallow(断掉「internal 文章 → 307 → next 参数里带 UUID」那条链)、/api/og/ 开口。 逐页 / 逐张的粒度由页面 robots 元数据与 X-Robots-Tag 收 —— 与 /api/images/[id]/raw 按张发头是同一个手法。

可见性变更记账(blog_visibility_logs,迁移 19): public 是不可逆的,所以「这篇什么时候被放出去的、改过几次」值得有账。只记变更、不记创建 (新文章落默认档不是「变更」,记它等于给每次发文写一行噪音)。改档位与记账在同一个事务里 (updateBlog / setBlogVisibility)—— 分开写会得到「档位改了但没账」,而那种状态只能靠 人工比对发现。actor_id 今天恒等于作者(PUT / PATCH 都只认作者本人),仍然留着:账不该 依赖「将来也不会有人代改」这个假设,假设一旦破那段历史补不回来。 读口是 listBlogVisibilityLogs(),目前只在「管理文章 → 设置可见性」里显示最近一条 —— 面向站长的完整轨迹视图还没做,待办登记在 docs/plan/maintenance-plan.md §3。

风险(详见 src/lib/blog-visibility.ts 与发文表单里的提示):

  1. 一旦公开就近乎永久 —— 搜索引擎与第三方存档会抓走副本,改回 internal 不收回 已抓走的副本,社交平台的卡片缓存(数周)也不撤回。
  2. 可见性不扫描正文。作者自己写下的文字就是他自己公开的;正文里的 [@…] 引用 展开到哪一档由「该引用的读口匿名取不取得到」决定(见 §7.3 的 contentRefs): 图床图片 / 音频 / 公开档剪贴板会出得来,投票与收藏夹保留字面量。 ⚠️ 私有资源仍然出不去 —— 图床与音频的 raw 路由对匿名逐条判档(私有档 404), 剪贴板的私有档在服务端就被筛掉(resolvePublicClipRefs)。
  3. spider 命名空间仍能读到全部 internal 文章(档位是 core+ 的一个账号)。这是现状, 本期刻意不动 —— 要收紧的正确做法是给 spider 单独一档或只读账号, 不是在那条路由里加可见性过滤(那会把「站外聚合器能读什么」和「文章是否对外」 这两件事混在一起)。

6.12 对外公开列表 /explore

第 1 期把「单篇读得到」打开了,但访客读完就是死胡同 —— 而从站外点进来的人没有经过 本站导航。/explore 是第二条边,也是全站唯一不需要登录的浏览面。

取数只有一个出口:listPublicBlogs()(src/lib/blog-service.ts),它以 ...INDEXABLE_BLOG_WHERE 起手,与 sitemap 的 listIndexableBlogs() 列同一个集合。 这条同源不是洁癖 —— 「可发现」的整个承诺就是「爬虫抓得到 ⟺ 公开列表上找得到」; 若列表多一层过滤,就会出现「搜索引擎收录了一篇,读者在公开列表上翻不到」,而这条差异 不会有任何报错。有静态守卫盯(见 §6.11 的守卫清单)。

⚠️ 它按「结果集」判空,而不是按请求:exclude_from_all 与专注模式的栏目屏蔽 (exclude_from_all 是站内「全部文章」的陈列规则;专注屏蔽是账号级浏览偏好 —— 用户自选存在 users.focus_hide_categories,未自定义时用站长标记的 categories.focus_hidden 作默认集合)一律不作用于这里 —— 对外列表的资格只有一条 visibility = public。 混合它们会直接破坏上面那条同源。

侧栏从公开集合反推:listPublicCategoryFacets() 取出公开文章真正出现过的 category_id,页面据此剪枝栏目树 —— 只列「自己或某个子栏目有公开文章」的栏目。 空栏目是死链,也会给搜索引擎一批空页面(与 sitemap 不收空路径是同一条纪律)。 它也刻意不递归:本站只支持两级栏目。

卡片上没有计数(.blog-stats 整块不渲染)。对外视图没有评论区(§6.11), 卡片上写「评论 12」却翻不到评论是自相矛盾的;点赞与鱼干更是站内的事。

作者头像与昵称链接到现有 /u/<id>。主页匿名可达,访客只见已有的公开身份字段; 最近文章 / 评论与计数仍按本人或 core+ 分档,服务层与页面各自判定。 卡片上的可见性短标记取自 VISIBILITY_BADGE (零依赖模块),加第四档时 tsc 会因 Record<BlogVisibility, string> 缺键报错。

索引口径(三条互不重叠):

情形 robots canonical
结果集为空 noindex, follow 自身
带 ?search= noindex, follow(恒) 自身(含 search)
其余 index, follow 自身(含 ?category= 与 ?page=)

⚠️ canonical 恒指向自身,别把分页指到第 1 页 —— 那等于告诉搜索引擎「第 2 页是第 1 页的副本」,后几页的文章会跟着一起掉出索引。这是本仓库第一处用 alternates.canonical。 空结果 → noindex 这一条与 sitemap 是同一件事的两半:sitemap.ts 也只在 listIndexableBlogs() 非空时才把 /explore 列进去,否则就是「把一个自报 noindex 的 URL 请来抓」。(robots.ts 的路径级规则不需要动 —— /explore 本来就未被 disallow, 被 allow: '/' 覆盖。)

访客的两条「博客」入口按档位分流:顶栏与首页卡都对所有人渲染,但 core+ 指向 /blog (站内全量)、其余指向 /explore。这不违反「入口不跟着藏」(§8)—— 那条反对的是 因档位不够就把入口藏掉;这里入口照旧在,只是通向一个访客真能打开的页面。 ⚠️ 别把这当成自相矛盾「统一」回 /blog,那等于让访客点进一张登录页。 另外,访客视图的文章底部还有一条回 /explore 的链接(blog/[id]/page.tsx)—— 那条边是给站外直接落地的读者准备的。

限频:/explore 是本站第一个匿名页面的限频,没有会话可依,只能按 IP (RULES.exploreSearchPerIp,与 ogImagePerIp / spiderFavoritePerIp 同档)。 只对真的带搜索词的请求计数,翻页 / 换栏目不计数 —— 否则限频会变成「限页」, 正常访客翻两页就撞墙,而且不报错、单测也测不出来。超限时不创建查询 promise (创建即发起查询,限频就只省流量、没省 CPU),且提示文案必须与「没有结果」可区分。

⚠️ 对外搜索绝不碰正文:字段集是 PublicSearchField = Exclude<SearchField, 'content'>, 所以那是编译期事实 —— 谁想把 content 加进去,tsc 当场拒绝,而不是等线上匿名用户 把 48.6MB 正文扫一遍才发现。行为侧另有一条哨兵用例兜底。

它不能静态化:根 layout 读 cookie 取登录态(顶栏要按档位渲染),整棵树因此都是动态的。 写 revalidate 是自欺(不会生效,只会让下一个人以为这页有缓存)。

结构化数据:public 文章页输出 BlogPosting(src/lib/json-ld.ts 的 jsonLdScript())。 ⚠️ 本站没有任何 CSP(next.config.mjs 与 src/middleware.ts 都查过),转义写错时 没有任何响应头兜底:一个含 </script> 的标题就能提前闭合脚本块,而文章页恰恰是对外 可索引的。所以 dangerouslySetInnerHTML 一律过那个函数,别在 JSX 里手写。 datePublished / dateModified 一律过 db-time.ts 的 isoWithOffset() —— 库内是「UTC+8 墙上时间贴 Z」,裸 toISOString() 会让机器以为它晚 8 小时发布 (落在未来还可能让搜索引擎暂缓收录)。

6.13 鱼干练手盘(/fish/trade)

投入鱼干买入一个绑定真实加密价格的仓位,价格涨跌决定能拿回多少鱼干。档位 core+ (与签到、投喂同档)—— 它是签到之外第二条 core+ 赚取渠道,没有突破「非核心账号没有 鱼干赚取渠道」那条口径。

这不是交易所,也不是庄家对赌:没有撮合、没有对手盘、没有敞口。所谓「无限水池」 是账外的、不是一行账户 —— 用户赚了就是凭空加进他的余额、亏了就是少发给他, 「水池」只表现为全站鱼干总量的增减。因此持仓流水 related_user_id 留空、 信息进 description(与签到同款)。 ⚠️ 别为了「复式配平」在 users 里虚构一个系统账户行:FishTransaction.userId 是必填外键,那行会长进用户列表与搜索里,成为一个谁都没打算给它的「用户」。 (历史上确实有过一行 raricy-blog-system,它在站外的账户微服务里,随 §6.3.1 一起消失。)

  • ★ 成交价必须是下单那一刻现取的 ★ 这是整个功能唯一的安全边界,其余都是体验问题。 openPosition / closePosition 都调 fetchQuote()(向交易所现拉,3 秒超时),绝不 读 getCachedQuotes() 那份展示缓存。用缓存价成交 = 看盘的人可以在价格跳动后、缓存 刷新前下单 —— 无风险、可重复、无上限的套利,不需要任何交易水平。行情源挂了就 503 拒单, 不降级。tests/unit/market-price.test.ts 有一条专门钉这条性质,别删也别「优化」掉。 前端不传价:提交体里只有标的与金额,确认弹窗如实写明「实际成交价以下单那一刻为准」。
  • 数据源是 data-api.binance.vision(币安拆出来的纯行情公共域,不吃 key)。之所以不用 api.binance.com:生产服务器是阿里云大陆,实测那个域名被 DNS 投毒,CoinGecko / OKX / Coinbase 等一概超时。基址可用 MARKET_PRICE_BASE_URL 覆盖 —— e2e 指向替身,也是上线时 万一出口不通的换源开关,不用发版。见 src/lib/market-price.ts 头部。
  • 展示缓存与轮询:market-poll-drainer.ts 每 15 秒刷一次(MARKET_POLL_MS=0 可关), 由 src/instrumentation.ts 启动 —— 它是本站第二个后台循环。它只服务展示, 与成交无关。页面自己也有轮询(1 秒;隐藏标签页不轮、回前台先补一次)。 ⚠️ 那份缓存(含 K 线)住在 globalThis 上,别改回模块级变量。 Next 把 instrumentation.ts 编进独立的 webpack compilation,market-price.ts 因此 在同一份产物里存在两份模块实例(实测:chunks/7345.js 的 module 7345 是轮询器 那份、chunks/5856.js 的 module 25198 是页面与三个接口那份;模块 id 不同,共享 runtime 的模块缓存于是不会去重)。模块级变量会让「轮询器每 15 秒刷自己那一份、 请求处理读另一份」,而 getCachedQuotes() 只在缓存为空时才去拉一次 —— 页面上那个价 从第一次渲染起永远不再变,且不报任何错(2026-09 实际发生过:站长盯着一个冻住的 页面半小时,同期 BTC 振幅 0.47%)。见 src/lib/market-price.ts 头部, 回归测试见 tests/unit/market-price.test.ts 的「缓存跨模块实例共享」。
  • 实时行情流:market-stream.ts 是一条常驻 WebSocket(本站第三个后台循环), 订币安的 @trade(与成交同口径:都是最新成交价),把价写进上面那份共享缓存里的 stream 字段 —— 与 REST 那份平级,因为 refreshQuotes() 是整份覆盖写。 读侧 getCachedQuotes() 逐标的挑:帧还在 STREAM_TRUST_MS(10 秒)之内就用它, 否则回落到轮询那份。实测依据(2026-09-22 生产机 10 分钟):0 重连、53828 tick、 合计最长静默 0.9s、事件时间差 30~54ms(对照 REST 一次往返 402ms)。
    • ⚠️ 半死判据盯「合计」静默,不是单标的 —— 同一次实测里 BTC 自己冷清过 3.5 秒, 按单标的判会白重连。
    • ⚠️ 握手失败时 node 内置 WebSocket 只报 error 不发 close(域被黑洞时连 error 都不来)→ 所有下线路径走 onDown(),另有 1 秒看门狗兜底。别只挂 onclose。
    • 三道闸门:NODE_ENV === 'test' / MARKET_STREAM_SILENCE_MS=0(运维开关)/ 本进程没有全局 WebSocket(Node 20)时打一行日志后优雅退化。e2e 与 vitest 都要关 (理由与另两个循环同款,e2e 那份在 playwright.config.ts 的 webServer env 里)。
    • 它只喂展示:成交仍然 fetchQuote() 现取。tests/unit/market-price.test.ts 有一条专门钉「流里有价也不许拿来成交」。
  • K 线图(页面中间那张):每个 (标的, 周期) 一次拉满 1000 根(币安单次上限), 缩放/平移全在本地这一批里做,不做懒加载更早的数据 —— 向左手感到头即止, 页面上写明了(「已加载 N 根」)。缩到最远时一根蜡烛是几根的聚合 (页面上标出「每根 = N 根聚合」)—— 那不是造假,就是币安原生大周期 K 线在做的事。
    • 周期白名单 1m/5m/15m/1h/4h/1d 与线上形状(六元组 [openTime,o,h,l,c,v]) 住在 src/lib/market-candles.ts(零依赖 —— 服务端拿去拼 URL 与缓存键, 客户端拿去渲染周期档与取数,两边必须说同一套话)。 ⚠️ 周期是白名单,且 parseInterval 只 trim 不折叠大小写:币安的 1M(月线) 与 1m(分钟)是两个周期,折叠就是把月线画成分钟线;非法周期一律 400, 不静默退回默认档(那会让「点的是 4h、画出来是 1h」活下来)。
    • ⚠️ 缓存键必须带 interval:${symbol}:${interval}:${limit}。改版前周期硬编码在 URL 里、键里只有 ${symbol}:${limit} —— 参数化之后 1h 与 4h 会落进同一格, 切过去原样读回上一档,不报任何错(回归测试在 tests/unit/market-price.test.ts 的「周期进缓存键」)。
    • 它也是展示:GET /api/fish/trade/candles 读的就是那份 60 秒保鲜的 K 线缓存, 与 quote 同性质。页面上「展示价并进最后一根」(末根随 1 秒轮询长高长低、 跨桶时追加新的一根)同样只是渲染。 ★ 成交价永远只有 fetchQuote() 一条路 —— 这条图上的一根线碰不到它。 e2e 里有一条把两者刻意拆开(K 线末根 70000、展示价 80000)再下一单。
    • ⚠️ 跨桶之后那一下「补数据」是个两端的闭环,缺一半就得到一个静默的坏图。 客户端发现末根停在过去的桶里就去补一次(mergeLivePrice 的 rolledOver), 补到追上为止 —— 补回来的那批若仍然缺当前桶就隔 5 秒再补 (TradeChartPanel 的 ROLLOVER_RETRY_MS;跨桶那一发注定补不到:交易所自己 慢半拍,实测币安 1m 在边界后 2.5 秒才换出新桶那一根)。服务端那份缓存的可用条件因此不止 60 秒保鲜期,还有「末根必须落在当前这一桶里」(market-price.behindCurrentBucket)。 少任何一半都会坏,而且都不报错:服务端只看 TTL 的话,跨桶那一刻回的正是上一桶 (上一份恰好取于上一次跨桶,那一刻它正好 60 秒),客户端补一次没补到就再也不补 —— 图永远停在进页面那一刻的右端,只有手动刷新才动(2026-09-28 线上)。
    • 不限频,理由写在路由头部:上游键空间被钉死在 2 标的 × 6 周期 = 12、 缓存两道闸(60 秒保鲜 + 末根必须落在当前桶)→ 每个键每桶最多一次上游请求, 进程级上游请求量有界且与访客数无关;而被每标签页 1 次/秒地 打的 /quote 从来不限频,给「切一次周期才拉一次」的接口上闸是不自洽的。
    • 图表的算术全在 src/lib/market-chart.ts(纯函数,脱离 DOM 单测):缩放的锚点 (指针下那一根必须不动)、聚合的桶对齐(锚在绝对下标上,拖动时整张图不重排)、 价格刻度、时间轴刻度、以及 mergeLivePrice。 ⚠️ 时间轴的跨度必须按原始周期算,别从画出来的图元上推 —— 聚合之后相邻两根 差的是一个桶,跨度会被算大好几倍、刻度跳档,整条轴上只剩一个标签。 同理标签粒度跟着步长走而不是跨度(41 天那张图步长 7 天,按跨度判会整排都是 「2026-09」)。
    • ⚠️ K 线的 openTime 是第三把钟:交易所给的真实 UTC 毫秒(与 Date.now() 同一把尺子,可以相减),既不是库内那套「UTC+8 墙上时间贴 Z」,也不能与它相减。 图上按本站钟面显示时走「加 SITE_TZ_OFFSET_MS 再 getUTC*」(同 nowForDb 的手法)。
    • 工作台与图表设计原则见 docs/frontend-styles.md §7.4;三栏阶梯的具体列宽由 src/styles-scss/pages/_fish-trade.scss 维护。
  • 结算(含杠杆与方向,见下面「杠杆、方向与爆仓」): payoutUnits = max(0, floor(投入 + 名义本金 × move − 名义本金 × 平仓价/开仓价 × 费率)), 其中 名义本金 = 投入 × 杠杆,move 多头 = 平仓价/开仓价 − 1、空头 = 1 − 平仓价/开仓价。 杠杆 = 1 的多头逐位退回加杠杆之前的算式 floor(投入 × 平仓价/开仓价 × (1 − 费率)) —— 存量仓位与那几条钉死的数都靠这条恒等。 手续费那一项不随方向翻转:空头平仓是「买回」,成交金额同样是 数量 × 平仓价。 floor 是刻意的 —— 舍入永远朝系统一侧,宁可少发一个单位也不凭空多铸。手续费 只在平仓侧收一次(开仓免费、持有免费),且按平仓时的名义本金收(杠杆越高摩擦 越大,这是杠杆最自然的一道刹车)。最小投入 1 条鱼干。 空头的收益封顶在 投入 × (1 + 杠杆)(价格最多跌到 0);多头不封顶。这是两个方向 唯一的结构性不对称,它让空头比多头温和,不会加宽账外 mint 的那条尾。 存储精度是这套结算的关键参数:floor 每次都朝系统丢零头,而零头的上界就是 存储的最小刻度。刻度还是 0.1 条时,投 0.1 条(= 1 个单位)的仓位只要价格不涨过 0.1%(那个阈值就是当时的费率)就必然结算成 0 —— 那不是高风险而是几乎必赔的陷阱。 2026-09 把刻度提到 0.0001 条(迁移 21_fish_units_1e4)之后零头上界降到 0.0001 条, 同一笔结算少丢 1000 倍:投 1 条、涨 0.05% 的一笔损耗 0.0003 条(费率 0.02% 占 0.0002,floor 只吃掉 0.0001)。 「最小投入 1 条」这条下限不再由舍入陷阱支撑(理由见 market-service.ts 的 MIN_STAKE_FISH 注释),它现在只剩产品理由。
  • 结算公式只有一份实现:src/lib/market-math.ts 的 settleClose —— 服务端真结算 (写 payout_units 与流水)与页面上那屏「卖出细则」(涨跌幅 / 毛额 / 手续费 / 盈亏 / 预计到手)import 的是同一个函数。它是零依赖模块 —— 页面是客户端组件,而 market-service.ts 拖着 prisma 进不了客户端包(同 blog-visibility.ts 的做法)。 ⚠️ 别在页面里另抄一份公式:两份必然 drift,而用户是看着「预计到手」按下确认的, 偏差又是静默的。e2e 有一条钉「弹窗上那个数 == 真到账的那个数」。 弹窗里那几个数还加得起来(毛额 − 手续费 = 到手、到手 − 投入 = 盈亏),所以 「手续费」这一栏含 floor 扔掉的零头(上界一个存储单位)—— 那是给用户看的账, 不是给对账看的。行情取不到时整组显示「—」,不显示 0。 ⚠️ leverage 在这个函数的入参里是必填,刻意不给默认值:一个忘了传杠杆的调用点 会静默地把 10 倍仓按 1 倍结算,而屏幕上那个数看起来完全合理。

杠杆、方向与爆仓(2026-10 起:1–100 自由杠杆 + 做空;迁移 23_market_leverage / 27_market_direction)

投入 N 条可以开 N×杠杆 条的名义仓位,涨跌按杠杆放大,亏损封顶在投入的那 N 条。 方向决定这份涨跌是「价涨赚」还是「价跌赚」,也决定爆仓价落在开仓价的哪一侧。

杠杆是 1–100 的整数自由输入(2026-10 从固定白名单 1/2/3/5/10/20 + 彩票档 100 改成区间)。上下界、快捷档、警告带阈值住零依赖的 market-leverage.ts (MIN_LEVERAGE / MAX_LEVERAGE / LEVERAGE_PRESETS / HIGH_RISK_LEVERAGE)—— 页面要 import 它渲染选择器,而 market-service 拖着 prisma 进不了客户端包。 ⚠️ LEVERAGE_PRESETS 不再是一份合法集:它只是页面摆几枚按钮、统计页恒定占几行 (见 market-stats-service.ts);服务端认的是区间内的每一个整数,越界 / 非整数 一律 400(绝不就近取整)。改上下界不需要迁移(那一列是整数,不是枚举也不是外键)。

那条「被打穿概率」的判据现在只用来定警告带:以昨收开仓的多头、当天就被打穿的 交易日占比 —— 10×(距离 10%)0.8%、20×(5%)4.4%、25×(4%)9.6%、50×(2%)34.8%、 100×(1%)58.4%。25× 正是曲线拐出「小概率」的地方,所以 ≥ HIGH_RISK_LEVERAGE(25) 在页面上常驻一条危险说明 —— 档位放开之后「档位即难度」不再表达得了这件事,只能靠 值本身的警告。⚠️ 这条曲线是波动率的函数,不是常数:σ 翻倍则整体折半,它本质上 属于标的而不是站点(哪天加一个日 σ 明显更高的标的,20× 在那个标的上就等于今天的 40×)。完整实测见 market-leverage.ts 的注释。

上限的物理意义:费率乘在名义本金上,所以「开仓那一刻实发为正」要求 1 − L × MARKET_FEE_RATE > 0,即 L < 5000 —— 100 离它很远,这一条只说明「为什么不能 无限放开」。100× 本身是「一根普通的日内波动就能把你收走」那一档(爆仓距离 1%、手续费 是投入的 2%,1× 的 100 倍),所以页面给的是警告与抽水的实数,不是当成「更猛的一档」。

方向:long(缺省 —— 存量客户端与 bot 不传这个字段,行为不变)|short。 开仓那一刻写死(同爆仓价,market_positions.direction)。三条静默陷阱: 方向不进开仓幂等键(同杠杆的理由 —— 塞进去 = 同一笔重试变成第二笔、重复扣款); 1× 空头会爆仓(它在 2 × 开仓价归零,见下面那条);收益着色要乘方向 (changePercent 是行情涨跌、不含方向,空头它为正时其实在亏)。

这条路径现在多空都做,但仍然没有挂单 / 限价 / 止盈止损,也没有部分平仓 —— 一行 = 一个批次,整进整出。

  • ★ 「借来的钱」是账外的,因此没有任何借贷表 ★ 它就是上面那个「无限水池」的另一种 用法:没有可借的账户、没有利息、没有还款路径 —— 它们都不需要存在。别为了「配平」 去建 loans / margin 之类的表,也别在 users 里虚构系统账户行(见本节开头那条)。

  • ★ 爆仓不是加在借贷之上的一道安全措施,它是账本形态逼出来的唯一形态 ★ 权益在「开仓价 × (1 − 1/杠杆)」处归零,再跌下去公式给出负数 —— 而负数在这个账本里 没有表示法(postEntry 只收非零整数、users.driedFish 不许为负、余额必须等于流水 之和)。于是 max(0, …) 截断:用户亏光投入、一分不多欠。 ⚠️ 手动平仓与强制平仓走同一个 max(0, …),所以价格穿过爆仓价之后两条路给出 同一个数(都是 0)。这不是巧合,是必需的:若手动平能亏穿,账本当场撕裂;若强平 能多留一点给用户,理性策略就变成「别平,等它爆我」—— 那是个鼓励用户等待的产品。

  • 爆仓价在开仓那一刻算一次就写死(market_positions.liquidation_price, 算法是 market-math.ts 的 liquidationPrice)。存而不重算的理由与 payout_units 同源:有争议时以库里的为准,而且强平引擎的判据必须与用户屏幕上那一行是同一个数 —— 两层各算一遍,症状就是「按用户看到的价不该爆、系统却爆了」。 公式随方向走:多头 开仓价 × (1 − 1/杠杆)(在下方)、空头 开仓价 × (1 + 1/杠杆) (在上方)。⚠️ 「1 倍恒为 0、永不爆仓」只对多头成立:1× 空头 = 2 × 开仓价,是一个 可达的价,它在价格翻倍时归零。于是那个 0 哨兵的含义收窄成「1× 多头 —— 结构上不可能 被强平」,而「要不要强平引擎」的判据是 needsLiquidator(leverage, direction) (= 方向是空 或 杠杆 > 1),不是 leverage > 1;页面「要不要显示爆仓价」同理, 判据是 liquidationPrice > 0(写成 leverage > 1 会把 1× 空头那条真实的线藏起来)。

  • ★ 强平引擎是本站第一个「自己动手动钱」的后台循环 ★(market-liquidator.ts, 第四个后台循环,MARKET_LIQUIDATE_MS 默认 15 秒、0 可关)。前三个循环要么只投递、 要么只写展示缓存;这一个会在用户没按任何按钮时改仓位行的状态。因此:

    • 它不写鱼干流水。爆仓价处的权益恰好为 0,实发恒为 0 —— 没有钱动过,也就不该有 流水(同「实发为 0 的平仓也不写流水」那一档)。不变式由 tests/unit/market-math.test.ts 的「以爆仓价结算,实发恒为 0」钉着: 谁把 liquidationPrice() 改成含维持保证金(爆仓时还剩一点权益),那条会当场变红 —— 那一刻必须回头给强平补一条流水。
    • 结算价是爆仓价,不是触发时的现价(写进 exit_price 的是那一列)。两条理由: ① 封顶 —— 按现价结算遇到插针会算出负权益,只能截成 0;按爆仓价结算则让「亏光」 是算出来的而不是截出来的。② 时间无关 —— 爆仓价是常量,所以「什么时候 发现」不影响「结算成多少」,引擎晚一轮、挂十分钟再起来,用户拿到的数完全一样。 ⚠️ 推论:两次 tick 之间的插针不触发爆仓(多头是跌下去又弹回来、空头是涨上去又跌 回来,那一轮扫不到就不爆)。这不是漏洞:用户无法影响 tick 时机,最坏是被白送一次 「幸免」;而按第 ② 条,它也绝不会让任何人多亏。
    • 扫描面与穿线方向都要按方向走(做空加进来时最容易漏的两处,且都不报错): 候选筛选是 status='open' AND liquidation_price > 0(不是 leverage > 1 —— 后者会把 1× 空头整类漏在扫描之外,而它恰好是最需要清算的那一类);触发判据是 多头 现价 ≤ 爆仓价、空头 现价 ≥ 爆仓价(用多头判据扫空头,一个刚开的健康空头 会在下一轮立刻被结清,而日志只有一行正常强平)。这两处与 openPosition 的开仓闸门 (needsLiquidator)必须同真同假 —— 闸门放行而扫描捞不到的仓位 = 一份没人清算的 免费期权。
    • 判定用现取价(fetchQuote(),与成交同一条边界),理由与成交那条不同: 不是为了防套利(用户按不了这个按钮),而是判定必须诚实 —— 拿 15 秒前的缓存价 去爆一个人的仓,会在他价格已经弹回来之后把他强平掉。行情源挂了就跳过这一轮, 不降级、不猜。仓位留着,下一轮再说。
    • 它不判禁言:禁言是「不能说话」,不该顺带变成「不能止损」。被禁言的人手上还开着 的杠杆仓照旧会被爆(也能自己平掉)。新增这条写路径时最容易顺手补一个 !isMuted —— 别加(同 sell 那条,见下面的禁言小节)。
    • ⚠️ 它不 import market-service(那边 import 本模块取闸门判据,反向成环会炸在 模块求值上)。要复用的算术全在零依赖的 market-math。它算实发时费率传 0 —— 那不是「爆仓不收手续费」,而是在问权益、不问到手(权益都是 0 了,扣不扣都一样)。
    • ⚠️ 那个告警的判据是「实发是否为 0」,不是「权益是否为 0」:浮点下爆仓价处的 权益会留下 ~1.8e-12 的尘埃(实测 3×/5×/10× 都有,2× 恰好干净),拿权益判会让告警 每次强平都响。而实发是 floor 过的整数,尘埃过不了 floor 那道坎(实测恒为 0)。
  • ★ 关掉强平 = 同时关掉「会爆仓的仓位」开仓 ★ openPosition 问的是 isLiquidationRunning()(引擎真的起来了才算,不是「env 配了」),引擎没在跑时 杠杆仓与空头仓(含 1× 空头)一律 503,而 1 倍做多照旧(它永远碰不到爆仓价, 不需要引擎)。卖一个兑现不了的产品比不卖更糟。 ⚠️ 判据是 needsLiquidator(leverage, direction),不是 leverage > 1 —— 写成后者 会让引擎关着的时候照卖 1× 空头,而它既过得了闸门、又进不了扫描(按爆仓价 > 0 筛的 那一套此刻没跑),行永远停在 open。 页面上同款处理:引擎没在跑时做空按钮与 >1 的档位是灰的,只留 1 倍做多 + 一句 「强平服务未运行」—— 与「行情拉不到就禁掉买入」同一档,不是「入口跟着藏」 (那条红线针对的是档位不够)。 ⚠️ 闸门在幂等重放之后、限频之前:重放只是回读既有仓位、不产生新风险,不该因为 引擎此刻没跑就失败;而一次注定被拒的请求没道理烧掉调用方的额度。

  • 平仓的重放判据是 status !== 'open',不是 status === 'closed':爆仓也是结清。 写成后者的话,一个已经爆掉的仓位会走进取价 + 结算 + 条件写那一段 —— 条件写会挡住它 (where status='open' 匹配 0 行)→ 落到 MarketAlreadyClosedError → 回读, 结果碰巧还是对的。这类「结果碰巧对」的错法最难发现,所以判据必须写全。 路由据此把文案从「已卖出」改成「已爆仓」(liquidated: true)—— 用户可能是 「看到跌穿了、点卖出」才发现早就爆了,回一句「已卖出,−100 条」会让他以为是自己卖掉的。

  • 杠杆不进开仓的幂等键(openKey 仍只吃 user/symbol/units):同一个键重放时回报的是 当初那一笔的真实倍数(从库里读)。所以带着不同 leverage 重试同一个键不会买成两笔、 也不会改掉已成交那笔的倍数。别去「补」它 —— 把杠杆塞进键的派生,同一笔重试就会 变成第二笔。

  • 页面上两个数分得很清:买入弹窗里的「参考爆仓价」是按展示价估的(真实那条线 要等成交价出来才定,文案明说了);持仓行里的「爆仓」读的是那一行写死的列, 不自己乘一遍。杠杆选择器只管下一笔,与已有持仓无关 —— 所以每一行自己带一枚倍数角标。

  • 一行 = 一个批次(lot),不是聚合持仓。每次开仓插一行,结清时就地改成 closed (本人平)或 liquidated(被强平)—— 两者都是终态,读取方问的是「还是 open 吗」。 部分平仓会把幂等做成一件难事(同一请求重放时要认出「这是同一笔」而不是「又一次部分 平仓」)。绝不物理删除,没有例外(唯一一处例外曾是「远端同步失败的补偿事务」, 随 §6.3.1 一起消失)。

  • 幂等:开仓靠 market_positions.open_key 的唯一约束(每次唯一;调用方给了客户端键 就按它派生、否则服务端现生成;混进 userId 哈希避免两个用户撞键)。重放时按 open_key 回读既有仓位 —— 这一列就是幂等的实现,所以开仓不另写 account_sync_ledger 行 (判据见 src/lib/fish-idempotency.ts 头部)。 平仓靠 status 的条件写天然幂等,不需要键。实发为 0 时不写流水(没有钱动过)—— ⚠️ 这一档必须显式守着:内核 postEntry 对 units === 0 是抛错的,漏了会让合法的 「近乎归零」变成 500。强平同样不写流水(实发恒为 0,见上面的杠杆小节), 而且它连事务都不需要 —— 一条 where status='open' 的条件 UPDATE 就是全部写操作。

  • 限频:RULES.tradeMinute / tradeDaily(20/分、300/天)。它防的不是刷屏,是 出站流量(每笔成交都要现取一次行情)+ 写压力。桶键 trade: 前缀,不复用 transfer:。

  • 禁言判定是「不对称」的,别统一:档位(core+)在两个页面(/fish/trade、 /fish/trade/stats)与 buy / sell / quote / candles 六处各判一次, 禁言只有 buy 判 —— 后三个只读口与统计页都不判。禁言是 「不能说话」,若在 sell 上也判,用户手上已经开着的仓位就一股也卖不掉,只能看着 浮亏扩大(而且禁言会递增 sessionVersion 废掉旧会话、重新登录也一样)——那等于把禁言 变成锁仓;quote / candles 同理:他恰恰要靠那张图决定要不要止损。代价是禁言用户 仍能兑现已有仓位的浮盈,那是「能出仓」的另一面,不是漏洞。理由写在 src/app/api/fish/trade/sell/route.ts 头部。 ⚠️ 统计页也不判禁言:它是只读页,「不能说话」不该顺带变成「不能看自己的账」。 ⚠️ 强平引擎同样不判禁言(它连用户请求都不是)。加杠杆没有多开一处 —— 它是 buy 的一个参数,当时那五处的档位与禁言判定一个字都没变(统计页是后来才加的 第六处,见下一条);被禁言的用户买不了杠杆仓(同 1 倍那扇门),但手上开着的杠杆仓 照旧会被爆、也照旧能自己卖。

  • 「专注模式屏蔽练手盘」也在这六处判,但它与禁言刚好相反是「全面封锁」: 用户同时开着专注模式且勾选了「屏蔽鱼干练手盘」(focusMode && focusBlockFishTrade) 时,两页 403(forbidden())、四个接口一律 403,含只读的 quote / candles 与统计页。 ⚠️ 别把上面那张禁言表当模板 ——「只读口要放行」那条推论的前提是「禁言只能等, 所以必须留出出仓的路」,而专注屏蔽是本人一键可关的偏好,挡掉只读口不会把人困住。 入口按子项整段不渲染(/fish 卡片的两组「引导语 + 入口」与 /fish/market 页脚那条, 由页面条件渲染;讨论侧栏那颗大区行同款,判据是「屏蔽讨论大区」子项)。这不违反 「入口不跟着藏」—— 那条反对的是「因档位不够就把入口藏掉」,判据见 §8 里那段例外脚注 (专注模式为何整段不渲染)。 ⚠️ 判据永远是 isFocusFishTradeBlocked(user),不是总开关 —— 写成 user.focusMode 会让「专注模式里保留练手盘」这个能力整体失效,而所有默认用例仍绿。 ⚠️ 别再给这处的 §8 补一个书名号标题 —— 引号引的必须是标题,而「入口不跟着藏」 在 §8 里是一段加粗正文、不是标题;补上就会被 tests/unit/docs-xref.test.ts 判红。

  • 协议闸(只走 https):这六条路径还压在 src/middleware.ts 的协议闸下,见 §6.4。 它与上面两条判定不是一类:档位 / 专注 / 禁言判的是「谁够不够格」, 协议判的是「这次请求是怎么来的」—— 所以它在中间件一处判,不在这六处各写一遍。

  • 统计页(/fish/trade/stats)与上面这些:

    • 不需要新表、不需要迁移、也没有接口。market_positions 一行 = 一个批次、 结清时就地改 status 且绝不物理删除,所以整套统计全部可由既有列推出来。 页面是 server component 直连 market-stats-service.ts(与 /fish/trade 同款)。
    • ★ 盈亏的唯一来源是 payout_units − stake_units ★。⚠️ 别改成从 fish_transactions 求和(那不是「另一种口径」,是错的):强平不写流水、 实发为 0 的正常平仓也不写流水(都没有钱动过),而迁移前的平仓流水在远端、 本地根本没有 —— 按账本求和会让盈亏偏乐观、笔数与胜率偏小,屏幕上却一切正常。
    • 判「已结清」用白名单 {closed, liquidated},不是 status !== 'open'。 两者今天等价,失败方向却不同:加第四个终态时后者会静默把它算进胜率与盈亏, 而万一那一行 payout_units 是 null,它会长得跟一次真爆仓一模一样。白名单的失败 方向是「少算」,而那被 Σ 拆解表 === 总数 这条不变式当场抓住。
    • 拆解表的桶 = 白名单顺序在前、数据里多出来的 key 追加在后:改过 MARKET_SYMBOLS 之后残留的旧仓(market-liquidator.ts 头部明写存在这种行)只按白名单遍历的话会 只进总数、不进拆解表 —— 两个数并排放在一屏里,谁也不会去加它。
    • 不做「手续费合计」这一栏:库里没有这一列,而爆仓行的手续费推不回来 (强平调 settleClose 时传 feeRate: 0,exit_price 写的是爆仓价)。只出 投入 / 实发,两者之差就是盈亏 —— 给用户看的账要能加得起来。
    • 持仓浮动盈亏走 settleClose(与卖出弹窗「预计到手」同一个口径,含平仓手续费), 缺价显示「—」且只要有一行缺价,合计也显示「—」。「行情刷新于 …」是轮询那 一次的时刻,不是那个价的时刻(行情流活着时价来自 WS 帧而 quotedAt 不跟着动)。
    • 「胜率」0 笔时是「—」而不是 0%(0/0 是 NaN,会在页面上印出「NaN%」); 爆仓按 status 判、不按「实发为 0」判(手动平一个跌穿爆仓价的仓实发同样是 0, 走的是同一个 max(0, …),只有 status 分得开)。
  • 「最近结清」(/fish/trade 下方那块)与统计页同源:listSettledPositions 逐笔列出 closed / liquidated 的仓位(白名单 SETTLED_STATUSES、closed_at 倒序, 默认取 10 笔且多取一条判「还有更多」,被截断时页面如实说明)。它补的是一个具体的 缺口:结清之后那笔仓位从「我的持仓」里消失,而流水页没有它的条目(实发为 0 不写 流水,见上)—— 用户于是只能看到「仓位没了、账上什么都没留下」。 ⚠️ 同样是只读 market_positions、绝不从账本求和;payout_units 为 null 的异常行 照 null 展示(页面显示「—」),别 ?? 0 —— 那会让「缺一块」与「真的亏光」长得一样。

  • 自选列表在拉不到价时显示「行情暂不可用」并禁掉买入;缓存超龄时显示「数据可能 不是最新的」。绝不编一个价出来 —— 用户会照着一个假价格按下买入。 K 线拉不到同样如实说(「K 线暂不可用」+ 重试钮),有上一次成功那份就继续画它。

6.14 头像框

一张带透明通道的 PNG,绝对定位叠在头像上(src/app/components/Avatar.tsx 的 .avatar__frame)。全站 16 个文件 21 处头像都走那个组件 —— 落点台账与静态守卫 在 tests/unit/avatar-sites-guard.test.ts。

两层:持有 vs 装备

表 / 列 谁说了算
持有 user_frames(一行 = 一款框) 站长发放(npm run cli -- frame grant/revoke,见 docs/cli.md)或用户自己租(鱼干商城,见本节末)
装备 users.equipped_frame_key + equipped_frame_expires_at 用户自己。/settings 的装备面板

一个用户可以同时持有多款、只戴一款;也可以持有却不戴。授予 ≠ 装备。

装备态为什么是 users 上的两个冗余列

15 处渲染点里 8 处的数据来自一条已经在读 users 行的查询(博客作者 / 讨论作者 / 讨论对方 / 签到榜 / 后台用户卡片 / 转账收款人)。加列 = 同一个查询里加两个 select 字段,零 join、零额外往返。写成 1:1 表则每处都要 include,而 chat-service 是按消息批量取作者的(要么 N+1,要么再加一次批量查询)。

漏加的后果不是报错,是那一处永远没有框 —— 静默。取舍的完整论证在 prisma/migrations/20_user_frames/migration.sql 头部。

★ 到期只有一处判,客户端一次都不判

frame-refs.resolveFrameKey(key, exp, now)   ← 唯一的比较运算(纯函数,now 由调用方给)
         ↓
frame-service.frameUrlFor(row)              ← 唯一出口:白名单 → 未退役 → 未过期 → 盘上有图
         ↓
service 层的各 DTO(下发 frame_url / frameUrl 字符串)
         ↓
客户端(16 个文件)—— 不做任何时间比较
  • frameUrlFor 内部固定用 nowForDb(),不接受 now 参数(少一个传错的机会)。
  • 到期是只读判定:过期的值留在列里无害(判定恒 null),所以没有 cron、没有 清理任务、没有「读时顺手清一下」 —— 少一条静默失效路径。想让某人下周失效, 用 frame grant --days 7,别用 revoke。
  • 客户端判零次不只是纪律:tests/unit/db-time-guard.test.ts 规则 3–5 扫整个 src/ (含页面组件),在那里写 new Date(expires_at) > new Date() 会被静态守卫判红。
  • 渲染层不许读 equipped_frame_key 那两个原始列(它们在 SafeUser 上一直存在, 直接读是完全合法的代码 —— 但没判到期)。由 tests/unit/frame-guard.test.ts 拦着。

五条不变量(src/lib/frame-service.ts 文件头)

F1 唯一写入者 users 那两列只由 grantFrameTx / grantFrame / revokeFrame / equipFrame 写(都在 frame-service.ts)。别的文件要动这两列时,往那个文件里加一个 …Tx(tx, …) 内核,别在外面自己写 —— 商城走的正是 grantFrameTx
F2 同步契约 改持有行的 expires_at 时,若他正戴着这个框,同一事务里刷新装备列的副本。违反 = 续期后框永远不出现(看起来像浏览器缓存)—— 这是本设计最隐蔽的一条
F3 装备前置 要 alive 持有行 + 未过期 + 白名单 + 未退役;但素材缺失不阻止装备(先授权后传素材是合法顺序)。卸下无条件成功 —— 否则退役的 key 会变成摘不掉的僵尸
F4 唯一约束含墓碑 收回后再授予必须复活旧行,不能新插(会撞唯一约束)
F5 永不物删 到期 ≠ 撤销:过期只让行失效,deleted 仍是 false

素材与安全

  • key 的权威是 src/lib/frame-refs.ts 的 FRAME_KEYS(源码白名单), public/static/frames/ 只提供字节 —— 见 §6.6 那一段与表情包的对照。
  • 素材随代码入库(2026-09 起):它是我们自己画的,源码就是 scripts/make-frame-demos.mjs,所以住 public/static/ 而不是运行时数据目录 instance/。搬家的理由见 §6.6 那张表;代价(改了脚本忘了重跑)与它的守卫 (manifest.json + tests/unit/frame-assets.test.ts)见 §10 的风险表。
  • 字节路由 /api/frames/[key] 按字节复核 MIME 并只放行 image/png (ALLOWED_FRAME_MIME),拒绝 SVG。刻意匿名,已登记进 tests/unit/anonymous-read-guard.test.ts 的台账。
  • Cache-Control: public, max-age=86400,刻意不 immutable —— 换图流程是 「重跑脚本(或往 public/static/frames/ 拷一张图)再提交」, immutable 会让浏览器一年不来看一眼。
  • 退役一个框:把 FRAMES[k].retired 置 true,不要从 FRAME_KEYS 里删 —— 删了 parseFrameKey 就认不出它,面板没法显示那一行,用户摘不掉它。

画报暂不画框

poster.ts 的 avatarBlock() 已有一圈 3px 描边环,框叠上去会双重描边 —— 而画报是发出去就收不回的分享物(不可逆),宁可暂时不加。预留接口:给 avatarBlock() 加一个可选参数,插在头像 <image> 与描边环之间。

鱼干商城:用鱼干租头像框

/fish/market 的第二块(页面上的「鱼干商城」)。在售的框各自带自己的计价 单位:fishblue 按 1 鱼干 / 天(自选 1–30 天)、starmoon 按 50 鱼干 / 月(自选 1–12 个月,一个月固定 30 天)。没有动表 —— user_frames.source 早有 'purchase' 这个值,框的定义、单位与定价都住 frame-refs.ts(纯词汇,客户端要读)。

  • 价格与在架清单住 frame-refs.ts,单位住 shop-refs.ts(FrameDef.rent / RENT_UNITS / rentableFrameKeys())—— 商城面板是客户端组件,而 frame-service 拖着 prisma 进不了客户端包。放一处,展示与校验读同一个数;两边各算一次的话症状是 「页面显示 1 鱼干、服务端扣 2 条」,而用户只会觉得账不对。 单位不能由调用方传(POST rent 的 body 只有 frame_key + count):让调用方 传的话,「传了月、框是按天的」会静默按月的区间放行。 ⚠️ 月租不摊成按天单价:50 ÷ 30 除不尽,摊出来的价乘上多数天数都过不了 fishToUnits 的 4 位小数闸 —— 按月计价是绕开那个闸,不是审美选择。
  • ★ 原子性:扣鱼干(postEntry)与发框(grantFrameTx)在同一个事务里。 为此把授予的事务体抽成 grantFrameTx(tx, input),grantFrame 退化成自开事务的 薄壳。分开写的后果是「钱扣了、框没到」,两边各自的日志都正常。
  • ★ 续期从「当前到期」起算,不是从「现在」。grantFrameTx 的口径是 「只延长不缩短」,传 now + N 天 会让一个还剩 20 天的人买 3 天走进 noop 分支 —— 鱼干照扣、到期一动没动、不报任何错。这是本功能最容易写错的一处, 用例里有两条专门钉它。
  • 两条拒卖:素材缺失 → 409(与 F3「素材缺失不阻止装备」不矛盾:F3 管的是站长 「先授权后传图」,那时用户没花钱;商城是先收钱);已经永久持有 → 400(永久是 最大的到期,再买必然算得更短 = 又一次「扣钱不办事」)。
  • 不登记幂等:判据在 fish-idempotency.ts —— 键是确定的才登记,而每次点击都是 一笔新交易(同练手盘开仓)。防重复提交是客户端的事(二次确认 + busy 锁), 服务端由 RULES.frameRentHourly/Daily 兜底。
  • POST /api/fish/market/rent 只认会话 —— 它与 bubble-rent 都不走 requireMarketActor 的凭据门。第三道门(请求体里的 username + password)是给站外 脚本的,一旦放行,租金与在架清单就从内部实现变成对外契约(改价 = 破坏兼容), 而机器人租头像框没有真实需求。默认关门,将来真需要再加。 禁言那一道照旧,文案与转账共用一条。
  • 到期仍然是懒判定:租期到了只是 resolveFrameKey 判 null,没有 cron、 没有清理任务(同本节上文)。想续,用户自己再租一次 —— 租期叠加在现有到期之后。
  • 流水 type 是 frame_rent(不是 purchase —— 后者在鱼干语境里已经是 「收银台付款」)。reference_type='frame' + reference_id=<key>。

6.15 音频床(/audio)

图床的平行物:同样一份「上传 → 拿 ID → 用 ID」的骨架,AudioHosting 与 ImageHosting 逐字段同构。四处刻意不同,改之前先看:

  • 独立配额。额度值仍取自同一张 QUOTA_LIMITS_MB(core 50 / admin 50 / owner 100,不另立第二份数字表),但用量聚合打在 audio_hosting 上 (audio-service.ts 的 getUserUsedAudioBytes)。传满 50MB 音频不影响图片,反之亦然。 ⚠️ 因此运维的磁盘占用要两边相加 —— admin-stats-service.getSiteStats 与 cli stats 都分别报「图床占用」与「音频床占用」两行。
  • 不转码、不压缩。图床便宜全靠 sharp;音频要压得动就得引 ffmpeg(本站第一个 非 npm 的二进制依赖),刻意不引。后果见下面那个配额算术。
  • 有 Range(图床那条没有),见下。
  • 不开附件链路。讨论 / 评论的图片附件走 ChatMessage.imageId 那种外键列, 与托管域是两回事;音频只经 [@音频/<ID>] 进正文。将来要做「语音条」那种一等公民 附件,那是另一个决定,别顺手照抄 image_id。

那个配额算术:单文件 10MB × 总额 50MB ⇒ 一个 core 用户最多存 5 个满额文件。 这是所选数字的机械后果,不是 bug —— 但它直接决定了正文里的展开预算: MAX_AUDIO_REFS = 3,远小于图片的 50。图片每个引用背后是一次小文件读,音频是 MB 级传输;正文上限 5000 字能塞下几百个引用,按 50 放开就是一条消息放大出几百 MB。

Range 是硬需求,不是优化。/api/audio/[id]/raw 必须回 206: 播放器拖动进度靠它,而 Safari 会先发 Range: bytes=0-1 探测,拿不到 206 直接不播。 四条实现纪律:

  • 不用 ReadableStream —— 仓库里那两处流(chat/stream、notifications/stream) 是无界 SSE、刻意没有 Content-Length,形状正好相反。本路由的直接读那一段字节 (fs.open + read)并显式给 Content-Length。
  • 畸形头一律当「没给」,回 200 全量。parseInt 的 NaN 参与的所有比较都是 false, 会静默落进一个没人定义过的分支(0 字节或全量)—— 所以先过 ^\d+$ 再转换。
  • 多段 Range 不做 multipart/byteranges,当全量处理。播放器不用它。
  • 200 / 206 用同一套缓存头(public, no-cache + 同一个 ETag,见 §6.6「原始字节 路由的缓存口径」)。别给 200 发长缓存、206 发短的:那会诱使中间缓存拿残段去满足后续的 全量请求,症状是播放器莫名失败、完全看不出跟缓存有关;两个状态的 ETag 不一致则缓存 永远不命中 304,每次都在重下。
  • 带 Range 的请求一律 206 / 416,绝不回 304。按 RFC 9110,给带 If-None-Match 的 Range 请求回 304 是合法的;但本站的硬需求是 Safari 的 bytes=0-1 探测必须先 拿到 206,而 304 是在赌播放器已经缓存了整段 —— 赌输的症状是「音频放不出来」。 所以 304 只发生在没有 Range 的验证请求上。删除不受影响:no-cache 要求每次 使用前验证,软删在验证之前就已经判成 404。

MIME 别名归一化(图床是另一套语义,别互相照抄):.m4a 在三种平台上被报成 audio/mp4 / audio/x-m4a / audio/m4a,Windows 上还可能是空串。normalizeAudioMime 把别名折到规范形,空则按扩展名兜底。⚠️ 归一化只用来补浏览器没给的那格:之后仍要与 detectAudioMime 严格相等,且 verifyAudioMime 返回的是规范形 —— 落库与下发的 Content-Type 必须是认过的那个值。别认 .mp4 扩展名:那是视频容器的通用扩展名, 认了就等于给「传视频」开一条明路。

图床那边(image-sniff.ts 的 normalizeClaimedMime)只把别名用于解释声明:它的 判定是内容优先,落库类型永远来自字节,不存在「归一化后与声明严格相等」这一层。

格式白名单只有三种:MP3 / M4A / OGG。不收 WAV 与 FLAC —— WAV 一分钟约 10MB, 正好等于单文件上限,收进来只会给用户一个「传什么都失败」的入口。嗅探有两处比图床严: MP3 的帧同步要核版本 / 层 / 位速率字段(只判 0xFF 打头太松), Ogg 首个页里必须出现音频编解码器(vorbis / OpusHead / fLaC)—— Ogg 是容器, 只认 OggS 会把 Theora 视频当音频收进来。

[@音频/<ID>] 与保留合集名:ID 也是 10 位 base62(与图床同长),走具名命名空间 而不是长度分流(见 §8「ID 风格」)。代价是 音频 成为保留合集名 —— 与 用户 完全同构的问题:表情那条正则的形状也是 [@A/B]。 两端必须一起改:sticker-refs.ts 的 RESERVED_CARD_COLLECTIONS 让开它, sticker-service.ts 的扫盘跳过同名目录。只改一端 = 「面板里挑得出、一渲染却变成播放器」。

两条管线的接入方式不同,别互相照抄:

管线 走法 为什么
评论 / 讨论(rich-text.ts) 净化后建 DOM(createElement + setAttribute) 白名单里没有 audio(与没有 img 同理)——放开白名单等于给任意外链播放器开口子
博客(MarkdownRenderer.tsx) 源文上直接拼标签串,单独一趟放最末、按区间切片 BLOG_SANITIZE_OPTIONS 本来就允许 audio/controls/preload(一行代码都没用过)

⚠️ 博客那一趟必须配 maskMarkdownCode:源文阶段没有 DOM、跳过不了 CODE/PRE, 不盖码块就会在 <code> 里嵌出一个真播放器(而 audio 在白名单里,DOMPurify 不会拦)。 这是博客侧最可能的静默错误。src 由我们用校验过的 ID 拼成,永不接受用户提供的 URL。

⚠️ 博客正文的两种视图都展开音频(成员 'expand' / 对外 'external',见 §7.3)—— 音频字节路由匿名可取,与图床同性质,所以《音频床使用指南》的 FAQ 里那句「设为对外可见, 读到那篇的人就都能听到」才成立。别把音频归进「要 core+ 的那三种引用」。

⚠️ 播放器的宽度归 .blog-content-container audio 管,且那条 max-width: 100% 不能少 (pages/blog/_blog.scss,紧挨着 img 那一条):<audio controls> 的浏览器默认宽度约 300px 且不随容器收缩,而 390px 视口下正文列的可用宽度只有 204px —— 控件会撑出横向 滚动,右半截(音量 / 菜单)被预览盒子裁掉。这件事源码里完全看不出来(img 有 max-width、audio 一条都没有),页面也不报错;documentElement.scrollWidth 同样量不到 (外层没溢出,是滚动盒子吃掉了)。判据与量测见 tests/e2e/editor.spec.ts 的 「预览里的音频控件不撑破正文容器」,别改成 width: 100%(宽正文里会把播放器拉成一整条)。

⚠️ 那个「空集早退」曾经把音频整趟吞掉:分流正则 \[@\s*(\w+)\s*\] 的 \w 匹配不到 中文,所以「正文里只有音频引用」的正文一个 match 都没有 → preprocess 直接返回原文 → 只贴了一段录音的文章什么都不展开,且不报错。现在音频那趟在早退之前先跑。

⚠️ 顺带一个既有事实(不是本次引入的):BLOG_SANITIZE_OPTIONS 早就允许 audio/source/track/controls/autoplay。也就是说 core+ 作者今天就能在博客里 手写 <audio src="外链">。别顺手删那几个白名单项 —— 博客正文存在库里不在仓库里, grep 仓库证明不了没有存量文章在用。

入口:工具箱(/tool)→「站务工具」区的「音频床」卡片,与图床并列。 与图床同款不设 coreOnly(页面自身 requireCoreUser,档位不够是就地 403)—— 与 §6.10 收藏夹那条同口径,改这里之前先看过去。


6.16 匿名评论(评论区,2026-10,迁移 26_blog_anonymous_comments)

只做评论区,讨论区不做。读者在评论输入框勾「匿名」,这条评论就带化名发表。

名字怎么来(src/lib/anon-identity.ts)

序号 → 化名的纯函数。序号是「在这篇文章里第几个开始匿名评论的人」,1-based:

序号 化名
1 – 26 Alice, Bob, … Zach(26 个裸名,A→Z)
27 – 52 Angry Alice, Angry Bob, …(前缀「Angry」= A)
… 前缀依次 A–Z,共 26 个
677 – 702 Zealous Alice … Zealous Zach
≥ 703 You Win #703, You Win #704, …

27 个前缀(第 0 个是空串 = 首轮裸名)× 26 个名字 = 702,所以第 703 个起才是 You Win #703。规则本身是「排到 ZZ」—— 空串那一行排完裸名,再依次用 A–Z 各开一行, 最后一行正是 Zealous Zach(前缀 Z × 名字 Z),字面上的 ZZ。前缀表因此必须是 27 个词:删掉 Z 那一项总数就掉到 676,末位变成 Yawning Zach(YZ), 「ZZ」不再字面成立,彩蛋边界也跟着提前 —— 而那个边界是对外承诺过的(docs/bot/comment-bot.md §10.4)。

数据形状

  • blog_anon_identities:(blog_id, user_id) → seq。这张表才是权威 —— 「第几个」是这篇文章的历史事实,读时现算(按评论时间取第 k 个)会在删评论或作者注销时 漂移,同一个人的化名会无声改名。
  • blog_comments.anon_seq:非空 ⇔ 匿名评论,值是该化名序号。判据冻在行上, 于是名字表将来怎么改都不会让历史评论改名。
  • blogs.anon_identity_count:发号器。分配靠 increment 原子自增 —— COUNT(*) / MAX(seq)+1 会在两个人同时首次匿名评论时发出同一个号(两个人同名)。 唯一索引 (blog_id, seq) 兜底。
  • blogs.allow_anonymous_comments:作者可关,默认开。闸门在 comment-service.createComment(服务端),前端只是不渲染那个勾选项。

★ 匿名 = 四件事同时成立,缺一条就是破诺 ★

  1. DTO 层(serializeCommentBase,站内评论树与 spider 对外接口的共同出口): author.id → null(真 UUID 一落地,读者点开 /u/<id> 就认出人了)、 username → 化名、is_admin → false(否则等于宣告「这条是管理员发的」)、 avatar_url → 按化名哈希的 identicon、frame_url → null(头像框也是身份指纹)。
  2. 通知(createComment 的两条分支):匿名评论的 actorId 传 null,身份改由 detail 文案里的化名交代。传真身 → 收件人在 /notifications 看到真名 + /u/<id> 链接, 评论树那边抹得再干净也白搭。不能把化名塞进 actorId:那是外键。
  3. 审计公示(AdminActionLog.hide_target):删/恢复匿名评论照旧写日志、照旧公示 (当事人仍可申诉),但 listPublicLogs / getLogDetail 把 target_user 抹掉 —— /audit 是 core+ 都能看的,不抹就等于「删一条匿名评论 = 公开一次身份」。 运维 CLI(npm run cli -- audit)不看这个标志,真身照样查得到。 ★ 需求「管理员删除时能从日志里查到原作者」靠的就是 targetUserId 仍是真身。 ⚠️ 它连申诉人一起管:createAppeal 只许当事人本人申诉,所以隐藏日志的申诉人 恒等于那个匿名作者 —— getLogDetail 因此把 appellant 的 id 与姓名一并抹掉。 只抹 target 而放着申诉人,等于「匿名作者一申诉就把自己交出去」(真名会印在 core+ 都能开的 /audit/<id> 上)。申诉正文照旧公示(透明性不能被匿名吃掉), 所以申诉表单里写明「内容与处理结果会公示」。 ⚠️ 别按对象类型另开小口:匿名评论的正文可以内嵌图床图,而图片 id 是公开的 (评论 DTO 直接下发 /api/images/<id>/raw),图床本身又不公开署名 —— 今天这条 链断在「图片恢复只有运维 CLI 会写、而那批日志是 internal」上,不是断在脱敏上。 哪天给图片(或任何带 id 的内容对象)开一条网页端写路径,就必须同时给它补 hideTarget,否则公示详情会同屏印出「对象 id + 作者真名」(判据见 src/lib/admin-image-service.ts 里 restore_image 那段注释)。
  4. 个人主页:匿名评论不进 getPublicProfile 的 recentComments 与评论计数 (主页本身已没有评论列表,但这个读口仍被 /api/users/:id 用着)。主页是 core+ 都能 看的,列在那里读者拿正文一比对就对上号了。主页墙上的「评论」类数据也遵循同一条: 统计块只给文章数 / 获赞 / 签到,没有评论数。

还有一条正交的:归属判定。CommentNode.is_mine 由服务端按真实 authorId 算 —— 匿名评论的 author.id 是 null,前端不能再拿 currentUserId === author.id 比。 漏了它,匿名作者删不掉自己的评论,且静默(按钮不出现)。

头像

种子是化名,不是真实用户 id(用真 id = 跨文章可关联的指纹)。种子形如 anon~<md5>:那枚 ~ 落在 resolveAvatar 的 [a-zA-Z0-9_-] 之外,于是这张图 必然是现算的 identicon,绝不会去读 instance/avatars/<种子>.png (否则站长放个同名文件就能盖掉它)。URL 仍由 avatarUrl() 这唯一出口拼。

规模与限频

序号只增不减(blog_anon_identities 不删除)。按现在的用户规模,702 那一档到不了。 匿名评论与实名评论共用 RULES.commentDaily 这一个桶(按真实用户 id 计), 没有另开额度 —— 匿名不是绕开限频的通道。


6.17 格式转换器(/tool/convert,2026-10)

八个能力区的文件转换,全部在浏览器本地完成 —— 用户文件字节既不上传也不经过服务端。 这一条是整个子系统的地基,不只是隐私卖点:它意味着这里没有任何服务端路由、没有数据库 表、没有限频、没有审计,也因此没有「服务器替我留一份」的所有风险面。

分层(src/lib/file-converter/)

层 文件 职责
契约 types.ts(零 import、零运行时代码) EdgeDef / RunContext / ConvertResultData / ParamSpec / CapabilityReport
词汇 formats.ts FORMATS 格式表、LIMITS 限额、超时、TEXT_ENCODINGS
识别 inspect.ts(node 可测)/ inspect-client.ts(浏览器入口) 按字节魔数判种类,不按扩展名猜
登记表 registry.ts(零依赖) 有向图的查询与运行时过滤
队列 queue.ts 串行执行、代次、输出预算
执行 execute.ts 边 → 任务校验 → runner 调用
引擎 engines/*.ts 各自能力区的重实现(ffmpeg / pdf / ocr / …)
边 categories/*.ts 八个能力区的 CategoryDef

契约层零 import 是硬纪律:它要被 node 环境的单测直接 import,拖进 DOM 库或引擎包 就废掉「契约可单测」。需要共享的运行时常量一律住 formats.ts。

能力登记表 = 有向图(roadmap §12.1)

识别出输入后,只展示当前真的能做出来的目标。每条边自带四个判据:

  • from + match(info):这条边接哪些输入(match 做内容级判据,如「静态图边拒绝 动画标记」——roadmap §3.1 的动画闸);
  • requires:运行时能力(worker / webp-encode / avif-decode / ffmpeg-enc:libmp3lame …),不满足的边不进菜单;
  • status:live(可执行)/ gated(代码在但当前环境不满足)/ planned (只登记、不实现 run,如 office:docx-to-pdf 需要服务端 LibreOffice);
  • estimateOutput:预算预检(见下)。

⚠️ planned 的边不许有 run、live 的边不许缺 run —— registryProblems() 静态校验,单测拿 CATEGORIES 整体跑一遍([] 才算过)。

⚠️ ffmpeg 系依赖在核心未加载时按「可满足」处理(edgeAvailable)。若按字面判, 菜单要等 31MB 核心下载完才出现 —— 那是「先下载再决定能干什么」。核心加载失败时 才把对应目标标为不可用并说明原因。

队列纪律(queue.ts)

  • 串行:转换是重计算,同时跑只会一起变慢。最多一个任务在跑。
  • 代次 gen:重试 +1。异步回调必须校验 id + gen,旧代次的结果不得覆盖新的。 缺它的话「取消 → 立刻重试」会被上一轮的迟到 resolve 覆盖成旧结果。
  • ⚠️ cancel() 必须先判 activeId === id,再判 status === 'queued':执行体还没 调 onPhase 时任务 status 仍是 queued,但它其实已经在跑。顺序反了 = 不 abort, ffmpeg 之类会在后台把整个转码跑完,界面却显示「已取消」。
  • 输出预算:单输出 64MiB、合计 64MiB。合计到顶时队列暂停,用户释放结果后自动继续。
  • 任务上限 10 个、持入输入 100MiB。

限额(formats.ts 的 LIMITS,页面提示与校验读同一份)

图片 20MiB / 1600 万像素;音频 20MiB / 5 分钟 / 双声道 / 48kHz;视频 100MiB / 2 分钟 / 1920×1080;文档 50MiB / 300 页;表格 20MiB / 20 万行;文本 10MiB;电子书 50MiB; 压缩包 100MiB / 2000 成员 / 解压后 500MiB / 深度 10 / 压缩比 100。

这些是起始实验条件,不是已测得的安全上限 —— 下调安全,上调要补测量记录。

引擎资产(scripts/copy-converter-assets.mjs)

约 65MB 的 wasm / worker / 语言包从 npm 包拷进 public/static/converter/ (不入库,postinstall 与 build 都会跑,见 docs/deploy.md §5)。 重引擎一律在 runner 体内 await import(...) 动态引入 —— 静态 import 会把它们 卷进每个页面的主包。前端只从同源 /static/converter/ 取资产,不做任何外部请求。

诚实说明(roadmap §15)

每次转换的结果区逐条列出保留与损失(有损重编码、透明压平、元数据不保留、动画丢失、 公式只求值、文字层被栅格化……)。这不是可选装饰:ConvertResultData.notices 是契约的 必填字段,边作者写不出「这次丢了什么」就等于没写完。

几个静默错(都不报错)

  • 串输出必须展开:video:to-frames / image:anim-to-frames 传的是模式 (frame-%04d.png),而 exec 是按字面名回读的 —— 不展开就永远拿到空 Map, 症状是「退出码 0,结果区空着」。展开在 ffmpeg.ts 的 expandSeqPattern (纯函数,单测钉住宽度是硬判据)。
  • 取消时引擎被整体杀掉:0.12 的 ffmpeg 没有「只中断当前命令」的接口, abort / 超时的唯一杀法是 terminate() 整个 worker,已加载的核心随之销毁 —— 下一次要重新加载。这是引擎协议决定的代价,调用方别指望「取消很便宜」。
  • 引擎读出的字节要收窄成 Uint8Array<ArrayBuffer>:TS 5.7 起 Uint8Array = Uint8Array<ArrayBufferLike>,new Blob([bytes]) 会报类型错。收窄点在各引擎的 takeOutput。
  • 压缩包安全闸(roadmap §11.2):成员数 / 解压总量 / 嵌套深度 / 压缩比四项上限, 加路径净化(拒绝绝对路径、..、盘符、反斜杠混用、NUL)。解压出来的名字不得直接 当路径用 —— 这是 zip-slip,防的是「解压到任意目录」。

玩家向说明见 docs/guide/格式转换器使用指南.md。

6.18 聊天气泡

user_bubbles 是持有权威,users.equipped_bubble_key / equipped_bubble_expires_at 是读时副本,唯一写入者是 bubble-service.ts。授予、续期、撤销在同一事务刷新副本;撤销软删,重新授予复活原行。到期只读判定,无后台清理。

款式在 bubble-refs.ts 白名单定义,rent 缺失表示停售(不影响既有持有者),disabled 表示停用(回到默认)。默认款免费,无持有行。blue-square(蓝底直角,原「蓝色直角」)与 blue-outline-square(蓝色直角,仅蓝边框、聊天区底色)均为 CSS 气泡,各 10 鱼干/月,权益独立;更名保留原 key 和租期。底色和文字共同适配主题。共享计价词汇在 shop-refs.ts,月固定 30 天。

ChatBubble 共用于真实消息、商城与设置预览,气泡不进入 Markdown 正文。删除占位、拍一拍、回复摘要和独立附件保持既有展示规则。消息不存气泡快照,ChatAuthorDTO.bubble_key 是作者当前有效气泡;分页、搜索、发送与 SSE 补齐都通过同一序列化出口。

装备/授予/续期/撤销提交后发布不含身份数据的 appearance SSE 事件;聊天页从 GET /api/chat/channels/:id/appearance?users=... 获取当前消息作者状态,只更新作者 DTO,不替换消息窗口、不移动历史视图。接口复用会话权限和大区屏蔽闸,每批最多 100 个作者;60 秒对账与重新可见时读取同一快照,兜住漏推与到期。

POST /api/fish/market/bubble-rent 与租框同档:登录、非禁言、仅会话。客户端只指定 key 和数量,服务端定价;postEntry 扣款和 grantBubbleTx 授予在同一事务。续期基准在事务内取 max(now, 旧到期),不向永久持有者售卖。两类租赁共用现有租框限频桶,二次确认/busy 防重复提交,每次确认是一笔新租赁。

气泡流水为 bubble_rent,reference_type='bubble'、reference_id=<key>;shop_all 合并 frame_rent 与 bubble_rent。购买不自动装备。运维命令和玩家规则见 docs/cli.md 与 docs/guide/聊天气泡使用指南.md。

6.19 玩具(/game)

首页内容卡按「故事 / 玩具 / 博客 / 工具」排列;讨论入口保留在顶栏。 游戏清单住 src/lib/game-catalog.ts:每款游戏只登记一次,玩法类别、游玩方式与 启动方式分别描述。合集可以嵌套:清单只写 parentId,URL 由父链推出来 (/game/collections/<父>/<子>),没有第二份 href 可漂;玩具首页只陈列顶层合集与 ATÅMAS(益智、单人、原生组件),合集页陈列「直属游戏 + 子合集」,路由是 catch-all (src/app/game/collections/[...slug]/page.tsx)。顶层「测试」合集 /game/collections/test 收录 Wand demo、Wand demo 2、公共空间、圈地运动、合页 Demo、 异文 Demo、折光与双影,并挂着手工间子合集 /game/collections/test/craft (织布机、剪纸、水面印花);旧的顶层地址 /game/collections/craft 永久跳到新路径。

src/games/atamas/ 包含客户端引擎、Canvas 绘制、24 种语言与界面, src/app/game/atamas/page.tsx 是路由包装。游戏匿名可玩,不调用账号接口、 不连接实时服务、不保存服务端成绩;语言偏好保存在浏览器 atamas_lang。 页面外壳与按钮随全站主题,画布及数字圆盘保留游戏配色。

折光(/game/lumen)是纯客户端光路解谜,源码在 src/games/lumen/。 22 关按镜片反射、分光、双色交汇、传送门与进阶实验逐步展开;可自由选关、撤销、 重来和查看两层提示。engine.ts 按「位置 / 方向 / 颜色」去重光线,分光与传送回路 也会有限结束。单色接收器要求颜色恰好匹配,双光接收器要求两种光同时到达。 每关最多 12 个可转动元件,求解器穷举朝向,用当前局面最近的解生成提示; 全部关卡的可解性与三星步数由 tests/unit/lumen.test.ts 验证。 浏览器 raricy_lumen_v1 保存当前局面、转动次数、最佳成绩与音效开关;损坏存档 会回退,禁用存储仍可游玩。撤销历史只在本次页面内保留,成绩不写服务端。 页面沿用玩具区的 HTTPS 与专注模式闸门,不新增接口、库表或游戏奖励。

src/games/wand/ 是 Wand 的 Canvas 客户端,/game/wand 登录后可玩,不要求 core 档位。 主站 POST /api/game/wand/launch 检查「专注模式屏蔽玩具区」、封禁与用户限频,签发 60 秒一次性票据。 票据摘要存在主站单实例的 globalThis Map,实时进程通过独立 Bearer 凭据调用 POST /api/internal/realtime/session 消费;浏览器 Cookie 不能调用这个内部接口。 实时进程每 10 秒复核会话版本、封禁与玩具区屏蔽状态,失败关闭已有连接。 地图、速度和碰撞由服务端计算,客户端只提交方向输入;当前没有持久化成绩和房间存档。

/game/wand2 是独立的精灵战斗版,共用一次性票据与身份复核边界,游戏 ID 为 wand2。 shared/wand2/ 的权威世界统一处理移动、长剑攻击、史莱姆寻路与扑击、受击击退及猫薄荷奖励; 客户端插值绘制四方向人物精灵与草地地图。在线账号按猫薄荷实时排名,同账号多标签共享分数, 仅保存在实时进程中,重启清零;没有玩家血量、鱼干写入或数据库迁移。协议与资源上限见 docs/realtime.md §3.2。

圈地运动(/game/quandi)保留原作六种模式与本地双人,登录后可玩;联机使用同一实时 服务与主站一次性票据。原作页面放在不透明来源的 sandbox iframe,文档路由单独鉴权, 父页持有网络连接与票据,游戏文档不能自行联网或访问本站 Cookie。账号名安全写入文本节点, 服务端独立维护双人房间与权威引擎,校验方向、序号、房主和准备状态,并限制房间资源。 授权与来源见 public/static/quandi/NOTICE.txt,生命周期及上限见 docs/realtime.md §3.4。

玩具区与练手盘同一条协议闸:/game 与它下面的一切(含 /game/quandi/play 那份 iframe 文档)以及四个 /api/game/*/launch 只走 https —— 页面在明文 http 下 308、 启动接口回 403。启动接口才是联机游戏真正的入口(一次性票据在响应体里),所以它在清单上; 而实时地址在回环之外本来就不许是 ws:(src/lib/realtime-url.ts 会抛错)。 判据、回环豁免与「为什么只在中间件一处判」见 §6.4 与 src/lib/https-guard.ts 文件头。

专注模式**且勾选「屏蔽玩具区」**时,首页卡和顶栏入口整段不渲染; src/app/game/layout.tsx 同时替换菜单和游戏直达页为设置提示,不挂载游戏引擎。 在设置里取消该项(或关闭专注模式)后即可重新进入;判据是 focus-mode.ts 的 isFocusGamesBlocked(user),不是总开关。

6.20 保存的私聊消息(/chat/saved,2026-10,迁移 30_saved_chat_messages)

它不是收藏夹。 收藏夹是「可分享的合辑」:公开夹有 6 位句柄 / 二维码 / [@ID] / bot 接口,私密夹也能复制成公开、能导出 JSON —— 它的每个动作都在暗示「可以发出去」。 私聊消息恰好相反:只有会话当事人能看。把两者放进同一个容器,用户就得永远记着 「这个夹子里的东西有些不能发」,每个出口也要各加一道例外。所以单开一张薄表 saved_chat_messages:没有句柄、没有复制、没有导出、没有 bot 出口 —— 不是藏起来, 是根本没有那些东西。需求是「把这句话存下来以后回看」,不是「把私聊消息编成合辑」。

四条不变量(src/lib/saved-message-service.ts 文件头):

  1. 只存指针,不存正文。消息会被作者 / 管理员软删(chat_messages.is_deleted), 快照会让保存箱变成「删了还能读」的后门 —— 读时按当前消息行重新解析,删了给 [该消息已删除] 占位。
  2. 保存范围 = 自己当前仍是成员的私聊(kind='direct')。写入口一次判齐「消息存在 + 未软删 + 是私聊 + 我是成员」,任何一条不满足都返回同一个 badinput —— 不区分 「不存在」与「无权」,否则接口就成了「某条消息是否存在于别人私聊」的探针。大区消息 不进保存箱。
  3. 读路径重新判权限。保存过不是永久通行证:消息删了 / 作者注销 → deleted 占位; 频道没了 / 不再是我的私聊 / 我被移出 → forbidden,不返回正文、作者、频道 id。
  4. 墓碑复活。唯一约束 (user_id, message_id) 是物理的、包含软删行 —— 取消后再保存 必须翻转 deleted 复活旧行,不能新插(同 favorite_items / user_frames)。

接口与页面:

路径 说明
POST /api/chat/saved body { messageId };幂等,已保存返回成功;失败一律 400 同一文案
DELETE /api/chat/saved/:messageId 取消保存(软删);幂等,且不限频(额度满了也要能腾名额)
GET /api/chat/saved/ids?channel=<id> 某私聊频道里我保存过的消息 id,供消息列表标「已保存」;大区返回空数组
/chat/saved 服务端页面:core+,禁言与 /api/chat/* 同闸;条目可跳到原消息,可取消保存

消息上的入口在右键 / 长按唤出的操作菜单里(.chat-menu,组件 ChatMessageMenu)—— 消息上没有常驻按钮,「保存 / 已保存」是菜单项。手势识别与菜单样式见 docs/frontend-styles.md §7.4。

每人上限 1000 条(只数未取消的);增删复用 likeHourly / likeDaily 的数值,但 另起键前缀 chat:saved:h: / chat:saved:d:(rule 不参与分桶,复用别的前缀会互相 吃额度)。

深链:条目链接是 /chat?channel=<id>&message=<id>。聊天页首屏会先 POST /api/chat/channels/:id/reveal —— 否则被「删除会话」隐藏的频道不在 poll 列表里, 页面会静默落到第一个会话(点了一条保存的消息、打开的却是别的对话)。恢复后再交给已有的 jumpToMessage:目标不在首页时按 id 补拉前后上下文窗口,与搜索跳转共用同一条路径。

测试:tests/service/saved-message-service.test.ts(权限 / 软删占位 / 复活 / 分页 / 配额)、tests/route/saved-message-api.test.ts(档位 / 同形失败 / 幂等)、 tests/route/chat-auth.test.ts 的 handler 清单(新增的 4 个 handler 必须登记)。

6.21 个人主页积木墙(/u/[id],2026-10,迁移 31_profile_blocks / 32_profile_block_sort)

主页在 2026-10 从「文章 / 评论标签页」换成了对外名片 + 可布置的积木墙。两张脸:

  • 上半截 profile-hero 是固定身份条:头像(含头像框)、用户名、role 徽章(按查看者 分档,仍走 user-service.getPublicProfile)、简介、注册日期。不可定制。
  • 下半截是一张网格:用户自己挑块、自己摆。块只有七类 —— 文章 / 图片 / 音频 / 文字 / 链接 / 统计 / 签到。旧标签页已删除:评论不能上公开主页(匿名承诺,见 §6.16), 文章列表改成「作者亲手挑的精选」。

为什么不再有 core+ 分档:旧页是「站内档案」,内容只对 core+ 开;新页是「对外名片」, 墙上挂的东西本来就对外可读(public / link 档文章、isPublic 的图与音频),所以对 游客也开放。分档只剩 role 徽章那一处(身份字段的人话说明)。布置入口只对「本人 + core」 出现,判据是 canEdit && !startEditing —— 不再等 isDefault:布置过的用户同样要能 再进去改。

四条不变量(src/lib/profile-service.ts 文件头):

  1. 只出对外可读的数据。读路径对文章叠 EXTERNAL_VISIBLE_BLOG_WHERE、对图 / 音频叠 isPublic = true;不做「先查出来再在渲染层挡」——值进了内存就有忘挡的活口。
  2. 存指针,不存快照。profile_blocks.config 里只有 blogId / imageId / audioId; 目标被删、降回 internal、作者归档时整块 view = null,访客视图这块连 DOM 都不出现。 抄标题进 config 会出现「文章已改成私有、名片上还挂着它」的洞,且永不报错。 高卡片的正文摘要也是读时现算(见下),同样不进 config。
  3. 保存 = 整页替换 + 软删墓碑。少掉的块翻 deleted 墓碑(全站永不物理删除), 其余按 id 更新;不「全删再全插」(会撞主键)。客户端带来的 id 只在本人的行里认, 其余重新发号(防借别人的 id 撞主键)。
  4. 保存只校归属,不校档位。档位在读路径判。若保存也要求「此刻对外可见」,文章刚 降档的人会整页都存不进去(包括想删掉那一块);而那块本来就会渲染成空。归属仍 必须查,否则等式变成「把自己的块指向别人的 id」= 越权指针。

同一份坐标:桌面 4 列是唯一真相,手机 2 列是它的投影(src/lib/profile-blocks.ts 的 projectMobile / mobileColumn / mobileOrder;这里就是「4 列和 2 列怎么对齐」的 全部答案):

  • 宽 ≥2 的块(2×1 / 2×2)在手机上通栏。所以「2×1 摆在桌面第 2–3 列」这种跨中线的 摆法不需要特殊处理:只要宽度够,它在手机上就是通栏。
  • 宽 1 的块看桌面左右半:x < 2(第 1–2 列)进手机左列,x ≥ 2(第 3–4 列)进 右列。两列桌面 = 一列手机。
  • 竖直方向按桌面阅读顺序 (y, x) 从上往下紧排、可填洞:桌面上刻意留的空行在手机上 不保留(窄屏空一整行是纯浪费);后面来的窄块可以补进前面高块旁边空出的列里。顺序由 投影纯函数唯一决定,不是浏览器行为(mobileOrder 是投影后的行优先序,读屏 / DOM 顺序 读它;上下移用 mobileNeighbors,同列内换位)。
  • 手机上的拖动 / 左移右移 / 上移下移改的都是桌面坐标 —— 两端因此永远显示同一份布局, 不会出现「桌面改完手机还是旧的」。上移 / 下移只与同列邻居换位(跨列换会看到「我 移它、隔壁却跑了」);左右移把 1 宽的块换到另一半,宽块通栏没有方向、按钮置灰。
  • 绝不用 grid-auto-flow: dense:JS 读不到浏览器流出来的位置,网格线与拖拽命中会 各算一套,症状是「看着在这、拖到那却换了别的块」。

profile_blocks.sort 已失效,只留旧值(迁移 32_profile_block_sort):读路径 orderBy: [{ y: 'asc' }, { x: 'asc' }],不读 sort;保存路径也不写 sort。老行里 残留的旧值无害(没有任何读取方),删列 / 回填要另做一次迁移,不值得。⚠️ 谁再按 sort 排序,症状是「手机上看到的顺序与桌面阅读顺序对不上」,而全程不报错 —— 手机 顺序的唯一来源已经是投影后的 (x, y),不是另一份被持久化的顺序。

文章摘要只给高卡片,且是读时现算的(src/lib/blog-excerpt.ts):h≥2 的文章块有 第二行空间,resolveViews 对这些块指向的文章额外查一次 blog_contents,用 blogExcerpt() 压成一行纯文本塞进 view.excerpt;查询仍叠归属与 EXTERNAL_VISIBLE_BLOG_WHERE(internal 正文连查都查不到)。矮卡片(h=1)恒为空串, 也就不会为它多查一次库。摘要从不写回 config —— 正文改了下次请求就变;文章降档 / 被删 → 那块 view = null,摘要一起消失。

默认布局:没存过任何块 = 最近三篇对外可读文章(最多 3 块)。刻意不落库 —— 落库会把「默认」变成一份需要迁移的数据;用户第一次进编辑器看到的就是这份默认布局, 保存才真正写表。它尊重设置页那个旧开关 showRecentBlogs:关掉的人不该因为「还没布置」 就被自动挂上三篇 —— 开关只管默认布局,亲手加的块照常。

页面与接口:

路径 说明
/u/[id] 匿名可达(主页画报二维码把站外人引到这里)。generateMetadata 给 robots: noindex, nofollow —— link 档的语义是「可读、不列举、不索引」,主页可被二维码传播,不能顺带把它挂的文章带进索引
PUT /api/profile/blocks 整页保存(body { blocks })。只认会话、只对 core+ 开(产品决定:2026-10 先只开核心用户);限频 RULES.profileSave(30/分)—— 整页替换一个事务里最多 16 次写。成功响应 { count, blocks, isDefault }(apiOk 把 data 摊平,所以字段在顶层,不是 data.blocks):blocks 是刚写进库那份经 resolveViews 解析后的形状(含 view / 含高卡片 excerpt),客户端拿它保存后立刻重画、不必等路由缓存;isDefault 同一响应回传,避免「块是新的、isDefault 是旧的」;catalog 不回传(编辑器打开时那一份足够)

词汇表 src/lib/profile-blocks.ts 是零依赖的:块类型 / 四档尺寸 / 单块与整页校验 / 网格找空位 / 手机投影与换位(projectMobile / mobileColumn / mobileOrder / mobileNeighbors / swapBlockPositions / moveToOtherHalf / moveInMobileOrder)/ 渲染契约 BlockView。编辑器要 import 它,而 profile-service 拖着 prisma 进不了客户端包; 两端用同一份校验,客户端那份只是体验,服务端才是防线。

测试:

  • tests/unit/profile-blocks.test.ts —— 白名单 / 重叠 / 越界 / 配置清洗;手机投影 (宽 2 通栏、跨中线 2×1 仍通栏、宽 1 左右半、(y,x) 紧排填洞、mobileOrder / mobileNeighbors)、swapBlockPositions 合法与「压到第三块」拒绝、moveToOtherHalf / moveInMobileOrder。
  • tests/service/profile-service.test.ts —— 对外可见 / 指针重解析 / 墓碑 / 越权 id / 降档后可保存;读序 = (y, x) 且 sort 不读不写;摘要只给 h≥2、矮卡片为空、internal 正文不外泄。
  • tests/e2e/profile-board.spec.ts(登记在 RESPONSIVE_SPECS,desktop + mobile 两轮)—— 身份条入口点进编辑、保存后不刷新看到新版;方格(宽≈高)与 gap、连续网格线; 手机按投影列落位 / 宽块通栏 / 左右移 / 同列上下移 / 把手拖动换位;固定工具条遮挡; 高卡片摘要;文字块详情层与 Esc / 背景关闭。

6.22 每日一言

签到成功后由客户端独立读取 /api/daily-quotes/today,原有签到与到账事务不变。 读取要求 core+ 且当天已签到;UTC+8 每日选择保存在 daily_quote_editions.day 唯一键上。 同进程通过全局在途 Promise 合并并发请求,数据库唯一约束是最终防线。 最近90天已经选用的内容不再参与;可用社区稿件优先,没有可用稿件才从内置经典中随机选择。 当天内容被下架后保留占位,不更换当天选择。

内容池与审核住 daily_quotes,正文规范化哈希唯一,拒稿终态且不能更改标点重复投稿。 内置300条公版原文目录在 src/data/daily-quote-classics.json,与手写迁移的种子逐条对账。 daily_quote_resonances 的语录/用户/日期唯一键、付款者出账、投稿人入账同事务提交; 社区共鸣从付款者扣1鱼干,投稿人全额收到,经典共鸣免费。作者角色与禁言在事务内重新核验。 匿名稿件的普通读口不返回账号ID或昵称,付款流水的关联收款人置空,审核只记内部日志。

daily_quote_favorites 是本人私密的「一言集」分类,所有者限定、无公开句柄;取消只软删, 重新收藏复活原行。已下架内容只保留可移出的占位。归档账号边界沿用 archive-scope。 用户操作、审核入口与接口字段见 docs/guide/每日一言使用指南.md。

7. 数据流(4 个典型路径)

7.1 用户登录

浏览器 POST /api/auth/login
  → middleware.ts  ✓ 同源
  → route.ts       校验 Turnstile(如开启)
  → auth.ts        password.verify()    (werkzeug 互通)
  → route.ts       session.sign()       (JWT cookie)
  → 响应 200 + Set-Cookie

7.2 投喂一篇文章(小鱼干写路径)

浏览器 POST /api/blogs/<id>/feed
  → middleware.ts  ✓ 同源 + core+ (装饰器)
  → feed-service.ts 一个事务:
        投喂者扣款(postEntry,条件谓词防超扣)
        作者全额收入(postEntry,与出账金额相等,无手续费)
        BlogFeed 累计 + Blog.fishCount
  → COMMIT
  → 通知作者(提交之后才发,失败只 warn,不影响已成交的钱)
  → 响应 200
       ├ 余额不足 / 超上限 → 400(业务结果)
       └ 事务失败          → 500(真故障)

⚠️ 这里没有 503 这一档。它曾经是「远端账户服务不可达」的专用码,随账户服务搬进 站内一起消失(历史注记见 §6.3.1)。别的鱼干路径也都一样:转账 / 签到 / 练手盘 / CLI 发扣全是「一个事务 + 400 或 500」。对外文档里凡教调用方 「看到 503 就重试一次」的地方都已改写 —— 那些重试建议的前提(本地已被补偿回滚) 不存在了。

例外只有注册(user-service.ts):它的两处通用故障兜底仍返回 503 (registerUser 的未知 precondition、mapCreateFailure 的 unexpected)。 那是错误码选择问题,与账户服务无关,改它会连带动 CLI user create 的 退出码映射(docs/cli.md 有口径)—— 本轮的契约变更不覆盖它, 别看到「没有 503 了」就顺手改掉。

7.3 浏览一篇文章(读路径)

文章时间在详情页、站内/公开列表与个人主页共用 BlogTimestamps:发布时间读 Blog.createdAt,更新时间读 BlogContent.updatedAt。详情默认显示完整日期,用原生 <details> 展开到秒的完整北京时间(鼠标、触屏和键盘都可用)。列表仅显示一枚短日期: 今年如「10月11日」,其他年份如「2025年10月11日」,今年按 nowForDb().getUTCFullYear() 判定。完整发布 / 更新时间保留在提示中,屏幕阅读器也能读到所示日期的发布 / 更新语义; 机器读的 <time datetime> 与 JSON-LD 一样走 isoWithOffset()。 详情首次发布只显示发布时间;后续更新时间晚于发布时间才提示编辑。同日只露一份日期 和「已编辑」,跨日再显示更新日期。站内列表按当前排序选择发布或更新日期,尚未编辑 的文章仍对应发布时间。历史空值不补造。 updateBlog 只在标题、摘要、正文或栏目实际变化时刷新更新时间,重复保存、仅改可见性 或匿名评论设置都不刷新。已有历史更新时间保留原值,无法从它反推当时是否实际改了内容。

一条路由两种视图,分叉判据是 isCore 而不是 visibility(core+ 读一篇 public 文章看到的仍是完整成员视图 —— 否则「设为公开」会顺手夺走作者自己与全站的互动能力):

浏览器 GET /blog/<id>
  → middleware.ts  ✓ 同源
  → blog-service.getBlogDetail(id, viewer)   viewer = null(游客)/ { id, isCore }
       非 core 叠 EXTERNAL_VISIBLE_BLOG_WHERE(core+ 不加条件)
  → 查不到(不存在 / 已软删 / 档位不够,三者同形)→
       未登录  → redirectToLogin('/blog/<id>')   站内文章不是「不存在」,访客拿登录页
       非 core → forbidden()                     已登录但档位不够 → 原地 403
       core+   → notFound()
  → Server Component 渲染 Markdown 占位 + 注入数据
       core+ → 正文 + FeedButton + CommentSection(contentRefs='expand')
       访客 → 标题 / 作者 / 文章时间 / 正文(contentRefs='external'),零站内 affordance
              + resolvePublicClipRefs(正文) 的结果当 externalClips 一起下发
  → 客户端 marked + DOMPurify + highlight.js 完成正文

⚠️ 原始 markdown 是作为 RSC prop 随首屏 payload 下发的 —— 「正文交给客户端渲染」 不构成任何保护,闸门必须在服务端把串传出去之前(docs/architecture.md §6.7 那条管线)。 对外视图那句 resolvePublicClipRefs 同理:判档在服务端做完,下发的只有判过的内容。

⚠️ MarkdownRenderer 的 contentRefs 是必传 prop,两个取值都展开引用,差别在 展开到哪一档:

取值 用在哪 剪贴板 投票 图床 收藏夹 音频
'expand' core+ 的页面(成员视图 / 剪贴板详情) ✅ 客户端带凭据拉 ✅ ✅ ✅ ✅
'external' 对外视图(访客 / 非 core) 只出服务端下发的公开档 ❌ 字面量 ✅ ❌ 字面量 ✅

判据不是「是不是站内内容」,而是这条引用的读口匿名取不取得到:图床与音频的字节 路由匿名可达、逐条判档(私有档对无权者 404);剪贴板 / 投票 / 收藏夹的三条接口一律 要 core+ 会话。'external' 一个请求都不发(所以它不需要凭据,也拿不到 401)。 ⚠️ 别把它缩回「一律不展开」——「作者把文章设为对外可见,读到的人却看不到正文里的 图和录音」正是被修掉的那版行为;也别忘了私有资源仍然出不去(见 §6.11 风险 2)。

⚠️ generateMetadata() 与页面是两个独立的渲染步,页面那道可见性判定管不到它 —— 它必须自己判一次,判不过就返回中性标题、绝不回显(tests/e2e/access-control.spec.ts 与 tests/e2e/blog-visibility.spec.ts 各有一条钉子)。

7.4 浏览目录 /blog(列表读路径,流式)

page.tsx 不 await 列表数据,把 listBlogs 的 promise 交给 <Suspense> 里的 BlogListSection:hero / 搜索框 / 侧栏分类在首个 flush 就画出来,列表随后补上。 实测 RTT 300ms:hero+侧栏 400ms、列表 700ms(改前两者同为 ~790ms)。

浏览器 GET /blog
  → middleware.ts  ✓ 同源
  → page.tsx       requireCoreUser() → cookies() → 渲染 hero / 侧栏   ← 首个 flush
  → BlogListSection  await listBlogs()                              ← 流式补上

为什么不能用路由级 loading.tsx(试过,三处回归,别再试):

  1. 它把整页压成 fallback;React 靠内联脚本 $RC 把真实内容从 <div hidden> 换入,无 JS 时永远停在骨架 —— 连 hero / 搜索 / 分类导航都没了,会踩掉 tests/e2e/blog.spec.ts 的「禁用 JS › 小屏目录保持展开」。Suspense 方案只罩列表。
  2. 守卫被推到挂起之后才跑,响应已以 200 冲出:匿名 /blog 307→200、 role=user 403→200。
  3. 边界让 DOM 非单调(内容 → 骨架 → 内容),.blog-item 计数/顺序与 cookie 稳态断言开始随机挂。

另一个反直觉点:服务端本来就在分块(实测拆成 57 个 chunk),但没有 Suspense 边界时客户端是整体提交的 —— 分块换不来渐进绘制。「服务端在流式」≠「用户看得见流式」。

骨架(BlogListSkeleton)尺寸按真实元素逐项实测对齐,类名一律 blog-skeleton-*, 不复用 .blog-item(专注模式用例断言它计数为 0)。

这套做法只值得用在载荷大的页面。 收益 ≈ (载荷 − 外壳)/带宽 —— 骨架能提前多久 出现,取决于「正文那部分要传多久」。实测各列表页的 RSC 载荷:

页面 载荷 值不值得改
/blog 91 KB ✅ 骨架可见窗口 ~300ms
/admin/users 39 KB ⛔ 见下方硬约束
/admin/appeals 26 KB ⛔ 见下方硬约束
/image(162 张的重度用户) 14 KB ❌ 窗口 ~70ms
/vote 13 KB ❌ 实测骨架只露 42ms,白加一层复杂度
/u/[id] 13 KB ❌ 同上
/audit 3 KB ❌ 外壳还只有个 <h2>

★ 硬约束:调用 router.refresh() 的组件不能落在边界内。

/admin/users 按上面这条本该改(39 KB),实测退回了:AdminUserActions 在边界内 调 router.refresh(),服务端数据已改(DB 与 RSC 载荷都确认是新的)但界面不更新 —— 3 次里错 2 次。对照 /blog:BlogSort 在边界外调 refresh,更新边界内的列表, 一切正常(有 e2e 覆盖)。所以:refresh 的发起方必须在边界之外。

据此不能改:/admin/users(AdminUserActions)、/admin/appeals(AdminAppealActions)、 /image/admin(ImageAdminTable)、/checkin(CheckinCard)—— 它们的 refresh 发起方 都在列表内部。/image 勉强可以(ImageUploader 留在外壳、只把 gallery 进边界), 但载荷只有 14 KB,不值得。

改任何列表页之前,先 grep -n "router.refresh()" <该页要放进边界的组件> 确认一下。

8. 关键约定

软删除

永不物理删除(站长手动除外):

账号归档:User.archivedAt 为非空时按账号级软删除处理,独立于角色与禁言。 归档、恢复及原始资料仅限后台 CLI(docs/cli.md 的用户归档节),HTTP 管理页也无豁免。 archive-scope.ts 在 Prisma 查询扩展内给用户、自有内容与凭据的读取、计数和修改附加归档过滤; 只有 scripts/cli.ts 的 runAsBackendOps 异步上下文可绕过,不能通过请求参数开启。 扩展不处理嵌套 relation 和 raw SQL:评论/讨论在共同序列化出口抹掉归档作者及原文、附件、引用摘要, 管理端评论显式过滤作者,讨论 raw 未读 SQL 显式排除归档作者。新增这类读口必须沿用这条边界。

user-archive-service.ts 在同一事务内写归档状态、撤销文章/评论点赞并重算冗余计数、 通过 postEntry 清空钱包(archive_clear 流水)、作废未结清仓位(独立终态 archived), 撤销凭据、软隐藏关联通知并记录内部审计。记账内核独立检查 archivedAt:null, 即使在 CLI 上下文里也不能再向归档钱包入账。恢复只解除账号状态,原内容的删除标志保留, 余额、点赞、凭据、隐藏通知和作废仓位不恢复。SSE 每帧(含心跳)查数据库复核权限, 覆盖 CLI 与网站不同进程时无法靠内存 bus 踢人的情形。

字段 默认 模型
Blog.ignore false 博客
BlogComment.isDeleted false 评论
BlogLike.deleted false 点赞记录
CommentLike.deleted false 评论点赞记录(同样软删,不物理删除)
ImageHosting.ignore false 图床
AudioHosting.ignore false 音频床(软删后磁盘文件保留;站长可在管理端硬删,见 §6.15)
Vote.ignore false 投票
ClipBoard.ignore false 剪贴板
ChatMessage.isDeleted false 讨论消息
Favorite.deleted false 收藏夹(配对 deletedAt)
FavoriteItem.deleted false 收藏夹条目(配对 deletedAt)

软删即抹掉附件与原文:评论被软删后,序列化时 content 与 content_html 一律换成 占位文案,image / blog 一律置空(image_missing / blog_missing 也置 false —— 软删 不是「附件丢了」)。漏掉任何一项都等于「删了没删」:原文还在 DOM 里,或删掉的图仍能 点开看原图。讨论消息同此口径(chat-service.ts 的 attach…)。

角色体系与鉴权门

三个词先定死(注释里到处在用,读混了就会把「谁被挡在外面」判错):

词 指谁 有账号吗
游客 未登录 没有 —— 不在 users 表里,服务端拿不到 uid
未认证用户(UI 上叫普通用户) role='user' 有 —— 用户名 / 密码 / 会话 / 可被禁言都齐全,只是没过邀请码认证
认证用户(也叫核心用户) core / admin / owner 有(broadcast-service.ts 的 'authenticated' 档即此)

⚠️ 「未认证用户」不是游客。 两者在门里走的是不同的岔路 —— 未登录 redirect 去登录页,已登录但角色不够 forbidden() 原地 403(见下一段)。所以「未认证用户 用不了界面,却 curl 得动」这类注释,说的是已登录、非 core 的账号绕过了页面门, 不是「匿名可打」。读混的代价是实的:会把「这里该收 core 门」误判成「收登录门」, 于是 role=user 那条路仍然是个洞。

接口语境里的「凭据」是另一回事:账户服务的双层密钥、鱼干市场无状态接口的凭据、 登录限频那几处注释,说的都是这个请求带没带凭据,与角色轴无关。按主体分就不会错 —— 主体是「人 / 账号」走角色轴,主体是「请求 / 接口」走凭据轴。

src/lib/guard.ts 是页面级鉴权门 —— async 函数,不是装饰器,共四个:

  • requireLogin()(已登录)—— 只要求登录,角色够不够交给调用方
  • requireCoreUser()(已登录 + core+)—— 「核心用户」档:管理员与站长自然也过
  • requireAdmin()(已登录 + admin+)—— 段内放宽后,由真正要管理权的页面自己把门
  • requireOwner()(仅站长)—— 全站最高一档,其它角色一律挡在外面

未登录 → redirect('/login?next=<原URL>') 回跳;已登录但角色不够 → forbidden() 原地渲染 403 页。

/admin/* 不是单一档位:段级 layout(admin/layout.tsx)只判 core+ —— 因为段内的 「用户管理」核心用户本来就能进(只读版:标题 「用户列表」,无禁言 / 发通知 / 角色按钮)。段内需要更高权限的页面各自把门 (URL 猜得到,侧栏藏起入口不等于挡住):/admin、/admin/blogs、/admin/logs 用 requireAdmin(); /admin/oauth 用 isOwner();broadcast / categories / appeals 各自的 layout.tsx 用 requireOwner()。新增段内路由请照抄其中一档,别默认继承。

API 路由不用它们(重定向对 XHR 无意义):各 route.ts 自取 getCurrentUser() 后返回 apiErr(403, …),管理端另用 hasAdminRights()(src/lib/auth.ts)之类的判定。

档位阶梯:页面与接口必须同档,但入口不跟着藏。 一个功能有页面 + 若干接口时, 每一层都要自己判,别只挡离用户最近的那层。签到是标准例子(全部 core+):

层 判定
/checkin 页面 isCoreUser(user) → 渲染 403 页(不用 requireCoreUser,理由见该文件头)
GET /api/checkin isCoreUser → apiErr(403, CORE_ONLY)
POST /api/checkin isCoreUser → apiErr(403, CORE_ONLY)

签到还多一道「内容前置条件」(2026-10 起):名下至少一篇未软删的文章 (hasPublishedBlog)。它与档位是两道独立的门,判法却不同 —— 因为 GET 是状态读、 POST 才是发鱼:GET 不拒,回 200 + can_check_in: false(回 403 会让 base.js 把 「拿不到 checked_in」当成可签到、点亮顶栏假徽标);POST 拒,403 发布过文章后才能签到。 页面同理,渲染引导态而不是按钮 —— 引导语建议先发一篇「自我介绍」,那条链落在 /blog?search=自我介绍(看见别人怎么写,比直送空编辑器更有着落),发文入口仍在目录页上。 理由(为何要这道门、为何它是门槛不是墙)见 checkin-service.ts 头部。

⚠️ POST /api/checkin 本身就是发鱼的那一步(2026-09 起签到是一步式,建记录与 发鱼干在同一个事务里,没有第二步)。页面那道 guard 只挡浏览器 —— 只挡页面而不挡这个 POST,匿名 curl 一次就能把鱼干领走。新增「页面 + 多接口」的档位功能时照此逐层对照。

读口也一样,而且漏了不报错。 页面那道 guard 只挡浏览器:/blog 一直有 requireCoreUser(),而同名的 GET /api/blogs 与 GET /api/blogs/[id] 长期免认证 —— 匿名 curl 一次就能拿到全站目录与全文 Markdown。「页面与接口必须同档」对读口同样成立。 全站哪些读口是有意匿名的,以 tests/unit/anonymous-read-guard.test.ts 的台账为准: 新增一条没有守卫的 GET 会让该测试当场变红,逼你在「加档位」与「写进白名单并说明理由」 之间选一个。已有的正例是 GET /api/images/[id]/raw(公开图匿名、私有图按档位)与 GET /api/users/[id](匿名可达但内容按查看者收敛)。

「对外可见的文章」是这条的更远一步(§6.11):/blog/<id> 与 /api/og/blog/<id> 匿名可达,但逐篇判可见性 —— 不在 PUBLIC_READ_ROUTES 白名单里,而是走 getExternallyVisibleBlog 这个具名出口过 GUARD_SYMBOLS 那条判据。它是全站第一个 「游客能读到用户内容」的入口,所以判定收在一个出口、白名单是开区间式的 (EXTERNAL_VISIBILITIES 只列算数的档,加第四档时不会自动放行)。

「档位」与「归属」是两层,别用一个代替另一个。 博客与评论域有多条接口同时要过两关:

接口 档位(你有没有资格用这个区) 归属(这一份是不是你的)
PUT / DELETE /api/blogs/:id core+ 作者本人
DELETE /api/comments/:id core+ 评论作者本人或管理员
GET /api/blogs/:id/likers・feeders core+ 作者本人或管理员
DELETE /api/images/:id · /api/audio/:id core+ 作者本人或管理员(一律软删,站长也走这条)
DELETE /api/images/admin/:id · /api/audio/admin/:id owner ——(硬删只有这一条路径)

只判归属的后果是实的:评论删除漏了档位这一层时,一个被降权(core→user)的账号仍能删掉 自己当年写下的评论 —— 那些评论产自他还是 core 的时候。归属判定管的是「这条归谁」, 它回答不了「你现在还配不配用这个区」。反过来只判档位也不行(core 用户能删别人的评论)。

「入口不跟着藏」是刻意的:拿签到举例,顶栏图标对所有人渲染(与博客、讨论同待遇), 未登录点了跳登录、非 core 点进去原地 403。但 layout.tsx 只给 core+ 发 checkin-api-url 这个 meta —— 否则 base.js 会拿 403 响应点亮一个骗人的「可签到」徽标。 即:门禁收紧,入口照给,但别给「你能用」的假信号。这条在 tests/e2e/access-control.spec.ts 的「入口保留」一组有契约。

⚠️ 它只管「档位」,专注模式是例外(2026-09 改的口径;2026-10 起可逐项配置): 专注模式里被屏蔽的那一项整段不渲染,不留灰链。判据是「他还能不能自己把这一块打开」—— 档位不够是「你现在不配用」,入口照给才不会让人以为功能下线;专注模式是本人一键可关 的账号偏好(开关就在 /settings#focus-mode,可分别屏蔽讨论大区 / 玩具区 / 练手盘, 也可自选博客栏目),留一条点了 403 的灰链只是噪音。 现行落点:练手盘的三处入口(/fish 卡片两组「引导语 + 入口」、/fish/market 页脚, 仅在「屏蔽鱼干练手盘」开启时)、讨论侧栏的大区行(仅在「屏蔽讨论大区」时), 以及首页玩具卡与顶栏玩具入口(仅在「屏蔽玩具区」时)。两个容易踩的: 连 <p> 一起条件渲染(p + p 的行距命中的是「有没有那个 <p>」); 讨论那条只在渲染层过滤 —— 服务端仍把带 disabled: true 的大区行发给客户端, 那是 ChatApp 三处导航逻辑的判据(见 chat-service.listChannelsForUser 的注释)。

ID 风格

实体 ID
User · Blog · BlogContent · Comment · Notification UUID4
ClipBoard 短 ID:base36(小写字母 + 数字,8 位,short-id.ts)
Vote · ImageHosting · AudioHosting 短 ID:base62(9 位 / 10 位 / 10 位)
Favorite UUID4(所有者管理页的路由参数;不可枚举,见 §6.10)+ 可选 publicId:6 位纯数字,仅公开收藏夹有值
FavoriteItem 自增整数(join 行)
Category · AdminActionLog · AdminActionAppeal · UserBan 自增整数

各 ID 的长度互不重叠是有意的:[@…] 引用语法只按长度分流,6 位是收藏夹、 8 位剪贴板、9 位投票、10 位图床。新增任何「会出现在正文引用里的 ID」都要先确认 长度没被占用(博客是 UUID、含连字符,根本进不了那条正则)。

⚠️ 音频床是那条不变式的第一个例外,且是刻意例外:它的 ID 也是 10 位 base62, 与图床同长。它不走长度分流,而是取了具名命名空间 [@音频/<ID>] —— 长度早被占满(6/8/9/10/12 加 UUID),再去挤一个既没空位、也不如名字可读。 代价是 音频 成了保留合集名(与 用户 同构的问题,见 §6.7 与 sticker-refs.ts 的 RESERVED_CARD_COLLECTION)。下一个新增引用语法照这个走: 能起名字就别抢长度。

9. 迁移史速查

阶段 状态
Flask 单体(blog+story+clipboard+vote+fish+...) 已被本分支 git rm 删除,git 历史可回看
Flask → Next 分阶段迁移(每模块独立 commit) 已合并;本分支 commit 全是 Next
鱼干账户微服务拆分(Phase 1/1.5/2) 已撤销(2026-09):账户服务搬回站内,见 §6.3.1
鱼干存储精度 0.1 → 0.0001 条(迁移 21_fish_units_1e4,五列 ×1000) 已应用(2026-09):练手盘 floor 的损耗降 1000 倍,见 §6.13
音频床落库表(迁移 22_audio_hosting,新表一张) 已应用(2026-09):不含数据变换,见 §6.15
schema 演进路径 Alembic 31 版删除;Prisma 0_init 基线接管

10. 风险与已知限制

风险 影响 缓解
SQLite 库级写锁 高并发写会互相 database is locked 长期建议迁 Postgres(届时去 DATABASE_URL 的 connection_limit/socket_timeout)
进程内限频 多实例下各自计数,总限翻倍 多实例前先换 Redis
进程内「在看」状态(chat-presence.ts) 多实例下「谁在看哪个会话」的报到与发消息可能落在不同实例 → 判不出在看 退化成照常发 @ 通知(多打扰一次,不静默丢),与进程内限频同一类已知限制
instance/ 在部署机器 需挂载真实目录否则上传 500 部署脚本里 node scripts/check-instance.mjs 兜底
初次部署既有库 存量回调签名密钥(fish_webhook_endpoints.secret_encrypted)是 SECRET_KEY 封的:跳过 fish webhook-rekey 就轮换 SECRET_KEY → 商户再也收不到通知,且不可逆(密文没坏、只是没了钥匙) npm run diagnose 段 4 逐条报「钥匙对不对 / 还剩几条没搬」;迁移命令 fish webhook-rekey 逐行判状态、可重复跑
账目没有第二个存储可以核对(账户服务搬进站内后,users.driedFish 是唯一真源) 有人改了余额却漏写流水这类静默损坏,没有外部的复式账本会替你发现 记账只走 postEntry 一扇门;不变式「每人余额 == 他所有流水之和」由 tests/helpers/fish-ledger.ts 的 expectLedgerConsistent() 钉着,写路径的用例都调它
某一个 key 的 PNG 缺失(git 里被删了、FRAMES_DIR 指到了别处、或某次部署漏了 public/static/frames/) 那个框全站静默不显示 —— 而「框不显示」与「没发过框」在页面上长得一模一样,页面不报任何错 渲染侧由 frame-service 的第三道闸降级成「干净的不显示」而不是 15 处破图;运维侧 npm run cli -- frame list --keys 是唯一能主动发现的地方(frame grant 成功时也会顺手体检并打黄色警告)。素材 2026-09 起随代码入库(原先要手工拷到服务器,那是个没有报错的部署步骤),所以这条风险现在只剩「git 里少了」与「指错了目录」两种
改了出图脚本却忘了重跑(素材入库新引入的失效:原先素材不在库里,非跑脚本不可,这件事不可能发生) 站点继续显示旧图,不报错、不 500、日志里什么都没有 —— 只有人眼盯着那个框才看得出来 出图脚本把「生成这一刻」写进 public/static/frames/manifest.json(脚本自身 + 每张产物的 sha256),tests/unit/frame-assets.test.ts 逐条核对,报错里给出该跑哪条命令
反代改写了 Host 且未透传 X-Forwarded-Host 浏览器 Origin 与三个来源都对不上 → 全站 POST 403(CSRF 误杀)。nginx 默认就把 Host 设成 $proxy_host(upstream 地址),所以两个头都要显式透传 ALLOWED_ORIGINS="你的域名" 兜底或修 nginx

11. 推荐阅读

  • docs/deploy.md — 部署 / 运行 / nginx / systemd
  • docs/cli.md — 运维 CLI 命令
  • ../CLAUDE.md — 关键约定(约束与反直觉决策)
  • 内容/玩法文档:docs/guide/ —— 玩家与创作者文档(cattca / 云剪贴板 / 图床 / 投票箱 / story)

本页内容来自仓库 docs/architecture.md·在 GitHub 上查看

项目架构 · 聪明山