Skip to content

fix(v1.13.0): 修复当前所有已知问题(11-128/11140 分流 #197 #231、队列满 #184、刷新并发 #215、配置原子落盘 #226、积分与选号 #222 #229、工具停摆 #149) - #1

Open
Huiaei wants to merge 88 commits into
mainfrom
fix/all-known-issues-v1.13.0
Open

Huiaei wants to merge 88 commits into
mainfrom
fix/all-known-issues-v1.13.0

Conversation

@Huiaei

@Huiaei Huiaei commented Oct 20, 2026 •

Copy link
Copy Markdown
Owner

fix(v1.13.0): 修复当前所有已知问题(11-128/11140 分流 linguo2625469#197 linguo2625469#231、队列满 linguo2625469#184、刷新并发 linguo2625469#215、配置原子落盘 linguo2625469#226、积分与选号 linguo2625469#222 linguo2625469#229)

用户实际运行的是 1.13.0 发行版,因此本仓库提供两条修复分支:

本分支额外包含上游 7595a0a / 8461419 的 DSML 原生工具调用标记还原(上游 PR linguo2625469#123 的功能增强)与上游 535e19e / 8391a05 / 0ab6690 / 20254d8 的修复。PR #2 不带 DSML 层(按「只修问题、不带功能」原则)。

一、问题分组与修复对照

组 上游 issue 根因 修复
A. 账号被误禁 / 渠道校验 linguo2625469#197 linguo2625469#187 linguo2625469#188 linguo2625469#231 linguo2625469#198 linguo2625469#184 ① 上游 400 11-128「request illegal」是请求级拦截(渠道校验未过),却被当成账号封禁 → Pool.Disable() 永久禁用好号;② 账号间风控判定不一致(同一时刻 A 号 400 / B 号 200);③ X-Conversation-ID 缺失导致上游判定非官方客户端;④ 6021 queue.full 被当 429 限流 → 冷却账号并放大重试 ① 按业务码分流:11128/"11-128" → ErrChannelRefused(不罚号、继续轮转),11140+request illegal → ErrAccountFault(禁用 + 申诉文案);② 恒定发送会话头三件套,缺 conversation_id 时回退到会话键(不伪造随机值);③ 队列满 → 503 upstream_busy,零账号惩罚 + 透传上游 Retry-After
B. 登录态 / 账号池 linguo2625469#215 linguo2625469#222 linguo2625469#229 linguo2625469#227 linguo2625469#160 linguo2625469#173 ① 并发刷新把已消费的 refresh token 再用一次 → 账号被踢下线(12153);② 「无限积分」哨兵值 1<<40 撑大分母 → 无限账号垄断选号;③ 只有余额下限、没有消费速率闸;④ 冷却账号不按到期紧急度选 ① 按账号 singleflight 刷新 + 12153 自动重登重试;② capUnlimitedCredits 改用第二大真实余额作分母;③ 新增账号级 burn guard(窗口内消费超限 → 冷却),所有选号路径统一生效;④ 补到期梯度与区域调度
C. 面板 / 配置 / 额度 linguo2625469#226 linguo2625469#134 linguo2625469#171 linguo2625469#170 linguo2625469#216 linguo2625469#189 linguo2625469#174 linguo2625469#163 linguo2625469#179 linguo2625469#155 linguo2625469#135 linguo2625469#81 linguo2625469#26 linguo2625469#181 ① config.json.tmp 不可写 → 面板保存配置直接失败(Docker 场景高发);② 面板保存后静默吞掉 401,旧 key 一直生效;③ global 目录「重新获取」打不破 1h 缓存;④ 缺 messages[0].role=system 校验 → 全量 11-128;⑤ 工具调用 id 重复未改名 → 上游 400 ① 新增 internal/atomicfile:原子落盘 + 多目录回退 + Docker PUID 提示;② 保存后显式报错、重新取 key;③ 目录探测可失效 + 记录来源(desktop/union/enterprise);④ 出站路径 EnsureSystemFirst 保证首条 system(所有 prompt 模式生效);⑤ 重复 call id 自动改名
D. 额度口径 / agentic 停摆 linguo2625469#222 linguo2625469#149 ① 企业口径 used 四舍五入 → 多报剩余额度,调度器以为还有额度继续派发(实际打过去就是 402);② 上游偶发 data:null / 缺字段 → 「额度 0 但账号健康」,与「接口失败」无法区分;③ agentic 请求声明了 tools 却返回 finish_reason=stop + 零 tool_calls → 工具永远不执行,客户端当成正常完成 ① 改为 ceil(向上取整是保守方向);② 判空口径升级为「额度字段一个都不存在」,个人口径同源现象一并修掉,面板优先显示「余额刷新失败」;③ 新增零 tool_calls 停摆检测:非流式换号重试(永不罚号),流式只观测不重试(帧已写出),并保证停摆那一跳照常计费(否则余额虚高、燃尽熔断漏跳)

二、关键设计取舍

1. 11-128 与 11140 必须按业务码分流,不能按文本匹配。
上游同一个 msg:"request illegal" 出现在两个完全不同的语义里:

上游响应 语义 正确处理
{"code":11128,"msg":"request illegal"} / {"code":"11-128",...} 渠道校验未过(账号是好的) 不罚号,继续轮转
{"code":11140,"msg":"request illegal"} 账号被限制(需在 WorkBuddy 申诉) 禁用 + 申诉文案

上游 PR linguo2625469#233 是文本 + 数字码双条件,既漏掉 #188 的 {"code":11128,"msg":"conversation id missing"},也漏掉 issue linguo2625469#197 抓包里的字符串码 "11-128",两者都退化成 ErrClient → 轮转耗尽后仍返回 503 no_healthy_account(正是 linguo2625469#188 的症状)。本分支改为业务码优先,文本只作兜底。

2. 通道校验失败必须继续轮转,不能 fail-fast。
初版实现曾对 ErrChannelRefused 直接返回 400(理由:指纹 = 出口 IP + 请求头 + 请求体,换号无用)。这是错的,实测证据:issue linguo2625469#197 中同一时刻一个账号稳定 400 11-128、另一个健康账号稳定 200 —— 上游风控是按账号判定的;且出站请求头因账号而异(企业账号带 X-Userinfo,个人账号不带)。改为继续轮转后:3 个健康账号、只有第 1 个命中 11-128 时,返回 200(2 次上游调用),而 fail-fast 返回 400(1 次调用)。

3. 11-128 不得归入内容审核域。
11-128 Illegal API invocation from an unapproved channel 若被分类为 ErrContentBlocked,会触发 h.degrade.Trigger() → 整个网关降级到次日零点(对所有账号、所有模型)。实测确认后已把内容审核的排除项收窄到唯一真实标记 blocked by security policy。

4. 11140 信封里的模型级限流文案不得当成封禁。
上游把 msg:"request illegal" 和 displayMsg:"The model provider is rate-limiting requests." 装在同一信封里。只按 code+text 判定会把健康账号永久禁用。现在只排除三条明确的限流文案,其余封禁措辞(Too many login attempts、Your usage limit has been reached、Rate limit abuse detected 等)仍然禁用 —— 漏禁比误禁更糟(账号会一直显示可用,用户永远不知道要去申诉)。

5. 只认证据。 全分支不新增任何凭据处理路径;linguo2625469#217 只采用 AccessToken/RefreshToken/ExpiresAt,不碰 Domain,不打印 token。

6. 「判空」与「取值」必须同一套口径。
#222 的判空最初用 map 精确键匹配(credit / limitNum / …),取值用结构体 + encoding/json(字段名大小写不敏感)。两处口径不同会同时出两种错:上游把 credit 改成 Credit → 判空说「没额度字段」→ 正常响应被误报成故障;反向则会出现「判定说有额度、数值却读成 0」→ 又回到「额度 0 但账号健康」。现在改为一处解析(parseEnterpriseQuota,按去下划线 + 小写的归一化键读值并同时给出 hasQuotaField),判空与取值天然同口径。

7. 停摆是上游行为缺陷,不是账号问题。
零 tool_calls 停摆(#149)时绝不能罚号:不冷却、不计失败、不禁用——上游在这个模型/这个请求上就是会停摆,罚号只会把好号赶出池子。重试是换号(同一个号大概率再次停摆)。同时停摆那一跳必须照常计费并计入燃尽熔断,否则账号余额虚高、调度器继续往正在烧钱的号上派单。

三、验证

每个修复都配有会真正执行的回归测试(不是空断言):

  • go build ./... / go vet ./... / go test -count=1 ./... 全绿,零测试被删除。
  • 端到端三态实测(同一套 fake upstream,分别在裸 v1.13.0 与修复后运行):
上游响应 裸 v1.13.0 修复后
11128 200,但 disabled=true(好号被禁) 200,upstream_calls=2,不禁用 / 不冷却 / err_total=0
11140 503,禁用 503,禁用 + 申诉文案
6021 429 rate_limit_exceeded,cooling=true 503 upstream_busy,不冷却,只打 1 次上游,Retry-After 原样透传
声明 tools 但零 tool_calls 200(工具没执行,客户端当成正常完成) 200(换号重试后正常返回 tool_call),不冷却 / 不禁用
企业额度 credit=812.45 1188(多报剩余额度) 1187(ceil,保守方向)
  • 裸 v1.13.0 树上 ErrChannelRefused / IsChannelRefused / upstream_busy 出现 0 次 → 修复确实长在 v1.13.0 的代码上,不依赖 v1.13.0 之后的提交。
  • DSML 标记实测:上游吐原生工具调用标记时,修复后客户端拿到结构化 tool_calls(1 次上游调用),未修复时拿到标记原文(换号重试 2 次调用)。两条路径都不禁用、不冷却任何账号。

四、附带交付:原生协议入站(Codex / Claude Code / 官方检索)

这三条入站路径是上游 issue linguo2625469#89 / linguo2625469#195 / linguo2625469#9 里被反复追问的形态,本次一并落地(PR #2 同样包含):

端点 协议 用途
POST /v1/responses OpenAI Responses 原生 Codex 直连;GET /v1/responses 明确返回 426(不支持 WebSocket 升级)
POST /v1/messages(及别名 POST /messages) Anthropic Messages 原生 Claude Code 直连;x-api-key 与 Authorization: Bearer 都接受
POST /v1/search 官方检索透传 字段与上游一致,不加工结果;deep:true 走 agentic 综述(上限 150s)
  • 三条路径与 /v1/chat/completions 共用同一套内核:同样的选号、同样的轮转、同样的错误策略、同样进请求流水与计费。
  • 旧有的 POST /v1/search/messages(Anthropic 形态的搜索结果壳)保留不变,只是搬进了 internal/server/search_messages.go。
  • 在途租约成对归还(本次一并修掉的上游缺陷):检索路径此前完全不取在途租约 → 检索流量绕过 MaxInFlight 限流,一次并发检索能把同一账号堆到超出上游并发上限。现在每次尝试都用 defer Release 圈住全部出口(上游 500 / ctx 取消 / 超时 / panic)。实测 4 条入站路径 × 3 种故障 = 12 个组合的 InFlight 全部归 0(去掉 defer 即失败)。
  • 兼容写法提醒:Handler.Config.ToolStallRetry 默认是 0,只有 cmd/server/config.go 的缺省才是 1 —— 写单测时必须显式传 ToolStallRetry: 1。

五、本 PR 包含的提交

六、配置与状态兼容性

  • 新增 13 个配置键(全部新增,零删除、零改名):cn.chat_base、dns_servers、models.hidden、pool.account_order.domestic/international、pool.burn_guard_limit/window/cooldown/cooldown_max、pool.cross_domain_fallback、pool.strategy.domestic/international、upstream.tool_stall_retry。
  • 加载逻辑是 Default() 先填默认值再 json.Unmarshal 覆盖,老 config.json 不改一行也能直接升级(新键取默认值,tool_stall_retry 还会被 clamp 到 [0,3])。
  • state.json / usage.json 格式未变,老文件可直接读;新增的 creditsRemainder / Scheduling / CreditVerified 等字段缺失时走自动迁移。credit-history.json 是新增文件(积分历史),首次启动自动创建,用户无需操作。

七、已知未覆盖(明确不在这版里)

以下 issue 没有在本次交付中实现,列出来以免被误认为已修:
#28(abort 端点)、#201(昵称→UID 映射,仅显示层)、#153(每账号 socks5 —— 目前只有环境变量代理,见 #144)、#175 #176 #141 #84 #25 #147 #209 #211 #182 #39 #56 #95 #23 #13 #19 #139 #142 #150。

另有若干 issue 判定为非代码缺陷(产品需求 / 账号本身被上游封禁 / 已在 v1.13.0 覆盖),同样不在本次范围。

八、附录:Windows 专项 CI(本次新增)与它当场抓出的真 bug

上游的两个 workflow(go-binaries.yml / docker-ghcr.yml)此前只在 ubuntu-latest 上跑测试,
而仓库同时发布 Windows 二进制(矩阵含 windows/amd64)→ Windows 专属的代码分支
永远不会被测试执行到。

本次给两个 workflow 各加了一个 test-windows job(runs-on: windows-latest,跑
go test ./... + go vet ./...),并把下游 job 的 needs 改成 [test, test-windows]。
这个 job 第一次运行就抓到了一个此前从未被任何测试覆盖的真 bug:

现场 症状 根因 修法
并发写同一目标 replace ...config.json: The system cannot find the file specified. 暂存名是固定的 path+".tmp" → 两个写者共用;前写者 rename 成功后暂存文件消失,后写者 rename 报「源文件不存在」。Windows 上更糟:文件正被另一个进程打开时 MoveFileEx 因共享冲突失败 → 降级的原地覆盖又因「目标尚不存在」报 ERROR_FILE_NOT_FOUND 暂存名改唯一(<base>.tmp-<16位hex>,同目录 → rename 仍原子);原地覆盖补 O_CREATE;错误信息里补上 rename 的原始原因(此前真实原因被次生错误盖住,排查时完全是误导)
依赖 POSIX 目录权限的用例 Windows 上 chmod 改的是「只读属性」,没有目录写保护语义 → 三个用例退化成「正常写入」,假通过 用例没区分平台 Windows 上显式 t.Skip 并写明原因

⚠️ 并发写的最坏形态是静默数据错乱(不是报错):后写者截断重写前写者已备好的内容,
前写者随后 rename 落盘的是后写者的内容。Linux 上竞态窗口极窄(4 写者 × 20 轮 × 200KB
负载实测都撞不出来),靠概率的回归测试等于没有测试 —— 因此这里用 stagingName 钩子
把「每次写都用自己的暂存名」这条不变量确定性地钉住(把实现退回固定名时用例必失败)。

同批还修了一个停摆跳的熔断口径不对称:流式路径的 NoteBurn 曾无条件执行,
而非流式路径有 !stall.Suspect 守卫 → 同一个停摆跳,流式会把它喂进积分燃尽熔断
(把好号熔断掉),非流式不会。现在两边完全一致:停摆跳照常计费(余额要准),
但不参与熔断计账(熔断针对的是「正常消耗过快」,不是「上游行为异常」)。

九、交叉审计(第二轮)修掉的问题

第一轮修复上线后,我又跑了一轮六组交叉审计(错误分类×账号惩罚×轮转 / 停摆×轮转×计费×熔断 /
熔断×冷却×选号 / 原子落盘×面板保存 / DSML×停摆检测 / 额度解析×面板×调度器),
把审计出的真缺陷补齐:

严重度 问题 根因 修法
BLOCKER 并发刷新账号文件会静默丢内容 SaveAtomic 固定用 FilePath+".tmp" 作暂存名;两个不同的 *Auth 实例(各有自己的 mu)指向同一文件时互相踩:实测 300 轮 × 2 写者,282 次「返回成功但内容没落盘」、266 次 ENOENT 假失败 改用 internal/atomicfile(唯一暂存名 + 原子替换 + 跨平台降级原地覆盖),两处不再各写一份
MAJOR 企业额度每次解析出不同余额 上游同时给 "credit" 与 "Credit" 时「谁后覆盖谁」依赖 Go 的 map 遍历顺序(实测 200 次解析 ⇒ {0:166, 812.45:34})⇒ 面板显示 2000/2000(实际 1187)⇒ 派活后 402 按归一化键分组,组内优先取非零值 ⇒ 与顺序无关
MAJOR 额度字段类型不匹配静默当 0 上游偶尔把数值写成字符串 "812.45" ⇒ json.Unmarshal 失败就跳过 ⇒ 额度变 0,且「空 payload」守卫拦不住 quotaNumber 接受数字字符串;实在解析不了就置 badType 并报错,不装作 0
MAJOR 健康企业账号在面板上是红色报错 企业版无签到体系(上游 10001),/checkin 把解释性文案也塞进 checkin_error,而 checkin_done 恒为 false ⇒ 前端判成「没签到 + 有错误」 后端区分「跳过」与「失败」(新增 checkin_skipped,跳过态不给 checkin_error);前端 checkin_skipped/企业号+仅文案 ⇒ severity 非 err;balance_error 仍优先,真 checkin_error 仍必须是 err
MINOR 12153 两套策略 chat 路径 1 次即 Disable(终态、不自愈),scheduler 路径连续 3 次才禁用;12153 在上游是偶发的(token 恰好被别的客户端用掉、并发刷新撞车) 统一为「连续 3 次才禁用」,任意一次成功即清零 —— 与 scheduler 口径完全一致
MINOR 402 硬冷却跟部署机器的本地时区走 上游额度重置墙钟是 Asia/Shanghai,部署在 UTC+13 的机器会多冷近 5 小时 改用固定 upstreamCSTZone(UTC+8,免 tzdata 依赖);同口径先例:travel.go 的 cstZone、client.go 的 promoZone
MINOR 停摆判定缺自防御 DetectToolStall 不看正文里有没有模型原生工具调用标记(DSML 块)⇒ 上游把工具调用吐成正文标记时,工具其实调了却判成停摆 ⇒ 白白换号重试 + 污染停摆率统计 正文含标记 ⇒ 不判停摆;标记常量提到 tool_stall.go(不引用 dsml.go —— v1.13.0 基线分支没有它),dsml.go 反过来由它派生,两边永不漂移
MINOR WithFrameObserver 是单槽位 后注册的观察者静默覆盖先注册的 ⇒ 现象是「判定偶尔不生效」且毫无报错 改成观察者列表,多观察者共存,调用顺序 = 注册顺序

新增测试:internal/auth/auth_concurrent_test.go(60 轮 × 2 写者并发,断言不虚假失败、落盘是完整内容、
不留暂存文件)、internal/upstream/tool_stall_marker_test.go(含「普通正文仍然判停摆」的对照组)、
internal/upstream/sse_frame_observer_test.go(多观察者都被调用 + 透传字节逐字节不变)。
上述用例均做了反向验证:把实现退回旧写法必失败。

补充:CI 又抓出两个真 bug(第二轮)

修完上表后,Windows 专项 CI 又抓出两个「本地过、CI 挂」的真 bug——它们
恰好证明了这个 job 的价值:

现象 根因 修法
四个 402 冷却用例在 CI 全挂(本地全绿) 断言看的是 st.Until 的本地小时数,而实现已改成上游时区(CST=UTC+8);WSL 本地正好 +8 ⇒ 恰好相等,CI 跑 UTC ⇒ 差 8 小时 断言改成 st.Until.In(pool.UpstreamCSTZone()).Hour();已用 TZ=UTC / TZ=America/New_York / TZ=Asia/Tokyo 三时区回归验证
并发写账号文件在 Windows 上第 5 轮就挂 Windows 的 MoveFileEx(..., MOVEFILE_REPLACE_EXISTING) 在目标文件正被另一个句柄打开时返回 ERROR_ACCESS_DENIED(5) / ERROR_SHARING_VIOLATION(32);两个写者并发替换同一目标时这必然会撞上(不是罕见故障)⇒ rename 失败 → 原地覆盖也失败(目标正被独占)⇒ 一次本该成功的写变成失败 rename 遇到「目标侧临时占用」类错误时短暂重试(40 次 × 5ms)让另一个写者先完成;重试仍失败才降级原地覆盖。非 Windows 平台行为完全不变(POSIX rename(2) 在目标被打开时不会失败,EXDEV/EBUSY 是结构性问题、重试无意义 ⇒ isTargetBusyErr 恒 false)

这两个 bug 在此前的所有 CI(只跑 ubuntu-latest)里永远不会出现。

十、交叉审计(第三轮)修掉的问题

前两轮修的是已知 issue;这一轮是交叉审计(让审计子代理在两个分支上实跑、
构造对抗用例,把每一条修复路径单独拎出来看「同一个上游错误在两条路径上是否同口径」)。
它抓到的问题不来自任何 issue,全是「修 A 引入/遗留 B」的结构性缺陷 —— 这类
问题在 issue 列表里永远不会出现,只能靠交叉审计。

10.1 审计抓到的问题与修法

级别 现象 根因 修法
BLOCKER 流式路径把 11-128 当成 ErrClient ⇒ 喂连败计数 ⇒ 5 次就把健康号降权出池 10 分钟 上游用「HTTP 200 + SSE error 帧」回报错误({"error":{"code":11128,"message":"request illegal"}}),而 FrameKind 只把 error.message 交给 Classify ⇒ 业务码丢失。11-128 与 11140 的判据都在业务码上,文案「request illegal」是两者共用的 FrameKind 把整个 error 对象搬成业务信封(code 提到顶层、message 映射成 msg、displayMsg 等原样保留)再交给 Classify ⇒ 流式与非流式口径完全一致
MAJOR 11-128 + displayMsg 里有审核词 ⇒ 整个网关对所有账号、所有模型降级到次日 00:00 CST 上游把通道校验与内容审核塞进同一个业务码,只靠文案区分;旧实现按整 body 扫文案,命中 blocked by security policy 就归 ErrContentBlocked ⇒ handler 执行 h.degrade.Trigger() 只看主文案字段(body.msg / error.message),不扫 displayMsg/displayTips;判不准时偏向通道校验(代价不对称:误判通道校验 = 有界重试零惩罚;误判内容审核 = 可能整网关整夜降级)
MAJOR 11140 的模型侧限流否决只认三条逐字句 ⇒ 上游改一个字就把可自愈的限流翻转成永久禁用 判据是逐字句而非语义家族 判据改为语义家族(modelRateLimitFamily,覆盖中英文与常见变体)
MAJOR 11140 但文案不含英文原句 ⇒ 真封禁既不禁用、又喂连败 文案判据只认 "request illegal" 一句 改为 accountRestrictedMsgFamily(明确说「账号被限制/封禁」的措辞);刻意不收「限流/用量」类宽词(那些词真封禁文案里也有)
MAJOR HTTP 429 带 11140 ⇒ 一次限流高峰就永久禁用一个健康号 11140 的封禁分支排在所有 429 判定之前;分类口径不变但惩罚强度没有按状态码分级 分类口径不动(保持与上游既有契约一致),改在惩罚侧分级:429 只软冷却(可自愈),其它状态码才硬禁用
MAJOR 非流式路径把「工具其实调了」的回合误判成停摆 ⇒ 白烧一次上游调用 + 白解绑一次粘性会话 同一个事实在两层用了两套判读方式,而动态类型不同:Aggregate 产出的 message.tool_calls 是 []map[string]any,JSON 反序列化的 body 里是 []any 新增 internal/upstream/toolcalls.go 把判读收成一个函数(HasStructuredToolCalls / MessageHasToolCalls / ToolCallList),停摆检测与 DSML 守卫都改调它。刻意不依赖 dsml.go —— v1.13.0 基线分支没有它
MAJOR DSML 修复器用「正文标记还原出的调用」覆盖真实 tool_calls ⇒ 调用参数被改写(实测 Hangzhou → Beijing) 同上(守卫只认 []any) 同上
MINOR 熔断号在面板两处被计为「健康 / 可服务」 熔断是选号层面的排除,但聚合口径没跟上(statusOf 本来就显示 CoolKind="burn") countsDetailedForRealm 把 burnBlocked 归入 cooling 桶;modelview.go 的 Servable 计数排除熔断号
MINOR 面板「解冻」/「删号重加」都不清熔断 ⇒ 运维点了恢复,号依然选不出来,且没有任何提示 Revive / Remove 不清 burnGuard 状态 一并清掉。面板「恢复账号」的语义本就是「运维口径无条件恢复」
MINOR ErrAccountFault 软冷却的 reason 写死 "account fault (14017)" ⇒ 运维按错误方向排查 实际业务码不一定是 14017 reason 带实际业务码(新增 upstream.BusinessCodeOf)
INFO 裸 "request illegal"(无业务码)落 ErrClient ⇒ 5 次连败就把健康号降权出池 ErrClient 是唯一会喂连败计数的分支 归 ErrChannelRefused(不罚号、继续轮转)。三个可选口径里最轻且最可自愈;且上游后来把同一句文案升级成带 11-128 业务码的形态 ⇒ 无码形态极可能是 11-128 的旧版
INFO 上游一旦把业务码换到别的键名(errCode/errorCode/bizCode)⇒ 11-128 与 11140 同时失明 hasBusinessCode 只认字面键名 code 认一组常见别名

10.2 两条贯穿全局的原则

这轮的 12 个缺陷收敛成两条原则,也是本 fork 反复被审计验证的设计基调:

  1. 同一个上游错误,在任何一条路径上必须同一个分类。
    流式与非流式、HTTP 状态码与 SSE 帧、chat 路径与 scheduler 路径 —— 口径
    不一致的后果是「同一个错误,一个路径罚号、另一个路径不罚」,运维完全无法
    从面板反推发生了什么。

  2. 请求级问题不得升级成账号级 / 全局级处置。
    通道校验失败(请求形状)不罚号;模型侧限流(几分钟自愈)不禁用;一次请求级
    的形状问题不得触发整个网关整夜的降级。代价不对称永远朝「轻罚」倾斜。

10.3 验证

  • 新增 internal/server/audit_wave3_test.go(15 个用例)、internal/upstream/toolcalls_unify_test.go(7 个)、
    internal/pool/burnguard_visibility_test.go(4 个)。
  • 每一组都做了反向验证:把实现退回旧写法,对应用例必须失败 —— 逐项实测过
    D2 / D3 / D4 / D5 / D6 / D7 / D10 / D11 / D13 / D14 共 10 组,全部非空洞。
  • go build ./... / go vet ./... / go test ./... 全绿(18 个包)。

十一、交叉审计(第四轮)修掉的问题

第四轮审计把范围从「多组修复之间的相互干扰」扩到运行时契约与启动级数据风险,
抓出 3 个此前几轮没覆盖到的缺陷。它们都不是「某个功能写错了」,而是跨层契约被
悄悄破坏
:上游信封的字段口径、观察者拿到的对象身份、以及进程被强杀时磁盘上的
字节状态。

严重级别 缺陷 后果 修法
MAJOR Q6.1 「额度未知」被当成「额度为 0」:pool 里「从没读到过上游余额」与「读到 0」是同一个值(credits=0) 配了 credit_floor>0 的部署会静默挡掉所有新账号的收费模型:号在池里、面板显示「余额 0」、但一个请求都接不到,没有任何日志解释为什么 新增 entry.creditsKnown:只有成功读到过权威余额(SetCredits / SetCreditsSnapshot / ReenableIfCredits / reviveCoolingLocked)才置 true;floorBlockedForRealmModel 在 creditsKnown=false 时不套用 credit_floor;状态文件持久化 credits_known,旧状态文件用「正余额证明读到过」推断、credits=0 保守视为未知(方向是「不静默挡号」);面板显示「未知」而不是「0」,积分汇总只累加额度已知的账号
MAJOR Q5.3 「观察者只读不改对象」只对同步读取成立:逐帧观察者拿到的就是同一个 map 引用,修复/规范化阶段会就地改写 delta.content 与 finish_reason 任何把帧对象存下来的观察者事后读到的都是被改写后的状态 ⇒ 停摆判定读到的是修复后的内容 交给观察者的是深拷贝(cloneFrame),观察者拿到的永远是「上游原样」;frameObserver 单槽位 → frameObservers 列表(多观察者共存);error 帧同样走深拷
MAJOR Q4.5 半截配置拖垮启动:降级路径(rename 不可用时的原地覆盖)是 O_TRUNC 覆写,非原子 —— 进程在写一半时被杀 ⇒ 磁盘上留下半截 config.json Load 解析失败 → main.go log.Fatalf ⇒ 服务起不来;且旧值已被覆盖、无法找回 三层兜底:① 覆盖前先留一份 .bak 备份(上一份完整配置);② 启动时主配置解析失败 → 自动回退到 .bak 并打 WARN,服务照常起来;③ .bak 也没有 → 明确报错并给出恢复路径(而不是裸 JSON 错误)。绝不拿半截配置启动

11.1 这一轮补上的两处「断言写错方向」

审计过程中还发现两个测试本身是空洞的(断言方向写反,永远通过):

  • TestCloneFrameHandlesNonChoiceFrames 原来改的是副本自己(改不到源,永远通过),
    现在改源并断言副本不变 —— 浅拷贝会当场失败。
  • 断言必须显式钉时区:402 冷却到期时刻的用例在本地(+8)恒过、在 CI(UTC)恒挂。
    已用 TZ=UTC / TZ=America/New_York / TZ=Asia/Tokyo 三时区回归验证全绿。

11.2 验证

  • 新增 internal/pool/credits_known_test.go(未知不被挡 / 读到 0 仍被挡 / 三个权威读取
    入口都置位 / 状态往返持久化 / 旧状态文件按未知处理)、
    internal/upstream/frame_observer_clone_test.go(4 个)、
    cmd/server/config_backup_test.go(4 个)。
  • 反向验证:撤掉 creditsKnown 判断 ⇒ 用例失败;撤掉 cloneFrame 的 error 深拷 ⇒
    cloneFrame 未深拷 error 对象: map[error:map[code:99999]];撤掉 .bak 回退 ⇒
    Load 解析失败。
  • go build ./... / go vet ./... / go test ./... 全绿(19 个包),CI 的 test 与
    test-windows 两个 job 均通过。

十二、致谢

watsonctl and others added 30 commits October 20, 2026 09:21
上游对「渠道校验失败」的响应是 HTTP 400 code 11-128 "request illegal",body 自带
displayMsg「请求被拦截,请重新发送」与 displayTips「渠道校验失败,可能不是从官方
客户端发出的,请用官方客户端重新发送」——上游明示这是可重试的请求级拦截,
不是账号级封禁。

但 applyErrorPolicy 在 ErrAccountFault 分支按 msg 字面量匹配 "request illegal",
直接调用 Pool.Disable(),把账号永久踢出池子并要求人工解冻:

  h.cfg.Pool.Disable(uid, "account banned by upstream (11140 request illegal), re-login required")

实测该账号凭证/余额/JWT 全部正常,同一套出站头部换健康账号即可 200,换任何模型、
任何 header 变体都稳定复现——账号本身没有任何问题,禁用纯属误判。

改为软冷却:账号暂时出池、自动轮换,上游解除标记后自愈,绝不永久禁用。

触发该 400 的根因(缺 X-Conversation-ID)已由 linguo2625469#190 在 internal/upstream/headers.go
层修复;本 PR 是互补的防御面:即使将来上游换个措辞、或出现别的触发路径,误判的
代价也从「账号永久禁用、必须人工解冻」降为「短暂轮换、自动恢复」。

Refs linguo2625469#190
Refs linguo2625469#197
上游对 refreshToken 反重放:同一 RT 被消费后再用会返回 12153 并整条 RT 链
作废,账号从此恒 401、只能人工重新登录。而 scheduler keepalive、handler 临近
过期刷新、signin 工具都会并发触发同一账号的 refresh(原实现把 I/O 放在锁外,
a.mu 挡不住并发),于是掉登录概率随并发升高。

- RefreshToken 按 UID 做 single-flight:并发调用等待并复用首个结果,不重复打
  上游;不同账号仍并行,不拖慢 keepalive 全量刷新。
- 12153 兜底:磁盘 auth 文件已被其他进程(server + signin 双进程部署)rotate
  过时,采纳磁盘 token 重试一次,跨进程竞态也能自救。
- 新增用例:并发单飞、按账号隔离、磁盘 token 采纳、无变化不重试;
  go test -race ./internal/upstream 通过。
…问题一)

账号级软冷却按 until 到期恢复,但 until/coolKind/reason 字段按设计不主动
清理(NoteSuccess 注释:不碰 until/coolKind,各自到期)。statusOf 无条件
透出 Reason,面板 renderAccounts 又无条件渲染,导致恢复数天的账号在
「可用」旁长期挂着灰色「429 rate limit」,运维无法判断是历史残留还是当前
限流。

按 issue 建议的服务端方案:statusOf 仅在 Cooling/Disabled 时透出 Reason
(禁用账号另有 DisabledReason 透出,零回归)。问题二(模型级 6004 双口径)
独立另单处理。

测试:新增 TestStatusReasonHiddenAfterCooldownExpires 钉住「冷却中透出 /
到期不透出」;go build/vet/test ./... 全部通过。

Refs linguo2625469#171
上游在接近全命中时可能回 hit=0 + miss=3(prompt 数万的矛盾组合),
CacheTokens 只做「hit→miss」单向推导,hit<=0 直接触发「大前缀整段未命中」
WARN,命中率 99.99% 也被告警。

按 issue linguo2625469#170 建议的选项 1 把反向推导补对称:
- 流式 CacheTokens:miss 显式存在而 hit 为 0/缺失且 prompt > miss 时,
  按 prompt - miss 反推 hit;prompt <= miss 保持 hit=0(真实全 miss)。
- 非流式提取收敛为 cacheTokensFromUsage,口径与流式一致;上游新增
  UsagePromptTokens 供反推取 prompt_tokens。

测试:新增 TestStatsReaderCacheTokensDeriveHit / TestCacheTokensFromUsage /
TestCacheMissSignalDerivedHitNoWarn;go build/vet/test ./... 全部通过。

Refs linguo2625469#170
根因:首页快照只读 CycleEndTime(体验版包的月度周期重置日,如 2026-10-31),而「检查」读 DeductionEndTime(真实失效时刻,2034/2035 年);且快照只存最早单批、前端按精确时间戳 ceil 天数,导致日期、该批数量、今天/明天文案全部错位。

- upstream:新增 UserResourceSnapshot,统一 DeductionEndTime → ExpiredTime → PackageEndTime → CycleEndTime 优先级,返回按自然日聚合的 ExpiryBucket(同日多包合并、升序)。
- pool:Status/state 持久化 credits_expiry_buckets(state.json + Redis 快照),NoteModelCost 按 FEFO 扣减批次并顺延最早批次;SetCreditsSnapshot 为新写入口,保留 SetCreditsDetailed 兼容。
- scheduler/panel:签到、单号余额、后台余额刷新均写入完整批次;StartBalanceRefresh 启动立即刷一轮。
- 前端:快照把批次还原成 packages 形态后复用 expiryRowsHtml,与「检查」逐字节一致(同日按昵称/uid 稳定排序);overview 新增 balance_refresh_sec/balance_refreshed_at,卡片标注「每 5 分钟后台刷新,x 分钟前更新」。
- 测试:upstream 口径/同日合并、pool FEFO+持久化往返、overview 接线、前端快照与检查 parity 与节奏标注,全量 go test ./... 通过。
(cherry picked from commit 19c355a)
…ool-wide cooldown

起因(2026-10-15 实案):腾讯 CodeBuddy 推理集群队列打满时返回
HTTP 429 + {"code":6021,"msg":"queue.full.title"}。旧 Classify 未识别 6021,
落入通用 status==429 兜底 → ErrSoftRate → 逐号 CooldownSoftRate(600s) + 轮转换号:
2 秒内把整池 DeepSeek 健康号全部误杀冷却,且换号白撞把无效请求放大 MaxRotate 倍。

这是渠道级容量事件,不是账号问题——同一时刻换任何账号都撞同一堵墙。

处置语义:
- 识别:Classify 在 status==429 兜底之前判 6021(结构化 code 主判据 +
  "queue.full" 文案外卡,与 14018 的具体优先哲学一致),新增 ErrUpstreamBusy。
- 不罚号:applyErrorPolicy 零动作(无冷却/熔断/NoteError/连败),账号余额
  与 session 本就健康。
- 不轮转:chatCompletions 内 fail-fast,503 + upstream_busy 透传上游原文
  (含 requestId),客户端 SDK 按退避自然重试。
- hint:GatewayHint 增加 upstream_busy 文案(明确「非账号问题、等待后重试」)。

测试:Classify 用例 7 条(code/嵌套/文案/换状态码/防过宽)+ handler 端到端
1 条(零轮转/零惩罚/503+upstream_busy)。go test ./... 全绿。

(cherry picked from commit f14f374)
此前失败只进负缓存、不留日志,CN 域不可达时网关只列 global 模型却毫无线索。
负缓存期内直接返回,不会刷屏。

(cherry picked from commit b54dabe)
在模型与档位页面展示 Global 可用性标签,依据模型锁与最近真实请求结果区分可用、无权限、限流、上游错误和待验证;不新增上游推理探测。

(cherry picked from commit 74f8583)
accountTaskAutoAll 与单任务入口共用同一套成长任务动作,此前漏挡:
企业版 / 企业切换会话(token_source=enterprise_switch)下同样会白跑
全部任务并消耗真实对话配额。下游 fork 吸收本 PR 时指出该缺口,一并修复。

(cherry picked from commit f09c844)
copilot.tencent.com 对海外出口(如 Clash 全局 + TUN)的 TLS 握手直接复位,
CN 模型目录与对话全部 EOF,/v1/models 只剩 global 模型。官方 WorkBuddy 桌面端
已改用 https://www.workbuddy.cn,实测该域海外出口可达,模型目录与
/v2/chat/completions 均正常。

仿照 global.chat_base:空 = 回落内置默认,老配置零影响;只在装配期生效,
已列入面板保存后的需重启字段。

(cherry picked from commit 7fcc20a)
将 11158 归类为账号与模型级确定性拒绝,按 realm 聚合模型锁,并在全域无权限时返回 400 model_unavailable,避免误报 503。补充分类、锁池和端到端回归测试。

(cherry picked from commit 6ee5491)
问题:浏览器处于企业切换状态时完成 OAuth 授权,签发的 access/refresh token
JWT 带 token_source=enterprise_switch。该形态与真企业版(enterpriseId 非空)
不同:成长任务列表 / 额度查询 / 领养 Buddy 等读端点与直连 API 照常,但
/v2/report 行为事件返回 200 却被上游静默丢弃(实测 2026-10-12,同部署双账号
A/B 对照:15 个成长任务全部 0/x,重跑并 90s 内逐 15s 回读进度从不跳动)。
网关凭 IsEnterprise()(仅看 enterpriseId)误判为个人号,队列照常执行——
每轮白跑全部任务(含 expert_5 等真实对话配额),且无任何报错可查。

修复:
- auth: 新增 IsEnterpriseSession()(现解码 JWT 判 token_source,不缓存——
  refresh 会整体换发 token,切换状态可能随重新授权翻转);刻意与
  IsEnterprise()(计费口径)隔离,额度查询仍走个人口径
- scheduler: 活跃上报 / 连登管家 / 猫猫旅行 / 夜猫子四处门控换用
  IsEnterpriseSession()(已证实零收益);签到不动(未观测到静默失败,
  且失败为显式报错)
- panel: 任务队列构建跳过该形态账号并打 WARN;单任务/一键完成接口
  422 拒绝并附重登指引;登录热加载新账号时当场 WARN 预警
- 保持不动:任务列表扫描、额度查询、选号、保活(该形态下均可用)

测试:新增 auth/enterprise_session_test.go(8 用例覆盖 token 形态矩阵)
与 scheduler/enterprise_session_gate_test.go(五任务路径 A/B 对照,
含"签到不门控"反向断言);go build / go vet / go test ./... 全绿。

(cherry picked from commit 9282779)
runAutoAll 对 before.Current >= before.Target 的任务直接 skipped 跳过,从不
领奖;而经真实对话等链路点亮的任务不经过任何自动动作链路(也不在 25 个
autoActions 白名单里),积分因此长期卡在 2000/2100/2300 上不去 4000(linguo2625469#174)。

改动:
- skip 前先按口径领奖(新 helper claimTaskReward:mp 码走 ClaimRewardMP,
  其余 ClaimReward,两处调用共用)。claim 幂等(重复领 already_claimed →
  0 分 0 能),判据只用 progress 口径 Current>=Target,不依赖
  accept_status=="completed"(回读会短暂转 completed 后回滚,据此领奖会误领)。
- 补领结果写回 item(claimed / credit / energy / claim_error),文案追加
  「已补领 +x 分 +y 能」或「奖励此前已领取」。

测试:新增 internal/panel/autotask_claim_test.go(httptest 假上游跑真实
runAutoAll 链路)——达标未领必须打一次领奖且 claimed=true/100/5;已领取的
任务不再打领奖端点。已验证去掉修复时该测试失败(非假绿)。
go build/vet/test ./... 全部通过。

Refs linguo2625469#174

(cherry picked from commit 5e6d413)
三条现有链路都不负责「把已达标的奖领掉」:growthPending 要求任务必须有自动
动作(25 个 autoActions 白名单),不在白名单的达标任务永远扫不进队列;队列
路径要先重跑行为动作且一天只跑一次;一键完成对达标任务直接跳过。于是积分
卡在 2000/2100/2300 上不去 4000。

新增一条职责单一的周期幂等清扫:只 accept 未接受的任务、只 claim 已达标未
领的任务,不跑任何对话/上报行为。

- internal/panel/tasksweep.go:RunTaskSweepOnce 并发扫全部非禁用账号(账号
  串行),默认列表 + mp 列表按 task_code 去重合并;accept 按默认/mp 口径
  分开提交(端标记不同);claim 按 Task.Claimable 走既有的 claimTaskReward。
  判据只用 progress 口径,不用 accept_status=="completed"(会回滚→误领);
  claim 幂等(重复领 already_claimed → 0 分 0 能)。global / 企业版账号零
  上游请求;命中 per-account 锁(正在跑动作)的账号整轮跳过、不打断链路。
- scheduler:TaskSweepHook + StartTaskSweep 独立 ticker + SetTaskSweepInterval
  热改(同 StartBalanceRefresh 结构,关=0 时不启动)。
- config/main:schedule.task_sweep_enabled(缺省 true)/ task_sweep_minutes
  (缺省 15,<=0 回落),normalize 计算 TaskSweepInterval,启动日志与面板
  保存配置时的热更新。
- 面板配置页:新增开关 + 间隔输入框(app.js CFG_MAP + index.html 控件)。

测试(internal/panel/tasksweep_test.go,httptest 假上游跑真实链路):
default/mp 分流与计数、两轮幂等(第二轮 0 次领奖请求)、global/企业版零请求、
单项领奖失败不阻塞其余、忙账号跳过且解锁后自动补。
go build/vet/test ./... 全部通过。

Refs linguo2625469#174

(cherry picked from commit 162762f)
…#189)

官方在「套餐与积分」页挂限时补偿横幅(例:「DeepSeek 模型服务异常额外补偿
500 积分」,带领取截止日),账号侧可见,但网关此前是**盲领**:

    if credit, err := ClaimCompensation(a); err == nil { log.Printf(...) }

于是「没有补偿」「已经领过」「**领取真的失败**」三种情形在日志里完全同构。
补偿带截止日,签到排程一旦停摆(签到被禁用 / 机器关机 / 账号被禁用),
补偿就静默过期,用户全程无感知。

本 PR 的命题是「不要静默失败」,因此所有分支都要求**要么成功、要么显形**:

改动:

- upstream: 新增 GetCompensationStatus()(POST /billing/meter/compensation-status)
  与 CompensationStatus.Pending() 三与门。active=false 是「当前无活动」的
  正常 200,不是错误——这条语义搞错会让日常无活动的账号把排程日志刷成一片错误。
- scheduler: 签到排程的盲领改为「先查状态 → 待领才领」,判据直接用
  upstream 的 Pending()(唯一权威实现,避免同一与门散落三份拷贝);签到排程与
  面板按钮共用 claimOne,两条路径的分支与日志不会漂移。
- scheduler: 并发查询按**池顺序**写回(预分配等长切片、各写各自下标,不用锁——
  不同下标是互不重叠的内存,且与 /panel/api/packages 的既有写法一致);
  参与账号与被门控跳过账号合成**同一个有序切片**,跳过行因此也带得上昵称。
- panel: 新增 GET /panel/api/compensation(只读)、POST accounts/{uid}/compensation、
  POST compensation_all。批量入口返回 summary{claimed,skipped,errored,credit}:
  **必须先看 errored 再看 claimed**,否则上游全挂时(每个账号都走 error 分支)
  会被说成「当前没有待领补偿」,把故障说成「没活动」——比不说更糟。
- 前端: 账号池新增「官方补偿」卡片——待领账号 + 领取截止倒计时(≤48h 标红)+
  逐号/全量领取按钮;**查询失败的账号渲染为红色行且强制显形**,混合态下
  备注同时说明待领数与失败数。取数与呈现分离:取数只在进页面与手动点击时发生
  (补偿状态没有 overview 快照可吃,挂进 5s 轮询就是每 5 秒轰炸上游),倒计时
  走动靠 refreshVisible 对缓存做本地重渲染,不发请求。

门控沿用 streakBonusAccount 同口径:global(D4)与 enterprise 跳过,disabled 跳过;
paused 不跳过(纯领奖 RPC,与「让位防风控」的选号语义无关,旅行/成长任务亦然)。

兼容性:不改 config.example.json、不改任何既有配置键、不改端点路径与请求头。
对既有行为的影响仅一处:streakBonusAccount 每号每天多一次 compensation-status 查询
(与同批次签到/旅行/连登查询同量级)。

测试:15 个测试函数 + 21 个子用例(upstream 5 / scheduler 6 / 面板前端 4),另有
既有的面板 JS 语法、顶层冒烟、无内联脚本、CFG_MAP 一致性用例一并通过。其中:

- TestAppJSCompGateMutationDiscriminates 做**变异测试**:把 compPending 的 6 条判据
  逐条从 app.js 副本里删掉,本用例必须 FAIL。首版样本造得不好(有 status 缺失的行
  会被 r.status 子句短路,删掉 !r.skip/!r.error 照样全绿),已补齐判别力样本。
- TestAppJSRenderCompensationErrorVisibility 钉住「全失败必须显形」,否则本 PR 的
  命题在自己的 UI 里被推翻。
- TestCompensationStatusAllKeepsPoolOrder 跑 20 轮逐位比对 uid 序列,钉住并发改造后
  的行序稳定(修的是 #1:原实现按 goroutine 完成顺序 append,行序随机漂移)。
- TestEnterpriseGateSkipsCompensationEntries 覆盖**面板直接触发**的三个新入口——
  既有的门控测试只覆盖五类排程任务,两个入口都要钉住。

go build ./... · go vet ./... · go test -count=1 ./... 全绿(CI 同款:ubuntu + node)。

(cherry picked from commit d48b715)
面板「添加账号」新增 API Key 入口:CodeBuddy 平台签发的 ck_/pt_ 静态 Key 可直接
粘贴入池,无需 OAuth 设备流。判定只看 access token 前缀(官方 CLI 即以此判
tokenType=ApiKey),落盘形态与 OAuth 账号一致(refreshToken 为空)。

- auth: 新增 IsAPIKey / UsesEnterpriseBilling / SupportsPersonalGrowth;
  NeedsRefresh 对静态 Key 恒 false——refresh 端点只认 OAuth 设备流,否则请求
  路径会先刷新失败、把好号当坏号冷却换号
- upstream: 额度查询分流改用 UsesEnterpriseBilling(个人 get-user-resource 对
  静态 Key 恒返回空 Accounts);chat 出站对齐官方 CLI 双头
  (Authorization + X-API-Key);不注入企业身份头
- scheduler: 签到 / 活跃上报 / 连登管家 / 猫猫旅行 / 夜猫子门控改用
  SupportsPersonalGrowth;签到早退守卫放过静态 Key,保证企业口径余额刷新照跑
- panel: 新增 POST /panel/api/accounts/add_apikey(探测额度 → 落盘 → 热加载);
  单账号任务类接口对静态 Key 返回 422 并附说明,不再静默空跑;账号列表加
  「API Key」徽标、隐藏成长类按钮、到期列显示「静态凭证」;昵称同步跳过静态 Key
- import: 必填判据收敛为 uid + access_token,refresh_token 可为空(issue linguo2625469#165)

(cherry picked from commit 7176aac)
PR linguo2625469#162(企业切换会话)与 PR linguo2625469#193(静态 API Key 账号)都引入成长门控,但语义不同:

- 企业切换会话(token_source=enterprise_switch):**读端点**照常可用(签到、任务列表
  扫描、额度查询),只是**行为事件**返回 200 却被上游静默丢弃(200 不计分)。
  → 行为类任务(活跃上报 / 连登 / 旅行 / 夜猫子 / 成长任务执行)必须门控;
    签到与列表扫描不在此列(签到失败是显式报错,不静默)。
- 企业版 / 静态 API Key 账号:连读端点都无意义(上游一律 403 / 无个人账号身份)。
  → 全部成长端点门控。

合并为单一 SupportsPersonalGrowth 会让 PR linguo2625469#162 的既有测试
(TestEnterpriseSessionGateSkipsBehaviorTasks/签到,断言切换会话**照常**发起
/daily-checkin)失败——直接证据表明两层语义不可合并。

因此拆为:
- auth.SupportsPersonalGrowth():!IsEnterprise() && !IsAPIKey()(硬不成长)
- auth.SupportsBehaviorEvents():SupportsPersonalGrowth() && !IsEnterpriseSession()

调用点归位:
- scheduler:490 签到 → SupportsPersonalGrowth(读端点,仅挡企业版/API Key)
- scheduler:559 活跃上报 / travel:62 / streak:32 / blackcat:41 → SupportsBehaviorEvents
- panel/autotask.go:638 单任务、:1280 一键完成全部 → SupportsBehaviorEvents
- panel/taskcenter.go:231 队列入队 → SupportsBehaviorEvents

另:internal/upstream/client.go 的 UserResourceSnapshot 保留 PR linguo2625469#212 的 buckets
能力,同时把企业口径分流条件从 a.IsEnterprise() 放宽为 a.UsesEnterpriseBilling()
(静态 API Key 账号同样无个人资源包,恒返回空 Accounts)。
账号备注:备注是纯展示层的本机信息(localStorage: wb2api.remarks.v1),
账号池、用量明细(按账号)、积分扣除(按账号)三张表渲染同一份映射——
有备注时备注当主标识,原昵称 / uid8 以 .rm-ghost 浅色虚体跟随,账号标识
本身仍按服务端下发的值渲染。键取 uid 前 8 位,与 renderUsageDim 账号维度
的 key 同源,三个表格因此可以直接 join,渲染层不需要额外映射表。

编辑入口放账号池卡片头部的「备注」按钮(沿用 .veil/.dlg 弹窗习惯),一次
列出所有账号、留空即清除该账号备注。备注来自用户本机输入,拼进 innerHTML
前一律走 esc() 转义。

积分到期提醒折叠:#expBox 头部加折叠开关,只切换列表容器 #expPad 的 hidden,
卡片标题与「检查」按钮始终可见;默认展开——点「检查」本来就是为了看结果,
默认收起等于让功能看起来不存在;用户收起一次后由 localStorage 记住。

新增三个测试:备注键与虚体渲染(含转义)、折叠的默认 / 持久化 / 恢复三态、
接线齐备性。app.js 的新增顶层接线放在文件末尾,不进入 frontend_test.go 用于
切片定位的代码区间。

(cherry picked from commit aac05ee)
…inguo2625469#171 问题二)

黄框出现/消失由 until 决定(网关防超长限流拖死全池,封顶 now+softRateMax),
但主文字优先按上游文案 resetAt 显示「预计 X 解封(剩余…)」,导致文字时间
与实际掉框时刻不一致(实测提前 4m~1h15m 掉框),且掉框后一用又弹。

until < resetAt 时主文字改为同时明示两个时刻与口径:

    网关 HH:MM 起重试(剩余 …) · 上游称 HH:MM 恢复

倒计时跟随 until,与实际掉框一致,也与「模型与档位」页锁池表的 until 口径
对齐;title 悬浮仍保留两个完整时刻。其余分支(无 resetAt / until>=resetAt /
model_unavailable)展示不变。

测试:更新 TestAppJSRateLimitMeta 期望值钉住新口径;node --check 通过,
go build/vet/test ./... 全部通过。

Refs linguo2625469#171

(cherry picked from commit 20c3997)
…625469#134)

Docker 单文件 bind mount(./config.json:/app/config.json)+ 非属主 uid 运行
(compose 允许 PUID/PGID=1000)时,同目录的 config.json.tmp 落在镜像内 /app
(属主 uid 10001),写不进去 → 面板「保存配置」恒返回
"write config: open /app/config.json.tmp: permission denied";而两个官方解法
(以本机 uid 运行 / chown 10001)恰好互相冲突。

- 新增 internal/atomicfile:同目录 tmp 优先;写不动时依次退到 state 目录
  (Docker 下即 ./data,宿主机挂载必然可写)与系统临时目录;rename 遇 EBUSY
  (单文件挂载点)/ EXDEV 时退化为原地覆盖,并保留完整新内容供恢复。
- cmd/server.saveConfig 改用 atomicfile.Write,fallback 目录取 state 所在目录。
- internal/auth.SaveAtomic 同样补上 rename 失败的降级路径(原先只有 tmp 写入
  的权限提示,rename 遇 EBUSY 直接报错)。
- 新增 internal/atomicfile 单测覆盖:正常原子替换、目录不可写时走 fallback、
  系统临时目录兜底、新建文件。
)

上游两种形态的 msg 文案**完全相同**(都是 "request illegal"),只有业务码不同:
  - 11-128 / 11128 → 请求级「渠道校验拦截」,上游明示可重试(displayMsg
    「请求被拦截,请重新发送」、displayTips「没通过渠道校验…请用官方版本重新发送」)。
    实测该账号凭证/余额/JWT 全部正常,用相同出站头部换健康账号即可 200。
  - 11140 → 账号级授权封禁(auth/auth_forbidden),需重新登录。

PR linguo2625469#198 把整个 "request illegal" 文案改成软冷却,副作用是**真封禁也降级**成
永远轮换、永远不恢复。本次按业务码分野,两头都不牺牲:

- 新增 upstream.IsChannelVerificationBlocked:优先按结构化业务码(11-128 /
  11128,hasBusinessCode 走 JSON 解析容忍空白与嵌套),无业务码时退回渠道校验
  文案特征串。
- applyErrorPolicy 的 ErrAccountFault 分支:渠道校验形态仍软冷却(绝不 Disable),
  11140 保持 Disable 并提示重新登录。
- 新增 internal/server/request_illegal_policy_test.go:钉死「11-128 不禁用、
  11140 必禁用」两个方向(PR linguo2625469#198 原本没有任何测试),并覆盖判定函数的 8 种输入。
IsChannelVerificationBlocked 的文案臂此前没有以业务码为前置条件,导致
一个真正的 11140 账号封禁(信封里同时带 displayTips/displayMsg 的渠道校验
文案)仍被判定为「渠道校验拦截」→ 走软冷却而不是 Disable。

两种形态共用同一句 msg 与同一套信封字段,只有业务码能区分:11-128/11128
= 请求级可重试拦截,11140 = 账号级授权封禁。故把 11140 的判定放在文案臂
之前,业务码是权威判据。

同时补齐回归用例:11140+displayTips / 11140+中文 displayMsg 两种混合信封
必须判 false;并修正原测试里两处输入完全相同(导致「无业务码、无特征」分支
实际未被覆盖)的问题。
上游「request illegal」有两种形态,msg 文案完全相同,只有业务码不同:

  11-128 / 11128 → 请求级「通道校验失败」(displayMsg「请求被拦截,请重新发送」、
                    displayTips「没通过渠道校验,可能不是官方客户端发出的」)。
                    账号本身健康(官方客户端可正常使用同一账号),历史实现却
                    Disable → 仍可用的号被永久踢出池子,用户只能反复点「解冻」
                    (issue linguo2625469#197 / linguo2625469#231 的用户侧症状)。
  11140          → 账号级授权限制(auth/auth_forbidden),真封禁,须申诉解除。

历史实现用文案 marker「request illegal」一刀切归 ErrAccountFault,于是:

  1. 11-128 被 Disable —— 误伤健康账号(issue linguo2625469#197、linguo2625469#231)。
  2. 一旦把 11-128 改成软冷却,11140 的真封禁也跟着降级 —— 账号永远轮换、
     永远不恢复。

本次改法(分类层分流,语义最具体者优先):

- internal/upstream/client.go
  - 新增 ErrKind ErrChannelRefused(String() = "channel_refused")。
  - accountFaultMarkers 移除「request illegal」(它已不能作为通用 marker)。
  - 新增 IsAccountRestricted(body):11140 + 文案「request illegal」双条件。
  - 新增 IsChannelRefused(body):业务码 11-128/11128(两种书写)+ 文案合取。
    认 issue linguo2625469#188「conversation id missing」、issue linguo2625469#231「first message is not
    system prompt」等同码变体;内容审核变体(blocked by security policy /
    unapproved channel / illegal api invocation)归 ErrContentBlocked 域,不被
    通道校验语义吞掉;11140 优先(真封禁不得降级)。
  - Classify 在 accountFaultMarkers 之前先按业务码分流二者。
  - 移除旧的 IsChannelVerificationBlocked / channelVerificationMarkers(被
    IsChannelRefused 取代)。
- internal/server/handler.go
  - applyErrorPolicy 的 ErrAccountFault 分支改用 IsAccountRestricted 判 Disable,
    文案换成「需在 WorkBuddy 官方渠道申诉解除」(重登无效)。
  - 新增 ErrChannelRefused 分支:**零动作**(不冷却/不熔断/不 NoteFailures),
    账号本身健康不该受罚。
  - 轮转循环内 fail-fast:通道指纹是「出口 IP + 出站头部 + 请求体形状」的组合,
    换号不改变它 —— 轮转只会把无效请求放大 MaxRotate 倍、每号各交一次配额,
    末端还落成 503 no_healthy_account,把确定性的客户端侧问题伪装成服务端过载,
    客户端于是无限重试。故透传上游原文回 400 + code=channel_refused。
    例外:跨域回退开启(pool.cross_domain_fallback,默认关)且另一域还有能服务
    该模型的号时,换域可能换到另一条通道,此时继续轮转。
  - 末端错误映射补 ErrChannelRefused → 400 channel_refused。
- internal/upstream/hint.go
  - ErrChannelRefused 的 gateway_hint:指向排查 user-agent/头部/拦截代理。
- internal/server/request_illegal_policy_test.go
  - 重写为按业务码分流的断言:11-128/11128 零罚号、11140 禁用、11140 限流
    变体仍为软限流、裸「request illegal」不归属账号级判定;覆盖 issue linguo2625469#188 /
    linguo2625469#231 的同码变体与内容审核变体。

上游 PR linguo2625469#233 走的是同一思路(新增 ErrChannelRefused + 按业务码分流),本提交
在两点上更强:① 覆盖 issue linguo2625469#188/linguo2625469#231 的同码文案变体(上游版要求文案必须是
「request illegal」,会漏掉「conversation id missing」);② 轮转内 fail-fast 并
给出 400 channel_refused + 可操作 hint,避免把客户端侧问题伪装成 503 过载。
…sue linguo2625469#163 linguo2625469#160 linguo2625469#179 linguo2625469#222 linguo2625469#155 linguo2625469#169 linguo2625469#229 linguo2625469#227 linguo2625469#81)

- linguo2625469#163 国际版(global)账号无成长任务体系:面板明确 422,不再静默空跑;
  上游任务编号字段名 `code`(国际版)/`task_code`(国内版)归一。
- linguo2625469#160 快过期优先从固定 ×3 改为平滑梯度(1.5→5.0,按剩余时间/窗口换算)。
- linguo2625469#179 非流式响应「有 credit 无 total_tokens」不再整条不记积分。
- linguo2625469#222 企业版「不限量」哨兵值(1<<40)不再污染权重分母:分母改用候选集
  次大真实余额,哨兵号按「不低于任何正常号」参与。
- linguo2625469#229 积分燃尽熔断(默认关闭):窗口内单账号烧分超阈值即熔断,全选号
  路径(pick/粘性/可用集合/探活/顺序策略/兜底)统一排除熔断号。
- linguo2625469#155 WB2A_API_KEY 覆盖 config.json 时留 WARN 日志;面板 401 不再被吞。
- linguo2625469#169 注入 body 级 conversation_id(部分上游按它分前缀缓存槽)。
- linguo2625469#227 面板新增「旅行」按钮:单号触发猫猫旅行巡检(不等全池)。
- linguo2625469#81 重复 tool_call id 改名(<id>_d<k>)并同步改写 tool 结果配对。
DSH Agent and others added 28 commits October 20, 2026 16:41
从 reporter 分支(misakano7545/workbuddy2api-panel)移植三条协议/检索能力,
并按本仓语义逐条改造:

1) POST /v1/responses(Codex Responses 协议)
   新增 internal/server/responses.go:入站 Responses 请求 → 内部 chat 请求,
   回程 chat 响应 → Responses 事件流(response.created / output_item.delta /
   response.completed),含 custom(freeform) 工具、namespace 工具组、
   托管 web_search。与 /v1/chat/completions 共用同一 chatCompletions 内核,
   故错误策略(11-128 轮转 / 11140 封禁 / 6021 503)完全一致。
   GET /v1/responses 回 426,让 Codex 客户端立刻回落 HTTPS。

2) POST /v1/messages(Anthropic Messages 原生入站)
   新增 internal/server/messages.go:Anthropic 消息/块结构 ↔ chat 互转,
   官方 SDK 的 x-api-key 头提升为 Bearer(withAnthropicAuth)。
   声明了 web_search 服务端工具时,网关侧执行(探测 → 检索 → 注入结果)。
   旧的 /v1/search/messages 搜索壳改名 searchMessages(文件改名
   search_messages.go),两条路由并存、互不遮蔽。

3) POST /v1/search(官方 agenttool 检索透传)
   新增 internal/server/search_agenttool.go:回程原样透出上游 JSON,
   deep=true 走综述式 agentic_search(新增 internal/upstream/agentic.go)。
   upstream 侧新增 SearchRaw/doSearchRaw:与既有 Search 共用请求构造与错误
   分类,但不做 min-length 校验、不加工结果(透传端点要「上游给什么回什么」),
   且保留 ErrNone 以便把上游业务错误(如 15003)原文透给调用方。

按本仓语义修掉 reporter 实现的四处问题:
- 在途租约:reporter 的 search / searchtool 两条路径都不占 MaxInFlight 名额,
  检索流量会绕过限流。现已在 /v1/search、probeOnce、searchText 三处 Acquire/Release。
- 综述式检索原本无独立时限(走 c.HTTP 的 120s),现给 150s 预算,
  避免一次综述长期扣住账号名额。
- 请求级错误的判据:ErrNone(判不出类别)也按请求级处理并原文透传,
  不再把 15003 这类错误改写成含糊文案后继续换号。
- 请求日志记账只挂了两条路径,Codex 走 /v1/responses 时整类请求无来源不入归档;
  现按 isChatEntry 一视同仁覆盖四条入站协议路径。

不改动本仓既有的错误分流(11-128 / 11140 / 6021)、计费与账号池语义。
上游 fork 的 searchtool.go 与 /v1/search 都不取在途租约,检索流量绕过
MaxInFlight 限流;上一版补了 Acquire/Release,但归还点散落在各出口,
新出口容易再漏。改为「单次尝试 = 一个闭包,defer 归还」,租约作用域与
一次上游调用严格同生命周期。

新增两个断言在途计数归零的回归测试(上游 500 路径、客户端 ctx 取消路径);
已用变异测试验证:去掉 Release 后两条测试即失败(非空断言)。
问题:auth.SaveAtomic 的 rename 降级判据是 atomicfile.IsReplaceableErr,它只看
POSIX 的 EXDEV / EBUSY。而 Windows 上 MoveFileEx(..., MOVEFILE_REPLACE_EXISTING)
的失败原因是:
  - ERROR_NOT_SAME_DEVICE(17):跨卷 rename;
  - ERROR_ACCESS_DENIED(5):目标文件被占用 / 只读。
Windows 的 syscall.EXDEV 是「臆造错误」列表里的占位值(0x20000000),永远
不等于任何真实 errno → errors.Is(err, syscall.EXDEV) 在 Windows 上恒为 false
→ IsReplaceableErr 认不出这两种情况 → SaveAtomic 直接报错 → **刷新后的
accessToken 不落盘**(磁盘上仍是旧 token)→ 下次启动继续用旧 token。

实测(Windows 10/11 + Go 1.22+):目标被本进程打开 → "Access is denied.";
跨卷 → "The system cannot move the file to a different disk drive.";两种情况
errors.Is(err, syscall.EXDEV) 与 EBUSY 均为 false。

改动:
- 新增 atomicfile.CanFallbackToInPlace(按平台分文件实现):
  - atomicfile_windows.go:额外识别 ERROR_NOT_SAME_DEVICE /
    ERROR_SHARING_VIOLATION / ERROR_LOCK_VIOLATION / ERROR_ACCESS_DENIED
    与 fs.ErrPermission;
  - atomicfile_other.go:与旧的 IsReplaceableErr 等价(POSIX 语义不变)。
- internal/auth/auth.go 的 SaveAtomic 改用 CanFallbackToInPlace。
- IsReplaceableErr 补上 ⚠️ 注释:它是 POSIX 口径,跨平台调用方请用
  CanFallbackToInPlace。

安全边界:rename 失败只可能来自目标侧(源 tmp 刚写成功),所以「权限类错误
→ 降级原地覆盖」是安全的——目标可写就写进去,不可写时 writeInPlace 返回
真实错误,不会静默丢数据。

测试(internal/atomicfile/atomicfile_test.go):
- TestCanFallbackToInPlaceRecognisesWindowsErrno(Windows 专属,POSIX 上跳过)
- TestCanFallbackToInPlaceRejectsUnrelatedErrors(全平台:nil/未知错误/ENOENT
  不得被判为可降级)
- TestCanFallbackToInPlaceMatchesIsReplaceableErrOnPosix(POSIX 上两者必须等价)
- TestWriteConcurrentSamePath(并发写同一路径不会互相污染内容)

CI 注意:现有 workflow 只在 ubuntu 上跑 go test,Windows 专属路径永远不会被
执行到——本次已用 GOOS=windows go build/vet 验证可编译。
问题:issue linguo2625469#149 的「零 tool_calls 停摆」是**上游行为缺陷**,不是账号问题,
所以命中后绝不罚账号。这个「不罚」在池层面包含两件事:
  1. 不冷却 / 不禁用 / 不 NoteFailures / 不 NoteError;
  2. **不喂燃尽熔断(issue linguo2625469#229)的计账**——把「模型这一轮没调工具」记成
     「这个号在烧积分」会让熔断器误伤健康号。

第 2 条在非流式路径有守卫(handler.go 的 `if !stall.Suspect`),但流式路径
是**无条件** NoteBurn → 同一形态(声明了 tools、零 tool_calls、stop 收尾)
在流式下会被熔断、在非流式下不会 → 熔断结果随请求模式漂移,统计与排障
都失去意义。

改动:流式路径把 NoteBurn 收进 `if !streamStall.Suspect` 守卫,与非流式
完全一致(streamStall 变量提出来复用,避免二次调用探测器)。

测试(internal/server/tool_stall_burn_test.go):
- TestStallHopDoesNotTripBurnGuardInStream(流式:声明 tools + 零 tool_calls
  + stop + usage.credit=1,阈值 0.5 → 不得熔断)
- TestStallHopDoesNotTripBurnGuardInNonStream(非流式对称)
- TestNormalHopStillTripsBurnGuard(反向对照:真调了工具的一跳必须照常熔断,
  否则上面两个用例会因「熔断根本没生效」而假绿)
- TestStallHopStillBilledButNotBurned(停摆跳照样记账——余额要准;只是不参与
  燃尽熔断计账)

已验证非空洞:把流式的守卫去掉(还原成无条件 NoteBurn)后
TestStallHopDoesNotTripBurnGuardInStream FAIL(账号进入冷却,reason=
「积分燃尽熔断:窗口内消耗 1 积分超过阈值 0.5」),恢复后 PASS。
问题:两个 workflow 都只在 ubuntu-latest 上跑 go test / go vet,而仓库**同时
发布 Windows 二进制**(go-binaries.yml 的 matrix 含 windows/amd64)→ Windows
专属的代码分支永远不会被测试执行到。

实测教训(本次修复的那个 bug):internal/auth.SaveAtomic 的 rename 降级判据
在 Windows 上恒为 false(Windows 的 syscall.EXDEV 是臆造占位值 0x20000000)
→ 刷新后的 accessToken 不落盘。这个缺陷在 ubuntu CI 上**完全不可见**,
因为 POSIX 上 EXDEV / EBUSY 的语义是成立的。

改动:两个 workflow 各加一个 test-windows job(runs-on: windows-latest,跑
go test ./... + go vet ./...),并把下游 job 的 needs 改成 [test, test-windows]。

为什么不用矩阵复用 ubuntu 的 test:Windows 上的错误码语义、文件占用语义、
路径大小写语义都与 POSIX 不同,必须在真正的 Windows runner 上跑;
而 `GOOS=windows go test` 只能编译不能执行(本仓库已验证:交叉编译出的
测试二进制需要在 Windows 上运行才有意义)。
Windows 的 os.Chmod 只改「只读属性」,没有目录写保护语义 → chmod(0o555) 之后
目录照样可写 → 三个依赖「目录不可写」的用例在 Windows 上会退化成「正常写入」,
断言仍成立但覆盖不到 fallback 路径(假通过,且看不出在测什么)。

改法:在 Windows 上显式 t.Skip 并写明原因,与仓库既有的
internal/credithist/fault_test.go、internal/auth/permhint_test.go 保持同一口径。

配套:上一条 CI 提交(4f079fb)加了 test-windows job,这些用例在
windows-latest 上现在会被真实执行。
发现方式:本次新增的 test-windows CI job(windows-latest)**首次运行**就抓到了一个
此前从未被任何测试覆盖的真 bug。

现场(CI 日志原文):
  --- FAIL: TestWriteConcurrentSamePath (0.00s)
  atomicfile_test.go:252: 并发 Write: replace C:\...\config.json:
  open C:\...\config.json: The system cannot find the file specified.
  (完整新内容保留在 C:\...\config.json.tmp)

根因(两层):
1. 暂存文件用的是**固定**名 path+".tmp" → 两个并发写者共用同一个暂存名:
   - 后写者的 os.WriteFile 会把前写者**已备好的内容截断重写**,
     前写者随后 rename 落盘的是后写者的内容 → **静默数据错乱**(不报错,最坏);
   - 前写者 rename 成功后暂存文件消失 → 后写者 rename 报「源文件不存在」;
   - Windows 上更糟:文件正被另一个进程打开时 MoveFileEx 因共享冲突失败
     (ERROR_SHARING_VIOLATION / ERROR_ACCESS_DENIED),
     随后的原地覆盖又因「目标文件尚不存在」报 ERROR_FILE_NOT_FOUND
     → 一次本该成功的并发写变成失败。
2. 降级路径 writeInPlace 用 O_WRONLY|O_TRUNC 打开目标,**目标不存在时**报
   ENOENT / ERROR_FILE_NOT_FOUND(Windows 上正是上面那条错误)。
   目标不存在是常态(首次写入、并发写者已把目标 rename 走)。

修法:
- 暂存名改为**唯一**:<base>.tmp-<16位十六进制>(同目录,保证 rename 同文件系统
  仍是原子替换)。每个写者只操作自己的暂存文件,互不干扰。
  代价:进程被强杀可能留下形如 config.json.tmp-<hex> 的残留
  (取舍:宁可留下可识别的残留,也不写坏主文件)。
- writeInPlace 补 O_CREATE:目标不存在时**新建**。
- 错误信息里补上 rename 的**原始原因**:此前 rename 的真实失败原因会被原地覆盖
  的次生错误盖住(实测踩过:rename 因共享冲突失败 → 原地覆盖因目标尚不存在报
  ERROR_FILE_NOT_FOUND → 排查时看到的是一个完全误导的错误)。

测试(全部已验证非空洞):
- TestWriteUsesUniqueStagingNamePerWrite:用 stagingName 钩子断言「每次写都走
  暂存名生成」——把实现退回固定 path+".tmp" 时该用例 FAIL
  (「stagingName 应被调用一次,实际 0 次」)。
- TestWriteUniqueTmpName:命名规则(与目标同目录、config.json.tmp- 前缀、两次不同名)。
- TestWriteInPlaceCreatesMissingTarget:原地覆盖能新建目标。
- TestWriteConcurrentSamePathNoLeftoverTmp:正常完成后不留暂存残留。
- TestWriteAtomicReplace / TestWriteNoFallbackReturnsPermissionError 随之调整
  (暂存名不再固定,改为校验目录内无残留 / 错误信息含前缀)。

为什么用钩子而不是靠并发压测撞概率:竞态窗口极窄——4 写者 × 20 轮 × 200KB
负载在 Linux 上都撞不出来(实测),靠概率的回归测试等于没有测试;
而 Windows 的 MoveFileEx 共享冲突把窗口放大了,才在 CI 上暴露。
SaveAtomic 之前固定用 FilePath+".tmp" 作暂存名。两个**不同**的 *Auth 实例
(各自有自己的 mu)指向同一 FilePath 时并发保存:

  - A 写完 tmp → B 也写同一个 tmp(覆盖)→ A rename 走 → B rename 报 ENOENT;
  - 或 A rename 走 → B rename 时目标已被占(Windows 报占用),内容是 A 的。

实测 300 轮 × 2 写者:SaveAtomic 返回 nil 但内容未落盘 **282 次**(被对方
rename 走),另有 **266 次**报「源文件不存在」的假失败。

a.mu 只保护**同一个对象**;scheduler 的 keepalive 刷新与 handler 的按需刷新
持有的是**不同**对象 ⇒ 互不排斥 ⇒ 现实里必然撞上。

修法:改用 internal/atomicfile.Write(唯一暂存名 + 原子替换 + 跨平台降级
原地覆盖),两处不再各写一份、日后各改各的。

新增 internal/auth/auth_concurrent_test.go:
  - TestSaveAtomicConcurrentDistinctInstances:60 轮 × 2 写者并发,两次保存都
    不得虚假失败,落盘内容必须是某一个写者的**完整**内容(不能是半截文件);
  - TestSaveAtomicLeavesNoStagingFiles:保存后不得残留暂存文件(.tmp 里有
    明文 token)。
两个用例都做了反向验证:还原成旧的固定暂存名实现后必失败。
此前 chat 路径遇到**一次** 12153 就 Pool.Disable,而 scheduler 的 keepalive
路径是「连续 3 次才禁用」——两套策略并存。

问题:
  - 12153 会在**上游偶发**地出现(token 恰好被别的客户端用掉、并发刷新撞车、
    上游瞬时风控),单次就杀号是历史误判的主要来源;
  - 禁用是**终态**(不自动恢复,只能在面板手动解冻),误杀代价很高;
  - 单账号池里 1 次 12153 = 账号直接死掉(且 NoteSuccess 不会自愈),
    而 keepalive 里要撞三次才死 —— 同一账号在两条路径上的命运不同。

改成统一走 Pool.NoteSessionDead(连续 3 次才禁用,任意一次成功即清零),
与 scheduler 口径完全一致。日志里打出「已禁用」的那一次,方便运维定位。

同步更新 internal/server/handler_test.go 的 TestChatSessionDeadDisables:
第一次 12153 不断言禁用,累计到阈值才断言禁用。
企业版没有签到体系(上游 400 code 10001「企业账号不支持该操作」),/checkin
端点会退化为「只刷新额度」。但此前这条**解释性**文案同时被塞进了
checkin_error,而企业账号的 checkin_done 恒为 false ⇒ 前端
checkinResultNote 判定「没签到 + 有错误」⇒ 渲染成 severity=err 的红色
「签到未完成:企业账号无签到体系…」。

现场:面板上每一个**健康**的企业账号都显示成红色报错,运维误以为账号坏了
(外部脚本与旧缓存前端同样中招)。

修法:
  - 后端:区分「跳过」与「失败」——企业账号走 checkinSkipped 分支,
    只给 checkin_message(说明性),**不给** checkin_error,并新增显式的
    checkin_skipped 标记;
  - 前端:checkin_skipped / 企业号+仅解释性文案 → severity 非 err;
    balance_error 仍然优先(余额查询真失败时要能看见原因);
    有真 checkin_error 时仍然必须是 err。

新增断言(internal/panel/task_frontend_test.go):跳过态 severity≠err、
说明文案要透出、旧后端(无 checkin_skipped 字段)的企业号也不误报、
真失败仍是 err。反向验证:去掉前端这两行即失败。
402(余额耗尽)的硬冷却要等上游把额度重置了才放号,而**重置墙钟由上游决定**
(Asia/Shanghai,UTC+8 无夏令时)。此前 nextDay4AM 用的是部署机器的本地时区:

  - 部署在 UTC 的机器:上游已重置后仍在冷却(早放,白冷一部分);
  - 部署在 UTC+13 的机器:多冷近 5 小时(晚放,账号多闲置 5 小时,
    调度器少一个可用号)。

改成固定 upstreamCSTZone(UTC+8)。同口径先例:internal/scheduler/travel.go
的自然日切分已固定用 cstZone,internal/upstream/client.go 的 promoZone 同样
是 FixedZone("CST", 8*3600)。固定 +8 还免掉 tzdata 依赖(精简容器镜像常缺
/usr/share/zoneinfo),也不受夏令时影响。

internal/pool/pool_test.go 补了三条跨时区用例:UTC 机器的傍晚/深夜、UTC-5 机器
的正午 —— 断言到期时刻是**上游时区的 04:00**。
两处来自交叉审计:

1) **停摆判定缺少自防御**:DetectToolStall 只看「零 tool_calls + stop」,
   不看正文里有没有模型原生工具调用标记(DSML 块)。上游把工具调用吐成
   正文里的文本标记时,工具**其实调了**,判成停摆会:
     - 白白换号重试一次(多花一次上游调用,客户端多等一轮);
     - 在**没有标记修复层的分支**(v1.13.0 基线)上把「工具其实调了」的回合
       误报成停摆,污染停摆率统计(issue linguo2625469#149 的可观测性失真)。

   修法:正文含标记开始标记 ⇒ 不判停摆。两条路径口径一致:
     - 修复层处理过 → 标记被移走、变成结构化 tool_calls → 不是停摆;
     - 修复层没处理 → 正文里还留着标记 → 也不是停摆。
   标记常量提到 tool_stall.go 里的 markupCallOpenTag(**不引用** dsml.go 的
   markupOpen —— v1.13.0 基线分支没有 dsml.go,引用它会编译不过);dsml.go 的
   markupOpen 改为由该常量派生,两边永不漂移。

2) **WithFrameObserver 是单槽位**:后注册的观察者会**静默覆盖**先注册的
   (option 按顺序应用),现象是「判定偶尔不生效」且**没有任何报错**,极难
   排查。改成 frameObservers 列表:多个观察者共存,调用顺序 = 注册顺序。

新增测试:internal/upstream/tool_stall_marker_test.go(含「普通正文仍然判停摆」
的对照组,防止自防御把真停摆也放过)、internal/upstream/sse_frame_observer_test.go
(多观察者都被调用 + 透传字节逐字节不变)。
两处交叉审计发现(issue linguo2625469#222 建议 3 的延伸):

1) **同族多键取值随机**:上游同时给出 "credit" 与 "Credit"(一个真实值、一个
   占位 0)时,旧的「谁后覆盖谁」写法依赖 Go 的 map 遍历顺序 ⇒ **同一份响应
   每次解析出不同的余额**。实测 200 次解析 {"credit":812.45,"Credit":0,
   "limitNum":2000} ⇒ credit 分布 {0:166, 812.45:34}。取到 0 ⇒ 面板显示
   剩余 2000/2000(实际 1187)⇒ 派活后 402。

   改成:先按归一化键(去下划线 + 小写)分组,组内**优先取非零值**、全零时
   取零 ⇒ 与顺序无关。归一化后等价族:credit/Credit/CREDIT、limitNum/
   limit_num/LIMITNUM、usedNum/used_num。

2) **类型不匹配静默当 0**:上游偶尔把数值序列化成字符串("812.45")。旧的
   json.Unmarshal 到 float64 失败就跳过 ⇒ credit=0,而 hasQuotaField 仍为
   true ⇒ 「空 payload」守卫拦不住 ⇒ 面板显示 2000/2000(真实 1187)、
   或账号被误判耗尽而挡掉收费模型。

   改成:quotaNumber 接受 JSON number **与**数字字符串;实在解析不了就置
   badType,调用方据此**报错**而不是把读不出来的额度当成 0。
上一条提交把 402 硬冷却的到期时刻改到**上游时区**(CST=UTC+8)后,四个
断言仍在看 st.Until 的**本地**小时数 —— 在 WSL(本地 +8)上恰好相等,所以
本地全绿;但 CI 跑在 UTC(差 8 小时)⇒ 四个用例全挂:

  - internal/pool: TestCooldownUntilTomorrow4AM / ...Persists
  - internal/server: TestChatHardCreditCooldownUntilNextDay4AM /
    TestChat429Code14018UsesHardCreditCooldown

这正是「本地过、CI 挂」的典型:断言没跟实现的口径对齐。

修法:断言改成 st.Until.In(pool.UpstreamCSTZone()).Hour()(新增导出
UpstreamCSTZone(),让调用方与测试用同一口径)。

已用三个时区回归验证:TZ=UTC / TZ=America/New_York / TZ=Asia/Tokyo 下
internal/pool 与 internal/server 全绿。
Windows CI 抓出来的第二个真 bug(本轮并发写用例在 Windows 上第 5 轮就挂):

现象:
  replace ...workbuddy-concurrent.json: rename 失败(... Access is denied.),
  原地覆盖也失败: open ...: The process cannot access the file because it is
  being used by another process.

根因:Windows 的 MoveFileEx(..., MOVEFILE_REPLACE_EXISTING) 在**目标文件正被
另一个句柄打开**时返回 ERROR_ACCESS_DENIED(5) / ERROR_SHARING_VIOLATION(32)。
两个写者并发替换同一目标时这**必然会撞上**(另一个写者的 rename 窗口只有几
微秒,但足以让本写者失败)—— 不是罕见故障,是并发写的常态。

此时直接降级原地覆盖也没用:目标正被另一个句柄独占,writeInPlace 同样失败
⇒ 一次本该成功的并发写变成失败。

修法:rename 遇到「目标侧临时占用」类错误时**短暂重试**(40 次 × 5ms),
让另一个写者先完成;重试仍失败才降级原地覆盖。

平台差异(important):
  - Windows:isTargetBusyErr 命中 ACCESS_DENIED / SHARING_VIOLATION /
    LOCK_VIOLATION / fs.ErrPermission;
  - 非 Windows:POSIX rename(2) 在目标被打开时**不会**失败,失败只来自 EXDEV
    (跨设备)与 EBUSY(单文件挂载点)—— 这两个是结构性问题,重试无意义 ⇒
    非 Windows 上 isTargetBusyErr 恒 false,**行为与从前完全一致**(不多睡、
    不改变任何错误语义)。

新增 internal/atomicfile/atomicfile_retry_test.go:40 轮 × 2 写者并发替换同一
目标,断言每次写都成功、落盘内容是某个写者的完整内容、结束后不残留暂存文件。
交叉审计第二轮 D3/D4:同一个事实在两层用了两套判读方式,而两层的动态类型
不同 —— Aggregate 产出的 message.tool_calls 是 []map[string]any,JSON 反序列
化出来的 body 里是 []any。

后果:
  - 停摆检测的 hasToolCalls 只认 []any ⇒ 非流式路径把「工具其实调了」的回
    合误判成停摆(D3)⇒ 白烧一次上游调用 + 白解绑一次粘性会话;
  - DSML 修复器的守卫(msg["tool_calls"].([]any))同样认不出 ⇒ 修复器会用
    「正文标记还原出的调用」覆盖真实 tool_calls(D4)⇒ 调用参数被改写(实测
    Hangzhou → Beijing)。

修法:新增 internal/upstream/toolcalls.go,把判读收成**一个**函数:
  - HasStructuredToolCalls(v any) bool —— 认 []any / []map[string]any /
    map[string]any(单调用对象或 index→call 映射);
  - MessageHasToolCalls(msg) bool —— 认 tool_calls 与老形态 function_call;
  - ToolCallList(v any) []map[string]any —— 归一化成同一份列表。
停摆检测与 DSML 守卫都改调它。

刻意放在独立文件里:v1.13.0 基线分支(release/v1.13.0-fixed)不含 dsml.go
(上游 v1.13.0 之后的提交引入),本文件不能依赖 dsml.go 的任何符号,两条分
支才能共用同一份实现。

回归:internal/upstream/toolcalls_unify_test.go(7 个用例);已验证非空洞
(回退 hasToolCalls 后 TestHasToolCallsSeesAggregateShape 失败)。
交叉审计第二轮 D2(BLOCKER):上游用「HTTP 200 + SSE error 帧」回报错误时,
帧形如 data: {"error":{"code":11128,"message":"request illegal"}}。

11-128/11128(通道校验)与 11140(账号限制)的判据**在业务码上**,文案
"request illegal" 是两者共用的。旧实现只把 error.message 交给 Classify ⇒ 业
务码丢失 ⇒ 11-128 落通用 4xx ErrClient ⇒ handler 的 default 分支喂
NoteFailures ⇒ **5 次就把健康号降权出池 10 分钟**。

而非流式路径走 Classify(status, 完整 body),同一个上游错误在那边是
ErrChannelRefused(零动作)——于是**同一个错误两条路径处置相反**:一个罚号
一个不罚号。

修法:FrameKind 把 error.code 与 error.message 合成一个**最小业务信封**再交
给 Classify,口径与非流式完全一致。

关键细节:**整个 error 对象都要搬**(不只是 code + msg)。11140 的模型级限流
说明("The model provider is rate-limiting requests.")只出现在 displayMsg
里,msg 仍是 "request illegal";只搬 code+msg 会把那句限流说明丢掉 ⇒
IsAccountRestricted 判不出限流变体 ⇒ 把**模型侧限流**当成账号封禁 ⇒ 永久
禁用健康号。code 原样搬运(数字 11128 或字符串 "11-128" 都可能),故用
json.RawMessage 直接嵌入。

回归:internal/server/audit_wave3_test.go 的 TestFrameKindKeepsBusinessCode
(6 个用例)+ TestFrameKindMatchesNonStreamClassification(流式/非流式口径一
致性,4 组 body)。已验证非空洞(回退 FrameKind 后前者失败)。
交叉审计第二轮 D5 / D6 / D7 / D13 / D14 —— 5 个缺陷同源:**判据与惩罚强度
没有对齐「上游到底说了什么」**。

D5(MAJOR,影响面最大):内容审核域的边界判错了。
上游把两种语义塞进**同一个业务码** 11-128/11128,只有「主文案字段」能区分:
主 msg = "blocked by security policy" 才是真内容审核;主 msg = "request
illegal" / "conversation id missing" / "first message is not system prompt" /
"Illegal API invocation from an unapproved channel" 都是通道校验(账号健康)。
早先的实现按**整 body** 扫文案,于是 11-128 + displayMsg 里的审核词会被归成
内容审核 ⇒ handler 执行 h.degrade.Trigger() ⇒ **整个网关对所有账号、所有模
型**降级到次日 00:00 CST(一次请求级形状问题被放大成整网关整夜的降级)。
修法:只看**主文案字段**(body.msg / error.message),不扫 displayMsg/displayTips。
判不准时**偏向通道校验**(代价不对称:误判通道校验 = 有界重试、零惩罚;误判
内容审核 = 可能整网关整夜降级)。

D6(MAJOR):11140 的模型侧限流否决只认三条逐字句,上游改一个字(「模型提供
方限流中」vs「模型提供方正在限流」)就把可自愈的限流翻转成**永久禁用**健康
号。修法:判据改为**语义家族**(modelRateLimitFamily,覆盖中英文与常见变体)。

D8(MAJOR,顺带修):11140 但文案不含英文原句时,真封禁既不禁用、又喂连败
计数(永不被检出 ⇒ 账号带病留在池里、面板显示可用、用户无从申诉 —— 比误封更
糟)。修法:文案判据改为 accountRestrictedMsgFamily(明确说「账号被限制/封禁」
的措辞),且**刻意不收**「限流/用量」类宽词。

D7(MAJOR):HTTP 429(瞬时限流)带 11140 ⇒ 永久禁用健康号。一次限流高峰就
白扔一个号(不可自愈、需人工申诉),与 issue linguo2625469#197 要避免的「好号被误禁」同类。
修法:**分类口径不动**(保持与上游既有契约一致:账号级故障码优先于 429 兜底),
改在**惩罚侧**分级 —— 429 只软冷却(可自愈),其它状态码才硬禁用。代价权衡:
若上游真用 429+11140 表示封禁,本分级会把它降级成软冷却(可自愈);反之永久
误禁不可自愈,两者不对称故取软冷却。

D13(INFO):裸 "request illegal"(**无业务码**)落通用 4xx ErrClient,而
ErrClient 是**唯一会喂连败计数**的分支 ⇒ 5 次连败就把健康号降权出池 10 分钟。
修法:归 ErrChannelRefused(**不罚号**、继续轮转)。判不准时三个口径里
(通道校验零惩罚 / 账号封禁永久禁用 / ErrClient 降权出池)最轻且最可自愈的
是通道校验;且上游后来把同一句文案升级成带 11-128 业务码的形态 ⇒ 无码形态
极可能是 11-128 的旧版。触发词刻意**只限** "request illegal" / "请求非法"
这一句真正二义的文案 —— 其它措辞("account banned" 等)只出现在封禁语义里,
无码时也应按封禁方向处理。

D14(INFO):hasBusinessCode 只认字面键名 "code",上游一旦换键名
(errCode/errorCode/error_code/bizCode)⇒ IsChannelRefused 与
IsAccountRestricted **同时失明**(11-128 与 11140 双双落通用 4xx)。修法:认一
组常见别名,并新增 BusinessCodeOf 供错误文案标注实际业务码。

顺带修 D12(MINOR):ErrAccountFault 软冷却分支把 reason 写死 "account fault
(14017)",而实际业务码不一定是 14017(例如 {"code":11140,"msg":"trial not
activated"})⇒ 运维按错误方向排查。修法:reason 带实际业务码。

回归:internal/server/audit_wave3_test.go(15 个用例)+ 更新
request_illegal_policy_test.go 的裸文案期望。全部已验证**非空洞**(逐项回退
后对应用例失败)。
交叉审计第二轮 D10 / D11(MINOR,但都指向同一个运维黑洞:**面板说「可用」、
实际选不出来**)。

D10:熔断号在面板两处被计为「健康 / 可服务」。
熔断(burn guard)是**选号层面**的排除:burnBlocked 在各选号路径上都生效,但
账号本身 healthy(可登入、可签到)。聚合口径若把它算成 healthy,面板总览就会
显示「还有 1 个健康号」,而实际上号根本选不出来 ⇒ 排障方向完全错(去查「号为
什么不出请求」,而不是查积分燃尽熔断)。
修法:countsDetailedForRealm 把 burnBlocked 归入 cooling 桶(与 statusOf 的口
径对齐 —— statusOf 本来就把 burn 显示为 Cooling=true / CoolKind="burn",数
据在、聚合口径没跟上);modelview.go 的 Servable 计数也排除熔断号。

D11:面板「解冻」(Revive)与「删号重加」(Remove)都不清熔断状态。
面板「恢复账号」的语义是「运维口径无条件恢复」(internal/panel/panel.go 的
accountRevive 注释原文如此)。不清熔断的话,运维点了恢复,账号**依然选不出
来**,而且没有任何提示说明还差 cooldown 到期 ⇒ 运维会以为按钮坏了。删号后重
新导入同一账号同理。
修法:Revive 与 Remove 一并清掉 burnGuard 状态。

回归:internal/pool/burnguard_visibility_test.go(4 个用例)。已验证**非空洞**
(逐项回退后 TestBurnBlockedAccountIsNotCountedAsHealthy /
TestBurnBlockedAccountIsNotCountedAsServableForModel / TestReviveClearsBurnGuard
/ TestRemoveClearsBurnGuard 分别失败)。
问题:pool 里「从没读到过上游余额」与「读到 0」是同一个值(credits=0)。
于是配了 credit_floor>0 的部署会把**所有新账号**(刚导入、余额查询失败、
额度信封判空)静默挡在收费模型之外:号在池里、面板显示「余额 0」、但一个请求
都接不到,且没有任何日志解释为什么。

修法(区分「未知」与「0」):
- entry.creditsKnown:只有**成功读到过**权威余额(SetCredits / SetCreditsSnapshot /
  ReenableIfCredits / reviveCoolingLocked)才置 true。
- pick.floorBlockedForRealmModel:creditsKnown=false ⇒ **不套用** credit_floor
  (未知 ≠ 触底;真正的触底会在下一次余额刷新后被拦住)。
- 状态文件持久化 credits_known;旧状态文件用「正余额证明读到过」推断,
  credits=0 保守视为未知(方向是「不静默挡号」)。
- Status.CreditsKnown 透出给面板:app.js 显示「未知」而不是「0」,
  积分汇总只累加**额度已知**的账号,另有 N 个额度未知时显式标注。

测试:internal/pool/credits_known_test.go(未知不被挡 / 读到 0 仍被挡 /
三个权威读取入口都置位 / 状态往返持久化 / 旧状态文件按未知处理),
已反向验证:去掉 creditsKnown 判断 ⇒ 用例失败。
问题:sse.go 里 repairFrame(DSML 标记还原)拿到的就是**同一个 map 引用**,
它会就地改写 delta.content 与 finish_reason(stop → tool_calls)。文档承诺
「观察者只读不改对象」实际上**只对同步读取成立**:

    [observer] live[0]  finish=""       contentLen=218 hasMarkup=true   ← 原始帧
    [observer] kept[0]  finish=""       contentLen=6   hasMarkup=false  ← 标记已被抹掉
    [observer] kept[1]  finish="tool_calls"                            ← stop 被改写

任何把帧对象**存下来**的观察者(日志记录、事后统计)事后读到的都是修复后的
状态 ⇒ 判定口径悄悄变了,且没有任何报错。

修法:新增 cloneFrame(深拷 choices[].delta 与 finish_reason,其余按值拷贝),
两个观察者调用点(正常帧 + error 帧)都传副本 ⇒ 观察者拿到的永远是「上游原样」,
任何时候读都对。透传字节零变化(cloneFrame 只读不改源对象)。

测试:internal/upstream/frame_observer_clone_test.go
- TestFrameObserverReceivesUnmodifiedFrame(观察者事后读到的仍是原始帧)
- TestFrameObserverDoesNotAlterPassthroughBytes(透传字节逐字节相同)
- TestCloneFrameIsDeepForDelta / TestCloneFrameHandlesNonChoiceFrames
已反向验证:改回传原引用 ⇒ 用例失败。
问题:落盘的降级路径(rename 不可用时的原地覆盖)是 O_TRUNC 覆写,**非原子**:
进程在写一半时被杀 ⇒ 磁盘上留下**半截** config.json。而半截配置解析失败 ⇒
main.go log.Fatalf("load config: …") ⇒ **服务起不来**,且旧值已被覆盖无法找回。
实测:半截配置 Load 报 "parse config: unexpected end of JSON input"。

修法(三层):
1) 落盘前留一份 .bak 备份(只备份**可解析**的当前内容——半截内容不值得备份
   成「上一份完整配置」;备份失败不阻断保存,备份是保险不是主路径);
2) 启动时主配置解析失败 ⇒ 自动回退到 .bak 并打 WARN(服务照常起来);
3) 备份也不可用 ⇒ 明确报错并给出恢复路径(从 config.example.json 复制,或
   删除该文件让程序自动生成推荐配置)——**绝不**拿半截配置启动:那比启动失败
   更难排查(面板/调度器会拿着缺字段的配置跑)。

