# InkStack-java **Repository Path**: du-jiangjiang/ink-stack-java ## Basic Information - **Project Name**: InkStack-java - **Description**: No description available - **Primary Language**: Unknown - **License**: MIT - **Default Branch**: main - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-09-21 - **Last Updated**: 2026-10-09 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # 墨栈 InkStack · Java 换栈版(双轨已收口:Node 侧不再有后端) > 博主有 AI 分身、读者能和文章对话、创作能变现、平台自己会运营。 > 本仓库是 [Next.js 单体版 inkstack](https://gitee.com/du-jiangjiang/inkstack) 的**后端换栈工作副本**: > 后端逐模块迁到 **Spring Boot 全家桶**,前端 Next.js 原样保留。 > **P7f-2 之后双轨已经收口**:58 个 Node 路由与那份遗留读 SQL 已删除,`/api/*` 恒由 Java 应答, > 渲染层进程内没有一句 MySQL。上面那句"两套后端并行运行、逐接口对拍"描述的是**过程**, > 那个过程已经走完;对岸的实现、以及以它为宾语的两道闸门(1 对拍、2 会话互通)都退役了, > 接班会写明在"质量闸门"一节。 | | | |---|---| | Gitee | | | GitHub | | **为什么是"双轨"而不是一次重写**:原项目已有线上站点在跑,换栈必须做到每个模块都能与旧实现 逐字段比对、并且改一个环境变量就能回滚。所以这里的形态是——Java 先把某一类接口实现到与 Node 完全等价,对拍 diff 归零后再切流量,切完再动下一类。任一时刻站点都是可用的。 **双轨期结束,这套做法留下的东西是 21 道闸门与 `contract/` 里冻住的旧实现形状**; (第 21 道不是换栈期的差分闸门:它不比形状、不比字节,它**复算数**——见"计数器守恒"那一节。) 回滚现在是一段 `git revert` 的距离,不再是一个环境变量(理由写在"演示模式与回滚"一节)。 --- ## 📌 迁移进度 | 阶段 | 内容 | 状态 | 验收 | |---|---|---|---| | P0 | `server/` 工程立骨:Boot 3.5.16 + MyBatis-Plus + MySQL 连通 | ✅ | `mvn compile` 通过,`/actuator/health` UP | | P1 | 会话互通:HMAC Cookie + scrypt + sessions 表 + 登录/登出/me | ✅ | **双向 Cookie 互通 8/8** | | P2 | 双轨对拍闸门 + middleware 按模块切流 | ✅ | 切流/回滚实测通过 | | P3 | 只读内容模块(列表 / 详情 / 搜索 / 热榜 / 归档…) | ✅ | 读侧 27 个函数全切;**11 页 × 4 身份 44/44 逐字一致**,检索防泄漏探针 4/4 | | P1b | 认证剩余端点(注册 / 邮箱验证码 / 重置 / 2FA / 改密 / 设备管理 / OAuth) | ✅ | 跨栈认证流程 **25/25**(一侧签发、另一侧消费),切流代理 10/10 | | P4 | 墨水经济(充值 / 打赏 / 解锁 / 打包 / 加热 / 签到 / 徽章) | ✅ | 资金闸门 **100/100**:六路跨栈并发双击零 500、`Δ余额 = ΔΣ流水` 精确成立、幂等分支跨栈一致;切流代理含资金路径 **16/16** | | P5a | 社区互动写侧(评论 / 点赞 / 收藏 / 关注 / 举报 / 站内信) | ✅ | 社区闸门 **108/108**(连跑三轮全绿):并发举报只落 1 行、`like_count = 关系行数` 守恒、奖励日上限拒了不吞额度;切流代理含社区路径 **24/24** | | P5b | 创作台写侧(发布 / 存草稿 / 草稿转正 / 更新重审 / 撤回 / 硬删) | ✅ | 创作台闸门 **51/51**:8 路并发同名标题零 500 且 slug 互不相同、审核归属只认 Cookie 里的 role、草稿硬删连子表一并清 | | P5c | 运营台(内容审核 / 评论删除 / 举报处理 / 用户管理)+ 两个全文出口 `{slug}/raw`、`{slug}/export` | ✅ | 运营台闸门 **65/65**:四条接口逐个 401/403、下架连带清置顶、扣回按真实余额记账、导出正文两侧逐字一致且不泄漏后端端口;切流代理含运营台路径 **31/31** | | P5d | 书房写侧(草稿箱 / 阅读足迹 / 外链审核 / 资料 / 改密 / 图片上传 / 专栏增删改) | ✅ | 书房闸门 **97/97**:门禁姿势逐个钉住(友链游客是 403 不是 401、足迹游客是 200 skipped)、昵称按 JS 空白判据折叠、上传两栈共用同一磁盘目录、12 路跨栈并发重设篇目零 500 且条目守恒;切流代理含书房路径 **41/41**(含 multipart 穿过 rewrite) | | P5e | 博主迁移工具 `POST /api/import`(RSS 抓取 + Markdown 批量导入) | ✅ | 迁移闸门 **100/100**:SSRF 私网黑名单的十几种写法(十进制 / 十六进制 / 八进制 / 短写 / 全角句号 / userinfo 掩护 / IPv4 映射)两侧同码同文案、重定向逐跳复校、消毒七条正则的产物逐字比库、RFC 1123 各家日期落进同一个本地墙钟;切流代理含迁移工具 **48/48** | | P6a | AI 写作助手 `POST /api/ai/write` + 分身状态 `GET /api/agent/status` | ✅ | AI 闸门 **41/41**:四段兜底模板逐字节相同、"上游真产出才扣墨"两侧同式(探针拦住 → 上游 0 次、上游坏态 → 零扣墨且落同一份模板)、数字/数组/对象入参转发给上游的字符串两边同式、六路并发 40→10 只有两路成功、`流水条数 == 扣费次数`;切流代理含 AI 路径 **54/54** | | P6b | 分身问答 `POST /api/agent/ask`(NDJSON 流式,三条通道) | ✅ | 三条通道逐个钉:透传**分块到达**(代理不攒包)、SSE 只放行 content 帧、演示档游客可问且零扣墨;上游 503 → error 帧同文案零扣墨、吐两帧再掐线 → 明说"本次已按成功计费";DeepSeek 收到的请求体两栈逐字一致(system prompt 里就是 RAG 挑出的那几段)。AI 闸门 **63/63**、切流代理 **56/56** | | P6c | Spring AI 智能体顶掉 Python 的 `agent-service/`(已删除) | ✅ | 引擎闸门 **20/20**:ReAct 那一圈真的转(模型先拿工具清单、第二轮带工具结果继续)、检索语料带付费墙与审核闸门(Python 版两条都缺,逐条对照钉死)、切块宽度与 cite 抽取照原实现、上游 502/空白一律落演示档零扣墨、`AGENT_MODEL_*` 决定徽标档位 | | P7a | 解除 `/api/series` 的跨栈语义冲突,整前缀切流 | ✅ | 书房闸门 **117/117**(新增 17 项:三条 URL 逐个逐字节对拍,含 `limit`/`author` 的 11 种取值与 6 种 id 形态)、切流代理 **56/56**(原来那条"必须仍在 Node"的反向断言翻成正向:前缀下每个 URL 每个方法都要带 `x-backend`);闸门 8 现算出的最长可切前缀已经是 **`/api`**(Node 58 个 URL 模式全部被 Java 覆盖、方法集合零差异、语义冲突登记表已空) | | P7b | 建库收进 Java 进程(`SchemaBootstrap`),Node 侧懒建表/加列从此有了对等物 | ✅ | 建库闸门 **15/15**(P7c 之后这道涨到 18 项):对着三个空临时库真起 `spring-boot:run`,建出来的表/列/类型/可空性/默认值/索引/外键与「手工应用 `db/schema.sql`」逐条一致,并覆盖运行库每一张表;0 用户 0 文章(建库不越界造演示数据);同库再起一次表数列数一条不变(幂等);`INKSTACK_SCHEMA_AUTO=false` 时一张表都不建、接口确实应不上 | | P7c | 删掉 Node 侧全部懒建表/加列,DDL 归属收进一处 | ✅ | web 层(`lib` / `app` / `components` / `middleware`)现在**一条 DDL 都没有**,9 个 `ensure*` 与 41 处调用点清零;这条归属由闸门 16 §0 静态钉住(连注释里藏着建表 SQL 都算红)。行为回归:认证 25/25、书房 117/117、社区 108/108、资金 100/100 | | P7d | 页面取数默认走 Java(`DATA_VIA_JAVA` 留空即跟着 `JAVA_BASE` 启用) | ✅ | 20 页 × 4 身份 **80/80 逐字一致**(游客 20/20 + test/writer/probe 各 20/20),付费墙 4/4、检索防泄漏 4/4、跨栈互通 8/8、切流代理 56/56、接口对拍全绿;`x-data-source` 头报出真实取数路径,闸门开跑先钉"两侧确实是两条路",否则直接停(这一道的开关与这个头都在 P7e′ 退役) | | P7e | 失败计数搬进共享存储:登录 / 改密 / 2FA / 发码共用 MySQL 里的 `rate_hits` | ✅ | 限流闸门 **18/18**(新的一道):五连失败按 node·java 交替累计且每次剩余次数精确、Java 记满后 Node 侧 429、两栈同一个 `Retry-After`、把行推出窗口才解锁(推 3 分钟仍 429,反证过)、成功登录跨栈清零、`send-code` 的 10 次上限同样共用而这一段一封邮件都没发、`api:*` 桶绝不在账本里(边缘闸故意留在进程内存)。Java 侧另有 5 条单测钉住"先记再判 / 库挂了放行而不是锁死"。回归:书房 **117/117**(其中那条"已知口子:两栈各记各的"翻成了正向证明)、建库 **18/18** | | P7e′ | 页面取数只剩 Java:27 个读函数改成一句 remote 调用,Node 读 SQL 整体搬进 `lib/data-legacy.ts` | ✅ | 搬移用 AST 逐字证明:26 个函数与 `HEAD` 版本**一字不差**(只少了分流语句与分流注释),手写的那一个(`listWeekly` 一半是 JS 组装)也只差 `listHot → rankHot` 一处替换。闸门 4 重做成**页面渲染不变量 + 来源证明**(34/34),并反证过:`JAVA_BASE=""` 的实例第一项就红——页面会安静地渲染演示数据,200、有卡片、有正文。回归:对拍 7/7、互通 8/8、付费墙 4/4、检索防泄漏 4/4、路由盘点无缺口、社区 108/108、书房 117/117、限流 18/18、`tsc` 零错。`DATA_VIA_JAVA` / `lib/data-mode.ts` / `x-data-source` 同时退役 | | P7f-1 | `/api` 整前缀切流**演练**(还没删路由):把"只跑 Java 也能自证"这件事先补上 | ✅ | 一台 `JAVA_ROUTES=/api` 的实例上:切流代理 **60/60**(`--keep-render` 档:五个页面/出口仍由 Next 渲染、`/apiXYZ` 没被顺走)+ AI 面 21/22(1 项如实 SKIP)、创作台 **51/51**、运营台 **65/65**、书房 **117/117**、页面 **34/34**、互通 **8/8**、限流 **18/18**。
资金 **94/100** 与社区 **103/108** 那 11 项不是回归:它们断的是"并发真的来自两个进程",NODE 侧切成 Java 之后这个宾语没了,于是从**假红**改成 **SKIP**(并发照打、只一路成交/败者分支/零 500/关系行数守恒照验),并反证过——同一对判据打真 Node 侧时 100/100、108/108 零 SKIP。对拍/付费墙/检索防泄漏三道则按设计**拒绝起跑**(两侧同一个执行者时"全绿"不说明任何事)。附带把边缘闸的档位做成环境变量 `EDGE_API_LIMIT`(默认仍 120,另一台实例实测照旧 120 后 429),闸门一次上百发不再把自己刷成 429 冒充两栈不一致 | | P7f-1b | 契约基线:把"旧实现应答长什么样"冻结进 `contract/`,闸门 1 从此有不依赖对岸的接班人 | ✅ | 30 条基线(Node 冻结 → Java 重放)**30/30**;Node 对自己的基线也 30/30(基线自洽)。反证三条:① 拿作者身份重放游客的付费墙基线 → 红在 `viewerUnlocked`/`md#lines`/`md#chars` 三条钉住的值上;② 手改基线(删一个键、把一个 `string` 改成 `number`)→ 精确报出 `comments.[*].id 现在多出这个键` 与 `comments.[*].nickname 类型 基线=number 现在=string`,复原即绿;③ 付费墙探针打一个不存在的 slug → 四个身份全部"取不到文章!!"、退出码 1(**改版前它打印"截断防线=不适用"然后报"全部通过"**)。同场把闸门 3/3′ 从"对岸不是 Node 就拒绝起跑"改成"绝对判据照跑、差分项记 —" | | P7f-1c | 页面直读 SQL 清单(闸门 18):把"web 退化为纯渲染层"从一句话变成一个数 | ✅ | 调用图算出来 **14 条仍在 Next 进程内直连 MySQL**(运营台 `adminList*`×7 + `adminInsights`、首页 `platformStats`/`topAuthors`/`listFollowingFeed`、个人中心 `listAchievements`/`badgeRewardClaimed`、书房 `suggestSeriesTitles`),并据此**订正了 P7e′ 那句话**——"页面只剩一条取数路"只对正文 27 个读函数成立,那 14 条从来没有 HTTP 面(`/api/admin/*` 只有 POST),所以对拍 / 契约基线 / 路由盘点三道全绿也看不见它。棘轮双向都反证过:往 `listArticles` 注一句 `getPool()` → 报出 5 条且 `listHot`/`listRelated` 标「经由 listArticles」、`listWeekly` 标「经由 listHot」(证明跑的是可达性闭包,不是逐函数看首行);往登记表塞一个没有 SQL 的名字 → 报"已经没有本地 SQL"。正则版曾把 `listWeekly` 误判成直连(下一个函数的文档注释里写着"先 INSERT 占位、再 FOR UPDATE 扣款"),所以这一道改走 AST。**P7f-2 的开工条件由 0 变成 14**(这个 14 后来被证明是同一个病:判据只盯 `lib/data.ts`,见 P7f-1e′) | | P7f-1d | 页面直读 SQL 迁走第一批(6 条)+ 给没有 HTTP 面的读立裁判(闸门 19) | ✅ | 首页 `platformStats`/`topAuthors`/`listFollowingFeed`、个人中心 `listAchievements`/`badgeRewardClaimed`、书房 `suggestSeriesTitles` 全部改成"问 Java 的一句",Java 侧新建 `GET /api/platform/stats`、`/api/platform/top-authors`、`/api/me/following-feed`、`/api/me/achievements`、`/api/me/badge-reward`、`/api/me/series-title-suggestions`;`lib/data.ts` 从此**一个成就算法都没有**(阈值表收进 `BadgeService.TIERS`,展示与领取判定共用一处定义,不会出现"墙上写 5 篇解锁、实际 3 篇发钱")。
**这六条没有对岸可对**:它们从来没有 HTTP 面,所以契约基线(闸门 1′)也冻不出来——于是新建闸门 19 用**回库独立复算**当裁判,**40/40**(1 项如实 SKIP:只有一个账号有可显示的关注流,比不出"换人换答案")。反证两轮:① 整道打 Node :3200 → 37 红、退出码 1;② 只改闸门自己的口径(把"著作等身"门槛 10→5、千分位 `en-US`→`de-DE`、后缀表 `手记`↔`札记` 对调)→ 精确红在成就墙 3 条与题名建议 2 条,其余 35 项不动。第一轮还揪出闸门一处**空对空假绿**(端点返回 `[]` 时"逐行相等"与 `slice(0,0)` 互相盖章),已补"宾语必须非空"
棘轮从 14 降到 **8**(全是运营台,Java 侧那批 GET 读端点是 P7f-1e 的活)。回归:页面 **34/34**、契约 **30/30**、资金 **100/100**、社区 **108/108**、书房 **117/117**、创作台 **51/51**、运营台 **65/65**、互通 8/8、付费墙 4/4、检索防泄漏 4/4、路由盘点无缺口、Java 单测 5 类全绿、`tsc` 零错。顺带记下一处**差分判据失去宾语**:资金闸门那条"两栈还差几枚逐字一致"从今往后比的是分支与文案,不再是两套判定——Node 那条路由的计数已经借道 Java | | P7f-1e | 运营台八张表迁到 Java:闸门 18 清零,"web 退化为纯渲染层"第一次可以被机器复验 | ✅ | 新建 `GET /api/admin/{articles,review,users,reports,actions,orders,comments,insights}`(**与那四条 POST 同一个 `staffOnly` 门禁**,读侧漏的是邮箱和未上架稿,门禁必须在方法第一件事)。`lib/data.ts` 里被页面取走的 45 个函数**没有一个再直连 MySQL**(棘轮 8→0,登记表清空后这道闸门改守反向:谁再加一句 `getPool()` 就红)。
顺带**修掉一个被 `catch` 掩了很久的真 bug**:`adminListOrders` 那句三段 UNION 在**两个活库上都报 `ER_CANT_AGGREGATE_NCOLLATIONS`**(`topup_orders` 是 `0900_ai_ci`、`users/articles/series` 是 `unicode_ci`),因为它整段包在 `catch { return []; }` 里,运营台的"资金流水"面板**一直是空的**而页面看着正常。修法是在 UNION 的字面量与 `IFNULL(p.name, o.pack_key)` 上显式 `COLLATE utf8mb4_unicode_ci`——只钉 `pack_key` 不够,换一种会话排序规则就撞在 `p.name` 上;显式钉住后两种会话(mysql2 的 unicode_ci 与 Connector/J 跟服务器默认走的 0900)实测都能跑。
闸门 19 从 40 项扩到 **156 项**:新增"并列感知的列表比对"(`ORDER BY 时间 DESC LIMIT n` 里 MySQL 不保证并列行先后,于是窗口内有并列时不要求同序、边界并列时不要求同集合,其余逐行钉死)、运营台读的门禁判据(游客/伪造会话 401、非运营 403 且文案不变,比对失败时 email 不外显),并给原本空集的两条读**造夹具**(待审稿 + 三条举报:目标已删那一支、匿名举报那一支、一条 `dismissed` 用来证明 `status=open` 真在过滤),判完在 `finally` 里删干净并复核残留 0。
两处"判据没有宾语"是反证抓出来的:① 把闸门里 `COALESCE(..., '游客')` 改成 `'匿名'` 一开始**不红**,因为库里每条举报都实名——补了匿名夹具才让那条支路可测;② 整道打 Node 时"作者榜逐行相等"曾绿在 `[]` 与 `slice(0,0)` 互相盖章上。注入三处口径错误(`pinned` 恒真、`游客`→`匿名`、分账 0.9→0.5)后精确红三条。
回归:页面 34/34(`JAVA_ROUTES` 空实例与 `/api` 整切实例各一次)、契约 30/30、资金 100/100、社区 108/108、书房 117/117、创作台 51/51、运营台 65/65、互通 8/8、限流 18/18、切流代理 21/21(含"代理导出不泄漏 `:3101`")、闸门 19 直连 Java 156/156 且**改打切流实例同样 156/156**、Java 单测全绿、`tsc` 零错。**当时结论是"P7f-2 的开工条件已成立"——这句话由下一行订正** | | P7f-1e′ | 判据拓宽:闸门 18 从"只看 `lib/data.ts` 的导出符号"改成"渲染层可达集合里任何直接执行 SQL 的函数" | ✅ | 清零第二天就又被推翻,而且这次红得更彻底:登记表说 0,实际有 **8 条**。原因是**判据的边界当初就是照方便画的**——第一版从 `page.tsx` 出发只收集"`from '@/lib/data'` 进来的那些符号",于是两类东西天生看不见:① 页面文件**自己**写的 `pool.query`(`app/admin`、`app/me`、`app/points`、`app/security` 四个页面各有一处到三处);② 经 `lib/auth.ts`、`lib/points.ts`、`lib/link-policy.ts`、`components/*` 这些**别的模块**摸到的库。
现在按一般化模型重算:全仓 `app`/`lib`/`components` 的顶层函数都成为节点(`文件#函数`),跨文件边靠导入表(含**默认导出的 `default` 别名**——页面组件清一色默认导出,只记本名的话最后一跳会断),**JSX 标签视为一次调用**(`` 不是 `CallExpression`),`export default` 的三种写法各自钉住;`lib/db.ts` 作为管道排除、`app/api/**` 与 `lib/data-legacy.ts` 作为"待删的遗留 HTTP 面"排除。测得 渲染入口 24 / 可达节点 204 / **直连 MySQL 8 条**,其中 `getCurrentUser` 被 **14 个**渲染入口共同走到(旧版把归属报成"首个发现者",已改成迭代到不动点)。
反证四条:① 往一个新页面注入页面内 `pool.query` → 报为登记表之外的新面孔、退出码 1;② 同一页只写 ``、SQL 藏在组件里 → 也被抓到;③ **关掉 JSX 那条规则再跑**,第 ② 条消失而第 ① 条还在(证明这条规则有独有战果,不是顺手加的);④ 登记表塞一个已无 SQL 的名字 → 报"已经没有本地 SQL"、退出码 1。
登记的 8 条里有一条根本**不是读**:`app/points/page.tsx` 在渲染时调 `grantDailyQuota`,一句 `UPDATE users` + 一条流水。"纯渲染层"这句话从此按 8 条重算,**P7f-2 的开工条件改回未成立**(迁完这 8 条为 P7f-1f) | | P7f-1f-a | 四个页面就地 SQL 迁到 Java:`GET /api/admin/overview`、`/api/me/profile`、`/api/me/points`、`/api/security/overview` | ✅ | 这八条里最隐蔽的四条——SQL 直接写在 `page.tsx` 里,连 `lib/data.ts` 都不经过。搬走之后 `app/{admin,me,points,security}/page.tsx` 不再 import `getPool`(棘轮 **8→4**,登记表里每条都写着"该给 Java 补哪个端点")。三处口径是刻意合并的:**连签只剩一条定义**(`CheckinService.streak()`,与 `GET /api/checkin`、签到发墨同源;Node 的页面那句 `>= CURDATE() - INTERVAL 60 DAY` 是实现上的截断而非规则,连签超过 60 天时页面会与签到组件自相矛盾);**印文留空回退昵称首字**这条默认值收进 Java,与 `PATCH /api/me/profile` 写库那条同式(分叉的表现是"保存后预览对,刷新就变");`/api/security/overview` 只查页面要的四列,`totp_enabled` 单独一条窄读而不是复用 `byIdForSecurity`(那一行带 `password_hash` 与 `totp_secret`,用不到的秘密不该搬进内存)。
闸门 19 从 **156 扩到 226 项**:新增这四条读的回库复算(九项计数按**九句各自查**而不是抄实现那句九子查询、`'%m-%d %H:%i'` 的格式化在 JS 里重做一遍、留痕按 `id` 逐条、流水按 `id DESC` 全序逐元素),运营台总览同样过 401/401/403 三道门禁。反证注入六处口径错误(`pending` 数错表、问答时间格式、注册日格式、流水改 `id ASC`、2FA 恒真、"今日已发"写成"往日已发")→ **精确红 16 项**,复原即 226/226。
两处"证人自己站不住"是反证抓出来的:① 第一版夹具把 `avatar_text` 置 NULL,被数据库直接拒掉——`users` 表这四列全 `NOT NULL`,回退支路真正可达的是**空串**那一支;改置空串后"印文回退昵称首字"第一次被证明走到了,同时如实记下 `COALESCE(...)` 与 `"—"` 那两条默认值**结构上不可达**(只有整行不存在才触发,本闸门不造)。② 新加的渲染判据第一版写成"四个计数里在 DOM 找到至少一个",注入兜底假数 `128/342/…` 后**照样绿**(库里 `users` 恰好是 8,页面上别处也有 8);改成按 DOM 顺序逐格比八格后,同一条注入立刻红。
另有一处**红要归因**:运营台闸门那条"驳回通知作者的文案与链接精确"连续红两次,不是冷编译——Node 的 `app/api/admin/articles` 里 `notify()` 不 await,接口 200 时站内信还在飞行,判据读一次就判负等于把闸门的调度竞态当成被测方的缺陷。改成 `waitRow()` 等到上限再判(Java 侧同步发信,白等一轮),65/65 稳定绿。
闸门 4 从 34 项扩到 **50 项**(新增 §5b:四个页面的读数必须落到 DOM 上——八格逐个、注册日、余额位在 `p-balance` 里、留痕按 `audit-tag` 数、且整页不得出现 `Invalid Date`);`skipped()` 从此记第三态而不是混进 PASS。回归:页面 50/50(`:3200` 与 `/api` 整切实例各一次)、闸门 19 直连 Java 226/226、运营台 65/65、创作台 51/51、资金 100/100、契约 30/30、互通 8/8、切流代理 21/21、路由盘点无缺口(Java 独有涨到 35 条)、Java 单测全绿、`tsc` 零错 | | P7f-1f-b | 渲染层最后 4 条交还 Java:闸门 18 清零,而且**这一次是复验过的** | ✅ | `getCurrentUser` → `remoteCurrentUser()`(走 `GET /api/auth/me`,外面包一层 `cache()` 合流同一渲染里报头与页面那两次调用);`listSessions` → `GET /api/security/sessions`(Java 那条早就在应答同一份数据,只是把 Node 的 `pool.query` 换成问它);`allowedDomains` → 新建 `GET /api/links/allowed-domains`(与运营台的 `GET /api/links` 是两件事:那条是审核队列、非运营 403,这条只回答"这个域名能不能直接点开"、游客渲染也要用所以必须公开,且只回 `domain` 一列);`grantDailyQuota` **整条摘掉**——`/api/auth/me` 里本来就在发同一份额度,页面不该再发第二次。落点:13 个 `page.tsx` + `components/Masthead.tsx` 换调用点,棘轮 **4→0**。
两处**故意与 Node 不同**要写明:① `allowedDomains` 失败口径原来是 `catch` 之后静默用默认清单(表现是"链接又灰了,没人说是取数挂了"),现在抛出、页面 500,`DEFAULT_ALLOW` 仍留在 Next 侧(那是展示层的默认审美不是库里的数据);② `lib/auth.ts` 那组服务器端会话校验**保留不迁**,因为它只服务 `app/api/**` 那 44 条遗留路由——路由的身份判定也借道 Java 的话,回滚就变成"Java 挂了谁也进不来"。这 44 条路由在 P7f-2 一起删,这一组跟着退场。
**口径合并的收获**:每日 30 滴从今往后整站只有 `PointsService.grantDailyQuota` 一处发(经 `/api/auth/me`),"访问任意页自动入仓"那句文案第一次字面成立(原来只有 `/points` 会发)。
闸门 19 新增 §⑩(**226→284 项**):会话五个字段逐个复算 + "换一个人问就换一个人的 id"、设备列表按 `last_seen_at` 复算且"本机有且只有一台"由闸门自己算 `sha256(令牌)` 判定、放行清单用**三行夹具**(approved 大写 / pending / rejected)当宾语——这份库的 `approved` 恰好是 0 条,没有夹具的话"过滤掉了未放行域名"又是一条空对空。§⑩.4 是这一族里唯一的**写**判据:把当天置成未发,一次 `GET /api/auth/me` 必须 +30 且落一条流水,同日再问必须一分不加,然后按余额/发放日/流水 id 三项精确回滚。闸门 4 新增 §5c(**50→64 项**):书房页印的昵称与墨仓数必须是**这个人**的(三人成对互不串、游客谁的都看不到),以及"只渲染一次首页,今日 30 滴就已入仓"——打端点会发不等于打页面会发,页面里那句调用删掉之后这一格只有渲染层能钉。
反证 6 个变异、各红各的宾语:Java 侧 ① 去掉 `status='approved'` → 红在清单那三条;② `current` 恒真 → 红在设备列表六条;③ `findActive` 不再看 `revoked`/时效 → 红在"签有效但库里已吊销仍是游客"那一条;④ `/api/auth/me` 不发额度 → 红在 §⑩.4 五条**和**闸门 4 的 5c.2 三条。Next 侧 ⑤ `remoteCurrentUser` 把失败吞成 `null` → 闸门 4 **30 项红**("全站安静地变游客"这个失败模式第一次有机器看见);⑥ 把 `/study` 改回 `getCurrentUser()` → 闸门 18 立刻报"登记表之外出现 1 条"。
⚠ 顺带抓到一处**该写下来的限制**:做变异 ⑥ 时那台早已编译过 `/study` 的 dev 实例照样渲染出正确答案(Next dev 只在页面模块自身变化时重编译,而 `lib/auth.ts` 自己就是一条能用的取数路)。所以闸门 18 守的是**源码形状**,不是运行时形状——它的宾语是仓库,不是某台进程。
回归:闸门 19 直连 Java 284/284(12 项如实 SKIP)、页面 64/64、棘轮 0、契约 30/30、互通 8/8、资金 100/100、社区 108/108、创作台 51/51、运营台 65/65、书房 117/117、限流 18/18、路由盘点无缺口、对拍打 `/api/auth/me`+`/api/security/sessions`+`/api/links` 零不一致、Java 单测 20 项全绿、`tsc` 零错 | | P7f-2 | 删掉 58 个 `app/api/**/route.ts` 与 `lib/data-legacy.ts`,`/api` 整前缀落地:Node 侧**不再应答任何 HTTP** | ✅ | **删了什么**:57 个被跟踪的 `app/api/**/route.ts` + 1 个从没被跟踪过的(`.gitignore` 里不锚根的 `uploads/` 把 `app/api/uploads/route.ts` 吞了,`git rm` 不提它、`git status` 也不提它,删完按盘上复验才发现——见下面那条教训);`lib/` 里 13 个模块整体退场(`data-legacy` / `db` / `audit` / `importer` / `mailer` / `notify` / `points` / `pwned` / `rag` / `rate-limit` / `topup` / `totp` / `verify-code`),`lib/auth.ts` 缩成三个纯类型与一个 `isStaff`(会话校验的判定整个交还 Java),`lib/link-policy.ts` 只剩判定不再写库;`scripts/parity.mjs` 与 `scripts/interop-check.mjs` 随之删除。**合计 72 个文件、6243 行**。`middleware.ts` 里 `JAVA_ROUTES` 的解析整段去掉:`/api/` 恒转发,没配 `JAVA_BASE` 就 503 + Retry-After(不放行去撞一个说"没有这个接口"的 404)。
**判据跟着逐条改判**(一道没有宾语的判据留着只会伪装成"还在守"):闸门 1 与 2 退役,接班人在 README 里点名(1′ + 19 §⑩ + 4 §5c + 6);1′ 的 `freeze` 改成**拒绝写入**并新增第 31 项"30 条基线全部冻自 node"(出处立成判据,因为删掉 Node 之后再冻结,同一份文件的意思会从"符合旧实现"悄悄变成"符合它自己上次");8 整个改版(三条判据:107 处调用点全覆盖 / `app/api` 登记表守反向 / middleware 不许再读名单,**三条各自注射反证过**——假 URL 与假方法各红一次、建一个 `app/api/ping/route.ts` 红"新面孔"、往 middleware 补一句读开关红第三条);17 的"两个入口"从两套实现改成经代理与直连,前置那一条**翻了面**(从前要求 Node 端口不带 `x-backend`,现在要求两个入口都带),§⑤ 反而更硬(`lib/rate-limit.ts` 存在本身就是红);6 的 `--keep` / `--keep-render` 直接**拒绝起跑**(退出码 2,静默忽略会让下一个人以为自己按老姿势跑过了这一道);5 与 13、14 的判据文案全部改名,5 与 14 开跑先自报每个入口由谁应答——**同一份代码在两种环境下守的不是同一件事,label 必须如实**。
**新立闸门 20(演示模式)**:这一档从来没有实例在跑,所以从来没有闸门看过它一眼,而 P7f-1f-b 那个回归(`remoteCurrentUser` 少一句 `javaReady()` 守卫 → 没配后端的实例每个页面 500)正好藏在里面。它自己起一台 `JAVA_BASE=""` 的实例,先证"这台真的没后端",再断言 23 个页面渲染得出来、渲染的是 `demo-data`、运营台自报"这是演示数据"。反证:删掉那句守卫 → **29 项红 22 项**。
**两处红都归了因,没有一处被叫成偶发**:① 闸门 11 那条"经代理导出"红在**第三台实例连不上**(它要 `PROXY_BASE=:3400` 那台演练实例——宾语已经不存在,于是恒红),改判成"经代理那一轮与直连那一轮归一后逐字相等,且不泄漏后端地址",65/65;② 闸门 13 那条"20 条上限"红在**抹平"导入时刻"的是一份手抄标题清单**,`/many` 那 25 条没抄进去——手抄的清单会漏,而漏掉的那一条看起来像被测方的缺陷。改成按导入窗口判定(`NULL` 刻意不算"此刻",否则给兜底写空开后门),并把 `firstDiff` 补进 detail(红了要说得出差在第几行哪个字段)。反证:把窗口收成 0 → 立刻红在 `published_at`。
回归(克隆库 `inkstack_j`):`tsc` 零错、闸门 18 读 **0**、8 **4/4**、1′ **31/31**、3 付费墙与 3′ 检索防泄漏全绿(差分项如实记 —)、4 **64/64**(1 项无宾语 SKIP)、19 **282/282**(14 项 SKIP:窗口内有并列或该账号无数据)、5 **25/25**、6 **68/68 零 SKIP**(含逐帧 NDJSON 与 multipart 穿 rewrite)、7 **94 + 6 恒 SKIP**、9 **103 + 5 恒 SKIP**、10 **51/51**、11 **65/65**、12 **117/117**、13 **100/100**、14 **63/63**、15 **20/20**、16 **18/18**、20 **29/29**、`mvn test` 全绿、Java 端点 **87 个 URL 模式**。
**这一行不许说过头话的地方**:跨实现互认那一半**不再是可证的事**(口令哈希、TOTP 口径、两套 JSON 序列化风格——只剩一份实现,没有对岸可对)。剩下的机器证据是形状(1′)、回库复算(19)、DOM(4)与保真度(6/13/14/17),它们盖住的是"别改坏",不是"当初抄得对" | | P7 | 收尾:web 退化为纯渲染层,删除 Node 侧 SQL 与 Node 侧 HTTP 面 | ✅ | 两半都齐了:渲染层进程内 MySQL **0 条**(闸门 18,且这个 0 被 6 个变异反证过),Node 侧 `/api` 实现**整体不存在**(58 个路由 + 13 个 lib 模块已删,`app/api/` 目录不在盘上,闸门 8 §2 从此守反向)。**换栈的活到这里全部结束**。剩下两条与栈无关的功能缺陷各自单独立了项:`agent_qa` 流水缺 `asker_id`(问答成就恒不推进)已在 a0fcfd3 补掉,`articles.agent_qa_count` 恒冻结由下一行收 | | P8a | 分身问答带上文章身份:`agent_qa.article_id` 与 `articles.agent_qa_count` 从此是活的 | ✅ | **改了什么**:`POST /api/agent/ask` 的 body 多一个**可选**键 `article`(文章 slug,按 `articles.slug` 的 160 字收口);`components/AgentChat.tsx` 从 `usePathname()` 认 `/article/`,只在文章页带这个键,全局浮窗在文章页里也算站在文章前;Java 侧新建 `AgentQaRecorder`,按 `slug + status='published'` 认领文章,**在同一个事务里**写 `agent_qa.article_id` 与 `agent_qa_count + 1`——两笔写要么一起成要么一起回滚,调用方吞掉的是整笔不是半笔(三条通道各自记一笔,所以这个入口只有一处)。
**契约一个字没动**:`article` 不进发给上游的 payload、不改应答形状,闸门 1′ 那份冻结基线原样成立。
闸门 14 从 63 项扩到 **77/77**,新增的 14 项各有独立宾语:两个入口归因同式、上游请求体里没有 `article` 键、`agent_qa_count == COUNT(*)`、草稿与假 slug 与数字/对象型 `article` 一律记 NULL 而回答照旧(不是 500,也不是一句"文章不存在")、`article` 缺席时不许变成 400、六路并发计数一路不丢、402 与演示通道零流水零计数,以及一条**源码判据**(`AgentChat` 里那句取 slug 的匹配没了,上面所有判据都还在而线上计数已经死了)。160 字边界是唯一只能这样验的一条:建一篇 slug 恰好 160 字的夹具,再传 250 字(前 160 字就是它)——不裁认不到、多裁一位也认不到。
反证三组,各红各的:① 摘掉 `bumpQaCount` → 红 3 条(`+1`、并发、160 边界),② mapper 不再看 `status` → 精确红在"指向草稿的提问"那一条(`orphan=0`、草稿被认领走),③ **把 `bumpQaCount` 指向一张不存在的表** → 红 3 条且 detail 变成 `行数=0`:流水随失败的计数一起回滚,而读者的回答照旧 200 拿到全文——"吞的是整笔"这一句第一次有了机器证据。
**两条如实的限制**:历史流水的 `article_id` 全是 NULL,**没有可回填的过去**(种子里那几个数字是展示用的虚构值,不该由修 bug 顺带替它拍板);页面上那个数字**要重新加载才变**,前端不做乐观 +1。回归:闸门 14 77/77、15 20/20、8 4/4、1′ 31/31、4 65/65、`mvn test` 全绿、`tsc` 零错 | | P8b | 计数器守恒:`read_count` 第一次有人写,并新立闸门 21 把四列摘要值钉进行集复算 | ✅ | **修了四处,其中一处是闸门 21 自己抓出来的**:
① `articles.read_count` 在**两套实现里都从来没有人写**——只有 `db/schema.sql` 的种子给过 12840/4200/3800。而它是热榜排序的一项、是「总阅读破 100 / 破 1000」两枚徽章的宾语(`BadgeService` 按这一列算,所以那两枚徽章此前**不可完成**)、也是书房付费墙漏斗的**分母**(分子 `paywall_views` 是活的,真实流量一来就会算出 >100% 的转化率)。现在 `POST /api/history` 在记 `read_history` 的**同一个事务**里给它 +1,新建 `ArticleReadRecorder` 承担那对写。口径由 dujiang 拍定:**登录读者每打开一次算一次,游客不计**(那条接口本来就对游客回 `skipped`),所以 `read_count == SUM(read_history.read_times)` 是恒等式而不是巧合。
② 评论 INSERT 与 `comment_count + 1` 从前是两句裸调用(与 P8a 修掉的 `agent_qa` 同形),收进同一个 `tx.execute`;并给那句 `UPDATE` 补上 `status='published'`——它的适用条件必须与 `INSERT..SELECT` **逐字相同**,否则"草稿涨了数、库里没有行"没人拦。
③ 运营台有两条删评论的路:直接删(连带一级回复、扣回真实行数)与**举报处置删**(只删被举报那一行、**一行都没扣**)。后者补上扣回,扣数按实际删除的行数——两条路边不同,判据也就分开钉。
④ 闸门 21 的红名单抓到 `GREATEST(0, comment_count - 1)` **不是兜底**:这些列是 `INT UNSIGNED`,MySQL 先按无符号算减法,下溢当场抛 `BIGINT UNSIGNED value is out of range` → 整条删评论 500 并回滚。改成 `GREATEST(0, CAST(col AS SIGNED) - x)`(评论与点赞两处)。
**新立闸门 21(`scripts/counter-check.mjs`,49 项)**:自建一篇四列全 0 的夹具文章,用真实接口把四条写路径各驱动若干次,每步断言 `stored == 行集复算`;两条删除路径各自守恒;扣回不许穿底;另有源码棘轮四判据(每列必须有 `+1`、每条 `DELETE FROM comments` 的调用点必须配扣回、无符号列的减法必须先 CAST、事务形状不许退回裸调用);末尾是全库逐列复算 + **双向**豁免登记表(登记表外的新不一致红,登记过却已经归零的也红)。
**反证四组,各红各的宾语**:不记足迹 → 6 红(§2 四条守恒 + §7 的 recorder 形状 + §8 点名新面孔);举报不扣回 → 3 红(§6⑤⑥ + §7 直接点名 `AdminService.java:142`);把评论那两笔写拆回裸调用 → **恰好 1 红**,而且只有源码形状判据红(成功时刻两种写法给出一模一样的数——这条判据测的是失败时刻,写在闸门头里);CAST 撤掉 → §6⑧ 红在 500 与那条 MySQL 原文。
**回归里冒出来的第三条红也归了因**:闸门 11 那两条"运营加减净额为 0"红在 `净流水 -30`——判据把"探针进这一节时余额为 0"当成了前提(注释里写着 `+60 −60`,代码实际是 `+60 −(bal+60)`,净额恒等于 `-bal`)。改成由闸门自己把水位钉成 0 再算,前提不再外包给环境历史。65/65。
**如实的限制三条**:种子与历史夹具写进的那 53 处不一致**保留**并逐条点名豁免(不写成"凡是种子稿一律不查",那样判据就空了);游客的阅读不计;`comment_count` 从前那条 `UPDATE` 不带 `status`,改完之后草稿文章不再涨数——这是刻意的行为变更,不是对齐旧实现。
回归(克隆库 `inkstack_j`):21 **49/49 零 SKIP**、9 **103 + 5 SKIP**、10 **51/51**、11 **65/65**、12 **117/117**、19 **284 + 12 SKIP**、4 **65/65**、1′ **31/31**、8 **4/4**、18 棘轮 **0**、7 **94 + 6 SKIP**、`mvn test` 全绿、`tsc` 零错。本轮未重跑 14/15(AI 侧代码没动)、6/13/16/17/20(切流、导入、schema、限流、演示档与本轮改动无关) | | P8c | 四列的"判"与"修"收进一处定义,并补上上线前要跑的那次摘要列对齐 | ✅ | **Java 与页面代码这一轮 0 改动**,动的是 `scripts/`。
**收定义**:新建 `scripts/counter-exempts.mjs`,四列复算式、53 条豁免登记表、全库逐列扫描(`measureDrift`)从此只有一份;闸门 21 §8 改成 import 它,判据一条不增不减——改造前后各跑一次,都是 **49/49 零 SKIP**,那三条全库判据的 detail 逐字未变。理由不是"整洁":接下来要写的那个修数据的脚本若再抄一遍复算式,就会走成"修的人按 A 口径、判的人按 B 口径",而这一族缺陷本身的成因正是"一列很多地方读、只有一处该写"。
**新工具 `scripts/recounters.mjs`**(默认只读):只 UPDATE `articles` 那四列,值 = 行集复算,**不动行集**(流水是事实来源,摘要列是抄件);要写必须 `--apply`,且库名要在连接串与 `--target` 两处同名才动手(默认只允许克隆库 `inkstack_j`);每一条写都是**比较后再写**(`WHERE id = ? AND col = <量到的值>`),影响行数不是 1 就整笔回滚而不是盖过去;写完在同一事务里复算一遍,仍不平也回滚;`--emit-registry` 把当前不平清单打成登记表字面量,"登记"这一步不再靠手抄(实测与那份手抄的表 53 条逐字一致)。
**反证四组**:① 手工往 `comments`/`article_likes`/`read_history`/`agent_qa` 各插几行而四列留 0 → 只读模式点名**那 4 处**并退 1,而**同一时刻**闸门 21 §8 红在同 4 处、其余 48 条照绿,`--apply` 修完闸门回到 49/49 且 §8 那句"全对的 7 篇"变 8 篇;② 并发前提:另一条连接在"量"与"写"之间做一次 `read_count + 1`,写回旧值那条 UPDATE 影响 **0 行**、列上留下增量那一边 → 比较后再写不吃掉刚发生的阅读,它选择回滚;下一轮再把多出来的 1 修回 3(**多记方向也修**,不只是补记);③ 写闸门:连接串指着主库 `inkstack` 而只给 `--apply` → 退出码 2,且输出里连"文章 N 篇"那一行都没有,是**在量之前**就被拦下而不是量完再回滚;④ 主库只读测量:`--target inkstack` 报 24 篇 / 同样 53 处 / 逐列分布与克隆库**完全一致**——克隆库里那种坏法就是线上那一刻的形状。
**如实的限制三条**:它修的是抄件不是账,行集若本身是错的(比如手工删了行)它会照抄件改回去;`--include-exempt` 会连首屏那几个虚构数字一起抹平,默认关着,因为那是产品决定;对齐完若顺手改了登记表顺序,双向棘轮那条会红——先改数据、再改表,顺序不能反。
**回归**:闸门 21 这一轮跑了四趟——改造后绿(49/49 零 SKIP,§8 那三个数字与改造前登记的口径一一对得上:漂移 53、豁免表 53、全对 7 篇)、夹具坏时红在**那 4 处**且其余 48 条照绿、`--apply` 之后绿且 §8 那句"全对的 7 篇"变 8 篇、拆掉夹具复原后再绿一次;`recounters` 自己五条路径各验过(只读 / `--apply` / `--include-exempt` 只读 / `--emit-registry` / `--target inkstack` 只读)。本轮 **Java 与页面代码 0 改动**,其余闸门与 `mvn test`、`tsc` 未重跑——它们这轮没有被测的对象。 | | P9 | 流式超时收口:卡住的上游不许把读者的请求永久挂着,也不许静默截断 | ✅ | **先把旧结论量了一遍再动手**:README 原来写着"Java 的 `InputStream` 只能在两块数据之间判 deadline,真挂起要靠连接层先收走;要治得换非阻塞 IO"。两句都不成立——① 那句 deadline 排在 `read()` **之后**,一次卡住的读永远走不到它,Corretto 17 实测请求超时设 4 秒、第 49 秒那次读仍然没醒(**JDK 的 `HttpRequest.timeout` 只 bound 到响应头,响应体不归它管**);② 代价不是"这一屏转圈"而是**一个 Tomcat worker 被永久占住**(攒够默认 200 个连 `GET /api/auth/me` 都不答);③ 不用换非阻塞 IO:到点从另一条线程 `close()` 那条流,阻塞中的 `read()` 当场抛 `IOException: closed`(实测 close 返回的同一瞬间读侧醒来)。
**改了什么**:新增 `UpstreamDeadline`(一个 2 线程守护 `ScheduledExecutorService`,到点关上游流、先标记再关),透传 `pump` 与 live `readSse` 各挂一次——**live 那条从前一句截止都没有**,同一个缺陷在两条通道上各修一次。**任何一次截断**都补一帧 `error`——被截止关掉的那次、上游半路断掉的那次都算(这套 NDJSON 没有 `[DONE]` 哨兵,"被截断"与"说完了"在读侧本来不可区分;透传那条从前是一句都不补),补帧前若上一块字节停在半行上先补一个换行,否则半帧会把 error 帧黏进同一行、读者连那句"卡住了"都拿不到。超时做成一个旋钮 `AGENT_STREAM_TIMEOUT_MS`(默认 60000,同时管响应头那一半与响应体这一半;设 0 或负数退回 60000——"永不超时"不是取值)。**这是两处刻意偏离旧实现**:Node abort 之后什么帧都不发,本仓库选"多一帧 error"而不是"忠实于静默"。
**闸门 14 从 77 项扩到 85/85**:夹具加两档 `stall`(透传发半帧就卡住不关连接 / SSE 吐两帧就卡住),新判据六条各有独立宾语——两秒档内自己收干净、末尾那一帧是 error 且文案含"秒没有再吐字"、那半帧确实坏了一行而 error 是**单独一行**(`badLines=1 且 frames.length=1`)、卡住这一次仍按已扣费结账、关掉卡住的上游后下一轮照常出完整四帧、live 同式。计时类判据全部配了**宾语判据**(夹具数收到几次提问)与**客户端天花板**(25 秒掐线记 `hitCeiling`),理由见经验列表那两条。
**反证两组 + 一组配置层**:不发 error 帧 → **恰好 2 红**("截断要说话"与"error 是单独一行",而计时判据照绿——它守的是另一件事);只把超时退回默认 60 秒档(不改一行代码)→ 5 红全落在 stall 两档、detail 写着 `25016ms/掐线=true`、其余 80 条照绿,红的地方就是缺的那条启动参数。第一轮的 FAIL 本身就是撞出来的:夹具那句 `agentMode !== "ok"` 把新档吃掉、请求落回 live 通道,计时判据**差点绿给空气看**——加了宾语判据之后同一件事当场红。
**如实的边界两条**:`AGENT_ENGINE=spring-ai` 那第四条通道不经过这里,它靠 `RestTemplate` 的 60 秒**逐次读**超时(不是绝对截止),慢 drip 的模型能把它拖过 60 秒(**这条由 P11 收口,且量出来比"拖过 60 秒"糟得多**);绝对截止意味着诚实但很慢的上游会被切掉——这与 Node 一致,要改 idle 语义得先改判据。
回归(克隆库 `inkstack_j`):14 **85/85**、15 **20/20**、21 **49/49 零 SKIP**、1′ **31/31**、8 **4/4**、`mvn test` 全绿。本轮没动页面与取数,4/7/19 未重跑 | | P10 | 部署手册与"两进程"形态对齐;顺手把生产构建第一次跑通 | ✅ | **触发点不是文档本身**:写 §4.3 那句"`npm run build`"之前没人真跑过它——21 道闸门全打在 `next dev` 上,而 dev 不做预渲染。第一次 build 直接炸在 `/study`(根布局带着 Masthead,每次渲染都 `no-store` 取一次会话,"这页能在构建期定成静态"是假前提),逐个补完 9 个静态路径页又炸在 Next 自己合成的 `/_not-found`——**逐页补不是完备方案**,一句 `app/layout.tsx` 的 `export const dynamic = "force-dynamic"` 才把整个站点收到全是 `ƒ`(只有 `/robots.txt` 是 `○`)。反证跑过:摘掉那一句 → dev 首页**照样 200**、闸门 4 恰好红新加的那 1 条、`npm run build` 再次退出在同一条上。这一族缺陷没有任何运行期症状。
**闸门 4 从 65 项扩到 68**:§9 三条源码判据——先钉宾语(21 个 `page.tsx` 里 20 个确实在渲染期问 Java 取数),再判根布局必须声明 `force-dynamic`,最后反向钉"没有任何页把自己写成 `force-static`"。
**DEPLOY.md 整篇按两进程重写**:删掉已经不成立的 `JAVA_ROUTES` 灰度与"pm2 只起 Next"那种形态(照旧手册部署会得到一个页面能看、每个 `/api/*` 都 503 的站),§1 补 JDK 17、§3 把"没人替你建表"的说法改准、§4 拆成 4.1 `.env` / 4.2 Java(jar 与两种配置定位方式)/ 4.3 Next / 4.4 systemd + pm2、§7 新增"上线前的一次性动作"三步(跑 build → 摘要列对齐 → 回归闸门)、§8 自查补 3 条、§9 边界补 3 条、§10 排查补 6 行。
**手册里每条命令都是本机跑过的**:`mvn package` 出 `inkstack-server-0.1.0.jar`、`java -jar` 从 `server/` 起(配置是相对路径导入)与显式 `--spring.config.additional-location` 两种都验过、`/actuator/health` 回 `{"status":"UP"}`、`X-Backend: inkstack-java`、`npm run build` 通过、`npm run start` 起产线实例后首页读到库里真文章且 `/api` 经渲染层 200。两个如实:pm2 直管 jar 那种写法**没跑过**,手册里就写着"没跑过、建议用 systemd 那段";`gen-java-env.mjs` 生成的那份 properties **不能拿 shell `source` 读**(`SMTP_FROM=… <…> ` 会让 shell 语法错,实测)——这条也写进手册了。
回归:闸门 4 **68/68**、19 **284 + 12 SKIP**(改了根布局就要重跑的两道页面级判据)、生产构建通过、`.next/` 与 dev 实例共用会互相冲掉这件事第一次踩到并已写进 §4.3(build 之后 dev 首页 500,重启即好,不是代码坏了) | | P11 | 第四条通道收口:一次引擎交换有一个绝对截止,覆盖**整串**模型往返 | ✅ | **先量再动**(P9 留下的那条边界写着"60 秒逐次读超时",量下来发现真实形状是"没有上界"):① 一次问答不是"一次"上游交换——`OpenAiChatModel.internalCall` 执行完工具就**递归调用自己**,而 Spring AI 1.1.5 的字节码里没有任何迭代上限,夹具把模型改成"每轮都只回 `tool_calls`"之后,本机实测一次提问在服务端跑了 **200.1 秒、2192 轮**还没停(≈58 轮/秒、每轮都真敲一次全文检索、payload 从 40KB 涨到 2.1MB);读者 25 秒就关了页面,**之后它还又转了 583 轮**,直到上游连接被强行掐断才收场(访问日志 64.09 秒、一个 500)。② 那个 60 秒是 `SimpleClientHttpRequestFactory` 的 **per-read**:响应头之后每 10 秒吐一块正文,那块计时器每 10 秒被重置一次,实测同一趟往返 **150.2 秒还在正常完成**——按 59 秒的节奏滴就是永远。③ 而且那个 60 **硬编码在 `AvatarEngine` 构造函数里**,所以 P9 那个 `AGENT_STREAM_TIMEOUT_MS` 对第四条通道一个字都不生效:当时注释写的"一个旋钮管两头"在这里是愿望不是事实。
**改了什么**:新增 `EngineDeadline`——把截止挂到整次交换上,往返由 Spring 自己发起、我们拿不到它的循环,所以改成**登记**:给 RestClient 挂一个 `ClientHttpRequestInterceptor`,每个往返的响应体第一次交到读侧手上时记进这次交换,到点全关掉(机制与 P9 同一条:到点 `close()` 让阻塞中的 `read()` 当场抛 `IOException: closed`;ThreadLocal 走一次交换一个,`close()` 里必须 `remove()`,因为这条线程是 Tomcat 池里复用的)。`UpstreamDeadline` 因此从"关一条流"放宽成"关一个 `Closeable`"——看门狗、先标记再关、`stalled` 标记仍然只有一份。工厂换成 `JdkClientHttpRequestFactory`(它的 `readTimeout` 到点自己去关响应体,是这一次往返的绝对截止,Simple 那个是每次读重置——**两个半缺各归一半**),硬编码的 60 撤掉、收进同一个旋钮,`ask()` 与 `write()` 两条都挂。**对外契约一个字没动**:截断仍然走"引擎没产出"那条既有出口(转演示档、零扣墨,P6c 定的),这一期只改"多久落",不改"落在哪儿";这里也不补 `error` 帧,因为截止落在第一个字节之前。
**闸门 15 从 20 项扩到 28 项**:夹具加两档(`loop` 永不给最终答案 / `drip` 正文每 1.2 秒一块),§6 七条判据各有独立宾语——两档都在短档内自己收干净(`hitCeiling=false`,服务端不收时由客户端 25 秒掐线并交回 `hitCeiling`)、`loop` 那条**配一个"模型至少被敲两轮"**的宾语判据(P9 的透传档栽过一次:截止没挂上而夹具一轮就收口,计时判据照样绿)、零扣墨且拼回的是完整的演示回答(不是半截引擎回答)、`drip` 那条钉"整档只敲一次模型"以证明它打在单次往返上、掐掉两条卡住的上游之后下一轮照常出完整帧序列、写作通道同式。§1 另加一条**这一期自己引入的风险**:引擎底下的 HTTP 客户端整层换成了 JDK 那个,于是 `Authorization` 头必须还在发给上游——JDK HttpClient 自带一份受限头名单,这种事只能让夹具把请求头记下来实测,不能凭记忆说"应该不影响"。§7 账实的期望同步改成"问答两轮 × 5 + 写作一轮 10"——**复原那一轮是真扣墨的**,为了少改判据而省掉它就会让账实那条撒谎。
**反证两组**:① 只把 `ask()` 那条截止摘掉(工厂那一半留着)→ **恰好 3 红全在 §6**:`loop` 那条写着 `25014ms/掐线=true`、833 轮,"落演示档"因为读者一个字都没收到而红(这条红得有价值:它证明那条判据不是恒绿的兜底),而 `drip` 那档红在"只敲一次模型"变成 **2 次**——上一个被读者放弃的请求还在替它敲,这正是缺陷自己的指纹。② 一行代码不改、只把那一台退回 14 秒档 → 3 红全是计时判据、detail 就是配置的那个数、其余 24 项不动(旋钮真的接进来了,红的地方就是缺的那条启动参数)。两组反证都跑在补"凭据头"那条判据**之前**(当轮共 27 项、各红 3 项),28/28 是补齐之后的那一轮。
顺带修掉一处**会把证据读反的单位**:`server.tomcat.accesslog.pattern` 写着 `%Dms`,而这台 Tomcat 的 `%D` 给的是**微秒**(两条自证:200.1 秒那次写 200117665、64.09 秒那次写 64091402,正好每秒 1e6)——照标签读,一次被占住 64 秒的请求看着像 17.8 小时。字段形状没变、只把标签改成 `us`,DEPLOY §4.2 补一句单位说明;这份日志正是"这条请求落没落在 Java 上"的唯一硬证据。
一处**未归因如实记下**:最初按 P9 的做法把反证 ② 做成"退回默认 60 秒档",那一轮没跑完——实例连同机器上另外两个进程一起消失(退出码 1073807364,`/tmp` 里那份日志没落盘),原因没查出来,不写成"内存被打爆"这种猜测;改成 14 秒档做同一件反证,代价有界、结论等价。
回归(克隆库 `inkstack_j`):15 **28/28**、14 **85/85**(`UpstreamDeadline` 换了签名,透传与 SSE 那两档是最被牵连的)、`mvn test` **20 项全绿**、夹具与探针留下的流水/问答/余额全部复原(含测量阶段一次 5 点扣费,事后清账并复核残留 0)。本轮没动页面与取数,4/19 未重跑 | 下面这份清单是 **Java 侧当前应答的全部端点**(`/api/*` 恒由它应答,没有"哪一部分还在 Node"这回事了; 闸门 8 §1 会从渲染层与组件里的 107 个调用点反查这张表有没有漏接): - 认证:`POST /api/auth/login|logout|register|send-code|reset` · `GET /api/auth/me|providers` · `GET /api/auth/{github,gitee,qq}` 与三家 `/{p}/callback` · `GET /api/auth/github/status` - 安全中心:`POST /api/security/password` · `GET|DELETE /api/security/sessions` · `POST|PUT|DELETE /api/security/2fa` · `GET /api/security/overview`(P7f-1f-a:安全中心那 20 条留痕 + 两步验证开关, 原本两句写在 `app/security/page.tsx` 里的 `pool.query`;只查页面要的四列, `totp_enabled` 走一条窄读而不是带 `password_hash`/`totp_secret` 的整行) - 内容:`GET /api/articles` · `GET /api/articles/{slug}` · `GET /api/search` - 墨水经济:`POST /api/articles/{slug}/unlock|tip|boost` · `POST /api/series/{id}/bundle` · `GET|POST /api/checkin` · `POST /api/me/badge-claim` · `GET|POST /api/topup/orders` · `POST /api/topup/pay`(这一组当年是按段通配逐条切过来的,现在整前缀都在这张表上) - 社区互动:`GET|POST /api/articles/{slug}/comments` · `POST /api/articles/{slug}/like|bookmark|report|paywall-view` · `POST /api/comments/{id}/like|report` · `POST /api/users/{id}/follow` · `GET /api/notifications` · `POST /api/notifications/read` - 创作台:`POST /api/articles` · `PUT|DELETE /api/articles/{slug}` · `GET /api/articles/{slug}/raw` · `GET /api/articles/{slug}/export`(`/api/articles` 这个前缀下 Node 与 Java 的方法集合 当年不齐,正是闸门 8 改版后要防的那类事:切过去不报错,只是把书房管理器打成 200 空列表) - 运营台:`POST /api/admin/articles|comments|reports|users`(内容管理与审核、删评论连带回复、 举报三种处置、封禁与点墨增减;`setRole` 两栈都只认 developer,admin 也一样 403) · **`GET /api/admin/articles|review|users|reports|actions|orders|comments|insights|overview`** (P7f-1e 新建前八条:运营台八张表的读,Node 侧从来没有对位 GET 路由,正确性由闸门 19 回库复算钉住。 八条与那四条 POST **共用同一个 `staffOnly` 门禁**——读侧漏的是邮箱和未上架稿, "只有写接口需要门禁"这种印象必须堵住。`overview` 是 P7f-1f-a 补的第九读:顶部那九项计数 与最近 8 条问答,原先是一句直接写在 `app/admin/page.tsx` 里的 `pool.query`) - 书房:`GET|PUT /api/drafts` · `POST /api/history`(P8b 起它同时是 `articles.read_count` 的唯一写入点, 与 `read_history` 同事务;游客仍然只回 `skipped`)· `GET|POST|PUT /api/links` · `GET /api/links/allowed-domains`(P7f-1f-b:渲染层判定外链要用的放行清单。它与上面那条 `GET /api/links` 是两件事——那条是运营审核队列、非运营 403,这条只回答"这个域名能不能直接点开", 游客渲染文章时也要用,所以必须公开,并且只回 `domain` 一列,不带 `url`/`note` 那些审核内饰) · `GET|PATCH /api/me/profile`(P7f-1f-a 给 GET 补上"印文留空回退昵称首字",与 PATCH 写库同一条式) · `GET /api/me/points`(墨水账户四块:余额 / 今日额度已发没有 / 连签 / 流水 20 条; 连签**只有一条定义**——复用 `CheckinService`,不在页面里再走一遍查) · `PATCH /api/me/password` · `POST /api/uploads` · `GET|POST /api/series` · `GET|PATCH|DELETE /api/series/{id}` · `GET /api/series/mine` · `POST /api/series/{id}/bundle`(P5d 时这四条**已实现却切不过去**:切流只有前缀粒度, 而 `GET /api/series` 两栈同 URL 同方法却不同义——Node 是"我的专栏" (`components/StudioClient.tsx` 正在吃它),Java 是公开合集架。P6d 把两件事拆成两条 URL: Node 的 `GET /api/series` 对齐成公开架、另开 `GET /api/series/mine`,前端改读新 URL, 整前缀这才切得动,见闸门 8 与闸门 10 §7.5) - 迁移工具:`POST /api/import`(RSS 用 JSON、Markdown 用 multipart,同一个 URL 两种体) —— 全平台唯一一处"由用户给地址、服务端替他联网"的入口,SSRF 防护见闸门 13 - AI 面:`POST /api/ai/write` · `POST /api/agent/ask` · `GET /api/agent/status`。这两个前缀 从"整前缀安全"(闸门 8 当年的判定)走到了"只有这一份实现"。 问答是 NDJSON 流式,切过去之后仍然逐块吐帧(闸门 6 有一项专门盯这个:代理把响应攒成一坨, 功能不坏但前端从打字机变成"等十几秒再整篇砸脸")。 `POST /api/agent/ask` 的 body 从 P8a 起多一个**可选**键 `article`(文章 slug):它只决定这笔流水 记在哪篇文章名下,不进发给上游的 payload、不改应答形状,所以闸门 1′ 那份基线一个字没动。 P9 起它有**第四种坏态**:上游"活着但不再吐字节"(头与第一帧都到了,之后没有下一个字节、连接也不关)。 到 `AGENT_STREAM_TIMEOUT_MS`(默认 60 秒)那一刻,Java 从另一条线程把上游流关掉,把读者的这一次 请求收成一帧 `{"type":"error","message":"…N 秒没有再吐字…"}`——**不是一声不响地结束**, 这套协议没有 `[DONE]` 哨兵,静默 EOF 与"说完了"在读侧是同一件事。这一帧是刻意比旧实现多出来的 (Node 那边 abort 之后什么帧都不发),选"多说一帧"不选"忠实于静默" - 只读聚合(为 RSC 分流新增):`/api/articles/{slug}/comments|tips|saved|series-nav`、 `/api/users/{id}/relation`、`/api/tags/{tag}/articles`、`/api/authors/{id}[/articles]`、 `/api/weekly/stats`、`/api/random`、`/api/me/*` 十七项(含 P7f-1f-a 新补的 `profile` 与 `points`)、 `/api/platform/stats`、`/api/platform/top-authors`、`/api/security/overview`。 它们中的大部分 Node 侧至今无对位路由(页面直连 `lib/data.ts` 取数, 没有 HTTP 入口),只有 `/api/series` 那一族在 P7a 之后两栈都有了。 `/api/me/` 那四条新读(`following-feed`/`achievements`/`badge-reward`/`series-title-suggestions`) 与 `/api/platform/` 两条是 P7f-1d 补的,`/api/admin/overview`、`/api/me/profile`、 `/api/me/points`、`/api/security/overview` 四条是 P7f-1f-a 补的, **都没有对岸可对拍**,正确性由闸门 19 回库复算钉住 移植过程中修掉的既有 bug(有的在原实现里就存在,有的差一点就跟着移植过去;对拍要求两侧同口径, 所以一律两边一起改): - `listAchievements` 把 mysql2 返回的 `Date` 直接 `String(date).slice(0, 10)` 当日期键,得到的是 `"Mon Sep "` 而不是 `"2026-09-14"`,与查询键永不相等 → 连签徽章恒为 0、集齐奖励领不到。 改成本地日历日键 `localDayKey()`(DATE 列回来是"本地零点的 Date",不能用 `toISOString()`,那会早一天)。 - 同函数里 `num(results[4])` 按默认列名 `"n"` 取值,而 `results[4]` 的列是 `points_balance`, 恒得 `undefined → 0`,"墨水富翁"徽章因此永远算不出来。 - 同键并发插入 InnoDB 未必回 dup key,也可能回死锁 / 锁等待超时,两侧原先都把它当"签到失败"返回。 现按 `isRetryableLockError` 重试三次(30ms×attempt),Node 与 Java 用同一套口径。 点赞链路上撞到了同一件事(`SELECT..FOR UPDATE` 在未命中的主键上取的是 gap 锁,两个并发事务 各持一把再去插入就成环),故 Java 的 `retryOnLock` 与 Node 点赞路由用同样的三次退避。 - **Java 的 `String.trim()` 与 JS 的不是一个东西**:`trim()` 只裁 `<=U+0020`,全角空格 `U+3000` 裁不掉;`strip()` 走 `Character.isWhitespace`,又不认 `U+00A0` 与 `U+FEFF`。于是一条"纯全角空格" 的评论在 Node 是「内容不能为空」、在 Java 会正常落库。已补 `NodeShapes.jsTrim()` 按 JS 判据裁。 - **运营台"资金流水"面板一直是空表,因为那句 SQL 从来没跑通过**(P7f-1e 由闸门 19 破案): `adminListOrders` 是三段 `UNION ALL`,而库里 `topup_orders` 的排序规则是 `utf8mb4_0900_ai_ci`、 `users` / `articles` / `series` 是 `utf8mb4_unicode_ci`——UNION 要求各列排序规则可合并, 于是这句在**主库 inkstack 与克隆库 inkstack_j 上都报 `ER_CANT_AGGREGATE_NCOLLATIONS`**。 Node 那份整段包在 `try { … } catch { return []; }` 里,所以症状不是报错而是"面板永远没有一行", 页面 200、样式正常、日志一片安静。修法:在 UNION 的字面量与 `IFNULL(p.name, o.pack_key)` 上 显式 `COLLATE utf8mb4_unicode_ci`——**只钉 `o.pack_key` 不够**,那种写法在 mysql2 的会话 (unicode_ci)下能跑,换成 Connector/J 跟服务器默认走的 0900 会话就撞在派生表的 `p.name` 上; 显式钉住之后两种会话实测都能跑,这一点也写进闸门 19 的反证。 两条教训:**`catch` 返回空集合的读,失败与"确实没数据"长得一模一样**,只要没有一条判据数过行数, 它就能一直空下去;而"逐字照搬旧实现"在这里如果严格执行,等于把一个恒空的面板原样搬进新栈—— 迁移中碰到这种,做法是修 + 留痕,而不是伪装成等价。 - 举报的防重锚点曾被写在**事务外**(`submit(id, type, db.lockArticleAnchor(slug), reason)`——参数在 进 `tx.execute` 之前就已求值,那句 `FOR UPDATE` 自动提交、取到锁立刻放掉),六路并发于是落两行。 现在锚点以 `Supplier` 传进事务内执行。这类"看着只是风格"的差别,只有并发打才暴露, 而 `reports` 表上没有 `(reporter,target)` 唯一索引,没有任何第二道兜底。 - 导出 Markdown 的 `md.trim()` 同样栽在上一条那个坑里:Java 的 `trim()` 留着全角空格与 BOM 开头, 两侧导出的字节就差在那里。已改 `NodeShapes.jsTrim()`。 - **DATETIME 不是 UTC**:mysql2 按**驱动本地时区**解释库里的 `DATETIME`(实测 `+08` 机器上 `'2030-01-02 03:04:05'` → `2030-01-01T19:04:05.000Z`),Node 再 `toISOString()` 吐回。 Java 必须落在同一个瞬间上——两侧读的都是 `2030-01-01T19:04:05.000Z`, 而"库里存的串就是 UTC"这个直觉会把早鸟到点整体推后一个时区。 - **`affectedRows` 在两栈默认值下不是同一个数**:Connector/J 给连接打上 `CLIENT_FOUND_ROWS`, UPDATE 回的是"匹配几行";mysql2 没打这个标志,回的是"真正改动几行"。同一句 no-op UPDATE (把 `bundle_price` 改成它当前的值)Node 拿 0、Java 拿 1,而工程里到处拿 affectedRows 当 "这行到底存不存在 / 是不是新建"的判据。已在全局生成 JDBC 串时补 `useAffectedRows=true`。 顺带一处证据:`OauthService` 里 `created = affected == 1` 那句注释写的就是 affectedRows 语义—— 默认值下它会把"老用户第 N 次登录"读成"刚建档"并补发欢迎邮件。 这个洞是书房闸门拿"改专栏打包价"逼出来的:跨栈比 `GET` 回来的价格一致、写回的却是两个世界。 - **JS 的 `slice` 会夹取,Java 的 `substring` 会抛**:生成 TOTP 备份码时,6 字节 base64url 是 8 个字符, 去掉 `-` / `_` 之后长度掉到 5~8,于是 `token.substring(4, 8)` 在长度 7 时抛 `begin 4, end 8, length 7`。按原写法实测**每四次"开启两步验证"就有一次 500**(22.7%), 而 Node 侧同一句 `slice(4, 8)` 只是安静地给出一个短一点的码。已补 `NodeShapes.slice(value, from, to)` 并在此后所有"从 Node 抄来的切片"处使用。这类差异不会出现在对拍里——它取决于随机数。 - **SSRF 黑名单栽在"字符串前缀判"上**(写闸门 13 时抓到的真实绕过,两栈原本都有): `[::ffff:192.168.1.2]` 经过 `new URL()` 会被压成 `[::ffff:c0a8:102]`,而原实现判映射地址是 "取 `::ffff:` 之后的子串按 IPv4 正则匹配"——那子串是十六进制,正则不命中就当公网放行, 实测一个 200 打到了本机网卡地址。十进制 `2130706433`、十六进制、八进制、`127.1`、 全角句号 `127。1`、userinfo 掩护这些写法同理:只要有一种在某一侧被判成"域名"丢进 DNS, 整条黑名单就失效。现在两侧都把地址解析成**字节**再判(Node 在 `route.ts` 里、Java 在 `IpGuard` 里), 并且 Java 自带了一个 WHATWG 口径的地址解析器 `NodeUrl`——`java.net.URI` 与 `java.net.URL` 都不做 IPv4 归一化,不能拿来当判据用。 - **V8 认识 RFC 1123,`java.time` 不认识**:RSS 的 `pubDate` 是 `Mon, 23 Sep 2026 08:00:00 GMT`, 移植时若只留 ISO 分支,Java 会把每一条日期判成"解析失败",于是**全部**导入稿都落到 "导入时刻"兜底分支——两侧都有日期、都不报错,只有值不一样。补分支时要连 V8 的怪癖一起补: 两位年(0-49 归 2000 段、50-99 归 1900 段)、星期名不参与校验、缺时区按本地而 ISO 纯日期按 UTC。 - **`NodeShapes.slice` 的两种签名又绊了一次**:JS 的 `title.slice(8)`(从下标 8 到结尾)被写成 单参数的 `slice(value, 8)`(那是 `slice(0, 8)`),于是"剥掉 `__SKIP__` 前缀"变成"只留前 8 个字符", 回给用户的文件名成了 `__SKIP__`。与备份码那次的 `substring(4, 8)` 是同一类:从 Node 抄切片时, 先确认它是"截断"还是"取尾"。 - **消毒正则少写一个 `+`**:未加引号的属性值那条分支 `[^\s>]+` 写成了 `[^\s>]`,Java 只吃掉一个字符, `` 就留下 `` 这种残骸—— 比"没消毒"更糟,因为它骗过了肉眼。这一条由"两栈各导一轮再逐列比库"报出来, 顺带确认了一件事:**每一条从 Node 抄来的正则都要过一遍 `Java \s ≠ JS \s` 这个筛子**, 消毒、标签匹配、空白折叠三处都在筛子上重写过。 - Windows 上 `.properties` 里的反斜杠是转义符:`INKSTACK_UPLOAD_DIR=D:\Desktop\...\uploads` 会让 `\u` 成为一个畸形的统一码转义,**JVM 在解析配置阶段就起不来**(`ConfigDataException`), 报的还不是路径问题。生成脚本现在统一 `replace(/\\/g, "/")`。 - **`body.author ?? "博主"` 的默认值,在 JSON 解析层就没了**:Java 侧把缺字段先映射成 `""` 再进业务, 于是 `(author == null ? "博主" : author)` 永不成立,转发给上游的 `author` 成了空串—— Node 给的是"博主"。这种差异两侧都不报错,只有生成的文风不一样,而且它藏在"没传 author"这条 平时没人测的路径上(AI 闸门第一轮 4 红里就有它)。规则:**可选字段的默认值必须在"键不存在"这个 层面上判**,所以 `AiWriteController.authorOf()` 缺键返回 `null` 而不是 `""`。 - **同一处默认值修完之后,另一半是 Node 会 500 而 Java 不会**:`draft`/`author` 在 JSON 里可以是 数字、数组、对象,Node 的 `(body.draft ?? "").trim()` 对 `{draft: 5}` 就是一个未捕获的 `TypeError` → 500,而 Java 按 `String()` 口径安静地收成 `"5"`。真实用户不会发这种 body, 但**切流的那一刻状态码会变**,这属于闸门 8 说的"切过去会静默改变行为"。所以两边一起改向不崩的那一侧 (Node 补 `String(...)`),并让闸门 14 拿"上游收到的字符串"来断言——那是这个口径唯一可见的地方。 P6b 的 `question`/`author`/`about` 是同一条规矩,一并收口。 - **流式接口的返回类型写成 `ResponseEntity` 会让 Spring 拒收你的流,而且是在扣款之后**: `StreamingResponseBodyReturnValueHandler` 看的是**声明的**泛型参数,通配 `?` 不算数,于是流式体 被当成普通返回值去找 JSON 转换器,报 `No converter for …Lambda…` → 500。这条链路在返回之前 就已经扣过 5 点墨了,所以失败形态是最糟的那种:**钱扣了、一个字都没给读者**。返回类型必须 字面写成 `ResponseEntity`(闸门 14 的演示通道第一轮整段红就是它)。 - **`slice` 的两种签名第三次绊人,`Map.of` 又不守键序**:JS 的 `t.slice(5)`(取下标 5 到结尾) 又写成了两参数版 `NodeShapes.slice(line, 5)`(截断到 5 个字符),于是 SSE 的 payload 恒为 `"data:"`、一帧 delta 都解不出来,而末尾的 cite 帧照发——"流看起来是通的,就是没有内容"。 同一个文件里转发给 DeepSeek 的消息用 `Map.of("role",…,"content",…)`,`Map.of` 不保证顺序, 两侧发给上游的 JSON 键序就不一样(语义相同、字节不同,夹具一比就露)。规则补两条: 从 Node 抄切片先定它是"截断"还是"取尾";**凡是要把字节交给别人的 Map,一律 LinkedHashMap**。 - **Spring AI 的 starter 会替"每种模型"都装 Bean,而没配 Key 时直接把整个应用炸掉**: 实测启动失败的原因不是聊天模型,是 `OpenAiAudioSpeechAutoConfiguration` 的语音合成 Bean ("OpenAI API key must be set")。一个部署不需要语音,却要被它的可选 Bean 拦住启动, 所以 `spring.ai.model.*` 一律设成 `none`,由 `AvatarEngine` 自己按"开关 + 有没有 Key"构造 ChatModel——没 Key 的部署照样起得来,并照旧落模板兜底。 - **Spring AI 自带的指数退避重试把一次 502 拖成四分钟**(2s / 10s / 50s / 180s 各撞一次超时): 一个 Tomcat 线程被占死,远超读者等分身回答的 60 秒预算,而 Node 那条 live 通道本来就是一次 fetch 定生死。引擎侧显式 `RetryTemplate.maxAttempts(1)`,与参照实现同口径——**框架的默认值 不是"更安全"的选项**,它只是另一个人的默认值。 - **被删掉的那台 Python 服务,检索 SQL 缺两道判定**:它的 `search_blog_articles` 只过 `status='published'`,既不看 `review_status='approved'` 也不看付费墙,于是"读者花 5 点墨问一句、 分身把没解锁的付费正文念出来"这条 P3 时代就堵掉的洞,在智能体这一侧一直开着。引擎按 `lib/rag.ts` 的口径重做,闸门 15 用"旧 SQL 查得到 vs 引擎没发给模型"成对钉住它。 - **派生配置时写空串,会静默吃掉 Spring 的占位符回落**:`application.yml` 里写的是 `${AGENT_MODEL_API_KEY:${DEEPSEEK_API_KEY:}}`,可 `gen-java-env` 只要在 properties 里留下 一行 `AGENT_MODEL_API_KEY=`,A 就算"已定义"、回落不再生效——运营照 DEPLOY.md 只填一个 Key, 引擎却因为空串没起来,而徽标照样报 `live`(它确实有 Key,只是走的不是引擎)。规则补一条: **派生脚本只写有值的行,回落链在脚本里判空,不要留给占位符语法**。 - **「与分身问答 10 次」这枚徽章从来没可能拿到**(原实现自带的 bug,两栈同错):`agent_qa` 建表时 有 `asker_id`,成就与 `badge-claim` 也都按 `COUNT(*) FROM agent_qa WHERE asker_id = ?` 算, 可两条 INSERT 语句一条都没写这一列——全是 NULL,进度恒为 0。**两侧一起错的对拍自然看不见**, 这一条是读 SQL 读出来的。现在三条通道(透传 / live / 引擎)都落 `asker_id`, 闸门 14 §8 §9 与闸门 15 §4 各钉一条"流水挂得上提问者"。 - **一条 URL 干两件事,切流就切不动**(`GET /api/series`):Node 侧它是书房的"我的专栏" (要登录、回 `{ok,series}`),Java 侧它是公开合集架(匿名、回另一套形状)。**两侧各自都没有 bug**, 所以对拍是绿的、页面是好的、接口测试全过——只有"把 `/api/series` 整前缀切过去"这个动作会引爆它, 而那时的表现是最难查的一种:书房管理器拿到 200 空列表,一声不吭。修法是把两件事拆成两条 URL。 教训是**双轨期的接口清单要按"URL × 方法 × 语义"三元组核,按"URL 存在不存在"核会漏**。 顺带一条同族的口径坑:Java 侧原来用带类型的 `@RequestParam int limit`,`?limit=abc` 就是 400 加一段带时间戳的默认错误体,而 Node 是 `Number("abc") || 60` → 200 正常数据;现在两侧共用 `NodeShapes.jsNumber()`(JS 的 `Number(字符串)`:认 `0x10` 不认 `1d`)把这类取值收成一份实现。 - **一份建表脚本里写着 `USE`,程序化应用它就会走进生产库**:`db/schema.sql` 开头两行是 `CREATE DATABASE IF NOT EXISTS inkstack; USE inkstack;`——对 mysql 客户端这是贴心,对 `multipleStatements` 的连接池这是一次劫持:你指定了临时库,语句却跑在 `USE` 之后的那个库里。 本仓库为此修完 `SchemaBootstrap` 之后仍看到"临时库里有表",一查是参照库自己也被写花了。 建库器因此**只执行 CREATE / ALTER 语句**,而闸门 16 额外钉两条:参照库应用脚本前先剥 `USE`, 并且每一步都断言 `SELECT DATABASE()` 还在预期库里。教训:**凡是会被程序喂的 SQL 文件, 要么不含会话级语句,要么执行者必须显式过滤**——"人手工跑没问题"不是证据。 - **删掉一处懒建表,要先确认它是唯一一处**:`SendCodeController` 上挂着一个 `@PostConstruct` 直接调 `EmailCodeMapper.ensureTable()`,那是建表脚本之外的第二条 DDL 路径。它让闸门 16 的 "`auto=false` 时一张表都不建"整条红掉(表数 = 1),顺带暴露了更糟的一半:**数据库不可达时它 抛异常、进程直接起不来**,而 `SchemaBootstrap` 是按语句容错的。现在建库只有 `SchemaBootstrap` 一个执行者。规则:**灰度一个"唯一入口"之前,先拿开关反证它真的唯一**——正例(开着能建) 绿了不算数,反例(关着不能建)才是排他性证明。 - **懒建表不是"自动建表",是"点开过那个功能才有那张表"**(P7c 删掉 Node 侧 9 个 `ensure*` 与 41 处调用点的理由):它在第一次用到时才建,所以一份没被点过的库就是缺那张表、缺那一列、 缺那个索引;更糟的是双轨期**缺哪一块取决于第一个请求走的是哪栈**——Node 的懒迁移与 Java 的 建库器各建各的,两边都绿,而对拍永远看不见,因为两边都假设对方已经把库建好了。 删不能只删:先补 schema(三条只活在懒迁移里的 DDL),再反证结构齐全(闸门 16 §2 最后两行: 拿**运行库**的每一条索引、每一条外键连级联规则反查回 Java 建的库),最后立一条静态不变量 (§0:web 层不许出现 DDL,藏在注释里也算红)。代价写进 DEPLOY.md 并说清楚: **只跑 Next、不起 Java 的部署从此必须先手工导一次 schema**,否则表现不是报错, 是登录、发文、打赏这些接口一个个 500——比缺表更糟的是它看起来像"应用挂了"。 - **改默认值等于改所有没写下来的配置,所以"怎么发现改坏了"要一起交付**(P7d): `DATA_VIA_JAVA` 以前留空就是"页面仍走 Node SQL",于是"配了 `JAVA_BASE` 却忘了设开关"会 静默留在旧路上——站点看起来完全正常,而 Java 的读路径一行没走过,真切上去那天才第一次见光。 翻成"配了就开"之后冒出一个新问题,而且更阴:**两台对拍实例会自己变成同一条路**, 页面双轨能比出满屏全绿,比的却是 Java 和 Java。所以这个阶段的产物不是那个默认值, 而是让闸门能自证在比两条路——middleware 每个应答都报 `x-data-source: node|java` (与页面取数读同一个 `lib/data-mode.ts`,避免"报的路"和"走的路"不是一回事), 对拍类闸门开跑先读它,不是一边 node、一边 java 就直接停。 - **自证的信号也会过期:它测的东西消失时,必须删掉而不是留着说谎**(P7e′):上一条交付的 `x-data-source` 与 `DATA_VIA_JAVA`,在页面只剩 Java 一条取数路之后同时失去意义—— 留着它要么恒真,要么"node"这个词开始指一件不存在的事。读的人分不清"没这条路"和 "没这个信息",那就是一次主动制造的假阳性。所以两个一起删,API 类闸门的自证改用 `x-backend`(这一发接口由谁应答,Java 自己盖的章,与取数路径无关)。 - **删代码前先问"还有谁在用",否则对拍会悄悄变成自己跟自己比**(P7e′ 没直接删的原因): `lib/data.ts` 那 27 个读函数看着只服务页面,实际 `app/api/articles`、`/api/search`、 `/api/series*`、`/api/me/overview` 等 10 个还没切走的 Node 路由也在调。当场删掉 Node SQL, 这些路由就会在 Node 进程里改口去问 Java,而对拍闸门(1/3)仍在把"Node 端口"当成 Node 的实现—— 于是它比的是 Java 与 Java,满屏绿字替一次没验过的改动背书。所以这一步是**收拢不是删除**: Node 读 SQL 整体搬进 `lib/data-legacy.ts`(AST 逐字比对证明搬移没改逻辑,只少了分流语句), 只有 `app/api/**` 引它,P7f 删路由时它跟着一起走,那时"没有消费者"是 grep 一句话的事。 - **切流范围一变,"必须仍在 Node"那类负断言就会自己打自己**(P7f-1 演练抓到的):闸门 6 原本 硬编码 `--keep=/api/articles`,切到 `JAVA_ROUTES=/api` 之后它报两条红——而这两条红是**它对**, 配置也**对**,只是判据的宾语换成了不存在的东西。同一件事在资金/社区闸门里是另外一副样子: 「并发:确实两栈各答了一半」测的从来不是账务,是**出处**——六路并发必须来自两个进程, 否则"只一路成交"完全可能是同一进程里一把锁的功劳,MySQL 的行锁写没写对根本没人验过。 NODE 侧变成 Java 之后这条既不该判红(不是实现回归)也不该继续判绿(那是给没验过的姿势盖章), 所以判据前先问一次 `isCrossStack()`,问不出两个执行者就如实 SKIP,其余判据一条不少地照跑。 反证做过:同一对闸门打真 Node 侧时 100/100、108/108,零 SKIP——降级不是一句永久免检。 - **"拒绝起跑"看着严谨,用错地方就是亲手删掉一道闸门**(P7f-1b 改的正是这个):闸门 3/3′ 原先硬要求 NODE 侧真是 Node,对岸被切走就停。双轨期这是对的——那时差分本该成立。但 P7f 之后 Node 永久不在,这道闸门就变成每次运行都拒绝起跑的那一个,而它身上挂着**唯一能判"两侧都错"** 的绝对判据(未解锁者正文必须被 SQL 切到 6 行以内)。所以判据要按寿命拆开:绝对的那半一台实例 就能判,照跑;差分的那一列没有宾语就记 `—`,别让它把整道闸门带走。 反证的时候才发现改版前有个更难看的洞:拿不存在的 slug 跑,四个身份全部 `取不到文章!!` 却打印"截断防线=不适用"并报"全部通过"——**共同失败必须显式判负**,这条在闸门 6 早就钉过, 漏在了另一道上。 - **基线要比形状而不是比字节,但"只比形状"守不住付费墙**(P7f-1b 撞出来的):把应答整段录下来 逐字节比,第三天就会红给空气——重力排序的先后、views、相对时间、加热有没有过期,全都随时间变。 所以基线收的是键集 / 类型 / 可空性 / 数组元素形状。可这么一来 `viewerUnlocked: false → true` 是 bool→bool、正文从 6 行变 11 行是 string→string,**一次裸奔的解密在纯形状比对里恰好是 PASS**。 于是那一组字段必须钉值(外加 `#lines` / `#chars` 两个派生取值器,因为"截断到第几行"既不是类型 也不是键)。教训:**每加一层放宽,都要回头问一遍"这层放宽会不会正好盖住最贵的那个 bug"**—— 答案不是一句"值由别的闸门管",而是把管的闸门跑一次反证给它看。 - **"还剩多少没迁"不能由三道绿着的闸门回答,因为它们的判据各有盲区**(P7f-1c 订正了本项目自己的说法): P7e′ 之后 README 写着"页面侧只剩一条取数路",对拍、契约基线、路由盘点三道也确实全绿。但三道闸门 判的都是**一条 HTTP 应答**——而运营台八张表、首页三块、个人中心的成就这些读压根没有 HTTP 面 (Server Component 直调 `lib/data.ts`,`/api/admin/*` 只有写侧的 POST)。没有可请求面,就没有判据位置, 于是 14 条进程内 SQL 在所有闸门之外活得很好。教训:**问"迁移完了吗"要先问"我的判据摸得到的地方有多大"**—— 答案不是把三道闸门跑一遍,而是换一种判据(把渲染入口的 import 图走一遍)去量那三道覆盖不到的区域。 这也是 P7f-2 的开工条件从"删就完了"改成"14 条清零"的原因:删掉路由不会让这 14 条消失, 只会让唯一还能看见它们的地方也消失。 - **上一条教训第二天原样复发,因为订正只是把名单换短、没把判据换宽**(P7f-1e′):闸门 18 清零 第二天,按"渲染层可达集合里任何直接执行 SQL 的函数"重算,答案是 **8 条而不是 0 条**—— 四个 `page.tsx` 自己就在写 `pool.query`,而每个已登录页面都要走的 `getCurrentUser` 本身就是一次读+一次节流写,`grantDailyQuota` 更是**渲染时发钱**。旧判据从页面出发却只收集 `from '@/lib/data'` 的符号,于是"页面自己写的"与"经由别的模块摸到的"两类天生不可见。 两条规则从这里落下:**判据的边界由它想证明什么决定,不由它当初怎么写方便决定**; **棘轮读到 0 是最危险的一次读数**——它让人停止查看,所以读到 0 的第一件事是问 "我看不见的地方有多大",而不是庆祝。同一批里还揪出两处实现缺陷:默认导出的组件只记本名, 跨文件边会在最后一跳断掉(页面组件清一色默认导出);`#default` 与真名是同一个函数, 不去重就会把 4 个页面报成 8 条。 - **"至少命中一个"是没有宾语的判据的新形态**(闸门 4 的 §5b 第一版,P7f-1f-a):它写成 "四个计数里只要有一个出现在 DOM 里就算绿"。注入兜底假数 `128/342/1284/4200` 之后它**照样绿**—— 因为库里 `users` 恰好是 8,而页面上别处也有一个 8。和"两条空数组互相盖章"不是一回事: 这次宾语存在,只是**撞上的是巧合**。规则:凡是"在页面/集合里找几个目标"的判据,必须写成 **逐个对应**(第 i 格等于第 i 个期望值,且格数先对上),"命中过任何一个"不配当判据。 改完同一条注入立刻红,位置精确到"第 1 格 期望 8 实得 128"。 - **同一格连续红两次,就不许再用"偶发"解释**(闸门 11 的"驳回通知作者的文案与链接精确"): 上一轮记录写的是"冷 dev 路由的 fire-and-forget 竞态,热了之后就干净"。这次它连红两轮, 去 `app/api/admin/articles/route.ts` 一看,`notify()` 确实**不 await**——HTTP 已 200, 站内信还在飞行,而闸门读得比写得快。这不是被测方错了,是**闸门的调度竞态被当成了缺陷报**。 改成 `waitRow()` 等到上限(3 秒)再判负:等到就比内容,等不到才红。 规则:红要先归因——是代码错、判据错,还是判据读得太早;第三种改判据,前两种改代码。 - **夹具被数据库拒掉,恰好说明我以为可达的那条支路不可达**(闸门 19 的 ⑨.2b 第一版): 为了证明"印文留空回退昵称首字"走到了,第一版把 `avatar_text` 置 NULL, 当场 `Column 'avatar_text' cannot be null`——`users` 表那四列(连同 `created_at`)全写的是 NOT NULL。改成置**空串**,那条回退第一次真的被执行到;同时把两件事如实记下: 实现与复算里的 `COALESCE(...)` 和 `"—"` 默认值只对"整行不存在"生效,这一支**结构上不可达**, 判据不假装测过一条真实数据走不到的路(要测它得伪造一枚指向已删除用户的会话,本闸门不造)。 - **打端点会发,不等于打页面会发**(P7f-1f-b 为此给闸门 4 加 §5c.2):`grantDailyQuota` 从 `/points` 页面摘掉、挪进 `GET /api/auth/me` 之后,闸门 19 那条"会发 + 同日不重发"的写判据全绿, 而**页面完全可能一次都不去问那个端点**——"访问任意页自动入仓"这句文案就会悄悄不成立。 规则:删掉渲染层里一句带副作用的调用时,必须有一条判据**从渲染入口起跑**(打页面、不打端点)。 - **把身份判定交给对岸之后,最坏的失败不是 500,是全站安静地变游客**(同场):`remoteCurrentUser` 只要 `catch` 一下返回 `null`,每个页面仍然 200、仍然有内容,只是少一个"书房"标签,而读者看到的是 "我明明登录了"。反证就是照这个做的:把那个函数改成"问完就 `return null`" → 闸门 4 红 30 项。 规则:身份这条路上的任何一次"回落"都必须被**成对**判据抓住——本人看得到自己、他人看不到、 游客谁都看不到,三条缺一就有洞。 - **"清零"要分得清"确实没有"与"没检测到"**(闸门 18 第二次读到 0):读 0 那天唯一有效的证据, 是把**刚被迁走的那一条**原样退回进程内,看它红不红(红了:`登记表之外出现 1 条`)。这一步顺带 暴露了这道闸门自己的边界——退回之后那台早已编译过该页的 dev 实例照样渲染出正确答案, 因为 Next dev 只在页面模块自身变化时重编译,而 `lib/auth.ts` 自己就是一条能用的取数路。 所以**静态闸门守的是源码形状**,别把它读成运行时形状;运行时那一头由闸门 4 与闸门 19 补。 - **备份清单要从变异清单来,不是从"我记得动过哪几个文件"来**(同一轮反证):四个 Java 变异里有 两个动在 mapper 上,备份却按"改过的控制器"收,于是复原时有两处只能手工反向替换, 最后靠 `git diff --stat` 归零才敢确认。写变异脚本时顺手让它自己输出待备份文件清单即可。 - **正则切正文会被注释骗,而骗出来的方向恰好是"多报"**(闸门 18 的第一版):按"从函数头切到下一个 函数头"取函数体,`listWeekly` 就顺带吞了后面 `unlockArticle` 的文档注释,注释里有"先 INSERT 占位、 再 FOR UPDATE 扣款"两句,于是它被判成直连 MySQL、闸门红在一条不存在的数据路径上。 多报比漏报好运——运气的成分在于它逼人去查,但**判据一旦开始说谎,人就会开始怀疑所有绿**: 现在改成 AST,注释与字符串根本进不了 `CallExpression` 的视野。规则:**凡是"从源码里数出点什么"的 闸门,都要拿它误报过的那一条去反证它现在不误报**(这里就是 `listWeekly`:它必须不在名单里)。 - **迁走一条"从来没有 HTTP 面"的读,等于迁进一片没有任何差分管的地方**(P7f-1d 为此新建闸门 19, P7f-1e 把它扩到十四读):对拍要两侧都有路由,契约基线要 Node 应答过——这十四读两样都不占。 所以裁判只能换成**自己回库把同一个 数再算一遍**:脚本里的 SQL 照 Node 时代的口径重写(那份源码在 git 里,不依赖任何一栈的实现), 于是删掉 `lib/data-legacy.ts` 也不会削掉这一道。教训:**"没有对岸"不是不测的理由,是换判据的理由**。 - **反证第一次跑,红出来的却是闸门自己**:整道打 Node 时"作者榜逐行相等"记了 **PASS**——端点返回 `[]`, 而复算的 `ranked.slice(0, 0)` 也是 `[]`,两条空数组互相盖章。这就是本仓库一直防的"自我证明的信号 丢了宾语",只不过这次丢的人是我刚写的闸门。规则落到代码里:**每个"逐行/逐字段相等"之前必须先断言 宾语非空**,非空判不出来就记 SKIP,绝不记 PASS。第二轮反证改打闸门自己的口径(门槛 10→5、 千分位 `en-US`→`de-DE`、后缀表两两对调),才证明那 35 项绿不是绿在空气上。 - **委派会把差分悄悄变成同分**(P7f-1d 的副产品,已在资金闸门那条判据上标出来): `app/api/me/badge-claim` 仍然是 Node 路由,可它的 `listAchievements` 现在问 Java——于是 "两栈'还差几枚'逐字一致"从**两套判定对拍**降级成**同一个人经两条路自比**,还照样绿。 判据的措辞必须跟着宾语走:路由删掉(P7f-2)之前,它守的只剩分支与文案,那就得写明只剩分支与文案。 - **回库复算的闸门不能抄实现那句 SQL**(P7f-1e 差点犯的):运营台资金流水的复算我最初就是照着 三段 UNION 抄的,结果**闸门自己先崩**在 `ER_CANT_AGGREGATE_NCOLLATIONS` 上——因为实现与复算 共享同一个缺陷时,两边一起失败,"独立证人"就成了同案犯。改成三段各自查、在 JS 里按 `'%m-%d %H:%i'` 合并截断之后,它才第一次真的从需求出发把这条读数算出来。 规则:**照搬需求,不照搬实现**;实现里那些"看起来只是写法"的结构(UNION、派生表、 `IFNULL` 的兜底顺序)恰恰是最需要被独立算法验证的部分。 - **一条改错不红的判据,等于一条没测过的支路**(P7f-1e 反证抓出来的第二处):把闸门里的 `COALESCE(ru.nickname,'游客')` 改成 `'匿名'`,闸门一声不响——因为克隆库里**每条举报都实名**, `reporter_id IS NULL` 那一支从没被走到。补一条匿名举报夹具之后同一处改动立刻红。 同类问题在"空表比空表"上出现过一次(关注流),这次是它的孪生形态:**判据全绿不代表每条分支 都被经过**,所以除了"宾语存在",还要为关键支路写一条"确实走到了"的断言。 - **照搬旧实现有时是把 bug 一起搬走**(资金流水那句在两个活库上都跑不通,却表现为"面板永远空"): `try { … } catch { return []; }` 让 SQL 失败与"确实没有数据"长得一模一样,页面 200、样式正常、 日志安静。这一类的修法不是"迁完之后再说",而是**迁的那一刻就地修 + 在 README 留痕**, 否则新栈继承的是一个"看起来正常"的空面板,而下一个人只会以为这个博主没卖过东西。 - **dev 实例的路由是按需编译的,fire-and-forget 的写入会落在断言之后**:运营台闸门那条 "驳回通知文案精确"在新启动的实例上读回 `null`,预热后 65/65 全绿——通知是 POST 里 `.catch(()=>{})` 异步补写的,dev 首次编译那条路由把整个响应推后了一拍。 教训有两层:**跑闸门先看实例预热过没有**,以及"写完不确认"这种姿势在测试里就是随机红的来源。 - **Git Bash 会把 `JAVA_ROUTES=/api` 变成 `JAVA_ROUTES=C:/Program Files/Git/api`**(演练文档里 `MSYS_NO_PATHCONV=1` 不是装饰):少了它,切流实例静默不切流,`/api/admin/articles` 回 405 而不是 Java 的 401,看起来像"新端点没生效"。判据侧的对策是把 `x-backend` 当唯一硬证据—— 闸门 19 每条端点都先钉它,环境没配好会当场红在"由 Java 正常应答"那一项,而不是错判成代码问题。 - **闸门把自己刷爆的 429,读起来和"两栈不一致"一模一样**(P7f-1 顺带收掉的):边缘闸每 IP 120 次/分,而一次资金闸门就有一百多发、全来自同一个回环地址。之前只能靠"连跑隔一分钟"这种 口头纪律躲,踩过两次才知道红不是代码的问题。现在档位是 `EDGE_API_LIMIT`,只给演练实例抬档, 对照实例照旧 120——抬档这件事本身也要能被反证,否则下一次红你会先怀疑实现。 - **随机页不能拿"逐字一致"当判据,但也不能因此跳过**:`/random`(漫游记)每次抽不同文章, 第一次全量跑就因为它红了。跳过等于把这一页从闸门里删掉。现在改成抽 10 次验三件事—— 必须 307 落到一篇文章;抽到的每一篇都要在该栈自己的公开列表里(随机池漏进草稿或未过审 文章时,页面照样是一篇像样的文章,只有回查列表看得见);10 次里必须出现过不止一篇 (`ORDER BY RAND()` 要是写丢了,每次都是同一篇,前两条照样全绿,只有这条抓得住)。 - **失败计数搬到共享存储,边缘限流故意不搬**(P7e 收掉的缺口):登录 / 改密 / 2FA / 发码的失败次数 原先两栈各有一个 `Map`(Node 的 `lib/rate-limit.ts`、Java 的 `LoginGuard`),同一账号在两栈各试 5 次 不会锁,而且切流量那一刻进程重启、计数直接清零。现在它落在 MySQL 的 `rate_hits`(一行一次命中, 按 bucket 聚合判定),两栈跑的是逐字相同的 SQL。窗口的时钟是 MySQL 的 `NOW(3)` 而不是两边的 `Date.now()`——判"距解锁还有几秒"要用到"现在是几点",这个也必须来自同一个源,否则同一个桶在 两栈会报出两个不同的 `Retry-After`。 **但 middleware 那道 120 次/分/IP 的全站刹车仍然留在进程内存**:它每个请求都要过一次,搬进库 等于给每个请求加一次写,DoS 刹车就变成 DoS 放大器。闸门 17 因此钉了两面——跨栈累计、跨栈清零、 两栈同一个解锁时间必须成立;`api:*` 桶绝不出现在共享账本里也必须成立。 还剩一条边界:**没有转发头时两栈解出的 ip 本来就不同**(Next 会把自己看到的 socket 对端注入 `x-forwarded-for`,Tomcat 不注入、Java 落到 `local`),同一个客户端因此落进两个桶。 生产部署必须 `TRUST_PROXY=1` 并由 nginx 写 `x-real-ip`,两侧才读同一个源;这条边界由闸门 17 ⑥ 反向钉住(真把兜底对齐了,它就该变红提醒改断言)。 - **"共享存储"不等于"共享",键必须由同一个来源算出来**(P7e 踩到的那一脚):把两栈的计数搬进同一张 表之后,书房闸门那条用例仍然一边"还可尝试 4 次"、一边"还可尝试 4 次"——因为键是 `pwdchg:{uid}:{ip}`,而 Node 拿到的 ip 是 Next 自己注入的 socket 对端 `::1`,Java 拿到的是兜底的 `local`,同一个客户端落进两个桶。账是共用的,索引却各自算,共享就只是看起来成立。 教训:**换存储时必须把"键怎么算出来的"一起对拍**,否则改动全部通过、防护一点没涨。 - **补掉一个已知口子,就要把钉着它的那条断言翻成正向证明**:闸门 12 里那条"【已知口子】失败计数是 进程内的"写成红字反而安全——它在缺口存在时绿。P7e 把它改成"Node 记一次之后 Java 侧剩余少一次", 缺口没补上就会红。规则:**已知缺口要以"会红的断言"的形式活着**,补掉的那一刻它必须反转, 否则"我们修好了"只是一句没有证据的话。 - **一条判据若它的前提被所有在跑的实例共享,它就没在守另一半**(P7f-2 立闸门 20 的起因): P7f-1f-b 把渲染层最后四条进程内 MySQL 迁走时,`remoteCurrentUser` 少了一句 `if (!javaReady()) return null`。所有在跑的实例都配着 `JAVA_BASE`,于是"没配后端"那一档 从来没有被任何闸门访问过——`javaBase()` 直接抛,"clone 下来跑起来看界面"这条产品承诺 当场变成"每个页面 500"。补的闸门必须**自己起一台那个前提不成立的实例**(`JAVA_BASE=""`), 而不是在现有实例上加一条断言。反证:删掉那句守卫,那道闸门 29 项红 22 项。 - **退役一道闸门,要当场写下接班人是谁**(P7f-2 收掉闸门 1 与 2 时立的规矩): 只写"已删除",下一个人分不清这是"少了一道"还是"换了一种守法"。所以 README 里那两段 各自点名:1(对拍)→ 1′ + 19 的 §⑩ + 4 的 §5c;2(会话互通)→ 6(Cookie 不被 rewrite 吞) + 19 的 §⑩(会话到底算不算有效)。顺带把契约基线的**出处**立成判据:`freeze` 现在直接拒绝 写入,`check` 里多一项"30 条基线全部冻自 node"——因为 Node 删了之后再冻结,同一份文件的 意思会从"必须符合旧实现"悄悄变成"必须符合它自己上次的样子",而读的人分不清这两者。 - **删除的安全证据要在删之前立**:闸门 8 §1(每个 `/api` 调用点都有 Java 路由接得住)是 在 `app/api/**` **还在盘上**的时候跑绿的——那时"Node 能答"与"Java 也能答"两件事同时可测。 删完之后再补这条判据,就只剩"Java 有这条路由"半句,另一半永远证不了,而表现一模一样的 404 要等到用户点上去才看得见。 - **一个已经做不到的开关,比没有开关更坏**:`JAVA_ROUTES` 当年的承诺是"留空即整套回滚"。 Node 侧没有 /api 实现之后,留空得到的是一堆 404,不是回滚——这个开关还活着,但它守的 是一件已经不存在的事,读文档的人会以为回滚是一条配置的距离。所以整条删掉,回滚退回工程 手段(`git revert` 那一系列提交);闸门 6 的 `--keep` / `--keep-render` 同理, **传进来直接拒绝起跑**,而不是静默忽略——静默忽略会让下一个人以为自己按老姿势跑过了这一道。 - **`git` 说删干净了,不等于盘上干净**:`git rm -r app/api` 之后 `app/api/uploads/route.ts` 还在原地,因为 `.gitignore` 里那条不锚根的 `uploads/` 把它吞了——**一个从未被跟踪过的源码文件**, `git status`、`git rm`、`git ls-files` 三处都不会提它。教训两条:删目录之后按盘上残留复验 (`ls`/`find`,不是 `git status`);忽略样式要锚根(`/public/uploads/`), 不锚根的目录名会命中任意层级,而它盖住的东西往往正是最不该被盖住的那一个。 - **"两笔写在同一个事务里"要靠制造一次真失败来验,不是靠读代码**(P8a 立 §10 时定的做法): 问答流水与 `agent_qa_count` 是一对必须同生同死的写,而"我加了 `@Transactional`"这句话在 正常情况下与"没加"长得一模一样。验法是**把第二笔指向一张不存在的表**,然后看第一笔还在不在: 还在就是半笔成功(计数器会漂),一起消失才是整笔回滚。这一跑还顺带证明了另外两件事—— 读者的回答仍然是 200 且帧数完整(吞异常的位置在事务边界之外),以及墨照扣(扣费的判定在 上游给出 2xx 那一刻,与流水成败无关)。一个反证能同时钉四条口径,比三条各钉一条更省。 - **实现全对但没人调用,只有源码判据抓得到**(P8a §10 最后一条的由来):`article` 这个键是 前端从 `usePathname()` 取的。库侧那 13 项判据在"页面根本没带这个键"的世界里**照样全绿**—— 它们测的是服务端。而线上表现是那个计数又开始不动了,跟修 bug 之前一模一样。 所以那道闸门读一次仓库(`AgentChat.tsx` 里必须同时有那条 slug 匹配与那个 `article` 键), 判的是**源码形状**而不是运行时形状:它不能证明页面真的这么跑了,它能证明这句话还在。 - **一列被读得越多、写的人越少,就越容易永久冻住**(P8b 立闸门 21 的起因): `articles.read_count` 在 Node 与 Java 两套实现里都没有任何一处写它,而首页列表、热榜排序、 文章页报头、作者主页、个人中心、运营台图表、成就徽章、付费墙漏斗的分母都在读它。 对拍比形状、契约基线比形状、页面判据比"DOM 上有没有这个数"——三道闸门一起绿了几十期, 因为**"有"与"对"是两件事**。守这种列只能靠一种新判据:把它总结的那张表复算一遍,比数。 顺带一条设计约束:摘要列的写必须长在写行集的那一侧(同一个事务),而不是"读的人记得自己复算"。 - **"防负"的 SQL 不一定防负:先看那一列是有符号还是无符号**。`comment_count` / `like_count` 都是 `INT UNSIGNED`,而 MySQL 在 `comment_count - 1` 这一步就按无符号算,下溢当场抛 `BIGINT UNSIGNED value is out of range`——`GREATEST(0, …)` 连被求值的机会都没有。 表现不是兜成 0,是整条请求 500 并回滚。这条坑躲过了代码审查,因为它**在正常数据上永远不会被触发**: 只有 `stored` 已经比行数小的时候才撞得上,而那恰好是种子与历史夹具留下的形状。 写法是先 `CAST(… AS SIGNED)` 再减;判据是"故意把 `stored` 压到比行数小,再删一次"。 - **形状判据测的是失败时刻,不是成功时刻**(P8b 反证 CP-2 撞出来的口径):把评论那两笔写拆回 两句裸调用,守恒判据、账实判据、DOM 判据**一条都不红**——成功的时候两种写法给出一模一样的数。 只有"这两句在不在同一个 `tx.execute` 里"这条源码判据红了(恰好 1 红)。这不算判据失灵: 事务这种东西本来只能在失败时刻被证明,所以要把它写成形状判据、并在闸门头里说明它测的是哪一刻。 - **判据的前提由环境历史决定时,就要由判据自己钉出来**(P8b 回归里冒出来的):闸门 11 那两条 "运营加减净额为 0"红在 `净流水 -30`。代码注释写着 `+60 −60`,实际序列是 `+60 −(bal+60) +200 −200`, 净额恒等于 `-bal`——它默认"探针进这一节时余额是 0",而这个余额会被每日 30 滴、社区奖励、 上一轮清场方式改掉。修法是让闸门在节首自己把水位 UPDATE 成 0,并把 `LEVEL` 写进算式, 收尾仍按开头快照复原。与"前提被所有在跑的实例共享"同族但不同形:那条是前提太普遍以致看不见, 这条是前提藏在数字里、由库的历史决定。 - **"判的那条"与"修的那条"共用一份定义,否则两边会各自漂**(P8c):闸门 21 判的是四列与行集复算, 而真正要按复算去改数据的运维脚本迟早要写一条。两份复算式各抄一遍的结果就是"修的人按 A 口径、 判的人按 B 口径"——修完闸门还红,或者反过来闸门绿着而数据仍是坏的。所以复算式、豁免登记表、 全库扫描那一段一起收进 `scripts/counter-exempts.mjs`,两边都 import 它。 这么做的收益在反证里当场看得见:手工往四张行集表各插几行、四列全留 0,只读脚本点名 4 处不平, **同一时刻**闸门 21 §8 红在同 4 处、detail 逐字相同——两边说的是同一件事,这才是"共用"可验证的样子。 附带一条:`--emit-registry` 能把当前不平清单打成登记表字面量,"登记"这一步也不再靠手抄。 - **计时判据必须配一个"宾语判据",否则它会绿给空气看**(P9 第一轮就撞上了):新加的"上游卡住时 几秒内自己收干净"那两条,跑起来是 PASS——但 detail 里的帧是**另一条通道**的输出。夹具里那句 `if (agentMode !== "ok")` 把新加的 `stall` 档先吃掉了,请求悄悄落回 live 通道,于是"两秒内收尾" 成立得像模像样(live 那档本来就秒回)。修法是紧跟着加一条只判宾语的判据: **这一档真的打在透传上吗**(夹具自己数收到几次提问)。计时、大小、耗时这类"数量判据"只能证明 "有个东西在限内完成了",它必须和一个"确实是这件事"的判据成对出现。 - **判据不能只是"不动":会给不出结果的那一类加客户端天花板**(P9):stall 判据的主语是"服务端到点 自己收",而旧实现就是不收——一条不加天花板的 fetch 会永远等下去,红了也不会告诉你, 它把整道闸门一起挂住。现在 `timedAsk` 到 25 秒由客户端掐线并记 `hitCeiling`, 于是"根本不收"这一种失败有一个**说得出口的形态**(detail 里就是 `25016ms/掐线=true`)。 反证跑法也顺带变了:不用改代码,只要那一台不带 `--inkstack.agent.stream-timeout-ms=2000` 起, 5 条红全在 stall 两档上、其余 80 条照绿——红得定位得住。 - **缺口里写的"代价"是估的,动手前值得再量一次**(P9):这一条原来写着"真要治得换非阻塞 IO, 代价与共享限流同量级,得单独排一期"。实际到点从另一条线程 `close()` 那条流就够了(十几行), 而且实测发现 JDK 的 `HttpRequest.timeout` **只 bound 到响应头**、响应体那次卡住的 read 永远不会醒—— 原估的"靠连接层先收走"其实不发生。一条缺口估价写错不可怕,可怕的是它从此没人再动。 - **注释里"一个旋钮管几头"描述的是意图,不是接线**(P11):`AGENT_STREAM_TIMEOUT_MS` 的注释写着 "同时管响应头那一半与响应体这一半",而第四条通道把 60 秒**硬编码在自己的构造函数里**—— 改那个值对它一个字都不生效,透传与 SSE 两条照动,看起来"旋钮是好的"。这类话只能由一种判据算数: **一行代码不改、只把那一台退回另一个档位,计时判据必须跟着红在配置的那个数上**(这一期反证 ② 就是这个形状:14 秒档 → 三条红全在 §6 的计时判据上、detail 写着 14017ms、其余 24 项不动)。 同一条道理反过来说也成立:一个从没被谁拧过的常量,多半就是那条没被管住的通道。 - **日志字段自带的单位字面量不能信,量一次再信它**(P11):`accesslog.pattern` 里那个 `%Dms` 的 `ms` 是人写的字面量,而这台 Tomcat 的 `%D` 给的是**微秒**——一次被占住 64 秒的请求读起来像 17.8 小时,于是这份"这条请求落没落在 Java 上"的唯一硬证据会被当成噪音扔掉。敢下结论靠的是两条自证 (200.1 秒那次写 200117665、64.09 秒那次写 64091402,正好每秒 1e6),不是靠查文档: 同一个字段名在不同版本/不同容器上的单位本来就不一致,本机数据比记忆可靠。 - **闸门跑的那个运行形态,不等于要发布的那个运行形态**(P10 撞出来的,同族但新的一格): 21 道闸门全都打在 `next dev` 上,而 dev **不做预渲染**。第一次真跑 `npm run build` 就炸了—— 根布局带着 Masthead,它每次渲染都要 `no-store` 取一次会话,于是"这一页可以在构建期定成静态" 这个假设是假的,Next 直接在第一个静态路由(`/study`)退出构建;把那九页逐个补上 `force-dynamic` 之后又炸在 Next 自己合成的 `/_not-found` 上,最后收在根布局一句。 这一族的可怕之处在于**它没有任何运行期症状**:反证跑的时候摘掉那一句,dev 首页照样 200、 正文照排、68 条判据只红新加的那 1 条,而 `npm run build` 再次退出在同一条上。 所以补了 §9 三条源码判据(根布局必须有、不许有 `force-static`、并且先钉住"确实有页在渲染期取数" 这个宾语),并在 DEPLOY.md 第 4.3 节把"发布前必须跑一次 build"写成硬步骤—— 源码判据是便宜的常驻哨兵,build 才是那一格的最终裁判。 另一个已知缺口在流式超时,**P9 把它收掉了**,代价比原来估的小一个数量级。原来的说法是 "Java 的 `InputStream` 只能在两块数据之间判 deadline,遇到『60 秒一个字节都不来』的挂起要靠连接层 先收走;真要治得换非阻塞 IO,代价与共享限流同量级"。前半句是对的,后半句两处都不对: - **等不到,而不是慢一点**:旧写法那句 `if (System.nanoTime() > deadline) return;` 排在 `read()` **之后**——一次卡住的读永远走不到它。本机 Corretto 17 实测:`HttpRequest.timeout` 设 4 秒、 上游只发一帧就再也不吐字节,第 49 秒那次 `read()` 仍然没醒(**JDK 的请求超时只 bound 到响应头**, 响应体不归它管)。而这一次读跑在 servlet 工作线程上,所以代价不是"这一屏在转圈", 是**一个 Tomcat worker 被永久占住**——攒够默认那 200 个,整台服务连 `GET /api/auth/me` 都不答。 - **不用换非阻塞 IO**:到点从另一条线程把上游流 `close()`,阻塞中的 `read()` 当场抛 `IOException: closed`(实测 close 返回的同一瞬间读侧醒来)。新增的 `UpstreamDeadline` 就是这十几行, 共用一个 2 线程的守护 `ScheduledExecutorService`。 - 超时是**整次交换的绝对截止**,与 Node 的 `AbortSignal.timeout(60_000)` 同一个语义(不是"idle"); 一个旋钮 `AGENT_STREAM_TIMEOUT_MS` 同时管响应头那一半和响应体这一半,默认 60000, 设 0 或负数一律退回 60000——"永不超时"不是这里的取值。 - **截断必须说出来**:这套 NDJSON 没有 `[DONE]` 哨兵、靠连接关闭收尾,于是"被截断"与"说完了" 在读侧本来不可区分。到点之后补一帧 `error`(live 通道走它现成的那句"已按成功计费"文案, 透传通道从前是**一句都不补**);上游半路断掉的那一次同样补帧,两条通道对同一件事说同一种话。 补帧前如果上一块字节停在半行上,先补一个换行——不补, 那半帧与 error 帧黏成一行,读者连那句"卡住了"都拿不到。这一句是**刻意偏离旧实现**: Node 那边 abort 之后什么都不发,本仓库选"多一帧 error"而不是"忠实于静默"。 - 两条通道各修一次,所以也各判一次:透传那条至少还判过一下时间,SSE 那条(`readSse`)**一句截止都没有**。 第四条通道(`AGENT_ENGINE=spring-ai`)当时留下的那条边界,**P11 也收掉了**,而它比前两条更糟: 不是"60 秒",是**没有上界**—— - **一次问答不是"一次"上游交换**。引擎允许模型自主调用检索工具,而 `OpenAiChatModel.internalCall` 在每次执行完工具之后**递归调用自己**,Spring AI 1.1.5 的字节码里没有任何迭代上限 (字段、常量、字符串都没有)。于是"模型每轮都只回 `tool_calls`"这一个上游行为就能把循环转成无穷次: 本机实测一次提问在服务端跑了 **200.1 秒、2192 轮**还没停(≈58 轮/秒),每轮都真敲一次全文检索, 发给模型的 payload 从 40KB 一路涨到 2.1MB;读者 25 秒就关了页面,**之后它还又转了 583 轮**, 直到那条上游连接被强行掐断才收场(访问日志落下 64.09 秒、一个 500)。 - **那个 60 秒是 per-read,不是绝对截止,而且它不归旋钮管**。工厂是 `SimpleClientHttpRequestFactory`, `readTimeout` 每读一块就重置一次:响应头之后每 10 秒吐一块正文,实测同一趟往返 **150.2 秒还在正常完成**, 按 59 秒的节奏滴就是永远。更难看的是那个 60 是**硬编码在 `AvatarEngine` 构造函数里**的—— 所以 P9 那个 `AGENT_STREAM_TIMEOUT_MS` 对这条通道一个字都不管, 当时注释里写的"一个旋钮管两头"在这一条上只是愿望,不是事实。 - **修法与前两条同一个机制、同一个数**:新增 `EngineDeadline`,把截止挂到**整次交换**上。 往返由 Spring 自己发起,我们拿不到它的循环,所以改成**登记**:往 RestClient 上挂一个 `ClientHttpRequestInterceptor`,每个往返的响应体第一次交到读侧手上时记进这次交换,到点全关掉 (`UpstreamDeadline` 因此从"关一条流"放宽成"关一个 `Closeable`"——看门狗、先标记再关的顺序、 `stalled` 标记仍然只有一份)。工厂换成 `JdkClientHttpRequestFactory`:它的 `readTimeout` 到点自己 去关响应体,是**这一次往返**的绝对截止,而 Simple 那个是每次读重置——两个半缺各归一半。 登记走 ThreadLocal,所以 `close()` 里那句 `CURRENT.remove()` 是必须的:这条线程是 Tomcat 池里复用的, 留着的就是一个会提前掐死下一个读者上游的僵尸截止(闸门 15 §6 最后一条专门钉它)。 - **截断之后对外一个字没变**:`engine.ask()` 抛出来的仍然走"引擎没产出"那条既有出口——转演示档、零扣墨 (P6c 定的口径)。这一期只改"多久落",不改"落在哪儿";这里不需要像流式那样补一帧 `error`, 因为截止落在**第一个字节之前**,读者拿到的是一份完整的演示回答,不是半截引擎回答。 还剩一条如实的边界:绝对截止意味着一个诚实但很慢的上游会被切掉——这与 Node 的行为一致, 要改成 idle 语义得先改判据再改这里。 第三个缺口是同一族但没一起改的,**P8a 把它补上了**:文章页那句「分身已回答 N 次」读的是 `articles.agent_qa_count`,而这个计数器在 P8a 之前**只在种子与导入时写过一次**——真实问答既不填 `agent_qa.article_id`,也没有人 `+1`,于是它永远停在种子那三个数。现在的形状是: `POST /api/agent/ask` 接受一个可选的 `article`(slug),Java 按 `slug + status='published'` 认领文章, 认领到的那一笔在**同一个事务**里写两样东西——`agent_qa.article_id` 与 `agent_qa_count = agent_qa_count + 1`(计数器与流水行数必须是同一件事的两个名字,一半成功就是漂移的开始)。 只认 `published` 是刻意的:重力排序式里有 `agent_qa_count * 10`,把加成发给一篇还没公开的题目就是造假; 草稿、假 slug、超长 slug 一律记 NULL 而**照旧回答**,认领失败不是读者的错。 三条限制写在这里,别把这一条读成"那个数字从此完全是真的": ① **没有可回填的过去**——历史流水的 `article_id` 全是 NULL(那一列原本根本不在 INSERT 里), 而种子写进去的数字是展示用的虚构值,与任何流水都对不上;要么当示例留着,要么清成 0, 这是产品决定,不由一次修 bug 顺带替它拍板。 ② **页面上那个数字要重新加载才变**——前端不做乐观 +1,问完立刻看到的还是旧值。 这也刻意的:乐观更新要不错位,就得把"什么算一次有效问答"的口径复制到客户端去。 ③ 归因只发生在库侧,`article` 不进上游 payload,所以它不是接口契约的一部分。 第四个缺口与第三个同形,而且更隐蔽:**`articles.read_count` 在两套实现里都没有人写过**。 P8b 补上了——`POST /api/history` 现在在记 `read_history` 的同一个事务里给它 +1 (`ArticleReadRecorder`)。它此前不只是"文章页那个阅读数是假的":热榜排序式里有它、 「总阅读破 100 / 破 1000」两枚徽章按它算(此前**不可完成**)、书房那张付费墙漏斗拿它当**分母** 而分子 `paywall_views` 是活的。一个永远不动的数当分母,表现是"真实流量一来转化率超过 100%", 而任何单点判据都看不见它。口径同样是拍过板的决定:**登录读者每打开一次算一次,游客不计** (那条接口对游客本来就回 `skipped`),所以 `read_count == SUM(read_history.read_times)` 是恒等式。 真要把它做成 PV(游客也算),得先给游客一条能上报的路、再回来改闸门 21 的复算式, 顺带处理刷新刷量——那是另一期,不是修 bug 顺手做的事。 同一次盘出来的还有三处:评论 INSERT 与 `comment_count + 1` 从前是两句裸调用(收进同一个 `tx.execute`,并给那句 UPDATE 补上 `status='published'`,让两笔写的适用条件逐字相同); 运营台**举报处置**删评论只删行不扣数(补上按实际删除行数扣回);以及 `GREATEST(0, 无符号列 - 1)` **根本不是兜底**——MySQL 先按无符号算减法,下溢当场抛 `BIGINT UNSIGNED value is out of range`, 整条请求 500 并回滚,所以要先 `CAST(… AS SIGNED)`。最后这条是闸门 21 自己抓出来的: 判据是"把 `stored` 压到比行数小再删",而这正是种子稿今天的形状 (`bo-20260911-1`:计数 0、库里有 4 行评论——修之前删其中任何一条都会 500)。 这四列留下的历史脏数据(种子写的展示值 + 种子直接 INSERT 的演示行,共 53 处) **按 `slug × 列` 逐条点名豁免**,写在 `scripts/counter-exempts.mjs` 的登记表里—— 判的那一条(闸门 21)与修的那一条(`scripts/recounters.mjs`)共用这一份,谁都不能单方面改口径。 没有写成"凡是种子稿一律不查",也没有按前缀放行——那样判据会被自己掏空。登记表是双向棘轮: 新出现的不一致要红,已经归零却还挂在表上的也要红。 **这一族缺陷还剩最后一次动作,而且它不是写代码**:新代码只保证"从这一版起增量守恒", 库里那 53 处旧账不会自己追平——所以**上线前值得跑一次摘要列对齐** (`node scripts/recounters.mjs` 先看,`--apply` 再动,细节见下面「运维脚本」)。 主库 `inkstack` 上做过一次只读测量:同样 24 篇、同样 53 处、逐列分布与克隆库**完全一致** (`{read_count:19, comment_count:4, agent_qa_count:16, like_count:14}`), 也就是说闸门 21 在克隆库里看到的那种坏法就是线上那一刻的形状,不是克隆库特有的。 其中 53 处全部登记在册,跑对齐工具默认**不会**动它们;要不要连种子那几个虚构数字一起抹平, 仍然是产品决定(首屏"阅读 12,840"比"阅读 3"好看,这一句是 dujiang 拍定的保留理由)。 页面侧(Server Component 进程内取数,不经 HTTP)的**读路径只剩一条**:`lib/data.ts` 里 41 条 remote 调用——列表 / 详情 / 评论 / 打赏 / 收藏 / 专栏导航 / 关注关系 / 我的专栏 / 搜索 / 话题页 / 作者主页 / 专栏架 / 专栏落地页 / 漫游记 / 个人中心六类足迹 / 创作台四组看板 / 全站统计与作者榜 / 关注动态流 / 成就墙 / 专栏题名建议 / 运营台八张表(后十四处是 P7f-1d 与 P7f-1e 补的,见闸门 19)—— 外加八处**不经过 `lib/data.ts`**、直接住在 `lib/java-source.ts` 里由页面自己调的:P7f-1f-a 那四条 (运营台总览 / 账号资料 / 墨水账户 / 安全中心,它们的 SQL 原本就写在 `page.tsx` 里)与 P7f-1f-b 那四条(`remoteCurrentUser` 会话解析、`remoteSessions` 设备列表、`remoteAllowedDomains` 外链白名单, 以及每日 30 滴——那一条不再有任何 Node 侧函数,它兑现的就是 `GET /api/auth/me` 里那次发放)。 `remoteCurrentUser` 是这一族里最重的一个:**每个已登录页面**都要走它(报头一次、页面一次, `cache()` 把它们合成一次渲染内的一问)。 两档口径写死在这里:配了 `JAVA_BASE` 就问 Java,问不到就**抛**、页面 500,绝不静默回落成演示数据; 没配就只用 `lib/demo-data.ts`——那是"clone 下来先跑起来看界面"的产品承诺,不是故障。 **后一档以前从来没有实例在跑,所以从来没有闸门看过它一眼**,P7f-2 起由闸门 20 守着。 `DATA_VIA_JAVA` 与 `x-data-source` 已在 P7e′ 退役;`JAVA_ROUTES` 与整份 `lib/data-legacy.ts` (Node 侧那套读 SQL)、`lib/db.ts` 以及 10 个只服务 `app/api/**` 的模块在 P7f-2 退役—— 路由删掉之后它们是死代码,而"这一发走的是哪条路"这个问题在只剩一条路之后不再存在。 **一个含义会变的信号比没有信号更坏。** `listHot` / `listRelated` / RSS / sitemap 不另设开关:它们是 `listArticles` 下游的纯 JS 组装, 上游一条路,下游就一条路;排序口径收进 `rankHot()` 一个函数,全站只有这一处定义。 **"页面侧只剩一条路"这句话曾经说过头了,它由闸门 18 机器订正、补齐却比订正慢了两步**: 订正那天(P7f-1c)算出来 `lib/data.ts` 里被页面调用的函数**有 14 个在 Next 进程内直连 MySQL**—— 首页三块、个人中心两条、书房一条,加上运营台八张表(`adminList*` ×7 + `adminInsights`)。 它们之所以一直看不见,是因为这些读**从来没有 HTTP 面**:页面是 Server Component 直调 `lib/data.ts`,`/api/admin/*` 那四条路由只有 POST(写侧)。所以对拍、契约基线、路由盘点三道 各自的判据里都没有它们的位置——三道闸门全绿,而"web 已经退化成纯渲染层"并不成立。 `node scripts/page-sql-inventory.mjs --verbose` 会把它们连同"哪个页面在调它"列出来,锁成棘轮: 登记表之外多一条 → 红;登记表里某条被迁走却没删登记 → 也红。 这个数一路是 14 → 8 → 4 → **0**(P7f-1d / P7f-1e / P7f-1f-a / P7f-1f-b),最后那个 0 与第一次 那个 0 的区别是**它被反证过**:把 `/study` 的身份判定改回 `getCurrentUser()`,这道闸门立刻报 "登记表之外出现 1 条"。也就是说它现在两头都在守——不许多出来,也不许退回去。 一条**必须写下来的限制**:这一道读的是 AST,不是运行时。反证时那台早已编译过 `/study` 的 dev 实例照样渲染出正确答案(Next dev 只在页面模块自身变化时重编译,而 `lib/auth.ts` 自己就是一条 能用的取数路)。所以它守的是**源码形状**,运行时形状由闸门 4 的 §5c 与闸门 19 的 §⑩ 钉。 "还剩几条"这个数只有在**看得见的地方等于管到的地方**时才有意义,而这条闸门为这件事红过三次 (P7f-1c 数漏运营台之外的页面、P7f-1e′ 数漏页面自己写的 SQL),教训已经落进下一节。 ## 🏗 架构 ``` 浏览器 │ 同域、同一枚 ink_session Cookie(浏览器从不直连 Java) ▼ Next.js 15 (App Router) :3100 —— 只做两件事:渲染 + 边缘闸口 ├─ 页面(Server Component)…… lib/data.ts / lib/java-source.ts │ └─ 每一条读都是一句 remote 调用 ──HTTP(转发 Cookie)──▶ Spring Boot :3101 │ · 41 条列表/详情读 + 八个页面各自那条自问的读(admin / me / points / security…) │ · 全站共用三条:GET /api/auth/me(每个已登录页面一次,顺带 last_seen 节流写 │ 与每日 30 滴的发放)、/api/security/sessions、/api/links/allowed-domains │ · JAVA_BASE 没配 → 只渲染 lib/demo-data.ts 的演示数据(这一档由闸门 20 守着); │ 配了就不回落,挂了即 500——"Java 挂了"不许伪装成"站点正常" ├─ /api/* ── middleware rewrite ─────────────▶ Spring Boot :3101 → MySQL(同一库) │ · **没有路由名单**:`startsWith("/api/")` 一条判定,配了后端就全切 │ · JAVA_BASE 没配 → 503 + Retry-After,而不是放它去撞一个 404 └─ 安全闸口(限流 / CSRF / 安全响应头)留在这里,与"谁来处理请求"解耦 └─ 闸门 18 数渲染层可达集合里直接执行 SQL 的函数:**0 条** (这里不再有"Node Route Handler → lib/data-legacy.ts"那一支:58 个 app/api/**/route.ts、 那份遗留读 SQL、以及为它服务的 11 个 api-only 模块在 P7f-2 一起删掉了。 Node 的 agent-service(Python FastAPI + AgentScope)早在 P6c 就移除,引擎收进 Java 进程内。) Spring Boot :3101 —— 唯一的后端 ├─ MyBatis-Plus 手写 SQL(87 个 URL 模式) └─ 智能体引擎(Spring AI + 文章检索工具)→ OpenAI 兼容端点 ``` 安全闸口(限流、CSRF、安全响应头)留在 Next 边缘中间件里,**与"谁来处理请求"解耦**—— 所以切流不会削弱任何一道防线。浏览器不直连 Java:会话 Cookie 是 host-only,而 CSRF 校验比对的是 带端口的 Origin,因此所有到 Java 的请求都由 Next 服务端转发。 转发这条路上有一处会静默出错:`NextResponse.rewrite` 会把请求的 **Host 覆写成后端地址**, 所以任何"由请求自己算绝对链接"的逻辑(导出 Markdown 里的 `url:` 与原文链接)都会得到 `http://localhost:3101/...` 这种用户不该看到的内网地址。middleware 因此在转发前显式 `set` 了 `x-forwarded-host` / `x-forwarded-proto`(用 set 而非 append,客户端伪造的同名头到不了 Java), Java 侧按「站点配置 → x-forwarded-host → Host」三级取值,闸门 11 反向断言 `:3101` 绝不出现在导出里。 ## ✨ 功能与归属 **阅读与创作**:杂志信息流首页(重力排序:互动热度 × 时间衰减,置顶 > 加热中 > 自然重力)、 文章页(Markdown 渲染 / 代码复制 / 阅读进度 / 目录高亮 / 上下篇)、AI 创作台(续写·润色·起标题·荐题)、 专栏合集与打包价、每周墨报、搜索 / 热榜 / 归档 / 标签墙 / 随机漫游。 **AI 分身**:每位博主一个 ReAct 分身,读者可与「文章本人」对话;NDJSON 流式输出; RAG 用 MySQL ngram 全文索引检索博主文章,回答带依据引用。 **墨水经济**:充值 / 打赏 90·10 / 付费解锁 70·30 / 专栏打包 / 限时折扣 / 加热 boost / 转化漏斗看板。所有资金操作**单事务原子**:占位判重 → `FOR UPDATE` 扣款 → 同事务分账流水。 **社区与安全**:邮箱验证码注册登录、评论点赞、举报流转、审核工作流;scrypt 加盐口令 + HMAC 签名 Cookie + 数据库会话表双保险、TOTP 两步验证(手写 RFC 6238)与一次性备份码、 全站 API 限流、CSP/COOP/CORP/HSTS、HIBP 泄露密码检查、新设备登录提醒、审计时间线。 > 完整功能说明见原版仓库;本仓库的增量在于换栈机制本身,见下两节。 ## 🧪 质量闸门(本仓库的核心方法) 换栈最大的风险是"看起来一样,其实不一样"。所以每个模块都必须过机器闸门——编号到 21, 其中 **1(对拍)与 2(会话互通)已在 P7f-2 随它们的宾语一起退役**,接班的是 1′(契约基线)、 19 的 §⑩ 与 20(演示模式)。第 21 道不是换栈期的差分闸门(它复算数,不比形状)。 每道闸门下面都写清了它守什么、怎么反证、以及**它现在不能守什么**: ```bash # 1) 对拍(P7f-2 退役,脚本已删):同一请求打两栈,递归比键集 / 类型 / 数组顺序。 # 它的宾语是"两套实现"。删掉 app/api/** 之后 Node 不再应答任何 /api,这一跑就是 # 拿 Java 比 Java——满屏绿却不说明任何事,比红灯危险。 # 退役不等于"少了一道":它当年守的那一格由 1′(重放冻结下来的旧实现形状)、 # 19 的 §⑩(会话/设备/放行清单回库复算)、4 的 §5c(身份真的落到 DOM 上)三道接手, # 三道都不依赖对岸存在。要看它本来的样子:git show P7f-1f-b:scripts/parity.mjs # (历史上它的目标必须挑"两栈都挂了同方法"的 URL:打一个 Node 没有 GET 的路径如 # /api/series/{id} 会得到 node=405 java=200 的假失败——那是切流缺口,该由闸门 8 报。) # 1′) 契约基线:闸门 1 的接班人,Node 路由删掉之后仍然在的那道。 # contract/ 里每条是一个接口"应答长什么样"的冻结形状,check 拿活应答逐条重放。 node scripts/contract.mjs check # 打 PARITY_JAVA 重放(P7f-2 之后的日常跑法) node scripts/contract.mjs check --as=writer --only=article-paid-guest # 反证跑法:必须红 node scripts/contract.mjs freeze # 已经停用:它只会拒绝起跑,原因见下面 ⚠ # 为什么不是"把应答录下来逐字节比":值会随时间变(重力排序的先后、views、相对时间、 # 加热还在不在有效期),录值的基线会在第三天红给空气。所以基线守的是**形状**—— # 键集 / 类型 / 可空性 / 数组元素形状 / 嵌套层级,这些才是会被一次重构改坏的东西。 # 值的正确性归闸门 3/4/7/9/12,那些是回库重算再比对的,本来就比"跟上周的快照自己比"强。 # 两个例外是刻意钉住的值:套餐表四档(两栈各自硬编码的常量),以及付费墙那一组 # viewerUnlocked + md 行数/字数——因为**付费墙失效在形状上完全看不出来** # (bool→bool、string→string),纯形状比对会对一次裸奔的解密说 PASS。 # 反证跑过三条:拿作者身份重放游客的基线 → 红在三条钉住的值上;把基线里一个键删掉 / # 把一个类型改成 number → 红并精确指到 comments.[*].id 与 .nickname;复原即绿。 # ⚠ 基线的**出处**记在每一份文件里(frozenFromExecutor)。这 30 条记的都是 "node", # 所以它们的意思是"Java 必须符合旧实现的形状";check 除重放之外还多一项判据: # **30 条出处全部是 node**(31 项里的第 31 项)。为什么要把出处立成判据:Node 删了 # 之后再 freeze,基线意思就退化成"Java 必须符合 Java 上次的样子"——防自我回归仍然有用, # 但它不再是换栈正确的证据,而读的人分不清这两者。所以 freeze 现在直接拒绝写入 # (不是警告一声照样覆盖),要加用例得照需求手写一份 JSON 并自标出处。 # 2) 会话互通(P7f-2 退役,脚本已删):两侧各自登录,要求对方后端用自己的完整校验链 # 认下这枚 Cookie。8 项里有 6 项的主语是"另一侧"——那个主语没了。 # 剩下的两半各有人接:Cookie 经 rewrite 不被丢 → 闸门 6;"这枚会话到底算不算有效" # → 闸门 19 的 §⑩(签有效但库里已吊销 / 已过期 / uid 不匹配,三种姿势回库复算)。 # git show P7f-1f-b:scripts/interop-check.mjs # 3) 正确性专项:对拍只能证明"一致",证明不了"都对" node scripts/paywall-probe.mjs bo-20260911-1 # 按 匿名 / 运营 / 已购读者 / 登录但未购非作者 四种身份取样, # 覆盖 viewerUnlocked 的全部三个 SQL 分支,断言未解锁者正文行数 ≤ 6 node scripts/search-leak-probe.mjs bo-20260911-1 # 检索侧的纵深:拿**只出现在第 7 行之后**的词去搜,未解锁身份必须 0 命中 # (否则隐藏正文就是一个可无限探测的 oracle),有权身份必须命中且摘录 ≤120 字。 # 若所有身份都不命中,探针自己判负——四条"不命中"可能只是空话。 # 两道都拆成"绝对判据 + 差分之一列"(P7f-1b):对岸被切走/删除后差分项记 —,绝对判据照跑。 # 早先它们用 requireExecutor 硬要求 NODE 侧真是 Node,整前缀切流后直接拒绝起跑——那看着 # 严谨,实则会把唯一能判"两侧都错"的那半也一起废掉:**一道永远拒绝起跑的闸门等于被删掉**。 # 反证跑过:拿一个不存在的 slug 打付费墙那道,四个身份全部"取不到文章!!"并退出码 1 # (改版前它会打印"截断防线=不适用"然后报"全部通过"——文章根本不在,闸门却满意)。 # 4) 页面渲染不变量(P7e′ 改版):接口对拍管不到 Server Component 的进程内取数, # 而页面取数只剩 Java 一条路之后,"两条路互相比对"这个办法本身就失效了。 # 所以这一道换成断言:页面必须长成什么样,与另一套实现对不对着写无关。 # ⚠ 它同时补上了对拍看不见的一格——双轨期"两栈一起错"照样绿,断言不会。 node scripts/page-check.mjs # · **来源证明**(这一条是整道闸门的骨头):首页列出的每一篇文章都必须落在 # `SELECT … ORDER BY 重力公式 LIMIT 50` 的集合里。JAVA_BASE 没配时页面会安静地渲染 # lib/demo-data.ts 的演示文章——200、有卡片、有正文,只有这一条抓得住。 # 反证跑过:起一台 `JAVA_BASE=""` 的实例打它,第一项就红("只有 3 篇,页面像空壳")。 # · §9 是 P10 补的**生产形态**判据:根布局必须声明 `force-dynamic`,且没有页反过来写 # `force-static`。起因是这一期第一次真跑 `npm run build`——直接炸在 /study:根布局带着 # Masthead,每次渲染都 no-store 取一次会话,"哪一页都能在构建期定成静态"这句假设是假的。 # ⚠ 这一族缺陷**只在生产构建里显形**:`next dev` 不做预渲染,页面 200、有正文、闸门全绿, # 而 `npm run build` 退出。反证跑过:摘掉那一句 → dev 首页照样 200、闸门 4 恰好红 1 条、 # `npm run build` 再次退出在同一条上。所以这一道从此固定跑它三条源码判据(生产构建本身 # 太贵,不能每次闸门都跑一遍完整 build)。 # · 顺带一条现场踩过的运维事实:生产构建会**覆写 `.next/`**,而 dev 实例用的就是同一个目录 # (这就是 `next.config.mjs` 里 `NEXT_DIST_DIR` 存在的理由)。build 之后 dev 实例的 # 第一次请求会 500,重新编译或重启才好——不是代码坏了。同一时间只让一个实例占 `.next`。 # · 文章页取库里真实的免费文与付费文:标题/昵称/正文长度必须到位;付费文对游客 # **不得出现最后一行正文**(取末行而不是中段,中段会连着 markdown 语法一起比, # 换个渲染器就误报),且定价必须亮在页面上。 # · 游客与登录者成对验:游客 /study 里不得出现写作者的稿子标题;写作者本人 /study # 必须认得出自己的稿子。两头都钉,中间才不是假绿——这条的来历是本项目真实踩过 # 把 cookies().get().value(只有值)当 Cookie 头发给 Java,Java 认不出会话就把作者 # 和已购者一律降级成游客付费墙,而页面仍然 200。 # · `/random`(漫游记)每次抽不同文章,逐字与条数都不是判据:抽 10 次验三件事—— # 必须 307 到一篇文章、抽到的每篇都要在公开列表里(随机池漏进草稿/未过审只有 # 回查列表看得见)、10 次里必须出现过不止一篇(ORDER BY RAND() 写丢每次同一篇)。 # · 单实例即可跑(打 PARITY_NODE,默认 3200);`--login=test|writer|probe` 只跑一个身份。 # · §5b(P7f-1f-a 加):那四条"页面就地 SQL"迁走之后,**端点算得对不等于页面看得见**—— # 页面接错一个 prop,十九道判据全绿而面板是空的。所以这一格断言的是 DOM:运营台顶部 # **八格按 DOM 顺序逐个**等于复算值(分组符钉 en-US)、/me 的注册日、/points 的余额位 # (``,它不带千分位)、/security 的 `audit-tag` 条数,外加整页 # 不得出现 `Invalid Date`。反证注入两处:把 stats 换成兜底假数 128/342/… → 八格立刻红; # 把 balance 写成常数 → 三个身份那条都红。 # 这一格的**第一版**写成"四个计数里在 DOM 找到至少一个",同一条注入下**照样绿** # (库里 users=8,而页面上别处也有 8)——"至少命中一个"是一句没有宾语的判据。 # 配套地,`skipped()` 从此记第三态(SKIP)而不是混进 PASS,合计行单列。 # · §5c(P7f-1f-b 加,**50→64 项**):渲染层不再自己摸库之后,"我是谁"只剩 Java 一处判定, # 这一段钉的是它与 DOM 之间最后那一寸,两个方向都钉: # ① **认人**——书房页那句「昵称 · 墨仓 N 点」必须是**这个人**的昵称与**这个人**库里的余额 # (三人成对互不串味、游客谁的昵称都看不到)。为什么必须成对:`remoteCurrentUser` 一旦把 # 失败吞成 `null`,全站每个页面都会安静地渲染成游客态,而"200、有内容"这类判据一条都不会红。 # 反证就是这么做的:把那个函数改成"问完就 return null" → 这一道 **30 项红**("绝不静默回落" # 这条规矩第一次被机器看见)。 # ② **页面会去发**——`/points` 里那句 `grantDailyQuota` 删了,发放挪进 `GET /api/auth/me`; # 闸门 19 打端点证明"会发",可**打端点会发不等于打页面会发**。所以这里把当天置成未发, # 只渲染一次首页(首页一个墨仓控件都没有,只经报头走一次会话解析),再回库里看它发没发: # 余额 +30、落一条流水、`last_quota_date` 记到今天,三项齐了才算"访问任意页自动入仓"成立。 # 夹具是写的,按余额/发放日/流水 id 精确回滚并复核残留。 # 两处口径要记:昵称比的是**剥过标签的可见文本**(JSX 会把相邻表达式渲染成 `墨仓 3 点`, # 拿正则去原始 HTML 里找数字必然空手——第一版就这么红过一次);余额在渲染**之后**读, # 当天第一次访问会顺手发 30 滴,渲染前读到的旧值不是页面的错。 # · ⚠ 它读的是**当前库里有什么**:换种子库、清过库之后要重跑,断言里的"那篇付费文" # 是按条件从库里挑的,不是写死的 slug。 # · 退役说明:原来的 page-parity(起两台实例、一台 DATA_VIA_JAVA=0 一台='*', # 比可见文本/链接序列/结构计数逐字一致)随页面只剩一条取数路一起删掉。 # 它当年按"27 个分流点各有哪个页面命中"来挑页面,那个映射表也就不需要了—— # 现在每个读函数就是一句 remote 调用,读代码即读映射;要守的是新规矩: # **加一个页面取数点,就给这一道补一条能命中它的断言**,而不是再加一个开关。 # 5) 认证流程闸门:验证码与会话必须"一侧签发、另一侧消费",整条生命周期跑通才算成立。 node scripts/auth-flow-check.mjs # 一侧签发→另一侧注册 / 一码两吃被拒 / 假码停在验码分支 / 一侧生成密钥→另一侧校验开启 # →两侧都要二因子→备份码一次一毁→另一侧关闭 / 改密保留当前会话并下线其他设备…… 25 项 # ⚠ P7f-2 的改判:这道原本的骨头是"两套实现互认"(两栈共用 email_codes / sessions 表, # 哈希口径各写各的,只有交叉使用才暴露走岔)。现在只剩一套实现,脚本**开跑先自报** # 落在哪一档(executorOf 探两个端口由谁应答),于是同一条用例在两种环境下名字不同: # 跨栈时叫"Node 签发 → Java 注册",同一执行者时叫"入口3299 签发 → 入口3199 注册"。 # 这不是修辞:一条听起来像跨实现验证的绿,冒充的是它没有的那个宾语。 # 已经不由这道证明的两件事:口令哈希跨实现互认(只剩一个哈希器)、TOTP 两份实现口径 # 一致(只剩 Java 一份;脚本自己那份 RFC 6238 留着,它现在是"脚本 vs 被测方"的对照)。 # ⚠ 本仓库 .env 里 SMTP 是**真实配置**,直连跑会真发信(每发一次 send-code 就真出一封邮件)。 # 必须另起一对"邮件降级"临时实例,跑完即停: SERVER_PORT=3199 SMTP_HOST="" mvn -f server/pom.xml spring-boot:run & JAVA_BASE=http://localhost:3199 NEXT_DIST_DIR=.next-mailtest \ node node_modules/next/dist/bin/next dev -p 3299 & PARITY_NODE=http://localhost:3299 PARITY_JAVA=http://localhost:3199 \ node scripts/auth-flow-check.mjs # SMTP_HOST 一空,Java 侧就走 dev 降级通道(打印 + 在应答里回显 devCode),脚本才拿得到码; # 拿不到码时它每条都停在"取不到码"上判红——那是环境前提不满足,但绝不判绿。 # 临时实例若用了 NEXT_DIST_DIR,会改写 next-env.d.ts / tsconfig.json 指过去,提交前记得 revert。 # 6) 切流代理路径:浏览器只见 Next 地址,/api/* 全靠 middleware rewrite 转发。 # 会在这条路上丢东西的三样是 Set-Cookie、请求体、边缘安全闸口——直连测不出来。 node scripts/proxy-cutover-check.mjs --base=http://localhost:3299 # 加 --money / --community / --admin / --study / --import / --ai 逐面追加经代理的证据, # 每一面都只挑"不改账、不扣墨、不问真上游"的入口(AI 那一面若发现实例接上了真 key 直接判红)。 # X-Backend: inkstack-java 由 Java 过滤器打上,是"这条请求确实落在 Java"的唯一硬证据; # 并断言跨站 Origin 的写请求在边缘就 403(安全闸口不随切流下沉)。 # ⚠ P7f-2 的改判:这道原来有两种跑法——`--keep=<前缀>` 指一个"名单外的 /api 前缀"证明它 # 没被顺走,或 `--keep-render` 把负断言改指渲染层。app/api/** 删掉之后第一种**没有落点**: # 不存在由 Node 应答的 /api 前缀,那条断言只能恒红或恒真,两者都不叫判据。所以现在只剩 # 一种形状、也不需要开关:① /api/* 每个入口都落在 Java;② / 、/hot、/archive、/feed.xml、 # /sitemap.xml 仍由 Next 出;③ /apiXYZ/* 没被顺走(抓 startsWith("/api/") 退化成 # startsWith("/api") 那种写法——Java 会回一个带 x-backend 的 404,正落在这条上)。 # 传 --keep / --keep-render 会被拒绝起跑(退出码 2):静默忽略两个失效开关,会让下一个人 # 以为自己按老姿势跑过了这一道。 # 7) 资金闸门:对拍只能比"读到的东西",钱要的三件它表达不了—— # ① 同一笔写一侧执行、另一侧看得懂(已购/已打包/已签/已付 的幂等分支跨栈一致); # ② 六路并发双击两栈不打折:账务只动一次,败者拿到"已成交/余额不足",绝不是一路 500; # ③ 增量精确:Δ余额 = ΔΣ流水 + 脚本手工注入的量,且 reason / 金额 / 明细行等于分账公式的结果。 node scripts/money-check.mjs # 解锁 70·30、打赏 90·10、打包按 floor(price/n) 比例分账且余数归首条、加热 80/24h、 # 签到 7 天周期 10/10/20/10/20/10/40、集章 100、充值四档——七个环节 + 账实核对共 100 项。 # ⚠ 会真扣真加真删:必须确认 DATABASE_URL 指向克隆库,跑完 finally 自动清场(并自检没留东西)。 # --keep 保留现场排查。 # 这一道闸门抓到的真 bug:六路并发签到 Java 返回 500——同主键并发插入 InnoDB 未必给 # DuplicateKey,也可能给死锁回滚,Spring 翻译出的就不是 DataIntegrityViolationException。 # Node 侧同样有这个洞(dev 模式编译串行化掩盖了它),两侧一并补了"锁冲突重试三次"。 # ⚠ P7f-2 之后 PARITY_NODE 与 PARITY_JAVA 背后是同一个执行者(middleware 见 /api/ 就转发), # 所以六处「并发:确实两栈各答了一半」**恒记 SKIP** 而非 PASS——它断的是并发来自两个进程, # 单栈时这个前提没了("只一路成交"可能只是一把进程内锁的功劳,也可能真是行锁, # 这一跑不再能区分)。其余判据一条不少。这不是把红改成灰蒙过去:那道判据由 gate-executor # 的 isCrossStack 探出来,**谁把某条链路重新指回另一套实现,它就自动翻回 PASS**, # 不需要人记着去改脚本。P7f-1 演练时反证过:打真 Node 的那一档 100/100 零 SKIP。 # 8) 路由盘点(P7f-2 改版):前端与渲染层点得到的每一个 /api URL,Java 侧必须有路由接得住。 node scripts/route-inventory.mjs # 三条判据,红就退出码 1 node scripts/route-inventory.mjs --json # 机器可读 # 为什么改版:原版算的是**两栈差分**——逐个 URL 模式核对"Node 有的方法 Java 是不是也有", # 再算出"当前可以整体写进 JAVA_ROUTES 的最长前缀"。那个问题的前提是"切流还没切完"。 # app/api/** 删掉之后"Node 有什么"这个集合恒等于空:缺口恒 0、方法集合恒等、可切前缀恒为 # /api——三条判据同时失去宾语。**一道任何错误都无法让它红的闸门留着,只会伪装"还在守"。** # 所以把它想证明的那个从来没变过的命题留下,换一条算得出来的判据。现在三条: # §1 调用点覆盖:AST 扫 app/**(不含 app/api)、components/**、lib/**,收出 107 处调用点, # 逐个要求 Java 有同 URL 同方法的路由(方法认不出来的按 URL 比,不许藏)。 # 四层规则:fetch(字面量,{method}) / 本文件里"把参数转手给 fetch"的辅助函数(post()、 # ask() 那一类)/ JSX 的 href·action·src 与 location.href=(浏览器一定发 GET)/ 兜底 "?"。 # §2 遗留 HTTP 面登记表:app/api 下的 route.ts 必须与 LEGACY_ROUTES 逐条相等。 # 删路由与清空这张表是同一个提交——中间那一步会红,那个红就是它在工作。 # 现在是空表,但它改守反向:谁再往 app/api 下建一个 route.ts,就是往已拆掉的 # 第二个后端里回填代码(闸门 18 同款)。 # §3 middleware 不许再读 process.env.JAVA_ROUTES:/api/* 的改写与任何名单无关。 # 三条都反证过:加一处 fetch("/api/nope/nothing") 与 fetch("/api/articles",{method:"DELETE"}) # → §1 两条分别红在"没有这条 URL 模式"与"该 URL 只有 [GET,POST]";建一个 app/api/ping/route.ts # → §2 报"新面孔";往 middleware 里补一句 process.env.JAVA_ROUTES 的读取 → §3 红。复原即全绿。 # ⚠ §3 判的是"还读不读这个开关"(正则钉 process\.env\.JAVA_ROUTES),不是"文中有没有出现过 # 这个词"——注释里还得留一段话解释它为什么退役,拿字符串命中当判据会把那段说明也判成违规, # 那种红只会逼人删掉解释。 # ⚠ 比对前会把 Java 的 `{provider}` 这类通配段与字面目录(原 app/api/auth/github)对上, # 否则已迁完的 OAuth 会被报成缺口——清单一旦开始说谎,就没人再信它。 # ⚠ 用 TypeScript 的 AST 而不是正则:字面量藏在 JSX 属性里、模板串的 ${} 里、 # 或者藏在注释里(闸门 18 就栽过"注释骗过正则")。 # 9) 社区互动闸门:评论/点赞/收藏/关注/举报/站内信这六条链路,各有对拍表达不了的洞—— # 奖励日上限"拒了不能吞额度"、举报防重靠的是锁而不是唯一键、并发 toggle 后计数要与关系行守恒。 node scripts/community-check.mjs # 九节 108 项:跨栈看得懂(一侧发的评论另一侧读得到、一侧标的已读另一侧未读数跟着降)、 # 六路并发两栈零 500 且关系行至多一行、like_count = COUNT(article_likes)、 # 游客也能发的评论其昵称裁剪与全角空格判空两侧同口径。 # ⚠ 一次约 150 个请求,贴着边缘限流(每 IP 120 次/分)的上沿:连跑要隔一分钟,否则红一片 429。 # (抬档位用 EDGE_API_LIMIT,见 .env.example;NODE 侧整前缀切走时五处"两栈各答了一半"记 SKIP, # 同闸门 7 的理由。) # 10) 创作台闸门:发布/编辑/撤回这条链路的三个洞,读侧对拍一个都盖不住。 node scripts/studio-check.mjs # · 中文标题一律回退成 bo-日期-1 这种**可枚举且必撞**的 slug,8 路并发同名发布必须换号重试; # 原实现"SELECT 判重 → 裸 INSERT"是 7×500,用户以为没发出去、重试即重复稿。 # · 审核归属(普通用户 pending / 运营 approved)只能来自 Cookie 里的 role; # 闸门会往请求体里塞 reviewStatus/role 验证它无效。 # · 草稿是硬删:八张子表按 article_id 在同一个事务里清完才删主行, # 分条自动提交就会留下"稿子还在、点赞却被清空"的部分删除。 # · 早鸟折扣的入参时间走 JS `new Date(串)` 的口径解析(无时区的日期时间 = 本地时区), # 两侧对同一个 datetime-local 串必须算出同一个 UTC 瞬间,否则早鸟到点差一个时区。 # 11) 运营台闸门:这一批的洞不在"算得对不对",而在权限与状态机。 node scripts/admin-check.mjs # · 门禁逐条接口都要 401/403 两栈齐全:少一个,运营台就成了任何人都能调的写接口; # `setRole` 更是 developer 专属——前端隐藏下拉不是防线,admin 打过去也必须 403。 # · 状态机要能回退且不留僵尸态:pin 是 toggle(跨栈同一套 1-pinned 语义)、 # 下架必须连带 pinned=0/featured=0、通过审核要把旧的驳回说明清成 NULL。 # · 扣回点墨按**真实余额变化**记 `applied`:原写法 `GREATEST(0, …)` 会留下 # "账记 -10000、余额只掉 60",`point_ledger` 求和与余额从此永久对不上。 # · `{slug}/raw` 认作者与运营、`{slug}/export` 认付费墙(未解锁 402):这两个是全文出口, # 少判一句就是可以无限拉全文的洞。夹具正文刻意排到第 9 行——读侧在 SQL 层只留前 6 行, # "导出的是全文"这句话只有这样才能被断言。 # · 导出内嵌绝对链接:两侧各用自己的 origin 拼接,比对前必须抹掉站点地址; # 经代理那一条反过来断言 `:3101` 绝不出现(见闸门 6 的 x-forwarded-host)。 # 12) 书房闸门:这批的洞在"门禁姿势不齐"和"字符串口径",不在算术。 node scripts/study-check.mjs # · 三条反直觉的姿势必须原样照搬,"顺手统一"就是一次静默的接口变更: # 友链 GET/PUT 对游客回 **403**(不是 401)、足迹上报对游客回 **200 skipped**、 # 外链审核不存在的 id 照样回 ok(Node 不判 affectedRows)。 # · 标题裁 200 但**不 trim** 去查、正文**不 trim** 去存、昵称却必须按 JS 的空白判据折叠: # "张  三"要折成"张 三",Java 的 `\s` 与 `trim()` 都不认全角空格,一处用错两栈就分叉。 # · 上传两栈必须写**同一个磁盘目录**:Java 的 cwd 是 server/,配错的表现是 # "上传成功、URL 回来了、图片 404",而两侧各测各自的都发现不了。 # · 重设专栏篇目是整单语义:混进一篇别人的稿就整单拒、且原柜子一篇不少 # (DELETE 必须排在校验之后、同一个事务里,先删后插就是"双击保存把柜子清空")。 # · 坏 JSON 与空对象是**两个不同的 400**("请求格式有误" vs 业务提示), # 用 `Bodies.json()` 一把兜成空对象就把其中一条分支抹掉了。 # · §7.5 钉的是"一条 URL 只干一件事":`GET /api/series` 是公开架(匿名 200、不带 `ok` 键)、 # `GET /api/series/mine` 是我的柜子(匿名 401 而不是空列表)。这两句合起来才是那次拆分的 # 验收——只测"两边返回一样"会放过最坏的那种错法:把两条都改成同一个意思。 # 参数与 id 的取值形态也逐条比字节(`limit=0`/`abc`/`9999`、`author=0x2`/`2.5`/`-1`、 # `id=abc`/`2.5`),因为这类解析在 Java 侧一旦图省事用带类型的 `@RequestParam int`, # 对岸就是 400 加一段带时间戳的默认错误体——两台机器都没 bug,只是不再相同。 # · ⚠ 会改联调账号的昵称与口令:清场按快照直接写回 `password_hash`, # 不走接口——那个密码在 HIBP 泄露名单里,接口拒它是对的,但不是清场该有的姿势。 # · ⚠ 同样吃边缘限流(每 IP 120 次/分):连跑要隔一分钟,否则后半程红一片 429。 # 13) 迁移工具闸门:全平台唯一一处"用户给地址、服务端替他联网",防护本身就是被测对象。 node scripts/import-check.mjs # · 私网有十几种写法而它们必须是同一个结论:`new URL()` 会把 2130706433 / 0x7f000001 / # 0177.0.0.1 / 127.1 / `127。1`(全角句号)全部规范成 127.0.0.1。用 JDK 自带的 # `java.net.URI`/`URL` 一种都不认——它们会把这些串当"域名"丢进 DNS,而操作系统照样按 # IPv4 连出去,黑名单整条失效。Java 侧因此自带了一个 WHATWG 口径的解析器(NodeUrl)。 # · 编写这一道闸门时抓到的真实绕过:`[::ffff:192.168.1.2]` 被 WHATWG 压缩成 # `[::ffff:c0a8:102]`,于是 Node 原实现"取 `::ffff:` 之后按 IPv4 正则判"拿到的是十六进制串、 # 不命中就放行,实测 200 打到了本机网卡。两侧现在都先解析成**字节**再判(闸门 13 / 2 节)。 # · 重定向必须手动逐跳:夹具用一个公网首跳 302 到 127.0.0.1,第二跳要拿到同一句拒绝—— # 这一条需要外网,跑不到时记 SKIP 而不是 PASS(离线是唯一让它闭嘴的合法理由)。 # · 产物比的是**库里每一列**,而且是"两栈各清一遍各导一轮":两个栈往同一张表写, # 同一轮里读两次只是把同一行读了两遍,连"Java 把正文写坏了"都报不出来。 # 消毒那七条正则的期望结果是**手推**出来的常量,否则两栈一起错就一起绿。 # · 日期是 RFC 1123 的天下:`Wed, 23 Sep 26`(两位年)、星期名写错、缺时区、`+0800`, # V8 全认;再加上 mysql2 按驱动本地时区绑定 Date、目标列 DATETIME(0) 会把 .700 进位成下一秒。 # 四件事凑一起,"两侧读回同一个瞬间"必须逐条钉,否则早鸟到点那种口径错会原样复现。 # · ⚠ 需要一对 `IMPORT_ALLOW_PRIVATE=1` 的临时实例(夹具订阅源必然在 127.0.0.1 上): # 常规那一对照旧拒绝所有本机地址,抓取成功类用例一条都跑不到。会真建真删文章,跑完自动清场。 # 14) AI 面闸门:这一道测的不是"文字好不好",而是**钱只在真实产出那一刻动一次**。 node scripts/ai-check.mjs # · 计费口径是"只读探针预检 → 上游确认可用之后才扣"。原实现是"先扣后生成、失败再退分", # 退分一失败就永久丢墨,模板兜底也照扣(文案还谎报"已退回")。所以顺序本身是断言: # 探针拦住 → 上游必须收到 **0** 次请求(否则用户白等、平台白烧 token); # 上游空白/500/非 JSON → 落模板、Δ余额=0、Δ流水=0,三种坏态 × 两栈逐个钉。 # · 四段演示模板是 Node 的字符串常量,Java 重抄一遍:前导 `\n` 少一个肉眼看不出来, # 而这段文字直接给读者看,所以按逐字节比。Java 侧刻意不用 text block——它会做缩进剥离, # 正好会把"少一个换行"这件事藏起来。 # · 六路并发只给两次成功:探针**不是**防线(六路同时通过预检),FOR UPDATE 才是。 # 余额 40 → 10,四路 402、零路 500。 # · 类型收口看的是**上游收到的字符串**:`{draft: 5}`、`{author: ["甲","乙"]}` 这类 body 合法存在, # 两栈必须各自 `String()` 成 "5" / "甲,乙" 再转发(数组按 join(",")、对象成 [object Object])。 # 这些用例一律挂在 garbage 档上跑:断言的是请求体,不该真扣墨,也就不会打扰收尾的账实核对。 # · 收尾一律按快照复原,且闸门自己垫本:上一批被杀在"把余额强设成 3"那一步之后, # 账号就永久停在 3、下一批直接跑不动(真踩过)。垫的钱不落流水,否则账实核对那条 # "本次没有别的 reason 偷偷记账"会被自己的垫本打红。 # · 分身问答(NDJSON)三条通道各有各的"钱停在哪":透传档要等上游 2xx 才扣、live 档要等 # DeepSeek 给出 200 响应头才扣、演示档从头到尾不动账。所以上游坏在哪个时刻,断言就下在哪个时刻: # 503 → 一条 error 帧 + 零扣墨;吐两帧再把连接掐了 → 那两帧照给读者、末尾一条 error 明说 # "本次已按成功计费"、且**没有** cite 帧。中途炸掉那种"钱扣了货没给全"是诚实交易, # 装作没发生才是问题。 # · P9 起还有第四种坏态:**活着的挂起**(头给了、第一帧也到了,然后一个字节都不再吐、也不关连接)。 # 它与"连接被掐"是两种失败——后者 read 当场抛,前者谁也不抛:JDK 的请求超时只 bound 到响应头, # 而旧代码那句 deadline 判断排在 read 之后,一次卡住的读永远走不到它。夹具因此有两档 `stall` # (透传发**半帧**就卡住 / SSE 吐两帧就卡住),判据钉三件事:短档内自己收干净、末尾那一帧是 # error、那半帧与 error 是**两行**(不补换行就会黏成一行,读者连"卡住了"都拿不到)。 # ⚠ 这一档要那一台带 `--inkstack.agent.stream-timeout-ms=2000` 起;不带就是默认 60 秒档, # 5 条红全落在这两档上、detail 写着 `25016ms/掐线=true`——红得指得出是哪条启动参数缺了。 # ⚠ 计时判据都配了一条宾语判据(夹具自己数收到几次提问):上一轮就是靠它抓到夹具的 # `agentMode !== "ok"` 把新档吃掉、请求落回 live 通道,那时计时判据正绿给空气看。 # · SSE → NDJSON 的转换器用 7 字节一块的夹具喂:一定会切在多字节字符中间、也会切在行中间。 # Node 是"增量解码后按行切",Java 是"按字节找换行、整行解码"(\n 不可能出现在 UTF-8 # 多字节序列里,所以两种写法必须给出同一个产物),夹具里还混了注释行、非 JSON 行、 # 空 delta 和 [DONE]——一帧都不许漏给读者。 # · 三条通道的模式判据来自两个开关,所以这一道要**两对**临时实例:一对接夹具(假 Key + # `DEEPSEEK_BASE_URL` 指到夹具自己的 `/chat/completions`),一对什么都没配(演示档,游客可问)。 # ⚠ 假 Key 与夹具 base 必须成对出现:只配 Key 不配 base,"上游 503 不许扣墨"这条用例 # 每跑一次就真向官方 API 发一次真请求、真扣一次墨。 # · 问答会往 `agent_qa` 落流水(三条通道各落各的形状),清场按 id 区间一起删——它进统计与成就。 # · P8a 起多了 §10「文章身份归因」14 项,判据的宾语是**库里的两行**:`agent_qa.article_id` 与 # `articles.agent_qa_count` 必须永远相等(一个是流水、一个是它的计数)。反证三组各红各的: # 摘掉 `bumpQaCount` → 红 3 条(+1、六路并发、160 边界);mapper 不再看 `status` → 精确红在 # "指向草稿的提问"那一条;把那条 UPDATE 指向一张不存在的表 → 红的 detail 变成 `行数=0`, # 也就是**流水随失败的计数一起回滚**,而读者的回答照旧 200 拿全文——"吞的是整笔不是半笔" # 第一次有机器证据。160 字这条边界只能这样验:建一篇 slug 恰好 160 字的夹具,再传 250 字 # (前 160 字正是它)才认得到,不裁与多裁一位都认不到。这一节建三篇夹具文章、跑完删干净。 # · 补一句实例数:上面说"两对"是 P6b 的话,P8a 之后是**三对**——① 常规那对(AI 配置为空) # 只跑 `/api/ai/write` 的兜底与入参校验,② 接夹具那对跑透传与 live(P9 起还要带 # `--inkstack.agent.stream-timeout-ms=2000`,否则 stall 两档测不动,见上),③ 什么都没配那对跑演示档。 # 少一对就有整段用例跑不到,所以闸门开跑先把每一对由谁应答打出来。 # · ⚠ 需要一对接了上游夹具的实例(`AGENT_SERVICE_URL` 指向闸门自己的 127.0.0.1:4601), # 否则"上游真产出 → 真扣墨"这一档跑不到,而那正是唯一会动钱的地方。 # · ⚠ 绝不允许拿真大模型跑这道闸门:本机的 `DEEPSEEK_API_KEY` 在 shell 环境里而不是 `.env`, # 闸门用的两栈实例都不带它,模式判据因此写成"两栈一致 + 上游不在场就绝不报 agentscope", # 而不是硬编码 `demo`——硬编码在有 Key 的机器上会假红。 # 15) 引擎闸门:Java 侧 Spring AI 智能体顶掉 Python 的 agent-service。这一道**没有对岸可比** # (引擎只存在于 Java 侧),所以全部换成绝对判据。 node scripts/agent-engine-check.mjs # · 先证明"检索真的跑了":模型第一轮拿到分身 system prompt 与工具清单,第二轮才带着 # role=tool 的消息继续。少一轮就说明 ReAct 被写成了单次直连——那才是换引擎最容易偷偷降级处。 # · 再证明语料带两道闸门。判据必须成对:先用 Python 原版那条**缺防护**的 SQL 查出 # "未解锁的付费正文"和"未过审稿"本来会进检索集,再证明引擎发给模型的那份里没有它们。 # 只写后半句是空断言——检索集里本来就没有它,任何时候都绿。 # (哨兵刻意用稀有中文串:用 FREE-SENTINEL 这类写法时 ngram 二元组会命中库里现成文章, # 三篇夹具稿被挤下 LIMIT 3,前面那句"本来会进检索集"就查不出来了。) # · 契约与手感:delta 拼回去必须等于模型的整段回答、末尾恰好一条 cite、cite 是从 # 「依据:《…》」那行抽出来的、切块宽度仍是 Python 的 max(1, len // 40)。 # · 钱:探针拦住 → 模型收到 0 次请求;引擎 502 或只回空白 → 落演示档、Δ余额=0; # 成功一轮才有一条「分身问答」流水。写作档同理(坏态落模板、零扣墨)。 # · 卡住的上游(P11):夹具两档——`loop`(模型永远只回 tool_calls,永不给最终答案)与 # `drip`(响应头先到、正文每 1.2 秒一块慢慢吐)。这一族判的是**整串往返**有一个绝对截止: # 引擎那条通道上一次问答是**好几趟**上游交换(每轮工具调用一趟),"每趟各有一个读超时" # 乘出来就是没有上界(把截止摘掉实测 25 秒还没停、833 轮,正文慢慢滴那一档 150 秒还在正常完成)。 # 两档各配一个宾语:`loop` 判"模型至少被敲了两轮"(证明它打在工具循环上), # `drip` 判"整档只敲了一次模型"(证明它打在单次往返上);两档都要零扣墨、拼回是**完整的** # 演示回答而不是半截。最后一条判"掐掉过两条卡住的上游之后,下一轮照常出完整回答"—— # 那个截止登记在 ThreadLocal 上,而 Tomcat 的工作线程是池里复用的,忘了摘就是给 # 下一个读者预备的僵尸截止。 # · ⚠ 需要一台只开引擎的 Java 实例(`--inkstack.agent.engine=spring-ai`,同时把 service-url # 与 deepseek-key 留空):那样"引擎失败之后落到哪一档"才是确定的演示档,不会去敲某台真服务。 # 夹具是一个本地 OpenAI 兼容端点(默认 4702),假 Key 配假地址,永远碰不到官方 API。 # 还要带 `--inkstack.agent.stream-timeout-ms=2000`:那三档 stall 判的是"这一次请求自己 # 在短时间内收干净",停在默认 60 秒档上判据只是在等客户端掐线(红的地方就是缺这条启动参数)。 # · ⚠ 会真建三篇文章、真扣墨、真写 agent_qa,跑完按快照与 slug 全清;全文索引刚写完可能查不到, # 闸门遇到这种情况直接停下提示重跑,而不是把检索类用例静默跳成绿。 # 16) 建库闸门:Java 进程自己把库建出来,且建出来的形状 == Node 那套 DDL 全跑完的形状。 # 双轨期建库是 Node 的 ensure* 顺手做的;P7 删掉那些 SQL 之后「谁来建库」只剩一个答案, # 所以现在就得有人证明这个答案够用——而不是等删完才发现少一张表。 node scripts/schema-check.mjs # · §0 先钉归属(静态扫描,不连库也不启进程):lib / app / components / middleware 里不许出现 # 一条 DDL,连注释与模板串里藏着建表 SQL 都算红。判据不是「跑起来没报错」,而是 # **表结构只有一个地方写着**:两处各建各的表时两边都绿,而「新库长什么样」 # 取决于第一个敲到接口的请求走的是哪栈。注释也扫,是因为藏在注释里的 DDL # 会在下一次改代码时复活。 # · §1–§5 测的是**进程**不是脚本:闸门自己 CREATE 三个临时库、真跑 mvn spring-boot:run、 # 等它应答——「脚本看着对」和「启动时真的会跑、跑完还能服务」之间隔着打包、 # Bean 初始化顺序与幂等与否,这些都没法靠读代码确认。五个环节每一环都是拿**真库**比的: # ① 对着一个空的临时库 `spring-boot:run`,起来之后 `GET /api/articles` 必须是 200 # (表不存在时这里是 500,所以这一条同时钉住了「200 是建库建的」); # ② 逐表、逐列、逐索引、逐外键与「手工应用 db/schema.sql」的参照库对 information_schema, # 再确认覆盖运行库 inkstack_j 的每一张表——少一张就是 P7 删完才炸出来的洞。 # 比的对象是 information_schema 里的类型、可空性、默认值,不是「看着差不多」: # DEFAULT 0 与 DEFAULT '' 差一个,「这行算不算已扣墨」就可能两栈不同。 # 外键连 DELETE_RULE 一起比:级联与 RESTRICT 是「同一条外键」的两种不同东西, # 只比名字与两端的话,删同一篇文章会一侧成功、一侧 500 而结构比对全绿。 # 本节最后两行是 P7c 删懒迁移的正面依据:把**运行库**的每一条索引、每一条外键(含级联规则) # 反查回 Java 建的库。前面那些行比的是 Java 与 schema.sql——同一份定义的两个执行者, # 对不出「定义本身漏了什么」;而运行库是被 Node 那批 ensure* 一路改出来的, # 它身上有、脚本给不出的东西,就是删掉懒迁移之后新库永远缺的那一块。 # ③ 新库里 0 用户 0 文章:schema.sql 末尾那两条演示 INSERT 归 scripts/seed.mjs, # 不归每次启动。建库只执行 CREATE TABLE / ALTER TABLE,别的语句一律丢掉。 # ④ 同一个库再起一次:仍然 200,且表/列/索引一个没变(幂等,不是先删再建)。 # ⑤ `INKSTACK_SCHEMA_AUTO=false` 对着空库起进程:一张表都不建,且接口确实应不上—— # 这一条是反证,缺了它上面那个 200 就可能是别的什么顺手建的。 # · 共 18 项。跑之前确认 server/target/classes 是最新编译产物(闸门自己比 mtime,不新就红)。 # · ⚠ 这道闸门自己会 `CREATE DATABASE`(三个临时库),所以要拿有建库权限的连接跑, # 并且**必须**在 finally 里把临时库 DROP 掉。⚠ 程序化应用 `db/schema.sql` 前必须先剥掉 # 开头的 `CREATE DATABASE …` 与 `USE …`,否则会话被带走、脚本往生产库里建表 # (这一条是被一次真实事故换来的,见「迁移进度」一节末尾那两条教训)。 # · ⚠ 参照库只读。闸门全程不许对 inkstack / inkstack_j 写任何东西。 ``` ```bash # 17) 限流共享:登录 / 改密 / 2FA / 发码的失败计数只有一本账,哪个入口来的都记在这本上。 node scripts/rate-limit-check.mjs # · 这道闸门不是对拍,对拍抓不到它。限流的行为是"第 6 次要拒绝",而双轨期两栈各算各的, # 同一 IP 在 Node 记 5 次、在 Java 也记 5 次,两边都觉得自己没锁,第 11 次仍然放行—— # **单看任何一栈的日志都完全正常**。所以判据只能是"换个入口接着记":一侧记的数另一侧 # 立刻看得见、一侧清零另一侧跟着解锁。 # · P7f-2 之后这里的"两个入口" = **经 Next middleware 代理** 与 **直连 Java**,不再是两套 # 实现。命题从来没变(计数只有一条路径),变的是反例还能不能存在:现在只剩"有人往进程里 # 塞回一个内存桶"这一种走法,而 §⑤ 钉的正是它。开跑前置也跟着翻了面:从前要求 # "打到 Node 端口那一发**不许**带 x-backend"(带了说明对岸被切走、这道闸门的宾语没了), # 现在要求**两个入口都带** x-backend——这不再是防线,而是这一跑成立的前提。 # · ①max=5 的桶按两个入口交替记:每次的"还可尝试 N 次"都必须精确, # 第 5 次当场就锁(先记再判),随后另一个入口 429、行数是 5、两入口 Retry-After 相差 ≤3 秒、 # 被锁期间的请求不再往账本里加行(否则被锁的人会越锁越久)。 # · ②窗口判定用的是数据库的钟:把该桶的行推到 16 分钟前 → 必须解锁,且命中时顺手 prune # 掉这 5 条旧行(只剩刚记的 1 条)。反证跑过:只推 3 分钟仍然 429,说明这条不是 # "只要 UPDATE 一下就变绿";把表改名后登录照旧 401 而不是 500,说明库挂了是放行不是锁死。 # · ③成功登录的清零跨两个入口(一个入口记的失败,另一个入口登录成功要能抹掉)。 # · ④同一张表容得下不同上限:send-code 的 max=10 同样共用记账。这一段刻意用"已注册邮箱 + # purpose=register"打——两个入口都在记完次数之后才判重复,于是每次都 409 且一封邮件都不发, # 闸门还额外查了 email_codes 行数没变("闸门不打真邮件通道"这条纪律也要有证据)。 # · ⑤边缘闸(120 次/分/IP)**故意**留在进程内存:它每个请求都要过一次,搬进库等于给每个 # 请求加一次写,DoS 刹车就变成 DoS 放大器。所以 30 个请求之后必须一条 api:* 行都没有。 # 同段还钉住"计数只有一条路径":`lib/rate-limit.ts` **存在本身就是红**(P7f-2 之前这条 # 读的是那个文件的源码,现在那个文件已随 Node 的 /api 实现一起删掉,判据反而更硬), # 而 Java 的 LoginGuard 里不许出现 ConcurrentHashMap——留一条内存退路,下次改动就会退回去。 # · ⑥是反向围栏:什么都不带时两个入口解出的 ip 本来就不同(Next 注入自己看到的 socket 对端、 # Tomcat 落 local),同一个客户端会落进两个桶。生产由 nginx 写 x-real-ip + TRUST_PROXY=1 # 让两侧同源;哪天兜底被对齐,这条就该变红,提醒把它翻成正向断言。 # · 共 18 项。⚠ 计数落在库里就不再随进程重启而清零:连跑或换实例重跑时, # 某个桶可能还带着上一轮的命中(本闸门自己的桶进出都清干净,别的闸门没有)。 # 18) 页面直读 SQL 清单:把"web 退化到纯渲染层"这句话从一句说明变成一个数。 node scripts/page-sql-inventory.mjs --verbose # · 判据是**渲染层可达集合里任何直接执行 SQL 的函数**,不管它住在哪个文件:全仓 # app/lib/components 的顶层函数都成为节点(id = `文件#函数`),跨文件边由 import 表接上 # (含默认导出的 `default` 别名——页面组件清一色默认导出,只记本名会让最后一跳断掉), # **JSX 标签按一次调用算**(`` 不是 CallExpression), # 只要可达路径上出现 getPool / pool.query / beginTransaction 就算"还在 Next 进程里摸 MySQL"。 # lib/db.ts 是管道、app/api/** 与 lib/data-legacy.ts 是待删的遗留 HTTP 面,两者都排除在计数外。 # ⚠ 这三条排除在 P7f-2 之后**已经没有宾语**(三个路径都不存在了),但规则留着并说清为什么: # 它们不是"过滤掉一些噪音",而是"如果谁把这些文件重建回来,它们仍然不算渲染层的债"。 # 一条永不命中的排除不会造成假绿(它只会少算,而少算的对象压根不存在), # 而删掉这条排除会让"重建 app/api"变成红——那时候它是会响的。 # · 为什么差分闸门抓不到它:这些读**从来没有 HTTP 面**。运营台是 Server Component 直调 # lib/data.ts,/api/admin/* 那四条路由只有 POST(写侧)。没有对等可请求面,对拍、契约基线、 # 路由盘点三道就都没有它们的位置——三道全绿而"页面只剩一条取数路"这句话是错的。 # · 棘轮而不是进度条:登记表之外冒出新面孔 → 红;登记表里某条已经没有本地 SQL 却没删登记 → 也红。 # **今天登记表已清空(0 条)**:P7f-1d 迁 6 条、P7f-1e 迁 8 条、P7f-1f-a 迁 4 条、P7f-1f-b 迁 # 最后 4 条,四次都是"先从表里删名字让棘轮变红、改完才绿"——那个红一次就是它在工作。 # 清空之后它不退役,改守反向:谁把页面改回进程内 SQL、谁给 lib 加一句 `pool.query`,立刻红。 # · ⚠ 它读的是 **AST,不是运行时**,所以它守的是源码形状。反证时顺带抓到一件事:把 `/study` # 改回 `getCurrentUser()` 之后,那台**早已编译过这一页**的 dev 实例照样渲染出正确答案 # (Next dev 只在页面模块自身变化时重编译,而 `lib/auth.ts` 自己就是一条能用的取数路)。 # 运行时形状不由这一道管,由闸门 4 的 §5c 与闸门 19 的 §⑩ 管——三道各管一段,别混着说。 # · 清零是 P7f-2 的**开工条件**,不是它的副产品;反过来说,只要判据比它想证明的命题窄, # "清零"就只是一句好听的话——这一道为这件事红过两次(详见 P7f-1e′ 那一行)。 # · 反证跑过五个方向:① 往 listArticles 注一句 `await getPool()` → 报出 5 条,其中 # listHot/listRelated 标「经由 listArticles」(证明跑的是可达性闭包,不是逐函数看首行); # ② 新建一个页面、SQL 就写在页面函数里 → 抓到(旧版判据这一条**看不见**); # ③ 页面里只写 ``、SQL 藏进组件 → 抓到;把 JSX 那条规则单独关掉再跑, # ② 还在而 ③ 消失,说明这条规则有独有战果、不是顺手加的; # ④ 登记表塞一个没 SQL 的名字 → 报"已经没有本地 SQL 了"、退出码 1; # ⑤ 清零之后把 `/study` 改回进程内 `getCurrentUser()` → 报"登记表之外出现 1 条"、退出码 1 # (这一条是给"清零"做的复验:它红得和第一次那个假 0 一样干脆)。 # · 正则版曾经把 listWeekly 判成直连 MySQL——它下一个函数的文档注释里写着"先 INSERT 占位、 # 再 FOR UPDATE 扣款"。这一道用 AST 不用正则切正文,教训见下一节。 # 19) 页面读端点核对:给"根本没有对岸"的那十八读与一条写立一个裁判。 node scripts/pagereads-check.mjs # · 闸门 18 数清还剩几条,闸门 1′ 冻住已有 HTTP 面的形状,但迁过来的这十八读**两头都不沾**: # 没有 Node 路由可对拍,也没有 Node 时代的应答可冻结。删掉 lib/data-legacy.ts 之后, # "Java 这些算得对不对"将再没有任何参照物——所以裁判换成**自己回库把同一个数再算一遍**。 # · 284 项:十八条端点的应答方(x-backend)、全站四计数、作者榜逐行 + limit 前缀一致 + limit # 越界不 500、关注流逐行 + 游客/伪造会话必须 401、成就墙 14 枚逐字段(含门槛文案与千分位)、 # 领取状态对 point_ledger、专栏题名建议(含后缀轮换与「已有 N 篇」的数字)、 # 运营台八张表逐行(含 q 过滤那条 '%%' 恒真、`status` 过滤、审计 limit 的 `Number(x)||30` 兜底), # 以及八条运营台读的**门禁**:游客 401、伪造会话 401、登录但非运营 403 且文案不变。 # P7f-1f-a 再加四条(运营台总览九项计数 + 最近问答、账号资料五字段、墨水账户四块、 # 安全中心留痕与 2FA 开关),其中九项计数刻意拆成**九句各自查**、时间格式化在 JS 里重做一遍, # 都是为了让证人不同于实现(P7f-1e 那次"闸门与实现共享同一个 UNION 缺陷"不能再犯)。 # · §⑩(P7f-1f-b,**226→284 项**)管的是另一族:那四条不在页面里,而在全站共用的 lib 里, # 其中三条是读、一条是写。所以宾语也换了——这一段不比数值为主,比的是"认人认得对不对": # ⑩.1 会话五字段逐个与库里那行相等 + 三个身份"换一个人问就换一个人的 id"; # ⑩.1b 再登一次拿一枚新 Cookie、只把它的库状态改成已吊销 → 签名依旧有效也必须是游客 # (双保险里最容易被省掉的那第二道;不做这一步,"只验签名"的实现照样全绿); # ⑩.2 设备列表按 `last_seen_at` 复算,"本机"那一格由闸门自己算 `sha256(令牌)` 判定, # 并加一条"有且只有一台被标成本机";⑩.3 放行清单用三行夹具(大写 approved / pending / # rejected)当宾语——这份库的 `approved` 恰好是 0 条,没夹具的话"过滤掉未放行域名"又是一次 # 空对空;同一条 pending 必须在审核队列里**看得见**、在放行清单里**看不见**,两个方向都钉。 # ⑩.4 每日 30 滴是这一族里唯一的写,判据也就得是写的判据:置成"当天未发"→ 一次 # `GET /api/auth/me` 必须 +30、必须落一条流水、必须把 `last_quota_date` 记到今天, # 同日再问必须一分不加。 # · 四条不糊弄的规矩:① **宾语必须存在**——库里没人关注任何作者时"复算==Java"是两条空数组 # 互相盖章,那种判据记 SKIP 并写明缺什么宾语;② **身份必须验**——四条 /me 读签名上不收 userId、 # 只认 cookie,所以游客与伪造会话必须 401,否则"忘了读 cookie"也能全绿; # ③ **并列不硬判**——`ORDER BY 时间 DESC LIMIT n` 里 MySQL 不保证并列行的先后, # 窗口内有并列时不要求同序、边界并列时不要求同集合,其余逐行钉死; # ④ **支路要被证明走到了**——审核队列与举报队列在克隆库里原本为空,空集比空集恒等, # 这两条读等于没测;闸门因此种一条待审稿 + 三条举报(目标已删那一支 / 匿名举报那一支 / # 一条 dismissed,用来证明 `status=open` 真在过滤),判完在 finally 里删干净并复核残留 0。 # 同一条规矩在 P7f-1f-a 又兑现一次:三个固定账号的印文都非空,"留空回退昵称首字"那支没人走过, # 于是临时把 probe 的 `avatar_text` 置成**空串**(不是 NULL——那四列全 NOT NULL, # 第一版夹具就是被数据库当场拒掉的),判完在 finally 里恢复并复核。 # · 反证跑过四轮:整道打 Node :3200 → 51 红、退出码 1;只改闸门自己的口径(门槛 10→5、 # 千分位 en-US→de-DE、后缀表手记↔札记对调、`pinned` 恒真、`游客`→`匿名`、分账 0.9→0.5) # → 精确红在对应那几条、其余不动。前两轮还揪出闸门自己两处**假绿**:端点返回 [] 时 # "逐行相等"与 `slice(0,0)` 互相盖章;以及 `COALESCE(...,'游客')` 改错**不红**—— # 因为库里每条举报都实名,那条支路根本没被走到(于是补了匿名举报夹具)。 # 第四轮打在 P7f-1f-a 那四条新读上:一次注入六处互不相干的口径错误(`pending` 数错表、 # 问答时间格式、注册日格式、流水改 `id ASC`、2FA 恒真、"今日已发"写成"往日已发") # → 精确红 16 项,且每条红都归到自己那格判据,复原即 226/226。 # 第五轮打在 §⑩ 上,而且改的是**被测方**不是闸门:Java 侧一次注入四处(去掉 `status='approved'` # 过滤、`current` 恒真、`findActive` 不再看 `revoked`/时效、`/api/auth/me` 不发额度) # → 清单红 3 条、设备列表红 6 条、"签有效但库里已吊销"红 1 条、每日额度红 5 条, # 并且同一台改坏的 Java 上闸门 4 的 5c.2 也红 3 条——六个宾语各红各的,没有互相冒领。 # · 这一道还是"运营台资金流水一直是空表"那桩旧 bug 的破案现场,见下面"移植过程中修掉的既有 bug"。 # · 判据本身只读;五处临时夹具(⑧ 的待审稿与三条举报、⑨.2b 的印文空串、⑩.1b 的一枚临时会话、 # ⑩.3 的三行白名单、⑩.4 的一次真实发墨)全部在 finally 里删除/按原值恢复并复核残留。 # ⑩.4 是唯一一处写业务,回滚按余额、发放日、流水 id 三项精确复原,不靠"重放一遍业务"。 # 做反证时留了一条操作教训:**备份清单要从变异清单来**,不是从"我记得动过哪几个文件"来—— # 那一轮四个变异动到两个 mapper,备份却只按四个控制器收,最后靠 `git diff --stat` 归零才敢确认复原。 # 开跑先问 DATABASE(),连到真库 inkstack 会警告。 # · 也可以改打切流实例验代理路径:`PARITY_JAVA=http://localhost:3299 node scripts/pagereads-check.mjs` # → 同样全绿,说明这些读经 Next rewrite 到 Java 与直连 Java 答得一模一样。 # 20) 演示模式:JAVA_BASE 为空的那一档必须"渲染得出来",而不是把站点变成一堆 500。 node scripts/demo-mode-check.mjs # 自己起一台 3399 的临时实例,跑完即停(约 40s) # · 这一道是 P7f-2 新立的,起因是 P7f-1f-b 我自己交出去的一个回归:迁渲染层最后四条 # 进程内 MySQL 时,`remoteCurrentUser` 少写了一句 `if (!javaReady()) return null`。 # 配了 JAVA_BASE 的实例上它一字不错、所有闸门全绿;而"clone 下来先跑起来看界面"这条 # 产品承诺走的正是**没配 JAVA_BASE** 的那一档,`javaBase()` 直接抛,于是每个页面 500。 # **一条判据若它的前提被所有在跑的实例共享,它就没在守另一半。** # · §0 先自证"我打到的这台真的在演示模式"(/api/* 回 503 且不带 x-backend)——这条不成立 # 时后面每一条绿都不说明任何事。§1 二十三个页面(含四个需要身份的页在游客态)必须 # 200 或 3xx,且页面上不许漏出取数异常的文本。§2 渲染出来的必须是 lib/demo-data.ts # 的内容(标记从那份文件现读,不抄进脚本)、身份页走的必须是游客分支、 # 而运营台必须**自报"这是演示数据"**——兜底假数最坏的不是假,是没说自己假。 # · 反证跑过:删掉 `remoteCurrentUser` 那句守卫 → **29 项红 22 项**(每个页面 500, # 且"漏出异常文本"逐条命中)。另一侧也红过一次,而且是判据太宽:第一版关键词表里有 # 裸的 "JAVA_BASE",于是正常状态下 /admin 就红——页眉那句"演示数据 · 未配置 JAVA_BASE" # 是产品故意写给人看的。判据太宽和太窄一样会骗人。 # · ⚠ 起实例这件事本身是这一道的一部分:Windows 下 `child.kill` 只杀得到 next 那个外壳, # 端口由它的子进程占着,所以清理走 `taskkill /T /F`;实例用独立 `NEXT_DIST_DIR`, # 与 3200 那台开发实例不抢 manifest。 # 21) 计数器守恒:这一道不比形状、不比字节,它**复算数**。 node scripts/counter-check.mjs # · 病根:`articles` 表上有四列摘要值(read_count / comment_count / like_count / agent_qa_count), # 页面、热榜排序、成就徽章、运营台漏斗都在读它们。P8b 之前 **read_count 在两套实现里都没有人写** # ——只有种子里那 12840/4200/3800。这类东西所有既有闸门都是绿的:对拍比形状、契约基线比形状、 # 页面判据比"DOM 上有没有这个数"。"有"与"对"是两件事。 # · 判据两条腿,缺一条就退化成一堆空断言:§2–§5 自建一篇**四列全 0** 的夹具文章,用真实接口把四条 # 写路径各驱动若干次,每步断言 `stored == 行集复算`(从 0 起算才能比增量;在种子稿上比增量, # 两边都不动时判据照样绿)。§6 钉两条删除路径——直接删(连带一级回复、按真实行数扣回)与 # 举报处置删(只删被举报那一行、扣 1),它们的口径本来就不一样。 # · §7 是源码棘轮:四列每列都要在 Java 里存在 `col = col + 1`;每一条 `DELETE FROM comments` 的 # **调用点附近**必须有扣回(红的时候直接点名 `AdminService.java:142` 这种位置);无符号计数列的 # 减法必须先 `CAST(… AS SIGNED)`;两笔写的事务形状不许退回裸调用。扫描前先把 Java 注释剥掉且 # **保持行数不变**——注释里写一句 `read_count = read_count + 1` 不算有人写,注释里引用坏形状也不算红。 # · §8 全库逐列复算 + **双向**豁免登记表:登记表之外的任何不一致红;登记表里那条已经不差的也红 # (该摘掉却不摘,下一个人会以为数据还是坏的)。种子与历史夹具留下的 53 处不一致逐条点名豁免, # 而不是写成"凡是种子稿一律不查"——后者会把这道闸门掏空。 # · 四列的复算式与那张登记表**不住在这道闸门里**,住在 `scripts/counter-exempts.mjs`(P8c): # 判的(这一道)与修的(下面那条运维脚本 `recounters`)import 同一份。各抄一份迟早走成 # "修的人按 A 口径、判的人按 B 口径"。改口径 = 改那一个文件 = 同时改了判据和工具,这是刻意的。 # · 反证四组:不记足迹 → 6 红;举报不扣回 → 3 红且点名调用点;把评论那两笔写拆回裸调用 → # **恰好 1 红**,只有形状判据红(成功时刻两种写法给出一模一样的数,这条判据测的是失败时刻); # 撤掉 CAST → §6⑧ 红在 500 与 MySQL 那句 `BIGINT UNSIGNED value is out of range`。 # 第五组是 P8c 补的、跨两条闸门的:手工往四张行集表各插几行而摘要列留 0, # 这道闸门 §8 红在那 4 处、`recounters` 只读模式点名的是**同 4 处**——"共用一份定义"这句话 # 到此刻才是可验证的。 # · ⚠ §5 只在被打的那台 `GET /api/agent/status` 报 `agentscope` 时才跑(把它指到本闸门 # 自己起的 4601 夹具)。不是这一档就整段记 SKIP 而不是发问:live 档会真调大模型真扣墨, # demo 档一条流水都不落——两种都问不出东西,宁可不测。 # · 会真建真删文章、真写评论/点赞/足迹/问答/审计/流水,收尾按快照复原并复核残留为 0 # (残留不为 0 就抛,不留"大概清干净了")。 ``` 演示模式与回滚:P7f-2 之后一台实例只有一条路 ```bash # .env JAVA_BASE=http://localhost:3101 # 非空 = 有后端;留空 = 演示模式(页面渲染 lib/demo-data.ts) ``` `JAVA_ROUTES` 已经不存在了。原来那套"逗号分隔前缀、留空即整套回滚"随 `app/api/**` 一起退役—— Node 侧没有任何能应答 /api 的实现之后,清空那个开关得到的是一堆 404,而不是"回滚到 Node"。 **一个含义已经变掉的开关比没有开关更坏**:它会让人以为还能回滚。于是回滚现在是工程手段—— `git revert` P7f-2 那一系列删除提交并重启(回回来的不只是路由文件,还有读 `JAVA_ROUTES` 的那版 middleware),不是一个配置项。 middleware 对 `/api/` 的判定收成一句 `pathname.startsWith("/api/")`,两档: - 配了 `JAVA_BASE` → rewrite 到它,并把浏览器看到的 host/proto 显式写进 `x-forwarded-host` / `x-forwarded-proto`。Next 的 rewrite 会覆写 `Host`,不补这两句的 表现是"导出的 Markdown 里每个绝对链接都变成 `http://localhost:3101/...`"—— 接口全 200、没有任何报错,只有用户下载到的文件是坏的。 - 没配 → `/api/*` 一律 **503 + Retry-After**,不放行去撞一个 404:404 说的是"没有这个接口", 真相是"这台没配后端"。页面照常渲染演示数据。这一档由闸门 20 守着。 日常跑写侧/代理类闸门要的那对临时实例(邮件降级 + AI 不接上游,跑完即停): ```bash SERVER_PORT=3199 SMTP_HOST="" DEEPSEEK_API_KEY="" AGENT_SERVICE_URL="" \ mvn -f server/pom.xml spring-boot:run & JAVA_BASE=http://localhost:3199 NEXT_DIST_DIR=.next-cutover EDGE_API_LIMIT=100000 \ node node_modules/next/dist/bin/next dev -p 3299 & node scripts/proxy-cutover-check.mjs --base=http://localhost:3299 \ --money --community --admin --study --import --ai PARITY_NODE=http://localhost:3299 PARITY_JAVA=http://localhost:3199 \ node scripts/auth-flow-check.mjs # 25/25 PARITY_NODE=http://localhost:3299 node scripts/studio-check.mjs # 51/51 PARITY_NODE=http://localhost:3299 node scripts/study-check.mjs # 117/117 PARITY_NODE=http://localhost:3299 node scripts/admin-check.mjs # 65/65 PARITY_NODE=http://localhost:3299 node scripts/page-check.mjs # 64/64 PARITY_NODE=http://localhost:3299 node scripts/money-check.mjs # 94 项 + 6 项恒 SKIP PARITY_NODE=http://localhost:3299 node scripts/community-check.mjs # 103 项 + 5 项恒 SKIP node scripts/contract.mjs check --base=http://localhost:3299 # 31 项,与对岸无关 ``` `EDGE_API_LIMIT=100000` 是给临时实例用的:这些闸门一轮几百发,不抬档位会把自己刷成 429, 而"满屏 429"读起来和"两栈不一致"一模一样可信。生产实例不设这个变量(默认 120 次/分/IP)。 `--ai` 那一段还要那个实例的 AI 面落在 demo 档:只要处在 live/透传档,一问就扣 5 点墨、 就去问真上游,所以上面那条命令把 `DEEPSEEK_API_KEY` 与 `AGENT_SERVICE_URL` 一起抹空; 闸门自己会先查 `/api/agent/status`,不是 demo 就记 SKIP 而不是发问——**宁可不测, 也不拿用户的墨水去赌配置**。 `contract.mjs check` 打谁都是同一句判据——它比的是一条活应答有没有长成基线那个形状, 所以 Node 路由删掉之后它是唯一还在守读侧契约的一道(`freeze` 已停用,见闸门 1′ 那一节)。 书房那六条是**整前缀**切的(`/api/drafts` 等下面没有未迁分支),`/api/series` 从 P6d 起也是整前缀: 它的 `GET` 曾经是两栈同 URL 却不同义(Node=我的专栏 / Java=公开合集架),所以只能按段通配留一条 `/api/series/*/bundle`,并在闸门 6 里反向断言"其余的必须仍在 Node"。现在两条语义各占一条 URL (公开架留在 `GET /api/series`,"我的"挪到 `GET /api/series/mine`),那一档也翻成了正向断言: 前缀下每个 URL、每个方法都要带 `x-backend: inkstack-java`,且状态码与文案与直连对岸一致。 P6b 之后 `/api/ai` 与 `/api/agent` 两个前缀整段安全,P6d 之后闸门 8 现算出的最长可切前缀已经是 `/api` 本身(Node 侧 58 个 URL 模式、Java 全的方法集合齐、语义冲突登记表已空)——剩下的收尾 不是"还有哪些切不过去",而是 P7 那件"把 Node 侧的取数代码删干净"。 临时实例会把 `next-env.d.ts` / `tsconfig.json` 里的构建目录指针改写成 `.next-cutover`,**提交前 revert 这两个文件**; 跑完顺手把 `.next-cutover` 和这个进程一起清掉。 ## 🔒 会话互通是字节级的 换栈期两套后端共用同一枚 Cookie,任何一处偏差都会把用户劈成"半登录态"。这些约定写死在代码与单测里。 ⚠ P7f-2 之后签名方只剩 Java 一套,于是这份清单的性质变了:它不再是"两侧必须逐字节对齐"的 **差分约束**,而是"Java 单方面必须守住的历史事实"——守的是老用户手里那枚已经签发的 Cookie (换了键序或签名口径就是全站掉登录)、`sessions.token_hash` 那把 sha256 的算法,以及 `contract/` 里冻结下来的旧实现形状。**一条差分约束在对岸消失后不会自动变成不变量**, 所以这一节该留着,而且要比以前更当回事。 - Cookie 值 = `base64url(JSON).base64url(HMAC-SHA256)`,**签名覆盖的是那段 base64url 字符串本身**的 UTF-8 字节,不是解码后的 JSON;载荷键序固定 `sid,uid,exp`,`exp` 是毫秒 - HMAC 密钥是 `SESSION_SECRET` 的原始 UTF-8 字节,不做任何 KDF——两侧必须是同一个值 - 口令哈希 `scrypt(N=16384, r=8, p=1, keylen=64)`,存储格式 `32位hex盐:128位hex派生钥`;**喂给 scrypt 的盐是那串 hex 字符本身,不是解码后的 16 字节**(按 16 字节算会得到完全不同的哈希) - `sessions.token_hash` 存整枚 Cookie 值的 sha256;登出 / 改密 / 封号靠这张表即时生效 - `expires_at` 由 MySQL `FROM_UNIXTIME()` 解释,不改写连接会话时区,否则同一时刻会被判成不同有效期 - `Secure` 标志跟随站点协议(http 部署带上会让浏览器拒收 Cookie,表现为"登录成功却仍是游客") - 付费墙:未解锁读者的正文在 **SQL 层**就被 `SUBSTRING_INDEX(md_content, '\n', 6)` 截断,全文绝不进结果集——先取全文再判权限等于没设防 ## 🚀 快速开始 环境要求:Node.js **≥ 22**(20.x 必须 ≥ 20.19)、**JDK 17+**、MySQL ≥ 8。(未配数据库时前端仍能以演示模式启动。) > Node 版本是硬要求,不是建议:渲染层经 `isomorphic-dompurify` → `html-encoding-sniffer` 以 > `require()` 加载 ESM-only 的 `@exodus/bytes`,Node 20.16 不支持 `require(esm)`, > 文章页会直接 500(与数据源无关,两种取数下同样炸)。 ```bash # 1) 前端(与原项目完全相同) npm install npm run dev # http://localhost:3100 # 2) 数据库 mysql -u root -p < db/schema.sql cp .env.example .env # 填 DATABASE_URL、SESSION_SECRET # 3) 后端 node scripts/gen-java-env.mjs # 从 .env 派生 server/config/(含同一个 SESSION_SECRET) cd server mvn spring-boot:run # http://localhost:3101 ``` 两个环境坑(都踩过): 1. **JDK 版本**:`mvn` 用的是 `JAVA_HOME` 指向的 JDK。本机默认可能是 JDK 8, Spring Boot 3 需要 17+,因此显式指定:`JAVA_HOME=<你的 JDK17 路径> mvn spring-boot:run`。 2. **Maven 镜像**:若你的全局 `conf/settings.xml` 里有指向内网私服的 `mirrorOf=external:*`, 它会盖掉 `pom.xml` 里声明的仓库、连 parent POM 都拉不下来。 本仓库用工程内 `server/settings.xml`(阿里云公共仓库)绕开,从 `server/` 目录执行 Maven 时 由 `server/.mvn/maven.config` 自动带上 `-s settings.xml`。在 IDE 里构建需把 User settings file 手动指到该文件。不修改任何全局配置。 双轨联调需要两个端口同时在线,此时前端可另起端口: `node node_modules/next/dist/bin/next dev -p 3200`,并设 `PARITY_NODE=http://localhost:3200`。 ## 🗂 目录结构 ``` . ├── app/ # Next.js App Router:**只有页面**。58 个 app/api/**/route.ts 已在 │ # P7f-2 删除,"谁应答 /api"这件事不再存在于这一层 ├── components/ lib/ # 前端组件与设计层;lib/data.ts=页面取数(只问 Java 或只渲染演示数据), │ # lib/java-source.ts=那一句句 remote 调用本身 ├── middleware.ts # 安全头 + 限流 + CSRF + /api/* 全量 rewrite(没有路由名单) ├── server/ # ⭐ Spring Boot 后端(Maven 模块) │ ├── settings.xml # 工程内 Maven 镜像(不改全局) │ └── src/main/java/com/inkstack/ │ ├── article/ auth/ points/ session/ # 按领域分包 │ ├── money/ community/ admin/ # 资金链路 / 社区互动 / 运营台 │ ├── series/ user/ study/ # 专栏读写 / 资料与改密 / 书房(草稿·足迹·友链·上传) │ ├── importer/ ai/ agent/ # 迁移工具 / AI 写作与分身状态 / Spring AI 智能体引擎 │ ├── entity/ mapper/ # MyBatis-Plus:实体与手写 SQL 映射 │ ├── common/ # NodeShapes / Nicknames / Links / Slugs / Pricing: │ │ # Node 的取值语义与口径,逐条对齐的落点 │ └── web/ # 参数解析器、ClientMeta、后端标记 ├── db/schema.sql # 建表脚本(含 ngram 全文索引) ├── contract/ # 闸门 1′ 的冻结基线:每个接口一份"应答该长成这样"(30 条,全部冻自 Node 时代) ├── scripts/ # 闸门(contract / paywall-probe / search-leak-probe / page-check │ # / auth-flow / proxy-cutover / money-check / route-inventory │ # / community-check / studio-check / admin-check / study-check │ # / import-check / ai-check / agent-engine-check / schema-check │ # / rate-limit-check / page-sql-inventory / pagereads-check │ # / demo-mode-check / counter-check,外加 gate-executor.mjs 那个"谁在应答"探针) │ # + 计数器那一族两处:counter-exempts.mjs 是判与修共用的复算式和豁免登记表, │ # recounters.mjs 是上线前那一次摘要列对齐(默认只读,边界写在代码里) │ # + 种子与运维脚本 └── docs/ # 预览图与集成方案 ``` (`scripts/parity.mjs` 与 `scripts/interop-check.mjs` 不是漏写:它们随对岸一起退役了, 想看跑法 `git show P7f-1f-b:scripts/parity.mjs`。) ## ⚙️ 环境变量 前端与切流相关(根目录 `.env`,全部说明见 [`.env.example`](.env.example)): | 变量 | 说明 | |---|---| | `DATABASE_URL` | MySQL 连接串;Java 侧由脚本派生成 JDBC | | `SESSION_SECRET` | 换栈期两栈必须同值(不同值表现为"另一侧完全认不出这枚 Cookie")。P7f-2 之后只有 Java 校验它,但它仍是**已下发的那枚 Cookie 的验证密钥**:换值等于全站掉登录,不是"改个配置" | | `JAVA_BASE` | "有没有后端"的总闸,同时决定两件事:① 页面取数——非空即只问 Java(问不到就抛、页面 500,绝不静默回落成演示数据),留空 = 演示模式渲染 `lib/demo-data.ts`;② HTTP——middleware 见 `/api/` 就转发给它,**没有名单**。留空时 `/api/*` 一律 503 而不是 404。这一档由闸门 20 守着 | | ~~`JAVA_ROUTES`~~ | **已退役(P7f-2)**,随 `app/api/**` 一起。原来它是"逗号分隔前缀、留空即整套回滚";Node 侧没有能应答 /api 的实现之后,清空它只会得到一堆 404 而不是回滚。**一个含义已经变掉的开关比没有开关更坏**,所以整条删掉;回滚请 `git revert` P7f-2 那一系列提交 | | `EDGE_API_LIMIT` | Next 边缘那道全站 API 滑窗的档位,留空 = 每 IP 每 60 秒 120 次。留这个口子有两个理由:出口共用一个公网 IP 的用户群会被 120 误伤;以及跑批量闸门时只给演练实例抬档——闸门一次上百发、全来自同一个回环地址,刷爆之后的 429 读起来和"两栈不一致"一模一样 | | ~~`DATA_VIA_JAVA`~~ | **已退役(P7e′)**:页面只剩一条取数路,逐函数开关没有东西可切了。`x-data-source` 应答头同时删除——一个含义会变掉的信号比没有信号更坏 | | `NEXT_PUBLIC_SITE_URL` | 站点地址;决定会话 Cookie 是否带 `Secure` | | `TRUST_PROXY` | 反代后设 `1`,限流与审计才取真实 IP | | `AGENT_SERVICE_URL` | 旧的 Python 分身服务地址(P6c 起仓库里已无该服务)。配了它,Java 仍会把问答透传过去——留着是为了兼容既有部署,新部署请用下面的引擎 | | `AGENT_ENGINE` | `off`(默认)或 `spring-ai`。`spring-ai` = 启用 Java 侧智能体引擎(Spring AI + 文章检索工具)。这个引擎只有 Java 有,而 `/api/ai`、`/api/agent` 自 P7f-2 起**恒由 Java 应答**,所以这里没有"切过去"这一步了——它现在纯粹是引擎档位开关 | | `AGENT_MODEL_BASE_URL` / `AGENT_MODEL_API_KEY` / `AGENT_MODEL_NAME` | 引擎接的 OpenAI 兼容端点与模型名,默认指向 DeepSeek 的 `/v1` 并复用 `DEEPSEEK_API_KEY`;换百炼只改这三行 | | `DEEPSEEK_API_KEY` | 没有分身服务时的降档判据(有 Key → `live`,无 → `demo`);同样派生给 Java。**跑闸门时不要把真 Key 写进 `.env`**:`/api/ai/write` 的计费链路会照着它去问真上游,烧的是账号里的墨水 | | `DEEPSEEK_BASE_URL` | live 通道的大模型地址,默认官方。**留这个口子是给闸门指的**:`ai-check` 把它指向自己的 SSE 夹具,否则"上游 503 不许扣墨"这类用例每跑一次就真发一次请求 | | `AGENT_STREAM_TIMEOUT_MS` | P9 起一次问答从头到尾允许多久没动静,默认 60000。**同时管两头**:响应头由 JDK 的请求超时,响应体由 `UpstreamDeadline` 到点关流(后者是重点——JDK 那个超时管不到"头都到了、一个字节都不再吐")。留短是给闸门用的(`ai-check` 的 stall 两档),线上别设 0:0 与负数一律退回 60000 | | `INKSTACK_SCHEMA_AUTO` | 默认 `true`:Java 启动时把 `db/schema.sql` 里的 `CREATE TABLE` / `ALTER TABLE` 应用到当前数据源。DB 用户没有 DDL 权限时设 `false`,改由 DBA 手工导 schema(闸门 16 §5 用反例验过它确实关得掉、且关不掉别的东西) | 后端(`server/config/application-local.properties`,由 `scripts/gen-java-env.mjs` 生成、已 gitignore): `INKSTACK_DB_URL` / `INKSTACK_DB_USER` / `INKSTACK_DB_PASSWORD` / `SESSION_SECRET` 等。 ## 🎨 设计系统 杂志编辑风:规则线、编号排版、首字下沉、朱砂印章、竖排题字。 暖纸底 `#F6F1E7` · 近黑 `#211C14` · 朱砂强调 `#C2401A`;展示字思源宋体 + Fraunces,正文思源黑体。 令牌集中在 `app/globals.css`,CSS 按版本分层追加、不重构旧层;夜间主题统一由 `body[data-theme="night"]` 驱动。 ## 📦 部署 部署手册是 [`DEPLOY.md`](DEPLOY.md),**已按换栈后的两进程形态重写**(第 4 节:先 Java 后 Next, `gen-java-env.mjs --prod-schema` 派生 Java 的配置、systemd 起 jar + pm2 起 Next;第 7 节: 上线前的一次性动作;第 10 节:出问题先看哪)。里面每条形如"跑起来验一下"的命令都在本机跑过, 唯一标注了"没跑过"的是 pm2 直管 jar 那种写法——那一处建议用 systemd 那条路。 一句话版本:Java 是**唯一**应答 `/api/*` 的进程,`JAVA_BASE` 配没配决定这台有没有后端; 只起 Next 得到的是一个每个接口都 503 的演示壳。 ### 上线前值得跑一次的那条:摘要列对齐 `scripts/recounters.mjs` 是"修"的那一条,闸门 21 是"判"的那一条,两者 import `scripts/counter-exempts.mjs` 里同一份复算式与豁免登记表。 ```bash node scripts/recounters.mjs # 只读:报有哪几处不平(有不平退 1) node scripts/recounters.mjs --target inkstack # 只读量另一个库(不改 .env,也不写) node scripts/recounters.mjs --apply # 修登记表外那些,写完在同一事务里复算复验 node scripts/recounters.mjs --emit-registry # 把当前不平清单打成 EXEMPT 字面量 ``` 安全边界写在代码里而不是这里(这里的每一句都只是那一处的摘要): - **默认只读**,要动手必须 `--apply`。 - 只 `UPDATE articles` 那四列,值 = 行集复算。**不动行集**:`comments` / `article_likes` / `read_history` / `agent_qa` 是事实来源,摘要列是抄件——抄件错了改抄件,顺手去删行是另一回事。 - **库名要说两遍才肯写**:默认只允许克隆库 `inkstack_j`;写别的库必须 `--target <库名>`, 且该值要与服务器答应的 `SELECT DATABASE()` 一致。主库上跑 `--apply` 而没点名 → 退 2, 而且是**在开始复算之前**就停住(那一跑里连"文章 N 篇"那一行都没印出来), 不是量完再回滚。 - **比较后再写**(`WHERE id = ? AND col = <量到的值>`)。影响行数不是 1 就说明量与写之间 有人在写这一列,整笔回滚而不是把那次增量盖掉——这条前提单独验过:另一条连接在中间做一次 `read_count + 1`,那条写回旧值的 UPDATE 影响 0 行。 - 写完在同一事务里按复算式再量一遍,仍不平就回滚;提交后再换一个新的读视图量一次。 - 退出码:`0` 没有要对齐的(或已对齐并复验通过)· `1` 还有不平的 · `2` 前提不成立。 两件事不要顺手做:① **不要挂成 cron**。这个工具存在一次的理由是"派生规则变了、旧账没追上"; 定期抹平等于承认还有一条不守恒的写路径没被找出来,而那道判据(闸门 21 §8)会一直红到有人 去修它为止。② 不要只改登记表不改数据。`--include-exempt` 会连首屏那几个虚构数字一起抹平, 那是产品决定;改完数据再用 `--emit-registry` 换登记表,顺序反了双向棘轮那条当场红。 ## 📄 License [MIT](LICENSE) · 作者 **dujiang**