POST /chat/upload 就能把 30GB 的系统盘写满(实测线上盘 22G 可用 / 24%,而单账号单日可写入的字节数没有任何上限);?t=<exp>.<hmac>,有效期内可无限重放 —— 反复拉同一个大附件即可持续占用服务器出口带宽,而本机在境外,出口流量是按量计费的。四道闸门(全部落在服务端,客户端只做提前提示):
| 闸门 | 位置 | 口径 | 被拒时会怎样 |
|---|---|---|---|
| ① 配额 | services/chatMediaService.js 的 uploadGate(),排在 multer 之前 |
按账号(不按 IP,换 IP 绕不过):60 次/小时、200MB/天、留存 500MB;另有 Content-Length 预检(主文件+小图+64KB 余量) |
被拒请求一个字节都不落盘;超 21MB 的请求在读第一个字节之前就被 413 |
| ② 磁盘水位 | 同上 assertDiskHeadroom() |
整机剩余 < 2GB 拒绝一切上传(fs.statfsSync;探测不可用则跳过而不是误杀) |
配额是「每人」,水位是「整机」 |
| ③ 归属 | 发消息时 resolveAttachments() |
地址必须是本站 /chat/files/;文件必须真实存在;台账归属必须是本人;一条消息最多 9 张图 |
403「不能引用他人上传的文件」/ 422「附件不存在或已过期」 |
| ④ 回收 | 每小时 sweepOnce() |
孤儿(上传了却从没发出去)24 小时删除;已引用媒体 180 天释放字节;撤回消息立即释放 | 磁盘只涨不减的问题闭环 |
chat_media_assets(sql/020_v3.43_chat_media_guard.sql)是唯一事实来源:记 stored_name / user_id / file_size / message_id / purged_at,四个索引分别服务「按人用量」「孤儿扫描」「保留期扫描」「按消息反查」。上传即入账(此时 message_id 为空=孤儿),只有真正发出去才由 bindAttachments() 认领 —— 两段式设计让「上传了但没发出去」天然可识别。派生小图 <主名>.w480.<ext> 不单独建账,但跟着主文件一起删。lib/mediaLimits.js):媒体路径从通用限流(120/min/IP)里 skip 出来,改由独立限流器计数(300/min/IP —— 校园 NAT 几百人共享一个公网 IP,沿用 120 会误伤正常浏览);真正的兜底是按 IP 的字节预算 1GB/10min,在 sendMediaFile 之前 allows()、之后 consume(),超限 429「下载流量已超出限制,请稍后再试」。ChatInput.vue 在压缩后仍 >8MB 的图片、>20MB 的文件上直接弹提示并不发起请求(服务端仍然独立判定,客户端改动不构成绕过面)。内测机实测取证(15 项,逐条贴在下面):
| # | 检查 | 结果 |
|---|---|---|
| 1-4 | 迁移 020 应用、台账表 + 4 索引建成、启动日志「回填历史文件台账 18 条」、健康检查 200 | ✅ |
| 5 | 正常上传 12,415 字节 PNG → 200,台账立即可见 kind=image orphan=true |
✅ |
| 6 | 带票下载 → 200,12,415 字节字节数一致 | ✅ |
| 7 | 21MB 文件 → 413「单个文件超过 20MB 上限」,磁盘文件数不变(multer 自清,不留半截文件) | ✅ |
| 8 | 8.5MB 图片 → 413「图片超过 8MB 上限,请压缩后再发送」(落盘后立即删除) | ✅ |
| 9 | 25MB 请求 → 413「请求体积超过上限」(走 Content-Length 预检,读字节前就拒) |
✅ |
| 10 | 预置 60 条记录后上传 → 429「上传过于频繁(每小时最多 60 次)」 | ✅ |
| 11 | 预置当天 250MB 后上传 → 429「今日上传流量已用完(每天上限 200MB)」 | ✅ |
| 12 | 预置留存 600MB 后上传 → 413「聊天文件存储已满(上限 500MB)…」 | ✅ |
| 13 | 清空探针后立即恢复 → 200(闸门不会误伤正常上传) | ✅ |
| 14 | 媒体路径连打 130 次全部 200(证明媒体已从通用限流 120/min 里豁免,否则必然 429) | ✅ |
| 15 | 非媒体接口连打 130 次 → 120 次 200 + 10 次 429(通用限流仍然生效,豁免没有把限流整体打漏) | ✅ |
[chat-media] 回收完成:孤儿 1,过期 1,残留 0,释放 0MB;孤儿文件与台账行一并消失,过期文件被删而台账行保留且 purged_at 已置位 —— 「删字节、留审计」的取舍按设计生效。内测机上那 13 个孤儿测试文件也将在各自 24 小时到期后自动消失(这是它第一次有了出口)。server/test/chatMediaGuard.test.js(18 例:配额三档判定与优先级、上限顺序、磁盘水位、闸门放行/拦截、台账缺失时降级不 500、声明体积预检、被拒请求零落盘、类型上限 413、mediaNameOf 与媒体路径识别、字节预算窗口与过期、跨账号 403、未知/站外附件 422、撤回释放主图+派生小图、sweepOnce 三类回收、backfillUntracked 归属回填、下载预算 429);新增 server/test/mediaCleanup.unit.test.js(7 例:任务幂等启动/.unref()/停机不复活、启动必打「闸门已武装」日志、派生图配对、kindOfName、limitsSnapshot、真实 freeBytesOf)。src 0 error / 71 warning(原 74,新代码顺带清掉 3 个既有 warning);vite build 成功,dist/index.html 637,226 字节。must be owner of table notices。原因是 notices/chat_messages 是历史上在线上手工建表的,属主是 postgres,而迁移执行器用的是应用角色 qbao(CREATE INDEX ... ON notices 需要表属主)。也就是说:这条迁移从写出来那天起在生产就永远跑不通,只是之前没人真的在生产上跑过它。处置:把 notices(及 notices_id_seq)属主改为 qbao(与 users、库属主同口径),迁移随即通过;chat_messages 属主仍是 postgres(本次不需要它),已记进 todo:将来任何动 chat_messages 结构的迁移都会以同样方式失败,届时先 ALTER TABLE chat_messages OWNER TO qbao。beta.questionbox.cn:服务端 + 客户端包体均已部署,迁移 020 已应用(第 20 条),15 项实测全通过,内测冒烟 5/5;questionbox.cn:已部署(服务端 3.37.7、客户端 dist/index.html 637,226 字节,SHA256 da29cbf8… 与本地 app/dist 逐字节一致),迁移 018 与 020 已应用(共 19 条),金丝雀只读巡检 4/4、健康检查 200、notices 索引补齐;生产 uploads/chat 为空、台账 0 条(没有历史文件需要回填,故无回填日志)。cf-ray 在场),而 trust proxy = 1 下 req.ip 取的是 XFF 最右一跳 —— 即 CF 边缘 IP,理论上多用户共用一个下载预算键。实测该机全机出口只有 1.6 KB/s(60 秒采样 97KB),而预算是 1.7 MB/s(1GB/10min),余量约 1000 倍,现实流量不可能触发;故意不改用 CF-Connecting-IP/XFF 左值:源站 IP 是公开的(beta 域名直连源站),直连伪造头即可让闸门形同虚设 —— 宁可接受「共键」也不接受「可绕过」。identity_notices 与 marble_redo)、019 是 marble_rollback,这两个文件只存在于内测主机、不在仓库里;本次新迁移因此排到 020(原按 019 命名,发现撞号后改名)。内测库因此无法由仓库单独重建 —— 建议后续把这两个文件收进仓库或明确标注为一次性热修。message_id 回填)→ 引用他人上传的文件 403「不能引用他人上传的文件」→ 引用不存在的文件 422「附件不存在或已过期」→ 站外地址 422「附件地址无效」→ 撤回 200,releasedMedia=1(文件从磁盘消失、台账行删除,且别人名下的文件没有被误删)。四道闸门里最容易被漏掉的「发送时归属校验 + 撤回立即释放」因此在线上被证过一次,不是只有单测。limitsSnapshot() 里根本没有 maxImageBytes(undefined 经 formatMB 变成 0MB)。补齐缺项,并把快照里每一个上限都加进断言(类型 / 有限 / >0)。现在两个环境都打出:[chat-media] 回收任务已启动:每 60 分钟一次;孤儿 24 小时、保留 180 天;单文件 20MB、图片 8MB、每人每日 200MB。(一句谎报比没有日志更糟,这类「日志里的数字必须是真数字」的问题只能靠断言兜。)main 1638436..9a0123e + 标签 v3.37.7(84fbe14);Release「桌面版构建」success;pages 同步 success;CI 6/6 jobs 全绿。Qbao-Setup-3.37.7.exe | 86,310,245 字节 | sha256 523fe5a05894f1521190c9b4376bd6cf71db8248e4fab016788b0ff049c6b5e5 |
| Qbao-Setup-3.37.7.exe.blockmap | 91,098 字节 | sha256 fb8687661929f2a3fa63f7842682f941fef866e08e92647d6af82f832e209d5d |
| latest.yml | 339 字节 | version: 3.37.7;sha512 与安装包实测值 一致 ✅ |
三个资产均 HTTP 200 可下载;把安装包与 latest.yml 都拉下来实测 sha512(base64),与清单里写的 27juhDsAbAaPYIb7bq16FSGEDy9Nt/X0zCBrEm+40AQcNGsQIH1OSBJd1+3ofnD0QYiBJ56QrB0LSxwDQPTtOA== 完全一致 —— 桌面端自动更新通道指向的文件哈希正确,客户端升级不会因哈希不符而失败。/dl 落地页)全部只认服务器上的 downloads/manifest.json。漏做 publish-installer add(DSH 流程里的 ⑪)= 用户端等于没发布。故障态的实测证据:补做前 /api/v1/desktop/latest 返回的还是 3.37.0(发布于 09-06)。docs/PUBLISHING.md §3 走签名直链搬包 → publish-installer add --channel stable --notes "…" --prune --keep 3 → stable 清单变为 3.37.7 / 3.37.0 / 3.36.0(3.35.0 安装包按留存策略剪枝,GitHub Release 上仍可下载;旧客户端不受影响 —— 「不在清单」不等于「已撤回」,这条 v3.37.1 已修)。523fe5a0…5e5 = 本机实测 = 入库记录,三者一致 ✓;工具内部再把 latest.yml 的 sha512/size 与安装包逐字节交叉校验。/api/v1/desktop/manifest?channel=stable 首位 3.37.7;/api/v1/desktop/latest → 3.37.7;generic feed /api/v1/desktop/update/stable/latest.yml → HTTP 200 + version: 3.37.7;/api/v1/desktop/download?file=Qbao-Setup-3.37.7.exe → HTTP 200 / 86,310,245 字节 / sha256 与清单一致;/dl 落地页已展示 3.37.7。desktop/updater-util.js):当前 3.37.0 / 3.36.0 / 3.37.6 → hasUpdate=true,且无误报「已被撤回」、无强制更新;当前 3.37.7 → 无更新。桌面端 package.json 已是 3.37.7(与 tag 一致,Release workflow 会逐步断言),因此装完后版本上报正确、下一次更新不会误判。docs/PUBLISHING.md §1 增加显著警示「Release 成功 ≠ 用户能看到更新」并附 30 秒自检命令(curl -s https://<host>/api/v1/desktop/latest),§6 故障判定表新增一行「桌面端查不到更新 → 漏做 ⑪ 入库」。backend 的 npm run lint 是 eslint .,覆盖 test/;而 server/eslint.config.cjs 的 vitest 全局清单里漏登记 test/beforeAll/afterAll → 6 个 no-undef error。本地只跑 npx eslint src 永远发现不了(src 一直是 0 error)——这是这次踩坑的真正教训。secret-scan(gitleaks):.gitleaks.toml 只豁免 sk-test-key-* 与 test-secret-* 两种已登记的假值形状,而本次新增的测试夹具写成了 sk-live-test-key-…(像真的 OpenAI 线上 key)与 another-secret-…,被判成真密钥。修法是改夹具去适配已登记形状,而不是放宽豁免规则(放宽等于给真泄漏开口子)。该 job 在推送范围内扫描,所以表现为「代码提交红、纯文档提交绿」,很容易被误判成偶发。docs/DEVELOPMENT.md §6。b11fd47 主轮 → e1116dd 启动日志 → 3c0abe5 图片 0MB → ecbc70d CI 两条红线。标签 v3.37.7 指向 3c0abe5;ecbc70d 只动测试夹具 / lint 配置 / 文档,无运行时行为变更,因此不重发 Release。
cubic —— 它把任何丢包都当作拥塞并立即砍半窗口,于是单条 TCP 流实测只有 10.6 KB/s。关键放大器:浏览器对一个源只开一条 HTTP/2 连接,聊天里所有图片、整个页面都挤在这一条流上。于是一张 1.97MB 的图要 67 秒,9.4MB 的原图要 471 秒 —— 这就是「发出去等半天、打开聊天等半天」。修复:启用 BBR(net.ipv4.tcp_congestion_control=bbr + net.core.default_qdisc=fq,模块 tcp_bbr/sch_fq)。BBR 按「带宽 × RTT」建模而不是把丢包当拥塞,同一条流立刻跑满可用带宽:
| 本地(上海电信)↔ beta 源站 | cubic(改前) | BBR(改后) | 提升 |
|---|---|---|---|
| 单条 TCP 流(= 浏览器一条 h2 连接) | 10.6 KB/s | 126.9 KB/s | 12.0× |
| 4 条并发流合计 | 95.5 KB/s | 497.8 KB/s | 5.2× |
| 8 条并发流合计 | 125.4 KB/s | 833.6 KB/s | 6.6× |
| 真实文件 9.4MB 端到端 | 471,203 ms | 22,370 ms | 21× |
| 真实文件 1.97MB 端到端 | 67,339 ms | 12,353 ms | 5.4× |
注:8 条流已达 833 KB/s,而本机到 Cloudflare 测速的下行只有 831 KB/s —— 也就是说瓶颈已经反过来变成用户自己的宽带,服务端侧不再有可挤的水分。改后浏览器实测单张 900KB 图 15,885ms(56.7 KB/s),仍在跨境抖动区间内,属正常波动。
server/src/lib/mediaToken.js。原先 exp = Date.now() + 1h,每次下发消息列表都重签一个新 URL;而 URL 就是浏览器的缓存键 —— 上一版把 Cache-Control 设得再好,命中率也是 0(每次都全量回源),这是「每次加载对话内图片都要很久」的直接原因。现在签发时刻按 30 分钟时间桶对齐,同桶内产出的地址逐字节相同,实际有效期按设计仍是 60~90 分钟(不缩短安全窗口)。线上实测:相隔 1.1 秒两次下发,URL 完全一致,有效期剩余 75 分钟。imageCompress.js 新增 thumb/thumbWidth/thumbHeight,质量 0.62),与主图同一次请求上传(multipart 字段 thumb);服务端存成 <原名>.w480.<ext>,下载端点新增 ?w=480。设计上刻意做成兼容 + 可回退:w 只从白名单 [320,480,640] 取值、票仍按原文件名校验(w 不构成越权面)、没有派生图就静默回退原图,因此老消息既不用改库表、也不会裂图。线上实测:921,616 字节的整图与 18,448 字节的 ?w=480(1/50),老图(无小图)请求 ?w=480 正确回退成原图。
app/src/services/mediaCache.js(与 stateDb.js 同风格:IndexedDB 存 Blob,node 测试环境静默 no-op)。缓存键是「文件名 + 宽度变体」,票据/nonce 等易变参数一律不参与 —— 所以换票据、换会话、甚至离线,同一张图第二次出现在屏幕上就是 0 流量。清单加载时先做一次纯本地批量解析(命中即用 blob: 地址渲染),未命中的照常按 loading="lazy" 走网络;图片加载成功后再落盘一份(走浏览器 HTTP 缓存,几乎不额外耗流量)。带容量上限(400 条 / 单条 8MB)与 LRU 淘汰,退出登录可整体清空。seedMedia),发送者的气泡不再等「从服务器下一遍」。这正是「显示 100% 但还要等很久才发出去」的最后一段。/etc/sysctl.d/99-qbao-net.conf + /etc/modules-load.d/qbao-net.conf,重启不丢),并写进 docs/DEPLOY.md §2 与 §8.5(服务器重建清单),避免重建后无声退化 12 倍。该参数对生产同样生效(同一台主机),方向上只会更快,不改变任何业务行为。server/test/mediaThumb.test.js(6 例:票据桶内稳定、有效期仍 60~90 分钟、带 thumb 时 ?w=480 发小图而不带 w 发原图、白名单外的 w 回原图、?w= 不影响票据校验且换票即 401、无 thumb 回退原图、小图内容非法时丢弃小图但主图仍上传成功);新增 app/src/services/mediaCache.test.js(10 例:换票据键不变、绝对地址键一致、缩略图与整图分键、blob/data/空值不产生键、无 IndexedDB 环境全部静默降级;另把「渲染取址」抽成纯函数单独锁死 —— ?/& 拼接、w= 幂等、blob: 命中时整串替换不再拼参数,这一环拼错一个字符就是全站裂图);imageCompress.test.js +4 例(缩略图 480/360、长截图缩略图不套用短边下限、不放大、默认参数);imageCompressGain.test.js +2 例(thumb 只解码原图一次、不传 thumb 行为不变);chatUpload.test.js +2 例(带 thumb 走同一次请求且只发一次、不带 thumb 不多发字段)。beta.questionbox.cn)服务端代码 + 客户端包体均已部署(dist/index.html 616.30 kB);内测冒烟 5/5 通过;线上取证见上。未 push、未打 tag、未动生产;版本号统一 3.37.6。
/chat/upload。手机原图动辄 2–8MB,而实测上传通道吞吐只有约 24–380KB/s(本地开发机实测:0.12MB→3.15–4.97s、0.58MB→2.98–4.23s、1.88MB→5.05–11.17s;服务端磁盘读取为 188MB/s,瓶颈在上行链路,不在服务器)。同时改用 XMLHttpRequest 只为拿到上传进度。
app/src/services/imageCompress.js:上传前本地压缩(长边 ≤1600px、质量 0.82、优先 WebP 回退 JPEG)。安全网优先于压缩率:非图片/GIF 不碰;<400KB 不解码(重编码收益低于画质损失);压完反而变大就保留原件;任何解码/编码异常一律回退原文件,绝不阻断发送;JPEG 输出前先给透明像素铺白底(否则透明区变黑);扩展名随输出格式改写(png→webp/jpg),避免服务端 magic bytes 校验拒绝。真实 Chromium 实测(不是估算)——用项目自带 Electron 36 的 Chromium,合成带传感器噪点的「手机原图」样张后走真实 canvas 编码路径:
| 输入 | 原始 | 压缩后 | 比例 | 输出 | 耗时 |
|---|---|---|---|---|---|
| 4032x3024 横拍照片 | 6.29MB | 452KB | 7.0% | 1600x1200 webp | 255ms |
| 3024x4032 竖拍照片 | 6.26MB | 450KB | 7.0% | 1200x1600 webp | 252ms |
| 1170x2532 手机截图(PNG) | 4.86MB | 289KB | 5.8% | 739x1600 webp | 154ms |
| 1080x12000 聊天长截图(PNG) | 20.95MB | 406KB | 1.9% | 400x4444 webp | 703ms |
| 800x600 小图 | 0.20MB | 0.20MB | 100%(按设计不压) | 原样 | 0ms |
| GIF | 0.23MB | 0.23MB | 100%(按设计不压) | 原样 | 0ms |
| 用户内测时真实发过的那张图 | 9.41MB | 257KB | 2.7% | 1600x1032 webp | 257ms |
按实测上行 ~24–380KB/s 折算:用户那张真图从约 25 秒~6.5 分钟降到约 0.7~11 秒,6.3MB 手机原图由「几十秒」降到「约 1–3 秒」;CPU 开销 150–700ms,且发生在本地、不占上行带宽(手机端会略慢,但同样的量级)。
MIN_SHORT_EDGE = 400:短边缩到低于 400px 就改按短边缩放,允许长边超出上限);上表末行即修复后的结果。普通照片与截图不受影响(它们的短边本来就远大于 400)。同时保留可选的总像素上限参数(显式传入时才生效)作为 canvas 内存的兜底开关。chatApi.uploadFile(file, { onProgress }) + 输入框进度文案(「处理图片…」→「上传中 47%」→「上传中 1.2MB(原 4.8MB,已压缩)」);401 处理与 fetchWithAuth 一致(只有本页令牌仍有效才清登出,避免把别的标签页登出),且保留 fetch 回退分支(无 XHR 环境功能不受影响)。GET /api/v1/chat/files/:filename 与 GET /api/v1/issues/images/:filename 走 res.sendFile,后者默认下发 Cache-Control: public, max-age=0;更糟的是反向代理按扩展名匹配静态资源(.png/.jpg…),把 API 媒体响应的缓存头改写掉了,于是浏览器的每一条 Cache-Control 都是冲突的。
cache-control: no-cache, no-store, must-revalidate 与 cache-control: public, max-age=0 同时出现,content-length: 1975851,第二次相同请求仍整包传输。server/src/lib/mediaCache.js,由业务端点显式接管缓存头 —— private, max-age=31536000, immutable + nosniff,并关闭 sendFile 自带的 max-age=0(避免两个冲突的 Cache-Control)。文件名由服务端随机生成、内容不可变、下载仍需 Bearer 或 1 小时有效的签名 ticket,故长缓存安全;用 private 是因为媒体属于用户私有数据,不允许中间共享缓存保存。@assets 匹配器原为纯扩展名匹配,会把 /api/*.png 这类受保护媒体端点一起命中,把下载响应改成 public, max-age=604800(一周)——这不仅是性能问题,还会把 401/404 缓存一周,图片一旦裂开就再也好不了(正是症状③的放大器)。
@assets path *.png … \ + 下一行 not path /api/*」在 Caddy 2.11 里不是「且」——适配器把 "not"、"path"、"/api/*" 当成三个文件扩展名模式塞进同一个 path 列表,匹配条件恒为假,行为与修改前完全一致(caddy validate 照样通过,只有查 /config/ 编译产物或实测响应头才能发现)。正确写法是块式匹配器(@assets { path … ; not path /api/* })或 path *.png !/api/*。Caddyfile.bak_assetsfix_20260916-232514 / bak_assetsfix2_20260916-233112 / bak_assets3_20260916-233414,caddy validate 通过后 reload。内测与生产共用同一份 Caddyfile,故两侧一起生效:实测内测 API 图片为 private, max-age=31536000, immutable(不再有冲突头)、API 401 为无缓存头、静态资源仍是原来的 no-store;生产 API 响应在源站已不再带 max-age=604800(max-age=604800 仅剩静态资源,实测静态 png 仍为 7 天)。requireAuthOrMediaToken 签发的 ?t=<exp>.<hmac> 有效期 1 小时,消息列表里的地址是加载那一刻签发的;页面停留超过 1 小时后,任何还没进浏览器缓存的图片再请求必然 401,而 <img> 失败后不会自己重试 —— 直到手动刷新页面。
@error 时向服务端重取一次消息(拿到重新签发的地址),并给地址挂 r=<n> nonce 强制重取(旧地址可能已被中间层按「图片」缓存了失败响应,不换 URL 会一直裂)。最多重试 2 次,之后标记为真·损坏并停止,避免坏图把服务端打成重试风暴。sanitizeMediaUrl 本就会剥离并重签 ticket,新鲜度以服务端为准。server/test/mediaCache.test.js(4 例:缓存头常量、显式设头并关闭 sendFile 默认 max-age=0、调用方无法覆盖 cacheControl、chat 与 issues 两个端点都走 sendMediaFile 防回退);新增 app/src/services/imageCompress.test.js(25 例:等比缩放/竖图/总像素上限/非法尺寸兜底/长截图保短边可读、跳过条件、WebP→JPEG 回退、扩展名改写、非图片与小图不解码、解码抛错回退原件、压完更大保留原件、JPEG 透明铺白底);新增 app/src/services/chatUpload.test.js(7 例:XHR 上传的目标地址/字段名/Bearer、进度换算为 0–100 且不越界、长度不可知时回调 −1、非 2xx 复用统一错误文案、401 清登出、网络中断不卡死、无 XHR 时回退 fetch)。其中「调用方无法覆盖 cacheControl」一例当场抓出了本轮的实现漏洞(Object.assign 顺序让调用方能把默认 max-age=0 打开),已修。beta.questionbox.cn)服务端代码 + 客户端包体均已部署(dist/index.html 632330 字节,sha256 cacb2c2f…,与本地逐字节一致);内测冒烟 5/5 通过。Caddyfile 侧修正同时作用于生产——因为两条域名共用同一份配置,而生产原本就在错误地改写 API 媒体缓存(实测源站 API 响应已不再带 max-age=604800,静态资源仍保持 7 天),此项已如实记录。未 push、未打 tag;版本号统一 3.37.5。
[] —— completion_tokens=2、耗时约 300ms,说明它根本没有执行审核。服务端随后判定「AI 自动判定后没有可用题目,保留原始结果」,题目虽然没丢,但这次自检调用完全无效。[](2 tokens);补上「没有问题的题目必须原样保留,不得因为『无需修改』而省略、合并或删除」→ 正常返回全部题目(111 tokens)。属于模型侧对”审核并返回修复后数组”的过度解读,提示词必须显式兜住。runAiSelfCheck 识别「未真正参与」的空结果(0 题 且 completion_tokens < 20),自动补显式指令重试,最多 3 次尝试并采用首次成功的结果;返回体新增 retried / unengaged 便于观测。阈值保证「模型认真审完、真的把所有题都删了」(消耗数百 tokens)不会被误判为空转,也不会白白多打上游。finish_reason=stop、completion_tokens=2、耗时约 1 秒),单次重试仍有约 1/3 概率两次都空转;空转到底也把用户的自检变成「静默无效」,因此加到 3 次尝试(空转调用仅 2 tokens,代价远低于自检失效)。[] → 日志「模型空转(0 题 / 2 tokens),补显式指令重试(第 2 次尝试)」→ 第二次正常审核并返回全部题目;4 次连续运行最终均为 selfCheck.status=ok。POST /ai/tasks + worker):真实资料《光合作用》→ 请求 3 单选/2 判断/1 名词/1 简答(共 7 题)+ 开启自检 → 202 受理 → 21 秒 completed → 恰好 7 题、题型与配额完全一致;客观题答案均为合法选项下标、判断题选项固定「正确/错误」、名词解释与简答题无需选项、题目带 tag 与解析。上游用量回传 usage={prompt_tokens:445, completion_tokens:1055, total_tokens:1500}。POST /ai/generate):同 Key 请求 1 单选/1 判断 → 200,约 11 秒,2 题齐全。[ecnu] posting to …,1021ms 后 401「无效的令牌」)→ 任务 failed 且错误文案带上游原文,未误报为解析失败。密钥不入库一并确认:任务表 request 里只有 provider/model/body,不含 Key。server/test/aiSelfCheck.retry.test.js(6 例:空转→重试并采用后续结果、连续空转达上限即停且标记 unengaged、前两次空转第三次成功、真删题不重试、正常单次调用、提示词严格模式不改载荷);aiQuestionFinalizer.test.js 增 1 例锁定「空转 → 保留原题 + 自检未生效提示」。dist/index.html 仍为 627424 字节、MD5 f667f6ab…);eslint 0 error。beta.questionbox.cn 服务端代码(-Mode server);未 push、未打 tag、未动生产。
chatCompletions 把「HTTP 请求 + JSON.parse 响应体」放在同一个 try 里,遇到 HTTP 200 但响应体不是 JSON 时直接抛错。而两条生成链路(/api/v1/ai/generate 与后台任务 worker)的「纠正性重试(≤2 次)」都写在 chatCompletions 正常返回之后、判断的是内容字符串能否解析——异常在适配器内部就抛出了,重试分支永远走不到,用户直接拿到失败。真实触发场景:模型回一句「抱歉,我无法完成该请求。」、被 WAF/网关替换成 HTML 拦截页、SSE 残留文本。JSON.parse 失败时保留原始文本并返回一个合成 completion(choices[0].message.content = 原始响应体),不再抛出;下游解析失败 → 原始输出被带进纠正提示词重试(提示词里会明确写「你上次返回了无效JSON,错误是:…」)。selfCheck/补题等下游逻辑对未知键本就容错,路径不变。finalizeAiQuestions 返回空题目数组 + 题型缺口,旧代码把这种任务标记为 completed,客户端随即提示「服务端任务完成,已导入 0 题」——用户看到成功却拿不到任何题目。现在 0 题一律 failed,错误文案「AI 未返回可用题目(已重试),请检查 API Key / 模型名称,或稍后重试」,与直连路径客户端对空结果的既有判定一致。server/test/aiGenerateFlow.e2e.test.js,4 例):可控假池 + 假 fetch 打通「创建任务 → worker 领取 → 调上游 → 校验/整理 → 落库」全链,断言发往 ECNU 的真实请求形态(URL / Authorization: Bearer / model / stream=false / system+user 两条消息 / 资料文本已并入 / max_tokens / 传入 AbortSignal)、题目题型与配额、客观题答案必须是合法下标、判断题选项与答案、usage 透传;并覆盖「首次非 JSON → 第二次纯 JSON → 仍 completed」「上游 401 → failed 且带上游信息」「连续非 JSON → 重试后 0 题 → 必须 failed」。beta.questionbox.cn 的服务端代码(-Mode server,不替换客户端包体);未 push、未打 tag、未动生产。
/api/v1/chat/files/<name> 签成带票 URL(上传响应、消息列表、工单详情三处都签),而前端 resolveMediaSrc(u) 又被以「一个参数」的形式调用(ChatMessages.imageSrcs、IssueDetailModal)——helper 于是把服务端下发的同一个地址同时当作「干净路径」和「带票版本」,把 ticket 追加了第二次:…png?t=<exp>.<sig>&t=<exp>.<sig>。Express 解析 req.query.t 得到逗号串 "<exp>.<sig>,<exp>.<sig>",HMAC 比对必然失败 → requireAuthOrMediaToken 抛 401。上传预览之所以正常,是因为预览用的是上传响应里的单票地址(未经过第二次追加)。withMediaTicket 改为幂等——入参本身已带 ticket 时原样返回(并在追加前剥掉入参里可能存在的旧 ticket),保证最终地址里只有一个 t 参数;两种调用形态(带票 URL / 干净路径 + 带票版本)都得到同一个正确结果。组件侧补注释说明「服务端已签票,这里只补绝对地址」,previewImage 直接使用已解析好的地址(此前走 resolveMediaUrl 会把带票地址再解析一遍)。…?t=1789563551422.8NIyUaE… → 浏览器式带票请求 200 / 69 字节;前端旧逻辑拼出的双票地址 → 401 / 0 字节(错误逐字节复现)。修复后同一地址只保留一个 t。utils.test.js 媒体 ticket 段新增 3 例锁定「入参带票不再追加」「结果里 t 参数最多一个」「带票 URL 原样保留」。x-ai-api-key 长度 < 10 即判定为无效 Key),属于预期行为——即「您填的这个 Key 不成立」,而不是把「未配置」误报成「太短」。行为符合预期,本轮不改文案。vite build 成功。requireAuth,而消费方是浏览器 <img src>——无法携带 Authorization 头,整改后必须双通道。做法:路由改挂 requireAuthOrMediaToken(Bearer 头照旧有效),图片场景由服务端出站时签 ?t=<exp>.<hmac>(HMAC-SHA256,密钥由 JWT_SECRET 派生、带用途分隔串,TTL 1 小时);签名只覆盖文件名,故票据无法被挪用去读别的文件。数据库只存干净路径(上传/发消息时剥离 query),读取时重签,历史脏行(库里已带 ?t=)自动归一化。非受保护路径(头像、uploads、外链 CDN)一律不签名。server/init.sql + 迁移建出的库缺 users.role / is_banned / avatar_url / last_login_at / last_active_at,且代码大量引用的 notices 表全仓没有任何 DDL —— 新环境注册即 42703、所有鉴权请求 500。新增迁移 018_v3.41_identity_notices.sql 补齐;并新增 schema.drift 双向守卫:代码引用的表必须在仓库 DDL 中可复现、关键列必须有 DDL。run_migration.js 新增 --verify,打印仓库编号缺口(允许)并告警「已记账但仓库无文件」的幽灵版本。files.routes.v2.js 里 DELETE /files/:id、POST /:id/assign、POST /:id/unassign、POST /:id/extend 各注册了两遍(Express 只命中先注册者,改后面那个副本不生效、且不报错)。删除旧副本,保留含章节关联与 in_pool 复位的完整实现;新增「同一 method+path 不得重复注册」守卫(当前 107 个端点全唯一)。ai_request_log 留下 2 条噪音。app.config.errorHandler(30s 节流 toast)、unhandledrejection(跳过 AbortError)、本地数据读取/云端恢复失败 toast、启动失败兜底页;新增 POST /api/v1/client-errors(免鉴权,20 次/分/IP + 8KB 体量上限,只落日志不落库,字段截断 600 字)。fetchWithRetry 此前对 4xx 也重试并把 {error:'登录已过期'} 当正常响应返回(上游因此把「登录过期」报成「AI 未返回有效题目」)——改为仅对 5xx/429 重试;网页端所有出网请求统一走 effectiveToken()(不再有取到失效 token 的旁路),401 区分「登录过期」与「无权限」。fileFilter 在落盘前拦截(非白名单 → 422,磁盘零残留);单次总量 >60MB → 413 并清理本请求已落盘分片。GET /api/v1/ai/providers 加 requireAuth。src/lib/gracefulShutdown.js——停定时器 → 停收新连接 → 等待在途 AI 任务/同步(默认 15s,QBAO_SHUTDOWN_GRACE_MS 可调,与 systemd TimeoutStopSec 联动)→ 关连接池 → exit 0;二次信号立即退出(escape hatch)。此前重启硬切,ai_tasks 会滞留 running。PATCH /issues/:id/status 先读快照后开事务,并发可写出互相矛盾的系统消息;改为 BEGIN + SELECT … FOR UPDATE 串行化,附图清理移到 COMMIT 之后(尽力而为,失败不牵连状态变更)。File too large / LIMIT_FILE_COUNT)——单文件超 20MB 被报成「参数错误」,用户无法自查。改为按 err.code 分流:体积类 → 413(中文文案),其余 → 422,业务侧文案原样透出。retracted 才提示;版本被清单剪枝(stable 只保留最新 3 个)不再被误判为「该版本已被撤回」并弹阻塞提示。mediaToken(票据签名/过期/换文件/篡改)、schema.drift(双向 DDL 漂移 + 路由唯一性)、clientErrors.routes(限流与体量)、gracefulShutdown(停机顺序/宽限超时/二次信号);ai.routes.validation 增补白名单零落盘、总量 413、审计零噪音、providers 鉴权;errorHandler.unit 增补 multer 分流;app utils 增补媒体票据透传与重试策略。vite build 成功(dist/index.html 606.96 kB)。