测试:cmd/server/config_backup_test.go
- TestLoadFallsBackToBackupWhenConfigTruncated(半截 ⇒ 回退 ⇒ 用备份里的 listen)
- TestLoadWithTruncatedConfigAndNoBackup(无备份 ⇒ 报错且信息含恢复指引)
- TestLoadWithEmptyConfigFile(空文件同样回退)
- TestSaveConfigKeepsBackupBeforeOverwrite(保存前备份的是**旧**完整配置)
已反向验证:关掉回退分支 ⇒ 用例失败。
交叉审计第二轮 Q5.3 的补充:cloneFrame 只深拷了 choices[].delta,
error 子对象仍是浅拷贝 ⇒ 错误帧的读方(补 code / 改文案)与观察者共享
同一个 map,「观察者拿到的是上游原样」这条契约在 error 帧上不成立。

同时把 TestCloneFrameHandlesNonChoiceFrames 的断言改强:原来改的是
**副本**自己(改不到源,永远通过),现在改**源**并断言副本不变
——浅拷贝会当场失败。

非空验证:撤掉 error 深拷贝 ⇒ 该用例 FAIL
(cloneFrame 未深拷 error 对象: map[error:map[code:99999]])。
上游 11-128 / 11140 这类错误是「按请求体判定」的,出问题时看不到
出站 body 就只能猜。开了这个开关,网关会把入站与出站请求体各打一行
到 stdout(与聊天流水同一份日志)。

