子系统修改约定
本文收纳从根指引移出的业务边界与实现注意事项,供修改对应模块时查询。
通用工作流和验证要求以 ../CLAUDE.md 为准;不要每次任务都通读本文。
先按功能名、路径或符号搜索,读取相关边界与小节,再按需要查看链接的源码和文档。
业务约束变更时更新对应小节,不把单次故障经过重新追加到根指引。
跨模块边界
以下规则按所改功能适用;涉及权限、净化、账户或推送时查看对应条目。
- 不加
X-Frame-Options/ CSPframe-ancestors(应用层与 nginx 层都不要),也别把它当成 「待补的安全响应头」—— 本站刻意允许被第三方 iframe 嵌入,有一部分用户只能从 iframe 进主站。 - 别为 iframe 跳出去改 session cookie 的
SameSite=None—— 那会把跨站 POST 的会话一并放进来。 - 别把
img加进评论 / 讨论的净化白名单(哪怕名义是「支持图片」)—— 那里的图床图是 绕开白名单插进来的,一放开就把外链图放行(跟踪像素 / 访客 IP 泄露)。 - 永不物理删除(站长手动例外)。新增任何「删除」功能时,默认都是写软删标记,不是
DELETE。 - 讨论未读不进铃铛数字:铃铛数必须等于
/notifications数出来的条目数,讨论未读另出chatUnread走小红点。别「统一未读语义」把它并回count—— 那会让铃铛写 5、点进去只有 2 条。 - 非核心账号没有鱼干赚取渠道(「鱼干 = core+ 体系的报酬」这条口径的地基)。
新增任何发鱼路径前,先确认这一条仍然成立 —— 给全站空投等于「注册就有鱼干」。
练手盘(
/fish/trade)仍然成立:它是签到之外的第二条 core+ 渠道,档位与 签到、投喂同档,没有把口子开到非核心账号上。它的 mint 是账外的(赚了凭空加进 用户余额、亏了少发给他,没有「系统账户」那一行 —— 见docs/architecture.md§6.3), 所以不受「预算有界」约束 —— 有界性来自档位,不来自额度。 - 练手盘的成交价必须在下单那一刻现取(
fetchQuote),绝不读展示缓存。 缓存价 = 看盘的人可以在价格跳动后、缓存刷新前下单,那是无风险、可重复、无上限的套利。 行情源挂了就拒单(503),不降级。展示缓存(15 秒轮询那份)只用于渲染。 K 线同样是展示:GET /api/fish/trade/candles、图上的蜡烛、以及页面自己把 展示价「并进最后一根」的那点算术,一律不得作为成交价 —— 它们只是另一份渲染数据。 (e2e 里有一条把两者刻意拆开再下一单。) 展开见docs/architecture.md§6.13。 - K 线的
openTime是第三把钟:交易所给的真实 UTC 毫秒,与Date.now()同一把尺子, 但不是库内那套「UTC+8 墙上时间贴 Z」——两者绝不能相减。按本站钟面显示走 「加SITE_TZ_OFFSET_MS再getUTC*」。db-time-guard 抓不到这条(它只匹配.getTime()相减),所以只靠注释与单测守。 - 练手盘与玩具区只走 https(闸在
src/middleware.ts一处;协议是传输层属性,不是档位, 别跟着各处的档位判定各抄一遍)。覆盖/fish/trade*+ 四个/api/fish/trade/*,以及/game*+ 四个/api/game/*/launch;路径清单只住src/lib/https-guard.ts(两区共用一张 表,/game/quandi/play那份 iframe 文档也在页面前缀里)。两条最容易踩的:- 回环豁免(
localhost/127.x)不是「宽松」,是本地与全部 e2e 的活路。Next 会自己按 socket 把X-Forwarded-Proto填成http,所以「明文 + localhost」是 本地开发的常态 —— 删了豁免,两区在本机与整个 e2e 里整体 403(/game那一堆 页面连同 e2e 里点开的游戏都会),而这只在真跑起来时才看得见(单测用不到真服务器,全绿)。 - 私网 / 拿不到 Host 时宁可回 403,也不拼跳转地址:308 会被浏览器永久缓存,
跳到上游地址(
127.0.0.1:3000)之后只能清缓存,而服务端一切正常、不报错。 判据与理由的唯一说明处是src/lib/https-guard.ts文件头;展开见docs/architecture.md§6.4。matcher 与 路径清单没有编译期关系(matcher 必须是构建期字面量),tests/unit/https-guard.test.ts拿文件系统双向对账 —— 新增/api/game/<x>/launch而没进表,那个接口就在明文下照发票据。
- 回环豁免(
- 表情包语法的正则里一个
\s*都不能有,字符集必须是白名单 —— 宽一点就是 向任意同名用户凭空发通知(extractMentions跑在原始正文上)。 - OAuth 三条限频不在
RULES里(内联在oauth/*/route.ts)—— 做全站限频审计时最容易漏。 - 档位阶梯:页面与接口必须同档(每层都自己判),但入口不跟着藏。 顶栏「讨论」与首页讨论卡
对所有人渲染是站长明确要的,别当「自相矛盾」顺手藏回去;签到是正例(三处判定位见 §8)。
⚠️ 这条只管「档位」—— 专注模式是例外,它用不了的东西整段不渲染。 判据是
「他还能不能自己把这一块打开」:档位不够是「你现在不配用」(core 点签到撞 403 是就地
403,不是功能下线),所以入口照给;专注模式是本人一键可关的账号偏好(设置页就在
那里),留一条点了 403 的灰链只是噪音,也没有「以为功能下线」的误导风险。
现行落点:练手盘的账户入口(
/fish的练手盘链接, 仅在「屏蔽鱼干练手盘」时)、讨论侧栏的大区行(仅在「屏蔽讨论大区」时),以及首页 玩具卡与顶栏玩具入口(仅在「屏蔽玩具区」时),都由调用方条件渲染 —— 连<p>一起 (p + p的行距命中的是「有没有那个<p>」,留个空壳会凭空多出一段空白)。别改回 「置灰保留」,也别给<Link>挂个类名了事(灰了却点得动比不灰更糟)。 /fish/pay的 DOM 类名被两个 e2e 用例钉死 —— 改样式 / 改类名 / 重做收银台之前先看用例。- 第三方素材不入库(表情包留在
instance/stickers/,git 历史删不干净)—— 只有当授权 明确允许再分发时才可入库(判例:chess/那套棋子因 BSD-3 明文授予才进来过;2026-09 那版棋类玩具区下线时已一并移除,2026-10 玩具区以/game重新上线也没把它搬回来)。 这条只管第三方:我们自己画的素材该入库(头像框就住在public/static/frames/, 见「关键约定」的「头像框」一节)—— 判据是「授权与来源」,不是「是不是图片」。
数据、推送与权限变更
- 库内时间戳取当前值用
nowForDb(),展示用db-time.ts的格式化函数或getUTC*; 不与真实 UTC 的Date.now()相减,也不经toLocale*或本地 getter 再平移时区。 真实 UTC 行情时间、客户端计时器等不应套用数据库时钟;具体范围与例外见tests/unit/db-time-guard.test.ts和src/lib/db-time.ts。 - 含数据变换的迁移(如 ×10 整数化)只允许执行一次,由跟踪表保证,绝不手工重跑。
- 顶栏推送两条纪律:
hasSubscriber必须在任何await之前同步早退; 算值必须放在吞异常的try内(调用点写的是void pushX(id))。写错 → 异常逃逸 →blog-service.toggleLike回滚notificationSent→ 下次点赞重发一条通知。 admin-user-service/user-service不能 importchat-service(成环):拒绝方向推精确值, 放开方向推{refresh:true}。- 兜底快照
GET /api/notifications/count不能删 —— SSE 有「连着但收不到」的半死状态, 只有轮询能纠正,且它在本地开发时完全看不出来。 - HTTP 绝不能在 SQLite 事务内 —— 写锁会被占满整个超时,并发写直接
database is locked。 任何「写库 + 调外部 HTTP」的新功能都照此办理。现行唯一样板是鱼干收款回调的 outbox (fish-webhook-service.ts:投递行与流水同事务写入、投递在事务外)—— 账户微服务当年那条最大的 HTTP 路径已搬进站内,别以为这条红线随之作废: 商户回调仍然是跨进程的,市场行情源(market-price.ts)也仍然是。 - 专注模式 = 总开关 + 逐项屏蔽(2026-10 起可自定义)。判定一律走
src/lib/focus-mode.ts的isFocusLobbyBlocked/isFocusGamesBlocked/isFocusFishTradeBlocked与focus-service.ts的resolveFocusHiddenCategoryIds, 绝不手写user.focusMode && user.focusBlockX(漏总开关或漏子项都静默错)。users.focus_hide_categories的 NULL(未自定义 → 用站长categories.focus_hidden默认集合)与'[]'(明确一个都不屏蔽)不可合并 —— 合并会让存量用户升级后突然看到 全部水区。categories.focus_hidden只是默认集合,不是真相。 设置页在/settings#focus-mode(FocusPanel.tsx);对外接口见docs/bot/account-bot.md§4。 - 权限变更分两类:禁言 / 重置密码 / 强制下线递增
sessionVersion(会话已废)→kickUser+kickTopbarUser成对出现;改角色 / 专注模式不递增 sessionVersion。 专注模式里只有大区可见性变化(总开关或focusBlockLobby)才额外踢讨论 SSE (重连按新值决定收不收大区广播)并推顶栏红点;改玩具 / 练手盘 / 博客栏目不踢流。 顶栏流一律不踢(会话没废,铃铛照常收推送)。 - 改
RULES的数值要同步对外文档 ——docs/bot/十四份都是自包含的(站外读者要能独立 对接),其中每一份都复述了限频 / 配额数值;docs/guide/的图床 / 收藏夹 / 每日一言 / 聊天气泡 / 表情包指南同样复述。清单与口径见docs/README.md「bot/」节,不同步就是下一次 drift。
关键约定
修改某个子系统时,只读对应小节及需要的源码说明;不要求通读本文或链接的整份文档。
数据与时间
src/lib/db-time.ts 头部(「UTC+8 墙上时间贴 Z 标签」的来龙去脉、反推证据、混钟后果)。
密码哈希与历史 werkzeug 双向互通(用户无需重设密码)见 docs/architecture.md §1/§6.1。
认证与角色
公开注册必须提供有效一次性邀请码,直接创建 core 并登录;历史 user 可补码认证。 取消成员资格不撤销账号安全、余额/流水、转出与既有仓位平仓能力,不能新开仓或租商品。
docs/architecture.md §8(角色阶梯、四个 guard、/admin/* 的分档)与 §6.1(会话 cookie、
登出只有 POST /api/auth/logout)。申诉闸门见 src/lib/admin-appeal-service.ts 头部。
CSRF / 中间件
docs/architecture.md §6.4(三源并集、只校验写方法;外加练手盘与玩具区的协议闸 ——
它排在 GET 早退之前,所以别以为「中间件只管写请求」)+ docs/deploy.md §6/§13(nginx 的
Host 与 X-Forwarded-Host 都没透传就会全站 POST 403)。鱼干市场的双门见
src/app/api/fish/market/_auth.ts 与 docs/bot/fish-bot.md。
iframe 嵌入
src/app/components/FrameBuster.tsx 头部(同站/跨站判定、两段跳出与那个 500ms 的含义、
为什么两段都失效时不再找「更狠的跳法」、为什么故意不记忆)。
数据库迁移
docs/deploy.md「修改 schema 后」节 + prisma/migrations/3_fish_integer_units/migration.sql
头部。不用 prisma migrate(schema.prisma 头禁了),走 npm run migrate
(status / up / mark <name> / verify);跟踪表 _raricy_migrations 自维护,
新迁移用 CREATE TABLE IF NOT EXISTS 保持幂等。
up 只执行手写 SQL,不编译或改写正文。ReactiveMD 直接从源码按需编译,复用内存
缓存,不读写数据库快照。旧源码修订升级与公开索引修复由独立命令执行,不能再次绑回
SQL 入口。status / verify 仍只读。
鱼干写路径
docs/architecture.md §6.3 是主副本(§6.3.1 是「这里曾经跨进程」的历史注记 ——
账户微服务已于 2026-09 搬进站内,别再照「远端 / 补偿 / 账本」的旧形状写新代码)。
各文件头分别讲自己那一段:fish-service.ts 的 postEntry(唯一记账内核)、
fish-idempotency.ts(哪些操作才登记幂等的判据)、fish-transfer-service.ts
(用户间转账 / 收银台 / 收款人查询的客户端幂等键与共享单号;与练手盘的
market-service.ts 不同义)、fish-compensate.ts(群发补偿为何要登记)、
fish-units.ts(单位换算与 Blog.fishCount 例外)、service-accounts.ts(配额白名单)。
注册建号两个入口共用 account-service.ts 的 createUserAccount 内核。
记账不变式:每人 users.driedFish == 他所有 fish_transactions.amount 之和。
测试侧由 tests/helpers/fish-ledger.ts 的 expectLedgerConsistent() 钉住 ——
新增任何鱼干写路径时,在它的用例末尾调一次。别自己写 update + create 绕过
postEntry。
鱼干练手盘
docs/architecture.md §6.13 是主副本;各文件头讲自己那一段:src/lib/market-service.ts
(一个事务、没有补偿、开仓的幂等靠 open_key 唯一约束而非独立幂等记录、
平仓为何不需要幂等键、最小投入为何是 1 条)、
src/lib/market-math.ts(结算公式的唯一实现 —— 服务端真结算与页面「预计到手 / 涨跌 /
手续费」是同一个 settleClose,零依赖所以两边都能 import)、
src/lib/market-leverage.ts(杠杆与方向的词表,零依赖 —— 上下界 1–100、快捷档、
警告带阈值、parseLeverage / parseDirection / needsLiquidator。页面要 import 它渲染
选择器,而 market-service 拖着 prisma 进不了客户端包)、
src/lib/market-price.ts(成交价现取 vs 展示缓存这条安全边界、为什么用币安 .vision
域、基址可配的两个理由)、
src/lib/market-candles.ts(K 线词汇表:周期白名单 / 根数上限 / 线上形状 / 缓存键 ——
零依赖,服务端与客户端共用一份)、
src/lib/market-chart.ts(K 线图的纯计算:窗口 / 聚合 / 刻度 / 映射 / 实时并线)、
src/lib/market-liquidator.ts(强平引擎 —— 本站第一个「自己动手动钱」的后台循环、
为什么结算是爆仓价而不是现价、为什么不写流水、为什么不 import market-service)、
src/lib/market-stats.ts(统计的纯聚合:终态白名单 / 拆解表分桶 / 持仓浮动盈亏。
白名单由调用方传进来 —— 一 import MARKET_SYMBOLS 就会把 prisma 拖进单元用例)、
src/lib/market-stats-service.ts(统计的读路径)。
统计页(/fish/trade/stats):不需要新表 / 迁移 / 接口 —— market_positions
一行 = 一个批次、结清时就地改 status 且绝不物理删除,统计全部由既有列推出来。
四条最容易踩的:
- 盈亏的唯一来源是
payout_units − stake_units。别改成从fish_transactions求和(那不是「另一种口径」,是错的):强平不写流水、实发为 0 的正常平仓也不写流水, 迁移前的平仓流水在远端 —— 按账本求和会让盈亏偏乐观、笔数与胜率偏小,却一切正常。 - 判「已结清」用白名单
{closed, liquidated},不是status !== 'open':加第四个 终态时后者会静默把它算进胜率,而那一行payout_units若为 null,会长得跟一次真 爆仓一模一样。 - 拆解表的桶 = 白名单顺序在前、多出来的 key 追加在后:改过
MARKET_SYMBOLS之后 残留的旧仓(market-liquidator.ts头部明写存在)否则会只进总数、不进拆解表。 - 持仓浮动盈亏必须走
settleClose(与卖出弹窗同口径,含平仓手续费),缺价显示 「—」,且只要有一行缺价,合计也显示「—」。
「最近结清」(/fish/trade 下方那块):listSettledPositions 逐笔列 closed /
liquidated 的仓位(白名单 SETTLED_STATUSES),补的是「结清后仓位无声消失、流水页
又没有条目」那个缺口。同样是只读 market_positions、绝不从账本求和;
payout_units 为 null 的异常行照 null 显示(页面「—」),别 ?? 0。
/fish账户卡片的入口只在.fish-card__actions里(转账 / 流水 / 收款码三颗)——tests/e2e/fish-layout.spec.ts钉的是「每颗 ≥44px 高、宽窄屏不溢出」,不是固定颗数; 练手盘入口在下方.fish-service列表,且a[href="/fish/trade"]只出现一次。- 加一个标的要改四处:
MARKET_SYMBOLS(market-price.ts,单一真相源)+prisma/schema.prisma的symbol注释 + 页面文案(fish/page.tsx、fish/market/page.tsx、fish/trade/page.tsx的副标题)+tests/e2e/mock-market-price.ts的prices/changes两张表(漏了它 ticker 直接 400,而症状看着像产品坏了)。行情流、轮询、报价与 自选列表都跟着MARKET_SYMBOLS走,不用动。 - 改
MARKET_FEE_RATE/MIN_STAKE_FISH要同步页面文案(与RULES的纪律同源)。 - K 线的缓存键必须带 interval:
${symbol}:${interval}:${limit}。漏了它,切到 4h 会 原样读回 1h 那一格,不报任何错(改版前正是如此,因为那时周期硬编码在 URL 里)。 周期一律过parseInterval白名单,非法周期返回 400 而不是退回默认档 —— 退回会让「点的是 4h、画出来是 1h」活下来。 - 行情有两个后台循环:轮询(
market-poll-drainer.ts,15 秒一次,本站第二个)与 行情流(market-stream.ts,常驻 WebSocket,第三个;MARKET_STREAM_SILENCE_MS=0可关)。两个都要在tests/setup.ts与 playwright 的 webServer env 里置 0 —— e2e 跑的是next start,那道NODE_ENV === 'test'的保险在那儿盖不住。 行情流即使开着也只喂展示:成交仍然fetchQuote()现取。 - 第三个练手盘循环是强平引擎,它与上两个不是一类东西(
market-liquidator.ts,MARKET_LIQUIDATE_MS,默认 15 秒)。上两个只写展示缓存;它会在用户没按任何按钮时 结清仓位。四条后果,每条都不报错:- e2e 里刻意不置 0,而是置成 1 小时 —— 置 0 = 关掉强平 = 杠杆开仓也一起关掉, leverage 那几条用例就买不进杠杆仓了。置大数让循环起来(闸门打开)但这一跑不 tick。
- 它不 import
market-service(那边 import 它取闸门判据,反向成环会炸在模块求值上)。 要复用的算术全在零依赖的market-math;它算实发时费率传 0 是在问权益、不问到手。 - 结算价写的是那行存着的爆仓价,不是触发时的现价 —— 于是「什么时候发现」不影响 「结算成多少」,引擎晚一轮不会改变任何人拿到的数。
- 它的告警判据是「实发是否为 0」,不是「权益是否为 0」 —— 浮点下爆仓价处的权益 会留 ~1.8e-12 的尘埃(3×/5×/10× 都有),拿权益判会每次强平都响。
- 展示缓存(含 K 线、以及行情流那一份)挂在
globalThis上,别改回模块级变量 —— Next 把instrumentation.ts编进独立的 compilation,market-price.ts于是在 同一份产物里有两份模块实例(轮询器一份、两个页面与四个接口一份)。模块级变量 = 「轮询器刷 自己那份、页面上冻住另一份」,不报任何错。展开见src/lib/market-price.ts头部。 K 线那一格的键是${symbol}:${interval}:${limit}(见上面那条)。 - 杠杆与方向有七条静默错(杠杆 1–100 的整数自由输入、方向
long/short;LEVERAGE_PRESETS只是页面的快捷档,不是合法集):- 手动平仓与强制平仓必须给出同一个数(都是 0)—— 两条路走同一个
max(0, …), 见market-math.ts头部那个「★ 那个 max(0, …) 就是爆仓 ★」。手动平能亏穿 → 账本 撕裂;强平能多留一点给用户 → 理性策略变成「别平,等它爆我」。 - 平仓的重放判据是
status !== 'open',不是status === 'closed'(爆仓也是结清)。 写成后者「结果碰巧还是对的」(条件写会挡住,回读到既有结果),所以不会报错, 只是多打一次行情源、多烧一次限频,而且语义已经错了。 - 杠杆与方向都不进开仓幂等键(
openKey仍只吃 user/symbol/units):同一个键带不同 leverage / direction 重试时回报的是当初那一笔(真实倍数与真实方向,都从库里读)。 塞进键 = 同一笔重试变成第二笔、重复扣款。 - 强平不判禁言(同
sell):禁言是「不能说话」,不该顺带变成「不能止损」。 新增这条写路径时最容易顺手补一个!isMuted。 settleClose的leverage与direction都是必填项,别给默认值:忘了传的调用点 会把 10 倍仓按 1 倍结算、把空头按多头结算(盈亏整体反向),而屏幕上那个数看起来 完全合理。两处都从仓位行读,不从调用方传。- ⚠️ 「1 倍永不爆仓」只对多头成立:1× 空头在 2 × 开仓价归零,是一个可达的价。
于是
liquidationPrice的0哨兵收窄成「1× 多头」,判「要不要强平引擎」必须用needsLiquidator(leverage, direction),不是leverage > 1;强平引擎的扫描筛选也 从leverage > 1改成liquidation_price > 0。闸门与扫描必须同真同假 —— 闸门放行 而扫描捞不到的仓位 = 一份没人清算的免费期权(引擎关着也能卖 1× 空头),全程不报错。 同理页面「要不要显示爆仓价」的判据是liquidationPrice > 0,写成leverage > 1会把 1× 空头那条真实的线藏起来。 - ⚠️ 空头的穿线方向是反的:判据多头
现价 ≤ 爆仓价、空头现价 ≥ 爆仓价; 收益着色要乘方向(changePercent是行情涨跌、不含方向,空头它为正时是在亏)。 写反的话,一个刚开的健康空头会被下一轮立刻强平,而日志只有一行正常强平。
- 手动平仓与强制平仓必须给出同一个数(都是 0)—— 两条路走同一个
软删除
docs/architecture.md §8(七张表的字段清单、软删即抹掉原文与附件);
「已删除且无回复的评论从楼中楼隐藏」在 docs/bot/comment-bot.md §6.1。
顶栏两个指示器(铃铛数字 + 讨论红点)
src/lib/topbar-bus.ts 头部(投递语义、单进程前提、调用方纪律)。
客户端侧在 public/static/js/core/base.js:applyTopbar 的 merge 语义与两档兜底轮询
(流连着 60s、没连上 20s —— 改间隔等于改 SSE 失效时的最坏表现)。已知缺口(由兜底
收敛):softDeleteMessage 减少未读时不推、/notifications 列表页本身不实时。
讨论与通知的关系
src/lib/chat-service.ts 头部(讨论未读口径、红点三道闸门、依赖方向)。
/admin/* 的分档见 docs/architecture.md §8;角色的权限分档(涉及 admin / owner 的
任何方向仅站长)见 src/lib/admin-user-service.ts 的 setRole;@ 提及的逐条闸门见
chat-service.notifyChannelMentions。
@ 通知「读了就清、在看就不发」:读到某个会话(进频道 / 停在里面看新消息)会把该会话的
@ 通知一并标已读;正在看这个会话时那条通知根本不产生(红点与未读徽标照常亮)。判据在
src/lib/chat-presence.ts(客户端报到 + 讨论流连接 + TTL,判不出就照常发)。这不是「讨论未读
混进铃铛」—— 铃铛有数 = 有 @ 且他还没看那个会话。
限频
docs/architecture.md §6.5 + src/lib/rate-limit.ts 的 RULES(多数配额的唯一权威)。
对外复述清单见 docs/README.md 的「bot/」节。单进程语义,多实例需换 Redis。
文件落盘
docs/architecture.md §6.6(六个存储域 + env 覆盖 + 读取入口 + 内容判定那条)+
src/lib/image-sniff.ts 头部(零依赖:magic bytes / SVG 文本头 / HEIC·AVIF·MOV 的
ftyp 品牌 / 声明归一化 / planImageUpload —— 服务端与浏览器共用同一份)+
src/lib/image-upload.ts 头部(sanitizeFilename 的白名单防线、配额常量的来由)。
音频床那一路另有一层坑,见 src/lib/audio-upload.ts 头部:MIME 别名归一化
(.m4a 的声明值三种平台三个样,还可能是空串;归一化只用来补浏览器没给的那格,
权威始终是字节,且落库的必须是认过的规范形),以及为什么刻意不转码。
src/lib/audio-refs.ts 头部是 [@音频/<ID>] 的全部口径。
⚠️ /api/audio/[id]/raw 的 Range 是硬需求不是优化:Safari 会先发
Range: bytes=0-1 探测,拿不到 206 直接不播;而仓库里那两处流是无界 SSE、
刻意没有 Content-Length,形状正好相反,不可复用。见 §6.15。
Markdown / 内容渲染
docs/architecture.md §6.7(四条管线的分工、五道防线)+ docs/guide/内容引用语法指南.md
([@8位/9位/10位] 与「博客一条管道、评论讨论另一条」的差别)。故事是服务端渲染
(story-service.ts,按可信输入处理)——见 docs/guide/story-module.md。
ReactiveMD 渲染器(2026-10):主副本是 docs/architecture.md §6.23,文件头在
src/lib/reactive/(read-render.ts 决策、diagnostics.ts 回退、ref-adapter.ts
引用、compile.ts 缓存)。三条不能破:reactive 源码永不交给旧 Markdown 解析器
(认不出或编译失败 → unavailable;可读正文由本次请求编译);<site-embed> 标签与
${kind}:${ref} 的 hostData 原子成对(缺数据只保留字面量,绝不发半套);external
模式零业务请求(剪贴板、投票与收藏夹均保留字面量)。阅读路径的渲染选择只住
reactive/read-render.ts;reactive 是唯一正文渲染路径(Markdown 与 Reactive 同引擎,
编译失败/认不出 → unavailable),旧 Markdown 回退管线已整条退役(REACTIVE_MD_MODE
不再被读取);别在页面里另判模式或恢复第二套渲染器。
表情包
src/lib/sticker-refs.ts 头部(正则纪律与分隔符选择)+ sticker-service.ts 头部
(扫盘缓存三层)+ docs/architecture.md §6.6(raw 路由按字节复核、拒绝 SVG)。
其机制(sendWith(token, { keepDraft: true }),以及为什么正文必须从参数来而不是读 state)
在 src/app/chat/ChatApp.tsx 的 sendWith 上方。
两个来源共用一条管道,行为口径见 docs/guide/表情包使用指南.md:
- 站长放的图片表情(
instance/stickers/)—— 讨论点一下直接发送; - 内置黄脸(
emoji-faces.ts的编译期清单 →public/static/emoji/,scripts/copy-emoji-assets.mjs从 npm 包拷)—— 和文字一样大(叠一个rich-emoji-ref类压到 1.2em,见_markdown-body.scss);整条正文只有它一张时 是例外,叠rich-emoji-solo退回表情包那一档(4em,判据在sticker-refs.ts的markSoloEmojiFaces)。且点一下插进输入框,讨论区也不例外。评论端两类都插。
三条容易踩的:
- 黄脸 img 必须保留
rich-sticker-ref类(只叠不加换)——降级链是按那个类名过滤的 (RichContentBody.tsx),换掉它缺图时会显示裂图而不是原文 token。 markSoloEmojiFaces必须跑在所有embed*之后(rich-text.ts的render()末行) —— 它判的是「最终 DOM 里是不是只有这一张」,往后加新embed*时插到它下面, 新元素就漏出判断:表现只是尺寸偶尔不对,不报错、不写日志。 「一样大」本身只在_markdown-body.scss定义一处(靠:not(.rich-emoji-solo)让落回 表情包那条规则),所以别把 4em 抄进黄脸那一档 —— 抄一遍就会静默漂成两档。- 别照拷 npm 包里那份 LICENSE:
@twemoji/svg只写了打包者自己的 MIT, 素材实际是 CC BY 4.0(出处jdecked/twemoji的LICENSE-GRAPHICS)。复制脚本 自己生成正确的LICENSE.txt,署名落在源码注释里(Twemoji 官方接受这种形式)。 - 这里的 SVG 不违反那条「拒绝 SVG」的闸门:那条防的是
instance/stickers/这个 无上游校验的运行时目录;黄脸是构建期从锁版本 npm 包生成、且只以<img src>引用。 复制脚本里有安全哨兵(脚本 / 事件属性 / 外链 → 构建失败)盯着,理由写在它文件头。
用户名片
[@用户/<用户名>] —— 讨论区与评论区里的一枚行内名片(带头像框的头像 + 用户名,
点进 /u/<id>)。src/lib/user-refs.ts 头部是全部口径,docs/architecture.md §6.7
是管线位置。四条最容易踩的:
用户是保留合集名:名片 token 与[@合集/表情]形状同构,所以表情那条正则带 一条(?!用户/)让开它,sticker-service.ts的扫盘也跳过同名目录。两端必须一起 改 —— 只改一端会得到「面板里挑得出、一渲染却变成别人的名片」。- 带名片 token 的正文不进渲染缓存(
rich-text.ts的USER_REF_PROBE判断)。 缓存以正文为键而名片数据会变,走缓存会把「数据到了之后」那份吃掉,症状是 名片永远不出现,不报错。 - 头像必须手搭 DOM 且与
<Avatar>同构(avatar盒子 +avatar__img+avatar__frame)—— 正文管线是字符串进字符串出,塞不进组件。少了avatar__frame这一处永远没有头像框;头像落点台账为此开出第二档(avatar-sites-guard.test.ts的AVATAR_DOM_SITES,判据是「有没有那个 img」而不是「有没有用组件」)。 - 它不算 @ 提及:不发通知、红点不动。token 里一个空格都不能有(同表情那条纪律
——
extractMentions跑在原始正文上,有空白就能凭空给同名人发通知)。
取数口是 GET /api/users/<id 或用户名>:按 id 匿名可达,按名字要 core+
(名字可枚举,id 不可)。句柄是 id 还是名字由实际命中了哪一列决定,
别改成拿 UUID 正则去认 —— id 不保证是 UUID。
音频床
docs/architecture.md §6.15 是主副本;各文件头讲自己那一段:audio-upload.ts
(嗅探与别名归一化)、audio-service.ts(独立配额聚合)、audio-refs.ts
([@音频/<ID>],零 import —— 要被 chat-shared 拉进客户端包)。
音频 是保留合集名,与 用户 完全同构的问题 —— 表情那条正则的形状也是
[@A/B]。两端必须一起改:sticker-refs.ts 的 RESERVED_CARD_COLLECTIONS
让开它,sticker-service.ts 的扫盘跳过同名目录。只改一端 = 「面板里挑得出、
一渲染却变成播放器」。加第三个保留名时照这个走。
两条管线的接入方式不同,别互相照抄:评论 / 讨论在净化后建 DOM(那边白名单里
没有 audio,与没有 img 同理);博客在源文上拼标签串(那边白名单本来就允许 audio),
但必须配 maskMarkdownCode —— 源文阶段没有 DOM、跳过不了 CODE/PRE,
漏了就会在代码块里嵌出一个真播放器。这是博客侧最可能的静默错误。
展开发开上限是 3,不是图片的 50:单文件 10MB × 配额 50MB ⇒ 一个 core 用户最多 5 个满额文件;图片每个引用是一次小文件读,音频是 MB 级传输。上限仍是正文长度的 确定性函数,所以不破坏渲染缓存。
画报与鱼干收款码
docs/architecture.md §6.8(管线与四条约束)+ src/lib/poster.ts 头部(二维码必须矢量、
纠错 H、静默区 ≥4 模块 —— tests/unit/poster.test.ts 会真解码)。收银台与收款页共用
src/app/fish/PayForm.tsx 的 variant 分支,别另抄一个。
主页画报的统计与公开主页同源(R3-546):项目固定为统计块的「文章 / 获赞 / 签到」,
数字来自 profile-service.ts 的 getPublicProfileStats(文章只计匿名访客可公开列举的
public / link,获赞只累加这些文章,签到为累计天数)。签到格只在主页已公开展示签到时
才画(公开布局里有 stats / checkin 块,见 isProfileCheckinPublic);默认布局不展示
签到,画报就只有文章 / 获赞两格。画报别再自己写一套 count。
前端样式
产品文案只写必要信息:不要主动添加抒情、装饰性或凑气氛的副标题与“小话”, 例如“一些思考,一点日常。慢慢写,也慢慢读。”。功能名称、操作提示与状态说明照常保留。
docs/frontend-styles.md 是唯一权威(含按钮三档、输入框圆角判据、容器阶梯、
胶囊滑块的适用范围)。改样式前按组件或场景读相关小节。容易踩的:
- 按钮只有三档(
abstracts/_mixins.scss的btn-primary/btn-secondary/btn-tab),别另抄一份;任何一档都不做垂直位移。 - 输入框圆角当且仅当「一行文字」时是胶囊,多行走 20px。聚焦一律用
--shadow-focus-brand,绝不用border(会让字段随焦点变高、下方内容位移)。 - 写
var(--x)前确认它存在。变量不存在时不报错,整条声明静默失效 —— 已经踩过--color-brand-primary-rgb、--color-bg-primary、--r-pill、 拼错的--color-background-card-unrend等六处。 - 正文里的通用
.类 img会漏进每一个内联元素(.chat-msg__md img/.comment-content__md img,见components/_markdown-body.scss)。新增内联引用 (表情 / 黄脸 / 名片)时逐条清零margin/border/border-radius/background/cursor—— 漏一条就静默坏一处:2026-09 名片的框贴图带上不透明底色, 连头像一起盖住。
文章对外可见性
docs/architecture.md §6.11 / §6.12 + src/lib/blog-service.ts 头部(六条不变量)。
三档 internal / link / public,只对非 core 的查看者生效 —— core+ 在博客域
是全读的,所以这一列对任何站内入口都是零行为变化,管理员也不需要豁免(那是结构性
豁免,服务层里没有也不该有 if (isAdmin))。
- 第一档叫
internal(站内),不叫private—— 改过名(迁移 17)。private在本仓库 已经是「只有本人」的意思(剪贴板的publicity、收藏夹public_id恒 NULL),而这一档 所有 core+ 成员都能看。撞名的代价正好落在「什么会对外可见」上,所以改掉了。 ⚠️ 旧拼写仍被守卫拦着,而且理由更硬:改名后没有一行再是'private',于是visibility !== 'private'对每一行都成立 —— 从「加第四档会漏」升级成「全部当成对外可见」。 - 判「对外可见」永远用白名单(
EXTERNAL_VISIBILITIES/EXTERNAL_VISIBLE_BLOG_WHERE), 绝不写{ not: 'internal' }或visibility !== 'internal'—— 加第四档时那两种写法会 静默把新档一起放出去,而放出去不可逆(有静态守卫盯:tests/unit/blog-visibility-guard.test.ts)。 - 不带查看者的读口必须走具名出口(
getExternallyVisibleBlog/listIndexableBlogs/listPublicBlogs),别各自手写 where —— 名字就是静态台账认得它的凭证。 - 「对外可读」与「可列举 / 可索引」是两件事:
link读得到,但不进 sitemap、不许索引。 - 对外视图按资源类型展开引用(
contentRefs='external'):图床与音频维持 raw 路由的公开规则;所有剪贴板、投票、收藏夹均保留字面量。剪贴板仅站内共享, external 不读取正文、标题或版本,也不随 RSC 下发。引用数据按当前请求的权限准备。 - sitemap 与
/explore必须列同一个集合(都用INDEXABLE_BLOG_WHERE)。这不是洁癖: 多一层过滤就会出现「搜索引擎收录了一篇,读者在公开列表上翻不到」,而那条差异 不会有任何报错。同理exclude_from_all与专注模式的栏目屏蔽不作用于对外列表 —— 它们是站内陈列规则与账号级偏好(focus_hide_categories为用户自选,未自定义时用站长 标记的focus_hidden作默认集合;有静态守卫盯:tests/unit/explore-visibility-guard.test.ts)。 - 改档位必须记账(
blog_visibility_logs,迁移 19):updateBlog/setBlogVisibility都在同一个事务里写日志。分开写会得到「档位改了但没账」,而那只能靠人工比对发现。updateBlog的actorId是必传的 —— 新增加可见性写路径时别忘了。(public不可逆, 这是它唯一的账。) - ⚠️ 别往
/api/spider/*加可见性过滤:它是 core+ 网关下的只读命名空间,机器人能调 是因为机器人手里就是一个 core+ 账号 —— 它没有绕过任何东西,internal本来就该被 core+ 读到。 而且/api/spider/favorites/:id同时是我们自家前端的数据源([@六位]卡片)。 - 不给
listBlogs加可见性过滤(它是站内列表,调用方都是 core+)。对外列表 已经另起了入口(listPublicBlogs)—— 已有一条钉现状的用例,谁加了过滤会当场红。 - 对外正文仅经公开索引检索(
public-blog-search.ts):不往匿名查询添加原始正文 LIKE。FTS 候选、计数、分页与片段都受当前 public 白名单约束。原文/格式/可见性/软删除 改动先由 SQL 触发器撤旧索引,服务写入后重建;历史数据的索引回填与修复使用npm run search:rebuild, 不把正文编译或索引回填绑回 SQL 迁移入口。 索引不完整时明确降级为元数据查询,绝不索引引用资源、隐藏数据或失败的 Reactive 源码。 - 词汇(三档的名字 / 白名单 / 解析 / 文案表)住在
src/lib/blog-visibility.ts, 那是个零依赖模块:发文表单与文章卡片都是客户端组件,而blog-service拖着 prisma 进不了客户端包。文案表都是Record<BlogVisibility, string>,加第四档时 tsc 会因缺键报错。 - 访客的「博客」入口按档位分流(core+ →
/blog,其余 →/explore)。这不违反 本文「跨模块边界」中的「入口不跟着藏」—— 那条反对的是「因档位不够就把入口藏掉」;这里入口照旧对所有人 渲染。别把它「统一」回/blog,那等于让访客点进一张登录页。 - JSON-LD 一律过
src/lib/json-ld.ts的jsonLdScript(),别手写dangerouslySetInnerHTML—— 本站没有 CSP,一个含</script>的文章标题就是对外可索引页面上的一发 XSS。 机器读的时间戳一律过db-time.ts的isoWithOffset(),不是裸toISOString()。 - 分享卡片走 sharp 复用
poster.ts,别改用next/og的 Satori(它不读系统字体栈, 中文要自带字体二进制 = 第二条字体管线)。改版式几何前先跑tests/unit/og-card.test.ts。
匿名评论(只做评论区,讨论区不做)
docs/architecture.md §6.16 是主副本;src/lib/anon-identity.ts 头部是名字表与发号
(27×26=702,第 703 个是 You Win #703 —— 前缀表 = 空串 + A–Z,末位 Zealous Zach
正好是字面的 ZZ;别删掉 Z 那一项,删了总数掉到 676、边界提前且不再字面成立)。
四条「破了就破诺」的纪律(每条都是静默的 —— 页面照常渲染,只是匿名不再是匿名):
- 真身份只许从
serializeCommentBase一处抹(站内树与 spider 对外接口的共同出口):author.id → null、username → 化名、is_admin → false、frame_url → null、 头像按化名哈希(anon~<md5>;种子里的~落在resolveAvatar的文件名字符集之外, 所以那张图必然是现算的 identicon,站长放个同名 png 也盖不掉)。 - 通知的
actorId必须传 null(匿名时),身份改由detail里的化名交代 —— 传了真身,收件人在/notifications看到真名 +/u/<id>链接,评论树那边白抹。 AdminActionLog.hide_target:删/恢复匿名评论照旧公示、照旧可申诉,但listPublicLogs/getLogDetail抹掉target_user(/audit是 core+ 都能看的)。 运维 CLI 不看这个标志 —— 「管理员能从日志里查到原作者」靠targetUserId仍是真身。 ⚠️ 它连申诉人一起管:createAppeal只许当事人本人申诉,所以隐藏日志的申诉人 就是那个匿名作者 ——getLogDetail必须把appellant一并抹掉。只抹target而 放着申诉人,等于匿名作者一申诉就把自己交出去(真名会印在 core+ 都能开的/audit/<id>上)。- 匿名评论不进个人主页(
getPublicProfile的recentComments与评论计数 —— 主页墙本身已没有评论列表,但/api/users/:id仍走这个读口):那个读口 core+ 都能用, 列在那里一比对就对上号了。新增主页积木时同理:统计块不许加评论数。
另外三条:
- 归属一律用服务端算的
CommentNode.is_mine,别拿currentUserId === author.id—— 匿名评论的author.id是 null,那样比会让作者删不掉自己的评论(按钮不出现,不报错)。 - 判据冻在
blog_comments.anon_seq上,权威是blog_anon_identities那一行。 别改成读时现算(删评论或作者注销都会让化名无声改名)。 - 开关
Blog.allowAnonymousComments的闸门在createComment(前端只是不渲染勾选项);PUT /api/blogs/:id对缺键是「这一列不动」—— 否则旧客户端一次普通保存就会把 作者关掉的匿名悄悄打开。同理ValidatedBlogData.allowAnonymousComments可选 ≠ 必填。
收藏夹
docs/architecture.md §6.10 + src/lib/favorite-service.ts 头部(六条不变量,
尤其是「private 的 public_id 恒为 NULL —— 没有句柄而不是藏起来」、
「判对外可见永远用 isPublic 而非 publicId != null」、「不继承站长的越权读」)。
[@六位] 的纯逻辑在 src/lib/favorite-refs.ts 头部(为什么卡片是成品 HTML 而不是
占位 + data-*,为什么替换按区间切片)。对外见 docs/bot/favorite-bot.md(含限频数值,
改 RULES 要同步)与 docs/guide/收藏夹使用指南.md。
篇数口径(R3-239):列表 / 详情 / 条目增删 API 的 itemCount 都是「当前查看者能
打开的文章数」—— 未软删的收藏条目 且 blog.ignore=false,与 loadItems 同判据;
服务层两个函数分工明确(countVisibleItems 给展示、countItems 只数记录、继续用于
FAVORITE_ITEMS_MAX 容量判定)。容量语义不变:博客软删只从展示里消失,收藏记录不腾空、
仍占上限。
改收藏夹按钮的样式前先在受影响页面与视口做实页检查(favorite-layout.spec.ts 已按
2026-11-04 前端样式测试清理计划移除几何断言,只保留操作与无障碍行为;该文件仍登记在
playwright.config.ts 的 RESPONSIVE_SPECS 里,desktop 与 mobile 都要过):
星标未收藏时不许亮(颜色只由 .favorite-btn.favorited 的 color 给,别再给
.icon-star-fill 写死 background-color);所有视口保持返回圆钮 / 互动胶囊 / 管理胶囊
同一行、外壳等高 48px。互动胶囊内按钮高 40px,只留图标与同行数字,无文字标签;
管理向右展开,窄屏只滚动操作区,不撑宽整页。选择器里名称独占一行、两颗创建按钮并排。
尺寸与理由见 docs/frontend-styles.md §7.2。
头像框
docs/architecture.md §6.14 是主副本;src/lib/frame-service.ts 头部是五条不变量
(F1–F5),src/lib/frame-refs.ts 头部是词汇与到期判定。玩家向见
docs/guide/头像框使用指南.md,画师向(站外受托画素材的人)见
docs/guide/头像框出图规范.md —— 那份是自包含的,可以整份转发,改几何 / 尺寸口径时
两处要一起改;发放命令见 docs/cli.md。
三句话:
- 框的定义住代码白名单(
frame-refs.ts的FRAME_KEYS),素材住public/static/frames/<key>.png—— 随代码入库(它是我们自己画的,源码就是scripts/make-frame-demos.mjs;不是instance/那种运行时数据)。 改了那个脚本就必须重跑并把素材一起提交,否则站点继续显示旧图、不报错 ——tests/unit/frame-assets.test.ts与脚本写的manifest.json就是为这件事设的。 加一款框不需要迁移 ——user_frames.frame_key是文本不是外键。退役一款框把它标retired,别从FRAME_KEYS里删 —— 删了用户就摘不掉它了。 users上那两个装备列的唯一写入者是frame-service(F1)。改持有行的expires_at时必须同一事务刷新装备列的副本(F2)—— 违反它,用户续期后 框永远不出现,看起来像浏览器缓存。别的文件要动这两列时往frame-service里加一个…Tx(tx, …)内核(商城走的正是grantFrameTx),别在外面自己写。- 到期只有一处判(
frameUrlFor),渲染层拿现成的frameUrl字符串、 一次都不比较时间。渲染层直接读equipped_frame_key会被tests/unit/frame-guard.test.ts静态判红;手写/api/avatar/模板串会被tests/unit/avatar-sites-guard.test.ts判红(全站头像只有<Avatar>一条路)。
另外三条容易踩的:
- 头像一律走
src/app/components/Avatar.tsx,落点台账在tests/unit/avatar-sites-guard.test.ts—— 新增渲染头像的页面要往那张表里添一行, 否则那一处既不在台账里、也不违反「唯一 URL 口径」,于是静默地永远没有框。 - 鱼干商城(
/fish/shop)的租框路径:续期必须从「当前到期」起算 (frame-shop-service.rentFrame)。grantFrameTx的口径是「只延长不缩短」, 传now + N 天的话,一个还剩 20 天的人买 3 天会走进 noop —— 鱼干照扣、 到期一动没动、不报任何错。价格、单位与在架清单只住frame-refs.ts的FrameDef.rent(单位只有天 / 月两种,上下限与量词在RENT_UNITS; 展示与校验读同一个数),别在服务层另写一份价格,也别让调用方传单位 —— 单位由框自己定,传就成了「传了月、框是按天的」那种静默错配。 另:那条接口只认会话,别顺手给它开requireMarketActor的凭据门 —— 一开,租金就成了对外契约。 frame list --keys是运维唯一能发现「登记了框但忘了传素材」的地方 —— 那时全站静默不显示框,页面没有任何报错。它同时报租金(—= 不零售)。
格式转换器(/tool/convert)
展开见 docs/architecture.md §6.17;文件写路径说明在 src/lib/file-converter/ 各文件头。
地基:文件字节绝不出站。 八个能力区(图片 / 音频 / 视频 / 文档 PDF / 表格 / 文本字幕 /
电子书 / 压缩包)全在浏览器本地转换 —— 没有服务端路由、没有库表、没有限频、没有审计。
新增任何「上传到服务端再转」的路径前先停下来:那会一次性推翻这条,并且是不可逆的
(用户按「不上传」在用它)。服务端方向(LibreOffice / Calibre)在登记表里只写
status:'planned'。
- 重引擎一律在 runner 体内
await import(...)(ffmpeg / pdfjs / xlsx / mammoth / docx / pptxgenjs / tesseract / utif / heic2any / node-unrar-js / 7z-wasm / iconv-lite / fflate…) —— 静态 import 会把几十 MB 卷进每个页面的主包。 - 契约层
types.ts零 import、零运行时代码 —— 它要被 node 单测直接 import。 共享的运行时常量住formats.ts;词汇(格式表 / 限额)也只有那一份, 页面提示与校验读同一个数。 LIMITS是硬上限,改它要同步页面文案(与RULES的纪律同源)。planned的边不许有run,live的不许缺run——registryProblems()静态拦。 菜单里看不到 = 那件事现在做不到,别用「给个能点的入口、点了报错」代替。notices[]是必填的诚实说明(有损重编码 / 透明压平 / 元数据不保留 / 动画丢失 / 公式只求值 / 文字层被栅格化…)。写不出「这次丢了什么」= 这条边没写完(roadmap §15)。- 引擎资产不入库(
public/static/converter/,约 65MB)——postinstall与build经scripts/copy-converter-assets.mjs生成;少了它音视频 / PDF / OCR 报「引擎加载失败」。 - 串输出必须展开再回读:抽帧类边传的是模式(
frame-%04d.png)而 exec 按字面名回读, 不展开就永远拿到空 Map —— 症状是「退出码 0、结果区空着」,不报错。ffmpeg.ts的expandSeqPattern干这件事,单测钉住宽度是硬判据。 - 取消 / 超时要杀掉整个 ffmpeg 核心(0.12 没有软中断),下次要重新加载 —— 别把它当便宜操作。
- e2e 输出一律按字节魔数断言(不是「有下载按钮就算过」):转换出一条空文件 / 错格式, 按钮照样能点。样本自举(内嵌 1×1 PNG + 现算 WAV),不往仓库放二进制样本。