Jianghu · 代码与架构优化方案
Jianghu · 代码与架构优化方案
五个节点 × 四个维度 —— 代码结构 / 数据存储 / 内存应用 / 并发 / 前端加载
2026-10-10 · 基于全量实读:前端 27 模块 17,118 行、后端 22 模块 10,809 行、tools 235 脚本、实测磁盘与产物数字
目录
- 怎么读这份文档
- 结论摘要:优先做这 12 件事
- 现状体检(五个节点)
- 优化矩阵 · 代码结构
- 优化矩阵 · 数据存储
- 优化矩阵 · 内存应用
- 优化矩阵 · 并发
- 优化矩阵 · 前端加载
- 落地路线图(P0/P1/P2)
- 红线:这些铁律不能破
〇、怎么读这份文档
这份方案的前半是体检(只讲事实、带证据),后半是处方(每个节点四个维度:性能 / 效果 / 稳定性 / 安全)。
- 每条优化都有 高中低 严重度,以及改哪里、怎么验收——没有一句"建议优化"的空话。
- 证据一律写成
文件:行号,可以直接跳过去核对。
- 凡是与项目既有铁律冲突的做法(后端权威、单实例、多会话并发、自测走 8000),在文末红线里单列,方案里不会让你踩。
- 本文只出方案,不动代码。项目当前有多条开发线并行,任何改动都要走"明确路径 → 最小 diff → 实跑验收"的流程。
一、结论摘要:优先做这 12 件事
按 危害 × 修复成本 排序。P0 是"已经在裸奔或已经埋雷"的,P1 是"花钱买性能与体验"的,P2 是"长期工程化"的。
| # | 级别 | 事项 | 为什么先做它 |
|---|---|---|---|
| 1 | P0 | 给调试写接口上闸门:/api/resources/grant、/api/equips/grant、/api/arts/grant、/api/time/advance、各种 /reset | 任何持 token 的玩家可无限刷金币/装备/功法、任意快进时间。生产 nginx 把 8000 全量反代到公网,等于经济系统对玩家开放写权限 |
| 2 | P0 | accounts.json 读取失败禁止覆盖 | 解析异常时会用空账号库写回原文件——一次磁盘/杀软事故就抹掉全服 1246 个账号(含Password哈希) |
| 3 | P0 | 登录/注册限流 + scrypt 改异步 | scryptSync 在主线程同步算,且零频率限制:撞库可爆破Password,高频请求可卡死全站 |
| 4 | P0 | 主循环异常隔离:setAnimationLoop 包 try/catch + 全局兜底 | 主循环回调一抛错就整条停摆且不自愈(历史已发生:"背景在、动态元素全停"),而链上没有一个 try/catch |
| 5 | P0 | 后端开 gzip/br + 静态改流式 | 166KB 的 index.html、867KB 的 main.js、120MB 的 webp 全裸传;dev 直连 8000 时尤其慢 |
| 6 | P0 | 写盘统一 + 失败必须告警 | 4 处写盘(clock/buildings/upkeep/accounts)连重试都没有,失败只打一行日志、调用方忽略返回值 → 内存新、磁盘旧,静默分叉 |
| 7 | P0 | 首屏 4 个串行 await 加超时并改并行 | /auth/me → /game/meta → /time → /resources 全程无超时,任一卡住首帧不画,玩家看到黑屏 |
| 8 | P0 | 清测试账号:1243 个 @wuxia.dev + 94 个孤立 world 目录 | 正式库里真实账号只有 2~3 个,其余全是验收脚本留下的;管理Admin统计与排行榜被污染 |
| 9 | P1 | JS 拆包 + 剥 CSS 注释 | 单 chunk 867KB(gzip 249KB)首屏必须下完才初始化;index.html 里 65KB 是没剥掉的 CSS 注释 |
| 10 | P1 | 首屏大图条件化:日月/rock_a/plants 按需加载,2.26MB 山体图转 webp | 无条件下 6MB 大图(含当前时段根本看不见的太阳/月亮贴图) |
| 11 | P1 | world CACHE 加上限 + 写盘去美化 + 单账号写队列 | 世界缓存只增不减(1246 账号可线性膨胀);每次写盘 JSON.stringify(null,2) 白烧 CPU;同账号多请求无互斥 |
| 12 | P1 | 修 16 个"红绿都退 0"的验收脚本 | 失败静默通过 = 断言作废,其中 verify_equip_ui.mjs 还是 README 点名的端到端脚本(全文 0 断言) |
一句话总括:这套代码的"设计纪律"很好(后端权威、幂等结算、有界容器、事件委托),但"工程护栏"很薄——没有限流、没有错误隔离、没有统一写盘、没有 CI、没有包体优化。优化的重点不是重写,而是补护栏。
二、现状体检(五个节点)
2.1 代码结构
17,118前端 JS 行数(27 模块)
10,809后端行数(22 模块)
2,918最大单文件 hall.js(3 个 class)
1,542index.mjs 行数(路由+鉴权+静态+编排)
235tools 脚本(110 mjs / 114 py)
0lint / 类型 / CI / test 入口
| 层 | 现状 | 证据 |
|---|---|---|
| 前端巨石 | hall.js 2918 行内含 HallPanel(114)+HeroPanel(2256)+StarterPanel(2679);warehouse.js 导出 48 个符号;main.js 无 class、纯过程式 1674 行,持有全部面板实例 | src/hall.js:114,2256,2679 · src/warehouse.js:824,955 · src/main.js:617-691 |
| 后端巨石 | 路由表(59 端点)、鉴权闸门、静态伺服、业务编排全在 index.mjs 一个文件;Store 层已按模块拆得不错 | server/index.mjs:277-341,694-862,908-1314,1338-1514 |
| 全局状态 | main.js 21 个模块级 let + 13 处 window.__* 调试出口(进生产构建) | src/main.js:631,670,724,804,825,841,995,1014,1029,1132,1286… |
| 无循环依赖 | 前后端都是严格单向依赖;api.js 是前端枢纽(被 8 处引用);world.mjs 用反向注入避开环 | src/main.js:112-125 · server/world.mjs:74 |
| 契约靠约定 | 前后端 59 个端点靠字符串字面量匹配,没有集中声明的路由表、没有契约校验、没有类型 | server/index.mjs:1340-1341 |
做得好的:Store 拆分清晰(22 模块职责单一)、前后端均无循环依赖、静态大表(技能 220 / 装备 111 / 功法 240)独立成模块,没有塞进业务逻辑里。
2.2 数据存储
1,329worlds 账号目录(94 个孤立)
40 MBworlds 总量(9,474 个 json)
656 KBaccounts.json(1246 用户 / 1289 会话)
254 KB最大单存档 equips.json
0 次fsync 调用
2 套并存的原子写实现
| 项 | 现状 | 证据 |
|---|---|---|
| 写盘粒度 | 全部 Store 都是整包覆盖:改一个字段就把整个文件 JSON.stringify(v,null,2) 重写;只有 equips 有批处理脏标记 | server/atomicFile.mjs:19 · server/equips.mjs:269-279,305-310 |
| 写盘实现 | atomicFile.mjs(tmp+rename+重试 3 次)与 4 处内联复制(clock/buildings/upkeep/accounts,无重试)并存 | server/atomicFile.mjs:18-37 · clock.mjs:62-73 · buildings.mjs:145-155 · upkeep.mjs:56-70 · accounts.mjs:50-60 |
| 失败处理 | 失败仅 console.error 一行 + 返回 false,调用方全部忽略返回值 → 内存领先磁盘,此间崩溃即丢改动 | server/atomicFile.mjs:27-35 |
| 一致性 | 一次挂机上报(POST /api/idle/report)要跨 4 个 json 同步落盘:resources 最多 5 次 + heroes 1 + equips 1 + arts 1+N,非原子提交 | server/index.mjs:1294-1305 · equips.mjs:934,967 · resources.mjs:610-620 · artsStore.mjs:239-246 |
| 读接口副作用 | GET /api/hall、GET /api/resources、GET /api/upkeep 会触发结算并落盘;账号首个请求还会自动建世界、补默认建筑、发开局礼包 | server/index.mjs:731,737,759,763,764,779 · world.mjs:40,87,103-104 |
| 账号库 | 启动读一次进内存,此后每次注册/登录/登出全量重写 656KB;会话 token 明文存储、无上限、无按用户吊销、只增不减 | server/accounts.mjs:34-48,50-60,73-78,175-201 |
| 索引 | 无:userByToken 每请求线性扫 1289 条会话;登录线性扫 1246 用户;heroes.get/equips.byId 是数组 find | server/accounts.mjs:132,186 · heroes.mjs:544 · equips.mjs:303 |
| 时间模型 | 锚点推导、无定时器、改流速先固化锚点 —— 设计干净,无问题 | server/clock.mjs:84-90,123-137 |
2.3 内存应用
∞后端 world CACHE 上限(无淘汰)
1 处前端 dispose() 调用(全项目)
0 个对象池
95addEventListener(removeEventListener 1 处)
~10常驻 window 级Anonymous监听
| 项 | 现状 | 证据 |
|---|---|---|
| 后端世界缓存 | CACHE = new Map() 永久驻留,只在 /api/world/reset 时删;无 TTL / LRU / 引用计数。1246 个账号 × 单账号最大 292KB 理论可线性膨胀 | server/world.mjs:32-37,115,311-314 |
| 后端扫盘 | 管理Admin每 8 秒缓存过期即同步扫 1329 个账号 × 7~8 个文件(≈9,000 次 readFileSync);GET /api/docs 每次读全部文档全文 | server/admin.mjs:262-353,382-395 · index.mjs:396-420 |
| THREE 资源 | 全项目只有 1 处 dispose()(铭牌贴图)。场景 geo/material/texture 全部常驻至刷新;FLAT_ART=true 下 3D 山体/植被仅 visible=false,显存照占 | src/flatBuildings.js:487 · src/main.js:583-587 · clouds.js:191 · plants.js:189 |
| 每帧垃圾 | 战斗中每帧 [...units, ...mobs] 建新数组;飘字每帧 filter;resources.update 每帧写徽标 style.transform | src/idle/idleBattle.js:537 · idleBand.js:911 · resources.js:744-748 |
| 主循环 | setAnimationLoop 回调链无 try/catch,也无 window.onerror/unhandledrejection 兜底 → 抛错即整条停摆 | src/main.js:1636-1674 |
| 容器有界性 | 这点做得好:animMap 超限删键、reports 上限 20、waveLog 上限 800、badges 按田对账回收;唯一"只增不删"的 unitFx 键空间被限制在英雄 id 集合内 | src/idle/idleBand.js:807-809,1162-1165 · idleBattle.js:1342-1343 · server/equips.mjs:1015 |
2.4 并发
1进程 / 1 个事件循环(单实例硬约束)
全同步所有存档 IO + 静态读文件
0锁 / 队列 / 版本号 / 文件锁
~42前端每分钟请求数(2s+5s 轮询)
0登录限流
| 项 | 现状 | 证据 |
|---|---|---|
| 请求模型 | 单进程单事件循环;全代码 await 只有 5 处(都是读 body),handler 从读完 body 到写响应全程同步,含磁盘 IO | server/index.mjs:1359,1411,1418,1440,1488 |
| 阻塞面 | 任一账号写 254KB 存档(stringify+rename)期间,全体玩家请求排队;无按账号队列、无合并 | server/world.mjs:32(单 CACHE)· atomicFile.mjs:19-25 |
| RMW 竞态 | 所有 Store 是"读档→改内存→全量写回",无互斥/版本号。单进程内靠同步天然串行不丢更新;风险在跨进程双写(无文件锁)与非原子多文件提交 | server/world.mjs:32-37 · deploy/prod/ecosystem.config.cjs:32-35(自述"不要开 cluster") |
| 轮询 | 前端:/api/time 2s + /api/resources 5s,由 rAF 驱动(标签页隐藏即停摆);后者一次可触发 3 类写盘 | src/timeClient.js:17 · src/resources.js:40,339-341 · src/main.js:1663,1666 |
| 登录 | scryptSync 同步 + 每次登录全量重写 accounts.json,两者叠加在事件循环上 | server/accounts.mjs:64,136-142 |
| HTTP 护栏 | 无 gzip、无 Range、无请求体超时(>1e6 字符直接 req.destroy(),不给 413)、无连接数/频率限制 | server/index.mjs:144-154 · 全仓无 zlib / Range 命中 |
| 幂等 | 挂机上报用 (stageIdx,waveSeq) 去重,但 waveLog 只保留 800 条(超限删最旧 300)⇒ 刷够 800 个新 key 后可重放旧 key 再领奖 | server/equips.mjs:1012-1017 |
2.5 前端加载
867 KB单个 JS chunk(gzip 249KB)
179 KBindex.html(内联 CSS 158KB)
66 KB其中未剥离的 CSS 注释
166 MBdist(1248 文件)
~6 MB首屏无条件大图
20 MB死资产(建筑 glb,前端零引用)
| 项 | 现状 | 证据 |
|---|---|---|
| 打包 | 作品 + three.js 全打进 一个 chunk main-C50Sbec8.js(867,320 B);无 manualChunks、无动态 import(import() 0 处) | vite.config.js:19-24 · dist/assets/main-C50Sbec8.js |
| CSS | 没有独立 CSS 文件,全内联在 HTML;build 只剥 HTML 注释,不剥 CSS 注释(242 块 / 65,635 B 白传) | tools/build.mjs:90-111 · dist/index.html |
| 压缩 | Node 端零 gzip/br(无 zlib import);生产靠 nginx gzip 文本类,glb/webp 不压 | server/index.mjs:655-663 · deploy/prod/nginx-idle-wuxia.conf:53-66 |
| 首屏关键路径 | 4 个串行 await(/auth/me → /game/meta → /time → /resources)全部无超时;主循环直到 1636 行才注册 | src/main.js:112,125,297,701,1636 · src/api.js:152-173 |
| 大图 | 无条件加载:flat_mount.png 2.26MB + sun 2.09MB + moon 1.69MB + 云带 1.58MB;FLAT_ART 下仍加载 3D 专用 rock_a.jpg 585KB 与 plants.glb | src/flatScene.js:133,164-166 · celestial.js:89,104-107 · main.js:497,597 |
| 按需加载(好) | 挂机条带已做 L0/L1/L2 分层 + 区域背景/怪物/特效按需(历史峰值从一次性 493 张图降下来);图片有 _loading 并发去重 | src/idle/idleBand.js:192-197,399-413,466-481,983-1002 |
| 列表渲染 | 仓库无分页/虚拟滚动,打开即全量 innerHTML + 每件一条 * 请求;大殿 render 全量重建 + 逐元素重绑监听 | src/warehouse.js:1318-1325 · src/hall.js:775-780,825-854,885-948 |
| 缓存 | hash 资源 immutable 1 年;420+ 张无指纹素材走 no-cache + ETag(有 304,但刷新仍有数百次往返) | server/index.mjs:621-661 |
三、优化矩阵 · 代码结构
判据:代码结构不直接决定帧率,它决定启动成本、分包空间、并行开发的冲突面、以及"靠人记"的规则能不能变成"机器查"。
3.1 性能
C-1 静态大表不再每次请求重新序列化中
server/index.mjs:791 · 实测 /api/game/meta 响应 281,316 B
- 做法:
GET /api/game/meta的表是常量(技能 220 / 装备 111 / 功法 240),现在每次请求都现拼对象 +JSON.stringify。改成模块加载时序列化一次,存成const META_JSON字符串,handler 直接返回;再配ETag+Cache-Control: private, max-age=300(内容变了要能失效,用内容哈希当 ETag)。
- 收益:每账号启动省一次 281KB 的对象构造 + 序列化;304 命中后零传输。
- 验收:
curl -H 'If-None-Match: <etag>' /api/game/meta返回 304;前端loadGameMeta()仍能拿到同样字段(对 meta 结构做一次深比较)。
C-2 面板代码按需加载,把首屏模块图缩小中
src/main.js:617,630,648,1042 静态 import 全部面板 · 全项目动态 import 0 处
- 做法:
hall.js(149KB)、warehouse.js(94KB)、journey.js、resources.js改为首次打开浮层时await import();main.js只保留 3D 场景 + 挂机条带 + 主循环。注意这些模块顶层无副作用(已核实),改动态导入是安全的。
- 收益:首屏 chunk 与启动解析时间直接下降;和 L-1 分包叠加效果最好。
- 风险:面板打开会多一次异步等待 → 必须配一个"打开中"的轻量占位(L-6)。
- 验收:
window.__hall等调试口在首次打开后才存在(现有验收脚本大量依赖这些口,需要同步改成"先点开再读");控制台无 404/未捕获异常。
C-3 冷代码搬出热文件低
src/hall.js:114(HallPanel) / 2256(HeroPanel) / 2679(StarterPanel) 同文件 2918 行
- 做法:按 class 拆成
hall/panel.js、hall/heroPanel.js、hall/starter.js,共享的卡片渲染与拖拽工具抽hall/shared.js。只搬家不改逻辑,一次一个 class,改完立刻实跑该模块验收。
- 收益:并行开发时两人同时改大殿的概率大降(这是当前最容易撞车的文件);配合 C-2 后 HeroPanel 可以只在点开人物时加载。
- 验收:
tools/verify_hall.mjs(67 断言)、verify_hero_panel.mjs、verify_hero_pick.mjs全绿;git diff应为纯移动(无逻辑变更)。
3.2 效果
C-4 把"靠人记的规则"变成"机器查的断言"中
server/index.mjs:277-283(PUBLIC)· 307-341(STARTER_GATED)· 记忆铁律"新写接口别忘了加进 STARTER_GATED"
- 做法:把 59 个端点从"一堆对象字面量"改成数据化路由表(数组,每项
{method, path, public, starterGated, handler, debugOnly}),由它同时生成routes/postRoutes/PUBLIC/STARTER_GATED四张表;启动时自检并打印:重复路径、缺 handler、写接口未声明是否 debugOnly、未登记进 STARTER_GATED 的写接口。
- 收益:把高频踩坑(漏加闸门、静默 404)变成启动即报;同时给 C-5 的调试门禁提供挂载点。
- 验收:故意加一个漏声明的新写接口,启动日志必须点名它;
GET /api/health等 59 个端点行为逐条对比改造前(写一个对拍脚本,遍历路由表发请求比状态码)。
C-5 生产构建剥掉调试出口低
src/main.js 13 处 window.__* · 6 处 window.__hall/__warehouse/__idle 等
- 做法:保留调试口,但用
if (import.meta.env.DEV)包裹,生产构建由 esbuild 常量折叠直接删掉。
- 收益:包体略降、避免玩家在控制台直接改作品状态(
__hall.recruit()之类能绕过 UI 走接口,虽然接口有校验,但暴露面没必要)。
- 验收:
npm run build后grep -c '__hall' dist/assets/*.js应为 0;dev 下验收脚本照旧可用。
C-6 契约挡在编译期低
后端 59 端点 / 前端 8 个模块各自拼 URL(src/api.js 等)
- 做法:建一份
src/specs/api.json(端点 + 参数 + 是否写口),两端各自 import:前端断言 URL 字面量在表内,后端用它生成路由骨架。不引入类型系统(项目基调是零依赖)。
- 收益:改名/删接口时前端立刻报错,而不是运行时 404。
- 验收:把某个接口改名只改一处,另一端跑起来立刻失败。
3.3 稳定性
C-7 后端入口再瘦一档:把"路由/鉴权"与"业务编排"分开中
server/index.mjs 1542 行 = 路由表 + 鉴权 + 静态伺服 + 跨 Store 编排 + admin + 进程兜底
- 做法:抽出
server/router.mjs(路由表 + 分发 + 闸门)、server/static.mjs(serveWebFile / serveDocFile / 注释剥离缓存),index.mjs只留 Store 装配 + handler 实现 + 启动。这是纯搬迁,风险最低但收益明确(index.mjs 是第二条开发线最常改的文件)。
- 风险:抽离顺序易出错 → 每抽一块立刻
node tools/guard.mjs+ 跑到该模块的 verify。
- 验收:
tools/test_account.mjs、verify_admin.mjs(48)、verify_logo_docs.mjs全绿;/docs穿越防护用例仍 403。
C-8 补最小工程护栏中
package.json:6-13 无 test / 无 lint / 无 CI / .git/hooks 全是 sample
- 做法:1
npm run test→ 跑tools/guard.mjs+ 一组"快脚本"(纯 HTTP 的几个,见 P1-9);2 加一个 gitpre-pushhook 跑 guard(不跑浏览器脚本,避免卡住提交);3 不做 CI 服务器,日常靠本地 hook + 发布前的deploy/prod/verify.mjs。
- 收益:把"发布后发现坏了"提前到"push 前发现",成本几乎为零。
- 注意:hook 必须可绕过(
--no-verify)且不依赖常驻服务在不在——服务不在时 hook 只提示不阻断。
3.4 安全
C-9 调试接口集中门禁(配合 S-1,见数据存储节点)高
server/index.mjs:304-305 注释自认"刻意不拦" · 涉及 1190,1227,1266,968,920,989,1077,1179,1185
- 做法:在数据化路由表(C-4)里给这些端点打
debugOnly: true,分发时统一判一个开关(见 D-9)。不要再靠"前端不暴露"——nginx 把 8000 全量反代到公网,前端不暴露 ≠ 接口不可达。
- 收益:一次性关掉"任意玩家无限刷资源/装备/功法 + 任意快进时间"这一整类风险。
- 验收:
ENABLE_DEBUG=0启动后逐条curl这 11 个端点全部 403;验收脚本需要它们时显式带开关跑(脚本里已有 token 引导层,加一个 header 或环境变量即可)。
四、优化矩阵 · 数据存储
判据:存档是唯一的资产(玩家进度 + 账号),它的第一优先级永远是"不丢、不错、能恢复",其次才是快。
4.1 性能
D-1 去掉写盘美化,体积与 CPU 双降中
server/atomicFile.mjs:19 JSON.stringify(data, null, 2) · 最大单文件 254KB · 47 个文件 >100KB
- 做法:默认
stringify(data)(无缩进);要人工看的话提供一个--pretty的离线导出脚本。
- 收益:体积约降 30~40%,stringify + write 的 CPU 与 IO 同步下降。这是投入产出比最高的一条。
- 注意:改动向后兼容(JSON 解析不看缩进),不需要迁移脚本;但要注意项目里有脚本会读存档文本断言内容,改完跑一遍涉及存档的 verify。
- 验收:随机取 3 个账号,改造前后
JSON.parse出来的对象深度相等;du -sh server/worlds前后对比。
D-2 写盘合并:一次业务操作只落一次盘中
一次 /api/idle/report 会写 resources 最多 5 次(gold 1 + 材料 ≤4)· equips.mjs:934,967 · resources.mjs:610-620
- 做法:把 equips 已经有的
_beginBatch/_endBatch模式上提到 Store 基类:RequestScope 开始时batchAll(),结束时flushAll(),中间所有_save()只置脏标记。等价于"请求级事务 + 一次落盘"。
- 收益:单次上报的写盘次数从 6~10 次降到每文件 1 次;直接缓解并发节点里"写盘阻塞全体"的问题。
- 风险:批量期间进程崩溃 → 丢的是一整批(当前是"丢后半部分")。配合 D-5 的提交顺序设计(先写钱、后写结果)可接受。
- 验收:在
writeAtomicJson里加计数,跑一次挂机上报,断言每文件写入次数 = 1;test_equips.mjs(155)、verify_exp_drops.mjs全绿。
D-3 建索引,去掉线性扫描中
server/accounts.mjs:132,186(线性扫 1246 用户 / 1289 会话)· heroes.mjs:544 · equips.mjs:303
- 做法:AccountStore 维护
byEmail/byToken两个 Map(加载时建、增删时同步);HeroStore / EquipStore 同理加byId。改成 Map 后注意所有遍历点要改成[...map.values()](顺序语义要保住,确认输出顺序是否有依赖)。
- 收益:每个需鉴权的请求省一次 1289 项数组扫描 —— 这是全站最热的路径。
- 验收:
verify_admin.mjs的"玩家列表顺序/分页"断言必须仍绿(顺序敏感);test_account.mjs登录/登出链路不变。
D-4 账号库拆分,登录不再重写 656KB中
server/accounts.mjs:50-60 每次 register/login/logout 全量重写 · 当前 656,401 B / 1289 sessions
- 做法:拆成
accounts.json(users,只有注册才变)+sessions.json(登录态,单独写)。会话条目加MAX_SESSIONS_PER_USER(如同账号保留最近 5 条)与"同设备复登复用"。
- 收益:登录写盘量降到几 KB;顺带解决"会话只增不减"(每次登录重写 656KB 的增长螺旋)。
- 注意:需要一次性迁移(读旧文件 → 分写两份 → 保留旧文件为
.bak),迁移必须幂等且可重入。
- 验收:迁移脚本跑两遍结果一致;老 token 在迁移后仍能通过鉴权(拿迁移前的 token 直接打接口)。
4.2 效果
D-5 读接口不再"顺手写盘"中
server/index.mjs:731,737(hall)· 759,763,764(resources)· 779(upkeep)
- 做法:区分两类副作用——结算(要落账)与自愈(补齐默认值)。结算保持在写接口路径上(前端本来就有 2s/5s 的写口:
/idle/progress、/idle/report),GET 只做纯推导展示;自愈改成"只在首次建档时做一次",之后 GET 不再落盘。
- 收益:GET 恢复语义纯粹,轮询不再产生 rename 抖动;爬虫/预取/探活不再触发写。这是当前"写放大"的主要来源。
- 风险:必须守住铁律——"已经花掉的账必须落账",所以能推导的(随时间累积的产出)才可以移出写路径,消耗类结算不能挪走。改前先逐条分类。
- 验收:改造后连打 20 次
GET /api/resources,writeAtomicJson计数为 0;前端资源数字仍随作品时间增长(verify_progress.mjs/verify_exp_drops.mjs)。
D-6 管理Admin改用增量索引,别每次全量扫盘中
server/admin.mjs:262-353,382-395 · 8s TTL 后同步扫 1329 账号 × 7~8 文件
- 做法:把"扫盘聚合"改成"懒加载 + 单账号缓存":只在玩家进入内存(
getWorld)时登记一条轻量索引行(等级/声望/在线时间);admin 列表读索引,明细才按 id 读盘。索引丢失可重建(重建脚本扫盘一次)。
- 收益:管理Admin刷新从"9,000 次同步读"降到"读一张内存表",同时去掉一个周期性卡顿源。
- 验收:
verify_admin.mjs(48 断言) 全绿;人工核对索引数据与直读盘数据一致(写一个对拍脚本)。
D-7 存档加"可审计快照"低
全仓无 .bak / journal / 校验和
- 做法:关键 Store(equips / heroes / resources)在每次成功写入前,把旧文件硬链或改名留一份
<name>.prev;异常时可用一条命令回滚到上一个一致点。
- 收益:玩家投诉"装备没了"时有据可查、可回滚;成本是每账号多 1 份磁盘占用(40MB → 约 80MB,可接受)。
- 验收:制造一次写失败(改权限或注入异常),确认
.prev存在且可解析。
4.3 稳定性
D-8 是最需要立刻处理的一条:server/accounts.mjs:39-48 的 _load() 在 catch 分支里调了 _save() —— 文件被截断/写坏/杀软锁定时,会用空账号库覆盖原文件,全服账号一次性消失且无备份。同款逻辑在 clock.mjs:50-59。
D-8 读档失败必须 fail-closed,绝不覆盖高
server/accounts.mjs:39-48 · server/clock.mjs:50-59
- 做法:1 解析失败时禁止
_save(),改为把原文件改名成accounts.corrupt-<ts>.json保留现场、写一份新的空库另起文件名,并在日志与/api/health里大声报错;2 启动时若发现.corrupt-*存在则拒绝启动(或进只读模式)等人处理;3 顺手加"启动即备份一份 accounts.json 到_backup/"(保留最近 7 份)。
- 收益:把"一次事故 = 全服账号清零"变成"一次事故 = 停机等人 5 分钟处理"。
- 验收:往 accounts.json 里写半个 JSON,重启服务 → 原文件被保留为
.corrupt-*,日志明确指出,服务拒绝带病运行;恢复文件后重启正常。
D-9 统一写盘实现 + 失败必须可见高
4 处内联 tmp+rename 无重试:clock.mjs:62-73 / buildings.mjs:145-155 / upkeep.mjs:56-70 / accounts.mjs:50-60 · 调用方全部忽略返回值
- 做法:1 全部改用
writeAtomicJson(或抽一个JsonFile小类,绝不再复制粘贴 tmp+rename);2 失败时改为抛出/记入"脏 Store"清单,由请求层决定回滚或返回 5xx,并在/api/health暴露dirtyStores;3 Windows 高频 rename 的 EPERM 已有 3 次重试,重试间隔从固定 4ms 改指数退避(4/16/64ms)。
- 收益:杜绝"内存新、磁盘旧"的静默分叉——这是最难排查的一类 bug。
- 验收:注入一次 rename 失败,断言接口返回 5xx 且
/api/health报脏;恢复后再次成功写盘自动清脏。
D-10 多文件提交定序,崩溃也有可解释状态中
/api/idle/report 跨 resources + heroes + equips + arts 四个文件
- 做法:固定提交顺序为「先写不可逆的消耗账,再写产出」——即先扣口粮/先落战斗消耗,再发金币材料与经验。崩溃时最坏结果是"玩家少拿一批产出"(可补偿、可查日志重放),而不是"白拿产出没扣消耗"。配合 D-2 的请求级批量,一次事务每文件只写一次。
- 收益:把不可解释的半完成状态收敛成"可解释的一侧"。同时保住铁律:消耗必须落账、掉落 roll 必须在后端。
- 验收:在四个写点之间注入进程Sign out,检查存档状态属于"少发"而非"多发";
verify_art_drop.mjs、test_equips.mjs全绿。
D-11 孤立数据 GC低
worlds 1329 目录 vs accounts 1246 用户 → 94 个孤立目录;测试号 1243 个
- 做法:写一个
tools/gc_worlds.mjs:1 默认--dry-run只列清单(账号无对应用户、>30 天无 mtime);2--apply时先备份到_backup/,再逐个移动到回收站(走项目已有的回收站删除方式,不用 rm);3 预留--keep-test保留测试号。
- 收益:回收磁盘、让管理Admin统计回归真实。
- 验收:dry-run 报告条数与实测一致;apply 后
/api/health的 worlds 数与账号数对得上。
4.4 安全
D-12 会话 token 不明文落盘中
server/accounts.mjs:175-181 randomBytes(32).toString('hex') 明文存 sessions · 当前 1289 条
- 做法:库里存
sha256(token),收到 Bearer 时哈希后比对;这样 accounts/sessions 文件泄漏≠会话可直接冒用。同时补:按用户吊销(改Password/登出全部设备)、会话数上限、运行期过期清理(现在_prune只在启动跑一次)。
- 收益:文件级泄露的影响面从"30 天内全部活跃会话"降到"无"。注意:一旦改了存储格式,所有旧 token 立即失效(玩家需重新登录)——发布时机要挑,或做"双读过渡期"(新 token 存哈希、旧 token 明文兼容并逐步淘汰)。
- 验收:
test_account.mjs登录→鉴权→登出全链路;登出后旧 token 401;同账号第 6 次登录把第 1 条会话挤掉。
D-13 写接口参数一律有界高
server/index.mjs:1190-1198(grant 金额无上限)· 968-972(advance 小时无上下限、可负数)
- 做法:把
numOrNull(v, lo, hi)作为所有数值参数的唯一入口(现在部分调用没传上下界),逐个补界:grant 单次 ≤ 10000、advance ≤ 24 且 ≥0、坐标/数量类走各自的合法域;越界返回 400 而不是静默钳制(钳制会掩盖前端 bug)。再抽一个"参数 schema"表,和 C-4 的路由表放一起。
- 收益:即使调试接口误开,也拿不到"无限"这个维度。
- 验收:
curl传amount=1e9、amount=-1、hours=99999全部 400;test_resources.mjs(58)、test_upkeep.mjs(39) 全绿。
D-14 幂等账本不可重放中
server/equips.mjs:1012-1017 waveLog 上限 800,超限删最旧 300
- 做法:把"最近 N 条"改成单调水位——记录每个
stageIdx已结算到的最大waveSeq,低于水位的请求直接判重放(O(1) 内存、无法被刷 key 绕过)。同时给上报带一个服务端下发的 nonce 或时间窗。
- 收益:堵住"刷 800 个新 key 再重放旧 key 领奖"。
- 验收:构造"先刷 900 个新 wave,再重报第 1 个"的用例,断言不再发奖;
verify_exp_drops.mjs(21)、verify_art_drop.mjs(15) 全绿。
五、优化矩阵 · 内存应用
判据:后端内存看"上限与淘汰",前端内存看"循环里有没有垃圾 + 异常会不会拖停整条链"。这个项目在有界容器上做得不错,问题集中在两处:THREE 资源零释放、主循环无隔离。
5.1 性能
M-1 干掉主循环里的每帧垃圾中
src/idle/idleBattle.js:537 [...this.units, ...this.mobs] · idleBand.js:911 每帧 filter · resources.js:744-748 每帧写 style
- 做法:1 战斗遍历改成两段独立
for(或维护一个复用的合并数组,只在增删单位时重建);2 飘字的清理改成环形缓冲/原地压缩(writeIdx紧凑写回,避免每帧新建数组);3 资源徽标的投影与transform写入按 30Hz 节流(相机不动时直接跳过)。
- 收益:长时间挂机的 GC 尖峰下降;低端机上帧时间更稳。
- 验收:挂机 10 分钟,DevTools Performance 里 Minor GC 频次明显下降;
verify_floater.mjs、verify_progress.mjs全绿。
M-2 大殿/仓库渲染改增量中
src/hall.js:775-780,825-854,885-948(每次 render 全量 innerHTML + 每元素重绑监听)· warehouse.js:1318-1325(无分页)
- 做法:1 大殿 render 拆成"结构重建"与"数据刷新"两条路径:换批/换人时重建,只有数值变化时只改
textContent/classList;2 交互统一改成容器级事件委托(仓库已经这么做了,把大殿也改过来,顺带消掉每次 render 的 N 次addEventListener);3 仓库加滚动分页(每屏 60 格滚动加载)。
- 收益:打开大殿/仓库的耗时与内存抖动下降;300 件装备不再一次产生 300 个图片请求。
- 验收:打开大殿 20 次,
performance.memory或 DOM 节点数不随次数增长;verify_hall.mjs(67)、verify_equip_ui.mjs(修好Sign out码后)全绿。
5.2 效果
M-3 给 THREE 资源一个统一的生命周期中
全项目唯一 dispose:src/flatBuildings.js:487 · 场景资源全部常驻至刷新
- 做法:加一个轻量
disposeTree(obj)工具(遍历释放 geometry/material/texture,纹理去重避免重复释放共享贴图),在"换场景/换区域/降级"时调用;不要给常驻对象滥用——先给三类真正的临时物:切换远景的旧纹理(backdrop.js:66-87)、按区域加载的挂机背景、动态生成的画布贴图。
- 收益:反复切换区域/长时间挂机时显存不再单调增长;
FLAT_ART模式下可主动释放 3D 山体与植被(它们仅visible=false)。
- 注意:共享贴图(如 5 条云带的
tex.clone())重复 dispose 会导致其他对象变黑 —— 必须按"创建者负责释放"的口径来。
- 验收:切换远景 20 次,
renderer.info.memory.textures稳定不增长;画面无变黑/丢贴图。
M-4 大贴图降规格与复用中
flat_mount.png 2.26MB / celestial_sun.png 2.09MB / moon 1.69MB · 5 条云带各 clone 一份纹理
- 做法:1 山体大图转 webp(保持视觉无损档位);2 日月贴图按屏幕上最大可能像素定尺寸,别按原图(它们只是个圆盘);3 云带用同一张纹理 + 不同
offset/UV 变换,避免 5 份克隆各自占显存(若必须独立滚动,至少共享同一 Image 源)。
- 收益:显存与首屏流量同时下降。
- 验收:截图对比(山体/日月/云带)无可见差异;
verify_flat_art.mjs(55)、_shot_flat_mount.mjs目检通过。
5.3 稳定性
M-5 是前端最值得立刻做的一条:main.js:1636-1674 的 setAnimationLoop 回调里依次调用 8 个子系统,没有任何 try/catch;项目历史上已经因此整条停摆过一次(漏 import 导致每帧 ReferenceError,表现为"背景在、动态元素全停")。
M-5 主循环异常隔离 + 全局兜底高
src/main.js:1636-1674(无 try/catch)· 全项目无 window.onerror / unhandledrejection · 历史事故见 .workbuddy/memory/2026-10-07.md
- 做法:1 主循环拆成逐子系统隔离:每个
tick(clouds / backdrop / celestial / daylight / timeClient / resources / idleBand / flatBuildings)各自包一层 try/catch,出错就跳过该系统并计数;2 连续 N 帧同一系统出错 → 自动禁用该系统(页面其余部分继续跑)并在角落给一个"某功能已停用,点此刷新"的提示;3 加window.onerror/unhandledrejection兜底,把首条错误写进window.__lastError(供验收脚本断言)并在控制台只打一次(防刷屏卡死)。
- 收益:把"整页停摆、玩家只能刷新"降级为"局部功能失效"。这是稳定性收益最大、改动最小的一条。
- 注意:不要静默吞异常——必须"计数 + 可见提示 + 可断言",否则会掩盖真 bug(项目铁律里明确反对"静默少测")。
- 验收:人为在
idleBand.update里抛错,页面其余部分(3D、资源栏、按钮)仍正常;window.__lastError可读到该异常;把错误去掉后刷新恢复。
M-6 world CACHE 加上限与淘汰中
server/world.mjs:32-37,115 永久 Map · 生产 PM2 max_memory_restart 600M(deploy/prod/ecosystem.config.cjs:110)
- 做法:给 CACHE 加 LRU 上限(按账号数,例如 500)+ 空闲淘汰(30 分钟未访问且已全部落盘则卸载)。卸载前必须先 flush 所有脏 Store,并保证下次访问能从磁盘完整重建(当前已经支持重建,风险低)。
- 收益:内存不再随"历史上访问过的账号数"单调增长,与 600M 护栏形成双保险。
- 注意:淘汰必须只淘汰已落盘且无在途请求的世界(与 N-9 的单账号队列配合),否则会丢更新。
- 验收:造 600 个账号轮流访问,
process.memoryUsage().heapUsed稳定;随机抽一个已淘汰账号再次访问,数据与淘汰前一致。
M-7 加长驻内存观测低
后端无内存埋点(仅 PM2 600M 硬重启);前端无 renderer.info / 无 FPS 常显
- 做法:1 后端每 5 分钟往日志打一行
heapUsed / worlds / 各 Store 实例数;2/api/health增加mem与dirtyStores字段;3 前端设置浮层的 FPS 改为"始终统计、按需显示",并加一行renderer.info.memory。
- 收益:内存泄漏从"靠症状猜"变成"看曲线"。开销极低。
5.4 安全
M-8 玩家 token 的存放位置中
src/api.js:25-32 localStorage['wuxia.token'] · 而角色名可含 HTML 且前端多处 innerHTML(见 L-19)
- 做法:短期——先把 XSS 面堵死(L-19)+ 上 CSP(L-17),让"XSS 偷 token"这条路走不通;中期再评估换 HttpOnly Cookie(会牵动全部 API 的鉴权方式与 dev 代理,属大改,不要和别的改动混在一起)。
- 收益:token 是账号的唯一凭证,值得单独设防。顺序很重要:先堵 XSS,再谈存储位置。
- 验收:注入
<img src=x onerror=...>类字符进角色名,页面上应显示为纯文本而非执行。
M-9 缓存与调试口不越界低
server/world.mjs:32 CACHE(按 userId 键,隔离本身正确)· dropWorld:311-314
- 做法:1
dropWorld后确认无残留引用(例如 admin 的索引行要一并清);2window.__*调试口按 C-5 在生产剥掉;3 任何新加的进程级缓存都必须按 userId 分区,不要出现"全局共享一份当前账号数据"的写法。
- 收益:防止未来加缓存时引入跨账号串数据的低级但致命的问题。
六、优化矩阵 · 并发
判据:并发在这里不是"高并发吞吐",而是三件事:单一事件循环别被同步 IO 卡住、同一份存档别被两个写者覆盖、别让轮询把 IO 放大。
6.1 性能
N-1 把同步 IO 从请求关键路径上挪开高
全部 Store 读写 + 静态整文件读 都是 *Sync(server/index.mjs:652 · atomicFile.mjs:23-25 · admin.mjs:262-353)
- 做法:分两步走,不要一次性全异步化(那会引入竞态,风险极高)。
1 先削峰:D-2 请求级批量(每文件每请求只写一次)+ D-5 去掉 GET 副作用 + D-1 去美化 → 写盘次数与体积先降一个量级; 2 再异步:只把"离关键路径最远"的写盘改成异步队列(例如管理Admin快照、日志、非关键计数),请求主链路继续用同步写换取"读-改-写原子性"。
- 收益:用最小的风险换最大的阻塞削减。保留同步写是当前架构的隐性优势(单进程内天然串行,不丢更新),不要为了"看起来优雅"把它拆掉。
- 验收:改造前后对比
GET /api/resources与一次/api/idle/report的耗时(curl -w '%{time_total}',各 50 次取 p95)。
N-2 轮询改条件轮询,并把"隐藏页停摆"修掉中
src/main.js:1663,1666(轮询由 rAF 驱动)· timeClient.js:17(2s)· resources.js:40(5s)
- 做法:1 请求条件化——拿回来的数据没变(用版本号/时间戳比对)就不渲染、不触发后续链;2 rAF 停摆:标签页隐藏时 rAF 暂停,时间同步与资源对账随之停 —— 明确改为
visibilitychange驱动(隐藏时降低频率到 30s 且用setInterval兜底),回来后立即补一次全量同步;3 服务器可下发nextPollMs做服务端节流(例如低活跃账号放宽)。
- 收益:42 req/min 的常态负载可下降一半以上;隐藏标签页不再是"数据停止更新"的黑洞。
- 验收:切到别的标签页 2 分钟后回来,时间与资源数字立即正确(不出现跳变或停滞);
verify_progress.mjs、verify_exp_ui.mjs全绿。
N-3 静态服务改流式 + 支持 Range中
server/index.mjs:651-663 readFileSync 整文件进内存
- 做法:
fs.createReadStream+ 显式Content-Length;支持Range(大 glb/webp 视频类可断点与 seek);配合 L-3 做 gzip/br 时注意只压文本、并对压缩结果做缓存或走 nginx。
- 收益:单响应不再占用整块内存;大文件可并行下载与断点续传。
- 验收:
curl -r 0-1023返回 206 与正确长度;下载同一文件时heapUsed不再随文件大小跳增。
6.2 效果
N-4 多端一致的"版本号"协议中
当前读写都靠时间轮询;同账号开两个标签页没有协同
- 做法:每个 Store 落盘时递增一个
rev,读接口一并返回;前端每次轮询带上上次的rev,服务端无变化就回304/空体。同时前端在收到"别人改过(rev 跳变)"时刷新对应面板而不是覆盖。
- 收益:省流量的同时解决"两个标签页互相覆盖"的观感问题,并为 N-2 的条件轮询提供统一判据。
- 验收:同账号开两个标签页,A 里招募后 B 打开大殿能看到新门人(或者在 B 操作时被明确提示已更新)。
N-5 实时性:把轮询升级为推送(可选,后置)低
现状:时间 2s / 资源 5s 轮询
- 做法:加一条 SSE(
GET /api/stream,服务端text/event-stream)推送低频但重要的事件:升级、掉落、自动刷新到点、维护公告。轮询退化为兜底(30s 一次)。
- 收益:挂机体验明显变好(掉落即时可见),同时常态请求量再降。放在 P2:先做 N-2/N-4 把轮询优化好,SSE 是锦上添花而不是救命。
- 注意:单实例下 SSE 连接会常驻,需要设上限与心跳(nginx 侧
proxy_read_timeout 300s已有,需配合心跳保活)。
6.3 稳定性
N-6 单账号写队列(进程内互斥)中
server/world.mjs:32(每账号一个 Store 集合,无锁)· 单进程同步模型当前"碰巧安全"
- 做法:给每个 world 加一个极简串行闸:请求进入写接口时
await world.lock(),结束release()。当前同步实现下这几乎不产生排队(因为本来就串行),但它把"不丢更新"从隐式依赖变成显式保证——一旦将来某处引入await(异步写盘、SSE),也不会立刻崩。
- 收益:为 N-1 第二步(异步化)与 M-6(淘汰 world)铺路。
- 注意:锁必须按 worldId 分区,且要有超时释放(防死锁把某个账号卡死)。
- 验收:并发 20 个写请求打同一账号,最终金币数 == 20 次预期之和(无丢失);
test_equips.mjs(155)、verify_rank*.mjs全绿。
N-7 跨进程双写防护(加锁文件)中
deploy/prod/ecosystem.config.cjs:32-35 自述"多实例会互相覆盖存档" · service_host.mjs:289-295 仅按端口防重
- 做法:启动时在
server/worlds/同级创建一个.lock文件(含 PID + 启动时间),若已存在且 PID 活着则拒绝启动并打日志。/api/health 暴露instanceId。
- 收益:把"别开第二个实例"从文档约定变成机器拒绝——实践中这类事故 100% 源于"以为端口没占着"。不引入 cluster(守住单实例铁律)。
- 验收:故意起第二个实例(换端口),第二个必须拒绝启动并说清楚原因。
N-8 优雅Sign out(Windows 侧也要能落盘)中
server/index.mjs:1537-1542 有信号处理,但 Windows 侧停止全是强杀(service_host.mjs:248-254 / service_install.py:284 / dev.mjs:23-28)
- 做法:Windows 下
SIGTERM不可用 → 加一条控制通道:守护进程停止子服务前先请求POST /api/shutdown(带一个仅存于 PID 文件旁边的一次性密钥),服务端 flush 全部脏 Store 后优雅Sign out,超时(如 5s)再taskkill /F。
- 收益:批处理/部署/重启不再存在"最后一批改动没落盘"的窗口。
- 验收:制造脏 Store(批量写但不 flush),触发守护停止,重启后数据完整。
N-9 显式设置 HTTP 超时与请求体上限中
server/index.mjs:144-154(超限直接 req.destroy(),无 413)· 未设 keepAliveTimeout/headersTimeout/requestTimeout
- 做法:显式设
keepAliveTimeout(5s,且要大于 nginx upstream 的 keepalive 行为,避免竞态 502)、headersTimeout、requestTimeout;body 超限改成先回413再关闭;给 body 读取加超时(例如 30s 无数据即断)。
- 收益:慢连接攻击面收敛;客户端能得到明确Error codes而不是"连接被重置"。
- 验收:发 2MB body 收到 413;慢速 body 被断开;nginx 反代下压测无 502。
6.4 安全
N-10 登录/注册限流 + scrypt 异步化高
server/accounts.mjs:64 scryptSync · index.mjs:1410-1445 无任何频率限制 · 全仓无 rate-limit
- 做法:1
scryptSync→ 回调版crypto.scrypt(真异步,不占主线程);2 加双层限流:按 IP(如 10 次/分钟)与按账号(如 5 次/分钟 + 连续失败 10 次锁 15 分钟);3 失败响应统一延迟(如固定 300ms)并做定长时间比较(现在已做);4 限流状态放内存即可(单实例),无需 Redis。
- 收益:同时堵住"撞库爆破"与"用登录接口 DoS"两条路。
- 注意:限流要留一个"本地白名单"(127.0.0.1),否则
tools/_bootstrap.mjs之类的自测会把自己限死。
- 验收:脚本连打 20 次错误Password → 第 11 次起 429/锁定;本地 127.0.0.1 不受影响;
test_account.mjs全绿。
N-11 补并发安全测试中
全仓无压测/并发/越权测试(tools 235 脚本里 0 个)
- 做法:写 3 个新脚本:
test_concurrency.mjs(同账号 50 并发写,断言账目守恒)、test_isolation.mjs(A 的 token 访问 B 的资源必须失败;改路径参数/伪造 userId 全试一遍)、test_limits.mjs(恶意参数矩阵:负数/超大/NaN/超长字符串/非法枚举)。
- 收益:把"并发与越权"从"没人测过"变成"每次改动可回归"。这三类测试也正是当前测试体系的空白。
- 验收:脚本本身要有非零Sign out码(对照 P1-9 的教训),并纳入
npm run test。
七、优化矩阵 · 前端加载
判据:玩家第一次打开页面(或每次冷启动)到能看到 3D 场景并能点的时间,以及这中间下载了多少字节。
7.1 性能
L-1 拆包:three 与作品代码分开中
dist/assets/main-C50Sbec8.js 867,320 B 单 chunk(gzip 249KB)· vite.config.js:19-24 无 manualChunks
- 做法:
build.rollupOptions.output.manualChunks里把three拆成vendor-three(它更新频率远低于作品代码,命中长缓存后玩家只需下作品那部分);再配合 C-2 的按需 import,自动产生面板分片。可选加modulepreload预热关键分片。
- 收益:改一次作品逻辑,玩家不再重下 three(约 500KB+ 原始体积)。首屏可并行下载分片。
- 验收:
npm run build后 dist 出现 ≥2 个 js 分片;改一行作品代码重新构建,three 分片文件名不变(hash 稳定)。
L-2 把 CSS 注释也剥掉中
tools/build.mjs:90-111 只剥 HTML 注释 · dist/index.html 的 style 里 242 块 / 65,635 B 注释
- 做法:把已有的
strip_html_comments.mjs扩展成也能处理<style>块内的/ /(注意别把content:"/*"这类字符串里的误剥);用已成熟的strip-comments式状态机或 esbuild 的 CSS minify(vite 默认会 minify JS 但不 minify 内联在 HTML 里的 style)。
- 收益:HTML 体积直接省 ~40%(179KB → 约 115KB)。注意:
/docs/*伺服也走同一剥离函数,改完要跑verify_logo_docs.mjs,确认文档页样式没被剥坏。
- 验收:
dist/index.html里不再有/*注释、页面样式截图前后一致。
L-3 后端补 gzip/br(当前最大的传输浪费)高
server/index.mjs:655-663(res.end(buf) 原样)· 全仓无 zlib · nginx 只压文本类
- 做法:两条路选一条(或都做):
1 nginx 侧全量化:把 gzip 类型补到 *(或至少补全 json/js/css/html/svg),对已压缩格式(webp/png/jpg/glb)加 gzip off 例外; 2 Node 侧:对文本类响应做 zlib.gzipSync 并缓存结果(大文件如 867KB js、281KB meta json 收益最明显),按 Accept-Encoding 协商;静态大文件仍建议交给 nginx。
- 收益:JS 867KB → 约 249KB(已实测 gzip 后为 249,414 B);meta json 与 index.html 类似比例。这一条对真实玩家的影响最大。
- 验收:
curl -H 'Accept-Encoding: gzip' -sI <url>返回Content-Encoding: gzip且Content-Length显著下降;webp/glb 请求不带 gzip。
L-4 首屏大图条件化中
celestial.js:89,104-107(日月无条件加载)· main.js:497,597(rock_a/plants 在 FLAT_ART 下仍加载)· flatScene.js:133
- 做法:1 日月贴图改为"进入对应时段前 N 分钟才加载"(或用一张图 atlas 切两半);2
FLAT_ART=true时不加载 3D 专用资产(rock_a.jpg 585KB、cloud.jpg、plants.glb);3 flat_mount.png 转 webp 并压到合适尺寸;4 云带三张按视距分级(远处低清)。
- 收益:首屏少下约 5~6MB,直接缩短"能看到 3D 场景"的时间。
- 验收:首屏 network 面板总字节对比(改造前后截图留档);视觉截图无退化。
L-5 素材加指纹,让 420+ 张图走长缓存中
server/index.mjs:621-661(仅 hash 命名的 js/css 走 immutable)· 美术素材统一 no-cache+ETag
- 做法:构建时给
assets/生成内容哈希清单(assets/manifest.json),前端用清单取真实文件名;服务端对assets/下的资源一律immutable, max-age=31536000。或**低成本方案:给素材 URL 统一带一个构建版本 query(?v=<buildHash>)。
- 收益:从"每次刷新几百个条件请求"变成"零请求"。这是 L-4 之外对二次访问体验最直接的改善。
- 注意:素材是"生成 + 人工 relay"进 public 的,加指纹后必须保证 relay 流程会更新清单(见 L-8)。
- 验收:二次刷新时 assets 请求命中
from disk cache;改一张图重新构建后该图 URL 变化且能取到新图。
L-6 列表分页 + 图片懒加载中
warehouse.js:1318-1325(无分页,全量 innerHTML + 每件一条 img)· 全项目 loading="lazy" / IntersectionObserver 均为 0
- 做法:仓库/名册/图鉴类列表统一用
loading="lazy" decoding="async"+ 可视区虚拟化(只渲染屏幕内 ±1 屏);图片统一走equipIcon.js/artIcon.js的拼接函数(已有两级 onerror 兜底)。
- 收益:仓库满仓(300 件)时的打开耗时与瞬时请求数大幅下降。
- 验收:仓库 300 件时打开,network 请求数从 300+ 降到几十;滚动到底部能正常加载。
L-7 清死资产(约 25MB 白打包)中
public/assets/buildings/.glb 11 个约 20MB 前端 0 引用 · portraits/turn_.webp 36 张 4.7MB 0 引用 · idle_sheet.webp、p_hero_*.png 6 张
- 做法:先从 dist 与 public 移出到
_out/dead-assets/(不是直接删,走回收站/备份目录),保留一版 git 历史;同时给guard.mjs加一条"assets 里的文件必须被 src/** 或 actions.json 引用"的检查,防止再次堆积。
- 收益:dist 与仓库各瘦 25MB;发布上传变快。
- 验收:移出后全量跑一遍 UI 截图脚本,无破图;
guard.mjs新检查项通过。
7.2 效果
L-8 首屏要有"进度感",而不是白屏中
src/main.js:112,125,297,701 四个串行 await 期间无任何 UI 反馈
- 做法:把登录背景图(
assets/scenery/login_bg.webp,已存在)作为静态首屏底图直接写在 HTML/CSS 里(不依赖 JS),上面叠一个极简进度指示;JS 初始化完成后淡出。3D 场景 ready 后再做一次"入场"。关键:首屏底图必须是 HTML 里就有的,不能被 JS 阻塞。
- 收益:从"白屏 2~5 秒"变成"立刻看到水墨背景 + 进度"。观感差异巨大,成本很低。
- 验收:限速到 Slow 3G,刷新后 1 秒内能看到背景;Lighthouse 的 FCP 显著改善。
L-9 图片规格与格式的分档策略低
webp 1170 张 / 119.6MB · png 53 张 / 18.1MB(含 celestia_* 与 flat_mount)
- 做法:1 按显示尺寸出图(2x 封顶),大图(山体/日月/云)单独出低清占位 + 高清替换;2 评估 AVIF(three 的
TextureLoader可通过KHR_texture_basisu或直接ImageBitmap走,但要先在一个资产上验证兼容性,别一次全换)。
- 收益:主资源目录继续瘦身。注意:项目铁律"生图一律模型出图、程序只拼装"——这里是后处理环节,压缩/缩放属允许范围,但不要用程序去"修图"。
7.3 稳定性
L-10 首屏 await 全部加超时 + 重试 + 降级高
src/main.js:112,125,297,701 · src/api.js:152-173(无 AbortController / 无重试)· 全项目唯一超时在 idleWorld.js:1426(2500ms)
- 做法:1
api.request()统一加AbortController超时(如 8s)+ 一次退避重试;2 首屏四个 await 改并行(/game/meta、/time与/auth/me之间无依赖,只有/resources需要 token)——注意/resources必须在登录之后;3 每个 await 都要有失败降级:meta 失败进重试页、time 失败用本地时间并标"未同步"、resources 失败显示占位并Admin重试,绝不阻塞setAnimationLoop注册(先跑起来,数据后补)。
- 收益:后端慢/挂时玩家能看到场景与提示,而不是黑屏无反应。
- 验收:把后端停掉,刷新页面 → 能看到底图 + 明确提示 + 自动重试,控制台无未捕获异常;后端恢复后自动进入作品。
L-11 统一资源加载失败兜底低
equipIcon.js:67-73 有二级兜底 · artIcon/portraits/idle 素材多数没有
- 做法:所有
<img>走统一的imgWithFallback(el, urls[]);挂机条带的loadImageRetry(idleBand.js:199-213)推广为公共工具;3D 纹理加载失败回退到纯色/占位贴图而不是留在"未定义纹理"。
- 收益:单张素材缺失不再导致整块 UI 破图或渲染异常。
L-12 弱网/离线态低
timeClient.js:60-62 已有 online 标记(局部);其余模块各自处理
- 做法:统一一个全局
netState(online / degraded / offline),顶部一条细提示;离线时挂机条带继续用本地插值跑表现,但不生成任何"要落账"的东西(守住后端权威:离线期间的产出等回到线上由后端按 gameSec 推导)。
- 收益:断网时的行为可解释;也顺带解决"隐藏标签页回来数据不对"的体验问题。
7.4 安全
L-13 补安全响应头(现在一个都没有)中
全仓 grep Content-Security-Policy|X-Frame-Options|X-Content-Type-Options|Referrer-Policy 零命中(含 index.html / admin.html / nginx.conf)
- 做法:1
X-Content-Type-Options: nosniff(防资源类型嗅探,最低成本最高收益);2X-Frame-Options: DENY(或 CSPframe-ancestors 'none')——/admin尤其需要;3 CSP 用渐进式:先上Content-Security-Policy-Report-Only收集违规,再切强制;注意作品用了blob:/data:画布纹理与 canvas,CSP 里要放行img-src 'self' data: blob:。4Referrer-Policy: same-origin。
- 收益:堵住点击劫持、资源嗅探、以及"XSS 偷 localStorage token"这条链(配合 M-8)。
- 验收:浏览器控制台无 CSP 违规刷屏;页面全功能(含截图脚本)正常;
deploy/prod/verify.mjs全绿 + 新增响应头断言。
L-14 角色名 XSS 两头堵中
server/heroes.mjs:678-694(长度 2~8,无字符黑名单)· src/hall.js:813,1109,1346,1422,2111 直接 innerHTML 插值
- 做法:1 后端白名单字符集(中文/字母/数字/常见符号),拒绝
<>&"';2 前端加统一esc()(admin.html 已有esc(),可复用同一实现思路),所有把玩家可控字符串插进 HTML 的地方一律过一遍;3 顺手清理其他玩家可控字段(队伍名等)。
- 收益:存储型 XSS 面归零。这是 M-8 / L-13 的前置条件,优先级高于换 token 存储位置。
- 验收:用
<b>x</b>、<img onerror>等构造名字,注册/改名应被拒;直接用接口写入的脏数据在界面渲染为纯文本。
L-15 依赖与发布面收敛低
运行时依赖仅 three 0.180;devDeps vite 7 / playwright 1.63 · 无 CI 无 audit
- 做法:1
npm audit纳入发布前检查(playwright 只用于本地测试,不进生产包);2 three 升级前先在_gen_的离线 demo 页验证渲染无回归;3 生产服务器只跑server/+dist/,*不要把 tools/ 与 _gen_ 传上去*(当前 push.sh 已是这个范围,保持住)。
- 收益:供应链面收敛在生产极小集合上。
八、落地路线图
原则:P0 全是"小改动、大收益、不碰架构"的;任何"重写/架构级"的改动都放到 P2,且必须能被单独立项回退。每批做完跑一次对应的验收脚本,改前先 node tools/guard.mjs。
P0 — 先补护栏(建议当周完成,预计总改动面集中在 6 个文件)
| 事项 | 动到的文件 | 验收 |
|---|---|---|
| C-9 + D-13 调试接口门禁 + 参数有界 | server/index.mjs(路由与 grant/advance 分支) | 11 个调试端点全 403;越界参数 400;test_resources / test_upkeep 全绿 |
| D-8 accounts/clock 读档失败保护 | server/accounts.mjs · server/clock.mjs | 故意写坏文件 → 原文件被保留为 .corrupt-*,服务拒绝带病启动 |
| N-10 登录限流 + scrypt 异步 | server/accounts.mjs · index.mjs(auth 段) | 连打 20 次错误Password被限;127.0.0.1 白名单不受影响;test_account 全绿 |
| M-5 主循环异常隔离 + 全局兜底 | src/main.js(1636-1674 + 顶部) | 注入异常后页面其余部分仍动;__lastError 可读 |
| L-10 首屏 await 超时/并行/降级 | src/api.js · src/main.js(112-125,297,701) | 停掉后端刷新 → 有底图与提示、不黑屏;后端恢复自动进入 |
| D-9 写盘统一 + 失败可见 | clock/buildings/upkeep/accounts 的 _save | 注入 rename 失败 → 接口 5xx 且 /api/health 报 dirtyStores |
| D-11 清测试号与孤立目录(先 dry-run) | 新增 tools/gc_worlds.mjs | dry-run 清单与实测一致;备份目录存在 |
| L-3 gzip/br(可先在 nginx 全量开) | deploy/prod/nginx-idle-wuxia.conf | curl -I 带 gzip;webp/glb 不带 |
P1 — 性能与体验(建议两周内)
- 包体与首屏:L-1 拆包 · L-2 剥 CSS 注释 · L-4 大图条件化 · L-5 素材指纹 · L-6 列表分页 · L-7 清死资产 · L-8 首屏底图与进度
- 存储:D-1 去美化 · D-2 请求级批量 · D-3 建索引 · D-4 账号库拆分 · D-5 读接口去副作用 · D-6 管理Admin改索引
- 内存/并发:M-1 消每帧垃圾 · M-2 大殿仓库增量渲染 · M-3 资源释放 · M-6 CACHE 上限 · N-2 轮询条件化 · N-6 单账号写队列 · N-7 锁文件 · N-9 HTTP 超时
- 测试体系:修 16 个"红绿都退 0"的脚本 · 统一服务地址口径(API/API_URL/WUXIA_BASE 三套合一)·
npm run test入口(guard + 快脚本)· N-11 并发/越权/参数三个新测试
P1 里 D-2 与 N-6 建议同批做(都动写盘路径,一起验收更省事);L-1 与 C-2 建议同批做(拆包与动态导入互相成就)。
P2 — 工程化与长期健康(一个月内,逐项独立立项)
- 结构:C-3 拆 hall.js · C-7 拆 index.mjs(router / static 分离)· C-6 契约文件 · C-4 数据化路由表
- 数据:D-7 存档 .prev 快照 · D-10 提交定序 · D-12 token 哈希存储(选好发布窗口)· D-14 幂等水位 · D-6 索引化
- 运行时:N-4 rev 协议 · N-5 SSE 推送 · N-8 优雅Sign out通道 · M-7 内存观测 · M-9 缓存分区规范
- 前端:L-9 图片规格分档 · L-11 统一兜底 · L-12 离线态 · L-13 CSP(先 Report-Only)· L-15 依赖审计
- 管线:把"人工 relay 进 public"的几处(flat_mount / celestial / logo / _out 合成图)脚本化,补齐 L-5 需要的素材清单生成;
guard.mjs加"素材必须被引用"与"会话/存档体积上限"检查
九、红线:这些铁律不能破
上面的方案里有几条(D-5 / D-10 / N-1 / N-6 / L-12)会碰到架构根部,实施时必须守着下面这些既有约定,否则优化会把玩家的账算坏。
| 铁律 | 对本方案的具体约束 |
|---|---|
| 后端权威 + 前端插值 | 任何"让前端算"的优化都只允许改表现(插值、动画、预测),入账必须留在写接口里。L-12 的离线表现为例:离线期间可以画,不能发。 |
| 产出可推导、消耗必落账(幂等) | D-5 去副作用时,只能把"随时间累积的产出"移出 GET;消耗类结算(扣粮、扣材料、扣金币)不能挪,且必须保持 lastFeedSec 式幂等。 |
| 时间一律挂 clock.gameSeconds() | N-2/N-4 改轮询与 rev 时,不要用 Date.now() 或 now().hour 做结算判据(now().hour 只给显示)。改流速前先固化锚点。 |
| 单实例,world 是进程内缓存 | N-7 是"加锁拒绝第二实例",不是"上 cluster"。任何多实例/多进程负载方案在本架构下都会互相覆盖存档 —— 不做。 |
| 多会话并发开发 | 改文件用明确路径、git add 绝不用 -A;同一文件混了两条线的改动要逐 hunk 暂存;绝不"先杀占端口的进程再起"。 |
| 自测脚本默认打 8000 | P1 统一服务地址口径时,默认值必须是正式常驻服务 8000;临时实例只允许显式覆盖。 |
| 生图一律模型出图 | L-4/L-9 的图片后处理只允许"缩放/转码/裁切",不允许用程序去修图或重画。 |
| 验收断言别写死 CSS 类名 / DOM / px | M-2 改渲染结构时,同步修正依赖旧 DOM 的脚本;但不要为了让旧断言通过而保留旧结构——脚本该改就改,别留"⏭ 跳过"。 |
| 覆盖前必须备份 | D-11 清数据、L-7 移死资产、D-4 迁移账号库,全部先备份到 _backup/ 或 _out/,删除走回收站而非 rm。 |
最后一句话:这套代码的玩法设计是干净的(后端权威、锚点时间、幂等结算、有界容器、按需加载),真正缺的是工程护栏——限流、异常隔离、统一写盘、包体与传输优化、以及"能自动跑的测试"。
上面 P0 的 8 件事加起来改动面只有 6~8 个文件,却能把"全服账号可被清零""经济系统对玩家开写权限""玩家端整页停摆""首屏黑屏 5 秒"这四类最疼的问题一次性按下去。建议就从这里开始。