用法:环境变量 WB2A_DUMP_BODY=1;不需要时不要设(长会话日志很大)。
上游风控是「按头族 + 请求体」联合判定的,出问题时看不到实际出站头就
只能猜。这个开关把出站头按名排序逐条打印(Authorization / X-API-Key
只打长度不打印值)。

配合同一个开关里的 body dump,一次就能把「我以为发了什么」和「实际发了
什么」对齐。调试用,上线不要设。
此前网关的运行日志只往 stderr/stdout 写。Windows 上双击或托盘启动时
根本没有控制台窗口可看,出了「上游返回 400 但客户端只看到一个错」这类
问题就只能猜。

新增 logging.log_file(空 = 不落盘,保持原行为):
- 追加写,不截断历史;父目录自动创建。
- 体积上限 + 轮转:超过 log_file_max_mb(默认 64MiB)后轮转为
  .1/.2…,最多保留 log_file_backups(默认 3)份,最老的删除。
- 打不开文件只降级为一行 WARN,不让网关起不来(stderr 仍有输出)。
- 与 stdout 共存:控制台照旧有输出,文件里是同一份。

新增 internal/logfile 包(轮转写入器 + 7 个测试)与 cmd/server 的
4 个配置测试;config.example.json 与 README 补上这三个键。

实测(本地起真实进程):启动信息与聊天流水行
'| #1 | 09:14:04 | gpt-4o | sync | 503 | … | src=127.0.0.1 ua=… |'
都完整写进 data/gateway.log,控制台输出不受影响。
上一个提交(dump 开关)有两个实际问题:

1. body 可能几百 KB,整条打进日志会冲垮日志、也会把提示词原文大面积
   留在磁盘上。现在截断到 4000 字符,截断处显式标注原始长度。
2. dump 走 log.Printf → stderr。上次用户只把 stdout 重定向到文件,
   dump 全跑进了装不下的控制台窗口,日志里一条都没有。现在日志落盘
   打开后 stderr 也在文件里;下面这个测试把「dump 开关必须真的打印
   入站体、出站体、出站头、且 Authorization 不明文」钉住。

新增 4 个测试:入站/出站体都打印、出站头整体打印且 Authorization 只打
长度、没开开关时一句都不打、截断行为。
Huiaei pushed a commit that referenced this pull request Oct 21, 2026
此前网关的运行日志只往 stderr/stdout 写。Windows 上双击或托盘启动时
根本没有控制台窗口可看,出了「上游返回 400 但客户端只看到一个错」这类
问题就只能猜。

新增 logging.log_file(空 = 不落盘,保持原行为):
- 追加写,不截断历史;父目录自动创建。
- 体积上限 + 轮转:超过 log_file_max_mb(默认 64MiB)后轮转为
  .1/.2…,最多保留 log_file_backups(默认 3)份,最老的删除。
- 打不开文件只降级为一行 WARN,不让网关起不来(stderr 仍有输出)。
- 与 stdout 共存:控制台照旧有输出,文件里是同一份。

新增 internal/logfile 包(轮转写入器 + 7 个测试)与 cmd/server 的
4 个配置测试;config.example.json 与 README 补上这三个键。

实测(本地起真实进程):启动信息与聊天流水行
'| #1 | 09:14:04 | gpt-4o | sync | 503 | … | src=127.0.0.1 ua=… |'
都完整写进 data/gateway.log,控制台输出不受影响。

(cherry picked from commit d243d03)
U 盘现场日志:
  panel: 保存后台任务恢复状态失败: rename data\.task-jobs-xxx.tmp data\task-jobs.json: Access is denied.

根因:Windows 上 Go 的 os.Open 不带 FILE_SHARE_DELETE ⇒ 只要有任何进程
(杀毒软件/索引器/并发写者/面板自己)持有该文件的句柄,
MoveFileEx(..., MOVEFILE_REPLACE_EXISTING) 就返回 ERROR_ACCESS_DENIED。
手写 tmp+rename 把这个错误当成致命错误直接返回 ⇒ **后台任务状态在
Windows 上永远存不下去**(重启后任务队列丢失)。

internal/panel/taskjobs.go persistLocked 与 internal/panel/ring.go
persistTasksLocked 两处都改成走 atomicfile.Write:
  · 唯一暂存名(并发写不互相踩)
  · rename 失败按 Windows 错误码降级为原地覆盖(CanFallbackToInPlace)
  · 目标被占用时的重试(isTargetBusyErr + retryRename)

新增测试:
  · internal/panel/taskjobs_atomic_test.go(跨平台:反复写不失败、
    不留暂存文件、目标被占用时降级成功)
  · internal/panel/taskjobs_windows_test.go(Windows 专项:持有目标句柄
    时仍写成功——手写 rename 在这里必失败,由 Windows CI job 实跑验证)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.