From d35bdfe509be363087c16a316f068599f7d32876 Mon Sep 17 00:00:00 2001 From: DSH Agent Date: Tue, 20 Oct 2026 13:32:54 +0800 Subject: [PATCH 01/57] =?UTF-8?q?fix:=20=E4=BB=A5=20v1.13.0=20=E4=B8=BA?= =?UTF-8?q?=E5=9F=BA=E7=BA=BF=E9=87=8D=E6=94=BE=2037=20=E9=A1=B9=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=EF=BC=88=E4=B8=8D=E5=90=AB=E4=B8=8A=E6=B8=B8=209=20?= =?UTF-8?q?=E4=B8=AA=E5=90=8E=E7=BB=AD=E6=8F=90=E4=BA=A4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本分支基线严格为上游发布标签 v1.13.0(d66384d),内容为 37 项修复 (issue #163 #160 #179 #222 #155 #169 #229 #227 #81 #197 #188 #231 #145 #171 #137 等)的重放,不含 v1.13.0..6b31db6 的 9 个上游后续提交 (DSML 原生 tool_call 修复、prompt_cache_key 会话回退、空 tool_call name 剥离、到期卡片面板改版)。 冲突解决(5 个文件): - internal/upstream/tool_pairing.go:取本次修复的 import 块(fmt), 移除上游 535e19e 的 nameBadIDs / toolCallNameEmpty 机制(上游后续 功能,非本次修复);#81 重复 tool_call id 改名逻辑完整保留。 - internal/panel/app.js:把 76c3bdb 的到期快照语义移植到 v1.13.0 的 app.js(renderExpirySnapshot / snapshotNoteText / expiryRowsHtml 共用渲染 / loadOverview 刷新钩子);丢弃上游 8391a05 的 loadPackages(force) 改动,保留 v1.13.0 的 loadPackages()。 - internal/panel/frontend_test.go:保留上游 0ab6690 的两个到期快照用例 与本次修复的「快照与检查逐字节一致」「节奏标注」用例。 - internal/upstream/sse_test.go:保留本次修复的 TestUserResourceSnapshotDeductionEndTime,丢弃上游 7595a0a 的 DSML 用例块(v1.13.0 无 internal/upstream/dsml.go,无法编译)。 - internal/server/handler_test.go:保留本次修复的 TestChatHiddenModelRejected / TestModelsHiddenFiltered,丢弃上游 7595a0a 的 DSML 用例块(v1.13.0 无 dsml.go)。 --- README.md | 56 +- cmd/server/cn_chat_base_test.go | 46 ++ cmd/server/config.go | 113 +++- .../cross_domain_fallback_config_test.go | 36 + cmd/server/main.go | 94 ++- cmd/server/strategy_config_test.go | 92 +++ config.example.json | 18 +- internal/atomicfile/atomicfile.go | 120 ++++ internal/atomicfile/atomicfile_test.go | 141 ++++ internal/auth/apikey_test.go | 103 +++ internal/auth/auth.go | 165 ++++- internal/auth/enterprise_session_test.go | 54 ++ internal/livecfg/livecfg.go | 38 +- internal/livecfg/livecfg_test.go | 53 ++ internal/panel/apikey.go | 133 ++++ internal/panel/apikey_test.go | 183 ++++++ internal/panel/app.js | 617 ++++++++++++++++-- internal/panel/autotask.go | 58 +- internal/panel/autotask_claim_test.go | 113 ++++ internal/panel/compensation_frontend_test.go | 285 ++++++++ internal/panel/frontend_test.go | 498 +++++++++++++- internal/panel/import.go | 39 +- internal/panel/import_test.go | 62 ++ internal/panel/index.html | 101 ++- internal/panel/login.go | 6 + internal/panel/model_probes_test.go | 6 +- internal/panel/overview_expiry_test.go | 64 ++ internal/panel/panel.go | 195 +++++- internal/panel/taskcenter.go | 18 +- internal/panel/tasks.go | 46 ++ internal/panel/tasksweep.go | 182 ++++++ internal/panel/tasksweep_test.go | 274 ++++++++ internal/pool/burnguard.go | 157 +++++ internal/pool/burnguard_test.go | 72 ++ internal/pool/cooldown.go | 142 +++- internal/pool/entry.go | 27 +- internal/pool/modelblocked_test.go | 31 + internal/pool/persist.go | 12 +- internal/pool/pick.go | 140 +++- internal/pool/pool.go | 55 +- internal/pool/pool_test.go | 121 +++- internal/pool/realm.go | 48 ++ internal/pool/state.go | 146 ++++- internal/pool/strategy.go | 174 +++++ internal/pool/strategy_test.go | 230 +++++++ internal/prompt/prompt.go | 69 ++ internal/prompt/system_first_test.go | 125 ++++ internal/scheduler/apikey_gate_test.go | 113 ++++ internal/scheduler/blackcat.go | 4 +- internal/scheduler/compensation.go | 277 ++++++++ internal/scheduler/compensation_test.go | 297 +++++++++ .../scheduler/enterprise_session_gate_test.go | 95 +++ internal/scheduler/scheduler.go | 167 ++++- internal/scheduler/streak.go | 12 +- internal/scheduler/travel.go | 37 +- internal/server/cache_stats_test.go | 75 +++ .../server/channel_refused_rotation_test.go | 148 +++++ internal/server/conversation_headers_test.go | 40 ++ internal/server/cross_domain_fallback_test.go | 130 ++++ internal/server/handler.go | 191 +++++- internal/server/handler_strategy_test.go | 58 ++ internal/server/handler_test.go | 177 ++++- internal/server/logging.go | 30 +- internal/server/models_fetch_log_test.go | 48 ++ .../server/request_illegal_policy_test.go | 193 ++++++ internal/server/resolve_model.go | 27 +- internal/session/session.go | 16 + internal/session/session_test.go | 27 + internal/upstream/account_restricted_test.go | 95 +++ internal/upstream/apikey_headers_test.go | 52 ++ internal/upstream/billing_summary.go | 277 ++++++++ internal/upstream/cache_key.go | 15 +- internal/upstream/client.go | 523 ++++++++++++--- internal/upstream/client_test.go | 21 +- internal/upstream/compensation.go | 70 ++ internal/upstream/compensation_test.go | 122 ++++ .../upstream/conversation_headers_test.go | 77 +++ internal/upstream/desktop.go | 109 +++- internal/upstream/desktop_test.go | 9 +- internal/upstream/enterprise_resource_test.go | 32 + internal/upstream/fingerprint_test.go | 427 ++++++++++++ internal/upstream/global_models.go | 220 ++++++- .../upstream/global_models_routes_test.go | 28 +- internal/upstream/global_models_test.go | 263 ++++++++ internal/upstream/headers.go | 213 ++++-- internal/upstream/hint.go | 7 + internal/upstream/payload.go | 52 ++ internal/upstream/payload_test.go | 111 ++++ .../upstream/refresh_singleflight_test.go | 174 +++++ internal/upstream/sse_test.go | 65 ++ internal/upstream/tasks.go | 15 +- internal/upstream/tool_pairing.go | 140 ++++ internal/upstream/tool_pairing_dup_test.go | 110 ++++ internal/upstream/transport.go | 2 + internal/upstream/usage.go | 8 + internal/upstream/useragent_test.go | 30 +- 96 files changed, 10539 insertions(+), 448 deletions(-) create mode 100644 cmd/server/cn_chat_base_test.go create mode 100644 cmd/server/cross_domain_fallback_config_test.go create mode 100644 cmd/server/strategy_config_test.go create mode 100644 internal/atomicfile/atomicfile.go create mode 100644 internal/atomicfile/atomicfile_test.go create mode 100644 internal/auth/apikey_test.go create mode 100644 internal/auth/enterprise_session_test.go create mode 100644 internal/livecfg/livecfg_test.go create mode 100644 internal/panel/apikey.go create mode 100644 internal/panel/apikey_test.go create mode 100644 internal/panel/autotask_claim_test.go create mode 100644 internal/panel/compensation_frontend_test.go create mode 100644 internal/panel/import_test.go create mode 100644 internal/panel/overview_expiry_test.go create mode 100644 internal/panel/tasksweep.go create mode 100644 internal/panel/tasksweep_test.go create mode 100644 internal/pool/burnguard.go create mode 100644 internal/pool/burnguard_test.go create mode 100644 internal/pool/strategy.go create mode 100644 internal/pool/strategy_test.go create mode 100644 internal/prompt/system_first_test.go create mode 100644 internal/scheduler/apikey_gate_test.go create mode 100644 internal/scheduler/compensation.go create mode 100644 internal/scheduler/compensation_test.go create mode 100644 internal/scheduler/enterprise_session_gate_test.go create mode 100644 internal/server/channel_refused_rotation_test.go create mode 100644 internal/server/conversation_headers_test.go create mode 100644 internal/server/cross_domain_fallback_test.go create mode 100644 internal/server/handler_strategy_test.go create mode 100644 internal/server/models_fetch_log_test.go create mode 100644 internal/server/request_illegal_policy_test.go create mode 100644 internal/upstream/account_restricted_test.go create mode 100644 internal/upstream/apikey_headers_test.go create mode 100644 internal/upstream/billing_summary.go create mode 100644 internal/upstream/compensation.go create mode 100644 internal/upstream/compensation_test.go create mode 100644 internal/upstream/conversation_headers_test.go create mode 100644 internal/upstream/fingerprint_test.go create mode 100644 internal/upstream/global_models_test.go create mode 100644 internal/upstream/refresh_singleflight_test.go create mode 100644 internal/upstream/tool_pairing_dup_test.go diff --git a/README.md b/README.md index 254fa20e..2b512eb5 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,7 @@ WorkBuddy2API 是一个自托管的 **OpenAI 兼容反向代理网关**,将腾 | **浏览器内 OAuth 添加账号** | 面板「添加账号」按钮完成设备授权 → 凭证落盘 → **热加载进池(免重启)**,替代命令行 `login.sh` 流程 | | **在线配置编辑(热生效)** | 面板直接改 `config.json`:API 密钥 / `soft_rate` / 脱敏开关 / 池参数 / 任务排程**立即生效**;装配期字段(listen 等)保存后提示需重启。写入采用深合并 + 原子替换,保留未知键 | | **积分任务体系** | 任务列表 / 接受 / 领取接口 + 面板弹窗;「一键完成」覆盖 **17 个任务**(对话 / 领养 / 桌面行为链 / 模板 / 灵感案例 / 画布 / 专家召唤 / 技能尝鲜 / 主题 / 资料库 / 夜猫子等),推进进度、等待异步计分落定后**自动领奖**,纯 API 零客户端依赖 | +| **限时补偿可观测(issue #189)** | 官方补偿(例:「模型服务异常额外补偿 500 积分」)在账号侧可见,网关改为**先查 `compensation-status` 再决定是否领**:面板「官方补偿」卡片列出待领账号 + 领取截止倒计时(≤48h 标红)+ 逐号/全量领取按钮;**查询失败的账号在卡片上显形为红色行,不会与「没有补偿」混为一谈**;每日签到排程同步走「查→领」,**状态查询失败与领取失败一律显式告警**(旧实现把「没有 / 已领 / 领取失败」三态压成同一形态,带截止日的补偿会静默过期) | | **首启自动生成配置** | 目录下无 `config.json` 时自动生成推荐配置(含 `crypto/rand` 随机 `api_key`),双击即开 | | **粘性会话内容回退** | 客户端不发 `conversation_id` 时,用 `system + 首条 user` 哈希派生会话键(`d-` 前缀),通用 OpenAI 客户端也能享受粘性 | | **余额后台刷新** | `schedule.balance_refresh_minutes`(默认 5)周期查余额并更新池,冷却账号余额恢复自动解冻 | @@ -380,6 +381,7 @@ curl -s http://localhost:7863/v1/chat/completions \ | `upstream.idle_timeout_seconds` | `300` | 聊天流中空闲上限(活跃续命,静默断流) | | `upstream.user_agent` | 空 | 出站 User-Agent 覆盖(空 = 现状 `CLI/2.63.2 CodeBuddy/2.63.2`)。官网「使用端」列按出站 UA 服务端归因;官方 WorkBuddy 桌面 UA 为 `WorkBuddy/`,需要时可配 | | `features.sanitize_blacklist_fingerprints` | `true` | 出站请求体黑名单指纹脱敏 | +| `models.hidden` | `[]` | 模型黑名单:不对外暴露的模型。裸名=两域都藏;`"cn:xxx"`/`"global:xxx"`=只藏该域。`GET /v1/models` 列表过滤 + `POST /v1/chat/completions` 点名拒绝(400 `model_unavailable`)。面板保存后热生效 | | `prompt.mode` | `custom` | 系统提示词模式:`custom` = 网关用自有提示词替换客户端 system;`append` = 开头连续 system/developer 块后插网关提示词(既有消息逐字不动);`passthrough` = 透传客户端原始 system(降级重试仍切中性提示词) | | `prompt.file` | 空 | 提示词文件路径;空 = 内置默认(约 2KB);路径非空但不可读 → 启动报错 | | `upstash.url` / `upstash.token` | 空 | 空 = 纯内存模式(Noop 降级,功能照常) | @@ -389,11 +391,15 @@ curl -s http://localhost:7863/v1/chat/completions \ | `pool.degrade_cooldown` / `pool.degrade_cooldown_max` | `10m` / `2h` | 连败降权时长与上限钳制 | | `pool.cost_explore_interval` | `30m` | costTier 条件探索窗口:免费层垄断且存在未知号时,每窗口把一个真实请求搭车改道给未知号(零新增上游请求;成功即毕业,失败走既有错误策略)。`0` = 关停 | | `pool.credit_floor` | `100` | **积分保底**:账号余额低于该值时,对**实测收费**模型(tier 2,账本 6h 内有效观测)不再参与选号——防止收费模型把余额打穿、连免费模型都 402 冷却到次日签到(最坏约 11.5 小时不可用)。tier 0(实测免费)/ tier 1(无观测)**不受限**:保底保的是「留余额给免费模型用」,且 tier 1 若拦会让账本过期 / 重启清零的触底号死锁在「学不回来」。含会话粘性路径(粘性号触底则解绑换号)。全池触底且全 tier 2 时选号返回空(网关回 503),**不放行**。签到回血越过 floor 即刻自动恢复。`0` = 关闭 | +| `pool.cross_domain_fallback` | `false` | **裸模型名跨域回退**(issue #135):开启后,请求 `model` 不带 `cn:` / `global:` 前缀时不再等价于 `cn:<模型>`——首选域(cn)选不到可用号(账号级冷却/熔断/在途占满,或该域账号对这个模型 11102 后被模型级负缓存避开)会自动改用另一域再选一次,客户端只写一个模型名即可跟随账号池切换。显式 `cn:` / `global:` 前缀**永不回退**(写哪个域就用哪个域)。跨域会消耗另一个域的额度,故默认关闭 | | `pool.breaker_threshold` | `3` | 连续失败触发熔断阈值 | | `pool.breaker_cooldown` | `30m` | 熔断基础退避时长 | | `pool.breaker_cooldown_max` | `6h` | 熔断指数退避封顶 | | `pool.idle_weight_per_hour` | `0.5` | 闲置补偿:每小时未使用 +0.5 权重 | | `pool.idle_weight_max` | `5.0` | 闲置补偿权重封顶 | +| `pool.strategy.domestic` | `weighted_random` | 国内版账号池调度:`weighted_random` / `round_robin` / `sequential_exhaust`,支持热更新 | +| `pool.strategy.international` | `weighted_random` | 国际版账号池独立调度策略,同上;不改变既有地区路由 | +| `pool.account_order.domestic` / `international` | `[]` | 对应地区的 UID 优先顺序;未列出账号按 UID 排序追加;只用于轮询和顺序耗尽 | | `pool.prefer_expiring` | `true` | 快过期积分加权:窗口内仍有有效快过期批次的账号,选号权重 ×3(虚拟实例);不按到期时间排序、与批次金额无关;是软偏好,弱于会话粘性与模型成本分层(#101) | | `pool.expiring_soon` | `168h` | 快过期加权窗口:仅窗口内仍有有效批次的账号命中上述 ×3;窗口开大 → 命中账号变多、偏好被稀释;留空或 `0` 关闭 | | `session_sticky.enabled` | `true` | 会话粘性路由开关 | @@ -528,6 +534,7 @@ curl -s http://localhost:7863/v1/chat/completions \ |---|---| | `POST /v2/billing/meter/daily-checkin`(签到) | `400 code 10001`「企业账号不支持该操作」 | | `POST /billing/meter/claim-gift` / `claim-compensation`(夜猫子领奖) | `400 code 10001` 同上 | +| `POST /billing/meter/compensation-status`(补偿状态查询) | 网关**在门控处一并跳过**(与 claim-compensation 同属无补偿体系的调用) | | `GET /activity/growth/*`(连登 / 旅行 / 热力图 / 抽奖)与 `GET /v2/activity/growth/tasks`(成长任务) | `403`「growth system is only available for personal users」 | 因此网关对企业号**不发起**这五类调用(签到 / 活跃上报 / 猫猫旅行 / 夜猫子 / 连登管家),面板也不渲染「签到」「任务」按钮(只留「额度」)。判定(`auth.Auth.IsEnterprise()`)与既有 `IsGlobal()`(D4 门控)在**同一批引用处并列书写**,`upstream` 侧零改动。 @@ -538,6 +545,21 @@ curl -s http://localhost:7863/v1/chat/completions \ > 成员账号**无权查询企业池总额度**(实测:池端点在成员 token 下返回 `403 not_authorized`,`/console/accounts` 显示 `isAdmin=false`)。面板展示的只是**该账号被分配的额度**;企业池余额只有企业管理员在管理后台可见。 +#### 静态 API Key 账号(面板「添加账号 → API Key」) + +CodeBuddy 平台签发的**静态 API Key**(`ck_` / `pt_` 前缀,控制台 API Key 页签发,官方用途是给 CodeBuddy CLI 用)可以直接当账号用:面板「添加账号 → API Key」粘贴即可,无需浏览器登录。落盘仍是 `auths/workbuddy-.json`(`accessToken` 存 Key、`refreshToken` 为空、`expiresAt` 0、`enterpriseId` 留空),uid 缺省取 Key 的 `` 段拼成 `apikey-`。 + +判定(`auth.Auth.IsAPIKey()`)只看 access token 前缀——官方 CLI 的判据是 `tokenType === "ApiKey"` 且 token 以 `ck_` / `pt_` 开头,落盘形态里前缀是唯一稳定、跨进程一致的判据。它与企业版共用「无个人成长体系」门控(`auth.Auth.SupportsPersonalGrowth()`),但**不等同**企业版:没有企业身份,不注入 `X-Enterprise-Id` / `X-Tenant-Id`。 + +| 能力 | 静态 API Key 账号 | +|---|---| +| chat / 选号派发 | 照常;出站按官方 CLI 双头 `Authorization: Bearer ` + `X-API-Key: ` | +| token 刷新 / 保活 | **不适用**(refresh 端点只认 OAuth 设备流):`NeedsRefresh()` 恒 false,不会因为"刷新失败"把好号冷却换号 | +| 签到 / 活跃上报 / 成长任务 / 连登管家 / 猫猫旅行 / 夜猫子 | **全部不适用**(无个人账号身份,上游 403 / 400「企业账号不支持该操作」):排程一个请求都不发;面板不渲染这些按钮;单账号任务类接口(`checkin` / `tasks` / `tasks/accept_all` / `tasks/auto` / `tasks/auto_all`)返回 `422` 并附说明,而不是执行后静默无效 | +| 额度 | 走**企业口径** `POST /v2/billing/meter/get-enterprise-user-usage`(个人 `get-user-resource` 对静态 Key 返回空 `Accounts`):由余额定时器(`schedule.balance_refresh_*`)与面板「额度」按钮刷新,不依赖 refresh token | + +导入 JSON(cockpit 形态)同样接受 `refresh_token` 为空的记录:必填判据只要求 `uid` + `access_token` 非空(issue #165 的 access-token-only 场景,静态 Key 凭证本就没有 refresh token)。 + #### 连登管家(签到排程末尾自动执行) 成长中心的连登档位(连续登录 7/14/28 天)兑换后发放积分 / 能量 / 补签卡 / **抽奖次数**,抽奖次数只能从兑换获得。管家在每日签到后自动跑一遍闭环(幂等,未解锁静默跳过): @@ -623,7 +645,7 @@ http://127.0.0.1:7863/panel/ 来源信息比 token 计数敏感(IP 属个人信息),因此由 `logging.request_client_info` 控制,**缺省开启、热生效、面板「配置 → 日志」可关**;关闭后归档与面板都不再出现来源字段(老归档里没有该字段的条目显示 `—`)。 **配置热生效**:保存配置后,`api_key`、`cooldown.soft_rate`、`features.sanitize_blacklist_fingerprints`、 -`logging.request_client_info`、`pool.*`(熔断/在途/权重)、`schedule.*`(时点/开关/余额刷新间隔)**立即生效,无需重启**; +`models.hidden`、`logging.request_client_info`、`pool.*`(熔断/在途/权重)、`schedule.*`(时点/开关/余额刷新间隔)**立即生效,无需重启**; 涉及进程装配期依赖的字段(`listen`、`auth_dir`、`state_file`、`upstream.*`、`upstash.*`、`session_sticky.ttl`) 保存后会提示"需重启进程生效"。配置写入采用「深合并且原子替换」:只更新面板表单覆盖的键, 用户手写的未知键与其余字段原样保留。 @@ -636,8 +658,9 @@ http://127.0.0.1:7863/panel/ |---|---| | 只读 | `GET overview` · `GET usage`(`hours` 或 `from`/`to`)· `GET packages` · `GET models` · `GET model_probes` · `GET logs` · `GET request_metrics` · `GET request_logs`(`limit` / `outcome` / `account` / `model` / `client_ip` / `user_agent` / `from` / `to`)· `GET config` · `GET tasks/queue` · `GET school/vouchers` | | 账号运维 | `POST accounts/{uid}/checkin` · `balance` · `revive` · `disable` · `remove`;`POST checkin_all` · `travel_all` · `activity_all` · `keepalive_all` · `balance_all` | +| 补偿 | `GET compensation`(逐号查补偿状态,**只读**);`POST accounts/{uid}/compensation`(单号领);`POST compensation_all`(全量领,返回 `summary{claimed,skipped,errored,credit}`——**先看 `errored` 再看 `claimed`**,否则会把「全部查询失败」说成「没有待领补偿」) | | 任务 | `GET accounts/{uid}/tasks`;`POST accounts/{uid}/tasks/accept` · `accept_all` · `claim` · `auto` · `auto_all`;`POST tasks/scan_all` · `tasks/run_queue` | -| 添加账号 | `POST login/start` · `GET login/poll` · `GET login/regions` · `POST import/cockpit` | +| 添加账号 | `POST login/start` · `GET login/poll` · `GET login/regions` · `POST import/cockpit` · `POST accounts/add_apikey`(静态 API Key,见上) | | 写入 | `POST config`(校验 → 落盘 → 热应用,返回需重启字段清单)· `POST usage/save`(立即把内存用量落盘) | 账号运维操作均落到池既有入口(`Revive`/`Disable`/`Remove` 等),与 `/status` 观测口径一致。 @@ -689,6 +712,7 @@ http://127.0.0.1:7863/panel/ | `plugin/auth/token/refresh` | POST | token 刷新 | | `billing/meter/daily-checkin` | POST | 每日签到 | | `billing/meter/get-user-resource` | POST | 余额查询 | +| `billing/meter/compensation-status` | POST | **补偿活动状态**(`active` / `claimed` / `credit_num` / `validity_period` / `end_time`);`active=false` 是「当前无活动」的正常 200,不算错误 | | `report` | POST | 对话活跃上报(`chat_request_send` 事件数组,必须含 `userId`;点亮连登 / 解锁领养) | | `plugin/auth/state?platform=CLI` | POST | OAuth 取授权 URL | | `plugin/auth/token?state=` | GET | OAuth 轮询取 token | @@ -904,3 +928,31 @@ sudo chown -R 10001:10001 ./auths ./data ./config.json - 允许任意使用、复制、修改、合并、发布、分发、再授权及销售 - 再分发(源码或二进制形式)时,请保留原仓库的 MIT 版权声明与许可声明(如在 NOTICE 或 README 中注明原始出处 `https://github.com/Sliverkiss/workbuddy2api`) - 本项目不授予任何上游(CodeBuddy / 腾讯)接口或服务的权利;使用者仍需自行遵守上游服务条款 + +### 按地区配置账号池调度 + +国内版(`cn`)与国际版(`global`)可独立选择策略,例如: + +```json +{ + "pool": { + "strategy": { + "domestic": "weighted_random", + "international": "sequential_exhaust" + }, + "account_order": { + "international": ["uid1", "uid2", "uid3"] + } + } +} +``` + +- **加权随机**:保留原有成本分层、Top5 加权、闲置补偿和快过期积分权重。旧配置缺少策略时,两域均使用此默认值。 +- **轮询**:在当前最优成本层的全部可用账号之间按顺序轮换,不截断 Top5。跳过禁用、暂停、对应模型冷却、积分保底拦截及在途占满的账号。 +- **顺序耗尽**:持续使用当前账号,直到对应模型冷却、账号不可用、积分保底拦截或成本层优先级变化,再向后切换。恢复的早序账号不会抢占仍可用的当前账号,后续轮转可再次使用。请求级重试和在途占满可暂用其他账号,但不推进主账号;普通失败仍按现有熔断规则处理。 + +两种有序策略仍遵守会话粘性和成本探索,轮询指针 / 顺序耗尽主账号按 `(realm, model)` 独立维护,通过既有本地状态与 Redis 快照机制恢复。全部候选冷却时返回无可用账号,等待既有恢复机制,不提前试探冷却账号;加权随机继续保留原有全冷却兜底。选号和指针更新持池锁,在途租约仍由既有 Acquire/Release 保证。 + +Web 面板「账号池与流量治理」可分别配置策略和账号顺序,保存后热生效。闲置补偿与快过期积分加权仅影响加权随机。无地区上下文的旧 Pool 入口保留原行为;策略不会改变路由或自动跨地区借号。 + +本功能不假定国际版免费模型已采用五小时限额;限流及恢复时间继续依据现有上游识别逻辑处理,不硬编码五小时。 diff --git a/cmd/server/cn_chat_base_test.go b/cmd/server/cn_chat_base_test.go new file mode 100644 index 00000000..27080755 --- /dev/null +++ b/cmd/server/cn_chat_base_test.go @@ -0,0 +1,46 @@ +package main + +import ( + "slices" + "testing" + + "github.com/linguo2625469/workbuddy2api-panel/internal/upstream" +) + +// 老配置(无 cn 段)必须保留内置 CN base,零回归。 +func TestCNChatBaseDefaultKeepsBuiltin(t *testing.T) { + cfg, err := ParseConfig([]byte(`{}`)) + if err != nil { + t.Fatalf("parse: %v", err) + } + up := upstream.New() + want := up.ChatBaseCN + applyCNChatBase(up, cfg) + if up.ChatBaseCN != want { + t.Fatalf("ChatBaseCN=%q want builtin %q", up.ChatBaseCN, want) + } +} + +func TestCNChatBaseOverride(t *testing.T) { + cfg, err := ParseConfig([]byte(`{"cn":{"chat_base":"https://www.workbuddy.cn"}}`)) + if err != nil { + t.Fatalf("parse: %v", err) + } + up := upstream.New() + applyCNChatBase(up, cfg) + if up.ChatBaseCN != "https://www.workbuddy.cn" { + t.Fatalf("ChatBaseCN=%q want override", up.ChatBaseCN) + } + // global base 不受 cn 覆盖影响。 + if up.ChatBaseGlobal != "" { + t.Fatalf("ChatBaseGlobal=%q want untouched", up.ChatBaseGlobal) + } +} + +// cn.chat_base 只在装配期生效,面板保存后须提示重启。 +func TestCNChatBaseRequiresRestart(t *testing.T) { + cfg := Default() + if !slices.Contains(restartRequiredFields(cfg), "cn.chat_base") { + t.Fatal("cn.chat_base missing from restart-required fields") + } +} diff --git a/cmd/server/config.go b/cmd/server/config.go index 1c9d5b2a..651b0df0 100644 --- a/cmd/server/config.go +++ b/cmd/server/config.go @@ -6,12 +6,14 @@ import ( "encoding/base64" "encoding/json" "fmt" + "log" "os" "path/filepath" "strconv" "strings" "time" + "github.com/linguo2625469/workbuddy2api-panel/internal/pool" "github.com/linguo2625469/workbuddy2api-panel/internal/prompt" ) @@ -106,6 +108,13 @@ type Config struct { // 解冻语义同签到(余额 > 0 的冷却账号自动解冻),但不做签到不刷 token。 BalanceRefreshEnabled bool `json:"balance_refresh_enabled"` // 缺省 true;false = 关闭 BalanceRefreshMinutes int `json:"balance_refresh_minutes"` // 缺省 5;<=0 回落 5 + + // 任务补领清扫(issue #174):周期把「已达标但未领奖」的成长任务领掉—— + // 这条链路此前完全缺失(成长队列要求有自动动作、一键完成对达标任务直接跳过), + // 导致积分长期卡在 2000/2100/2300 上不去 4000。 + // 只做 accept/claim 两条幂等请求,不跑任何行为链路;分钟级开销可忽略。 + TaskSweepEnabled bool `json:"task_sweep_enabled"` // 缺省 true;false = 关闭 + TaskSweepMinutes int `json:"task_sweep_minutes"` // 缺省 15;<=0 回落 15 } `json:"schedule"` Global struct { @@ -121,6 +130,15 @@ type Config struct { BillingBase string `json:"billing_base"` } `json:"global"` + CN struct { + // ChatBase 国内版 chat/模型目录上游 base 覆盖;空 = 回落内置默认 + // https://copilot.tencent.com(internal/upstream.New 的 ChatBaseCN)。 + // copilot.tencent.com 对海外出口(如 Clash 全局 + TUN)的 TLS 握手直接复位, + // CN 模型目录与对话全部 EOF;官方 WorkBuddy 桌面端已改用 https://www.workbuddy.cn, + // 该域海外出口可达,/v2/chat/completions、/v3/config 与模型目录路径同构。 + ChatBase string `json:"chat_base"` + } `json:"cn"` + Upstream struct { // TimeoutSeconds 短 RPC(refresh/checkin/balance/FetchModels)总时长上限,默认 120。 TimeoutSeconds int `json:"timeout_seconds"` @@ -154,6 +172,13 @@ type Config struct { SanitizeBlacklistFingerprints bool `json:"sanitize_blacklist_fingerprints"` } `json:"features"` + Models struct { + // Hidden 不对外暴露的模型黑名单。裸名=两域都藏;"cn:xxx"/"global:xxx"=只藏该域。 + // 生效点:GET /v1/models 列表过滤 + POST /v1/chat/completions 点名拒绝。 + // 面板保存后经 livecfg 热生效,无需重启。 + Hidden []string `json:"hidden"` + } `json:"models"` + Prompt struct { // Mode passthrough(默认)= 透传客户端原始 system(降级重试仍会切到 Degraded); // custom = 网关用自有系统提示词替换客户端 system/developer; @@ -173,11 +198,13 @@ type Config struct { } `json:"upstash"` Pool struct { - MaxInFlight int `json:"max_in_flight"` // 单账号最大在途请求数,0 = 不限 - MaxInFlightGlobal int `json:"max_in_flight_global"` // global 域单账号在途上限(WAF 风控紧域压低并发),0 = 回落默认 2 - BreakerThreshold int `json:"breaker_threshold"` // 连续失败次数触发熔断,默认 3 - BreakerCooldown string `json:"breaker_cooldown"` // 基础熔断时长,默认 "30m" - BreakerCooldownMax string `json:"breaker_cooldown_max"` // 指数退避封顶,默认 "6h" + Strategy pool.RegionStrategies `json:"strategy"` + AccountOrder pool.RegionAccountOrder `json:"account_order,omitempty"` + MaxInFlight int `json:"max_in_flight"` // 单账号最大在途请求数,0 = 不限 + MaxInFlightGlobal int `json:"max_in_flight_global"` // global 域单账号在途上限(WAF 风控紧域压低并发),0 = 回落默认 2 + BreakerThreshold int `json:"breaker_threshold"` // 连续失败次数触发熔断,默认 3 + BreakerCooldown string `json:"breaker_cooldown"` // 基础熔断时长,默认 "30m" + BreakerCooldownMax string `json:"breaker_cooldown_max"` // 指数退避封顶,默认 "6h" // 连败降权(issue #114):ErrClient/传输层这类「不罚号」失败连续计数,达阈 // 临时出池。与冷却/熔断并存取更长者不叠加。默认 5 次 / 10m。 DegradeThreshold int `json:"degrade_threshold"` // 连败次数触发降权,默认 5 @@ -205,6 +232,23 @@ type Config struct { // tier 0(免费)/ tier 1(无观测)不受限;签到回血越过 floor 自动恢复。 // 默认 0 = 关闭;负值钳 0。 CreditFloor int64 `json:"credit_floor"` + // BurnGuardLimit 积分燃尽熔断阈值(issue #229):窗口内单账号累计烧掉的 + // 积分超过该值 → 该账号进入熔断(暂时不参与选号),避免高缓存 miss 的 + // 模型(如 hy4-preview)一轮烧掉 20+ 积分而无人拦截。 + // 0 = 关闭熔断(默认,零回归)。 + BurnGuardLimit float64 `json:"burn_guard_limit"` + // BurnGuardWindow 燃尽计账窗口(如 "10m"):窗口滑动重置计账。 + BurnGuardWindow string `json:"burn_guard_window"` + // BurnGuardCooldown 熔断冷却时长(首次触发);随连续触发次数递增。 + BurnGuardCooldown string `json:"burn_guard_cooldown"` + // BurnGuardCooldownMax 熔断冷却时长上限。 + BurnGuardCooldownMax string `json:"burn_guard_cooldown_max"` + // CrossDomainFallback 裸模型名跨域回退开关(issue #135),默认 false(零回归)。 + // 开启后:请求模型名不带 realm 前缀时,首选域(cn)选不到可用账号(账号级冷却/ + // 熔断/在途占满,或该域账号对这个模型 11102 后进入模型级负缓存)会自动改用 + // 另一域再选一次;显式 cn:/global: 前缀不受影响,行为与引入前完全一致。 + // 语义取舍:跨域会消耗另一个域的额度,属策略选择,故默认关闭、显式开启。 + CrossDomainFallback bool `json:"cross_domain_fallback"` } `json:"pool"` SessionSticky struct { @@ -223,9 +267,15 @@ type Config struct { SessionTTL time.Duration `json:"-"` SessionGCInterval time.Duration `json:"-"` BalanceRefreshInterval time.Duration `json:"-"` // 0 = 不启动(enabled=false) + TaskSweepInterval time.Duration `json:"-"` // 0 = 不启动(task_sweep_enabled=false) ExpiringSoonDur time.Duration `json:"-"` // CostExploreIntervalDur 解析后的 costTier 探索窗口(issue #136);0 = 关停。 CostExploreIntervalDur time.Duration `json:"-"` + // BurnGuardWindowDur / BurnGuardCooldownDur / BurnGuardCooldownMaxDur 解析后的 + // 积分燃尽熔断参数(issue #229);0 = 用默认。 + BurnGuardWindowDur time.Duration `json:"-"` + BurnGuardCooldownDur time.Duration `json:"-"` + BurnGuardCooldownMaxDur time.Duration `json:"-"` // ServerReadTimeoutDur 解析后的入站请求读取上限(issue #100);0 = 不限制。 ServerReadTimeoutDur time.Duration `json:"-"` } @@ -264,6 +314,8 @@ func Default() *Config { c.Schedule.BlackcatEnabled = true c.Schedule.BalanceRefreshEnabled = true c.Schedule.BalanceRefreshMinutes = 5 + c.Schedule.TaskSweepEnabled = true + c.Schedule.TaskSweepMinutes = 15 c.Upstream.TimeoutSeconds = 120 // HeaderTimeoutSeconds/IdleTimeoutSeconds 默认 0(未设置态),回落见 normalize()。 c.Upstream.HeaderTimeoutSeconds = 0 @@ -273,6 +325,7 @@ func Default() *Config { c.Global.Enabled = true c.Features.SanitizeBlacklistFingerprints = true c.Prompt.Mode = "passthrough" // 缺省 passthrough:透传客户端原始 system(对齐上游;custom 由用户显式选择) + c.Pool.Strategy = pool.RegionStrategies{Domestic: pool.WeightedRandom, International: pool.WeightedRandom} c.Pool.MaxInFlight = 3 // MaxInFlightGlobal 缺省 2:global 域 WAF 风控更紧,压低单号并发(WAF 403 修复 // P1-1);0/负数 normalize 回落默认(与 max_in_flight 的 0=不限语义不同,分档键 @@ -290,6 +343,10 @@ func Default() *Config { c.Pool.ExpiringSoon = "168h" // 快过期窗口默认 7 天:官方活动奖励积分多在两周内过期 // costTier 探索默认 30m(issue #136:垄断破除 + 搭车改道零新增请求);"0" 关停。 c.Pool.CostExploreInterval = "30m" + // 积分燃尽熔断(issue #229):默认关闭(阈值 0 = 不熔断,零回归)。 + c.Pool.BurnGuardWindow = "10m" + c.Pool.BurnGuardCooldown = "10m" + c.Pool.BurnGuardCooldownMax = "2h" c.SessionSticky.Enabled = true c.SessionSticky.TTL = "30m" c.SessionSticky.GCInterval = "5m" @@ -378,6 +435,11 @@ func applyEnv(c *Config) { c.Listen = v } if v := os.Getenv("WB2A_API_KEY"); v != "" { + // 环境变量优先于 config.json(既有语义),但**必须留痕**:此前是静默 + // 覆盖,用户改完 config.json 重启后仍被旧 env key 挡住,而面板把 + // 401 吞掉 → 表现为「改了密钥不生效、旧登录态还在」(issue #155)。 + // 这里打一行 WARN 说明来源(只打来源与长度,绝不打明文)。 + log.Printf("WARN: [config] api_key 来自环境变量 WB2A_API_KEY(%d 字符),已覆盖 config.json 中的 api_key", len(v)) c.APIKey = v } if v := os.Getenv("WB2A_AUTH_DIR"); v != "" { @@ -453,6 +515,9 @@ func applyEnv(c *Config) { func (c *Config) normalize() error { var err error + if c.Pool.Strategy, err = c.Pool.Strategy.Normalize(); err != nil { + return fmt.Errorf("pool.strategy: %w", err) + } if c.Panel.PackageDetailLimit <= 0 { c.Panel.PackageDetailLimit = 5 } @@ -527,6 +592,37 @@ func (c *Config) normalize() error { if c.Pool.CreditFloor < 0 { c.Pool.CreditFloor = 0 } + // 积分燃尽熔断(issue #229):阈值 <= 0 = 关闭(默认,零回归)。 + if c.Pool.BurnGuardLimit < 0 { + c.Pool.BurnGuardLimit = 0 + } + if c.Pool.BurnGuardWindow == "" { + c.Pool.BurnGuardWindow = "10m" + } + if c.BurnGuardWindowDur, err = time.ParseDuration(c.Pool.BurnGuardWindow); err != nil { + return fmt.Errorf("pool.burn_guard_window: %w", err) + } + if c.BurnGuardWindowDur < 0 { + c.BurnGuardWindowDur = 0 + } + if c.Pool.BurnGuardCooldown == "" { + c.Pool.BurnGuardCooldown = "10m" + } + if c.BurnGuardCooldownDur, err = time.ParseDuration(c.Pool.BurnGuardCooldown); err != nil { + return fmt.Errorf("pool.burn_guard_cooldown: %w", err) + } + if c.BurnGuardCooldownDur < 0 { + c.BurnGuardCooldownDur = 0 + } + if c.Pool.BurnGuardCooldownMax == "" { + c.Pool.BurnGuardCooldownMax = "2h" + } + if c.BurnGuardCooldownMaxDur, err = time.ParseDuration(c.Pool.BurnGuardCooldownMax); err != nil { + return fmt.Errorf("pool.burn_guard_cooldown_max: %w", err) + } + if c.BurnGuardCooldownMaxDur < 0 { + c.BurnGuardCooldownMaxDur = 0 + } if c.Pool.BreakerThreshold <= 0 { c.Pool.BreakerThreshold = 3 } @@ -591,6 +687,13 @@ func (c *Config) normalize() error { } c.BalanceRefreshInterval = time.Duration(c.Schedule.BalanceRefreshMinutes) * time.Minute } + // 任务补领清扫:启用时 minutes<=0 回落默认 15;关闭时 interval 保持 0(不启动)。 + if c.Schedule.TaskSweepEnabled { + if c.Schedule.TaskSweepMinutes <= 0 { + c.Schedule.TaskSweepMinutes = 15 + } + c.TaskSweepInterval = time.Duration(c.Schedule.TaskSweepMinutes) * time.Minute + } if err := c.validateScheduleHours(); err != nil { return err } diff --git a/cmd/server/cross_domain_fallback_config_test.go b/cmd/server/cross_domain_fallback_config_test.go new file mode 100644 index 00000000..addf20dc --- /dev/null +++ b/cmd/server/cross_domain_fallback_config_test.go @@ -0,0 +1,36 @@ +// cross_domain_fallback_config_test.go pool.cross_domain_fallback 配置测试: +// 默认 false(零回归)/ 文件覆盖为 true。 +package main + +import ( + "os" + "path/filepath" + "testing" +) + +// TestCrossDomainFallbackDefault 键缺席 → 默认 false(裸名仍是 cn 单域,行为与引入前一致)。 +func TestCrossDomainFallbackDefault(t *testing.T) { + c := Default() + if err := c.normalize(); err != nil { + t.Fatalf("normalize: %v", err) + } + if c.Pool.CrossDomainFallback { + t.Error("cross_domain_fallback 缺省必须为 false") + } +} + +// TestCrossDomainFallbackParsedFromFile 显式配置覆盖默认。 +func TestCrossDomainFallbackParsedFromFile(t *testing.T) { + dir := t.TempDir() + fp := filepath.Join(dir, "c.json") + if err := os.WriteFile(fp, []byte(`{"pool":{"cross_domain_fallback":true}}`), 0o600); err != nil { + t.Fatal(err) + } + c, err := Load(fp) + if err != nil { + t.Fatal(err) + } + if !c.Pool.CrossDomainFallback { + t.Error("cross_domain_fallback=true 未生效") + } +} diff --git a/cmd/server/main.go b/cmd/server/main.go index f2056dbc..02812ec2 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -17,6 +17,7 @@ import ( "syscall" "time" + "github.com/linguo2625469/workbuddy2api-panel/internal/atomicfile" "github.com/linguo2625469/workbuddy2api-panel/internal/auth" "github.com/linguo2625469/workbuddy2api-panel/internal/livecfg" "github.com/linguo2625469/workbuddy2api-panel/internal/panel" @@ -47,6 +48,24 @@ func stateSibling(stateFile, name string) string { return filepath.Join(dir, name) } +// fallbackDirs 返回「同目录不可写时可用的暂存目录」候选列表。 +// +// 场景(issue #226 / #134):Docker 单文件 bind mount 下 config.json 位于镜像内 +// /app(属主 uid 10001),而 compose 允许以宿主机 uid 运行容器——同目录的 +// config.json.tmp 因此无法创建(EACCES),面板保存配置永远失败。 +// +// 兜底顺序: +// 1. state 文件所在目录(Docker 部署下即 ./data,宿主机挂载、必然可写); +// 2. auth 目录(./auths,同样是宿主机挂载目录); +// 3. 系统临时目录(os.TempDir 由 atomicfile 内部追加)。 +// +// 落在哪个目录都不影响语义:atomicfile 会把暂存文件 rename 回 config.json; +// 跨设备时退化为原地覆盖目标文件(单文件挂载点 rename 必然 EBUSY)。 +func fallbackDirs(stateFile string) []string { + dirs := []string{filepath.Dir(stateFile)} + return dirs +} + func main() { cfgPath := flag.String("config", "config.json", "配置文件路径(默认当前目录 config.json;不存在时自动生成推荐配置)") flag.Parse() @@ -103,6 +122,10 @@ func main() { p.SetCreditFloor(cfg.Pool.CreditFloor) // 积分保底(默认 0 = 关闭) p.SetWeights(cfg.Pool.IdleWeightPerHour, cfg.Pool.IdleWeightMax) p.SetPreferExpiring(cfg.Pool.PreferExpiring) + p.SetExpiringSoonWindow(cfg.ExpiringSoonDur) // 快过期紧迫度梯度窗口(issue #160) + p.SetCrossDomainFallback(cfg.Pool.CrossDomainFallback) // 裸名跨域回退(issue #135,默认关闭) + p.SetBurnGuard(cfg.Pool.BurnGuardLimit, cfg.BurnGuardWindowDur, cfg.BurnGuardCooldownDur, cfg.BurnGuardCooldownMaxDur) // 积分燃尽熔断(issue #229,默认 0 = 关闭) + _ = p.SetScheduling(cfg.Pool.Strategy, cfg.Pool.AccountOrder) // 会话粘性路由(可配关闭)。 var sessRouter *session.Router @@ -164,6 +187,7 @@ func main() { up.GlobalEnabled = cfg.Global.Enabled up.ChatBaseGlobal = cfg.Global.ChatBase up.BillingBaseGlobal = cfg.Global.BillingBase + applyCNChatBase(up, cfg) auth.SetGlobalEnabled(cfg.Global.Enabled) // model.json 本地缓存接线(context_length/max_output_tokens 四级查找链第 3 级): // 数据目录与 state.json 同风格(Docker volume 持久化路径)。首次缺失/损坏自动 @@ -225,6 +249,12 @@ func main() { case cfg.BalanceRefreshInterval > 0: log.Printf("余额后台刷新:每 %s(签到时点照常额外刷新)", cfg.BalanceRefreshInterval) } + switch { + case !cfg.Schedule.TaskSweepEnabled: + log.Printf("任务补领清扫已禁用(schedule.task_sweep_enabled=false)") + case cfg.TaskSweepInterval > 0: + log.Printf("任务补领清扫:每 %s(补领已达标未领的成长任务奖励)", cfg.TaskSweepInterval) + } if cfg.Schedule.IncludeDisabledInTasks { log.Printf("保号任务覆盖禁用账号(schedule.include_disabled_in_tasks=true):禁用号仍签到 / 活跃 / 保活 / 刷新余额,但不参与选号") } @@ -237,6 +267,7 @@ func main() { SoftCooldown: cfg.SoftRateDur, SanitizeFingerprints: cfg.Features.SanitizeBlacklistFingerprints, RecordClientInfo: cfg.Logging.RequestClientInfo, + HiddenModels: livecfg.NormalizeHiddenModels(cfg.Models.Hidden), }) // 用量记录器:与 state 文件同目录,随 state_file 配置一起搬移。 // datapath 由 state 文件路径推出,避免再加一个配置项。 @@ -282,12 +313,15 @@ func main() { return Load(*cfgPath) }, SaveConfig: func(raw []byte) ([]string, error) { - return saveConfig(raw, *cfgPath, live, p, up, sch) + return saveConfig(raw, *cfgPath, live, p, up, sch, fallbackDirs(cfg.StateFile)...) }, }) // 成长任务队列每日自动执行(与「执行全部待办」同管线):Sequential 族零点解锁后 // 无需手动扫描;hook 返回即启动(异步执行),已在跑时内部跳过。 sch.SetGrowthHook(pn.RunGrowthQueueOnce) + // 清扫回调返回统计汇总(测试/日志可观测),调度器 hook 只要求 func(): + // 这里收掉返回值——汇总已由 panel 侧内部日志输出。 + sch.SetTaskSweepHook(func() { pn.RunTaskSweepOnce() }) log.SetOutput(io.MultiWriter(os.Stderr, pn.Logs())) server.SetChatLogOutput(io.MultiWriter(os.Stdout, pn.Logs())) @@ -310,12 +344,16 @@ func main() { RecordClientInfo: cfg.Logging.RequestClientInfo, // handler 侧第三道闸(global realm):false(显式逃生门)时不列 global: 模型名。 GlobalEnabled: cfg.Global.Enabled, + // 模型黑名单原始条目;运行期以 livecfg 快照为准(热生效),此处供 + // Live 为 nil 的裸用/测试路径。 + ModelsHidden: cfg.Models.Hidden, }) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() go sch.Run(ctx) sch.StartBalanceRefresh(ctx, cfg.BalanceRefreshInterval) + sch.StartTaskSweep(ctx, cfg.TaskSweepInterval) // 启动即预热模型积分倍率表:倍率只在 FetchModels/FetchGlobalModelInfos 成功时 // 填充(两者均懒触发),重启后到首次 /v1/models 或面板模型页被访问之前, @@ -419,7 +457,7 @@ func panelListenPath(listen string) string { // 落盘用"先写 tmp 再 rename"原子替换,且优先保留磁盘上的原始 JSON 结构(只改 // 面板表单覆盖到的键),避免把用户手写的注释性字段/未知键洗掉——这里直接整体 // 序列化校验后的配置,未知键在 json.Unmarshal 时已丢失,故先合并原始 map。 -func saveConfig(raw []byte, path string, live *livecfg.Holder, p *pool.Pool, up *upstream.Client, sch *scheduler.Scheduler) ([]string, error) { +func saveConfig(raw []byte, path string, live *livecfg.Holder, p *pool.Pool, up *upstream.Client, sch *scheduler.Scheduler, fallbackDirs ...string) ([]string, error) { // 1) 解析原始 JSON 为 map(保留用户手写的未知键),再叠加面板提交的键。 oldRaw, err := os.ReadFile(path) if err != nil { @@ -445,37 +483,11 @@ func saveConfig(raw []byte, path string, live *livecfg.Holder, p *pool.Pool, up if err != nil { return nil, fmt.Errorf("marshal config: %w", err) } - tmp := path + ".tmp" - if err := os.WriteFile(tmp, out, 0o600); err != nil { - return nil, fmt.Errorf("write config: %w", err) - } - if err := os.Rename(tmp, path); err != nil { - // A single-file Docker bind mount cannot be renamed over its mount - // target (Linux returns EBUSY / "device or resource busy"). Keep the - // atomic path for regular files, but update the mounted file in place - // for this specific deployment shape. - if !errors.Is(err, syscall.EBUSY) { - return nil, fmt.Errorf("replace config: %w", err) - } - f, openErr := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0o600) - if openErr != nil { - _ = os.Remove(tmp) - return nil, fmt.Errorf("replace config (bind mount fallback): %w", openErr) - } - _, writeErr := f.Write(out) - if writeErr == nil { - writeErr = f.Sync() - } - closeErr := f.Close() - // 写失败时保留 tmp(挂载文件已被 O_TRUNC 破坏,tmp 里是完整新内容, - // 可手工恢复);写成功才清理。 - if writeErr != nil { - return nil, fmt.Errorf("replace config (bind mount fallback, 完整新内容保留在 %s): %w", tmp, writeErr) - } - _ = os.Remove(tmp) - if closeErr != nil { - return nil, fmt.Errorf("replace config (bind mount fallback): %w", closeErr) - } + // 同目录 tmp 不可写时退到 data 目录暂存(Docker 单文件 bind mount 场景: + // config.json 挂在 /app/config.json,而 /app 属镜像内 uid 10001, + // tmp 写不动;./data 是宿主机挂载目录,一定可写)——见 issue #226 / #134。 + if err := atomicfile.Write(path, out, 0o600, fallbackDirs...); err != nil { + return nil, err } // 4) 热应用:能立即生效的字段全部应用,并列出仍需重启的字段。 @@ -484,6 +496,7 @@ func saveConfig(raw []byte, path string, live *livecfg.Holder, p *pool.Pool, up SoftCooldown: newCfg.SoftRateDur, SanitizeFingerprints: newCfg.Features.SanitizeBlacklistFingerprints, RecordClientInfo: newCfg.Logging.RequestClientInfo, + HiddenModels: livecfg.NormalizeHiddenModels(newCfg.Models.Hidden), }) up.SanitizeFingerprints.Store(newCfg.Features.SanitizeBlacklistFingerprints) p.SetBreaker(newCfg.Pool.BreakerThreshold, newCfg.BreakerCooldownDur, newCfg.BreakerCooldownMaxD) @@ -495,6 +508,10 @@ func saveConfig(raw []byte, path string, live *livecfg.Holder, p *pool.Pool, up p.SetCreditFloor(newCfg.Pool.CreditFloor) // 积分保底热生效(0 = 关闭) p.SetWeights(newCfg.Pool.IdleWeightPerHour, newCfg.Pool.IdleWeightMax) p.SetPreferExpiring(newCfg.Pool.PreferExpiring) + p.SetExpiringSoonWindow(newCfg.ExpiringSoonDur) // 快过期紧迫度梯度窗口热生效(issue #160) + p.SetCrossDomainFallback(newCfg.Pool.CrossDomainFallback) // 裸名跨域回退热生效(issue #135) + p.SetBurnGuard(newCfg.Pool.BurnGuardLimit, newCfg.BurnGuardWindowDur, newCfg.BurnGuardCooldownDur, newCfg.BurnGuardCooldownMaxDur) // 积分燃尽熔断热生效(issue #229) + _ = p.SetScheduling(newCfg.Pool.Strategy, newCfg.Pool.AccountOrder) sch.SetExpiringSoonWindow(newCfg.ExpiringSoonDur) sch.Reconfigure( newCfg.Schedule.CheckinHours, newCfg.Schedule.TravelHours, @@ -504,11 +521,20 @@ func saveConfig(raw []byte, path string, live *livecfg.Holder, p *pool.Pool, up !newCfg.Schedule.ActivityEnabled, !newCfg.Schedule.KeepaliveEnabled, !newCfg.Schedule.BlackcatEnabled, !newCfg.Schedule.GrowthEnabled) sch.SetBalanceInterval(newCfg.BalanceRefreshInterval) + sch.SetTaskSweepInterval(newCfg.TaskSweepInterval) sch.SetIncludeDisabledInTasks(newCfg.Schedule.IncludeDisabledInTasks) return restartRequiredFields(newCfg), nil } +// applyCNChatBase 把 config cn.chat_base 装配进上游 client;空则保留内置默认, +// 老配置(无 cn 段)行为不变。 +func applyCNChatBase(up *upstream.Client, cfg *Config) { + if cfg.CN.ChatBase != "" { + up.ChatBaseCN = cfg.CN.ChatBase + } +} + // restartRequiredFields 返回本次改动中无法热生效、需要重启进程的字段名。 // 恒返回完整清单中的"与当前进程装配期依赖相关"的项——面板据此提示用户。 func restartRequiredFields(c *Config) []string { @@ -528,6 +554,8 @@ func restartRequiredFields(c *Config) []string { // 之后不再读取——不在 livecfg 热快照里,也无法热改。此前漏列,导致面板改完 // 显示"已保存"却不提示需要重启,用户以为没生效(issue #102 附带发现 2)。 out = append(out, "upstream.user_agent") + // cn.chat_base 同样只在装配期写进 up.ChatBaseCN(请求路径并发读取,不做热改)。 + out = append(out, "cn.chat_base") if c.Upstash.URL != "" || c.Upstash.Token != "" { out = append(out, "upstash") } diff --git a/cmd/server/strategy_config_test.go b/cmd/server/strategy_config_test.go new file mode 100644 index 00000000..f8e94520 --- /dev/null +++ b/cmd/server/strategy_config_test.go @@ -0,0 +1,92 @@ +package main + +import ( + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" + "github.com/linguo2625469/workbuddy2api-panel/internal/livecfg" + "github.com/linguo2625469/workbuddy2api-panel/internal/pool" + "github.com/linguo2625469/workbuddy2api-panel/internal/scheduler" + "github.com/linguo2625469/workbuddy2api-panel/internal/upstream" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRegionStrategyConfigCompatibility(t *testing.T) { + for _, raw := range []string{`{}`, `{"pool":{}}`, `{"pool":{"strategy":{"domestic":""}}}`} { + c, err := ParseConfig([]byte(raw)) + if err != nil { + t.Fatal(err) + } + if c.Pool.Strategy.Domestic != pool.WeightedRandom || c.Pool.Strategy.International != pool.WeightedRandom { + t.Fatalf("legacy defaults: %+v", c.Pool.Strategy) + } + } + c, err := ParseConfig([]byte(`{"pool":{"strategy":{"domestic":" ROUND_ROBIN ","international":"sequential_exhaust"},"account_order":{"international":["b","a"]}}}`)) + if err != nil { + t.Fatal(err) + } + if c.Pool.Strategy.Domestic != pool.RoundRobin || c.Pool.Strategy.International != pool.SequentialExhaust || c.Pool.AccountOrder.International[0] != "b" { + t.Fatalf("config=%+v", c.Pool) + } + for _, raw := range []string{`{"pool":{"strategy":{"domestic":"bad"}}}`, `{"pool":{"strategy":{"international":"bad"}}}`, `{"pool":{"strategy":"round_robin"}}`} { + _, err := ParseConfig([]byte(raw)) + if err == nil || !strings.Contains(err.Error(), "strategy") { + t.Fatalf("invalid config err=%v", err) + } + } +} + +func TestSaveRegionSchedulingHotAppliesAndRejectsInvalid(t *testing.T) { + fp := filepath.Join(t.TempDir(), "config.json") + original := []byte(`{"pool":{"strategy":{"international":"sequential_exhaust"}},"custom":"keep"}`) + if err := os.WriteFile(fp, original, 0600); err != nil { + t.Fatal(err) + } + p := pool.New("") + p.Add(&auth.Auth{UID: "a"}) + p.Add(&auth.Auth{UID: "b"}) + live := livecfg.New(livecfg.Snapshot{}) + up := upstream.New() + sch := scheduler.New(scheduler.Config{}) + raw := []byte(`{"pool":{"strategy":{"domestic":"round_robin"},"account_order":{"domestic":["b","a"]}}}`) + restart, err := saveConfig(raw, fp, live, p, up, sch) + if err != nil { + t.Fatal(err) + } + for _, key := range restart { + if strings.HasPrefix(key, "pool.") { + t.Fatalf("strategy requires restart: %s", key) + } + } + for _, uid := range []string{"b", "a", "b"} { + if a := p.PickExcludingForRealm(nil, "m", "cn"); a == nil || a.UID != uid { + t.Fatalf("hot pick=%v want %s", a, uid) + } + } + saved, err := os.ReadFile(fp) + if err != nil { + t.Fatal(err) + } + cfg, err := ParseConfig(saved) + if err != nil { + t.Fatal(err) + } + if cfg.Pool.Strategy.International != pool.SequentialExhaust || !strings.Contains(string(saved), "keep") { + t.Fatalf("merge lost config: %s", saved) + } + _, err = saveConfig([]byte(`{"pool":{"strategy":{"domestic":"bad"}}}`), fp, live, p, up, sch) + if err == nil { + t.Fatal("invalid strategy accepted") + } + after, err := os.ReadFile(fp) + if err != nil { + t.Fatal(err) + } + if string(saved) != string(after) { + t.Fatal("invalid save changed file") + } + if a := p.PickExcludingForRealm(nil, "m", "cn"); a == nil || a.UID != "a" { + t.Fatalf("invalid save changed strategy: %v", a) + } +} diff --git a/config.example.json b/config.example.json index 4461074d..367c9f94 100644 --- a/config.example.json +++ b/config.example.json @@ -41,6 +41,9 @@ "chat_base": "", "billing_base": "" }, + "cn": { + "chat_base": "" + }, "upstream": { "timeout_seconds": 120, "header_timeout_seconds": 120, @@ -56,6 +59,10 @@ "features": { "sanitize_blacklist_fingerprints": true }, + "models": { + "_comment": "hidden: 不对外暴露的模型黑名单。裸名=两域都藏;\"cn:xxx\"/\"global:xxx\"=只藏该域。/v1/models 列表过滤 + chat 点名拒绝,面板保存后热生效。", + "hidden": [] + }, "prompt": { "mode": "passthrough", "file": "" @@ -65,6 +72,14 @@ "token": "" }, "pool": { + "strategy": { + "domestic": "weighted_random", + "international": "weighted_random" + }, + "account_order": { + "domestic": [], + "international": [] + }, "max_in_flight": 3, "max_in_flight_global": 2, "breaker_threshold": 3, @@ -78,7 +93,8 @@ "prefer_expiring": true, "expiring_soon": "168h", "cost_explore_interval": "30m", - "credit_floor": 100 + "credit_floor": 100, + "cross_domain_fallback": false }, "session_sticky": { "enabled": true, diff --git a/internal/atomicfile/atomicfile.go b/internal/atomicfile/atomicfile.go new file mode 100644 index 00000000..a937a6c4 --- /dev/null +++ b/internal/atomicfile/atomicfile.go @@ -0,0 +1,120 @@ +// Package atomicfile 提供「优先原子替换、不可写时降级原地覆盖」的落盘原语。 +// +// # 为什么需要它(issue #226 / #134) +// +// Docker 官方推荐的部署形态是把 config.json 以**单文件** bind mount 进容器 +// (./config.json:/app/config.json)。此时「先写 path+".tmp" 再 rename」的原子替换 +// 会两头撞墙: +// +// 1. tmp 落在**镜像内**的 /app 目录(属主 uid 10001),而 compose 允许以宿主机 +// uid 运行容器(PUID/PGID=1000)。→ tmp 写入 EACCES: +// "write config: open /app/config.json.tmp: permission denied"。 +// 面板「保存配置」因此永远失败,而 auths/ 与 data/ 挂载目录却是可写的—— +// 两个官方解法(以本机 uid 运行 / chown 10001)恰好互相冲突。 +// 2. 即便 tmp 写成功,rename 覆盖单文件挂载点会得到 EBUSY(device or resource busy)。 +// +// 本包的处理:同目录 tmp 写不动时,依次退到调用方给的**可写暂存目录** +// (Docker 部署下即 ./data),再退到 os.TempDir();暂存成功后优先 rename +// (同一文件系统时仍是原子替换),跨设备(EXDEV)或挂载点(EBUSY)时退化为对 +// 目标文件的原地覆盖,并保留暂存文件里的完整新内容以便可手工恢复。 +// +// 原地覆盖是有损的(进程在写一半时被杀 → 目标文件为空),因此它只是**最后手段**: +// 正常路径始终是「同目录 tmp + rename」,失败时的第一选择是「换目录暂存 + rename」。 +package atomicfile + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "syscall" +) + +// Write 把 data 写入 path。 +// +// mode 为**新建**文件时的权限位(已存在的文件保持原权限)。 +// fallbackDirs 是同目录 tmp 不可写时依次尝试的暂存目录(可为 nil)。 +// 返回 nil 表示 path 已包含 data 的全部内容。 +// +// 失败时返回的错误里会带上可操作信息(哪个路径不可写、完整新内容留在哪)。 +func Write(path string, data []byte, mode os.FileMode, fallbackDirs ...string) error { + primary := path + ".tmp" + primaryErr := os.WriteFile(primary, data, mode) + if primaryErr == nil { + // 正常路径:同目录 tmp + rename(原子)。 + if err := os.Rename(primary, path); err == nil { + return nil + } else { + // rename 失败(EXDEV 跨设备 / EBUSY 单文件挂载点 / EXDEV 目标为目录…): + // 退化为原地覆盖;tmp 里已是完整新内容,失败时留在原地供恢复。 + if werr := writeInPlace(path, data); werr == nil { + _ = os.Remove(primary) + return nil + } else { + return fmt.Errorf("replace %s: %w(完整新内容保留在 %s)", path, werr, primary) + } + } + } + + // 同目录 tmp 不可写:退到可写暂存目录。 + // 常见现场:单文件 bind mount(tmp 落在镜像目录 /app)+ 非 10001 uid 运行。 + // 末位追加系统临时目录:几乎总能写(/tmp 默认 1777)。 + candidates := append(append([]string{}, fallbackDirs...), os.TempDir()) + for _, dir := range candidates { + if dir == "" { + continue + } + if err := os.MkdirAll(dir, 0o755); err != nil { + continue + } + alt := filepath.Join(dir, filepath.Base(path)+".tmp") + if err := os.WriteFile(alt, data, mode); err != nil { + continue + } + if err := os.Rename(alt, path); err == nil { + return nil + } + // 跨设备 / 挂载点:原地覆盖目标(目标文件本身通常是可写的)。 + if werr := writeInPlace(path, data); werr == nil { + _ = os.Remove(alt) + return nil + } else { + return fmt.Errorf("replace %s: %w(完整新内容保留在 %s)", path, werr, alt) + } + } + + // 全部退路都失败:把原因讲清楚,并附上权限排查指引。 + msg := fmt.Sprintf("write %s: %v(临时文件 %s 无法创建:所在目录不可写)", path, primaryErr, primary) + if errors.Is(primaryErr, fs.ErrPermission) { + msg += "\n(Docker 部署:容器内用户对目标文件所在目录无写权限。解法任选:" + + "1) 以本机 uid 运行容器:PUID=$(id -u) PGID=$(id -g) docker compose up -d;" + + "2) sudo chown -R 10001:10001 ./auths ./data ./config.json;" + + "3) compose 设 user: \"0:0\" 以 root 运行)" + } + return errors.New(msg) +} + +// writeInPlace 原地覆盖目标文件(不新建)。用于「rename 不可用」的降级路径: +// 单文件 bind mount(EBUSY)与跨设备(EXDEV)。 +func writeInPlace(path string, data []byte) error { + f, err := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0o600) + if err != nil { + return err + } + if _, err := f.Write(data); err != nil { + _ = f.Close() + return err + } + if err := f.Sync(); err != nil { + _ = f.Close() + return err + } + return f.Close() +} + +// IsReplaceableErr 报告 rename 失败是否属于「应当降级为原地覆盖」的情形: +// EXDEV(跨设备)与 EBUSY(单文件 bind mount 挂载点)。 +func IsReplaceableErr(err error) bool { + return errors.Is(err, syscall.EXDEV) || errors.Is(err, syscall.EBUSY) +} diff --git a/internal/atomicfile/atomicfile_test.go b/internal/atomicfile/atomicfile_test.go new file mode 100644 index 00000000..01a170fb --- /dev/null +++ b/internal/atomicfile/atomicfile_test.go @@ -0,0 +1,141 @@ +package atomicfile + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestWriteAtomicReplace 正常路径:同目录 tmp + rename,路径上不留 tmp 残留。 +func TestWriteAtomicReplace(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.json") + if err := os.WriteFile(path, []byte("old"), 0o600); err != nil { + t.Fatal(err) + } + if err := Write(path, []byte("new"), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(got) != "new" { + t.Fatalf("content=%q want %q", got, "new") + } + if _, err := os.Stat(path + ".tmp"); !os.IsNotExist(err) { + t.Fatal("tmp file should not remain") + } +} + +// TestWriteFallbackDirWhenPrimaryDirUnwritable 复现 issue #226/#134 的核心现场: +// 目标文件所在目录不可写(单文件 bind mount + 非属主 uid),但 fallback 目录可写。 +// 此时必须落盘成功,且内容完整。 +func TestWriteFallbackDirWhenPrimaryDirUnwritable(t *testing.T) { + root := t.TempDir() + roDir := filepath.Join(root, "ro") + fbDir := filepath.Join(root, "fallback") + for _, d := range []string{roDir, fbDir} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } + path := filepath.Join(roDir, "config.json") + if err := os.WriteFile(path, []byte("old"), 0o600); err != nil { + t.Fatal(err) + } + // 去掉目录写权限(当前用户即属主时 chmod 同样有效)。 + if err := os.Chmod(roDir, 0o555); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(roDir, 0o755) }) + + if err := Write(path, []byte("new"), 0o600, fbDir); err != nil { + t.Fatalf("write with fallback: %v", err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(got) != "new" { + t.Fatalf("content=%q want %q", got, "new") + } +} + +// TestWriteNoFallbackReturnsPermissionError 无 fallback 可用时,错误信息必须点名 +// 不可写的 tmp 路径并给出 Docker 权限指引(而不是裸 syscall 错误)。 +func TestWriteNoFallbackReturnsPermissionError(t *testing.T) { + root := t.TempDir() + roDir := filepath.Join(root, "ro") + if err := os.MkdirAll(roDir, 0o755); err != nil { + t.Fatal(err) + } + path := filepath.Join(roDir, "config.json") + if err := os.WriteFile(path, []byte("old"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Chmod(roDir, 0o555); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(roDir, 0o755) }) + + // 只有系统临时目录可写——但它一定能写,所以这里刻意不传 fallback, + // 让实现走到"全部退路失败"的分支前先验证同目录失败时的错误质量。 + err := Write(path, []byte("new"), 0o600) + if err == nil { + // 若当前用户是 root,chmod 拦不住——跳过断言。 + t.Skip("running as root: directory permission cannot be revoked") + } + if !strings.Contains(err.Error(), path+".tmp") { + t.Fatalf("error should name the unwritable tmp path, got: %v", err) + } +} + +// TestWriteTempDirFallback 系统临时目录兜底:同目录不可写、无显式 fallback 时, +// 仍应通过 os.TempDir() 完成落盘。 +func TestWriteTempDirFallback(t *testing.T) { + root := t.TempDir() + roDir := filepath.Join(root, "ro") + if err := os.MkdirAll(roDir, 0o755); err != nil { + t.Fatal(err) + } + path := filepath.Join(roDir, "config.json") + if err := os.WriteFile(path, []byte("old"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Chmod(roDir, 0o555); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(roDir, 0o755) }) + + if err := Write(path, []byte("new"), 0o600); err != nil { + if os.Geteuid() == 0 { + t.Skip("running as root: directory permission cannot be revoked") + } + t.Fatalf("temp-dir fallback should have succeeded: %v", err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(got) != "new" { + t.Fatalf("content=%q want %q", got, "new") + } +} + +// TestWriteCreatesNewFile 目标不存在时也能落盘(自动创建)。 +func TestWriteCreatesNewFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "brand-new.json") + if err := Write(path, []byte(`{"a":1}`), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(got) != `{"a":1}` { + t.Fatalf("content=%q", got) + } +} diff --git a/internal/auth/apikey_test.go b/internal/auth/apikey_test.go new file mode 100644 index 00000000..4c5e0c6b --- /dev/null +++ b/internal/auth/apikey_test.go @@ -0,0 +1,103 @@ +package auth + +import ( + "testing" + "time" +) + +// TestIsAPIKeyMatrix 静态 API Key 判定矩阵:只看 AccessToken 前缀(ck_/pt_), +// JWT 与空 token 都不是 API Key。该判定驱动额度口径分流与成长体系门控。 +func TestIsAPIKeyMatrix(t *testing.T) { + cases := []struct { + name string + tok string + want bool + }{ + {"ck_ 前缀", "ck_9f3a2b1c.deadbeef", true}, + {"pt_ 前缀", "pt_abc123.secret", true}, + {"JWT 形态", "eyJhbGciOiJIUzI1NiJ9.payload.sig", false}, + {"空 token", "", false}, + {"裸前缀无分隔", "ck_", true}, // 官方判据是前缀,不做形态完整性校验 + {"大小写敏感(CK_ 不算)", "CK_abc.def", false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + a := &Auth{UID: "u1", AccessToken: c.tok} + if got := a.IsAPIKey(); got != c.want { + t.Errorf("token=%q IsAPIKey()=%v want %v", c.tok, got, c.want) + } + }) + } +} + +// TestAPIKeyNeverNeedsRefresh 静态 Key 是永久凭证、无法 refresh(refresh 端点只认 +// OAuth 设备流),故无论 expiry 形态都恒 false——否则 server 路径会先 refresh、失败 +// 后把好号当坏号冷却换号。 +func TestAPIKeyNeverNeedsRefresh(t *testing.T) { + now := time.Now().Unix() + for _, exp := range []int64{0, now - 10000, now + 60, now + 10*24*3600} { + a := &Auth{UID: "u1", AccessToken: "ck_abc.def", ExpiresAt: exp} + if a.NeedsRefresh(time.Hour) { + t.Errorf("API Key(ExpiresAt=%d)不应需要 refresh", exp) + } + } + // 对照:普通 OAuth 账号照旧按 expiry 判定(零回归)。 + expired := &Auth{UID: "u2", AccessToken: "at", ExpiresAt: now - 10} + if !expired.NeedsRefresh(time.Hour) { + t.Error("已过期的 OAuth 账号应需要 refresh") + } + noExpiry := &Auth{UID: "u3", AccessToken: "at", ExpiresAt: 0} + if !noExpiry.NeedsRefresh(time.Hour) { + t.Error("无 expiry 的 OAuth 账号应需要 refresh") + } + valid := &Auth{UID: "u4", AccessToken: "at", ExpiresAt: now + 10*3600} + if valid.NeedsRefresh(time.Hour) { + t.Error("未到期的 OAuth 账号不应需要 refresh") + } +} + +// TestUsesEnterpriseBilling 额度口径分流:企业版与静态 API Key 走企业口径端点, +// 个人号走个人资源包端点。 +func TestUsesEnterpriseBilling(t *testing.T) { + cases := []struct { + name string + ent string + tok string + want bool + }{ + {"企业版", "ent-1", "at", true}, + {"静态 API Key", "", "ck_abc.def", true}, + {"个人号(对照)", "", "at", false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + a := &Auth{UID: "u1", EnterpriseID: c.ent, AccessToken: c.tok} + if got := a.UsesEnterpriseBilling(); got != c.want { + t.Errorf("UsesEnterpriseBilling()=%v want %v", got, c.want) + } + }) + } +} + +// TestSupportsPersonalGrowth 个人成长体系门控:企业版与静态 API Key 均无该体系, +// 只有个人号支持(对照)。 +func TestSupportsPersonalGrowth(t *testing.T) { + cases := []struct { + name string + ent string + tok string + want bool + }{ + {"个人号", "", "at", true}, + {"企业版", "ent-1", "at", false}, + {"静态 API Key", "", "ck_abc.def", false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + a := &Auth{UID: "u1", EnterpriseID: c.ent, AccessToken: c.tok} + if got := a.SupportsPersonalGrowth(); got != c.want { + t.Errorf("SupportsPersonalGrowth()=%v want %v", got, c.want) + } + }) + } +} diff --git a/internal/auth/auth.go b/internal/auth/auth.go index ea1bef80..246eba9d 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -3,6 +3,7 @@ package auth import ( + "encoding/base64" "encoding/json" "errors" "fmt" @@ -15,6 +16,7 @@ import ( "sync/atomic" "time" + "github.com/linguo2625469/workbuddy2api-panel/internal/atomicfile" "github.com/linguo2625469/workbuddy2api-panel/internal/logfmt" ) @@ -190,6 +192,77 @@ func BackfillRealmFor(a *Auth, realm string) (bool, error) { // IsGlobal 报告账号是否属于 global realm(= Realm() == "global")。 func (a *Auth) IsGlobal() bool { return a.Realm() == "global" } +// apiKeyPrefixes 静态 API Key 的前缀集合:ck_(CodeBuddy 控制台签发)与 +// pt_(官方 CLI @tencent-ai/codebuddy-code 另有该前缀)。 +// 官方 CLI 的判据是 tokenType === "ApiKey" 且 token 以这两个前缀之一开头; +// auth 文件不持久化 tokenType(refresh 只认 OAuth 设备流,静态 Key 无法刷新), +// 前缀是落盘形态里唯一稳定、跨进程一致的判据。 +var apiKeyPrefixes = []string{"ck_", "pt_"} + +// isAPIKeyToken 判 token 是否以静态 API Key 前缀开头。 +func isAPIKeyToken(at string) bool { + for _, p := range apiKeyPrefixes { + if strings.HasPrefix(at, p) { + return true + } + } + return false +} + +// isAPIKeyLocked IsAPIKey 的无锁内部实现:仅限**已持 a.mu** 的调用方使用 +// (sync.Mutex 不可重入,锁内再调 AccessTokenValue 会自锁)。 +func (a *Auth) isAPIKeyLocked() bool { return isAPIKeyToken(a.AccessToken) } + +// IsAPIKey 报告账号是否为静态 API Key 账号(AccessToken 以 ck_/pt_ 开头)。 +// 经 AccessTokenValue() 加锁读取:RefreshToken 会在锁内改写 AccessToken。 +// +// 语义:静态 Key 直接可用(Authorization: Bearer ),但**无个人成长体系** +// (签到/成长任务/连登/旅行/夜猫子一律不适用),且额度只能走企业口径端点 +// (个人 get-user-resource 对静态 Key 返回空 Accounts),见 UsesEnterpriseBilling。 +func (a *Auth) IsAPIKey() bool { + if a == nil { + return false + } + return isAPIKeyToken(a.AccessTokenValue()) +} + +// UsesEnterpriseBilling 报告账号的额度查询是否走企业口径端点 +// (/v2/billing/meter/get-enterprise-user-usage)。 +// 企业版与静态 API Key 账号同属此列:个人 get-user-resource 对二者均返回空 +// Accounts(企业账号无个人资源包;静态 Key 无个人账号身份),额度必须走企业口径。 +func (a *Auth) UsesEnterpriseBilling() bool { return a.IsEnterprise() || a.IsAPIKey() } + +// SupportsPersonalGrowth 报告账号是否具备个人成长体系(签到 / 成长任务 / 连登管家 / +// 猫猫旅行 / 夜猫子)。以下两类账号都没有该体系: +// - 企业版(IsEnterprise):上游一律拒绝(400 code 10001 / 403 growth-only); +// - 静态 API Key 账号(IsAPIKey):根本没有个人账号身份。 +// +// scheduler 五类任务与 panel 成长入口据此并列门控,避免对注定失败的端点空发请求。 +// +// 注意门控分两层:本方法只判「**硬**不成长」(企业版 / API Key)——对二者连 +// **读端点**(签到、任务列表扫描)都无意义。企业切换会话(token_source=enterprise_switch) +// 不在此列:它的读端点照常可用、失败也是显式报错,只有**行为事件**会被上游静默 +// 丢弃(200 不计分),故由 SupportsBehaviorEvents 单独判,行为类任务用它、 +// 读端点与签到不在此列(见 SupportsBehaviorEvents 注释)。 +func (a *Auth) SupportsPersonalGrowth() bool { + return !a.IsEnterprise() && !a.IsAPIKey() +} + +// SupportsBehaviorEvents 报告账号的**行为事件**(活跃上报 /v2/report、成长任务 +// 执行、连登、猫猫旅行、夜猫子)是否会被上游真正记账。 +// +// 与 SupportsPersonalGrowth 的差别只在企业切换会话(token_source=enterprise_switch, +// 见 IsEnterpriseSession):这类凭证的**读端点**(签到、任务列表扫描、额度查询)照常 +// 可用,但行为事件返回 200 却被上游静默丢弃(实测 2026-10-12:15 个成长任务全部 +// 0/x,90 秒内逐 15s 回读进度从不跳动)——即「读得见、记不上账」。不跳过则队列 +// 每轮白跑全部任务(含真实对话配额)且无任何报错。 +// +// 因此:行为类任务(活跃上报 / 连登 / 旅行 / 夜猫子 / 成长任务执行)用本方法门控; +// 签到与任务列表扫描**不在**此列——签到失败是显式报错(不静默),列表扫描实测可用。 +func (a *Auth) SupportsBehaviorEvents() bool { + return a.SupportsPersonalGrowth() && !a.IsEnterpriseSession() +} + // IsEnterprise 报告账号是否为企业版(auth 文件带非空 enterpriseId)。 // // 企业版**没有个人成长体系**:签到 / 成长任务 / 连登管家 / 猫猫旅行 / 夜猫子 @@ -209,6 +282,66 @@ func (a *Auth) IsGlobal() bool { return a.Realm() == "global" } // 无需加锁——Realm() 加锁是因为 realm/Domain 会被 RefreshToken 在锁内改写。 func (a *Auth) IsEnterprise() bool { return strings.TrimSpace(a.EnterpriseID) != "" } +// IsEnterpriseSession 报告当前凭证是否处于「企业上下文」: +// 账号本身是企业版(IsEnterprise),或 AccessToken 签发自**企业切换会话** +// (JWT payload 带 token_source=enterprise_switch——浏览器在企业空间状态下 +// 完成 OAuth 授权时签发)。 +// +// 为什么单独一站:企业切换会话与真企业版的上游表现**不同**(实测 2026-10-12, +// 同一时刻与个人号 A/B 对照)——成长任务列表 / 额度查询 / 领养 Buddy 等**读端点 +// 与直连 API 照常**,但 /v2/report 行为事件**返回 200 却被静默丢弃**(15 个成长 +// 任务全部 0/x,重跑并 90s 内逐 15s 回读进度从不跳动),连登 / 签到同样无效。 +// 即「读得见、记不上账」:不跳过则队列每轮白跑全部任务(含真实对话配额)且 +// 无任何报错。因此行为类任务(活跃上报 / 连登 / 旅行 / 夜猫子 / 成长任务执行) +// 的门控应使用本方法;签到与任务列表扫描不在此列(签到失败是显式报错, +// 列表扫描实测可用)。额度查询 / 选号 / 保活照常(按 IsEnterprise 口径)。 +// +// 每次现解码 JWT 而非 Parse 时缓存:RefreshToken 会整体换发 token, +// 企业切换状态可能随重新授权翻转,缓存会读到过期形态。解码成本仅一次 +// base64 + 小 JSON(每调度轮每账号数次),可忽略。 +func (a *Auth) IsEnterpriseSession() bool { + if a.IsEnterprise() { + return true + } + return jwtTokenSource(a.AccessTokenValue()) == "enterprise_switch" +} + +// jwtTokenSource 解出 JWT payload 中的 token_source 声明;非 JWT / 解码失败 +// 一律返回 ""(老 token 与非标准形态按无此声明处理,零回归)。先按无填充 +// base64url 解(JWT 标准),失败再试带填充形态(兼容非标准签发方)。 +func jwtTokenSource(token string) string { + parts := strings.Split(token, ".") + if len(parts) < 2 { + return "" + } + raw, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + if raw, err = base64.URLEncoding.DecodeString(parts[1]); err != nil { + return "" + } + } + var payload struct { + TokenSource string `json:"token_source"` + } + if err := json.Unmarshal(raw, &payload); err != nil { + return "" + } + return payload.TokenSource +} + +// TokenSource 从 AccessToken 的 JWT payload 里读 token_source 声明(官方客户端用它 +// 填出站 X-Auth-Method 与 X-Userinfo.token_source)。非 JWT / 无该声明 / 解码失败一律 +// 返回空串——空表示「这个账号没有该维度」,调用方随之不发对应头(不伪造)。 +// +// 每次现解码而不缓存:RefreshToken 会整体换发 token,token_source 可能随重新授权翻转。 +// 成本是一次 base64 + 小 JSON,相对一次上游请求可忽略。 +func (a *Auth) TokenSource() string { + if a == nil { + return "" + } + return strings.TrimSpace(jwtTokenSource(a.AccessTokenValue())) +} + // isGlobalDomain 判定 domain 是否指向 www.workbuddy.ai 家族。 // 同时接受裸域 workbuddy.ai 与任意子域(HasSuffix("www.workbuddy.ai") 或裸域本身)。 func isGlobalDomain(d string) bool { @@ -220,6 +353,11 @@ func isGlobalDomain(d string) bool { func (a *Auth) NeedsRefresh(within time.Duration) bool { a.mu.Lock() defer a.mu.Unlock() + // 静态 API Key 永不刷新(refresh 端点只认 OAuth 设备流):恒 false,否则 + // server 请求路径会先 refresh、失败后把该号当坏号冷却换号——Key 本身是好的。 + if a.isAPIKeyLocked() { + return false + } if a.ExpiresAt <= 0 { return true } @@ -351,7 +489,32 @@ func (a *Auth) SaveAtomic() error { } return errors.New(msg) } - return os.Rename(tmp, a.FilePath) + if err := os.Rename(tmp, a.FilePath); err != nil { + // rename 失败(单文件 bind mount 的 EBUSY / 跨设备 EXDEV):退化为原地覆盖。 + // 目标文件本身通常可写(能读到它就能写它),而 tmp 里是完整新内容。 + if !atomicfile.IsReplaceableErr(err) { + return fmt.Errorf("replace %s: %w", a.FilePath, err) + } + f, openErr := os.OpenFile(a.FilePath, os.O_WRONLY|os.O_TRUNC, 0o600) + if openErr != nil { + _ = os.Remove(tmp) + return fmt.Errorf("replace %s (bind mount fallback): %w", a.FilePath, openErr) + } + _, writeErr := f.Write(raw) + if writeErr == nil { + writeErr = f.Sync() + } + closeErr := f.Close() + if writeErr != nil { + return fmt.Errorf("replace %s (bind mount fallback, 完整新内容保留在 %s): %w", a.FilePath, tmp, writeErr) + } + _ = os.Remove(tmp) + if closeErr != nil { + return fmt.Errorf("replace %s (bind mount fallback): %w", a.FilePath, closeErr) + } + return nil + } + return nil } // LoadDir 扫描并解析 dir 下 workbuddy*.json;解析失败的文件静默跳过(启动日志由调用方统计)。 diff --git a/internal/auth/enterprise_session_test.go b/internal/auth/enterprise_session_test.go new file mode 100644 index 00000000..84342edd --- /dev/null +++ b/internal/auth/enterprise_session_test.go @@ -0,0 +1,54 @@ +package auth + +import ( + "encoding/base64" + "testing" +) + +// mustJWT 用给定 payload 拼一个最小 JWT(header.payload.signature)。 +// IsEnterpriseSession 不校验签名,只解 payload,故签名段可为任意占位。 +func mustJWT(t *testing.T, payload string) string { + t.Helper() + return "hdr." + base64.RawURLEncoding.EncodeToString([]byte(payload)) + ".sig" +} + +// TestIsEnterpriseSession 企业切换会话判定:JWT token_source=enterprise_switch +// 视为企业上下文;无该声明 / 非 JWT / 空 token 一律否(老凭证零回归); +// enterpriseId 非空短路为真(与 IsEnterprise 同口径)。 +func TestIsEnterpriseSession(t *testing.T) { + cases := []struct { + name string + token string + want bool + }{ + {"enterprise_switch", mustJWT(t, `{"sub":"u1","token_source":"enterprise_switch"}`), true}, + {"无 token_source 声明", mustJWT(t, `{"sub":"u1","scope":"openid profile"}`), false}, + {"其他 token_source", mustJWT(t, `{"sub":"u1","token_source":"password"}`), false}, + {"带填充 base64url", "hdr." + base64.URLEncoding.EncodeToString([]byte(`{"token_source":"enterprise_switch"}`)) + ".sig", true}, + {"payload 非 JSON", "hdr." + base64.RawURLEncoding.EncodeToString([]byte("not-json")) + ".sig", false}, + {"非 JWT 形态", "opaque-token", false}, + {"空 token", "", false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + a := &Auth{UID: "u1", AccessToken: c.token} + if got := a.IsEnterpriseSession(); got != c.want { + t.Errorf("IsEnterpriseSession()=%v want %v", got, c.want) + } + }) + } + + t.Run("enterpriseId 非空短路为真", func(t *testing.T) { + a := &Auth{UID: "u1", EnterpriseID: "e1", AccessToken: mustJWT(t, `{"sub":"u1"}`)} + if !a.IsEnterpriseSession() { + t.Error("IsEnterprise()=true 时 IsEnterpriseSession() 应恒为 true") + } + }) + + t.Run("企业切换会话不影响 IsEnterprise(计费口径隔离)", func(t *testing.T) { + a := &Auth{UID: "u1", AccessToken: mustJWT(t, `{"token_source":"enterprise_switch"}`)} + if a.IsEnterprise() { + t.Error("仅 token_source=enterprise_switch 不应翻转 IsEnterprise(额度查询仍走个人口径)") + } + }) +} diff --git a/internal/livecfg/livecfg.go b/internal/livecfg/livecfg.go index 0db0c341..ed51bb06 100644 --- a/internal/livecfg/livecfg.go +++ b/internal/livecfg/livecfg.go @@ -9,16 +9,18 @@ package livecfg import ( + "strings" "sync/atomic" "time" ) // Snapshot 一次读取的不可变配置视图。 type Snapshot struct { - APIKey string // 网关/面板共同鉴权密钥;空 = 不鉴权 - SoftCooldown time.Duration // 429 软冷却基数(<=0 时调用方回退内置默认) - SanitizeFingerprints bool // 出站请求体指纹脱敏 - RecordClientInfo bool // 请求日志是否记录调用来源(客户端 IP / UA) + APIKey string // 网关/面板共同鉴权密钥;空 = 不鉴权 + SoftCooldown time.Duration // 429 软冷却基数(<=0 时调用方回退内置默认) + SanitizeFingerprints bool // 出站请求体指纹脱敏 + RecordClientInfo bool // 请求日志是否记录调用来源(客户端 IP / UA) + HiddenModels map[string]struct{} // 精确隐藏的模型 id(已归一化为 "cn:x"/"global:x" 全量形式);nil = 不隐藏 } // Holder 原子持有当前快照。 @@ -46,3 +48,31 @@ func (h *Holder) Load() Snapshot { // Store 整体替换快照。 func (h *Holder) Store(s Snapshot) { h.p.Store(&s) } + +// NormalizeHiddenModels 把用户配置的 hidden 条目归一化为精确 id 集合。 +// +// 条目带合法 "cn:"/"global:" 前缀 → 只隐藏该域;裸名 → 两域都隐藏。 +// 大小写敏感(与 internal/server.resolveModel 的前缀规则对称:取第一个 ":", +// 前段恰为 "cn"/"global" 才剥离)。返回的 map 只读共享,调用方不得修改。 +func NormalizeHiddenModels(hidden []string) map[string]struct{} { + if len(hidden) == 0 { + return nil + } + out := make(map[string]struct{}, len(hidden)*2) + for _, m := range hidden { + m = strings.TrimSpace(m) + if m == "" { + continue + } + if prefix, bare, ok := strings.Cut(m, ":"); ok && (prefix == "cn" || prefix == "global") { + out[prefix+":"+bare] = struct{}{} + } else { + out["cn:"+m] = struct{}{} + out["global:"+m] = struct{}{} + } + } + if len(out) == 0 { + return nil + } + return out +} diff --git a/internal/livecfg/livecfg_test.go b/internal/livecfg/livecfg_test.go new file mode 100644 index 00000000..6c687eb6 --- /dev/null +++ b/internal/livecfg/livecfg_test.go @@ -0,0 +1,53 @@ +package livecfg + +import "testing" + +// TestNormalizeHiddenModels 归一化语义: +// - nil/空/全空白 → nil(不隐藏) +// - 裸名 → 两域都藏("cn:x" + "global:x") +// - "cn:"/"global:" 前缀 → 只藏该域 +// - 非 cn/global 前缀(如 "xx:yy")视为裸名整体(与 resolveModel 对称: +// 前缀不合法时 realm=cn、bare=原串,网关侧检查 "cn:xx:yy" 命中) +func TestNormalizeHiddenModels(t *testing.T) { + if got := NormalizeHiddenModels(nil); got != nil { + t.Errorf("nil → %v, want nil", got) + } + if got := NormalizeHiddenModels([]string{}); got != nil { + t.Errorf("empty → %v, want nil", got) + } + if got := NormalizeHiddenModels([]string{" ", ""}); got != nil { + t.Errorf("all blank → %v, want nil", got) + } + + got := NormalizeHiddenModels([]string{"deepseek-v3"}) + for _, want := range []string{"cn:deepseek-v3", "global:deepseek-v3"} { + if _, ok := got[want]; !ok { + t.Errorf("bare name: missing %q in %v", want, got) + } + } + if len(got) != 2 { + t.Errorf("bare name: got %d entries %v, want 2", len(got), got) + } + + got = NormalizeHiddenModels([]string{"cn:pro-model"}) + if _, ok := got["cn:pro-model"]; !ok { + t.Errorf("cn: prefix: missing %q in %v", "cn:pro-model", got) + } + if _, ok := got["global:pro-model"]; ok { + t.Errorf("cn: prefix: %q must not hide global realm", "global:pro-model") + } + + // 前后空白被裁掉 + got = NormalizeHiddenModels([]string{" glm-5.2 "}) + if _, ok := got["cn:glm-5.2"]; !ok { + t.Errorf("trimmed bare name: missing %q in %v", "cn:glm-5.2", got) + } + + // 非法前缀视为裸名整体 + got = NormalizeHiddenModels([]string{"xx:yy"}) + for _, want := range []string{"cn:xx:yy", "global:xx:yy"} { + if _, ok := got[want]; !ok { + t.Errorf("odd prefix: missing %q in %v", want, got) + } + } +} diff --git a/internal/panel/apikey.go b/internal/panel/apikey.go new file mode 100644 index 00000000..c5dac80f --- /dev/null +++ b/internal/panel/apikey.go @@ -0,0 +1,133 @@ +// apikey.go 面板「添加 API Key 账号」:CodeBuddy 平台签发的静态 API Key +// (ck_/pt_ 前缀)直接可用,无需 OAuth 设备流。 +// +// 落盘形态与 OAuth 账号完全一致(auths/workbuddy-.json,嵌套形,0600), +// 区别仅在没有 refreshToken / expiresAt 与 enterpriseId——静态凭证不可刷新, +// 额度走企业口径端点(见 upstream.UserResourceDetailedWithExpiry 的分流)。 +package panel + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" +) + +// apiKeyUIDMaxLen 派生/显式 uid 的长度上限(与 validUID 同口径:拼文件名的兜底)。 +const apiKeyUIDMaxLen = 64 + +// apiKeyPrefixes 静态 API Key 前缀集合,用于派生 uid 时剥前缀。 +// 前缀合法性判定走 auth.IsAPIKey()(单一判据来源),此处仅作字符串裁剪。 +var apiKeyPrefixes = []string{"ck_", "pt_"} + +// addAPIKey 添加一个静态 API Key 账号。 +// +// POST /panel/api/accounts/add_apikey +// body: {"access_token":"ck_...","nickname":"","uid":""} +// +// 流程:校验前缀 → 解析/派生 uid → 用企业额度端点探测 key 是否有效(无效即 400, +// 不落盘)→ 落盘 → 热加载进池并写入探测到的额度。 +func (p *Panel) addAPIKey(w http.ResponseWriter, r *http.Request) { + var body struct { + AccessToken string `json:"access_token"` + Nickname string `json:"nickname"` + UID string `json:"uid"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeErr(w, http.StatusBadRequest, "invalid json: "+err.Error()) + return + } + key := strings.TrimSpace(body.AccessToken) + // 前缀判据复用 auth:构造一个只带 token 的临时 Auth 走 IsAPIKey(避免前缀 + // 集合在 panel 与 auth 两处各写一遍)。 + if !(&auth.Auth{AccessToken: key}).IsAPIKey() { + writeErr(w, http.StatusBadRequest, "access_token 必须是 ck_/pt_ 前缀的 API Key") + return + } + uid, err := resolveAPIKeyUID(body.UID, key) + if err != nil { + writeErr(w, http.StatusBadRequest, err.Error()) + return + } + + // 探测 key 是否有效:静态 Key 的额度只能走企业口径端点(个人 get-user-resource + // 对它返回空 Accounts)。探测失败(无效 Key / 上游报错)即 400,不落盘。 + probe := &auth.Auth{AccessToken: key} + remain, total, expiring, earliestAt, earliestRemaining, err := p.cfg.Upstream.UserResourceDetailedWithExpiry(probe, p.expiringSoonWindow()) + if err != nil { + writeErr(w, http.StatusBadRequest, "API Key 无效或额度查询失败:"+err.Error()) + return + } + + a := &auth.Auth{ + AccessToken: key, + // RefreshToken 留空:静态 Key 无法 refresh(refresh 端点只认 OAuth 设备流); + // ExpiresAt 留 0(静态凭证无过期),EnterpriseID 留空(无企业身份,也不注入 + // X-Enterprise-Id / X-Tenant-Id)。 + UID: uid, + Nickname: strings.TrimSpace(body.Nickname), + FilePath: filepath.Join(p.cfg.AuthDir, fmt.Sprintf("workbuddy-%s.json", uid)), + } + // realm 按现有约定补全(静态 Key 恒 CN;与 LoadDir 存量迁移同口径)。 + _, _ = a.BackfillRealm() + if err := os.MkdirAll(p.cfg.AuthDir, 0o755); err != nil { + writeErr(w, http.StatusInternalServerError, "mkdir auth dir: "+err.Error()) + return + } + if err := a.SaveAtomic(); err != nil { + writeErr(w, http.StatusInternalServerError, "save auth: "+err.Error()) + return + } + p.cfg.Pool.Add(a) // upsert:已存在则覆盖凭证、保留原池状态 + p.cfg.Pool.SetCreditsDetailed(uid, remain, total, expiring, earliestAt, earliestRemaining) + log.Printf("panel: 添加 API Key 账号 uid=%s nickname=%q credits=%d/%d", uid, a.Nickname, remain, total) + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "uid": uid, "nickname": a.Nickname, "api_key": true}) +} + +// resolveAPIKeyUID 解析落盘用 uid:显式传入时按字符白名单校验(拒绝路径穿越); +// 缺省时取 key 的 段(ck_. 的 id)拼成 apikey-,非法字符剔除。 +func resolveAPIKeyUID(explicit, key string) (string, error) { + if e := strings.TrimSpace(explicit); e != "" { + if !validImportUID(e) { + return "", fmt.Errorf("uid 只能包含字母、数字、下划线、连字符(≤%d 字符)", apiKeyUIDMaxLen) + } + return e, nil + } + id := apiKeyID(key) + if id == "" { + return "", fmt.Errorf("无法从 key 解析 id 段,请显式指定 uid") + } + uid := "apikey-" + id + if len(uid) > apiKeyUIDMaxLen { + uid = uid[:apiKeyUIDMaxLen] + } + return uid, nil +} + +// apiKeyID 取 ck_. 的 段(剥前缀、截到首个点之前),只保留 +// [A-Za-z0-9_-]——拼文件名前必须净化,避免路径穿越。 +func apiKeyID(key string) string { + s := key + for _, p := range apiKeyPrefixes { + if strings.HasPrefix(s, p) { + s = s[len(p):] + break + } + } + if i := strings.IndexByte(s, '.'); i >= 0 { + s = s[:i] + } + var b strings.Builder + for _, c := range s { + switch { + case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '-', c == '_': + b.WriteRune(c) + } + } + return b.String() +} diff --git a/internal/panel/apikey_test.go b/internal/panel/apikey_test.go new file mode 100644 index 00000000..9fef98a0 --- /dev/null +++ b/internal/panel/apikey_test.go @@ -0,0 +1,183 @@ +package panel + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" + "github.com/linguo2625469/workbuddy2api-panel/internal/pool" + "github.com/linguo2625469/workbuddy2api-panel/internal/upstream" +) + +// panelWithUpstream 构造带 mock 上游与临时 auth 目录的面板(APIKey 留空 = 不鉴权)。 +func panelWithUpstream(t *testing.T, handler http.HandlerFunc) *Panel { + t.Helper() + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + return New(Config{ + Version: "test", + Pool: pool.New(""), + Upstream: &upstream.Client{HTTP: srv.Client(), ChatBaseCN: srv.URL, BillingBaseCN: srv.URL, WebBaseCN: srv.URL}, + AuthDir: t.TempDir(), + }) +} + +// postJSON 发一次 JSON POST 到面板。 +func postJSON(t *testing.T, p *Panel, path string, body any) *httptest.ResponseRecorder { + t.Helper() + raw, err := json.Marshal(body) + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(raw)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + p.ServeHTTP(rec, req) + return rec +} + +// authFiles 返回 auth 目录下的文件名列表(断言"未落盘"用)。 +func authFiles(t *testing.T, dir string) []string { + t.Helper() + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + var out []string + for _, e := range entries { + out = append(out, e.Name()) + } + return out +} + +// TestAddAPIKeyRejectsBadPrefix 非 ck_/pt_ 前缀直接 400,且不打上游、不落盘。 +func TestAddAPIKeyRejectsBadPrefix(t *testing.T) { + upstreamCalled := false + p := panelWithUpstream(t, func(w http.ResponseWriter, r *http.Request) { + upstreamCalled = true + w.Write([]byte(`{"code":0,"msg":"ok","data":{}}`)) + }) + rec := postJSON(t, p, "/panel/api/accounts/add_apikey", map[string]any{"access_token": "eyJhbGciOiJIUzI1NiJ9.x.y"}) + if rec.Code != http.StatusBadRequest { + t.Fatalf("code=%d want 400 body=%s", rec.Code, rec.Body.String()) + } + if upstreamCalled { + t.Error("前缀非法时不应探测上游") + } + if files := authFiles(t, p.cfg.AuthDir); len(files) != 0 { + t.Errorf("前缀非法时不应落盘,实际=%v", files) + } +} + +// TestAddAPIKeyRejectsInvalidKey 上游报错(key 无效)时 400 并带上游文案,不落盘。 +func TestAddAPIKeyRejectsInvalidKey(t *testing.T) { + p := panelWithUpstream(t, func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"code":10001,"msg":"invalid api key"}`)) + }) + rec := postJSON(t, p, "/panel/api/accounts/add_apikey", map[string]any{"access_token": "ck_bad.secret"}) + if rec.Code != http.StatusBadRequest { + t.Fatalf("code=%d want 400 body=%s", rec.Code, rec.Body.String()) + } + var resp map[string]any + _ = json.Unmarshal(rec.Body.Bytes(), &resp) + msg, _ := resp["error"].(string) + if !strings.Contains(msg, "invalid api key") { + t.Errorf("应带上游错误文案,实际 error=%q", msg) + } + if files := authFiles(t, p.cfg.AuthDir); len(files) != 0 { + t.Errorf("key 无效时不应落盘,实际=%v", files) + } +} + +// TestAddAPIKeySuccess 正常路径:探测通过 → 落盘(auth.LoadDir 兼容)→ 热加载进池 +// 并写入企业口径额度 → 返回 uid/nickname/api_key。 +func TestAddAPIKeySuccess(t *testing.T) { + p := panelWithUpstream(t, func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/get-enterprise-user-usage") { + w.Write([]byte(`{"code":0,"msg":"OK","data":{"credit":812.45,"limitNum":2000,` + + `"cycleEndTime":"2099-10-26 23:59:59","cycleResetTime":"2099-10-27 00:00:00"}}`)) + return + } + w.Write([]byte(`{"code":0,"msg":"ok","data":{}}`)) + }) + const key = "ck_9f3a2b1c.deadbeef" + rec := postJSON(t, p, "/panel/api/accounts/add_apikey", map[string]any{"access_token": key, "nickname": "钥匙"}) + if rec.Code != http.StatusOK { + t.Fatalf("code=%d want 200 body=%s", rec.Code, rec.Body.String()) + } + var resp map[string]any + _ = json.Unmarshal(rec.Body.Bytes(), &resp) + if resp["uid"] != "apikey-9f3a2b1c" || resp["nickname"] != "钥匙" || resp["api_key"] != true { + t.Errorf("响应异常:%v", resp) + } + + // 落盘形态必须与 auth.LoadDir/Parse 兼容。 + fp := filepath.Join(p.cfg.AuthDir, "workbuddy-apikey-9f3a2b1c.json") + raw, err := os.ReadFile(fp) + if err != nil { + t.Fatalf("read saved auth: %v", err) + } + a, err := auth.Parse(raw) + if err != nil { + t.Fatalf("reparse: %v", err) + } + if a.AccessToken != key || a.RefreshToken != "" || a.ExpiresAt != 0 || a.EnterpriseID != "" { + t.Errorf("落盘字段异常:%+v", a) + } + if a.UID != "apikey-9f3a2b1c" || !a.IsAPIKey() { + t.Errorf("落盘 uid/判定异常:uid=%q isAPIKey=%v", a.UID, a.IsAPIKey()) + } + // Windows 的权限位只有只读位有语义(os.WriteFile 的 0600 落成 0666), + // 该断言只在 POSIX 平台有意义——容器与 CI 都是 Linux,仍能守住 0600。 + if runtime.GOOS != "windows" { + if fi, err := os.Stat(fp); err == nil && fi.Mode().Perm() != 0o600 { + t.Errorf("auth 文件权限=%v want 0600", fi.Mode().Perm()) + } + } + loaded, err := auth.LoadDir(p.cfg.AuthDir) + if err != nil || len(loaded) != 1 { + t.Fatalf("LoadDir 应加载 1 个账号:n=%d err=%v", len(loaded), err) + } + + // 热加载进池 + 企业口径额度写入。 + st, ok := p.cfg.Pool.Status("apikey-9f3a2b1c") + if !ok { + t.Fatal("账号未热加载进池") + } + if !st.APIKey { + t.Error("Status.APIKey 应为 true") + } + if st.Credits != 1188 || st.CreditsTotal != 2000 { + t.Errorf("额度映射错误:credits=%d total=%d want 1188/2000", st.Credits, st.CreditsTotal) + } +} + +// TestAddAPIKeyExplicitUID 显式 uid 走字符白名单校验:合法值原样使用,非法值 400。 +func TestAddAPIKeyExplicitUID(t *testing.T) { + p := panelWithUpstream(t, func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"code":0,"msg":"OK","data":{"credit":0,"limitNum":100}}`)) + }) + rec := postJSON(t, p, "/panel/api/accounts/add_apikey", map[string]any{ + "access_token": "pt_abc.secret", "uid": "my-uid_1", + }) + if rec.Code != http.StatusOK { + t.Fatalf("合法 uid 应 200,实际 code=%d body=%s", rec.Code, rec.Body.String()) + } + if _, ok := p.cfg.Pool.Status("my-uid_1"); !ok { + t.Error("显式 uid 未生效") + } + + rec2 := postJSON(t, p, "/panel/api/accounts/add_apikey", map[string]any{ + "access_token": "ck_abc.secret", "uid": "../../evil", + }) + if rec2.Code != http.StatusBadRequest { + t.Errorf("非法 uid 应 400,实际 code=%d body=%s", rec2.Code, rec2.Body.String()) + } +} diff --git a/internal/panel/app.js b/internal/panel/app.js index 2c85c40e..6387def3 100644 --- a/internal/panel/app.js +++ b/internal/panel/app.js @@ -1,6 +1,9 @@ 'use strict'; /* ── 状态 ─────────────────────────────────────────────────────────── */ const LS_KEY = 'wb2api.key', LS_THEME = 'wb2api.theme'; +/* 账号备注与「积分到期提醒」折叠的本机键(同上:模块级声明放顶部,避开 TDZ)。 */ +const LS_REMARKS = 'wb2api.remarks.v1', LS_EXP_FOLD = 'wb2api.expfold.v1'; +let remarksCache = null; let theme = localStorage.getItem(LS_THEME) || 'auto'; // auto | light | dark let view = 'accounts'; let overviewData = null, cfgLoaded = null; @@ -9,6 +12,7 @@ let refTimer = null; /* 视图级筛选状态(模块级声明放在文件顶部,避免顶层 go() 早于声明执行时踩 TDZ)。 */ let mdFilter = { q: '', realm: '', cap: '', effort: '', promo: '', sort: 'default' }; let mdAll = [], mdProbes = {}, mdProbeOf = () => undefined; +let mdLocks = [], mdRecent = []; let reqFilter = { q: '', outcome: '' }; let reqEntries = []; let usDim = 'account', usCreditDim = 'account', usSort = 'total'; @@ -259,13 +263,21 @@ function rateLimitMeta(row, now) { title: model + '\n模型当前不可用' + (deadline ? '\n最早重试:' + fmtLocalDateTime(deadline) : ''), }; } - let detail = resetAt - ? '预计 ' + fmtLocalDateTime(resetAt) + ' 解封' + (remaining ? '(剩余 ' + dur(remaining) + ')' : '') - : (until ? '预计 ' + fmtLocalDateTime(until) + ' 恢复(剩余 ' + dur(remaining) + ')' : '预计解封时间未知'); const title = [model, resetAt ? '上游重置:' + fmtLocalDateTime(resetAt) : '上游重置:时间未知']; if (until && resetAt && until < resetAt) { - detail += ' · 网关最快 ' + dur(Math.max(0, Math.round((until - now) / 1000))) + ' 后重试'; + // 双时钟(issue #171 问题二):黄框出现/消失由 until 决定(网关为防超长 + // 限流拖死全池,把重试封顶在 now+softRateMax),主文字却按 resetAt 显示, + // 导致「写的解封时间」与「实际掉框时刻」不一致。此时主文字同时明示两个 + // 时刻与口径,倒计时跟随 until(与实际掉框一致,也与「模型与档位」页 + // 锁池表的 until 口径对齐)。 + const retryIn = until > now ? Math.round((until - now) / 1000) : 0; + detail = '网关 ' + fmtLocalDateTime(until) + ' 起重试' + (retryIn ? '(剩余 ' + dur(retryIn) + ')' : '') + + ' · 上游称 ' + fmtLocalDateTime(resetAt) + ' 恢复'; title.push('网关最早重试:' + fmtLocalDateTime(until)); + } else { + detail = resetAt + ? '预计 ' + fmtLocalDateTime(resetAt) + ' 解封' + (remaining ? '(剩余 ' + dur(remaining) + ')' : '') + : (until ? '预计 ' + fmtLocalDateTime(until) + ' 恢复(剩余 ' + dur(remaining) + ')' : '预计解封时间未知'); } return { model, kind, detail, title: title.join('\n') }; } @@ -412,6 +424,9 @@ function renderAccounts(list) { ' ' + c.model + ':' + (c.cost_per_1k <= 0 ? '免费' : c.cost_per_1k)).join('\n'); } const frozen = s.disabled || cool > 0; + // 企业版与静态 API Key 账号均无个人成长体系:隐藏签到/任务按钮,只留「额度」 + // (走企业口径 get-enterprise-user-usage)。API Key 账号另有「API Key」徽标。 + const noGrowth = s.enterprise || s.api_key; const tu = s.token_usage || {}; const req = tu.request_count || 0; const totalTok = formatTokenCount(tu.total_tokens); @@ -421,7 +436,7 @@ function renderAccounts(list) { const usageTitle = '最近一次:' + req + ' 次 / ' + totalTok + ' / 延迟 ' + latency + ' / ' + rate; return '' + '' + - '
' + (s.nickname ? esc(s.nickname) : '未命名') + (s.realm === 'global' ? ' 国际版' : '') + (s.enterprise ? ' 企业版' : '') + '
' + esc(short) + '
' + + '
' + remarkName(s.uid, s.nickname ? esc(s.nickname) : '未命名') + (s.realm === 'global' ? ' 国际版' : '') + (s.enterprise ? ' 企业版' : '') + (s.api_key ? ' API Key' : '') + '
' + esc(short) + '
' + '' + tag + note + rateLimits + '' + '
' + cred + '
' + '' + (s.success_count || 0) + ' / ' + (s.err_total || 0) + '' + @@ -434,17 +449,19 @@ function renderAccounts(list) { '' + '' + ago(s.last_success) + '' + '' + - // 企业版无个人成长体系(签到 400「企业账号不支持该操作」/ 成长任务 403): + // 企业版与静态 API Key 账号无个人成长体系(签到 400「企业账号不支持该操作」/ 成长任务 403): // 不渲染「签到」「任务」按钮,只留「额度」——点它走 /balance,企业额度由 // upstream 的 get-enterprise-user-usage 口径填充。 - (s.enterprise ? '' : + (noGrowth ? '' : '') + - '' + - (s.enterprise ? '' : + '' + + (noGrowth ? '' : '') + + (noGrowth || s.realm === 'global' ? '' : + '') + (frozen ? '' : (s.paused ? '' - : '')) + + : '')) + (s.disabled ? '' : '') + '' + ''; @@ -512,6 +529,11 @@ async function loadOverview(quiet) { $('subMeta').textContent = '运行 ' + (up >= 86400 ? Math.floor(up / 86400) + ' 天 ' : '') + Math.floor(up % 86400 / 3600) + ' 时 ' + Math.floor(up % 3600 / 60) + ' 分'; renderAccounts(d.accounts || []); renderModelLocks(d.model_locks); + // 到期卡片跟随首页统计同步刷新(用同一份响应里的按日批次,不额外发请求)。 + // 2 分钟内的实时明细最新且更全,保留;过期后由 state 快照接管,避免点过一次 + // 「检查」之后卡片永远停在那一刻的旧数据。 + const liveFresh = lastPackages && (Date.now() - lastPackagesAt) < EXP_FRESH_MS; + if ($('expBox') && !liveFresh) renderExpirySnapshot(d); } catch (e) { if (!quiet) toast(e.message, 'err'); } } @@ -543,6 +565,9 @@ $('accBody').addEventListener('click', async ev => { toast('已恢复选号', 'ok'); } else if (a === 'tasks') { openTasks(u); + } else if (a === 'travel') { + const r = await api('accounts/' + encodeURIComponent(u) + '/travel', { method: 'POST' }); + toast(r.skipped ? ('已跳过:' + (r.skip_reason || '不满足触发条件')) : '旅行巡检已执行,结果见日志', r.skipped ? 'err' : 'ok'); } else if (a === 'remove') { const r = await api('accounts/' + encodeURIComponent(u) + '/remove', { method: 'POST' }); toast(r.file_error ? '已移除(凭证文件删除失败:' + r.file_error + ')' : '已移除', 'ok'); @@ -618,14 +643,24 @@ function rateCell(m) { return m.credits ? esc(m.credits) : '—'; } -async function loadModels() { +async function loadModels(force) { const tb = $('mdBody'); tb.innerHTML = '
正在向上游查询…
'; try { - // 探测数据是可选增强:拉取失败不影响模型列表本身 - const [d, pr] = await Promise.all([api('models'), api('model_probes').catch(() => ({}))]); + // 探测、模型锁与最近请求都是本地可选增强:任一失败不影响模型目录本身。 + // 可用性标签不额外发推理请求,只用模型锁和最近真实请求结果合成。 + // force=true(「重新获取」按钮):带 refresh=1 让后端清掉 global 目录 1h 缓存, + // 真正重新问一次上游,否则点多少次都是同一份缓存(issue #216 现象 3)。 + const [d, pr, st, rm] = await Promise.all([ + api('models' + (force ? '?refresh=1' : '')), + api('model_probes').catch(() => ({})), + api('status').catch(() => ({})), + api('request_metrics').catch(() => ({})), + ]); mdAll = d.models || []; mdProbes = pr.probes || {}; + mdLocks = Array.isArray(st.model_locks) ? st.model_locks : []; + mdRecent = Array.isArray(rm.recent) ? rm.recent : []; if (!mdAll.length) { tb.innerHTML = '
上游未返回模型
'; $('mdCount').textContent = ''; @@ -636,7 +671,14 @@ async function loadModels() { const probeKeys = Object.keys(mdProbes); mdProbeOf = id => mdProbes[id] || mdProbes[probeKeys.find(k => k.endsWith(':' + id))]; const hit = mdAll.filter(m => mdProbeOf(m.id)).length; - $('mdNote').textContent = mdAll.length + ' 个模型 · 已刷新降级缓存' + (hit ? ' · ' + hit + ' 个有实测上限' : ''); + // 目录来源:桌面端族是官方 picker 口径;union/enterprise 说明 v3 探测部分或全部失败, + // 拿到的是回落目录(数量偏少、倍率可能是旧值),必须在界面上说清楚。 + const srcNote = { + 'v3-desktop': '', + 'v3-union': ' · ⚠ 国际版目录来自回落(IDE+CLI 并集)', + 'enterprise': ' · ⚠ 国际版目录已降级到企业端点(窄目录/旧倍率)', + }[d.global_source] || ''; + $('mdNote').textContent = mdAll.length + ' 个模型 · 已刷新降级缓存' + (hit ? ' · ' + hit + ' 个有实测上限' : '') + srcNote; renderModels(); } catch (e) { mdAll = []; @@ -702,6 +744,67 @@ function mdSortList(list, f) { return out; } +// mdAvailabilityOf 合成 Global 模型当前可用性:模型锁优先于最近请求结果。 +// 只返回有真实本地证据的状态;目录中存在但从未请求过的模型标为“待验证”, +// 避免把“上游列出了模型”误当成“当前账号有权限”。 +function mdAvailabilityOf(m, locks, recent) { + const id = String((m && m.id) || ''); + if (!id.startsWith('global:')) return null; + locks = Array.isArray(locks) ? locks : mdLocks; + recent = Array.isArray(recent) ? recent : mdRecent; + const bare = id.slice('global:'.length); + const lock = locks.find(r => r && r.realm === 'global' && r.model === bare); + if (lock) { + const reason = String(lock.reason || '').trim(); + const lower = reason.toLowerCase(); + if (lock.state === 'partial') { + return { key: 'partial', label: '部分可用', cls: 'warn', + tip: '仍有 ' + Number(lock.servable || 0) + ' 个 global 账号可服务;已锁 ' + Number(lock.locked || 0) + ' 个' + (reason ? ';' + reason : '') }; + } + if (lock.state === 'starved') { + return { key: 'starved', label: '账号暂不可用', cls: 'mute', + tip: '没有账号能服务该模型,但并非该模型自身被锁;请检查账号冷却/在途占用' }; + } + if (lower.startsWith('11158') || lower.includes('not allowed for user type')) { + return { key: 'denied', label: '无权限', cls: 'bad', + tip: '当前 global 账号类型不支持该模型' + (reason ? ';' + reason : '') }; + } + if (lower.startsWith('11102') || lower.includes('service info not found')) { + return { key: 'missing', label: '上游无此模型', cls: 'bad', + tip: '上游目录残留,实际请求返回模型不存在' + (reason ? ';' + reason : '') }; + } + return { key: 'limited', label: '限流/不可用', cls: 'warn', + tip: '模型级冷却中' + (reason ? ';' + reason : '') }; + } + const ev = recent.find(e => e && e.model === id); + if (ev) { + const status = Number(ev.status || 0); + const when = ev.time ? new Date(ev.time).toLocaleString('zh-CN', { hour12: false }) : ''; + if (ev.outcome === 'success' || (status >= 200 && status < 300)) { + return { key: 'ok', label: '可用', cls: 'ok', + tip: '最近一次真实请求成功' + (when ? ' · ' + when : '') }; + } + if (status === 429) { + return { key: 'rate', label: '限流', cls: 'warn', + tip: '最近一次请求被限流' + (when ? ' · ' + when : '') }; + } + if (status >= 500) { + return { key: 'upstream', label: '上游错误', cls: 'bad', + tip: '最近一次请求上游错误 HTTP ' + status + (when ? ' · ' + when : '') }; + } + return { key: 'failed', label: '最近失败', cls: 'bad', + tip: '最近一次请求失败 HTTP ' + (status || '—') + (when ? ' · ' + when : '') }; + } + return { key: 'unknown', label: '待验证', cls: 'mute', + tip: '尚无模型锁或近期真实请求记录;目录存在不代表当前账号有权限' }; +} + +function mdAvailabilityTag(m) { + const av = mdAvailabilityOf(m); + if (!av) return ''; + return ' ' + esc(av.label) + ''; +} + // mdRowHtml 单个模型行(纯渲染,便于独立测试)。 function mdRowHtml(m, pr) { const eff = (m.supported_efforts || []).slice(); @@ -716,7 +819,7 @@ function mdRowHtml(m, pr) { if (m.supports_reasoning && !m.can_disable_thinking) caps.push('思考常开'); const capHtml = caps.length ? '
' + caps.join(' ') + '
' : ''; const tip = m.description ? ' title="' + esc(m.description) + '"' : ''; - return '
' + esc(m.id) + '
' + esc(m.name || '') + '
' + capHtml + '' + + return '
' + esc(m.id) + '' + mdAvailabilityTag(m) + '
' + esc(m.name || '') + '
' + capHtml + '' + '' + rateCell(m) + '' + '' + (m.default_effort ? '' + esc(m.default_effort) + '' : '—') + '' + '' + effs + '' + @@ -758,7 +861,7 @@ for (const [id, key] of [['mdRealm', 'realm'], ['mdCap', 'cap'], ['mdEffort', 'e el.onchange = () => { mdFilter[key] = el.value; renderModels(); }; } $('mdReset').onclick = resetModelFilter; -$('btnModels').onclick = loadModels; +$('btnModels').onclick = () => loadModels(true); /* ── 日志(频道:全部/任务/对话/系统) ─────────────────────────────── */ let logCh = 'all'; @@ -961,6 +1064,10 @@ $('btnLogPin').onclick = () => { /* ── 配置 ─────────────────────────────────────────────────────────── */ const CFG_MAP = { + strategy_domestic: ['pool', 'strategy', 'domestic'], + strategy_international: ['pool', 'strategy', 'international'], + domestic_account_order: ['pool', 'account_order', 'domestic'], + international_account_order: ['pool', 'account_order', 'international'], listen: ['listen'], api_key: ['api_key'], package_detail_limit: ['panel', 'package_detail_limit'], checkin_hours: ['schedule', 'checkin_hours'], checkin_enabled: ['schedule', 'checkin_enabled'], growth_hours: ['schedule', 'growth_hours'], growth_enabled: ['schedule', 'growth_enabled'], @@ -968,6 +1075,7 @@ const CFG_MAP = { activity_hours: ['schedule', 'activity_hours'], activity_enabled: ['schedule', 'activity_enabled'], keepalive_hours: ['schedule', 'keepalive_hours'], keepalive_enabled: ['schedule', 'keepalive_enabled'], balance_refresh_enabled: ['schedule', 'balance_refresh_enabled'], balance_refresh_minutes: ['schedule', 'balance_refresh_minutes'], + task_sweep_enabled: ['schedule', 'task_sweep_enabled'], task_sweep_minutes: ['schedule', 'task_sweep_minutes'], include_disabled_in_tasks: ['schedule', 'include_disabled_in_tasks'], max_in_flight: ['pool', 'max_in_flight'], max_in_flight_global: ['pool', 'max_in_flight_global'], breaker_threshold: ['pool', 'breaker_threshold'], @@ -1019,7 +1127,7 @@ async function loadConfig() { const v = dig(cfgLoaded, path); if (el.type === 'checkbox') el.checked = !!v; else if (Array.isArray(v)) el.value = v.join(', '); - else el.value = v == null ? '' : v; + else el.value = v == null ? (name.startsWith('strategy_') ? 'weighted_random' : '') : v; } markDurationFields(); // 回填后重置校验态(清掉残留红框;现值来自后端必然合法) $('cfgNote').textContent = ''; @@ -1036,7 +1144,8 @@ function collectConfig() { else { const raw = el.value.trim(); // 覆盖型字段空串照发(见 CLEARABLE_CFG);其余空 = 不下发。 - if (raw === '') v = CLEARABLE_CFG.has(name) ? '' : undefined; + if (name.endsWith('_account_order')) v = raw.split(/[,,\s]+/).filter(Boolean); + else if (raw === '') v = CLEARABLE_CFG.has(name) ? '' : undefined; else if (name.endsWith('_hours')) v = raw.split(/[,,\s]+/).filter(Boolean).map(Number); else v = raw; } @@ -1112,6 +1221,7 @@ function openAdd() { $('addLoad').hidden = true; $('addReady').hidden = true; $('addDone').hidden = true; $('addErr').hidden = true; $('importDone').hidden = true; $('importErr').hidden = true; + $('apiKeyDone').hidden = true; $('apiKeyErr').hidden = true; $('btnCopyUrl').hidden = true; $('btnOpenUrl').hidden = true; $('btnStartLogin').hidden = false; $('btnStartLogin').disabled = false; stopPoll(); @@ -1120,6 +1230,11 @@ function switchAddTab(tab) { document.querySelectorAll('#addTabs .tab').forEach(b => b.classList.toggle('on', b.dataset.tab === tab)); $('addTabLogin').hidden = tab !== 'login'; $('addTabImport').hidden = tab !== 'import'; + $('addTabApiKey').hidden = tab !== 'apikey'; + // 页脚按钮随标签切:登录页显示「获取授权链接」(登录进行中由 startAddLogin 隐藏), + // API Key 页显示「添加 API Key」;导入页无页脚按钮。 + $('btnAddApiKey').hidden = tab !== 'apikey'; + $('btnStartLogin').hidden = tab !== 'login' || loginState != null; } document.querySelectorAll('#addTabs .tab').forEach(b => { b.onclick = () => switchAddTab(b.dataset.tab); @@ -1168,6 +1283,30 @@ $('btnStartLogin').onclick = startAddLogin; $('btnOpenUrl').onclick = () => open($('addUrl').textContent, '_blank'); $('btnCopyUrl').onclick = () => navigator.clipboard.writeText($('addUrl').textContent) .then(() => toast('链接已复制', 'ok'), () => toast('复制失败,请手动选择复制', 'err')); +$('btnAddApiKey').onclick = async () => { + const token = ($('apiKeyInput').value || '').trim(); + $('apiKeyDone').hidden = true; $('apiKeyErr').hidden = true; + if (!token) { $('apiKeyErr').hidden = false; $('apiKeyErr').textContent = '请填写 API Key'; return; } + const btn = $('btnAddApiKey'); + btn.disabled = true; + try { + const r = await api('accounts/add_apikey', { + method: 'POST', + body: JSON.stringify({ + access_token: token, + nickname: ($('apiKeyNickname').value || '').trim(), + uid: ($('apiKeyUid').value || '').trim(), + }), + }); + $('apiKeyDone').hidden = false; + $('apiKeyDone').textContent = '已添加 ' + (r.nickname || r.uid) + '(API Key 账号),账号已载入池中'; + $('apiKeyInput').value = ''; + setTimeout(() => { closeAdd(); loadOverview(true); }, 1600); + } catch (e) { + $('apiKeyErr').hidden = false; + $('apiKeyErr').textContent = '添加失败:' + e.message; + } finally { btn.disabled = false; } +}; $('importFile').onchange = async () => { const file = $('importFile').files[0]; if (!file) return; @@ -1208,21 +1347,207 @@ $('btnRefresh').onclick = async () => { if (view === 'logs') loadLogs(); }; +/* ── 官方补偿(issue #189)───────────────────────────────────────── + 「查状态 → 待领才领」。 + + 取数与呈现刻意分开: + - 取数只有手动触发(进页面一次 + 「检查」/「领取」后)。补偿状态没有 + overview 快照可吃,必须逐号打上游,挂进 5s 轮询就是每 5 秒轰炸上游。 + - 呈现走 compCache 的本地重渲染(refreshVisible 每轮调一次),只为让 + 倒计时走动 —— 不发任何请求。 + 之前这里有个「5 分钟新鲜窗口」分支,但它唯一的非强制调用点是 start(), + 而那里 compCache 必为 null,所以该分支永远不会命中:注释宣称「防轰炸」, + 实际是「根本不刷新」。现已删除,别再加回来。 + + 声明位置在本段(轮询区之前)是必需的:start() 会调用 loadCompensation(), + 若这些 let 写在 start() 之后,顶层求值会在 TDZ 上炸—— + TestAppJSTopLevelSmoke 正是为这类问题设的闸门。 */ +let compCache = null; +let compInFlight = null; // 在途请求的 promise(非布尔标志:布尔标志配 force 刷新会静默空转) + +// compPending 挑出「可领未领」的账号。 +// 纯函数:判据独立于渲染,node 侧单测单独钉住。 +// 判据与后端 upstream.CompensationStatus.Pending() 对齐——两端各留一份实现是 +// 有意为之(前端要在渲染前先算总数),但**必须同步**,改一处就要改另一处。 +function compPending(rows) { + return (rows || []).filter(r => r && !r.skip && !r.error && r.status && + r.status.active && !r.status.claimed && (r.status.credit_num || 0) > 0); +} + +// compErrored 挑出「查询失败」的账号。 +// 单独拎出来,是因为它们**必须显形**:上游 5xx / token 过期时所有账号都会落到 +// 这里,若和「没有补偿」一起被过滤掉,卡片就会整块消失,用户看到的是「什么都没有」 +// ——而真相是「一个都没查成」。这正是本 PR 要消灭的静默失败。 +function compErrored(rows) { + return (rows || []).filter(r => r && r.error); +} + +// compTotalPending 待领总额。 +function compTotalPending(rows) { + return compPending(rows).reduce((s, r) => s + (r.status.credit_num || 0), 0); +} + +// compDeadlineText 领取截止倒计时文案。 +// 上游 end_time 形如 "2026-10-22 23:59:59" 且**不带时区**,按浏览器本地时区解析 +// (与后端积分到期的处理同口径)。urgent=true 表示距截止 <=48h,前端据此染色告警。 +function compDeadlineText(endTime, now) { + const raw = String(endTime == null ? '' : endTime).trim(); + if (!raw) return { text: '无截止信息', urgent: false }; + const t = new Date(raw.replace(' ', 'T')).getTime(); + if (!isFinite(t)) return { text: raw, urgent: false }; + const ms = t - (now || Date.now()); + if (ms <= 0) return { text: '已截止', urgent: true }; + const d = Math.floor(ms / 86400000); + const h = Math.floor(ms % 86400000 / 3600000); + const text = d > 0 ? (d + ' 天 ' + h + ' 小时') : (Math.max(1, h) + ' 小时'); + return { text: '剩 ' + text, urgent: ms <= 2 * 86400000 }; +} + +function renderCompensation(d) { + const rows = (d && d.accounts) || []; + const box = $('compBox'); + if (!box) return; + const active = rows.filter(r => r && !r.error && r.status && r.status.active); + const failed = compErrored(rows); + // 「有失败就必须显形」——哪怕一条有效数据都没有。 + if (!active.length && !failed.length) { box.hidden = true; return; } + box.hidden = false; + + const now = Date.now(); + const okRows = active.map(r => { + const s = r.status || {}; + const done = !!s.claimed; + const dl = compDeadlineText(s.end_time, now); + const name = esc(r.nickname || String(r.uid).slice(0, 8)); + return '
' + name + '' + + (done + ? '已领取' + : '待领取' + + '' + esc(dl.text) + '') + + '' + esc(fmtTok(s.credit_num || 0)) + ' 积分' + + (done ? '' : '') + + '
'; + }).join(''); + // err 文本来自上游响应体,是外部可控字符串,必须 esc。 + const errRows = failed.map(r => + '
' + esc(r.nickname || String(r.uid).slice(0, 8)) + '' + + '查询失败' + + '' + esc(String(r.error).slice(0, 120)) + '
' + ).join(''); + + $('compList').innerHTML = okRows + errRows; + + const n = compPending(rows).length; + let note = n ? (n + ' 个账号待领 · 共 ' + fmtTok(compTotalPending(rows)) + ' 积分') : '当前无待领补偿'; + // 混合态下也要把失败数说出来,否则 note 仍读作「没事」。 + if (failed.length) note += ' · ' + failed.length + ' 个账号查询失败'; + $('compNote').textContent = note; +} + +// renderCachedCompensation 从缓存重渲染(不发请求),让倒计时走动。 +// 由 refreshVisible 每轮调用;无缓存(还没查过)时什么都不做。 +function renderCachedCompensation() { + if (compCache) renderCompensation(compCache); +} + +async function loadCompensation() { + // 已有在途请求就等它跑完,而不是静默返回——领取后紧接着的刷新必须能看到新状态, + // 否则用户点了「领取」但列表纹丝不动。 + if (compInFlight) { await compInFlight; return; } + compInFlight = (async () => { + try { + const d = await api('compensation'); + compCache = d; + renderCompensation(d); + } catch (e) { + const box = $('compBox'); + if (box) box.hidden = false; + if ($('compList')) $('compList').innerHTML = + '
查询失败:' + esc(e.message) + '
'; + if ($('compNote')) $('compNote').textContent = ''; + } + })(); + try { + await compInFlight; + } finally { + compInFlight = null; // 必须 finally:否则一次异常就让此后所有刷新永久失效 + } +} + +// 领取按钮走事件委托:列表每次重渲染都整块换 innerHTML,绑在具体节点上必丢。 +if ($('compList')) $('compList').addEventListener('click', async e => { + const b = e.target.closest('[data-comp-uid]'); + if (!b) return; + b.disabled = true; + try { + const r = await api('accounts/' + encodeURIComponent(b.dataset.compUid) + '/compensation', { method: 'POST' }); + const res = r.result || {}; + if (res.claimed) toast('已领取 ' + fmtTok(res.credit || 0) + ' 积分', 'ok'); + else if (res.error) toast('查询失败,未能确认是否有补偿', 'err'); + else if (res.skip) toast(res.skip_msg || '无需领取', ''); + else toast('领取失败:未知结果', 'err'); + await loadCompensation(); + loadOverview(true); + } catch (err) { + toast('领取失败:' + err.message, 'err'); + } +}); + +if ($('btnCompRefresh')) $('btnCompRefresh').onclick = () => loadCompensation(); + +if ($('btnCompClaimAll')) $('btnCompClaimAll').onclick = async () => { + const b = $('btnCompClaimAll'); + b.disabled = true; + try { + const r = await api('compensation_all', { method: 'POST' }); + const s = r.summary || {}; + // 顺序要紧:先看有没有账号没拿到结论,再看有没有领到。 + // 只按 claimed 判定的话,「全部查询失败」会被说成「当前没有待领补偿」, + // 把故障说成了「没活动」——比不说更糟。 + if (s.errored) { + toast(s.errored + ' 个账号查询失败,未能确认是否有补偿', 'err'); + } else if (s.claimed) { + toast('已领取 ' + s.claimed + ' 个账号,共 ' + fmtTok(s.credit || 0) + ' 积分', 'ok'); + } else { + toast('当前没有待领补偿', ''); + } + await loadCompensation(); + loadOverview(true); + } catch (e) { + toast('领取失败:' + e.message, 'err'); + } finally { + b.disabled = false; + } +}; + /* ── 轮询 ─────────────────────────────────────────────────────────── */ function refreshVisible() { - if (view === 'accounts') loadOverview(true); + if (view === 'accounts') { loadOverview(true); renderCachedCompensation(); } else if (view === 'logs') loadLogs(); else if (view === 'taskscenter') reattachQueueView(); } function start() { loadOverview(true); + loadCompensation(); // 补偿状态:首次进页面查一次(之后靠新鲜窗口) if (refTimer) clearInterval(refTimer); refTimer = setInterval(refreshVisible, 5000); checkAuthGate(); } async function checkAuthGate() { + // 401 必须在**任何**静默路径上把密钥对话框弹出来:改完 config.json 的 + // api_key 并重启后,本机 localStorage 里还是旧 key,首次 overview 请求 + // 401 —— 但 loadOverview(quiet=true) 会吞掉错误、checkAuthGate 又把它 + // 静默 return,于是「密钥已改」表现为「面板还在用旧登录态」(issue #155)。 + // 这里显式识别 401 并弹框 + 提示重新输入,兼顾 quiet 与非 quiet 两条路径。 try { await api('overview'); } - catch (e) { if (String(e.message).includes('密钥') || String(e.message).includes('api_key')) return; } + catch (e) { + const msg = String(e.message || ''); + if (msg.includes('密钥') || msg.includes('api_key')) { + openKey(); + toast('密钥已变更或无效,请重新输入面板密钥', 'err'); + return; + } + } } start(); @@ -1857,10 +2182,12 @@ function usPct(part, total) { /* usRow 生成一行。mid 是插在「名称」之后、请求数之前的额外单元格(如「域」列)。 withPerf 控制延迟/速率两列;列开关显式传入,避免调用方改动后与表头错列。 */ -function usRow(name, sub, a, mid, withPerf) { +/* nameHtml 非空时替代 esc(name)(已转义的富文本,见 remarkName):账号维度的第一列 + 需要把备注渲染成主标识、uid8 作为浅色虚体跟随,其余维度仍走纯文本转义。 */ +function usRow(name, sub, a, mid, withPerf, nameHtml) { return '' + '' + - '' + esc(name) + (sub ? '
' + esc(sub) + '
' : '') + '' + + '' + (nameHtml || esc(name)) + (sub ? '
' + esc(sub) + '
' : '') + '' + (mid || '') + '' + fmtTok(a.requests) + '' + '' + (a.errors ? '' + fmtTok(a.errors) + '' : '—') + '' + @@ -1928,13 +2255,16 @@ function renderUsageDim() { realm: (d.by_realm || []).length, }); $('usDimHead').innerHTML = usDimHead(usDim); - $('usDimBody').innerHTML = rows.map(x => - usRow(usDim === 'account' ? String(x.key || '').slice(0, 8) : x.key, - usDim === 'account' ? (x.extra || '') : '', + $('usDimBody').innerHTML = rows.map(x => { + const isAcct = usDim === 'account'; + const key = isAcct ? String(x.key || '').slice(0, 8) : x.key; + return usRow(key, + isAcct ? (x.extra || '') : '', x, - usDim === 'account' ? '' + esc(x.realm || '') + '' : '', - m.withPerf) - ).join('') || '暂无数据'; + isAcct ? '' + esc(x.realm || '') + '' : '', + m.withPerf, + isAcct ? remarkName(x.key, esc(key)) : ''); + }).join('') || '暂无数据'; $('usDimNote').textContent = rows.length + ' 行 · 请求数含失败尝试'; } @@ -1985,7 +2315,7 @@ function renderCreditDim() { const account = row.nickname || uid.slice(0, 8) || '—'; return '' + '' + - '' + esc(account) + '
' + esc(row.realm || '') + ' · ' + esc(uid.slice(0, 8)) + '
' + + '' + remarkName(uid, esc(account)) + '
' + esc(row.realm || '') + ' · ' + esc(uid.slice(0, 8)) + '
' + '' + fmtTok(row.requests) + '' + '' + fmtCredit(row.credits) + '' + '' + fmtTok(row.credit_tokens) + '' + @@ -2774,8 +3104,11 @@ function expDaysLeft(dateStr, today) { return Math.round((new Date(dateStr + 'T00:00:00') - today) / 86400000); } -function renderExpiry(d) { - const list = (d.accounts || []); +// expiryRowsHtml 到期提醒的逐账号行渲染。**「检查」与首页快照共用这一个函数**, +// 保证两条数据路径(逐包明细 / state 里的按日批次)输出完全一致:排序、今天/明天 +// 文案、日均需耗、7 天内合计、随后批次都只有一份实现。 +// list 每项形态:{ uid, nickname, packages:[{end_time, remain}], error? }。 +function expiryRowsHtml(list) { const today = new Date(); today.setHours(0, 0, 0, 0); // 按「最近到期」升序排(issue #125)。 // @@ -2795,21 +3128,29 @@ function renderExpiry(d) { return { a, key }; }); keyed.sort((x, y) => { - if (x.key === y.key) return 0; - if (x.key === null) return 1; - if (y.key === null) return -1; - return x.key < y.key ? -1 : 1; + if (x.key !== y.key) { + if (x.key === null) return 1; + if (y.key === null) return -1; + return x.key < y.key ? -1 : 1; + } + if (x.key === null) return 0; + // 同日到期的账号按昵称/uid 兜底排序:两条数据路径(检查的余额降序、 + // overview 的 uid 升序)输入顺序不同,加固定 tie-break 才能保证输出一致。 + const ax = String(x.a.nickname || x.a.uid || ''); + const ay = String(y.a.nickname || y.a.uid || ''); + return ax < ay ? -1 : ax > ay ? 1 : 0; }); - const rows = keyed.map(({ a }) => { + return keyed.map(({ a }) => { + const name = a.nickname || String(a.uid || '').slice(0, 8); if (a.error) { return '
' + - '' + esc(a.nickname || a.uid.slice(0, 8)) + '' + + '' + esc(name) + '' + '查询失败:' + esc(a.error) + '
'; } const bs = expBatches(a.packages).filter(b => expDaysLeft(b.date, today) >= 0); if (!bs.length) { return '
' + - '' + esc(a.nickname || a.uid.slice(0, 8)) + '' + + '' + esc(name) + '' + '7 天内无到期积分
'; } const first = bs[0]; @@ -2826,7 +3167,7 @@ function renderExpiry(d) { const rest = bs.slice(1, 4).map(b => '随后 ' + esc(b.date.slice(5)) + ' · ' + fmtTok(b.remain)).join(' ') + more; return '
' + - '' + esc(a.nickname || a.uid.slice(0, 8)) + '' + + '' + esc(name) + '' + '最近到期 ' + esc(first.date) + '(' + dayWord + ')· 该批 ' + fmtTok(first.remain) + ' 积分 · 到期前日均需耗 ≥' + fmtTok(daily) + '' + @@ -2834,6 +3175,11 @@ function renderExpiry(d) { (rest ? '
' + rest + '
' : '') + '
'; }).join(''); +} + +function renderExpiry(d) { + const list = (d.accounts || []); + const rows = expiryRowsHtml(list); $('expList').innerHTML = rows || '
没有账号
'; // 数据新鲜度透明化:走缓存时标注年龄,免得把旧数据误当实时。 const ageMin = lastPackages ? Math.floor((Date.now() - lastPackagesAt) / 60000) : 0; @@ -2843,6 +3189,112 @@ function renderExpiry(d) { $('expBox').hidden = false; } +/* 快照版到期提醒:不查上游,直接用 /overview 里的 credits_expiry_buckets。 + 后端每次余额刷新(默认每 5 分钟)按自然日聚合并写入 state.json,口径与 + 「检查」的逐包按日汇总完全相同;这里把批次还原成 packages 形态后直接复用 + expiryRowsHtml,因此排序/今天明天/日均需耗/7 天内合计/随后批次全部一致。 + + 旧 state(升级后尚未跑过一轮余额刷新,或 Redis 快照来自旧版本)没有 buckets, + 退回只显示最早一批,保证卡片不空白;下一轮刷新后自动变成完整视图。 */ +function renderExpirySnapshot(d) { + const list = (d && d.accounts) || []; + const hasBuckets = list.some(a => Array.isArray(a.credits_expiry_buckets) && a.credits_expiry_buckets.length); + if (hasBuckets) { + const isoDay = v => { + const dt = new Date(v); + if (isNaN(dt.getTime())) return ''; + const p2 = n => String(n).padStart(2, '0'); + return dt.getFullYear() + '-' + p2(dt.getMonth() + 1) + '-' + p2(dt.getDate()); + }; + // 逐账号取批次;升级过渡期/单号刷新失败的账号可能还没有 buckets,用旧的 + // 「最早一批」字段兜底,避免在同一次渲染里被误报成「无到期」。 + const packagesOf = a => { + const bs = a.credits_expiry_buckets || []; + if (bs.length) { + return bs.map(b => ({ end_time: (b.date || '') + 'T00:00:00', remain: b.remain })); + } + const day = isoDay(a.credits_earliest_expiry); + return (day && a.credits_earliest_remaining) + ? [{ end_time: day + 'T00:00:00', remain: a.credits_earliest_remaining }] + : []; + }; + renderExpiry({ + accounts: list.map(a => ({ + uid: a.uid, + nickname: a.nickname, + realm: a.realm, + packages: packagesOf(a), + })), + }); + $('expNote').textContent = snapshotNoteText(list.length, d); + return; + } + // 旧 state 兜底:只有最早一批的日期与数量。 + const today = new Date(); today.setHours(0, 0, 0, 0); + const keyed = list.map(a => { + // 后端给的是 RFC3339 带时区;无到期信息(长期包/未刷新)的账号排在最后。 + let key = null, ms = 0; + if (a.credits_earliest_expiry) { + const t = new Date(a.credits_earliest_expiry); + if (!isNaN(t.getTime())) { ms = t.getTime(); key = ms; } + } + return { a, key, ms }; + }); + keyed.sort((x, y) => { + if (x.key === null && y.key === null) return 0; + if (x.key === null) return 1; + if (y.key === null) return -1; + return x.key - y.key; + }); + const rows = keyed.map(({ a, ms }) => { + const nm = esc(a.nickname || String(a.uid || '').slice(0, 8)); + if (!ms || !a.credits_earliest_remaining) { + return '
' + + '' + nm + '' + + '无近期到期批次
'; + } + // 与 expDaysLeft 同口径:按本地自然日算,而不是对精确时间戳 ceil(后者会把 + // 明天/今天各多算一天,日均需耗也随之少一半)。 + const dt = new Date(ms); + const p2 = n => String(n).padStart(2, '0'); + const when = dt.getFullYear() + '-' + p2(dt.getMonth() + 1) + '-' + p2(dt.getDate()); + const days = Math.max(0, Math.round((new Date(when + 'T00:00:00') - today) / 86400000)); + const remain = Number(a.credits_earliest_remaining || 0); + const daily = Math.ceil(remain / Math.max(1, days)); + const cls = days <= 3 ? 'var(--bad)' : days <= 7 ? 'var(--warn)' : 'var(--ok)'; + const dayWord = days === 0 ? '今天到期' : days === 1 ? '明天到期' : days + ' 天后到期'; + return '
' + + '' + nm + '' + + '最近到期 ' + esc(when) + '(' + dayWord + + ')· 该批 ' + fmtTok(remain) + ' 积分 · 到期前日均需耗 ≥' + + fmtTok(daily) + '' + + '
仅最早一批,点「检查」看全部到期批次
' + + '
'; + }).join(''); + $('expList').innerHTML = rows || '
没有账号
'; + $('expNote').textContent = snapshotNoteText(list.length, d); + $('expBox').hidden = false; +} + +// snapshotNoteText 快照卡片的数据新鲜度标注。界面本身 5 秒轮询一次,但卡片数据 +// 只随余额后台刷新更新(默认每 5 分钟)——不标出来的话,5 秒级的界面刷新会让人 +// 误以为数据也是 5 秒级新鲜。放在 renderExpirySnapshot 之后:前端 harness 按 +// 「renderExpirySnapshot 起」切片求值,函数声明提升让它在同一切片内可用。 +function snapshotNoteText(count, d) { + const iv = Number(d && d.balance_refresh_sec) || 0; + const at = d && d.balance_refreshed_at ? new Date(d.balance_refreshed_at).getTime() : 0; + if (iv <= 0) { + return count + ' 个账号 · 快照(来自本地状态 · 未开启后台刷新,点「检查」拉实时明细)'; + } + const every = iv % 60 === 0 ? iv / 60 + ' 分钟' : iv + ' 秒'; + let tail = '每 ' + every + '后台刷新'; + if (at && !isNaN(at)) { + const min = Math.floor((Date.now() - at) / 60000); + tail += ',' + (min < 1 ? '刚刚更新' : min + ' 分钟前更新'); + } + return count + ' 个账号 · 快照(' + tail + ',点「检查」拉实时明细)'; +} + async function loadExpiry(force) { if (!$('expBox')) return; if (expFetching) return; @@ -2866,3 +3318,90 @@ async function loadExpiry(force) { if ($('btnExp')) $('btnExp').onclick = () => loadExpiry(true); if ($('btnPk')) $('btnPk').onclick = loadPackages; +/* ══ 账号备注(只写本机 localStorage;账号池 / 用量两个视图共用同一份映射)══════ + 键取 uid 前 8 位——与 renderUsageDim 账号维度的 key 和积分扣除表的 uid 前缀同源, + 三个表格因此都能直接 join。展示层只替换文本并挂 .rm-ghost 虚体,账号标识本身 + 仍由服务端下发,不写回后端;备注是纯展示层信息,不参与选号 / 计费 / 鉴权。 */ +function remarksAll() { + if (remarksCache) return remarksCache; + try { remarksCache = JSON.parse(localStorage.getItem(LS_REMARKS)) || {}; } + catch (e) { remarksCache = {}; } + return remarksCache; +} +function remarkKey(uid) { return String(uid == null ? '' : uid).slice(0, 8); } +function remarkOf(uid) { + const k = remarkKey(uid); + return k ? String(remarksAll()[k] || '') : ''; +} +/* nameHtml 必须是已转义的 HTML;有备注时返回「备注 + 原标识(浅色虚体)」 */ +function remarkName(uid, nameHtml) { + const r = remarkOf(uid); + return r ? esc(r) + '' + nameHtml + '' : nameHtml; +} +function storeRemarks(map) { + remarksCache = map; + try { localStorage.setItem(LS_REMARKS, JSON.stringify(map)); } catch (e) {} +} + +/* 编辑器的行来自 overview 缓存(账号池加载过就有),另外补上「已有备注但当前列表里 + 没有」的键,避免账号被移除后备注看起来凭空消失。 */ +function remarkFormRows() { + const rows = ((overviewData && overviewData.accounts) || []) + .map(s => ({ uid: remarkKey(s.uid), who: s.nickname || remarkKey(s.uid) })) + .filter(r => r.uid); + for (const k of Object.keys(remarksAll())) { + if (!rows.some(r => r.uid === k)) rows.push({ uid: k, who: k }); + } + return rows; +} +function renderRemarkBody() { + const box = $('remarkBody'); + if (!box) return; + const rows = remarkFormRows(); + box.innerHTML = rows.length + ? rows.map(r => '
' + esc(r.who) + '' + + '
').join('') + : '
暂无账号:先在「账号池」加载一次账号列表。
'; +} +function openRemarks() { + renderRemarkBody(); + if ($('remarkVeil')) $('remarkVeil').classList.add('on'); +} +function closeRemarks() { if ($('remarkVeil')) $('remarkVeil').classList.remove('on'); } +function saveRemarkForm() { + const map = {}; + document.querySelectorAll('#remarkBody input[data-uid]').forEach(inp => { + const v = inp.value.trim(); + if (v) map[inp.getAttribute('data-uid')] = v; + }); + storeRemarks(map); + closeRemarks(); + // 备注只影响本机展示:重渲染当前视图即可,无需后端参与。 + if (view === 'accounts') loadOverview(true); + else if (view === 'usage' && usageData) renderUsage(usageData); + toast('备注已保存(仅本机浏览器)'); +} +if ($('btnRemarks')) $('btnRemarks').onclick = openRemarks; +if ($('btnRemarkClose')) $('btnRemarkClose').onclick = closeRemarks; +if ($('btnRemarkClear')) $('btnRemarkClear').onclick = () => { storeRemarks({}); renderRemarkBody(); }; +if ($('btnRemarkSave')) $('btnRemarkSave').onclick = saveRemarkForm; + +/* ══ 首页「积分到期提醒」折叠(选择记在本机,默认展开)═════════════════════ + 展开态是原行为:点「检查」就要能立刻看到结果,所以默认不折叠;用户收起一次后 + 由 localStorage 记住。折叠只作用于列表容器,卡片标题与「检查」按钮始终可见。 */ +function expFolded() { return localStorage.getItem(LS_EXP_FOLD) === '1'; } +function applyExpFold() { + const pad = $('expPad'), btn = $('btnExpFold'); + if (!pad || !btn) return; + const folded = expFolded(); + pad.hidden = folded; + btn.textContent = folded ? '展开' : '收起'; + btn.setAttribute('aria-expanded', folded ? 'false' : 'true'); +} +if ($('btnExpFold')) { + $('btnExpFold').onclick = () => { + try { localStorage.setItem(LS_EXP_FOLD, expFolded() ? '0' : '1'); } catch (e) {} + applyExpFold(); + }; +} +applyExpFold(); diff --git a/internal/panel/autotask.go b/internal/panel/autotask.go index bfe1a2f8..2b6708a1 100644 --- a/internal/panel/autotask.go +++ b/internal/panel/autotask.go @@ -215,6 +215,15 @@ var mpTaskCodes = map[string]bool{ // isMPTaskCode 报告任务是否小程序口径专属(决定回读/接受/领奖走 mp 变体)。 func isMPTaskCode(code string) bool { return mpTaskCodes[code] } +// claimTaskReward 按任务码口径领奖:mp 码走 chat 域 mp 端点,其余走 Web 端。 +// claim 幂等(重复领返回 already_claimed → 0 分 0 能)。 +func claimTaskReward(p *Panel, a *auth.Auth, code string) (credit, energy int64, err error) { + if isMPTaskCode(code) { + return p.cfg.Upstream.ClaimRewardMP(a, code) + } + return p.cfg.Upstream.ClaimReward(a, code) +} + // taskByCode 拉取任务列表并定位单个任务;未找到返回 nil(不视为错误)。 // 双口径:mp 专属任务在默认列表查不到,自动回落 mp 列表(仅对已登记的 mp 码, // 未知码不多打一次上游)。 @@ -622,6 +631,15 @@ func (p *Panel) accountTaskAuto(w http.ResponseWriter, r *http.Request) { if a == nil { return } + // 企业上下文门控(企业版 / 企业切换会话 / 静态 API Key 账号):行为事件会被上游 + // 静默丢弃或直接 403,执行任何成长任务都是零收益白跑(含真实对话配额)——直接 + // 拒绝并给出修复路径,不再让用户对着「已上报但进度 0/x」猜原因 + // (见 auth.SupportsBehaviorEvents / auth.IsEnterpriseSession)。 + if !a.SupportsBehaviorEvents() { + writeErr(w, http.StatusUnprocessableEntity, + "该账号为企业版/企业切换会话(token_source=enterprise_switch)或 API Key 账号,无个人成长体系,成长任务行为事件会被上游忽略;请移除账号后以个人身份重新登录") + return + } var body struct { TaskCode string `json:"task_code"` } @@ -1182,7 +1200,29 @@ func (p *Panel) runAutoAll(a *auth.Auth) []map[string]any { } if before.Claimed || before.Current >= before.Target && before.Target > 0 { item["status"] = "skipped" - item["message"] = "已完成(" + taskProgressText(before) + ")" + msg := "已完成(" + taskProgressText(before) + ")" + // 达标未领:先补领再跳过(issue #174)。经真实对话等链路点亮的 + // 任务不经过任何自动动作,若这里只 skip,奖永远没人领(积分卡在 + // 半路)。claim 幂等(重复领返回 already_claimed → 0 分 0 能), + // 宁可多试一轮也不漏领;判据用 progress(Current>=Target),不 + // 依赖 accept_status=="completed"(回读会短暂转 completed 后回滚, + // 据此领奖会误领)。 + if !before.Claimed { + credit, energy, cerr := claimTaskReward(p, a, act.TaskCode) + switch { + case cerr != nil: + item["claim_error"] = cerr.Error() + msg += ";补领失败(可在列表手动重试)" + case credit > 0 || energy > 0: + item["claimed"] = true + item["credit"] = credit + item["energy"] = energy + msg += fmt.Sprintf(";已补领 +%d 分 +%d 能", credit, energy) + default: + msg += ";奖励此前已领取" + } + } + item["message"] = msg out = append(out, item) continue } @@ -1206,13 +1246,7 @@ func (p *Panel) runAutoAll(a *auth.Auth) []map[string]any { // 领奖失败不掩盖主流程结果:status 仍为 done,附加 claim_error 供前端提示。 if after != nil && after.Claimable { item["claimable"] = true - var credit, energy int64 - var cerr error - if isMPTaskCode(act.TaskCode) { - credit, energy, cerr = p.cfg.Upstream.ClaimRewardMP(a, act.TaskCode) - } else { - credit, energy, cerr = p.cfg.Upstream.ClaimReward(a, act.TaskCode) - } + credit, energy, cerr := claimTaskReward(p, a, act.TaskCode) if cerr == nil { item["claimed"] = true item["credit"] = credit @@ -1240,6 +1274,14 @@ func (p *Panel) accountTaskAutoAll(w http.ResponseWriter, r *http.Request) { if a == nil { return } + // 企业上下文门控:与 accountTaskAuto 同口径(企业版 / 企业切换会话 / API Key + // 账号下成长任务行为事件被上游静默丢弃,跑全量只会白耗真实对话配额)。 + // 「一键完成全部」与单任务入口共用同一套动作,必须一并挡上。 + if !a.SupportsBehaviorEvents() { + writeErr(w, http.StatusUnprocessableEntity, + "该账号为企业版/企业切换会话(token_source=enterprise_switch)或 API Key 账号,无个人成长体系,成长任务行为事件会被上游忽略;请移除账号后以个人身份重新登录") + return + } // per-account 互斥(与单任务动作共用一把锁):重复点击 409。 if !p.tryLockAccount(uid) { writeErr(w, http.StatusConflict, "该账号有任务动作正在执行中,请等本轮结束后再试") diff --git a/internal/panel/autotask_claim_test.go b/internal/panel/autotask_claim_test.go new file mode 100644 index 00000000..d34d409c --- /dev/null +++ b/internal/panel/autotask_claim_test.go @@ -0,0 +1,113 @@ +package panel + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" + "github.com/linguo2625469/workbuddy2api-panel/internal/upstream" +) + +// TestRunAutoAllClaimsDoneButUnclaimed issue #174 顺带项:runAutoAll 遇到 +// 「已达标但未领取」的任务时,必须先把奖补领掉再标记 skipped——真实对话等 +// 链路点亮的任务不经过任何自动动作,若只 skip 不领,积分永远卡在半路。 +func TestRunAutoAllClaimsDoneButUnclaimed(t *testing.T) { + old := reportGap + reportGap = 0 // 项间节流在测试里无意义,避免拖慢 + t.Cleanup(func() { reportGap = old }) + + var claims int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/v2/activity/growth/tasks": + // 唯一存在且已达标(5/5)未完成领取的任务;accept_status=accepted, + // 因此阶段 0 不会再产生 accept 请求。 + json.NewEncoder(w).Encode(map[string]any{ + "code": 0, "msg": "OK", + "data": map[string]any{"tasks": []map[string]any{ + {"task_code": "chat_5", "title": "对话 5 次", "target": 5, "current": 5, + "accept_status": "accepted", "reward_credit": 100, "reward_energy": 5}, + }}, + }) + case strings.HasPrefix(r.URL.Path, "/activity/growth/tasks/") && strings.HasSuffix(r.URL.Path, "/claim"): + atomic.AddInt32(&claims, 1) + json.NewEncoder(w).Encode(map[string]any{ + "code": 0, "msg": "OK", + "data": map[string]any{"already_claimed": false, "credit": 100, "energy": 5}, + }) + default: + json.NewEncoder(w).Encode(map[string]any{"code": 0, "msg": "OK", "data": map[string]any{}}) + } + })) + defer srv.Close() + + p := &Panel{cfg: Config{Upstream: &upstream.Client{ + HTTP: srv.Client(), ChatBaseCN: srv.URL, BillingBaseCN: srv.URL, WebBaseCN: srv.URL, + }}} + out := p.runAutoAll(&auth.Auth{AccessToken: "at", UID: "u1"}) + + var hit map[string]any + for _, item := range out { + if item["task_code"] == "chat_5" { + hit = item + } + } + if hit == nil { + t.Fatalf("runAutoAll 未处理 chat_5,out=%v", out) + } + if got := atomic.LoadInt32(&claims); got != 1 { + t.Fatalf("claim 调用次数 = %d, want 1(达标未领必须补领)", got) + } + if hit["claimed"] != true { + t.Errorf("claimed = %v, want true;out=%+v", hit["claimed"], hit) + } + if hit["credit"] != int64(100) || hit["energy"] != int64(5) { + t.Errorf("credit/energy = %v/%v, want 100/5", hit["credit"], hit["energy"]) + } + // 仍是 skipped(进度早已达标),但文案要体现补领结果。 + if hit["status"] != "skipped" { + t.Errorf("status = %v, want skipped", hit["status"]) + } + if msg, _ := hit["message"].(string); !strings.Contains(msg, "已补领") { + t.Errorf("message = %q, 应含「已补领」", msg) + } +} + +// TestRunAutoAllSkipsClaimedTask 已领取的任务不再触发领奖请求(幂等入口不应 +// 无意义地打上游)。 +func TestRunAutoAllSkipsClaimedTask(t *testing.T) { + old := reportGap + reportGap = 0 + t.Cleanup(func() { reportGap = old }) + + var claims int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/v2/activity/growth/tasks": + json.NewEncoder(w).Encode(map[string]any{ + "code": 0, "msg": "OK", + "data": map[string]any{"tasks": []map[string]any{ + {"task_code": "chat_5", "target": 5, "current": 5, "accept_status": "claimed"}, + }}, + }) + case strings.HasSuffix(r.URL.Path, "/claim"): + atomic.AddInt32(&claims, 1) + json.NewEncoder(w).Encode(map[string]any{"code": 0, "msg": "OK", "data": map[string]any{}}) + default: + json.NewEncoder(w).Encode(map[string]any{"code": 0, "msg": "OK", "data": map[string]any{}}) + } + })) + defer srv.Close() + + p := &Panel{cfg: Config{Upstream: &upstream.Client{ + HTTP: srv.Client(), ChatBaseCN: srv.URL, BillingBaseCN: srv.URL, WebBaseCN: srv.URL, + }}} + p.runAutoAll(&auth.Auth{AccessToken: "at", UID: "u1"}) + if got := atomic.LoadInt32(&claims); got != 0 { + t.Fatalf("已领取任务不应再打领奖端点,got %d", got) + } +} diff --git a/internal/panel/compensation_frontend_test.go b/internal/panel/compensation_frontend_test.go new file mode 100644 index 00000000..520f21a2 --- /dev/null +++ b/internal/panel/compensation_frontend_test.go @@ -0,0 +1,285 @@ +package panel + +import ( + "os" + "os/exec" + "strings" + "testing" +) + +// 本文件覆盖「官方补偿」卡片(issue #189)的前端纯函数与渲染。 +// +// 为什么要有:compPending 是「能不能领」的唯一判据,compDeadlineText 决定用户 +// 会不会错过截止日,renderCompensation 决定**失败会不会显形**——三者都只在浏览器里 +// 生效,Go 侧不执行 JS,编译与后端测试对它们完全失明。判据写反或让错误静默隐藏, +// 都不报错、只能肉眼看,故在此用 node harness 钉死。 + +func TestAppJSCompPendingGate(t *testing.T) { + node, err := exec.LookPath("node") + if err != nil { + t.Skip("node not installed; compensation gate test skipped") + } + script := `const fs = require('fs'); +const vm = require('vm'); +const src = fs.readFileSync(process.argv[2], 'utf8'); +const start = src.indexOf('function compPending'); +const end = src.indexOf('function compDeadlineText'); +if (start < 0 || end < 0 || end < start) throw new Error('compensation region not found'); +const ctx = { Array, Object, Number, String }; +vm.createContext(ctx); +vm.runInContext(src.slice(start, end) + + '\nthis.compPending = compPending; this.compTotalPending = compTotalPending; this.compErrored = compErrored;', ctx); + +const rows = [ + { uid: 'a', status: { active: true, claimed: false, credit_num: 500 } }, // 待领 + { uid: 'b', status: { active: true, claimed: true, credit_num: 500 } }, // 已领 + { uid: 'c', status: { active: false, claimed: false, credit_num: 0 } }, // 无活动 + { uid: 'd', status: { active: true, claimed: false, credit_num: 0 } }, // 额度为 0 + // ↓ 下面两行是**判别力用例**:状态本身完全「待领」,只有 skip / error 能排除它们。 + // 若样本里没有 status(或 status 非 pending),r.status 子句会先行短路排除, + // 于是删掉 !r.skip / !r.error 本用例仍然通过——等于没测到那两条判据。 + { uid: 'e', skip: 'enterprise', skip_msg: '企业版', + status: { active: true, claimed: false, credit_num: 500 } }, + { uid: 'f', error: 'boom', + status: { active: true, claimed: false, credit_num: 500 } }, + null, +];process.stdout.write(JSON.stringify({ + uids: ctx.compPending(rows).map(r => r.uid), + total: ctx.compTotalPending(rows), + errored: ctx.compErrored(rows).map(r => r.uid), + emptyOk: ctx.compPending(null).length === 0 && ctx.compPending(undefined).length === 0, + dirtyOk: ctx.compPending([null, undefined]).length === 0, +}));` + f, err := os.CreateTemp(t.TempDir(), "compgate-*.cjs") + if err != nil { + t.Fatal(err) + } + if _, err := f.WriteString(script); err != nil { + t.Fatal(err) + } + f.Close() + out, err := exec.Command(node, f.Name(), "app.js").CombinedOutput() + if err != nil { + t.Fatalf("compensation gate node test failed: %v\n%s", err, out) + } + want := `{"uids":["a"],"total":500,"errored":["f"],"emptyOk":true,"dirtyOk":true}` + if strings.TrimSpace(string(out)) != want { + t.Fatalf("compensation gate=%s\nwant %s", strings.TrimSpace(string(out)), want) + } +} + +// TestAppJSCompGateMutationDiscriminates 反向证明:把 !r.skip / !r.error 两条判据 +// 从 app.js 副本里删掉,本用例必须 FAIL。 +// +// 这类「变异测试」是本文件存在的主要理由——正向断言全绿并不代表测到了东西, +// 若样本造得不好(比如被测行没有 status 字段),判据会被 r.status 短路吃掉, +// 删除判据照样全绿。review 时请连同 app.js 副本一起看。 +func TestAppJSCompGateMutationDiscriminates(t *testing.T) { + node, err := exec.LookPath("node") + if err != nil { + t.Skip("node not installed; compensation mutation test skipped") + } + mutants := map[string]struct{ from, to string }{ + "drop !r.skip": {"!r.skip && ", ""}, + "drop !r.error": {"!r.error && ", ""}, + "drop r.active": {"r.status.active && ", ""}, + "drop !claimed": {"!r.status.claimed && ", ""}, + "drop credit>0": {"(r.status.credit_num || 0) > 0", "true"}, + "drop whole status": {"r.status &&", "true &&"}, + } + for name, m := range mutants { + t.Run(name, func(t *testing.T) { + orig, err := os.ReadFile("app.js") + if err != nil { + t.Fatal(err) + } + mutated := strings.Replace(string(orig), m.from, m.to, 1) + if mutated == string(orig) { + t.Fatalf("变异未生效:app.js 里找不到 %q(判据已改写?)", m.from) + } + tmp := t.TempDir() + mutFile := tmp + "/app.js" + if err := os.WriteFile(mutFile, []byte(mutated), 0o600); err != nil { + t.Fatal(err) + } + script := `const fs = require('fs'); +const vm = require('vm'); +const src = fs.readFileSync(process.argv[2], 'utf8'); +const start = src.indexOf('function compPending'); +const end = src.indexOf('function compDeadlineText'); +const ctx = { Array, Object, Number, String }; +vm.createContext(ctx); +vm.runInContext(src.slice(start, end) + '\nthis.compPending = compPending;', ctx); +const rows = [ + { uid: 'a', status: { active: true, claimed: false, credit_num: 500 } }, // 唯一应保留 + { uid: 'g', status: { active: true, claimed: true, credit_num: 500 } }, // 删 !claimed 会漏进来 + { uid: 'h', status: { active: true, claimed: false, credit_num: 0 } }, // 删 credit>0 会漏进来 + { uid: 'i', status: { active: false, claimed: false, credit_num: 500 } }, // 删 active 会漏进来 + { uid: 'e', skip: 'enterprise', status: { active: true, claimed: false, credit_num: 500 } }, + { uid: 'f', error: 'boom', status: { active: true, claimed: false, credit_num: 500 } }, + // 完全没有 status 的脏行:r.status 守卫挡住它(否则 r.status.active 抛 TypeError, + // node 非零退出也算「变异被抓到」)。这条判据测的是崩溃安全而非过滤结果。 + { uid: 'k', nickname: '脏数据' }, +]; +process.stdout.write(JSON.stringify(ctx.compPending(rows).map(r => r.uid)));` + sf := tmp + "/m.cjs" + if err := os.WriteFile(sf, []byte(script), 0o600); err != nil { + t.Fatal(err) + } + out, err := exec.Command(node, sf, mutFile).CombinedOutput() + // 变异被「抓到」有两种形态:① 跑通了但输出变了(判据被删 → 多出/少了 + // 账号);② 直接崩了(如删掉 r.status 空值守卫后 TypeError)。两者都算 + // 有判别力;只有「跑通且输出与基线完全一致」才是假通过。 + if err != nil { + return // 崩溃 = 检出 + } + if got := strings.TrimSpace(string(out)); got == `["a"]` { + t.Fatalf("变异 %s 后 compPending 结果不变 = 本用例对该判据无判别力(假通过)", name) + } + }) + } +} + +func TestAppJSCompDeadlineText(t *testing.T) { + node, err := exec.LookPath("node") + if err != nil { + t.Skip("node not installed; compensation deadline test skipped") + } + script := `const fs = require('fs'); +const vm = require('vm'); +const src = fs.readFileSync(process.argv[2], 'utf8'); +const start = src.indexOf('function compDeadlineText'); +const end = src.indexOf('function renderCompensation'); +if (start < 0 || end < 0 || end < start) throw new Error('deadline region not found'); +const ctx = { Date, Math, Number, String, isFinite }; +vm.createContext(ctx); +vm.runInContext(src.slice(start, end) + '\nthis.f = compDeadlineText;', ctx); + +// 上游 end_time 是不带时区的 "YYYY-MM-DD HH:mm:ss",按本地时区格式化造输入, +// 这样用例在任何 TZ 下都自洽(写死日期会在换季/换区后自己失效)。 +const p = n => String(n).padStart(2, '0'); +const mkEnd = ms => { const d = new Date(ms); + return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + + p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds()); }; +const base = new Date(2026, 9, 22, 23, 59, 59).getTime(); // 2026-10-22 23:59:59 本地时 +const H = 3600000, D = 86400000; +process.stdout.write(JSON.stringify({ + six: ctx.f(mkEnd(base), base - 6 * H), // 只剩 6 小时 → 临期 + three: ctx.f(mkEnd(base), base - 3 * D), // 还剩 3 天 → 不告警 + past: ctx.f(mkEnd(base), base + H), // 已过截止 + empty: ctx.f('', base), // 上游没给 end_time + junk: ctx.f('not-a-date', base), // 解析失败不得抛 + undef: ctx.f(null, base), +}));` + f, err := os.CreateTemp(t.TempDir(), "compdl-*.cjs") + if err != nil { + t.Fatal(err) + } + if _, err := f.WriteString(script); err != nil { + t.Fatal(err) + } + f.Close() + out, err := exec.Command(node, f.Name(), "app.js").CombinedOutput() + if err != nil { + t.Fatalf("compensation deadline node test failed: %v\n%s", err, out) + } + got := strings.TrimSpace(string(out)) + for _, must := range []string{ + `"text":"剩 6 小时","urgent":true`, // ≤48h 必须标临期 + `"text":"剩 3 天 0 小时","urgent":false`, // 3 天不算临期 + `"text":"已截止","urgent":true`, + `"text":"无截止信息","urgent":false`, + `"text":"not-a-date","urgent":false`, // 脏值原样透出,不抛异常 + } { + if !strings.Contains(got, must) { + t.Fatalf("compensation deadline 缺少 %s\n实际输出: %s", must, got) + } + } +} + +// TestAppJSRenderCompensationErrorVisibility 钉住本 PR 的核心命题: +// **查询失败的账号必须显形**。上游 5xx / token 过期时所有账号都会落到 error 分支, +// 若渲染把它们和「没有补偿」一起过滤掉,卡片整块消失,用户看到「什么都没有」—— +// 而真相是「一个都没查成」。这正是 issue #189 要消灭的静默失败。 +func TestAppJSRenderCompensationErrorVisibility(t *testing.T) { + node, err := exec.LookPath("node") + if err != nil { + t.Skip("node not installed; compensation render test skipped") + } + script := `const fs = require('fs'); +const vm = require('vm'); +const src = fs.readFileSync(process.argv[2], 'utf8'); +const start = src.indexOf('function compPending'); +const end = src.indexOf('function renderCachedCompensation'); +if (start < 0 || end < 0 || end < start) throw new Error('render region not found'); +const sinks = {}; +const mk = id => (sinks[id] = { innerHTML: '', textContent: '', hidden: true }); +const ctx = { + esc: s => String(s == null ? '' : s), + fmtTok: v => String(v == null ? 0 : v), + $: id => (sinks[id] || mk(id)), + Date, Math, Number, String, isFinite, Array, Object, +}; +vm.createContext(ctx); +vm.runInContext(src.slice(start, end) + '\nthis.render = renderCompensation;', ctx); + +const out = {}; +// ① 全部查询失败 → 卡片必须可见,且列出失败原因 +ctx.render({ accounts: [ + { uid: 'a1', nickname: '甲', error: 'boom-1' }, + { uid: 'b2', nickname: '乙', error: 'boom-2' }, +] }); +out.allFailedVisible = sinks['compBox'].hidden === false; +out.allFailedNote = sinks['compNote'].textContent; +out.allFailedShows = sinks['compList'].innerHTML.includes('boom-1'); + +// ② 无活动且无失败 → 卡片隐藏(不能变成常驻空卡片) +ctx.render({ accounts: [{ uid: 'a1', nickname: '甲', status: { active: false, claimed: false, credit_num: 0 } }] }); +out.quietHidden = sinks['compBox'].hidden === true; + +// ③ 混合态:一条待领 + 一条失败 → 可见,且 note 同时提到待领数与失败数 +ctx.render({ accounts: [ + { uid: 'a1', nickname: '甲', status: { active: true, claimed: false, credit_num: 500, end_time: '2026-10-22 23:59:59' } }, + { uid: 'b2', nickname: '乙', error: 'boom-2' }, +] }); +out.mixedVisible = sinks['compBox'].hidden === false; +out.mixedNote = sinks['compNote'].textContent; +out.mixedHasBtn = sinks['compList'].innerHTML.includes('data-comp-uid'); + +// ④ 已领取 → 无「领取」按钮 +ctx.render({ accounts: [ + { uid: 'a1', nickname: '甲', status: { active: true, claimed: true, credit_num: 500 } }, +] }); +out.claimedNoBtn = !sinks['compList'].innerHTML.includes('data-comp-uid'); + +process.stdout.write(JSON.stringify(out));` + f, err := os.CreateTemp(t.TempDir(), "comprender-*.cjs") + if err != nil { + t.Fatal(err) + } + if _, err := f.WriteString(script); err != nil { + t.Fatal(err) + } + f.Close() + out, err := exec.Command(node, f.Name(), "app.js").CombinedOutput() + if err != nil { + t.Fatalf("compensation render node test failed: %v\n%s", err, out) + } + got := strings.TrimSpace(string(out)) + for _, must := range []string{ + `"allFailedVisible":true`, // ① 全失败必须显形 + `"allFailedShows":true`, // ① 且要带出失败原因 + `"quietHidden":true`, // ② 真没活动才隐藏 + `"mixedVisible":true`, // ③ 混合态可见 + `"mixedHasBtn":true`, // ③ 待领行仍有按钮 + `"claimedNoBtn":true`, // ④ 已领无按钮 + } { + if !strings.Contains(got, must) { + t.Fatalf("render 断言缺少 %s\n实际输出: %s", must, got) + } + } + // note 必须在混合态里同时说出「待领」与「查询失败」两件事。 + if !strings.Contains(got, "查询失败") { + t.Errorf("note 未提示查询失败: %s", got) + } +} diff --git a/internal/panel/frontend_test.go b/internal/panel/frontend_test.go index 8024954e..5ab8efc1 100644 --- a/internal/panel/frontend_test.go +++ b/internal/panel/frontend_test.go @@ -212,7 +212,8 @@ process.stdout.write(JSON.stringify({ if err != nil { t.Fatalf("rate-limit formatting node test failed: %v\n%s", err, out) } - const want = `{"rate":"预计 2026-09-28 16:00 解封(剩余 2时00分) · 网关最快 1时00分 后重试","unavailable":"预计 1时00分 后重试","unknown":"预计解封时间未知"}` + // issue #171 问题二:until < resetAt 时主文字双时刻明示、倒计时跟随 until。 + const want = `{"rate":"网关 2026-09-28 15:00 起重试(剩余 1时00分) · 上游称 2026-09-28 16:00 恢复","unavailable":"预计 1时00分 后重试","unknown":"预计解封时间未知"}` if strings.TrimSpace(string(out)) != want { t.Fatalf("rate-limit formatting=%s want %s", out, want) } @@ -398,6 +399,58 @@ process.stdout.write(JSON.stringify({ } } +// Global 模型可用性标签:模型锁优先于最近请求;无本地证据时保持“待验证”, +// 不能把上游目录里出现模型误判为当前账号有权限。 +func TestAppJSModelAvailability(t *testing.T) { + node, err := exec.LookPath("node") + if err != nil { + t.Skip("node not installed; model availability test skipped") + } + script := `const fs = require('fs'); +const vm = require('vm'); +const src = fs.readFileSync(process.argv[2], 'utf8'); +const start = src.indexOf('function mdAvailabilityOf'); +const end = src.indexOf('function mdRowHtml'); +if (start < 0 || end < 0) throw new Error('model availability helpers not found'); +const ctx = { esc: s => String(s) }; +vm.createContext(ctx); +vm.runInContext(src.slice(start, end) + '\nthis.mdAvailabilityOf=mdAvailabilityOf; this.mdAvailabilityTag=mdAvailabilityTag;', ctx); +const model = id => ({ id }); +const state = (m, locks, recent) => ctx.mdAvailabilityOf(m, locks, recent).key; +const success = [{ model: 'global:ok', status: 200, outcome: 'success', time: '2026-10-18T12:00:00Z' }]; +ctx.mdLocks = [{ realm: 'global', model: 'gpt-5.6-sol', state: 'locked', reason: '11158 model not allowed for account type' }]; +ctx.mdRecent = []; +const deniedTag = ctx.mdAvailabilityTag(model('global:gpt-5.6-sol')); +process.stdout.write(JSON.stringify({ + cn: ctx.mdAvailabilityOf(model('cn:glm-5.3'), [], []), + unknown: state(model('global:unknown'), [], []), + denied: state(model('global:gpt-5.6-sol'), [{ realm: 'global', model: 'gpt-5.6-sol', state: 'locked', reason: '11158 model not allowed for account type' }], []), + missing: state(model('global:auto-chat'), [{ realm: 'global', model: 'auto-chat', state: 'locked', reason: '11102 service info not found' }], []), + partial: state(model('global:glm-5.3'), [{ realm: 'global', model: 'glm-5.3', state: 'partial', reason: '6004 model rate limit' }], []), + starved: state(model('global:glm-5.3'), [{ realm: 'global', model: 'glm-5.3', state: 'starved' }], []), + rate: state(model('global:glm-5.3'), [], [{ model: 'global:glm-5.3', status: 429 }]), + upstream: state(model('global:glm-5.3'), [], [{ model: 'global:glm-5.3', status: 503 }]), + ok: state(model('global:ok'), [], success), + deniedTag: deniedTag.includes('无权限') && deniedTag.includes('tag bad'), +}));` + f, err := os.CreateTemp(t.TempDir(), "model-availability-*.cjs") + if err != nil { + t.Fatal(err) + } + if _, err := f.WriteString(script); err != nil { + t.Fatal(err) + } + f.Close() + out, err := exec.Command(node, f.Name(), "app.js").CombinedOutput() + if err != nil { + t.Fatalf("model availability node test failed: %v\n%s", err, out) + } + const want = `{"cn":null,"unknown":"unknown","denied":"denied","missing":"missing","partial":"partial","starved":"starved","rate":"rate","upstream":"upstream","ok":"ok","deniedTag":true}` + if strings.TrimSpace(string(out)) != want { + t.Fatalf("model availability=%s want %s", out, want) + } +} + // 用量时序图的柱体类名不得叫 bar:账号池的积分条是 .bar{height:3px},而 SVG2 里 // height 是 rect 的 CSS 几何属性——同名类会把每根柱子压成 3px 高,图看起来"没数据"。 // 这个坑只能在浏览器里看出来,所以在这里钉住类名。 @@ -837,6 +890,260 @@ process.stdout.write(JSON.stringify(names));` } } +// TestAppJSExpirySnapshotRenders 到期卡片用 overview 快照即可渲染(issue #137)。 +// +// 此前的死锁:卡片在 index.html 上写死 hidden,而唯一解除隐藏的语句在 +// renderExpiry() 末尾——该函数只有点「检查」才被调用。按钮本身就在隐藏卡片里, +// 用户看不见也点不到,于是"积分到期提醒"这个功能等于不存在。 +// +// 修复引入 renderExpirySnapshot():吃 /overview 响应里的 credits_earliest_expiry / +// credits_earliest_remaining(state.json 现成数据,零上游请求),并显式设 +// expBox.hidden = false。本用例钉住两件事: +// 1. 只给 overview 形态的输入(没有 packages 字段)也能出内容; +// 2. 渲染后 expBox.hidden 必须为 false——把这句删掉(还原 bug)测试就必须 FAIL。 +func TestAppJSExpirySnapshotRenders(t *testing.T) { + node, err := exec.LookPath("node") + if err != nil { + t.Skip("node not installed; expiry snapshot test skipped") + } + script := `const fs = require('fs'); +const vm = require('vm'); +const src = fs.readFileSync(process.argv[2], 'utf8'); +const start = src.indexOf('function renderExpirySnapshot'); +const end = src.indexOf('async function loadExpiry'); +if (start < 0 || end < 0 || end < start) throw new Error('expiry snapshot region not found'); +const sinks = {}; +const mk = id => (sinks[id] = { innerHTML: '', textContent: '', hidden: true }); +const ctx = { + esc: s => String(s == null ? '' : s), + fmtTok: v => String(v == null ? 0 : v), + $: id => (sinks[id] || mk(id)), + Date, Math, Number, String, Map, Array, Object, isNaN, +}; +vm.createContext(ctx); +vm.runInContext(src.slice(start, end) + '\nthis.renderExpirySnapshot = renderExpirySnapshot;', ctx); +const day = n => { + const d = new Date(); d.setHours(0, 0, 0, 0); d.setDate(d.getDate() + n); + return d.toISOString(); +}; +// overview 形态:没有 packages 数组,只有到期快照字段。 +const d = { accounts: [ + { uid: 'aaaaaaaa-1', nickname: '秋', credits: 4099, + credits_earliest_expiry: day(2), credits_earliest_remaining: 499 }, + { uid: 'bbbbbbbb-2', nickname: '秋千易', credits: 3243, + credits_earliest_expiry: day(30), credits_earliest_remaining: 43 }, + { uid: 'cccccccc-3', nickname: '长期包', credits: 900, + credits_earliest_expiry: null, credits_earliest_remaining: 0 }, +] }; +ctx.renderExpirySnapshot(d); +const html = sinks['expList'] ? sinks['expList'].innerHTML : ''; +const names = [...html.matchAll(/([^<]*)<\/span>/g)].map(m => m[1]); +// 2 天到期的应排最前(红),30 天其次,无到期信息的排最后。 +const wantNames = JSON.stringify(['秋', '秋千易', '长期包']); +const gotNames = JSON.stringify(names); +process.stdout.write(JSON.stringify({ + visible: sinks['expBox'] ? sinks['expBox'].hidden === false : false, + rows: names.length, + order: gotNames === wantNames, + got: gotNames, + want: wantNames, +}));` + f, err := os.CreateTemp(t.TempDir(), "expsnap-*.cjs") + if err != nil { + t.Fatal(err) + } + if _, err := f.WriteString(script); err != nil { + t.Fatal(err) + } + f.Close() + out, err := exec.Command(node, f.Name(), "app.js").CombinedOutput() + if err != nil { + t.Fatalf("expiry snapshot node test failed: %v\n%s", err, out) + } + const want = `{"visible":true,"rows":3,"order":true,"got":"[\"秋\",\"秋千易\",\"长期包\"]","want":"[\"秋\",\"秋千易\",\"长期包\"]"}` + if strings.TrimSpace(string(out)) != want { + t.Fatalf("expiry snapshot=%s\nwant %s(visible 必须为 true,否则卡片仍隐藏)", + strings.TrimSpace(string(out)), want) + } +} + +// TestAppJSExpirySnapshotMatchesLive 钉住「打开面板看到的」与「点检查后看到的」逐字节 +// 一致(issue:首页快照与检查用了两套到期口径)。 +// +// 做法:给同一逻辑数据的两条形态——packages(检查)与 credits_expiry_buckets(overview +// 快照)——分别渲染,比较 expList.innerHTML。相等意味着排序、日期、该批合计、日均 +// 需耗、7 天内合计、随后批次全部一致,而不是只对齐了某一个字段。 +func TestAppJSExpirySnapshotMatchesLive(t *testing.T) { + node, err := exec.LookPath("node") + if err != nil { + t.Skip("node not installed; expiry parity test skipped") + } + script := `const fs = require('fs'); +const vm = require('vm'); +const src = fs.readFileSync(process.argv[2], 'utf8'); +const start = src.indexOf('function expBatches'); +const end = src.indexOf('async function loadExpiry'); +if (start < 0 || end < 0 || end < start) throw new Error('expiry region not found'); +const sinks = {}; +const mk = id => (sinks[id] = { innerHTML: '', children: [], textContent: '', hidden: true }); +const ctx = { + esc: s => String(s == null ? '' : s), + fmtTok: v => String(v == null ? 0 : v), + $: id => (sinks[id] || mk(id)), + Date, Math, Number, String, Map, Array, Object, isNaN, + lastPackages: null, lastPackagesAt: 0, +}; +vm.createContext(ctx); +vm.runInContext(src.slice(start, end) + '\nthis.renderExpiry = renderExpiry; this.renderExpirySnapshot = renderExpirySnapshot;', ctx); +const iso = n => { + const d = new Date(); d.setHours(0, 0, 0, 0); d.setDate(d.getDate() + n); + const p = x => String(x).padStart(2, '0'); + return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()); +}; +// 同一天多个包:检查侧是 3 + 100 + 80,快照侧已聚合成 180。 +const live = { accounts: [ + { uid: 'a', nickname: '同日多批', packages: [ + { end_time: iso(0) + 'T09:00:00', remain: 3 }, + { end_time: iso(1) + 'T09:00:00', remain: 100 }, + { end_time: iso(1) + 'T12:00:00', remain: 80 }, + { end_time: iso(2) + 'T09:00:00', remain: 50 }, + ] }, + { uid: 'b', nickname: '远期', packages: [ + { end_time: iso(16) + 'T22:25:00', remain: 1600 }, + { end_time: iso(17) + 'T09:00:00', remain: 400 }, + ] }, + { uid: 'c', nickname: '无到期', packages: [] }, + { uid: 'd', nickname: '明天', packages: [{ end_time: iso(1) + 'T10:00:00', remain: 77 }] }, +] }; +const snap = { accounts: [ + { uid: 'b', nickname: '远期', credits_expiry_buckets: [ + { date: iso(16), remain: 1600 }, + { date: iso(17), remain: 400 }, + ] }, + { uid: 'a', nickname: '同日多批', credits_expiry_buckets: [ + { date: iso(2), remain: 50 }, + { date: iso(1), remain: 180 }, + { date: iso(0), remain: 3 }, + ] }, + { uid: 'c', nickname: '无到期', credits_expiry_buckets: [] }, + { uid: 'd', nickname: '明天', credits_expiry_buckets: [{ date: iso(1), remain: 77 }] }, +] }; +ctx.renderExpiry(live); +const liveHTML = sinks['expList'].innerHTML; +sinks['expList'].innerHTML = ''; +ctx.renderExpirySnapshot(snap); +const snapHTML = sinks['expList'].innerHTML; +process.stdout.write(JSON.stringify({ + equal: liveHTML === snapHTML, + today: liveHTML.includes('今天到期'), + tomorrow: liveHTML.includes('明天到期'), + weekSum: liveHTML.includes('7 天内合计'), + len: liveHTML.length, +}));` + f, err := os.CreateTemp(t.TempDir(), "expparity-*.cjs") + if err != nil { + t.Fatal(err) + } + if _, err := f.WriteString(script); err != nil { + t.Fatal(err) + } + f.Close() + out, err := exec.Command(node, f.Name(), "app.js").CombinedOutput() + if err != nil { + t.Fatalf("expiry parity node test failed: %v\n%s", err, out) + } + const want = `{"equal":true,"today":true,"tomorrow":true,"weekSum":true,"len":` + got := strings.TrimSpace(string(out)) + if !strings.HasPrefix(got, want) { + t.Fatalf("expiry parity=%s\nwant prefix %s(快照与检查必须逐字节一致)", got, want) + } +} + +// TestAppJSExpirySnapshotNoteCadence 到期卡片必须如实标注数据节奏与年龄。 +// 界面本身 5 秒轮询一次 overview,但卡片数据只随余额后台刷新更新(默认每 5 分钟); +// 不标注的话,5 秒级的界面刷新会让人误以为数据也是 5 秒级新鲜。 +func TestAppJSExpirySnapshotNoteCadence(t *testing.T) { + node, err := exec.LookPath("node") + if err != nil { + t.Skip("node not installed; expiry note test skipped") + } + script := `const fs = require('fs'); +const vm = require('vm'); +const src = fs.readFileSync(process.argv[2], 'utf8'); +// 从 expBatches 切起:renderExpirySnapshot 的按日批次分支要调 renderExpiry, +// 只切它自己会 ReferenceError。 +const start = src.indexOf('function expBatches'); +const end = src.indexOf('async function loadExpiry'); +if (start < 0 || end < 0 || end < start) throw new Error('expiry region not found'); +const sinks = {}; +const mk = id => (sinks[id] = { innerHTML: '', children: [], textContent: '', hidden: true }); +const ctx = { + esc: s => String(s == null ? '' : s), + fmtTok: v => String(v == null ? 0 : v), + $: id => (sinks[id] || mk(id)), + Date, Math, Number, String, Map, Array, Object, isNaN, + lastPackages: null, lastPackagesAt: 0, +}; +vm.createContext(ctx); +vm.runInContext(src.slice(start, end) + '\nthis.renderExpirySnapshot = renderExpirySnapshot;', ctx); +const refreshedAt = new Date(Date.now() - 3 * 60000).toISOString(); +ctx.renderExpirySnapshot({ accounts: [ + { uid: 'a', nickname: 'n', credits_expiry_buckets: [{ date: '2099-01-01', remain: 1 }] }, +], balance_refresh_sec: 300, balance_refreshed_at: refreshedAt }); +const withCadence = sinks['expNote'].textContent; +ctx.renderExpirySnapshot({ accounts: [ + { uid: 'a', nickname: 'n', credits_expiry_buckets: [{ date: '2099-01-01', remain: 1 }] }, +] }); +const withoutCadence = sinks['expNote'].textContent; +process.stdout.write(JSON.stringify({ + cadence: withCadence.includes('每 5 分钟后台刷新') && withCadence.includes('3 分钟前更新'), + noCadence: withoutCadence.includes('未开启后台刷新'), + note: withCadence, +}));` + f, err := os.CreateTemp(t.TempDir(), "expnote-*.cjs") + if err != nil { + t.Fatal(err) + } + if _, err := f.WriteString(script); err != nil { + t.Fatal(err) + } + f.Close() + out, err := exec.Command(node, f.Name(), "app.js").CombinedOutput() + if err != nil { + t.Fatalf("expiry note node test failed: %v\n%s", err, out) + } + const want = `{"cadence":true,"noCadence":true,"note":` + if !strings.HasPrefix(strings.TrimSpace(string(out)), want) { + t.Fatalf("expiry note=%s\nwant prefix %s", strings.TrimSpace(string(out)), want) + } +} + +// TestPanelIndexExpiryBoxNotHidden 到期卡片在 HTML 骨架里不得带 hidden 属性(issue #137)。 +// +// 与上一个用例互补:app.js 侧有 renderExpirySnapshot 兜底,HTML 侧若仍写死 hidden, +// 用户在首次 overview 返回前(以及 JS 加载失败时)看到的依然是空白。这个断言就是 +// 那个曾经卡住整个功能的属性——把它加回去测试必须 FAIL。 +func TestPanelIndexExpiryBoxNotHidden(t *testing.T) { + b, err := os.ReadFile("index.html") + if err != nil { + t.Fatal(err) + } + // 定位到期卡片那一行,只检查它自己,避免误伤其他合法的 hidden 元素。 + idx := strings.Index(string(b), `id="expBox"`) + if idx < 0 { + t.Fatal(`index.html missing id="expBox"`) + } + lineStart := strings.LastIndex(string(b[:idx]), "\n") + 1 + lineEnd := strings.Index(string(b[idx:]), "\n") + if lineEnd < 0 { + lineEnd = len(b) - idx + } + line := string(b[lineStart : idx+lineEnd]) + if strings.Contains(line, "hidden") { + t.Fatalf(`expBox 元素不得带 hidden(issue #137 死锁根因),实际行:%s`, strings.TrimSpace(line)) + } +} + // TestAppJSChartTooltipInsideBar 图表 tooltip 必须挂在每根柱子内部(issue #128)。 // // 在 SVG 里描述的是**父元素**。此前它被平铺在 <svg> 根下(<rect> 是自闭合的, @@ -903,3 +1210,192 @@ process.stdout.write(JSON.stringify({ strings.TrimSpace(string(out)), want) } } + +// 账号备注:键取 uid 前 8 位(与 renderUsageDim 账号维度、积分扣除表的 uid 前缀同源, +// 三个表格因此能直接 join);有备注时备注当主标识、原昵称作浅色虚体跟随;备注来自本机 +// 输入,必须转义后才能拼进 innerHTML。 +func TestAppJSRemarkName(t *testing.T) { + node, err := exec.LookPath("node") + if err != nil { + t.Skip("node not installed; remark name test skipped") + } + script := `const fs = require('fs'); +const vm = require('vm'); +const src = fs.readFileSync(process.argv[2], 'utf8'); +const escStart = src.indexOf('function esc('); +const escEnd = src.indexOf('function ago('); +const start = src.indexOf('function remarkKey('); +const end = src.indexOf('function storeRemarks('); +if (escStart < 0 || escEnd < 0) throw new Error('esc helpers not found'); +if (start < 0 || end < 0) throw new Error('remark helpers not found'); +const ctx = { String, RegExp, Number }; +vm.createContext(ctx); +vm.runInContext( + src.slice(escStart, escEnd) + src.slice(start, end) + + '\nconst REM = {abcdef12: "JTOL", deadbeef: "<b>x</b>"};' + + '\nfunction remarksAll() { return REM; }' + + '\nthis.remarkKey=remarkKey; this.remarkName=remarkName;', + ctx +); +process.stdout.write(JSON.stringify({ + key: ctx.remarkKey('ABCDEF1234567890'), + short: ctx.remarkKey('abc'), + empty: ctx.remarkKey(null), + remarked: ctx.remarkName('abcdef12', '张三'), + plain: ctx.remarkName('zzzzzzzz', '张三'), + escaped: ctx.remarkName('deadbeef', 'n'), +}));` + f, err := os.CreateTemp(t.TempDir(), "remark-name-*.cjs") + if err != nil { + t.Fatal(err) + } + if _, err := f.WriteString(script); err != nil { + t.Fatal(err) + } + f.Close() + out, err := exec.Command(node, f.Name(), "app.js").CombinedOutput() + if err != nil { + t.Fatalf("remark name node test failed: %v\n%s", err, out) + } + const want = `{"key":"ABCDEF12","short":"abc","empty":"","remarked":"JTOL<span class=\"rm-ghost\">张三</span>","plain":"张三","escaped":"<b>x</b><span class=\"rm-ghost\">n</span>"}` + if strings.TrimSpace(string(out)) != want { + t.Fatalf("remark name=%s\nwant %s", strings.TrimSpace(string(out)), want) + } +} + +// 「积分到期提醒」折叠:默认展开(点「检查」就得能看到结果),用户收起一次后由 +// localStorage 记住、重载时恢复;折叠只切列表容器的 hidden,不影响卡片与「检查」。 +func TestAppJSExpiryFold(t *testing.T) { + node, err := exec.LookPath("node") + if err != nil { + t.Skip("node not installed; expiry fold test skipped") + } + script := `const fs = require('fs'); +const vm = require('vm'); +const src = fs.readFileSync(process.argv[2], 'utf8'); +const start = src.indexOf('function expFolded('); +const endMarker = 'applyExpFold();'; +const end = src.lastIndexOf(endMarker); +if (start < 0 || end < 0) throw new Error('expiry fold helpers not found'); +const slice = src.slice(start, end + endMarker.length); +function boot(stored) { + const ctx = {}; + vm.createContext(ctx); + vm.runInContext( + "const LS_EXP_FOLD='wb2api.expfold.v1';" + + "const store=" + JSON.stringify(stored) + ";" + + "const pad={hidden:false};" + + "const btn={textContent:'',attrs:{},setAttribute:function(k,v){this.attrs[k]=v;}};" + + "function $(id){return id==='expPad'?pad:(id==='btnExpFold'?btn:null);}" + + "const localStorage={getItem:function(k){return (k in store)?store[k]:null;},setItem:function(k,v){store[k]=v;}};" + + slice + + "\nthis.state=function(){return {hidden:pad.hidden,text:btn.textContent,aria:btn.attrs['aria-expanded'],raw:store['wb2api.expfold.v1']||null};};" + + "\nthis.click=function(){btn.onclick();};", + ctx + ); + return ctx; +} +const a = boot({}); +const fresh = a.state(); +a.click(); +const folded = a.state(); +a.click(); +const unfolded = a.state(); +const restored = boot({'wb2api.expfold.v1': '1'}).state(); +process.stdout.write(JSON.stringify({fresh: fresh, folded: folded, unfolded: unfolded, restored: restored}));` + f, err := os.CreateTemp(t.TempDir(), "expiry-fold-*.cjs") + if err != nil { + t.Fatal(err) + } + if _, err := f.WriteString(script); err != nil { + t.Fatal(err) + } + f.Close() + out, err := exec.Command(node, f.Name(), "app.js").CombinedOutput() + if err != nil { + t.Fatalf("expiry fold node test failed: %v\n%s", err, out) + } + const want = `{"fresh":{"hidden":false,"text":"收起","aria":"true","raw":null},"folded":{"hidden":true,"text":"展开","aria":"false","raw":"1"},"unfolded":{"hidden":false,"text":"收起","aria":"true","raw":"0"},"restored":{"hidden":true,"text":"展开","aria":"false","raw":"1"}}` + if strings.TrimSpace(string(out)) != want { + t.Fatalf("expiry fold=%s\nwant %s", strings.TrimSpace(string(out)), want) + } +} + +// 备注与折叠的接线齐备性:少一个 id 或漏一个 localStorage 键,表现为「按钮点了没反应」, +// 而 Go 编译、JS 语法检查、顶层冒烟都发现不了(脚本能正常跑,只是挂不上)。 +func TestPanelRemarkAndFoldWiring(t *testing.T) { + html, err := os.ReadFile("index.html") + if err != nil { + t.Fatal(err) + } + for _, want := range []string{`id="btnRemarks"`, `id="remarkVeil"`, `id="remarkBody"`, `id="btnRemarkSave"`, `id="btnRemarkClear"`, `id="expPad"`, `id="btnExpFold"`} { + if !bytes.Contains(html, []byte(want)) { + t.Fatalf("index.html 缺少 %s", want) + } + } + // 折叠容器默认必须可见:列表一旦带上 hidden,点「检查」就看不到结果了。 + for _, line := range strings.Split(string(html), "\n") { + if strings.Contains(line, `id="expPad"`) && strings.Contains(line, "hidden") { + t.Fatalf("index.html 的过期列表容器默认带 hidden:%s", strings.TrimSpace(line)) + } + } + js, err := os.ReadFile("app.js") + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + "LS_REMARKS = 'wb2api.remarks.v1'", + "LS_EXP_FOLD = 'wb2api.expfold.v1'", + "function remarkKey(", + "function remarkName(", + "function applyExpFold(", + } { + if !bytes.Contains(js, []byte(want)) { + t.Fatalf("app.js 缺少 %s", want) + } + } +} + +// 用实际表单函数验证地区策略回填、独立提交以及清空账号顺序。 +func TestRegionSchedulingFormRoundTrip(t *testing.T) { + node, err := exec.LookPath("node") + if err != nil { + t.Skip("node not available") + } + script := `const fs=require('fs'), vm=require('vm'), assert=require('assert'); + const src=fs.readFileSync(process.argv[2],'utf8'); + const html=fs.readFileSync('index.html','utf8'); + const elements={}; + for(const name of ['strategy_domestic','strategy_international','domestic_account_order','international_account_order']) { + assert(html.includes('name="'+name+'"'), 'missing form field '+name); + elements[name]={type:name.startsWith('strategy_')?'select-one':'text',value:''}; + } + const cfg={pool:{strategy:{domestic:'round_robin',international:'sequential_exhaust'},account_order:{domestic:['c','a'],international:['y','x']}}}; + const nodes={cfgForm:{elements},cfgPath:{},cfgNote:{}}; + const ctx={cfgLoaded:null,api:async()=>({config:cfg}),$:id=>nodes[id],markDurationFields(){},toast(message){throw Error(message)}}; + vm.createContext(ctx); + vm.runInContext(src.slice(src.indexOf('const CFG_MAP ='),src.indexOf('/* Go 时长字段即时校验')),ctx); + (async()=>{ + await ctx.loadConfig(); + assert.equal(elements.strategy_domestic.value,'round_robin'); + assert.equal(elements.strategy_international.value,'sequential_exhaust'); + assert.equal(elements.domestic_account_order.value,'c, a'); + elements.strategy_domestic.value='weighted_random'; + elements.international_account_order.value=''; + const result=ctx.collectConfig(); + assert.equal(result.pool.strategy.domestic,'weighted_random'); + assert.equal(result.pool.strategy.international,'sequential_exhaust'); + assert.equal(JSON.stringify(result.pool.account_order.domestic),'["c","a"]'); + assert.equal(JSON.stringify(result.pool.account_order.international),'[]'); + delete cfg.pool.strategy; await ctx.loadConfig(); + assert.equal(elements.strategy_domestic.value,'weighted_random'); + assert.equal(elements.strategy_international.value,'weighted_random'); + })().catch(e=>{console.error(e);process.exitCode=1});` + fp := filepath.Join(t.TempDir(), "region-form.cjs") + if err = os.WriteFile(fp, []byte(script), 0600); err != nil { + t.Fatal(err) + } + if out, err := exec.Command(node, fp, "app.js").CombinedOutput(); err != nil { + t.Fatalf("form round trip: %v\n%s", err, out) + } +} diff --git a/internal/panel/import.go b/internal/panel/import.go index 3ac7050e..dee71103 100644 --- a/internal/panel/import.go +++ b/internal/panel/import.go @@ -15,23 +15,23 @@ import ( // cockpitAccount 映射 cockpit tools 导出格式的单个账号。 type cockpitAccount struct { - ID string `json:"id"` - Email string `json:"email"` - UID string `json:"uid"` - Nickname string `json:"nickname"` - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - TokenType string `json:"token_type"` - ExpiresAt int64 `json:"expires_at"` - Domain string `json:"domain"` - DosageNotify string `json:"dosage_notify_code"` - PaymentType string `json:"payment_type"` - Status string `json:"status"` - UsageUpdatedAt int64 `json:"usage_updated_at"` - LastCheckin int64 `json:"last_checkin_time"` - CheckinStreak int `json:"checkin_streak"` - CreatedAt int64 `json:"created_at"` - LastUsed int64 `json:"last_used"` + ID string `json:"id"` + Email string `json:"email"` + UID string `json:"uid"` + Nickname string `json:"nickname"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + TokenType string `json:"token_type"` + ExpiresAt int64 `json:"expires_at"` + Domain string `json:"domain"` + DosageNotify string `json:"dosage_notify_code"` + PaymentType string `json:"payment_type"` + Status string `json:"status"` + UsageUpdatedAt int64 `json:"usage_updated_at"` + LastCheckin int64 `json:"last_checkin_time"` + CheckinStreak int `json:"checkin_streak"` + CreatedAt int64 `json:"created_at"` + LastUsed int64 `json:"last_used"` } // importCockpit 接收 cockpit tools 导出的 JSON 文件,批量导入账号到池中。 @@ -76,7 +76,10 @@ func (p *Panel) importCockpit(w http.ResponseWriter, r *http.Request) { uid := strings.TrimSpace(acc.UID) at := strings.TrimSpace(acc.AccessToken) rt := strings.TrimSpace(acc.RefreshToken) - if uid == "" || at == "" || rt == "" { + // 必填判据只要求 uid + access_token 非空:refresh_token 可以缺(issue #165 的 + // access-token-only 导出——例如静态 API Key 凭证,本就没有 refresh token)。 + // 缺 refresh_token 的账号仍可接流量与刷新额度,只是不参与保活刷新。 + if uid == "" || at == "" { skipped++ errs = append(errs, fmt.Sprintf("missing required fields (id=%s)", acc.ID)) continue diff --git a/internal/panel/import_test.go b/internal/panel/import_test.go new file mode 100644 index 00000000..f79d9de8 --- /dev/null +++ b/internal/panel/import_test.go @@ -0,0 +1,62 @@ +package panel + +import ( + "bytes" + "encoding/json" + "mime/multipart" + "net/http" + "net/http/httptest" + "testing" +) + +// postImport 以 multipart 表单发一次 cockpit 导入。 +func postImport(t *testing.T, p *Panel, payload string) *httptest.ResponseRecorder { + t.Helper() + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + fw, err := mw.CreateFormFile("file", "accounts.json") + if err != nil { + t.Fatal(err) + } + if _, err := fw.Write([]byte(payload)); err != nil { + t.Fatal(err) + } + mw.Close() + req := httptest.NewRequest(http.MethodPost, "/panel/api/import/cockpit", &buf) + req.Header.Set("Content-Type", mw.FormDataContentType()) + rec := httptest.NewRecorder() + p.ServeHTTP(rec, req) + return rec +} + +// TestImportCockpitAllowsEmptyRefreshToken 必填判据收敛为 uid + access_token: +// refresh_token 为空的记录不再被跳过(issue #165 的 access-token-only 场景, +// 如静态 API Key 凭证)。缺 access_token 的记录仍应被跳过。 +func TestImportCockpitAllowsEmptyRefreshToken(t *testing.T) { + p := panelWithUpstream(t, func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"code":0,"msg":"ok","data":{"Response":{"Data":{"Accounts":[]}}}}`)) + }) + payload := `[` + + `{"id":"a1","uid":"u-no-rt","access_token":"at-1","refresh_token":""},` + + `{"id":"a2","uid":"u-no-at","access_token":""}` + + `]` + rec := postImport(t, p, payload) + if rec.Code != http.StatusOK { + t.Fatalf("code=%d want 200 body=%s", rec.Code, rec.Body.String()) + } + var resp map[string]any + _ = json.Unmarshal(rec.Body.Bytes(), &resp) + if resp["imported"] != float64(1) || resp["skipped"] != float64(1) { + t.Fatalf("imported/skipped=%v/%v want 1/1 body=%s", resp["imported"], resp["skipped"], rec.Body.String()) + } + a := p.cfg.Pool.AuthByUID("u-no-rt") + if a == nil { + t.Fatal("refresh_token 为空的记录应被导入") + } + if a.RefreshTokenValue() != "" { + t.Errorf("refreshToken 应为空,实际=%q", a.RefreshTokenValue()) + } + if _, ok := p.cfg.Pool.Status("u-no-at"); ok { + t.Error("缺 access_token 的记录不应被导入") + } +} diff --git a/internal/panel/index.html b/internal/panel/index.html index 96606b9b..9da35b7c 100644 --- a/internal/panel/index.html +++ b/internal/panel/index.html @@ -203,6 +203,10 @@ .box > header select, .box > header input { width: auto; } .box > header h3 { white-space: nowrap; } .box .pad { padding: 17px; } +/* 补偿活动行:昵称 / 状态标 / 截止倒计时 / 额度 / 领取按钮,单行紧凑排布。 + 沿用 .tag 体系染色(ok=已领、warn=待领、bad=临期、mute=中性信息)。 */ +.comp-row { display: flex; align-items: center; gap: 9px; padding: 7px 0; border-bottom: 1px solid var(--line-soft); } +.comp-row:last-child { border-bottom: none; } /* ── 账号表(签名元素:健康色条 + 积分量条)───────────────────────── */ .tbl-wrap { overflow-x: auto; } @@ -236,6 +240,8 @@ #taskTable .who .nm, #taskTable .who .id { white-space: normal; word-break: break-all; } .who .nm { font-weight: 550; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .who .id { font-family: var(--mono); font-size: 11.5px; color: var(--ink-3); margin-top: 1px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.acc.md .who .nm { display: flex; align-items: center; gap: 6px; min-width: 0; } +.acc.md .who .nm .mid { min-width: 0; overflow: hidden; text-overflow: ellipsis; } .realm-tag { display: inline-block; margin-left: 6px; padding: 1px 5px; border-radius: 4px; font: 500 10px/1 var(--sans); color: var(--accent); background: var(--accent-soft); border: 1px solid color-mix(in srgb, var(--accent) 22%, transparent); vertical-align: 1px; } .realm-row { display: flex; align-items: center; gap: 14px; padding: 4px 0 10px; } .realm-opt { display: inline-flex; align-items: center; gap: 5px; font-size: 12.5px; color: var(--ink-2); cursor: pointer; } @@ -275,6 +281,7 @@ .tag.warn { background: var(--warn-soft); color: var(--warn); } .tag.bad { background: var(--bad-soft); color: var(--bad); } .tag.mute { background: var(--surface-2); color: var(--ink-3); border: 1px solid var(--line); } +.md-avail { flex: none; padding: 0 6px; font-size: 10.5px; line-height: 16px; } .rate-limits { display: flex; flex-direction: column; gap: 4px; margin-top: 6px; min-width: 210px; } .rate-limit { display: flex; align-items: baseline; gap: 6px; padding: 4px 7px; @@ -586,6 +593,16 @@ .exp-main .note { margin-top: 2px; font-size: 11px; color: var(--ink-3); } .exp-main.err { color: var(--bad); } + /* ── 账号备注(本机 localStorage,不写服务器)───────────────────────── + 有备注时备注当主标识,原昵称 / uid8 以 .rm-ghost 浅色跟随。账号池、用量明细、 + 积分扣除三张表共用同一套渲染,保证同一账号在两个视图里显示一致。 */ + .rm-ghost { color: var(--ink-3); font-weight: 400; margin-left: .35em; } + .rm-list { display: flex; flex-direction: column; gap: 9px; } + .rm-row { display: flex; align-items: center; gap: 10px; } + .rm-row .rm-who { flex: 0 0 168px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + color: var(--ink-2); font-size: 12.5px; } + .rm-row input { flex: 1; } + </style> </head> <body> @@ -649,14 +666,34 @@ <h2 id="ttl">账号池</h2> <div class="stat"><div class="v" id="sSticky">-</div><div class="k">粘性会话</div></div> </div> - <div class="box" id="expBox" hidden> + <!-- 卡片默认可见:此前 hidden + 「仅点检查才渲染」形成死锁(按钮藏在隐藏卡片里, + 用户看不到也就点不到,issue #137)。现在进页面即用 /overview 的到期快照渲染 + (数据来自 state.json,不经上游),「检查」按钮改为拉取逐包明细的增强入口。 --> + <div class="box" id="expBox"> <header> <h3>积分到期提醒<span class="hint">按最近到期批次估算 · 日均需耗 = 批次剩余 ÷ 距到期天数 · 上游按失效时刻先后自动优先扣减(FEFO)</span></h3> <span class="grow"></span> <span class="note" id="expNote"></span> + <button class="xs" id="btnExpFold" aria-expanded="true">收起</button> <button class="xs" id="btnExp">检查</button> </header> - <div class="pad"><div id="expList"></div></div> + <div class="pad" id="expPad"><div id="expList"></div></div> + </div> + + <!-- 官方补偿活动(issue #189):「模型服务异常额外补偿 N 积分」这类限时补偿在账号侧 + 可见,但网关原先只在每日签到后盲领一次,「没有 / 已领过 / 领取真失败」三态在 + 日志里同构,补偿带截止日时签到一停就静默过期。这里把它显式摆出来:截止倒计时 + + 逐号领取 + 全部领取。无生效活动时整卡隐藏——隐藏的是卡片本身、且渲染不依赖用户 + 点它,故不构成 issue #137 那种「入口藏在自己隐藏卡片里」的死锁。 --> + <div class="box" id="compBox" hidden> + <header> + <h3>官方补偿<span class="hint">服务异常等限时补偿 · 查状态后按需领取 · 不占用每日签到</span></h3> + <span class="grow"></span> + <span class="note" id="compNote"></span> + <button class="xs" id="btnCompRefresh">检查</button> + <button class="xs" id="btnCompClaimAll">全部领取</button> + </header> + <div class="pad"><div id="compList"></div></div> </div> <div class="box"> @@ -664,6 +701,7 @@ <h3>积分到期提醒<span class="hint">按最近到期批次估算 · 日均 <h3>账号池</h3> <span class="grow"></span> <span class="note" id="accNote"></span> + <button class="xs" id="btnRemarks">备注</button> <button class="xs" id="btnCheckinAll">全部签到</button> <button class="xs" id="btnTravelAll">旅行巡检</button> <button class="xs" id="btnActivityAll">活跃上报</button> @@ -960,6 +998,14 @@ <h3>积分扣除历史</h3> <span class="hint">每日到点自动「扫描 + 执行全部待办」(Sequential 任务链零点解锁后自动推进,建议零点后)</span></label> </div> <div class="hr"></div> + <div class="grid2"> + <div class="switch"><span class="lb">任务补领清扫</span> + <input type="checkbox" name="task_sweep_enabled" checked></div> + <label class="fld"><span class="lb">清扫间隔(分钟)</span> + <input name="task_sweep_minutes" type="number" min="1" placeholder="15"></label> + </div> + <div class="hint">周期扫描全部账号,把「进度已达标但尚未领奖」的成长任务补领掉(不含自动动作的口令任务也能领),并顺带接受未接受的任务。只发 accept / claim 两条<b>幂等</b>请求,不跑任何对话或上报行为,开销可忽略。<br>用途:真实对话点亮的任务不在自动动作白名单里,成长队列与「一键完成」都不会去领它——积分因此卡在半路上不去(#174)。</div> + <div class="hr"></div> <div class="switch"><span class="lb">保号任务覆盖已禁用账号</span> <input type="checkbox" name="include_disabled_in_tasks"></div> <div class="hint">禁用只关选号:打开后已禁用的账号仍会签到 / 活跃上报 / 保活 / 刷新余额(依旧不参与选号)。适合「一次只放开一个账号、用禁用做流量开关」的轮换养号用法,默认关闭。<br>若只想让<b>单个</b>账号临时退出选号但保留保号,直接用账号行的「<b>暂停选号</b>」按钮(账号级,无需打开本开关)。</div> @@ -969,6 +1015,27 @@ <h3>积分扣除历史</h3> <div class="box"> <header><h3>账号池与流量治理</h3></header> <div class="pad"> + <div class="grid3"> + <label class="fld"><span class="lb">国内版调度策略</span> + <select name="strategy_domestic"> + <option value="weighted_random">加权随机(默认)</option> + <option value="round_robin">轮询</option> + <option value="sequential_exhaust">顺序耗尽</option> + </select></label> + <label class="fld"><span class="lb">国际版调度策略</span> + <select name="strategy_international"> + <option value="weighted_random">加权随机(默认)</option> + <option value="round_robin">轮询</option> + <option value="sequential_exhaust">顺序耗尽</option> + </select></label> + </div> + <div class="grid3"> + <label class="fld"><span class="lb">国内版账号顺序(UID)</span> + <input name="domestic_account_order" placeholder="uid1, uid2, uid3"></label> + <label class="fld"><span class="lb">国际版账号顺序(UID)</span> + <input name="international_account_order" placeholder="uid1, uid2, uid3"></label> + </div> + <div class="hint">按地区独立热生效;未列出的账号按 UID 排序追加。指针按地区和模型分别保存。会话粘性、模型成本分层优先;有序策略等待冷却恢复,不提前试探。临时重试或在途占满不推进顺序耗尽的主账号。闲置与快过期积分权重仅用于加权随机。</div> <div class="grid3"> <label class="fld"><span class="lb">单账号最大在途</span> <input name="max_in_flight" type="number" min="0" placeholder="3"> @@ -1194,6 +1261,7 @@ <h3>开学季 · 我的券码</h3> <h3>添加账号</h3> <div class="tabs" id="addTabs"> <button class="tab on" data-tab="login">浏览器登录</button> + <button class="tab" data-tab="apikey">API Key</button> <button class="tab" data-tab="import">导入 JSON</button> </div> </header> @@ -1217,6 +1285,15 @@ <h3>添加账号</h3> <div id="addDone" class="state ok" hidden></div> <div id="addErr" class="state err" hidden></div> </div> + <!-- API Key --> + <div id="addTabApiKey" class="tab-panel" hidden> + <div class="hint" style="margin-bottom:8px">粘贴 CodeBuddy 平台签发的静态 API Key(ck_/pt_ 前缀)。添加后直接可用、无需浏览器登录;该账号无签到 / 成长任务体系,额度走企业口径。</div> + <input type="password" id="apiKeyInput" placeholder="ck_..." autocomplete="off" style="width:100%;margin-bottom:8px"> + <input type="text" id="apiKeyNickname" placeholder="昵称(可选)" autocomplete="off" style="width:100%;margin-bottom:8px"> + <input type="text" id="apiKeyUid" placeholder="uid(可选,缺省由 key 派生)" autocomplete="off" style="width:100%;margin-bottom:8px"> + <div id="apiKeyDone" class="state ok" hidden></div> + <div id="apiKeyErr" class="state err" hidden></div> + </div> <!-- 导入 JSON --> <div id="addTabImport" class="tab-panel" hidden> <div class="hint" style="margin-bottom:8px">选择 cockpit tools 导出的 JSON 文件,批量导入账号。文件需为账号数组格式。</div> @@ -1228,6 +1305,7 @@ <h3>添加账号</h3> <footer> <span class="grow"></span> <button id="btnStartLogin" class="primary">获取授权链接</button> + <button id="btnAddApiKey" class="primary" hidden>添加 API Key</button> <button id="btnCopyUrl" hidden>复制链接</button> <button id="btnOpenUrl" class="primary" hidden>在浏览器打开</button> <button id="btnCloseAdd">关闭</button> @@ -1253,6 +1331,25 @@ <h3>需要访问密钥</h3> </div> </div> +<!-- 账号备注:只写本机 localStorage,不写回服务器;账号池与用量两个视图同步显示 --> +<div class="veil" id="remarkVeil"> + <div class="dlg" style="width:560px"> + <header> + <h3>账号备注</h3> + <div class="hint">备注只存在本机浏览器(localStorage: wb2api.remarks.v1),不写回后端;账号池与用量页会同时显示「备注 + 原昵称」。留空即清除该账号备注。</div> + </header> + <div class="body"> + <div class="rm-list" id="remarkBody"></div> + </div> + <footer> + <button class="danger" id="btnRemarkClear">清空全部</button> + <span class="grow"></span> + <button id="btnRemarkClose">关闭</button> + <button class="primary" id="btnRemarkSave">保存</button> + </footer> + </div> +</div> + <div id="toasts"></div> <script src="app.js"></script> diff --git a/internal/panel/login.go b/internal/panel/login.go index a03bfbe1..72cf538c 100644 --- a/internal/panel/login.go +++ b/internal/panel/login.go @@ -280,6 +280,12 @@ func (p *Panel) loginPoll(w http.ResponseWriter, r *http.Request) { delete(p.logins, state) p.loginMu.Unlock() log.Printf("panel: 新账号已热加载 uid=%s nickname=%q realm=%s(免重启生效)", acct.UID, acct.Nickname, sess.realm) + // 企业切换会话预警:该形态下 /v2/report 行为事件被上游静默丢弃(200 不计分), + // 成长任务全部无法点亮(签到/列表/额度/领养照常)。当场给出修复路径, + // 避免用户事后对着「已上报但进度 0/x」排查(见 auth.IsEnterpriseSession)。 + if a.IsEnterpriseSession() && !a.IsEnterprise() { + log.Printf("panel: WARN uid=%s 的凭证为企业切换会话(token_source=enterprise_switch):成长任务行为事件会被上游静默丢弃;建议移除该账号,在浏览器退出企业切换(个人身份)后重新登录", acct.UID) + } writeJSON(w, http.StatusOK, map[string]any{ "done": true, "uid": acct.UID, diff --git a/internal/panel/model_probes_test.go b/internal/panel/model_probes_test.go index 6e5dd06b..8576f4b2 100644 --- a/internal/panel/model_probes_test.go +++ b/internal/panel/model_probes_test.go @@ -37,8 +37,8 @@ func TestModelProbesEndpoint(t *testing.T) { } var got struct { Probes map[string]json.RawMessage `json:"probes"` - Exists bool `json:"exists"` - UpdatedAt string `json:"updated_at"` + Exists bool `json:"exists"` + UpdatedAt string `json:"updated_at"` } if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { t.Fatal(err) @@ -65,7 +65,7 @@ func TestModelProbesEndpoint(t *testing.T) { } var gotEmpty struct { Probes map[string]json.RawMessage `json:"probes"` - Exists bool `json:"exists"` + Exists bool `json:"exists"` } if err := json.Unmarshal(rec.Body.Bytes(), &gotEmpty); err != nil { t.Fatal(err) diff --git a/internal/panel/overview_expiry_test.go b/internal/panel/overview_expiry_test.go new file mode 100644 index 00000000..9400c764 --- /dev/null +++ b/internal/panel/overview_expiry_test.go @@ -0,0 +1,64 @@ +package panel + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" + "github.com/linguo2625469/workbuddy2api-panel/internal/pool" + "github.com/linguo2625469/workbuddy2api-panel/internal/scheduler" +) + +// TestOverviewIncludesExpiryBuckets 钉住面板接线:池内写入的按日到期批次必须原样 +// 出现在 /overview 响应里,否则前端快照只能退回旧的「最早一批」字段(口径错误)。 +// 同时钉住余额刷新节奏/时刻两个字段——前端据此标注「每 5 分钟刷新,x 分钟前更新」。 +func TestOverviewIncludesExpiryBuckets(t *testing.T) { + pl := pool.New("") + sch := scheduler.New(scheduler.Config{Pool: pl}) + sch.SetBalanceInterval(5 * time.Minute) + sch.RunBalanceRefreshNow() // 空池 → 不打上游,只落完成时刻 + + p := New(Config{Version: "test", Pool: pl, Scheduler: sch}) + pl.Add(&auth.Auth{UID: "u1", Nickname: "n1"}) + now := time.Now() + at1 := now.Add(24 * time.Hour) + at2 := now.Add(48 * time.Hour) + pl.SetCreditsSnapshot("u1", pool.CreditsSnapshot{ + Remain: 300, Total: 300, Expiring: 100, + ExpiryBuckets: []pool.ExpiryBucket{ + {Date: at1.Format("2006-01-02"), ExpireAt: at1, Remain: 100}, + {Date: at2.Format("2006-01-02"), ExpireAt: at2, Remain: 200}, + }, + }) + + rec := httptest.NewRecorder() + p.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/panel/api/overview", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("overview status=%d body=%s", rec.Code, rec.Body.String()) + } + var resp struct { + BalanceRefreshSec int `json:"balance_refresh_sec"` + BalanceRefreshedAt time.Time `json:"balance_refreshed_at"` + Accounts []struct { + CreditsExpiryBuckets []pool.ExpiryBucket `json:"credits_expiry_buckets"` + } `json:"accounts"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode overview: %v", err) + } + if len(resp.Accounts) != 1 || len(resp.Accounts[0].CreditsExpiryBuckets) != 2 { + t.Fatalf("overview accounts=%+v want 2 expiry buckets", resp.Accounts) + } + if got := resp.Accounts[0].CreditsExpiryBuckets[0]; got.Remain != 100 || got.Date != at1.Format("2006-01-02") { + t.Fatalf("first bucket=%+v", got) + } + if resp.BalanceRefreshSec != 300 { + t.Fatalf("balance_refresh_sec=%d want 300", resp.BalanceRefreshSec) + } + if resp.BalanceRefreshedAt.IsZero() { + t.Fatal("balance_refreshed_at 为零值:前端无法标注数据年龄") + } +} diff --git a/internal/panel/panel.go b/internal/panel/panel.go index e1379825..050323ac 100644 --- a/internal/panel/panel.go +++ b/internal/panel/panel.go @@ -158,12 +158,14 @@ func (p *Panel) routes() { p.mux.HandleFunc("GET /panel/api/login/poll", p.withAuth(p.loginPoll)) p.mux.HandleFunc("GET /panel/api/login/regions", p.withAuth(p.loginRegions)) p.mux.HandleFunc("POST /panel/api/import/cockpit", p.withAuth(p.importCockpit)) + p.mux.HandleFunc("POST /panel/api/accounts/add_apikey", p.withAuth(p.addAPIKey)) p.mux.HandleFunc("POST /panel/api/accounts/{uid}/revive", p.withAuth(p.accountRevive)) p.mux.HandleFunc("POST /panel/api/accounts/{uid}/disable", p.withAuth(p.accountDisable)) p.mux.HandleFunc("POST /panel/api/accounts/{uid}/pause", p.withAuth(p.accountPause)) p.mux.HandleFunc("POST /panel/api/accounts/{uid}/resume", p.withAuth(p.accountResume)) p.mux.HandleFunc("POST /panel/api/accounts/{uid}/checkin", p.withAuth(p.accountCheckin)) p.mux.HandleFunc("POST /panel/api/accounts/{uid}/balance", p.withAuth(p.accountBalance)) + p.mux.HandleFunc("POST /panel/api/accounts/{uid}/travel", p.withAuth(p.accountTravel)) // 单号旅行巡检(issue #227) p.mux.HandleFunc("POST /panel/api/accounts/{uid}/remove", p.withAuth(p.accountRemove)) p.mux.HandleFunc("GET /panel/api/accounts/{uid}/tasks", p.withAuth(p.accountTasks)) p.mux.HandleFunc("POST /panel/api/accounts/{uid}/tasks/accept", p.withAuth(p.accountTaskAccept)) @@ -186,6 +188,9 @@ func (p *Panel) routes() { p.mux.HandleFunc("GET /panel/api/model_probes", p.withAuth(p.modelProbes)) p.mux.HandleFunc("GET /panel/api/config", p.withAuth(p.getConfig)) p.mux.HandleFunc("POST /panel/api/config", p.withAuth(p.saveConfig)) + p.mux.HandleFunc("GET /panel/api/compensation", p.withAuth(p.compensation)) + p.mux.HandleFunc("POST /panel/api/accounts/{uid}/compensation", p.withAuth(p.accountCompensationClaim)) + p.mux.HandleFunc("POST /panel/api/compensation_all", p.withAuth(p.compensationAll)) } // ServeHTTP 统一入口:先写安全响应头再分发,保证页面、静态资源、API @@ -224,6 +229,19 @@ func (p *Panel) expiringSoonWindow() time.Duration { return p.cfg.Scheduler.ExpiringSoonWindow() } +// poolExpiryBuckets 把 upstream 的按日到期批次转换为 pool 结构。两个包字段同构, +// 手工转换是为了不让底层 pool 反向依赖 upstream。 +func poolExpiryBuckets(in []upstream.ExpiryBucket) []pool.ExpiryBucket { + if len(in) == 0 { + return nil + } + out := make([]pool.ExpiryBucket, len(in)) + for i, b := range in { + out[i] = pool.ExpiryBucket{Date: b.Date, ExpireAt: b.ExpireAt, Remain: b.Remain} + } + return out +} + // --------------------------------------------------------------------------- // 只读接口 // --------------------------------------------------------------------------- @@ -253,9 +271,29 @@ func (p *Panel) overview(w http.ResponseWriter, r *http.Request) { // model_locks 模型级限流全清单(哪些模型不能用、锁了几个号、还要锁多久): // 与 accounts 的账号池视图互补,前端「模型锁池」表直接渲染。无锁时为 null。 "model_locks": p.cfg.Pool.ModelLockView(), + // 到期提醒快照的节奏与年龄:界面轮询是 5 秒级,但卡片数据只随余额后台刷新 + // 更新(默认每 5 分钟)——把这两个数透出,前端才能如实标注「x 分钟前更新」。 + "balance_refresh_sec": p.balanceRefreshSec(), + "balance_refreshed_at": p.balanceRefreshedAt(), }) } +// balanceRefreshSec 余额后台刷新间隔(秒);无调度器/未开启时为 0。 +func (p *Panel) balanceRefreshSec() int { + if p.cfg.Scheduler == nil { + return 0 + } + return int(p.cfg.Scheduler.BalanceInterval() / time.Second) +} + +// balanceRefreshedAt 最近一次后台余额刷新完成时刻;无调度器或尚未跑过为零值。 +func (p *Panel) balanceRefreshedAt() time.Time { + if p.cfg.Scheduler == nil { + return time.Time{} + } + return p.cfg.Scheduler.BalanceRefreshedAt() +} + // logsHandler 返回日志环形缓冲快照(时间升序,含频道标记 chat/task/sys)。 func (p *Panel) logsHandler(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"entries": p.logs.Snapshot()}) @@ -320,6 +358,13 @@ func (p *Panel) models(w http.ResponseWriter, r *http.Request) { out := make([]map[string]any, 0) var fetchErrs []string + // refresh=1:强制重探 global 目录(issue #216 现象 3)。此前面板「重新获取」按钮 + // 只是重读 1h 缓存,上游目录变了也纹丝不动。CN 侧本来就是每请求直连上游 + // (无缓存),无需处理。 + if r.URL.Query().Get("refresh") == "1" { + p.cfg.Upstream.InvalidateGlobalModelsCache() + } + // CN 域:有可用 CN 账号才查(此前无条件 Pool.Pick()+FetchModels——选中 global // 账号时打 CN 端点必然失败,混合池表现为偶发 502,纯 global 池必炸)。 if uids := p.cfg.Pool.AvailableUIDsForRealm("cn"); len(uids) > 0 { @@ -361,7 +406,15 @@ func (p *Panel) models(w http.ResponseWriter, r *http.Request) { writeErr(w, http.StatusServiceUnavailable, "没有可用账号:请先在面板添加账号再查询") return } - writeJSON(w, http.StatusOK, map[string]any{"ok": true, "models": out}) + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "models": out, + // global_source global 目录来源(v3-desktop / v3-union / enterprise)。 + // 前端据此提示「拿到的是回落/降级目录」——issue #216 里用户只看到「少了 11 个 + // 模型、hy4-preview 倍率是 0」,完全不知道已经降级到企业端点(那份目录只有 + // 18 条、倍率还是旧值)。 + "global_source": p.cfg.Upstream.GlobalCatalogSource(), + }) } // panelModelEntry 构造单个模型条目(两域共用):id 带 realm 前缀(调用值即显示值), @@ -515,6 +568,11 @@ func (p *Panel) accountCheckin(w http.ResponseWriter, r *http.Request) { writeErr(w, http.StatusNotFound, "account not found") return } + // 静态 API Key 账号无签到/成长任务体系:明确拒绝(不静默空跑),额度刷新请走 /balance。 + if a.IsAPIKey() { + writeErr(w, http.StatusUnprocessableEntity, "API Key 账号无签到/成长任务体系(请用「额度」刷新企业口径额度)") + return + } checkinMsg := "" checkinDone := false // 企业版无签到体系(上游 400 code 10001「企业账号不支持该操作」):不发起该调用, @@ -540,14 +598,21 @@ func (p *Panel) accountCheckin(w http.ResponseWriter, r *http.Request) { if checkinMsg != "" { resp["checkin_message"] = checkinMsg } - remain, total, expiring, earliestAt, earliestRemaining, err := p.cfg.Upstream.UserResourceDetailedWithExpiry(a, p.expiringSoonWindow()) + remain, total, expiring, earliestAt, earliestRemaining, buckets, err := p.cfg.Upstream.UserResourceSnapshot(a, p.expiringSoonWindow()) if err != nil { resp["balance_error"] = err.Error() writeJSON(w, http.StatusOK, resp) return } p.cfg.Pool.ReenableIfCredits(uid, remain, total) - p.cfg.Pool.SetCreditsDetailed(uid, remain, total, expiring, earliestAt, earliestRemaining) + p.cfg.Pool.SetCreditsSnapshot(uid, pool.CreditsSnapshot{ + Remain: remain, + Total: total, + Expiring: expiring, + EarliestAt: earliestAt, + EarliestRemaining: earliestRemaining, + ExpiryBuckets: poolExpiryBuckets(buckets), + }) resp["credits"] = remain resp["credits_total"] = total log.Printf("panel: checkin uid=%s msg=%q credits=%d/%d", uid, checkinMsg, remain, total) @@ -562,12 +627,19 @@ func (p *Panel) accountBalance(w http.ResponseWriter, r *http.Request) { writeErr(w, http.StatusNotFound, "account not found") return } - remain, total, expiring, earliestAt, earliestRemaining, err := p.cfg.Upstream.UserResourceDetailedWithExpiry(a, p.expiringSoonWindow()) + remain, total, expiring, earliestAt, earliestRemaining, buckets, err := p.cfg.Upstream.UserResourceSnapshot(a, p.expiringSoonWindow()) if err != nil { writeErr(w, http.StatusBadGateway, "user resource: "+err.Error()) return } - p.cfg.Pool.SetCreditsDetailed(uid, remain, total, expiring, earliestAt, earliestRemaining) + p.cfg.Pool.SetCreditsSnapshot(uid, pool.CreditsSnapshot{ + Remain: remain, + Total: total, + Expiring: expiring, + EarliestAt: earliestAt, + EarliestRemaining: earliestRemaining, + ExpiryBuckets: poolExpiryBuckets(buckets), + }) writeJSON(w, http.StatusOK, map[string]any{"ok": true, "credits": remain, "credits_total": total}) } @@ -620,6 +692,28 @@ func (p *Panel) travelAll(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"ok": true, "started": true}) } +// accountTravel 手动触发**单个**账号的猫猫旅行巡检(issue #227)。 +// +// 全量 travel_all 只能整池跑(RunTravelNow 无子集参数),且账号间固定 +// 800ms 限速——40 个号就是 32s,运维想单独试一个号只能等全池跑完。 +// 这里给单号一个同步端点:单号无并发压力,故不限速,点完即出结果。 +func (p *Panel) accountTravel(w http.ResponseWriter, r *http.Request) { + if p.cfg.Scheduler == nil { + writeErr(w, http.StatusNotImplemented, "scheduler not available") + return + } + uid := r.PathValue("uid") + ran, skipReason := p.cfg.Scheduler.RunTravelNowFor(uid) + if !ran { + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, "ran": false, "skipped": true, "skip_reason": skipReason, + }) + return + } + log.Printf("panel: 手动旅行巡检已触发 uid=%s", uid) + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "ran": true, "skipped": false}) +} + // activityAll 手动触发全量活跃上报(异步执行;点亮连登 + 解锁领养前置)。 func (p *Panel) activityAll(w http.ResponseWriter, r *http.Request) { if p.cfg.Scheduler == nil { @@ -656,6 +750,92 @@ func (p *Panel) balanceAll(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"ok": true, "accounts": p.cfg.Pool.List()}) } +// compensation 查询全部账号的补偿活动状态(**只读**,不发起任何领取)。 +// +// 与 balanceAll 同构:同步等待而非「触发后盲刷」——账号量小(个位数), +// 面板拿到响应即可直接渲染横幅,不用再轮询。 +func (p *Panel) compensation(w http.ResponseWriter, r *http.Request) { + if p.cfg.Scheduler == nil { + writeErr(w, http.StatusNotImplemented, "scheduler not available") + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "accounts": p.cfg.Scheduler.CompensationStatusAll(), + }) +} + +// accountCompensationClaim 单号领取补偿:查状态 → 待领才领(幂等)。 +// 领完顺带刷一次余额,面板上的积分数字立即反映到账。 +func (p *Panel) accountCompensationClaim(w http.ResponseWriter, r *http.Request) { + if p.cfg.Scheduler == nil { + writeErr(w, http.StatusNotImplemented, "scheduler not available") + return + } + uid := r.PathValue("uid") + res, err := p.cfg.Scheduler.ClaimCompensationFor(uid) + if err != nil { + writeErr(w, http.StatusNotFound, err.Error()) + return + } + if res.Claimed { + p.refreshCreditsQuiet(uid) + } + // ok 反映「是否拿到确定结论」:error 非空即 false。注意前端 api() 只看 + // HTTP 状态码,这个字段是给日志/调用方看的,真正的分流依据是 result.error。 + writeJSON(w, http.StatusOK, map[string]any{"ok": res.Error == "", "result": res}) +} + +// compensationAll 手动全量领取补偿(逐号查状态后领取;无待领的号静默跳过)。 +func (p *Panel) compensationAll(w http.ResponseWriter, r *http.Request) { + if p.cfg.Scheduler == nil { + writeErr(w, http.StatusNotImplemented, "scheduler not available") + return + } + results := p.cfg.Scheduler.ClaimCompensationNow() + for _, res := range results { + if res.Claimed { + p.refreshCreditsQuiet(res.UID) + } + } + summary := scheduler.SummarizeCompensation(results) + log.Printf("panel: 手动全量补偿领取完成 合计=%d 领取=%d(+%dc) 跳过=%d 失败=%d", + summary.Total, summary.Claimed, summary.Credit, summary.Skipped, summary.Errored) + // ok=false 表示有账号没拿到确定结论;前端据此提示「查询失败」而不是 + // 「没有待领补偿」——两者语义完全相反,不能混。 + writeJSON(w, http.StatusOK, map[string]any{ + "ok": summary.OK(), + "summary": summary, + "results": results, + }) +} + +// b2i bool → int(汇总计数用)。 +func b2i(b bool) int { + if b { + return 1 + } + return 0 +} + +// refreshCreditsQuiet 领取成功后刷新该号余额,失败不影响领取结果回传。 +// +// 失败只记日志不返回:领取本身已经成功并落到上游,余额刷新失败只是展示滞后, +// 前端紧接着还会拉一次 overview。静默吞掉会让「积分没变」看起来像没领到。 +func (p *Panel) refreshCreditsQuiet(uid string) { + a := p.cfg.Pool.AuthByUID(uid) + if a == nil { + return + } + remain, total, expiring, earliestAt, earliestRemaining, err := + p.cfg.Upstream.UserResourceDetailedWithExpiry(a, p.expiringSoonWindow()) + if err != nil { + log.Printf("panel: compensation 后刷新余额失败 uid=%s: %v", uid, err) + return + } + p.cfg.Pool.SetCreditsDetailed(uid, remain, total, expiring, earliestAt, earliestRemaining) +} + // syncNicknames 手动刷新时的昵称同步(issue #94:上游改名免重登)。 // 只在面板手动「刷新」路径调用——后台余额定时器不触发(用户明确要求资料接口 // 仅手动触达)。逐号拉 /console/account,只取 nickname(手机号等敏感字段在 @@ -672,6 +852,11 @@ func (p *Panel) syncNicknames() { continue } if a := p.cfg.Pool.AuthByUID(st.UID); a != nil && a.AccessTokenValue() != "" { + // 静态 API Key 账号没有个人账号资料(/console/account 对它无效), + // 发起只会每次多一行「失败」日志,昵称以添加时填写的为准。 + if a.IsAPIKey() { + continue + } jobs = append(jobs, job{uid: st.UID, a: a}) } } diff --git a/internal/panel/taskcenter.go b/internal/panel/taskcenter.go index 6d50e5b4..b5d1a19f 100644 --- a/internal/panel/taskcenter.go +++ b/internal/panel/taskcenter.go @@ -77,9 +77,10 @@ func (p *Panel) tasksScanAll(w http.ResponseWriter, r *http.Request) { if a.IsGlobal() { return } - // 企业版门控:同上——企业版无个人成长体系(GET /v2/activity/growth/tasks - // 上游 403「growth system is only available for personal users」)。 - if a.IsEnterprise() { + // 企业版与静态 API Key 账号门控:同上——二者无个人成长体系 + // (GET /v2/activity/growth/tasks 上游 403「growth system is only available + // for personal users」)。 + if !a.SupportsPersonalGrowth() { return } if tasks, err := p.cfg.Upstream.ListTasks(a); err != nil { @@ -222,8 +223,15 @@ func (p *Panel) startGrowthQueue(concurrency int, growth bool) (started bool, to if a.IsGlobal() { return } - // 企业版门控:同上(企业版无个人成长体系,任务端点上游一律 403)。 - if a.IsEnterprise() { + // 企业版 / 静态 API Key 账号门控:同上(二者无个人成长体系,任务端点上游一律 403)。 + // 企业切换会话(token_source=enterprise_switch)同跳:任务列表读得到, + // 但执行后行为事件被上游静默丢弃(200 不计分)——每项都是零收益白跑 + // (含 expert_5 等真实对话配额),故不入队并给出重登指引(见 + // auth.IsEnterpriseSession / auth.SupportsBehaviorEvents)。 + if !a.SupportsBehaviorEvents() { + if a.IsEnterpriseSession() { + log.Printf("panel: WARN 队列跳过 uid=%s nickname=%q:凭证为企业切换会话(token_source=enterprise_switch),成长任务行为事件会被上游静默丢弃;请移除该账号,在浏览器退出企业切换(个人身份)后重新登录", a.UID, a.Nickname) + } return } if growth { diff --git a/internal/panel/tasks.go b/internal/panel/tasks.go index 45445969..b4bd74bb 100644 --- a/internal/panel/tasks.go +++ b/internal/panel/tasks.go @@ -8,6 +8,7 @@ import ( "encoding/json" "log" "net/http" + "strings" "time" "github.com/linguo2625469/workbuddy2api-panel/internal/auth" @@ -33,6 +34,18 @@ func (p *Panel) accountTasks(w http.ResponseWriter, r *http.Request) { if a == nil { return } + // 静态 API Key 账号无成长任务体系:明确拒绝(不静默空跑)。 + if a.IsAPIKey() { + writeErr(w, http.StatusUnprocessableEntity, "API Key 账号无签到/成长任务体系") + return + } + // 国际版(workbuddy.ai)无国内版成长任务体系:与任务中心队列同口径门控 + // (taskcenter.go 的 D4 门控),否则本路径会静默拉一份字段名不同的任务 + // 列表、解析出空编号,按钮点了没反应(issue #163)。 + if a.IsGlobal() { + writeErr(w, http.StatusUnprocessableEntity, "国际版账号无成长任务体系(该功能仅国内版 workbuddy.cn 支持)") + return + } tasks, err := p.cfg.Upstream.ListTasks(a) if err != nil { writeErr(w, http.StatusBadGateway, "list tasks: "+err.Error()) @@ -69,6 +82,25 @@ func (p *Panel) accountTaskAccept(w http.ResponseWriter, r *http.Request) { writeErr(w, http.StatusBadRequest, "task_codes required") return } + // 国际版账号无成长任务体系:明确拒绝(不静默空跑,issue #163)。 + if a.IsGlobal() { + writeErr(w, http.StatusUnprocessableEntity, "国际版账号无成长任务体系(该功能仅国内版 workbuddy.cn 支持)") + return + } + // 空编号过滤:上游解析不出 task_code(国际版字段名不同 / 上游缺字段)时 + // 面板会拿到 codes=[""],提交上去只会得到 task not found,却仍回 ok:true + // ——这里提前拦下并把真实问题透给前端(issue #163)。 + codes := make([]string, 0, len(body.TaskCodes)) + for _, c := range body.TaskCodes { + if strings.TrimSpace(c) != "" { + codes = append(codes, c) + } + } + if len(codes) == 0 { + writeErr(w, http.StatusBadRequest, "task_codes 为空:上游未返回可接受的任务编号(该账号可能不支持成长任务体系)") + return + } + body.TaskCodes = codes if err := p.cfg.Upstream.AcceptTasks(a, body.TaskCodes); err != nil { writeErr(w, http.StatusBadGateway, "accept: "+err.Error()) return @@ -89,6 +121,16 @@ func (p *Panel) taskAcceptAll(w http.ResponseWriter, r *http.Request) { if a == nil { return } + // 静态 API Key 账号无成长任务体系:明确拒绝(不静默空跑)。 + if a.IsAPIKey() { + writeErr(w, http.StatusUnprocessableEntity, "API Key 账号无签到/成长任务体系") + return + } + // 国际版账号无成长任务体系:明确拒绝(与任务中心队列同口径,issue #163)。 + if a.IsGlobal() { + writeErr(w, http.StatusUnprocessableEntity, "国际版账号无成长任务体系(该功能仅国内版 workbuddy.cn 支持)") + return + } tasks, err := p.cfg.Upstream.ListTasks(a) if err != nil { writeErr(w, http.StatusBadGateway, "list tasks: "+err.Error()) @@ -100,6 +142,10 @@ func (p *Panel) taskAcceptAll(w http.ResponseWriter, r *http.Request) { if t.Claimed || t.Locked || t.AcceptStatus == "accepted" || t.AcceptStatus == "completed" { continue } + // 编号为空的条目上游必然 task not found(issue #163)。 + if strings.TrimSpace(t.TaskCode) == "" { + continue + } codes = append(codes, t.TaskCode) } if len(codes) == 0 { diff --git a/internal/panel/tasksweep.go b/internal/panel/tasksweep.go new file mode 100644 index 00000000..25a00ae5 --- /dev/null +++ b/internal/panel/tasksweep.go @@ -0,0 +1,182 @@ +// tasksweep.go 周期幂等「补领清扫」。 +// +// 职责单一——只 accept 尚未接受的任务、只 claim 已达标未领的任务,**不跑任何 +// 行为链路**(不真实对话、不上报事件)。来源见 issue #174: +// +// - growthPending 要求任务必须有自动动作(25 个 autoActions 白名单),不在 +// 白名单的任务即使达标也永远扫不进队列、永远不领奖; +// - 队列路径要先重跑一遍行为动作(可能失败/前置条件已变),且一天只跑一次; +// - 「一键完成」对已达标任务直接跳过不领。 +// +// 三条链路都不负责「把已达标的奖励领掉」,于是积分卡在 2000/2100/2300 上不去 4000。 +// 本文件补的就是这条缺失链路:分钟级周期、幂等、可开关。 +// +// 设计要点(与 issue 一致): +// - 判据只用 progress 口径 Task.Claimable(!claimed && target>0 && current>=target), +// **不用 accept_status == "completed"**:它在回读时会短暂转 completed 后整体回滚, +// 据此领奖会误领。 +// - claim 幂等(重复领返回 already_claimed → 0 分 0 能),宁可多试一轮也不漏领。 +// - 与单任务 / 一键完成 / 成长队列共用 per-account 锁:正在跑动作的账号本轮跳过, +// 不打断正在进行的动作链路。 +// - global(无 CN 成长体系)与企业版(上游 403 growth system is only available +// for personal users)账号都不发起任何上游请求。 +package panel + +import ( + "log" + "sync" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" +) + +// TaskSweepStats 一轮补领清扫的汇总(供日志与潜在的未来 HTTP 回执)。 +type TaskSweepStats struct { + Accounts int `json:"accounts"` // 参与清扫的账号数 + Skipped int `json:"skipped"` // 因正在执行任务动作而跳过的账号数 + Accepted int `json:"accepted"` // 本轮接受成功的任务数 + Claimed int `json:"claimed"` // 本轮领奖成功的任务数 + Credits int64 `json:"credits"` // 本轮到账积分 + Energy int64 `json:"energy"` // 本轮到账能量 + Errors int `json:"errors"` // 本轮出错(不中断)的次数 +} + +// RunTaskSweepOnce 对所有账号做一轮幂等补领清扫(账号间并发、账号内串行)。 +// 由 scheduler 的 ticker 调用(详见 scheduler.StartTaskSweep)。 +func (p *Panel) RunTaskSweepOnce() TaskSweepStats { + var st TaskSweepStats + var mu sync.Mutex + var wg sync.WaitGroup + + for _, ps := range p.cfg.Pool.List() { + if ps.Disabled { + continue // 禁用账号不参与(与任务中心扫描同口径) + } + a := p.cfg.Pool.AuthByUID(ps.UID) + if a == nil || a.IsGlobal() || a.IsEnterprise() { + continue // 无个人成长体系:零上游请求 + } + wg.Add(1) + go func(a *auth.Auth, uid string) { + defer wg.Done() + r := p.sweepAccount(a, uid) + mu.Lock() + defer mu.Unlock() + st.Accounts += r.accounts + st.Skipped += r.skipped + st.Accepted += r.accepted + st.Claimed += r.claimed + st.Credits += r.credit + st.Energy += r.energy + st.Errors += r.errors + }(a, ps.UID) + } + wg.Wait() + + if st.Claims() > 0 || st.Errors > 0 || st.Accepted > 0 { + log.Printf("panel: 任务补领清扫:%d 个账号(跳过 %d)· 接受 %d · 领奖 %d · +%d 分 +%d 能 · 出错 %d", + st.Accounts, st.Skipped, st.Accepted, st.Claimed, st.Credits, st.Energy, st.Errors) + } + return st +} + +// Claims 本轮实际到账奖励的任务数(与 Claimed 一致,语义化别名)。 +func (s TaskSweepStats) Claims() int { return s.Claimed } + +// sweepResult 单账号清扫结果(RunTaskSweepOnce 汇总用,非导出)。 +type sweepResult struct { + accounts, skipped, accepted, claimed, errors int + credit, energy int64 +} + +// sweepAccount 单账号一轮清扫。命中账号锁(正在跑任务动作)时整账号跳过: +// 不打断进行中的链路,下一轮自然补上。 +func (p *Panel) sweepAccount(a *auth.Auth, uid string) (res sweepResult) { + res.accounts = 1 + if !p.tryLockAccount(uid) { + res.skipped = 1 + return res + } + defer p.unlockAccount(uid) + + tasks, err := p.cfg.Upstream.ListTasks(a) + if err != nil { + res.errors++ + return res + } + // 小程序口径列表(school_season / Sequential_Tasks_1 等仅在 mp 头下发): + // 与默认口径按 task_code 去重合并;mp 列表失败静默(活动结束 / 无 mp 任务)。 + mpOnly := map[string]bool{} + if mpTasks, mErr := p.cfg.Upstream.ListTasksMP(a); mErr == nil { + seen := map[string]bool{} + for _, t := range tasks { + seen[t.TaskCode] = true + } + for _, t := range mpTasks { + if seen[t.TaskCode] { + continue + } + mpOnly[t.TaskCode] = true + tasks = append(tasks, t) + } + } + + // 阶段一:接受未接受的任务。默认口径与 mp 口径必须**分开提交**(端标记不同, + // 混提交会让 mp 任务报 task not found)。 + var codes, mpCodes []string + for _, t := range tasks { + if t.Claimed || t.Locked { + continue + } + if t.AcceptStatus == "accepted" || t.AcceptStatus == "completed" { + continue + } + if isMPTaskCode(t.TaskCode) || mpOnly[t.TaskCode] { + mpCodes = append(mpCodes, t.TaskCode) + } else { + codes = append(codes, t.TaskCode) + } + } + acceptCodes := func(cs []string, isMP bool) { + if len(cs) == 0 { + return + } + var aErr error + if isMP { + aErr = p.cfg.Upstream.AcceptTasksMP(a, cs) + } else { + aErr = p.cfg.Upstream.AcceptTasks(a, cs) + } + if aErr != nil { + res.errors++ + return + } + res.accepted += len(cs) + } + acceptCodes(codes, false) + acceptCodes(mpCodes, true) + + // 阶段二:领取已达标的奖励。claim 幂等(重复领 already_claimed → 0 分 0 能), + // 单项出错只记录、不中断后续任务。 + for _, t := range tasks { + if !t.Claimable { + continue + } + // mp 口径任务必须走 mp 变体领奖端点(缺 mp 头上游返回 task not found); + // 其余走 Web 域端点——与 taskcenter / autotask 的领奖口径一致。 + var c, e int64 + var cErr error + if isMPTaskCode(t.TaskCode) || mpOnly[t.TaskCode] { + c, e, cErr = p.cfg.Upstream.ClaimRewardMP(a, t.TaskCode) + } else { + c, e, cErr = p.cfg.Upstream.ClaimReward(a, t.TaskCode) + } + if cErr != nil { + res.errors++ + continue + } + res.credit += c + res.energy += e + res.claimed++ + } + return res +} diff --git a/internal/panel/tasksweep_test.go b/internal/panel/tasksweep_test.go new file mode 100644 index 00000000..e1bda37d --- /dev/null +++ b/internal/panel/tasksweep_test.go @@ -0,0 +1,274 @@ +package panel + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" + "github.com/linguo2625469/workbuddy2api-panel/internal/pool" + "github.com/linguo2625469/workbuddy2api-panel/internal/upstream" +) + +// sweepTask 构造假上游的一个任务条目。 +func sweepTask(code string, target, current int64, acceptStatus string, extra map[string]any) map[string]any { + t := map[string]any{ + "task_code": code, "title": code, "target": target, + "current": current, "accept_status": acceptStatus, + "reward_credit": 100, "reward_energy": 5, + } + for k, v := range extra { + t[k] = v + } + return t +} + +// sweepBackend 假上游:默认任务列表 + mp 列表 + accept + claim,带请求计数。 +type sweepBackend struct { + listFn func(isMP bool) []map[string]any + acceptFn func(codes []string, isMP bool) (int, string) // (status, body) + claimFn func(code string) (int, map[string]any) // (status, data) + + reqs atomic.Int32 + accepts atomic.Int32 + claims atomic.Int32 +} + +func sweepTasksBody(tasks []map[string]any) string { + b, _ := json.Marshal(map[string]any{"code": 0, "msg": "OK", "data": map[string]any{"tasks": tasks}}) + return string(b) +} + +func (b *sweepBackend) handler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + b.reqs.Add(1) + isMP := r.Header.Get("X-Client-Platform") == "miniprogram" + switch { + case r.URL.Path == "/v2/activity/growth/tasks": + if b.listFn != nil { + w.Write([]byte(sweepTasksBody(b.listFn(isMP)))) + return + } + w.Write([]byte(sweepTasksBody(nil))) + case strings.HasSuffix(r.URL.Path, "/accept"): + var body struct { + TaskCodes []string `json:"task_codes"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + for range body.TaskCodes { + b.accepts.Add(1) + } + deferFN := func() { + w.Write([]byte(`{"code":0,"msg":"OK","data":{}}`)) + } + if b.acceptFn == nil { + deferFN() + return + } + if status, msg := b.acceptFn(body.TaskCodes, isMP); status != 0 { + w.WriteHeader(status) + w.Write([]byte(msg)) + return + } + deferFN() + case strings.HasSuffix(r.URL.Path, "/claim"): + code := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/activity/growth/tasks/"), "/claim") + b.claims.Add(1) + if b.claimFn == nil { + w.Write([]byte(`{"code":0,"msg":"OK","data":{"already_claimed":true}}`)) + return + } + status, data := b.claimFn(code) + if status != 0 { + w.WriteHeader(status) + } + if data == nil { + data = map[string]any{"already_claimed": true} + } + out, _ := json.Marshal(map[string]any{"code": 0, "msg": "OK", "data": data}) + w.Write(out) + default: + w.Write([]byte(`{"code":0,"msg":"OK","data":{}}`)) + } + } +} + +// newSweepPanel 用给定假后端与账号搭一个可被 RunTaskSweepOnce 直接调用的 Panel。 +func newSweepPanel(t *testing.T, b *sweepBackend, accounts ...*auth.Auth) *Panel { + t.Helper() + srv := httptest.NewServer(b.handler()) + t.Cleanup(srv.Close) + p := pool.New("") + for _, a := range accounts { + p.Add(a) + } + return &Panel{cfg: Config{ + Pool: p, + Upstream: &upstream.Client{ + HTTP: srv.Client(), ChatBaseCN: srv.URL, BillingBaseCN: srv.URL, WebBaseCN: srv.URL, + }, + }} +} + +func claimOK(data map[string]any) (int, map[string]any) { return 0, data } + +// TestTaskSweepAcceptAndClaim 单轮清扫:未接受的任务走 accept(默认/mp 分开提交)、 +// 已达标的走 claim,其余(已领取 / locked / 未达标)不产生任何请求。 +func TestTaskSweepAcceptAndClaim(t *testing.T) { + b := &sweepBackend{ + listFn: func(isMP bool) []map[string]any { + if isMP { + return []map[string]any{ + sweepTask("school_season", 1, 1, "accepted", nil), // mp 口径达标任务 + } + } + return []map[string]any{ + sweepTask("chat_5", 5, 5, "accepted", nil), // 达标未领 → claim + sweepTask("invite_1", 1, 0, "not_accepted", nil), // 未接受 → accept + sweepTask("locked_1", 3, 0, "not_accepted", map[string]any{"locked": true}), + sweepTask("halfway", 10, 3, "accepted", nil), // 未达标 → 不动 + sweepTask("done_1", 5, 5, "claimed", nil), // 已领取 → 不动 + } + }, + claimFn: func(code string) (int, map[string]any) { + return claimOK(map[string]any{"already_claimed": false, "credit": 100, "energy": 5}) + }, + } + p := newSweepPanel(t, b, &auth.Auth{UID: "u1", AccessToken: "at"}) + st := p.RunTaskSweepOnce() + + if st.Accounts != 1 || st.Skipped != 0 { + t.Fatalf("accounts=%d skipped=%d, want 1/0", st.Accounts, st.Skipped) + } + // invite_1 需要 accept(mp 列表里的 school_season 已 accepted,不再接受)。 + if st.Accepted != 1 { + t.Errorf("accepted = %d, want 1(只有 invite_1 未接受)", st.Accepted) + } + // chat_5 与 school_season 均达标未领 → 各领一次。 + if st.Claimed != 2 || st.Credits != 200 || st.Energy != 10 { + t.Errorf("claimed=%d credits=%d energy=%d, want 2/200/10", st.Claimed, st.Credits, st.Energy) + } + if st.Errors != 0 { + t.Errorf("errors = %d, want 0", st.Errors) + } + if got := b.claims.Load(); got != 2 { + t.Errorf("claim 请求数 = %d, want 2", got) + } +} + +// TestTaskSweepIdempotent 两轮幂等:第二轮任务已被标记为 claimed(不再 Claimable), +// 不应重复打领奖端点;即便上游返回 already_claimed 也不计错误。 +func TestTaskSweepIdempotent(t *testing.T) { + claimed := map[string]bool{} + b := &sweepBackend{ + listFn: func(isMP bool) []map[string]any { + if isMP { + return nil + } + status := "accepted" + if claimed["chat_5"] { + status = "claimed" + } + return []map[string]any{sweepTask("chat_5", 5, 5, status, nil)} + }, + claimFn: func(code string) (int, map[string]any) { + claimed[code] = true + return claimOK(map[string]any{"already_claimed": false, "credit": 100, "energy": 5}) + }, + } + p := newSweepPanel(t, b, &auth.Auth{UID: "u1", AccessToken: "at"}) + + first := p.RunTaskSweepOnce() + if first.Claimed != 1 || first.Credits != 100 { + t.Fatalf("first round: claimed=%d credits=%d, want 1/100", first.Claimed, first.Credits) + } + // 第二轮:状态已 claimed → 不再 Claimable → 领奖端点 0 次调用。 + b.claims.Store(0) + second := p.RunTaskSweepOnce() + if second.Claimed != 0 || second.Errors != 0 { + t.Errorf("second round: claimed=%d errors=%d, want 0/0", second.Claimed, second.Errors) + } + if got := b.claims.Load(); got != 0 { + t.Errorf("第二轮不应再打领奖端点,got %d", got) + } +} + +// TestTaskSweepSkipsGlobalAndEnterprise global / 企业版账号无个人成长体系: +// 零上游请求(与任务中心扫描同一门控)。 +func TestTaskSweepSkipsGlobalAndEnterprise(t *testing.T) { + b := &sweepBackend{} + p := newSweepPanel(t, b, + &auth.Auth{UID: "g1", AccessToken: "at", Domain: "www.workbuddy.ai"}, + &auth.Auth{UID: "e1", AccessToken: "at", EnterpriseID: "ent-1"}, + ) + st := p.RunTaskSweepOnce() + if st.Accounts != 0 || st.Errors != 0 || st.Claimed != 0 { + t.Errorf("stats = %+v, want 全零", st) + } + if got := b.reqs.Load(); got != 0 { + t.Fatalf("global/企业版账号不应产生任何上游请求,got %d", got) + } +} + +// TestTaskSweepClaimErrorDoesNotBlock 单个领奖失败只计 errors,不中断后续任务, +// 也不掩盖其他任务的成功。 +func TestTaskSweepClaimErrorDoesNotBlock(t *testing.T) { + b := &sweepBackend{ + listFn: func(isMP bool) []map[string]any { + if isMP { + return nil + } + return []map[string]any{ + sweepTask("bad_1", 5, 5, "accepted", nil), + sweepTask("good_1", 5, 5, "accepted", nil), + } + }, + claimFn: func(code string) (int, map[string]any) { + if code == "bad_1" { + return 400, map[string]any{"code": 400, "msg": "task not completed"} + } + return claimOK(map[string]any{"already_claimed": false, "credit": 100, "energy": 5}) + }, + } + p := newSweepPanel(t, b, &auth.Auth{UID: "u1", AccessToken: "at"}) + st := p.RunTaskSweepOnce() + if st.Errors != 1 { + t.Errorf("errors = %d, want 1", st.Errors) + } + if st.Claimed != 1 || st.Credits != 100 { + t.Errorf("claimed=%d credits=%d, want 1/100(失败项不阻挡成功项)", st.Claimed, st.Credits) + } +} + +// TestTaskSweepSkipsBusyAccount 账号正在跑任务动作(持有 per-account 锁)时整账号 +// 跳过:不打断进行中的链路,下一轮自然补。 +func TestTaskSweepSkipsBusyAccount(t *testing.T) { + b := &sweepBackend{ + listFn: func(isMP bool) []map[string]any { + if isMP { + return nil + } + return []map[string]any{sweepTask("chat_5", 5, 5, "accepted", nil)} + }, + } + p := newSweepPanel(t, b, &auth.Auth{UID: "u1", AccessToken: "at"}) + if !p.tryLockAccount("u1") { + t.Fatal("模拟占用失败") + } + st := p.RunTaskSweepOnce() + if st.Skipped != 1 || st.Claimed != 0 { + t.Errorf("stats = %+v, want skipped=1 claimed=0", st) + } + if got := b.reqs.Load(); got != 0 { + t.Fatalf("忙账号不应产生上游请求,got %d", got) + } + p.unlockAccount("u1") + // 解锁后同一轮即可正常清扫(下一周期自动补)。 + st = p.RunTaskSweepOnce() + if st.Skipped != 0 || st.Claimed != 1 { + t.Errorf("解锁后 stats = %+v, want skipped=0 claimed=1", st) + } +} diff --git a/internal/pool/burnguard.go b/internal/pool/burnguard.go new file mode 100644 index 00000000..e93f0ff6 --- /dev/null +++ b/internal/pool/burnguard.go @@ -0,0 +1,157 @@ +// burnguard.go 积分燃尽熔断(issue #229)。 +// +// 问题:某些模型(实测 hy4-preview)单轮 140k 上下文就要烧 20+ 积分, +// 而前缀缓存命中率极低(26%)。用户一个长会话下来,积分肉眼可见地蒸发, +// 但网关此前**没有任何额度护栏**——只管把请求转给上游。 +// +// 诉求(issue #229 作者原话):「短时间消耗积分过多 / 连续多轮缓存率极低 +// → 触发时拒绝继续对话」。 +// +// 做法:在**账号**维度做滑动窗口计账。窗口内累计烧掉的积分超过阈值时, +// 该账号进入 burn-cooldown(软冷却的一种),在一段时间内不再被选号命中; +// 面板显式展示原因,用户可手动解除(暂停/恢复选号或刷新余额即清零)。 +// +// 设计取舍: +// - 熔断是**账号级**而非全局级:池里还有其它账号时,请求照常走别的号, +// 不会让整个网关不可用(真到"全部烧完"时自然会 no_healthy_account)。 +// - 阈值默认 0 = 关闭(零回归):不配置就不熔断,行为与引入前完全一致。 +// - 只统计**有上游 credit 观测**的消耗:免费请求(credit=0)不计。 +// - 冷却时长随倍率递增(线性封顶),避免"刚好过线就恢复、马上又烧穿"。 + +package pool + +import ( + "log" + "strconv" + "time" + + "github.com/linguo2625469/workbuddy2api-panel/internal/logfmt" +) + +// burnWindow 单账号的滑动窗口计账。 +type burnWindow struct { + // windowStart 窗口起点(窗口滚动时重置)。 + windowStart time.Time + // spent 窗口内累计烧掉的积分。 + spent float64 + // trips 触发熔断次数(用于冷却时长递增)。 + trips int + // until 熔断解除时刻(零值 = 未熔断)。 + until time.Time +} + +// burnGuard 参数默认值。 +const ( + // defaultBurnGuardLimit 窗口内允许烧掉的积分上限(0 = 关闭熔断)。 + defaultBurnGuardLimit = 0.0 + // defaultBurnGuardWindow 计账窗口长度。 + defaultBurnGuardWindow = 10 * time.Minute + // defaultBurnGuardCooldown 首次熔断的冷却时长。 + defaultBurnGuardCooldown = 10 * time.Minute + // defaultBurnGuardCooldownMax 冷却时长上限。 + defaultBurnGuardCooldownMax = 2 * time.Hour +) + +// SetBurnGuard 注入积分燃尽熔断参数(issue #229)。 +// +// - limit:窗口内允许烧掉的积分上限;<= 0 = 关闭(默认,零回归)。 +// - window:计账窗口;<= 0 = 用默认 10m。 +// - cooldown / cooldownMax:熔断冷却时长与上限;<= 0 = 用默认。 +func (p *Pool) SetBurnGuard(limit float64, window, cooldown, cooldownMax time.Duration) { + p.mu.Lock() + defer p.mu.Unlock() + p.burnGuardLimit = limit + if window <= 0 { + window = defaultBurnGuardWindow + } + p.burnGuardWindow = window + if cooldown <= 0 { + cooldown = defaultBurnGuardCooldown + } + p.burnGuardCooldown = cooldown + if cooldownMax <= 0 { + cooldownMax = defaultBurnGuardCooldownMax + } + p.burnGuardCooldownMax = cooldownMax +} + +// NoteBurn 记录一次积分消耗并在超限时熔断该账号(issue #229)。 +// +// credit <= 0(免费请求)不计账。窗口过期后重新起窗。 +// 返回 true 表示本次调用触发了熔断。 +func (p *Pool) NoteBurn(uid string, credit float64) bool { + if uid == "" || credit <= 0 { + return false + } + p.mu.Lock() + defer p.mu.Unlock() + if p.burnGuardLimit <= 0 { + return false // 熔断未启用 + } + if _, ok := p.byUID[uid]; !ok { + return false + } + now := time.Now() + bw := p.burnGuard[uid] + if bw == nil { + bw = &burnWindow{windowStart: now} + p.burnGuard[uid] = bw + } + // 窗口滚动:窗口长度已过 → 重置计账(但不重置熔断状态 trips/until)。 + // + // 注意:这里判的是「**已过去一个窗口**」,而不是「距上次记账已过一个窗口」: + // 后者会让连续小额的请求永远滚不出窗口(每笔都把 windowStart 推到当下, + // spent 从不清零)——那正是熔断要防的场景。窗口是**固定起点**的滚动窗口, + // 超限判定读的是该窗口内的累计值。 + if now.Sub(bw.windowStart) >= p.burnGuardWindow { + bw.windowStart = now + bw.spent = 0 + } + bw.spent += credit + if bw.spent <= p.burnGuardLimit { + return false + } + // 超限:熔断。冷却时长随触发次数递增(线性,封顶)。 + bw.trips++ + dur := p.burnGuardCooldown + if extra := time.Duration(bw.trips-1) * p.burnGuardCooldown; extra > 0 { + dur += extra + } + if max := p.burnGuardCooldownMax; dur > max { + dur = max + } + bw.until = now.Add(dur) + log.Printf("WARN: [pool] burn guard: acct=%s spent=%.1f credits in %s (limit=%.1f) → 熔断 %s", + logfmt.UID8(uid), bw.spent, p.burnGuardWindow, p.burnGuardLimit, dur) + return true +} + +// burnBlocked 报告账号是否处于积分燃尽熔断中。 +func (p *Pool) burnBlocked(e *entry, now time.Time) bool { + if p.burnGuardLimit <= 0 || e == nil { + return false + } + bw := p.burnGuard[e.a.UID] + if bw == nil || bw.until.IsZero() { + return false + } + return bw.until.After(now) +} + +// burnReason 返回熔断原因文案(未熔断返回空串)。 +func (p *Pool) burnReason(uid string, now time.Time) string { + if p.burnGuardLimit <= 0 { + return "" + } + bw := p.burnGuard[uid] + if bw == nil || bw.until.IsZero() || !bw.until.After(now) { + return "" + } + return "积分燃尽熔断:窗口内消耗 " + trimFloat(bw.spent) + " 积分超过阈值 " + trimFloat(p.burnGuardLimit) + + ",冷却至 " + bw.until.Format("15:04:05") +} + +// trimFloat 去掉浮点尾巴(面板文案用)。 +func trimFloat(v float64) string { + return strconv.FormatFloat(v, 'f', -1, 64) +} diff --git a/internal/pool/burnguard_test.go b/internal/pool/burnguard_test.go new file mode 100644 index 00000000..a6d9c726 --- /dev/null +++ b/internal/pool/burnguard_test.go @@ -0,0 +1,72 @@ +package pool + +import ( + "testing" + "time" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" +) + +// TestBurnGuardDisabledByDefault 验证默认关闭熔断(零回归): +// 不配置阈值时,记再多的消耗也不会熔断,账号始终可选。 +func TestBurnGuardDisabledByDefault(t *testing.T) { + p := New("") + a := &auth.Auth{UID: "u1"} + p.Add(a) + // 连续烧 1000 积分 + for i := 0; i < 100; i++ { + if p.NoteBurn(a.UID, 10) { + t.Fatalf("熔断在未配置阈值时不应触发(第 %d 次)", i) + } + } + if got := p.Pick(); got == nil { + t.Fatal("未启用熔断时账号应仍可被选中") + } +} + +// TestBurnGuardTripsOnLimit 验证超限熔断:窗口内累计消耗超过阈值后, +// 该账号不再被 pick 选中,Status 透出 burn 原因。 +func TestBurnGuardTripsOnLimit(t *testing.T) { + p := New("") + a := &auth.Auth{UID: "u1"} + p.Add(a) + p.SetBurnGuard(50, time.Minute, time.Minute, time.Hour) + + if p.NoteBurn(a.UID, 20) { + t.Fatal("未达阈值不应熔断") + } + if p.Pick() == nil { + t.Fatal("未熔断前账号应可被选中") + } + if !p.NoteBurn(a.UID, 40) { + t.Fatal("累计 60 > 阈值 50 应触发熔断") + } + if p.Pick() != nil { + t.Fatal("熔断期内账号不应被选中") + } + st := p.statusOf(a.UID, p.byUID[a.UID]) + if st.CoolKind != "burn" { + t.Fatalf("CoolKind = %q,期望 burn", st.CoolKind) + } + if st.Reason == "" { + t.Fatal("熔断原因应透出到 Status.Reason") + } +} + +// TestBurnGuardWindowRollover 验证窗口滑动:窗口过期后重新计账, +// 不再处于熔断状态(直到再次超限)。 +func TestBurnGuardWindowRollover(t *testing.T) { + p := New("") + a := &auth.Auth{UID: "u1"} + p.Add(a) + p.SetBurnGuard(50, 50*time.Millisecond, time.Minute, time.Hour) + + if !p.NoteBurn(a.UID, 60) { + t.Fatal("应触发熔断") + } + // 窗口过期 → 重新起窗计账;再次熔断需要新的累计超限 + time.Sleep(60 * time.Millisecond) + if p.NoteBurn(a.UID, 10) { + t.Fatal("窗口滚动后 10 积分不应再次触发熔断") + } +} diff --git a/internal/pool/cooldown.go b/internal/pool/cooldown.go index 90c29eb7..70c1dfe1 100644 --- a/internal/pool/cooldown.go +++ b/internal/pool/cooldown.go @@ -5,6 +5,7 @@ package pool import ( "log" + "sort" "strings" "time" ) @@ -63,40 +64,106 @@ func (p *Pool) NoteCheckinDone(uid string) { } } -// SetCreditsDetailed 更新账号余额/总额、配置窗口内的快过架子集,以及最早未来 -// 到期批次。earliestAt 为零或不在未来时清空最早批次;expiring/earliestRemaining -// 均钳到 [0, credits],避免上游脏数据污染选号。 -func (p *Pool) SetCreditsDetailed(uid string, credits, total, expiring int64, earliestAt time.Time, earliestRemaining int64) { +// CreditsSnapshot 一次余额查询的完整结果(含按日到期批次)。 +// 调度器/面板把 upstream.UserResourceSnapshot 的输出转换成本结构后写入池。 +type CreditsSnapshot struct { + Remain int64 + Total int64 + Expiring int64 + EarliestAt time.Time + EarliestRemaining int64 + ExpiryBuckets []ExpiryBucket +} + +// SetCreditsSnapshot 更新账号余额/总额、窗口内快过期子集、最早到期批次与按日到期批次。 +// 有 buckets 时以 buckets 为准重算最早批次(同一天多个包合计);无 buckets 时沿用 +// 入参 earliestAt/earliestRemaining(兼容旧调用与测试)。所有数值钳到 [0, credits], +// 避免上游脏数据污染选号。 +func (p *Pool) SetCreditsSnapshot(uid string, s CreditsSnapshot) { p.mu.Lock() defer p.mu.Unlock() - if e, ok := p.byUID[uid]; ok { - if credits < 0 { - credits = 0 - } - if expiring < 0 { - expiring = 0 - } - if expiring > credits { - expiring = credits - } - now := time.Now() - if earliestRemaining < 0 { - earliestRemaining = 0 + e, ok := p.byUID[uid] + if !ok { + return + } + credits := s.Remain + if credits < 0 { + credits = 0 + } + expiring := s.Expiring + if expiring < 0 { + expiring = 0 + } + if expiring > credits { + expiring = credits + } + now := time.Now() + buckets := sanitizeExpiryBuckets(s.ExpiryBuckets, now) + earliestAt, earliestRemaining := s.EarliestAt, s.EarliestRemaining + if len(buckets) > 0 { + earliestAt = buckets[0].ExpireAt + earliestRemaining = buckets[0].Remain + } + if earliestRemaining < 0 { + earliestRemaining = 0 + } + if earliestRemaining > credits { + earliestRemaining = credits + } + if earliestAt.IsZero() || !earliestAt.After(now) || earliestRemaining == 0 { + earliestAt = time.Time{} + earliestRemaining = 0 + } + e.credits = credits + e.creditsTotal = s.Total + e.creditsExpiring = expiring + e.creditsEarliestExpiry = earliestAt + e.creditsEarliestRemaining = earliestRemaining + e.expiryBuckets = buckets + p.dirty.Store(true) +} + +// SetCreditsDetailed 旧入口:只有聚合余额与最早一批,没有按日批次。 +// 保留给测试与只关心最早批次的调用方;生产路径统一走 SetCreditsSnapshot。 +func (p *Pool) SetCreditsDetailed(uid string, credits, total, expiring int64, earliestAt time.Time, earliestRemaining int64) { + p.SetCreditsSnapshot(uid, CreditsSnapshot{ + Remain: credits, + Total: total, + Expiring: expiring, + EarliestAt: earliestAt, + EarliestRemaining: earliestRemaining, + }) +} + +// sanitizeExpiryBuckets 深拷贝并清洗:只保留有日期、未过期、正剩余的桶,按日期升序。 +// ExpireAt 缺失时按日期补当天 00:00(本地时区),保证排序与最早批次仍可用。 +func sanitizeExpiryBuckets(in []ExpiryBucket, now time.Time) []ExpiryBucket { + if len(in) == 0 { + return nil + } + today := now.Format("2006-01-02") + out := make([]ExpiryBucket, 0, len(in)) + for _, b := range in { + if b.Remain <= 0 || b.Date == "" || b.Date < today { + continue } - if earliestRemaining > credits { - earliestRemaining = credits + if b.ExpireAt.IsZero() { + if t, err := time.ParseInLocation("2006-01-02", b.Date, time.Local); err == nil { + b.ExpireAt = t + } } - if earliestAt.IsZero() || !earliestAt.After(now) || earliestRemaining == 0 { - earliestAt = time.Time{} - earliestRemaining = 0 + if b.ExpireAt.IsZero() || !b.ExpireAt.After(now) { + continue } - e.credits = credits - e.creditsTotal = total - e.creditsExpiring = expiring - e.creditsEarliestExpiry = earliestAt - e.creditsEarliestRemaining = earliestRemaining - p.dirty.Store(true) + out = append(out, b) } + sort.Slice(out, func(i, j int) bool { + if out[i].Date != out[j].Date { + return out[i].Date < out[j].Date + } + return out[i].ExpireAt.Before(out[j].ExpireAt) + }) + return out } // ClearExpiringSnapshots 清空所有账号的快过期/最早到期缓存。配置窗口改变时调用, @@ -273,10 +340,9 @@ func (p *Pool) BlockModelBackoff(uid, model, reason string) { p.dirty.Store(true) } -// BlockModelClear 清除 (账号, 模型) 的 11102 负缓存条目(该模型实测又通了)。半开探测 -// 或正常请求对该模型成功后调用(handler 成功路径)。只清 11102 条目、不碰 6004 独立 -// 冷却表——6004 有自身上游重置墙钟语义,成功不该抹掉。reason 前缀判定区分两者: -// 11102 条目的 reason 恒以 "11102" 开头(见 upstream.BlockModelReason)。 +// BlockModelClear 清除 (账号, 模型) 的确定性模型拒绝负缓存(11102/11158;该模型 +// 实测又通了)。半开探测或正常请求对该模型成功后调用(handler 成功路径)。只清 +// 这两类条目、不碰 6004 独立冷却表——6004 有自身上游重置墙钟语义,成功不该抹掉。 func (p *Pool) BlockModelClear(uid, model string) { if uid == "" || model == "" { return @@ -288,7 +354,7 @@ func (p *Pool) BlockModelClear(uid, model string) { return } mc, exists := e.modelCooldowns[model] - if !exists || !strings.HasPrefix(mc.Reason, "11102") { + if !exists || (!strings.HasPrefix(mc.Reason, "11102") && !strings.HasPrefix(mc.Reason, "11158")) { return } delete(e.modelCooldowns, model) @@ -321,6 +387,13 @@ type ModelBlockStatus struct { // 口径必须与选号一致:用 modelCooled 而非直接查 map,这样 AuditOnly 条目 // (只审计不拦截)不会被误报成阻塞。 func (p *Pool) ModelBlocked(model string) ModelBlockStatus { + return p.ModelBlockedForRealm(model, "") +} + +// ModelBlockedForRealm 与 ModelBlocked 同口径,但只统计指定 realm 的可选账号。 +// 模型请求已按 realm 分池时(如 global:gpt-5.6-sol 只走 global 账号),另一域的 +// 未冷却账号不构成“还能服务”的证明,否则会把本域全锁误报成 no_healthy_account。 +func (p *Pool) ModelBlockedForRealm(model, realm string) ModelBlockStatus { if model == "" { return ModelBlockStatus{} } @@ -330,6 +403,9 @@ func (p *Pool) ModelBlocked(model string) ModelBlockStatus { var st ModelBlockStatus for _, e := range p.byUID { + if realm != "" && e.a.Realm() != realm { + continue + } // 暂停选号(paused)号与禁用号同口径跳过:它此刻不可选,「能服务」的 // 证明不成立——否则一个暂停的健康号会掩盖「其余号全被模型级冷却挡住」。 if e.disabled || e.paused { diff --git a/internal/pool/entry.go b/internal/pool/entry.go index a29d339c..c086d3ac 100644 --- a/internal/pool/entry.go +++ b/internal/pool/entry.go @@ -59,6 +59,14 @@ type TokenUsageDelta struct { TokensPerSecond float64 } +// ExpiryBucket 单个自然日到期的积分合计(面板「到期提醒」快照用)。 +// 与 upstream.ExpiryBucket 同构;单独定义是为了不让底层 pool 反向依赖 upstream。 +type ExpiryBucket struct { + Date string `json:"date"` + ExpireAt time.Time `json:"expires_at,omitempty"` + Remain int64 `json:"remain"` +} + // Status 单个账号对外暴露的状态(脱敏)。 type Status struct { UID string `json:"uid"` @@ -71,7 +79,11 @@ type Status struct { CreditsExpiring int64 `json:"credits_expiring,omitempty"` CreditsEarliestExpiry time.Time `json:"credits_earliest_expiry,omitempty"` CreditsEarliestRemaining int64 `json:"credits_earliest_remaining,omitempty"` - Cooling bool `json:"cooling"` + // CreditsExpiryBuckets 全部未来到期批次按自然日聚合(date/expires_at/remain)。 + // 面板首页「积分到期提醒」快照直接用它渲染,与「检查」的逐包按日汇总同口径; + // 只有最早一批的旧字段无法表达「同一天多个包」「后续批次」「7 天内合计」。 + CreditsExpiryBuckets []ExpiryBucket `json:"credits_expiry_buckets,omitempty"` + Cooling bool `json:"cooling"` CoolKind string `json:"cool_kind,omitempty"` CoolRemaining int64 `json:"cool_remaining_sec,omitempty"` Until time.Time `json:"until,omitempty"` @@ -88,6 +100,10 @@ type Status struct { // 企业版无个人成长体系(签到/成长任务/连登/旅行/夜猫子 上游均拒), // 面板据此隐藏对应按钮;选号、保活、额度展示不受影响。 Enterprise bool `json:"enterprise,omitempty"` + // APIKey 静态 API Key 账号(accessToken 以 ck_/pt_ 开头,auth.IsAPIKey 计算值 + // 不落盘)。同样无个人成长体系且不可刷新,额度走企业口径;面板据此隐藏 + // 成长类按钮、显「静态凭证」,选号与额度展示不受影响。 + APIKey bool `json:"api_key,omitempty"` Disabled bool `json:"disabled"` DisabledReason string `json:"disabled_reason,omitempty"` // 仅 disabled 账号:禁用原因(运维可见) // Paused 暂停选号:退出选号候选(与 disabled 一样不参与选号),但**照常参与** @@ -189,6 +205,9 @@ type entry struct { creditsExpiring int64 creditsEarliestExpiry time.Time creditsEarliestRemaining int64 + // expiryBuckets 全部未来到期批次按自然日聚合(升序)。供面板快照展示与 + // NoteModelCost 的 FEFO 近似扣减;由 SetCreditsSnapshot 整体替换。 + expiryBuckets []ExpiryBucket successCount int64 // 累计成功 errTotal int64 // 累计错误(供成功率权重 successRate = successCount/(successCount+errTotal),不清零) lastErr time.Time // 最近一次错误时间 @@ -463,6 +482,9 @@ type stateAccount struct { // 与 CreditsExpiring 一并持久化,重启后首次请求仍可沿用最近一次余额快照。 CreditsEarliestExpiry time.Time `json:"credits_earliest_expiry,omitempty"` CreditsEarliestRemaining int64 `json:"credits_earliest_remaining,omitempty"` + // CreditsExpiryBuckets 全部未来到期批次按自然日聚合。持久化后重启即可用完整 + // 快照渲染首页卡片;恢复时惰性剔除已过期/零剩余条目。 + CreditsExpiryBuckets []ExpiryBucket `json:"credits_expiry_buckets,omitempty"` // ModelCooldowns 模型级独立冷却表(model → 冷却记录:6004 重置墙钟 / 11102 // 负缓存退避)。持久化:6004 对齐上游重置墙钟后单模型冷却可长达数小时, // 跨重启是常态;不持久化会导致 healthyForModel 重启失忆、重新踩雷区。 @@ -495,7 +517,8 @@ type stateModelCost struct { // stateFile 持久化格式。 type stateFile struct { - Accounts map[string]stateAccount `json:"accounts"` + Accounts map[string]stateAccount `json:"accounts"` + Scheduling map[string]scheduleCursor `json:"scheduling,omitempty"` } // flushInterval 后台落盘周期。 diff --git a/internal/pool/modelblocked_test.go b/internal/pool/modelblocked_test.go index 40e240cc..be8b85c5 100644 --- a/internal/pool/modelblocked_test.go +++ b/internal/pool/modelblocked_test.go @@ -1,6 +1,7 @@ package pool import ( + "strings" "testing" "time" @@ -68,6 +69,36 @@ func TestModelBlockedOneAccountStillServes(t *testing.T) { } } +// TestModelBlockedForRealmIgnoresOtherRealm 模型请求分池后,另一域的账号不能 +// 掩盖本域全锁:global 两个号都被 11158 锁住时,即使 CN 号未锁也应返回 Blocked。 +func TestModelBlockedForRealmIgnoresOtherRealm(t *testing.T) { + p := New("") + p.Add(&auth.Auth{UID: "cn1", Domain: "www.codebuddy.cn"}) + p.Add(&auth.Auth{UID: "g1", Domain: "www.workbuddy.ai"}) + p.Add(&auth.Auth{UID: "g2", Domain: "www.workbuddy.ai"}) + p.BlockModelBackoff("g1", "gpt-5.6-sol", "11158 model not allowed for account type") + p.BlockModelBackoff("g2", "gpt-5.6-sol", "11158 model not allowed for account type") + + if st := p.ModelBlockedForRealm("gpt-5.6-sol", "global"); !st.Blocked || st.Count != 2 { + t.Fatalf("global 域应判定为全锁,得到 %+v", st) + } else if !strings.HasPrefix(st.Reason, "11158") { + t.Fatalf("模型锁原因应保留 11158,得到 %q", st.Reason) + } + if st := p.ModelBlockedForRealm("gpt-5.6-sol", "cn"); st.Blocked { + t.Fatalf("cn 域仍有可用账号,不应判定为全锁:%+v", st) + } + // 旧入口保持跨域口径,便于非分池调用方向后兼容。 + if st := p.ModelBlocked("gpt-5.6-sol"); st.Blocked { + t.Fatalf("跨域仍有 cn1 可用,旧入口不应报全锁:%+v", st) + } + // 11158 与 11102 一样是确定性负缓存,权限恢复后成功请求必须能清除。 + p.BlockModelClear("g1", "gpt-5.6-sol") + p.BlockModelClear("g2", "gpt-5.6-sol") + if st := p.ModelBlockedForRealm("gpt-5.6-sol", "global"); st.Blocked { + t.Fatalf("清除 11158 后不应再阻塞:%+v", st) + } +} + // TestModelBlockedDisabledDoesNotCount 禁用号不参与判定:它不可用与模型无关, // 既不构成"还能服务"的证明,也不该被算进被挡账号数。 func TestModelBlockedDisabledDoesNotCount(t *testing.T) { diff --git a/internal/pool/persist.go b/internal/pool/persist.go index eb7b02bb..1f551b1e 100644 --- a/internal/pool/persist.go +++ b/internal/pool/persist.go @@ -127,6 +127,7 @@ func (p *Pool) load() { return } p.applyAccountsLocked(sf.Accounts) + p.restoreSchedulingLocked(sf.Scheduling) } // applyAccountsLocked 用持久化账号状态覆盖/插入 byUID(placeholder 凭证,Add 时换全)。 @@ -176,6 +177,13 @@ func (p *Pool) applyAccountsLocked(accounts map[string]stateAccount) { e.creditsEarliestExpiry = time.Time{} e.creditsEarliestRemaining = 0 } + // 按日到期批次:惰性剔除已过期/零剩余条目;有批次时以批次为准同步最早批次, + // 保证重启后首页快照与「检查」仍同口径。 + e.expiryBuckets = sanitizeExpiryBuckets(s.CreditsExpiryBuckets, now) + if len(e.expiryBuckets) > 0 { + e.creditsEarliestExpiry = e.expiryBuckets[0].ExpireAt + e.creditsEarliestRemaining = e.expiryBuckets[0].Remain + } // 熔断器持久化恢复:breakerUntil 未过期才恢复(过期不复活),retryCount 仅在 // 熔断仍有效时保留(否则归零,不保留无用退避指数)。 if s.BreakerUntil != nil && now.Before(*s.BreakerUntil) { @@ -227,6 +235,7 @@ func (p *Pool) adoptSnapshot(s snapshot) { func (p *Pool) applySnapshotLocked(s snapshot) { p.byUID = map[string]*entry{} p.applyAccountsLocked(s.Accounts) + p.restoreSchedulingLocked(s.Scheduling) } func (p *Pool) saveLocked() { if p.stateFp == "" { @@ -281,7 +290,7 @@ func (p *Pool) notePersistFail(err error) { // stateOverviewLocked 收集当前内存状态为 stateFile(供落盘 + 快照镜像复用)。调用方必须已持 p.mu。 func (p *Pool) stateOverviewLocked() stateFile { now := time.Now() - sf := stateFile{Accounts: map[string]stateAccount{}} + sf := stateFile{Accounts: map[string]stateAccount{}, Scheduling: p.scheduling} for uid, e := range p.byUID { s := stateAccount{ Credits: e.credits, @@ -303,6 +312,7 @@ func (p *Pool) stateOverviewLocked() stateFile { CreditsExpiring: e.creditsExpiring, CreditsEarliestExpiry: e.creditsEarliestExpiry, CreditsEarliestRemaining: e.creditsEarliestRemaining, + CreditsExpiryBuckets: append([]ExpiryBucket(nil), e.expiryBuckets...), } // 熔断截止:仅未过期才落盘(指针 nil 才能被 omitempty 真省略)。 if !e.breakerUntil.IsZero() && now.Before(e.breakerUntil) { diff --git a/internal/pool/pick.go b/internal/pool/pick.go index b8b7add6..ddfc126e 100644 --- a/internal/pool/pick.go +++ b/internal/pool/pick.go @@ -47,9 +47,9 @@ func (p *Pool) pick(tried map[string]bool, reqModel, realm string) *auth.Auth { defer p.mu.Unlock() now := time.Now() realmOK := func(e *entry) bool { return realm == "" || e.a.Realm() == realm } - healthyOf := func(e *entry) bool { return realmOK(e) && e.healthy(now) } + healthyOf := func(e *entry) bool { return realmOK(e) && e.healthy(now) && !p.burnBlocked(e, now) } if reqModel != "" { - healthyOf = func(e *entry) bool { return realmOK(e) && e.healthyForModel(now, reqModel) } + healthyOf = func(e *entry) bool { return realmOK(e) && e.healthyForModel(now, reqModel) && !p.burnBlocked(e, now) } } // floorBlocked 积分保底拦截判定(实现在 floorBlockedForRealmModel,与粘性路径共用): // 触底 + 收费(本地实测台账 或 上游目录倍率)即拦;免费/未知倍率不受限。 @@ -77,6 +77,9 @@ func (p *Pool) pick(tried map[string]bool, reqModel, realm string) *auth.Auth { if len(cands) == 0 { // 全冷却兜底:无 healthy 候选时,从冷却账号里选 until 最早到期的一个 // (熔断/冷却共用 expiry 口径,取较早截止者)。禁用的账号永不参与兜底。 + if p.strategyForRealmLocked(realm) != WeightedRandom { + return nil // 有序策略等待恢复,不提前试探仍在冷却中的账号。 + } return p.pickEarliestExpiryLocked(tried, now, realm, reqModel) } // top5 短名单按权重降序截断(而非 credits 单纯降序):否则闲置补偿根本进不了 @@ -89,6 +92,14 @@ func (p *Pool) pick(tried map[string]bool, reqModel, realm string) *auth.Auth { maxCredits = e.credits } } + // 企业版「不限量」账号的 credits 是哨兵值 1<<40(上游 limitNum=-1, + // 见 upstream.enterpriseUnlimitedRemain)。它若参与 maxCredits 计算, + // 会把分母撑到 1e12 量级 → 其余账号的 credits 比例项被压成 ~0, + // 权重几乎只剩闲置补偿,不限账号则近乎垄断选号——与「不限量不构成 + // 约束」的语义相反(issue #222)。故权重口径下把哨兵值从分母里剔除, + // 分母改用候选集里的次大真实余额;哨兵号自身按「不低于任何正常号」参与 + // (见 weightOf)。 + maxCredits = capUnlimitedCredits(cands) // 成本分层(reqModel 非空时):按该模型的实测扣费把候选分层,只保留最优层。 // 0 = 已实测免费(限免期/夜间免费的号,最强偏好) // 1 = 无观测(含观测过期) @@ -156,6 +167,20 @@ func (p *Pool) pick(tried map[string]bool, reqModel, realm string) *auth.Auth { ws = append(ws, weighted{e: e, w: p.routingWeightOf(e, maxCredits, now), tier: ti, cost1k: ci}) } } + if strategy := p.strategyForRealmLocked(realm); strategy != WeightedRandom { + ordered := make([]*entry, 0, len(ws)) + for _, w := range ws { + ordered = append(ordered, w.e) + } + e := p.pickOrderedLocked(ordered, tried, reqModel, realm, strategy, explored, now) + e.lastUsed = now + p.pickSeq++ + e.usedSeq = p.pickSeq + if explored { + log.Printf("[pool] cost explore model=%s realm=%q acct=%s window=%s", reqModel, realm, logfmt.Label(e.a.UID, e.a.Nickname), p.costExploreInterval) + } + return e.a + } // 等权重洗牌:仅当存在权重相等且候选数超过 top5 时,才对 ws 做 Fisher-Yates // 洗牌(且**不消耗 p.randInt64N 注入源**,避免改变 pickWeighted 的确定性语义, // 见 TestPickDeterministicViaSetRandomSource)。权重全等或存在并列时,按字典序 @@ -313,6 +338,12 @@ func (p *Pool) pickEarliestExpiryLocked(tried map[string]bool, now time.Time, re if p.floorBlockedForRealmModel(e, model, realm, now) { continue // 积分保底:触底号不接收费模型(兜底路径同判据) } + // 积分燃尽熔断(issue #229):熔断号不参与兜底——兜底是「没有健康号时 + // 提前试探冷却号」,熔断的语义是「这个号在窗口内不要接流量」,两者 + // 冲突时以熔断为准(宁可少一个号,也不要把流量推回正在烧钱的号)。 + if p.burnBlocked(e, now) { + continue + } if p.inFlightFull(e) { continue } @@ -364,12 +395,7 @@ var minPickGap = 100 * time.Millisecond // 随机源优先用 p.randInt64N(仅供测试注入确定性),nil 时回退 math/rand/v2 全局源。 func (p *Pool) pickWeighted(cands []*entry) *entry { now := time.Now() - var maxCredits int64 - for _, e := range cands { - if e.credits > maxCredits { - maxCredits = e.credits - } - } + maxCredits := capUnlimitedCredits(cands) const scale = 1_000_000 // 定点放大:int64 累加权重大整数抽签 weights := make([]int64, len(cands)) var total int64 @@ -396,12 +422,58 @@ func (p *Pool) pickWeighted(cands []*entry) *entry { return cands[len(cands)-1] } +// unlimitedCreditsSentinel 企业版「不限量」在池内的剩余量哨兵值 +// (上游 limitNum == -1;见 upstream.enterpriseUnlimitedRemain)。 +// 该值是**展示/判定用的伪值**,不是真实额度——参与权重分母会把其余账号的 +// credits 比例项压成 ~0(issue #222)。 +const unlimitedCreditsSentinel int64 = 1 << 40 + +// capUnlimitedCredits 把权重分母里的不限量哨兵值替换成候选集里的**次大真实 +// 余额**,使「不限量」不构成约束,同时不把其余账号的 credits 比例项压成 ~0 +// (issue #222 的建议 2)。 +// +// 为什么不是简单封顶:封顶成 1<<40(= 哨兵本身)或任何固定大数,分母仍然远大于 +// 真实余额量级(真实余额通常在几百~几千),比例项照样被压到接近 0——那等于没改。 +// 正确口径是「哨兵号按候选集的次大值参与计算」:它仍拿到「不低于任何正常号」 +// 的比例待遇(1.0),但分母回到真实余额量级,其余账号的权重恢复正常。 +// +// all 为候选集的全部账号;返回用于计算比例的分母。 +func capUnlimitedCredits(all []*entry) int64 { + maxReal := int64(0) + hasUnlimited := false + for _, e := range all { + if e.credits >= unlimitedCreditsSentinel { + hasUnlimited = true + continue + } + if e.credits > maxReal { + maxReal = e.credits + } + } + if !hasUnlimited { + return maxReal + } + // 全部候选都是不限量号(或没有正常余额号)→ 分母取哨兵值:此时比例项对 + // 所有人都是 1.0(等价),用哨兵值不改变结果。 + if maxReal <= 0 { + return unlimitedCreditsSentinel + } + return maxReal +} + // weightOf 计算单个账号的普通加权分值。 func (p *Pool) weightOf(e *entry, maxCredits int64, now time.Time) float64 { w := 1.0 // 1. credits 比例 ×10(会计入 mid-credit 锚点,避免全员 0 时 credits 项为 0)。 + // 不限量哨兵号(credits == 1<<40)按「不低于任何正常号」参与:比例封 1, + // 既不撑大分母(分母已剔除哨兵值,见 capUnlimitedCredits),也不让它 + // 在比例项上与真实余额号拉开差距(issue #222)。 if maxCredits > 0 { - w += float64(e.credits) / float64(maxCredits) * 10 + own := e.credits + if own >= unlimitedCreditsSentinel { + own = maxCredits + } + w += float64(own) / float64(maxCredits) * 10 } // 2. 闲置补偿。 if e.lastUsed.IsZero() { @@ -431,12 +503,56 @@ func expiringNow(e *entry, now time.Time) bool { e.creditsEarliestExpiry.After(now) } -// routingWeightOf 在普通账号权重上叠加快过期虚拟实例数量。prefer_expiring=false -// 或账号无有效快过期批次时,实例数恒为 1,结果与旧 weightOf 完全一致。 +// 快过期紧迫度梯度(issue #160)。 +// +// 此前是**固定倍率** expiringVirtualSlots(=3):只要账号有任意快过期余额, +// 权重一律 ×3,与「还剩多久到期」无关——所以用户看到的是「负载均衡」而不是 +// 「优先消耗快到期的」。同时 ×3 的力度也远不足以对抗 credits 比例项 +// (weightOf 里 credits 比例 ×10,一个大余额号能把 ×3 轻易盖过去)。 +// +// 现在改成**平滑梯度**:紧迫度 u = 1 - 剩余时间/窗口(越接近到期越接近 1), +// 倍率 = M_base + (M_max - M_base) * u,窗口 = 快过期窗口(expiring_soon, +// 默认 168h)。窗口外(剩余时间 > 窗口)不额外加权,与旧行为一致; +// 刚好到期(剩余时间 ≈ 0)拿满 M_max。 +const ( + // expiringBaseMultiplier 快过期账号的最小加权倍率(刚进入窗口时)。 + expiringBaseMultiplier = 1.5 + // expiringMaxMultiplier 快过期账号在临近到期时的最大加权倍率。 + expiringMaxMultiplier = 5.0 +) + +// expiringUrgency 返回账号的快过期紧迫度 u∈[0,1]:1 = 恰好到期,0 = 不在窗口内。 +// 窗口未知(未配置 expiring_soon)时退化为固定倍率口径(u=1 → M_max)。 +func (p *Pool) expiringUrgency(e *entry, now time.Time) float64 { + if !expiringNow(e, now) { + return 0 + } + win := p.expiringSoonWindow + if win <= 0 { + return 1 // 窗口未知:按最紧迫处理(等价于旧的固定倍率上限) + } + remain := e.creditsEarliestExpiry.Sub(now) + if remain <= 0 { + return 1 + } + if remain >= win { + return 0 // 还在窗口外:不做额外加权 + } + return 1 - float64(remain)/float64(win) +} + +// expiringMultiplier 返回快过期账号的加权倍率(平滑梯度)。 +func (p *Pool) expiringMultiplier(e *entry, now time.Time) float64 { + u := p.expiringUrgency(e, now) + return expiringBaseMultiplier + (expiringMaxMultiplier-expiringBaseMultiplier)*u +} + +// routingWeightOf 在普通账号权重上叠加快过期加权。prefer_expiring=false +// 或账号无有效快过期批次时,倍率恒为 1,结果与旧 weightOf 完全一致。 func (p *Pool) routingWeightOf(e *entry, maxCredits int64, now time.Time) float64 { w := p.weightOf(e, maxCredits, now) if p.preferExpiring && expiringNow(e, now) { - return w * expiringVirtualSlots + return w * p.expiringMultiplier(e, now) } return w } diff --git a/internal/pool/pool.go b/internal/pool/pool.go index 3b0b0bc3..e1ebe4e9 100644 --- a/internal/pool/pool.go +++ b/internal/pool/pool.go @@ -60,6 +60,13 @@ type Pool struct { // preferExpiring 最早到期优先路由开关(默认 true)。开启且快过期窗口内存在有效 // 批次时,选号在成本层内先按最早到期排序;关闭后只使用普通加权路由。 preferExpiring bool + // expiringSoonWindow 快过期窗口(expiring_soon 配置项解析结果,如 168h)。 + // 0 = 窗口未知 → 快过期梯度退化为固定上限倍率(兼容旧口径)。 + expiringSoonWindow time.Duration + // crossDomainFallback 裸模型名跨域回退开关(默认 false = 零回归,issue #135)。 + // 仅由选号调用方(server handler)读取:开启后,未带 realm 前缀的请求在首选域 + // 选不到可用号时改用另一域再选一次;显式 cn:/global: 前缀不受影响。 + crossDomainFallback bool // maxInFlight 单账号最大在途请求数;0 = 不限(租约关闭)。 maxInFlight int // maxInFlightGlobal global 域单账号在途上限分档(WAF 403 修复 P1-1:global 域 @@ -74,7 +81,20 @@ type Pool struct { // pickSeq 单调递增的选号序号:每次 pick 选中账号时自增并记到 entry.usedSeq, // 为 LRU 兜底/防惊群提供与 time.Now() 精度无关的严格全序(Windows ~0.5ms 精度下 // lastUsed 墙钟会全等)。仅 pick 写锁路径读写,无需 atomic。 - pickSeq uint64 + pickSeq uint64 + strategies RegionStrategies + accountOrder RegionAccountOrder + scheduling map[string]scheduleCursor + // burnGuard 积分燃尽熔断状态(issue #229):uid → 窗口内累计烧掉的积分。 + // 仅在 burnGuardLimit > 0(启用熔断)时写入。 + burnGuard map[string]*burnWindow + // burnGuardLimit 窗口内允许烧掉的积分上限(0 = 关闭熔断,零回归)。 + burnGuardLimit float64 + // burnGuardWindow 燃尽计账窗口。 + burnGuardWindow time.Duration + // burnGuardCooldown / burnGuardCooldownMax 熔断冷却时长与上限。 + burnGuardCooldown time.Duration + burnGuardCooldownMax time.Duration // stopCh 关闭信号:Close 关闭它使 startFlusher 的后台 goroutine 退出。 // nil = 未启动 flusher(stateFp 为空时 New 不起 flusher)。 stopCh chan struct{} @@ -86,6 +106,7 @@ type Pool struct { func New(stateFp string) *Pool { p := &Pool{ byUID: map[string]*entry{}, + scheduling: map[string]scheduleCursor{}, stateFp: stateFp, breakerThreshold: defaultBreakerThreshold, breakerCooldown: defaultBreakerCooldown, @@ -98,8 +119,12 @@ func New(stateFp string) *Pool { degradeCooldownMax: defaultDegradeCooldownMax, // 探索缺省 30m:tier 0 垄断下的 tier 1 探索窗口(issue #136)。用户经 // config 显式 "0" 关停(SetCostExploreInterval(0))。 - costExploreInterval: defaultCostExploreInterval, - exploreLast: map[string]time.Time{}, + costExploreInterval: defaultCostExploreInterval, + exploreLast: map[string]time.Time{}, + burnGuard: map[string]*burnWindow{}, + burnGuardWindow: defaultBurnGuardWindow, + burnGuardCooldown: defaultBurnGuardCooldown, + burnGuardCooldownMax: defaultBurnGuardCooldownMax, } if stateFp != "" { p.load() @@ -193,6 +218,30 @@ func (p *Pool) SetPreferExpiring(enabled bool) { p.preferExpiring = enabled } +// SetExpiringSoonWindow 注入快过期窗口(expiring_soon 解析结果),供快过期 +// 紧迫度梯度换算「还剩多久到期 → 加权倍率」(issue #160)。 +// 窗口为 0 时梯度退化为固定上限倍率(与旧口径兼容)。 +func (p *Pool) SetExpiringSoonWindow(d time.Duration) { + p.mu.Lock() + defer p.mu.Unlock() + p.expiringSoonWindow = d +} + +// SetCrossDomainFallback 注入裸模型名跨域回退开关(issue #135;false = 零回归)。 +// 与其它 pool.* 参数一致,面板保存配置后经同一条热应用路径立即生效。 +func (p *Pool) SetCrossDomainFallback(enabled bool) { + p.mu.Lock() + defer p.mu.Unlock() + p.crossDomainFallback = enabled +} + +// CrossDomainFallback 报告裸模型名跨域回退开关当前状态(选号侧只读,热改安全)。 +func (p *Pool) CrossDomainFallback() bool { + p.mu.RLock() + defer p.mu.RUnlock() + return p.crossDomainFallback +} + // SetDegrade 注入连败降权参数(main 从 config 解析后调用,issue #114)。 // 非正值保留原值(用默认,见 defaultDegrade*),风格同 SetBreaker/SetSoftRateMax。 func (p *Pool) SetDegrade(threshold int, cooldown, cooldownMax time.Duration) { diff --git a/internal/pool/pool_test.go b/internal/pool/pool_test.go index fe8ec627..b709c658 100644 --- a/internal/pool/pool_test.go +++ b/internal/pool/pool_test.go @@ -327,6 +327,28 @@ func TestDisablePersists(t *testing.T) { } } +// issue #171 问题一:软冷却到期后 until/coolKind/reason 字段按设计不清理, +// statusOf 不应再把过期 reason 透出给面板(避免「可用」旁挂着历史 429)。 +func TestStatusReasonHiddenAfterCooldownExpires(t *testing.T) { + p := New("") + p.Add(&auth.Auth{UID: "u1"}) + // 冷却中:reason 正常透出。 + p.Cooldown("u1", CoolSoft, time.Hour, "429 rate limit") + st, ok := p.Status("u1") + if !ok || !st.Cooling || st.Reason != "429 rate limit" { + t.Fatalf("cooling status=%+v ok=%v", st, ok) + } + // 冷却到期、字段残留(NoteSuccess 不碰 until/coolKind/reason):不再透出。 + p.Cooldown("u1", CoolSoft, -time.Minute, "429 rate limit") + st, ok = p.Status("u1") + if !ok || st.Cooling { + t.Fatalf("expired cooldown should not be cooling: %+v", st) + } + if st.Reason != "" { + t.Fatalf("expired cooldown reason leaked to status: %q", st.Reason) + } +} + func TestReenableIfCredits(t *testing.T) { p := New("") p.Add(&auth.Auth{UID: "u1"}) @@ -1795,12 +1817,14 @@ func TestPreferExpiringDisabledRestoresWeight(t *testing.T) { p.SetCreditsDetailed("a", 100, 100, 50, now.Add(time.Hour), 50) p.SetCreditsDetailed("b", 100, 100, 0, time.Time{}, 0) + // 快过期梯度(issue #160):窗口未知(未 SetExpiringSoonWindow)时退化为 + // 固定上限倍率 expiringMaxMultiplier。 p.mu.Lock() we := p.routingWeightOf(p.byUID["a"], 100, now) wn := p.routingWeightOf(p.byUID["b"], 100, now) p.mu.Unlock() - if we != wn*expiringVirtualSlots { - t.Fatalf("enabled expiring weight=%v want %v", we, wn*expiringVirtualSlots) + if we != wn*expiringMaxMultiplier { + t.Fatalf("enabled expiring weight=%v want %v", we, wn*expiringMaxMultiplier) } p.SetPreferExpiring(false) @@ -1814,6 +1838,45 @@ func TestPreferExpiringDisabledRestoresWeight(t *testing.T) { } } +// TestExpiringUrgencyGradient 钉住快过期紧迫度梯度(issue #160): +// 倍率随「剩余时间/窗口」线性上升——刚进窗口 ≈ M_base,临近到期 ≈ M_max。 +func TestExpiringUrgencyGradient(t *testing.T) { + const win = 168 * time.Hour + p := New("") + p.Add(&auth.Auth{UID: "a"}) + p.SetExpiringSoonWindow(win) + now := time.Now() + + cases := []struct { + name string + until time.Time + want float64 + }{ + {"窗口边缘", now.Add(win), expiringBaseMultiplier}, + {"窗口中点", now.Add(win / 2), (expiringBaseMultiplier + expiringMaxMultiplier) / 2}, + {"临近到期", now.Add(time.Minute), expiringMaxMultiplier - (expiringMaxMultiplier-expiringBaseMultiplier)/float64(win)*float64(time.Minute)}, + } + for _, c := range cases { + p.SetCreditsDetailed("a", 100, 100, 50, c.until, 50) + p.mu.Lock() + got := p.expiringMultiplier(p.byUID["a"], now) + p.mu.Unlock() + if diff := got - c.want; diff > 1e-6 || diff < -1e-6 { + t.Errorf("%s: multiplier=%v want %v (diff %v)", c.name, got, c.want, diff) + } + } + + // 窗口外:不做额外加权(倍率 = 1)。 + p.SetCreditsDetailed("a", 100, 100, 50, now.Add(2*win), 50) + p.mu.Lock() + we := p.routingWeightOf(p.byUID["a"], 100, now) + wn := p.routingWeightOf(p.byUID["a"], 100, now) + p.mu.Unlock() + if we != wn { + t.Fatalf("窗口外不应额外加权: %v/%v", we, wn) + } +} + func TestCreditExpirySnapshotConsumptionAndClear(t *testing.T) { p := New("") p.Add(&auth.Auth{UID: "u1"}) @@ -1845,6 +1908,60 @@ func TestCreditExpirySnapshotConsumptionAndClear(t *testing.T) { } } +// TestExpiryBucketsFEFOAndPersistence 钉住按日到期批次的三件事: +// 1. 最早批次以 buckets 为准(同日多包合计已在写入前完成),而不是入参单批值; +// 2. 两次余额刷新之间按 FEFO 扣减,扣穿第一桶后最早批次顺延到下一桶; +// 3. state.json / Redis 快照往返不丢批次,重启后首页快照仍与「检查」同口径。 +func TestExpiryBucketsFEFOAndPersistence(t *testing.T) { + p := New("") + p.Add(&auth.Auth{UID: "u1"}) + now := time.Now() + at1 := now.Add(24 * time.Hour) + at2 := now.Add(48 * time.Hour) + p.SetCreditsSnapshot("u1", CreditsSnapshot{ + Remain: 300, Total: 300, Expiring: 100, + // 故意给一个与 buckets 不一致的最早值:buckets 必须优先。 + EarliestAt: at1, EarliestRemaining: 999, + ExpiryBuckets: []ExpiryBucket{ + {Date: at1.Format("2006-01-02"), ExpireAt: at1, Remain: 100}, + {Date: at2.Format("2006-01-02"), ExpireAt: at2, Remain: 200}, + }, + }) + st, _ := p.Status("u1") + if len(st.CreditsExpiryBuckets) != 2 || st.CreditsEarliestRemaining != 100 || !st.CreditsEarliestExpiry.Equal(at1) { + t.Fatalf("initial buckets=%+v earliest=%v/%d", st.CreditsExpiryBuckets, st.CreditsEarliestExpiry, st.CreditsEarliestRemaining) + } + + // 扣 150:第一桶扣穿(100),第二桶剩 150,最早批次顺延到 at2。 + p.NoteModelCost("u1", "m", 150, 1000) + st, _ = p.Status("u1") + if st.Credits != 150 || len(st.CreditsExpiryBuckets) != 1 || st.CreditsExpiryBuckets[0].Remain != 150 { + t.Fatalf("after consume buckets=%+v credits=%d", st.CreditsExpiryBuckets, st.Credits) + } + if !st.CreditsEarliestExpiry.Equal(at2) || st.CreditsEarliestRemaining != 150 { + t.Fatalf("earliest after consume=%v/%d want %v/150", st.CreditsEarliestExpiry, st.CreditsEarliestRemaining, at2) + } + + // 持久化往返。 + p.mu.Lock() + sf := p.stateOverviewLocked() + p.mu.Unlock() + if got := sf.Accounts["u1"].CreditsExpiryBuckets; len(got) != 1 || got[0].Remain != 150 { + t.Fatalf("persisted buckets=%+v", got) + } + p2 := New("") + p2.mu.Lock() + p2.applyAccountsLocked(sf.Accounts) + p2.mu.Unlock() + st2, ok := p2.Status("u1") + if !ok || len(st2.CreditsExpiryBuckets) != 1 || st2.CreditsExpiryBuckets[0].Remain != 150 { + t.Fatalf("restored buckets=%+v", st2.CreditsExpiryBuckets) + } + if !st2.CreditsEarliestExpiry.Equal(at2) || st2.CreditsEarliestRemaining != 150 { + t.Fatalf("restored earliest=%v/%d", st2.CreditsEarliestExpiry, st2.CreditsEarliestRemaining) + } +} + // ---------- 暂停选号(paused)---------- // // paused 与 disabled 正交:两者都退出选号候选,但 paused 是「临时让位」—— diff --git a/internal/pool/realm.go b/internal/pool/realm.go index d82ca93e..17422d45 100644 --- a/internal/pool/realm.go +++ b/internal/pool/realm.go @@ -24,6 +24,10 @@ func (p *Pool) AvailableUIDsForRealm(realm string) []string { if !e.healthy(now) { continue } + // 积分燃尽熔断(issue #229):熔断中的账号不参与选号,与 pick 口径一致。 + if p.burnBlocked(e, now) { + continue + } if p.inFlightFull(e) { continue } @@ -33,6 +37,40 @@ func (p *Pool) AvailableUIDsForRealm(realm string) []string { return uids } +// ServableForModelRealm 返回 realm 内此刻**真正能服务该模型**的账号数。 +// +// 与 pick 的候选口径一致(healthyForModel 的 6004/11102 模型级冷却豁免、在途租约、 +// 积分保底都计入),但**不含 pick 的全冷却兜底**——跨域回退(issue #135)要的正是 +// 「这个域此刻有没有健康号」,兜底把冷却号捞回来的乐观语义会掩盖这一点。 +// +// 只读:持 RLock 遍历,不改任何状态。realm=="" 时统计全池(与既有 realm 语义一致)。 +func (p *Pool) ServableForModelRealm(model, realm string) int { + p.mu.RLock() + defer p.mu.RUnlock() + now := time.Now() + n := 0 + for _, e := range p.byUID { + if realm != "" && e.a.Realm() != realm { + continue + } + if !e.healthyForModel(now, model) { + continue + } + // 积分燃尽熔断(issue #229):熔断号不算「能服务该模型」。 + if p.burnBlocked(e, now) { + continue + } + if p.inFlightFull(e) { + continue + } + if p.floorBlockedForRealmModel(e, model, realm, now) { + continue + } + n++ + } + return n +} + // WeightedAvailableUIDsForModelRealm 返回带虚拟实例权重的可用账号列表。 // // 普通账号出现 1 次;有效快过期账号出现 expiringVirtualSlots 次。调用方继续按 @@ -53,6 +91,11 @@ func (p *Pool) WeightedAvailableUIDsForModelRealm(model, realm string) []string if !e.healthyForModel(now, model) { continue } + // 积分燃尽熔断(issue #229):熔断号不列入可用集合(会话粘性分配 + // 也不该把它选为候选)。 + if p.burnBlocked(e, now) { + continue + } if p.inFlightFull(e) { continue } @@ -89,6 +132,11 @@ func (p *Pool) AvailableUIDsForModelRealm(model, realm string) []string { if !e.healthyForModel(now, model) { continue } + // 积分燃尽熔断(issue #229):熔断号不列入可用集合(会话粘性分配 + // 也不该把它选为候选)。 + if p.burnBlocked(e, now) { + continue + } if p.inFlightFull(e) { continue } diff --git a/internal/pool/state.go b/internal/pool/state.go index 65862878..63266698 100644 --- a/internal/pool/state.go +++ b/internal/pool/state.go @@ -139,11 +139,12 @@ func (p *Pool) ReenableIfCredits(uid string, remain, total int64) { e.credits = remain e.creditsTotal = total } - // ReenableIfCredits 只有聚合余额上下文;到期明细必须由 SetCreditsDetailed + // ReenableIfCredits 只有聚合余额上下文;到期明细必须由 SetCreditsSnapshot // 重新写入,不能沿用旧窗口/旧批次的缓存。 e.creditsExpiring = 0 e.creditsEarliestExpiry = time.Time{} e.creditsEarliestRemaining = 0 + e.expiryBuckets = nil p.dirty.Store(true) } } @@ -233,14 +234,7 @@ func (p *Pool) NoteModelCost(uid, model string, credit float64, tokens int) { e.creditsExpiring -= d } } - if e.creditsEarliestRemaining > 0 { - if d >= e.creditsEarliestRemaining { - e.creditsEarliestRemaining = 0 - e.creditsEarliestExpiry = time.Time{} - } else { - e.creditsEarliestRemaining -= d - } - } + p.deductExpiryLocked(e, d) } if e.modelCost == nil { e.modelCost = make(map[string]modelCostEntry) @@ -264,6 +258,58 @@ func (p *Pool) NoteModelCost(uid, model string, credit float64, tokens int) { p.dirty.Store(true) // 账本已持久化:写入口统一置脏 } +// deductExpiryLocked 按 FEFO 近似扣减到期快照:先从最早的自然日桶扣,扣穿再顺延。 +// 有 buckets 时同步重算 earliestAt/earliestRemaining(下一桶的 ExpireAt 成为新最早批次), +// 使两次余额刷新之间的首页快照仍随用量收敛;无 buckets(旧状态/测试只写了聚合字段) +// 时退回只扣最早一批。调用方必须已持有 p.mu。 +func (p *Pool) deductExpiryLocked(e *entry, d int64) { + if d <= 0 { + return + } + if len(e.expiryBuckets) == 0 { + if e.creditsEarliestRemaining > 0 { + if d >= e.creditsEarliestRemaining { + e.creditsEarliestRemaining = 0 + e.creditsEarliestExpiry = time.Time{} + } else { + e.creditsEarliestRemaining -= d + } + } + return + } + left := d + for i := range e.expiryBuckets { + if left == 0 { + break + } + b := &e.expiryBuckets[i] + if b.Remain <= 0 { + continue + } + take := b.Remain + if take > left { + take = left + } + b.Remain -= take + left -= take + } + // 清掉被扣穿的桶(保持升序;切片原地过滤)。 + out := e.expiryBuckets[:0] + for _, b := range e.expiryBuckets { + if b.Remain > 0 { + out = append(out, b) + } + } + e.expiryBuckets = out + if len(e.expiryBuckets) > 0 { + e.creditsEarliestExpiry = e.expiryBuckets[0].ExpireAt + e.creditsEarliestRemaining = e.expiryBuckets[0].Remain + } else { + e.creditsEarliestExpiry = time.Time{} + e.creditsEarliestRemaining = 0 + } +} + // RecordTokenUsage 记录一次实际发起的聊天账号尝试及上游返回的 usage 增量。 // usage 字段缺失时仍累计请求次数,但只累计明确存在的 token 字段。 func (p *Pool) RecordTokenUsage(uid string, delta TokenUsageDelta) { @@ -340,6 +386,10 @@ func (p *Pool) AvailableUIDs() []string { if !e.healthy(now) { continue } + // 积分燃尽熔断(issue #229):熔断号不参与选号。 + if p.burnBlocked(e, now) { + continue + } if p.inFlightFull(e) { continue } @@ -361,6 +411,10 @@ func (p *Pool) AvailableUIDsForModel(model string) []string { if !e.healthyForModel(now, model) { continue } + // 积分燃尽熔断(issue #229):熔断号不参与选号。 + if p.burnBlocked(e, now) { + continue + } if p.inFlightFull(e) { continue } @@ -385,6 +439,13 @@ func (p *Pool) PickByUIDForModel(uid, model string) *auth.Auth { if !e.healthyForModel(now, model) { return nil } + // 积分燃尽熔断(issue #229):粘性号在熔断期内同样不可用 → 解绑走普通轮换, + // 让流量落到未熔断的账号上,而不是继续在一个已超阈值的号上烧。 + if p.burnBlocked(e, now) { + log.Printf("WARN: [pool] burn guard: sticky acct=%s unbind (%s)", + logfmt.Label(e.a.UID, e.a.Nickname), p.burnReason(e.a.UID, now)) + return nil + } // 积分保底(粘性路径):与 pick 的 floorBlocked 同判据——触底 + 收费即拦 // (判据含上游目录倍率兜底,realm 取账号所属域——粘性号已确定,无需外部传入)。 // 返回 nil 后 handler 侧解绑粘性(unbindSticky)走普通轮换换号,粘性号回血 @@ -418,6 +479,10 @@ func (p *Pool) PickByUID(uid string) *auth.Auth { if !e.healthy(now) { return nil } + // 积分燃尽熔断(issue #229):熔断号不可被直取(粘性快路径)。 + if p.burnBlocked(e, now) { + return nil + } if p.inFlightFull(e) { return nil } @@ -512,6 +577,11 @@ func (p *Pool) ServableForRealm(realm string) bool { if p.inFlightFull(e) { continue } + // 积分燃尽熔断(issue #229):熔断号不可服务(与 pick 口径一致), + // 否则探活会误报「可服务」而 chat 实际选不出号。 + if p.burnBlocked(e, now) { + continue + } // 存在性语义:账号级 healthy,或处于模型级豁免形态(6004 单模型软冷却—— // 对触发模型不可用,对其他模型仍可选)。探活无请求模型上下文,取"存在可服务 // 模型"与 chat 实际可达性等价(issue #31 探活侧补齐)。 @@ -548,30 +618,41 @@ func (p *Pool) statusOf(uid string, e *entry) Status { RateLimitedModels: p.rateLimitedModelsLocked(e, now), Realm: e.a.Realm(), Enterprise: e.a.IsEnterprise(), + APIKey: e.a.IsAPIKey(), Nickname: e.a.Nickname, Credits: e.credits, CreditsTotal: e.creditsTotal, CreditsExpiring: e.creditsExpiring, CreditsEarliestExpiry: e.creditsEarliestExpiry, CreditsEarliestRemaining: e.creditsEarliestRemaining, - Cooling: now.Before(e.until) || now.Before(e.breakerUntil), - Reason: e.reason, - Disabled: e.disabled, - Paused: e.paused, - SuccessCount: e.successCount, - ErrTotal: e.errTotal, - CheckinDone: e.lastCheckinDay == now.Format("2006-01-02"), - TokenUsage: e.tokenUsage, - LastSuccessTime: e.lastSuccess, - LastErrTime: e.lastErr, - Until: e.until, - SoftStreak: e.softStreak, - ModelCosts: p.modelCostsStatusLocked(e, now), - ConsecutiveFails: e.consecutiveFails, - DegradeUntil: e.degradeUntil, - InFlight: int(e.inFlight.Load()), - BreakerFails: e.fails, - BreakerUntil: e.breakerUntil, + // 拷贝一份:List 返回后 JSON 序列化在锁外进行,不能与 NoteModelCost 的 + // 原地扣减共享底层数组(数据竞争)。 + CreditsExpiryBuckets: append([]ExpiryBucket(nil), e.expiryBuckets...), + Cooling: now.Before(e.until) || now.Before(e.breakerUntil), + Disabled: e.disabled, + Paused: e.paused, + SuccessCount: e.successCount, + ErrTotal: e.errTotal, + CheckinDone: e.lastCheckinDay == now.Format("2006-01-02"), + TokenUsage: e.tokenUsage, + LastSuccessTime: e.lastSuccess, + LastErrTime: e.lastErr, + Until: e.until, + SoftStreak: e.softStreak, + ModelCosts: p.modelCostsStatusLocked(e, now), + ConsecutiveFails: e.consecutiveFails, + DegradeUntil: e.degradeUntil, + InFlight: int(e.inFlight.Load()), + BreakerFails: e.fails, + BreakerUntil: e.breakerUntil, + } + // reason 仅在冷却/禁用中透出(issue #171):软冷却按 until 到期恢复, + // 但 until/coolKind/reason 字段按设计不主动清理(NoteSuccess 注释: + // 「不碰 until/coolKind,那些是即时冷却,各自到期」)。无条件透出会让 + // 面板把过期数天的「429 rate limit」与「可用」并排展示,运维无法判断 + // 是历史残留还是当前限流。禁用原因仍由 DisabledReason 透出。 + if st.Cooling || st.Disabled { + st.Reason = e.reason } if st.Disabled { // 禁用账号透出禁用原因(运维看不到为什么死)。 @@ -598,6 +679,17 @@ func (p *Pool) statusOf(uid string, e *entry) Status { st.CoolKind = e.coolKind.String() } } + // 积分燃尽熔断(issue #229):熔断中的账号同样「不可选」,但它不是 + // 软/硬冷却(不会自动解除,也不进 cooldown 台账),故单独透出原因, + // 面板据此显示「积分燃尽熔断」而不是笼统的「不可用」。 + if br := p.burnReason(uid, now); br != "" { + st.Cooling = true + st.CoolKind = "burn" + st.Reason = br + if r := int64(time.Until(p.burnGuard[uid].until).Seconds() + 0.999); r > 0 { + st.CoolRemaining = r + } + } return st } diff --git a/internal/pool/strategy.go b/internal/pool/strategy.go new file mode 100644 index 00000000..2dd2ff7e --- /dev/null +++ b/internal/pool/strategy.go @@ -0,0 +1,174 @@ +package pool + +import ( + "fmt" + "sort" + "strings" + "time" +) + +const ( + WeightedRandom = "weighted_random" + RoundRobin = "round_robin" + SequentialExhaust = "sequential_exhaust" +) + +// RegionStrategies 使用配置层的地区名;路由层仍使用现有 cn/global realm。 +type RegionStrategies struct { + Domestic string `json:"domestic"` + International string `json:"international"` +} + +func (s RegionStrategies) Normalize() (RegionStrategies, error) { + for _, field := range []struct { + name string + value *string + }{ + {"domestic", &s.Domestic}, {"international", &s.International}, + } { + *field.value = strings.ToLower(strings.TrimSpace(*field.value)) + if *field.value == "" { + *field.value = WeightedRandom + } + switch *field.value { + case WeightedRandom, RoundRobin, SequentialExhaust: + default: + return s, fmt.Errorf("%s: %q 不是合法策略(weighted_random / round_robin / sequential_exhaust)", field.name, *field.value) + } + } + return s, nil +} + +// RegionAccountOrder 指定 UID 优先顺序;未列出的账号按 UID 排序追加。 +type RegionAccountOrder struct { + Domestic []string `json:"domestic,omitempty"` + International []string `json:"international,omitempty"` +} + +type scheduleCursor struct { + Strategy string `json:"strategy"` + UID string `json:"uid"` +} + +// SetScheduling 原子热更新两域策略。状态带策略标签,切换算法后忽略旧指针。 +func (p *Pool) SetScheduling(s RegionStrategies, order RegionAccountOrder) error { + s, err := s.Normalize() + if err != nil { + return err + } + p.mu.Lock() + defer p.mu.Unlock() + p.strategies = s + p.accountOrder = RegionAccountOrder{ + Domestic: append([]string(nil), order.Domestic...), + International: append([]string(nil), order.International...), + } + for key, cursor := range p.scheduling { + realm, _, _ := strings.Cut(key, "\x1f") + if cursor.Strategy != p.strategyForRealmLocked(realm) { + delete(p.scheduling, key) + p.dirty.Store(true) + } + } + return nil +} + +func (p *Pool) strategyForRealmLocked(realm string) string { + s := "" + switch realm { + case "cn": + s = p.strategies.Domestic + case "global": + s = p.strategies.International + } + if s == "" { + return WeightedRandom + } // 无 realm 的旧入口不混用地区策略。 + return s +} + +// StrategyForRealm 供会话入口决定新会话是否应交由账号池分配。 +func (p *Pool) StrategyForRealm(realm string) string { + p.mu.RLock() + defer p.mu.RUnlock() + return p.strategyForRealmLocked(realm) +} + +func (p *Pool) restoreSchedulingLocked(cursors map[string]scheduleCursor) { + p.scheduling = make(map[string]scheduleCursor, len(cursors)) + for key, cursor := range cursors { + realm, _, ok := strings.Cut(key, "\x1f") + if ok && (realm == "cn" || realm == "global") && + (cursor.Strategy == RoundRobin || cursor.Strategy == SequentialExhaust) { + p.scheduling[key] = cursor + } + } +} + +// pickOrderedLocked 接收已完成健康、保底、在途与成本层过滤的全量候选。 +// 临时 tried/在途占满/成本探索可借用其他账号,但不推进顺序耗尽的主账号。 +// 调用方持 p.mu;指针按 (realm, model) 隔离,并通过既有快照机制持久化。 +func (p *Pool) pickOrderedLocked(cands []*entry, tried map[string]bool, model, realm, strategy string, explored bool, now time.Time) *entry { + order := p.accountOrder.Domestic + if realm == "global" { + order = p.accountOrder.International + } + rank := make(map[string]int, len(order)) + for _, uid := range order { + if _, exists := rank[uid]; !exists { + rank[uid] = len(rank) + } + } + lessUID := func(a, b string) bool { + ar, aok := rank[a] + br, bok := rank[b] + if aok != bok { + return aok + } + if aok { + return ar < br + } + return a < b + } + sort.Slice(cands, func(i, j int) bool { return lessUID(cands[i].a.UID, cands[j].a.UID) }) + key := realm + "\x1f" + model + cursor := p.scheduling[key] + if cursor.Strategy != strategy { + cursor = scheduleCursor{Strategy: strategy} + } + var selected *entry + if strategy == SequentialExhaust { + for _, e := range cands { + if e.a.UID == cursor.UID { + selected = e + break + } + } + } + if selected == nil { + selected = cands[0] + if cursor.UID != "" { + for _, e := range cands { + if lessUID(cursor.UID, e.a.UID) { + selected = e + break + } + } + } + } + advance := true + if strategy == SequentialExhaust && cursor.UID != "" { + current := p.byUID[cursor.UID] + if current != nil && current.a.Realm() == realm && current.healthyForModel(now, model) && + !p.floorBlockedForRealmModel(current, model, realm, now) && + !p.burnBlocked(current, now) && + (explored || tried[cursor.UID] || p.inFlightFull(current)) { + advance = false + } + } + if advance && cursor.UID != selected.a.UID { + p.scheduling[key] = scheduleCursor{Strategy: strategy, UID: selected.a.UID} + p.dirty.Store(true) + } + return selected +} diff --git a/internal/pool/strategy_test.go b/internal/pool/strategy_test.go new file mode 100644 index 00000000..6f991820 --- /dev/null +++ b/internal/pool/strategy_test.go @@ -0,0 +1,230 @@ +package pool + +import ( + "encoding/json" + "fmt" + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" + "path/filepath" + "sync" + "testing" + "time" +) + +func orderedPool(t *testing.T, strategy string) *Pool { + t.Helper() + auth.SetGlobalEnabled(true) + t.Cleanup(func() { auth.SetGlobalEnabled(true) }) + p := New("") + for _, uid := range []string{"a", "b", "c", "d", "e", "f", "g"} { + p.Add(&auth.Auth{UID: uid}) + } + for _, uid := range []string{"x", "y"} { + p.Add(&auth.Auth{UID: uid, Domain: "workbuddy.ai"}) + } + if err := p.SetScheduling(RegionStrategies{strategy, strategy}, RegionAccountOrder{}); err != nil { + t.Fatal(err) + } + p.SetCostExploreInterval(0) + return p +} +func wantPick(t *testing.T, p *Pool, tried map[string]bool, model, realm, uid string) { + t.Helper() + a := p.PickExcludingForRealm(tried, model, realm) + if a == nil { + if uid != "" { + t.Fatalf("pick nil; want %s", uid) + } + return + } + if a.UID != uid { + t.Fatalf("pick %s; want %s", a.UID, uid) + } +} +func TestRoundRobinAllAccountsAndIndependentCursors(t *testing.T) { + p := orderedPool(t, RoundRobin) + for i := 0; i < 21; i++ { + wantPick(t, p, nil, "m", "cn", string(rune('a'+i%7))) + } + wantPick(t, p, nil, "m", "global", "x") + wantPick(t, p, nil, "other", "cn", "a") + wantPick(t, p, nil, "m", "global", "y") + wantPick(t, p, nil, "m", "cn", "a") + p.Pause("b") + p.Disable("c", "test") + p.CooldownSoftForModel("d", time.Hour, time.Now().Add(time.Hour), "m", "6004") + p.SetMaxInFlight(1) + if !p.Acquire("e") { + t.Fatal("acquire") + } + wantPick(t, p, nil, "m", "cn", "f") + p.Release("e") + p.Revive("b") + p.Revive("c") + p.Revive("d") + wantPick(t, p, nil, "m", "cn", "g") + wantPick(t, p, nil, "m", "cn", "a") + wantPick(t, p, nil, "m", "cn", "b") +} +func TestSequentialExhaustTemporaryAndModelIsolation(t *testing.T) { + p := orderedPool(t, SequentialExhaust) + for i := 0; i < 10; i++ { + wantPick(t, p, nil, "m", "cn", "a") + } + p.NoteError("a") + wantPick(t, p, map[string]bool{"a": true}, "m", "cn", "b") + wantPick(t, p, nil, "m", "cn", "a") + p.SetMaxInFlight(1) + p.Acquire("a") + wantPick(t, p, nil, "m", "cn", "b") + p.Release("a") + wantPick(t, p, nil, "m", "cn", "a") + p.CooldownSoftForModel("a", time.Hour, time.Now().Add(time.Hour), "m", "6004") + wantPick(t, p, nil, "m", "cn", "b") + wantPick(t, p, nil, "other", "cn", "a") + wantPick(t, p, nil, "m", "global", "x") + p.Revive("a") + wantPick(t, p, nil, "m", "cn", "b") + p.Disable("b", "test") + wantPick(t, p, nil, "m", "cn", "c") +} +func TestOrderedStrategiesRespectCostAndFloor(t *testing.T) { + for _, strategy := range []string{RoundRobin, SequentialExhaust} { + t.Run(strategy, func(t *testing.T) { + p := orderedPool(t, strategy) + p.SetCreditFloor(100) + p.SetModelRateOf(func(realm, model string) string { return "1" }) + for uid := range p.byUID { + p.SetCredits(uid, 50, 0) + } + p.NoteModelCost("c", "m", 0, 1000) + wantPick(t, p, nil, "m", "cn", "c") + p.CooldownSoftForModel("c", time.Hour, time.Now().Add(time.Hour), "m", "6004") + wantPick(t, p, nil, "m", "cn", "") + p.SetCredits("b", 200, 0) + wantPick(t, p, nil, "m", "cn", "b") + }) + } +} +func TestOrderedAllCoolingNoFallbackOrCrossRealm(t *testing.T) { + for _, strategy := range []string{RoundRobin, SequentialExhaust} { + t.Run(strategy, func(t *testing.T) { + p := orderedPool(t, strategy) + for _, uid := range []string{"a", "b", "c", "d", "e", "f", "g"} { + p.Cooldown(uid, CoolSoft, time.Hour, "429") + } + wantPick(t, p, nil, "m", "cn", "") + wantPick(t, p, nil, "m", "global", "x") + }) + } +} +func TestOrderedCustomOrderHotUpdateAndMembership(t *testing.T) { + p := orderedPool(t, RoundRobin) + order := RegionAccountOrder{Domestic: []string{"c", "a", "c", "missing"}} + p.SetScheduling(RegionStrategies{RoundRobin, SequentialExhaust}, order) + order.Domestic[0] = "g" // 注入后外部修改不污染池。 + for _, uid := range []string{"c", "a", "b", "d", "e", "f", "g", "c"} { + wantPick(t, p, nil, "m", "cn", uid) + } + p.Remove("c") + wantPick(t, p, nil, "m", "cn", "a") + p.Add(&auth.Auth{UID: "h"}) + p.SetScheduling(RegionStrategies{SequentialExhaust, RoundRobin}, RegionAccountOrder{}) + wantPick(t, p, nil, "m", "cn", "a") + wantPick(t, p, nil, "m", "cn", "a") + if p.SetScheduling(RegionStrategies{Domestic: "bad"}, RegionAccountOrder{}) == nil { + t.Fatal("invalid strategy accepted") + } + wantPick(t, p, nil, "m", "cn", "a") +} +func TestOrderedPersistenceLocalAndSnapshot(t *testing.T) { + for _, strategy := range []string{RoundRobin, SequentialExhaust} { + t.Run(strategy, func(t *testing.T) { + fp := filepath.Join(t.TempDir(), "state.json") + p := New(fp) + accounts := []*auth.Auth{{UID: "a"}, {UID: "b"}, {UID: "x", Domain: "workbuddy.ai"}, {UID: "y", Domain: "workbuddy.ai"}} + p.SyncToDir(accounts) + s := RegionStrategies{strategy, strategy} + p.SetScheduling(s, RegionAccountOrder{}) + wantPick(t, p, nil, "m", "cn", "a") + wantPick(t, p, nil, "m", "global", "x") + p.CooldownSoftForModel("a", time.Hour, time.Now().Add(time.Hour), "blocked", "6004") + p.Close() + q := New(fp) + defer q.Close() + q.SyncToDir(accounts) + q.SetScheduling(s, RegionAccountOrder{}) + want := "a" + gwant := "x" + if strategy == RoundRobin { + want = "b" + gwant = "y" + } + wantPick(t, q, nil, "m", "cn", want) + wantPick(t, q, nil, "m", "global", gwant) + wantPick(t, q, nil, "blocked", "cn", "b") + // Redis 快照与本地格式共用,JSON 往返后恢复指针。 + p.mu.Lock() + raw, err := json.Marshal(snapshot{stateFile: p.stateOverviewLocked(), SavedAt: time.Now()}) + p.mu.Unlock() + if err != nil { + t.Fatal(err) + } + var snap snapshot + if err = json.Unmarshal(raw, &snap); err != nil { + t.Fatal(err) + } + r := New("") + r.adoptSnapshot(snap) + r.SyncToDir(accounts) + r.SetScheduling(s, RegionAccountOrder{}) + wantPick(t, r, nil, "m", "cn", want) + }) + } +} +func TestRoundRobinConcurrentFairnessAndLeases(t *testing.T) { + p := orderedPool(t, RoundRobin) + const n = 700 + got := make(chan string, n) + var wg sync.WaitGroup + for i := 0; i < n; i++ { + wg.Add(1) + go func() { defer wg.Done(); a := p.PickExcludingForRealm(nil, "m", "cn"); got <- a.UID }() + } + wg.Wait() + close(got) + counts := map[string]int{} + for uid := range got { + counts[uid]++ + } + for _, uid := range []string{"a", "b", "c", "d", "e", "f", "g"} { + if counts[uid] != 100 { + t.Fatal(fmt.Sprintf("unfair counts: %v", counts)) + } + } + p.SetMaxInFlight(1) + success := make(chan bool, 100) + for i := 0; i < 100; i++ { + wg.Add(1) + go func() { defer wg.Done(); success <- p.Acquire("a") }() + } + wg.Wait() + close(success) + wins := 0 + for ok := range success { + if ok { + wins++ + } + } + if wins != 1 { + t.Fatalf("lease winners=%d", wins) + } + p.Release("a") +} +func TestSequentialCostExplorationDoesNotReplaceCurrent(t *testing.T) { + p := orderedPool(t, SequentialExhaust) + p.NoteModelCost("a", "m", 0, 1000) + wantPick(t, p, nil, "m", "cn", "a") + p.SetCostExploreInterval(time.Hour) + wantPick(t, p, nil, "m", "cn", "b") + wantPick(t, p, nil, "m", "cn", "a") +} diff --git a/internal/prompt/prompt.go b/internal/prompt/prompt.go index 82ee92a8..fd1b0a68 100644 --- a/internal/prompt/prompt.go +++ b/internal/prompt/prompt.go @@ -92,6 +92,75 @@ func Rewrite(body []byte, systemPrompt string) []byte { return out } +// EnsureSystemFirst 保证 messages[0] 是 role=system —— 上游**渠道校验**的硬要求。 +// +// 为什么必须做(2026-10-20 真机实测,国际版 5.8.4):上游对首条消息不是 system +// 的请求直接 400 code=11-128「first message is not system prompt」,displayTips 与 +// 「非官方客户端」同一句。三条实测对照: +// +// 首条 user → 400 11-128 "first message is not system prompt" +// 首条 developer → 400 11-128 "Illegal API invocation from an unapproved channel" +// 首条 system → 200(同一 body 其余部分不变,模型正常回包) +// +// 也就是说这不是内容策略、不是账号问题:**首条角色本身**是渠道校验的一部分。 +// custom / append 两种模式本来就以网关 system 开头,唯独 passthrough 会原样透传 +// 客户端消息——客户端不带 system(大量 SDK / 手搓 curl 都不带)时首条是 user, +// 于是每一次请求都必然 11-128,账号被反复计成「渠道校验拦截」而全池冷却, +// 表象就是「国际版账号全被禁用,但网页还能聊」(issue #231)。 +// +// 语义边界:只在**首条不是 system/developer 时**补一条,位置在最前;首条已是 +// system/developer 时只做 developer→system 归一(上游不认 developer),其余消息 +// 逐字不动。fallback 为空时用 prompt.Degraded(中性提示词)兜底,绝不因为没配 +// 提示词就不补。 +func EnsureSystemFirst(body []byte, fallback string) []byte { + if len(body) == 0 { + return body + } + var obj map[string]any + if err := json.Unmarshal(body, &obj); err != nil { + return body + } + msgs, ok := obj["messages"].([]any) + if !ok || len(msgs) == 0 { + // 无 messages / 空数组:没有「首条」可补,交给上游按原样报错(不凭空造对话)。 + return body + } + first, ok := msgs[0].(map[string]any) + if !ok { + return body + } + role, _ := first["role"].(string) + switch role { + case "system": + return body // 已是官方形态,零改动。 + case "developer": + // developer 首条同样被拒(报的是另一句 unapproved channel)。就地改成 system, + // 内容逐字保留——不删不并,避免动客户端语义。 + first["role"] = "system" + if out, err := json.Marshal(obj); err == nil { + return out + } + return body + } + prompt := fallback + if prompt == "" { + prompt = Degraded + } + if prompt == "" { + return body + } + // 首条是 user/assistant/tool:在最前补一条网关 system,其余逐字不动。 + rewritten := make([]any, 0, len(msgs)+1) + rewritten = append(rewritten, map[string]any{"role": "system", "content": prompt}) + rewritten = append(rewritten, msgs...) + obj["messages"] = rewritten + out, err := json.Marshal(obj) + if err != nil { + return body + } + return out +} + // Append 解析 OpenAI 请求体并在"开头连续 system/developer 块"之后插入一条 // 网关自有 system 提示词(issue #129 append 模式): // - 开头连续块 = 从 messages[0] 起向后 role 为 system/developer(精确字符串 diff --git a/internal/prompt/system_first_test.go b/internal/prompt/system_first_test.go new file mode 100644 index 00000000..c4d13d92 --- /dev/null +++ b/internal/prompt/system_first_test.go @@ -0,0 +1,125 @@ +// system_first_test.go 上游渠道校验「首条必须是 system」的回归。 +// +// 2026-10-20 真机实测(国际版 5.8.4,同一 token / 同一 body,只改首条角色): +// +// 首条 user → 400 code=11-128 "first message is not system prompt" +// 首条 developer → 400 code=11-128 "Illegal API invocation from an unapproved channel" +// 首条 system → 200,模型正常回包 +// +// 这不是内容策略也不是账号问题:**首条角色本身**属于渠道校验。此前 passthrough 模式 +// 原样透传,客户端不带 system 时每次请求都必然 11-128,账号被反复记成「渠道校验 +// 拦截」而全池冷却——表象即 issue #231「国际版账号全被禁用但网页还能聊」。 +package prompt + +import ( + "encoding/json" + "testing" +) + +func firstRole(t *testing.T, body []byte) (string, []map[string]any) { + t.Helper() + var obj map[string]any + if err := json.Unmarshal(body, &obj); err != nil { + t.Fatalf("输出不是合法 JSON: %v (%s)", err, body) + } + raw, _ := obj["messages"].([]any) + out := make([]map[string]any, 0, len(raw)) + for _, m := range raw { + mm, _ := m.(map[string]any) + out = append(out, mm) + } + if len(out) == 0 { + return "", nil + } + r, _ := out[0]["role"].(string) + return r, out +} + +// 首条 user(最常见的手搓/SDK 形态)→ 补一条 system 在最前。 +func TestEnsureSystemFirstPrepends(t *testing.T) { + in := []byte(`{"model":"m","messages":[{"role":"user","content":"hi"}]}`) + out := EnsureSystemFirst(in, "GW") + role, msgs := firstRole(t, out) + if role != "system" { + t.Fatalf("首条 role=%q want system", role) + } + if msgs[0]["content"] != "GW" { + t.Errorf("补的 system content=%v want GW", msgs[0]["content"]) + } + if len(msgs) != 2 || msgs[1]["role"] != "user" || msgs[1]["content"] != "hi" { + t.Errorf("原有消息必须逐字不动:%v", msgs) + } +} + +// 首条已是 system → 一个字节都不改(幂等,且不重复插入网关提示词)。 +func TestEnsureSystemFirstKeepsSystem(t *testing.T) { + in := []byte(`{"model":"m","messages":[{"role":"system","content":"client"},{"role":"user","content":"hi"}]}`) + out := EnsureSystemFirst(in, "GW") + role, msgs := firstRole(t, out) + if role != "system" || len(msgs) != 2 { + t.Fatalf("不该改动:role=%q len=%d", role, len(msgs)) + } + if msgs[0]["content"] != "client" { + t.Errorf("客户端 system 应原样保留,got %v", msgs[0]["content"]) + } +} + +// 首条 developer:上游同样拒绝(另一句 unapproved channel)→ 就地归一成 system。 +func TestEnsureSystemFirstConvertsDeveloper(t *testing.T) { + in := []byte(`{"model":"m","messages":[{"role":"developer","content":"dev-msg"},{"role":"user","content":"hi"}]}`) + out := EnsureSystemFirst(in, "GW") + role, msgs := firstRole(t, out) + if role != "system" { + t.Fatalf("developer 应归一成 system,got %q", role) + } + if msgs[0]["content"] != "dev-msg" { + t.Errorf("内容必须逐字保留,got %v", msgs[0]["content"]) + } + if len(msgs) != 2 { + t.Errorf("不该新增消息,len=%d", len(msgs)) + } +} + +// 未配提示词时用 Degraded 兜底,而不是「因为没有提示词就不补」。 +func TestEnsureSystemFirstFallsBackWhenEmptyPrompt(t *testing.T) { + in := []byte(`{"model":"m","messages":[{"role":"user","content":"hi"}]}`) + out := EnsureSystemFirst(in, "") + role, msgs := firstRole(t, out) + if role != "system" { + t.Fatalf("空 fallback 也应补 system,got %q", role) + } + if msgs[0]["content"] != Degraded { + t.Errorf("content=%v want Degraded", msgs[0]["content"]) + } +} + +// 坏 JSON / 无 messages 原样返回(关键路径绝不失败、绝不凭空造对话)。 +func TestEnsureSystemFirstPassthroughOnBadInput(t *testing.T) { + for _, in := range []string{ + `not json`, + `{"model":"m"}`, + `{"model":"m","messages":[]}`, + `{"model":"m","messages":"nope"}`, + } { + out := EnsureSystemFirst([]byte(in), "GW") + if string(out) != in { + t.Errorf("输入 %q 应原样返回,got %q", in, out) + } + } +} + +// 其他字段(tools / temperature / max_tokens…)不得因为补 system 而丢失。 +func TestEnsureSystemFirstKeepsOtherFields(t *testing.T) { + in := []byte(`{"model":"m","temperature":0.3,"max_tokens":99,"tools":[{"type":"function"}],"messages":[{"role":"user","content":"hi"}]}`) + out := EnsureSystemFirst(in, "GW") + var obj map[string]any + if err := json.Unmarshal(out, &obj); err != nil { + t.Fatal(err) + } + if obj["model"] != "m" || obj["temperature"] != 0.3 || obj["max_tokens"] != float64(99) { + t.Errorf("基础字段丢了:%v", obj) + } + if _, ok := obj["tools"].([]any); !ok { + t.Errorf("tools 丢了:%v", obj["tools"]) + } +} diff --git a/internal/scheduler/apikey_gate_test.go b/internal/scheduler/apikey_gate_test.go new file mode 100644 index 00000000..db123cda --- /dev/null +++ b/internal/scheduler/apikey_gate_test.go @@ -0,0 +1,113 @@ +package scheduler + +import ( + "testing" + "time" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" + "github.com/linguo2625469/workbuddy2api-panel/internal/pool" + "github.com/linguo2625469/workbuddy2api-panel/internal/upstream" +) + +// apiKeyTestScheduler 构造只含一个账号的调度器;apiKey=true 时该号是静态 API Key +// (AccessToken 以 ck_ 开头、无 refreshToken、expiresAt=0),否则是个人号(对照组)。 +// 两个账号形态与真实落盘一致:静态 Key 本就没有 refresh token。 +func apiKeyTestScheduler(t *testing.T, apiKey bool) (*Scheduler, *recorderUpstream) { + t.Helper() + r := &recorderUpstream{} + srv := r.server() + t.Cleanup(srv.Close) + + a := &auth.Auth{UID: "u1", AccessToken: "at", RefreshToken: "rt", ExpiresAt: 9999999999} + if apiKey { + a = &auth.Auth{UID: "u1", AccessToken: "ck_9f3a2b1c.deadbeef"} + } + p := pool.New("") + p.Add(a) + + up := &upstream.Client{ + HTTP: srv.Client(), + ChatBaseCN: srv.URL, + BillingBaseCN: srv.URL, + WebBaseCN: srv.URL, + } + s := New(Config{Pool: p, Upstream: up}) + return s, r +} + +// TestAPIKeyGateSkipsGrowthTasks 静态 API Key 账号无个人成长体系:五类任务必须一个 +// 上游请求都不发(企业版的同构门控见 enterprise_gate_test)。每个子用例配个人号 +// 对照,证明"没发请求"是门控生效而不是构造有误。 +func TestAPIKeyGateSkipsGrowthTasks(t *testing.T) { + // 夜猫子窗口守卫先于账号循环:钉住时钟,避免该子用例的判别力依赖运行环境墙钟。 + restoreClock := nowForNightWindow + nowForNightWindow = func() time.Time { return time.Date(2026, 10, 7, 23, 30, 0, 0, time.Local) } + t.Cleanup(func() { nowForNightWindow = restoreClock }) + + cases := []struct { + name string + run func(*Scheduler) + // endpoint 是该任务在个人号上必然发起的上游路径(对照组用)。 + endpoint string + }{ + {"签到", func(s *Scheduler) { s.RunCheckinNow() }, "/daily-checkin"}, + {"活跃上报", func(s *Scheduler) { s.RunActivityNow() }, "/v2/report"}, + {"猫猫旅行", func(s *Scheduler) { s.RunTravelNow() }, "/activity/growth/buddy/info"}, + {"夜猫子", func(s *Scheduler) { s.RunBlackcatNow() }, "/v2/activity/growth/tasks"}, + {"连登管家", func(s *Scheduler) { s.RunStreakBonusNow() }, "/activity/growth/streak"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + sKey, rKey := apiKeyTestScheduler(t, true) + c.run(sKey) + if rKey.has(c.endpoint) { + t.Errorf("API Key 账号不应发起 %s,实际请求=%v", c.endpoint, rKey.snapshot()) + } + + sPer, rPer := apiKeyTestScheduler(t, false) + c.run(sPer) + if !rPer.has(c.endpoint) { + t.Errorf("个人号对照应发起 %s,实际请求=%v", c.endpoint, rPer.snapshot()) + } + }) + } +} + +// TestAPIKeyCheckinStillRefreshesCredits API Key 账号虽跳过签到,但**必须继续刷新 +// 额度**(企业口径是 credit_floor 与面板额度的唯一数据源)。这正是 RunCheckinNow +// 的早退守卫放过无 refreshToken 的 API Key、且签到门控只 skip 不 continue 的原因。 +func TestAPIKeyCheckinStillRefreshesCredits(t *testing.T) { + s, r := apiKeyTestScheduler(t, true) + s.RunCheckinNow() + + if r.has("/daily-checkin") { + t.Errorf("API Key 账号不应发起签到,实际请求=%v", r.snapshot()) + } + if !r.has("/get-enterprise-user-usage") { + t.Fatalf("API Key 账号应走企业口径额度查询,实际请求=%v", r.snapshot()) + } + st, ok := s.cfg.Pool.Status("u1") + if !ok { + t.Fatal("status not found") + } + // limitNum - round(credit) = 2000 - 812 = 1188(recorderUpstream 企业响应)。 + if st.Credits != 1188 || st.CreditsTotal != 2000 { + t.Errorf("企业额度映射错误:credits=%d total=%d want 1188/2000", st.Credits, st.CreditsTotal) + } +} + +// TestAPIKeyKeepaliveSkipped API Key 无法 refresh:保活路径不发任何上游请求。 +// 对照个人号照常刷新(/token/refresh)。 +func TestAPIKeyKeepaliveSkipped(t *testing.T) { + sKey, rKey := apiKeyTestScheduler(t, true) + sKey.RunKeepaliveNow() + if rKey.has("/token/refresh") { + t.Errorf("API Key 账号不应参与保活,实际请求=%v", rKey.snapshot()) + } + + sPer, rPer := apiKeyTestScheduler(t, false) + sPer.RunKeepaliveNow() + if !rPer.has("/token/refresh") { + t.Errorf("个人号对照应发起保活,实际请求=%v", rPer.snapshot()) + } +} diff --git a/internal/scheduler/blackcat.go b/internal/scheduler/blackcat.go index 3b0a432a..eaf1bc26 100644 --- a/internal/scheduler/blackcat.go +++ b/internal/scheduler/blackcat.go @@ -38,8 +38,8 @@ func (s *Scheduler) RunBlackcatNow() { if a.IsGlobal() { continue // D4 门控:global 无 CN 任务体系,不发起任何上游调用 } - if a.IsEnterprise() { - continue // 企业版门控:无成长体系(growth 403 / claim-gift 400「企业账号不支持该操作」) + if !a.SupportsBehaviorEvents() { + continue // 企业版 / 企业切换会话 / 静态 API Key 账号门控:无成长体系(growth 403 / claim-gift 400「企业账号不支持该操作」;切换会话不计分) } need, err := s.cfg.Upstream.BlackcatNeed(a) if err != nil { diff --git a/internal/scheduler/compensation.go b/internal/scheduler/compensation.go new file mode 100644 index 00000000..fd0e4f1a --- /dev/null +++ b/internal/scheduler/compensation.go @@ -0,0 +1,277 @@ +// compensation.go 补偿活动的可观测化执行器(issue #189)。 +// +// 承接 internal/upstream/compensation.go 补上的状态查询,把签到排程里那次「盲领」 +// 改成「先查状态 → 待领才领」。解决三个原本无法区分的情形: +// +// ① 没有补偿 ② 已经领过 ③ 真的领取失败 +// +// 旧写法 `if credit, err := ...; err == nil { log }` 让三者同构,补偿带截止日 +// (实测 2026-10-16 那次是 10/22),签到停摆就会静默过期。本文件让「可领未领」 +// 在日志和面板上都能显式看见。 +package scheduler + +import ( + "errors" + "log" + "sync" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" + "github.com/linguo2625469/workbuddy2api-panel/internal/logfmt" + "github.com/linguo2625469/workbuddy2api-panel/internal/upstream" +) + +// 补偿任务的跳过原因(面板据此渲染,勿随意改字面量)。 +// +// 注意没有「paused」:暂停选号**不**跳过补偿——补偿是纯领奖 RPC,与「让位防风控」 +// 的选号语义无关,旅行/成长任务亦然。这里显式不设该枚举,免得后人手滑补一个 +// 把暂停号错误地排除掉。 +const ( + compSkipDisabled = "disabled" // 账号已禁用,等人工 revive + compSkipNoAuth = "no_auth" // 无凭证 + compSkipGlobal = "global" // D4 门控:global 无 CN 任务体系 + compSkipEnterprise = "enterprise" // 企业版门控:claim-compensation 返回 10001 + compSkipInactive = "no_active" // 当前无生效中的补偿活动 + compSkipClaimed = "claimed" // 本次活动已领过 + compSkipZeroCredit = "zero_credit" // 活动在,但额度为 0 +) + +// CompensationRow 单账号的补偿状态行(面板 GET /panel/api/compensation)。 +type CompensationRow struct { + UID string `json:"uid"` + Nickname string `json:"nickname,omitempty"` + Status upstream.CompensationStatus `json:"status"` + Skip string `json:"skip,omitempty"` // 非空 = 本次未发起上游查询/领取 + SkipMsg string `json:"skip_msg,omitempty"` // 跳过的中文说明 + Error string `json:"error,omitempty"` +} + +// CompensationResult 单账号的领取结果(面板 POST /panel/api/compensation_all)。 +type CompensationResult struct { + UID string `json:"uid"` + Nickname string `json:"nickname,omitempty"` + Status upstream.CompensationStatus `json:"status"` + Claimed bool `json:"claimed"` + Credit int64 `json:"credit,omitempty"` + Skip string `json:"skip,omitempty"` + SkipMsg string `json:"skip_msg,omitempty"` + Error string `json:"error,omitempty"` +} + +// compTarget 一次遍历里某个账号的处理目标。 +// +// 参与账号与被门控跳过的账号合成**同一个有序切片**(而不是 jobs + skips 两个 +// 容器)——后者会让跳过行只能从 map 取出,既丢掉昵称,又让面板行序随机漂移 +// (池顺序是按 UID 稳定排序的,面板逐行对齐依赖它)。 +type compTarget struct { + uid string + nick string + a *auth.Auth // nil = 被门控跳过 + skip string + msg string +} + +// compTargets 按池顺序枚举全部账号并完成门控判定。 +// +// 门控与 streakBonusAccount 同口径:D4 global 跳过;企业版跳过(上游对 +// claim-compensation 一律 400「企业账号不支持该操作」,与 claim-gift 同批实测); +// disabled 跳过(凭证可能已失效,发起无意义)。paused **不跳过**(见上方枚举说明)。 +func (s *Scheduler) compTargets() []compTarget { + list := s.cfg.Pool.List() + out := make([]compTarget, 0, len(list)) + for _, st := range list { + t := compTarget{uid: st.UID, nick: st.Nickname} + if st.Disabled { + t.skip, t.msg = compSkipDisabled, "账号已禁用" + out = append(out, t) + continue + } + a := s.cfg.Pool.AuthByUID(st.UID) + if a == nil || a.AccessTokenValue() == "" { + t.skip, t.msg = compSkipNoAuth, "无凭证" + out = append(out, t) + continue + } + if a.IsGlobal() { + t.skip, t.msg = compSkipGlobal, "国际版无 CN 补偿体系" + out = append(out, t) + continue + } + if a.IsEnterprise() { + t.skip, t.msg = compSkipEnterprise, "企业版无补偿体系" + out = append(out, t) + continue + } + t.a = a + out = append(out, t) + } + return out +} + +// CompensationStatusAll 并发查全部账号的补偿状态(只读,不领取)。 +// +// 输出与池顺序一致:先按 compTargets 预分配等长切片,每个 goroutine 只写自己那 +// 一个下标——不同下标是互不重叠的内存,无需互斥锁即可避免竞态(也避免了 +// 「goroutine 完成顺序 = 返回顺序」导致的行序漂移)。并发上限 3 与既有 +// /panel/api/packages 的账号遍历一致。 +func (s *Scheduler) CompensationStatusAll() []CompensationRow { + targets := s.compTargets() + rows := make([]CompensationRow, len(targets)) + + sem := make(chan struct{}, 3) + var wg sync.WaitGroup + for i, t := range targets { + if t.a == nil { + rows[i] = CompensationRow{UID: t.uid, Nickname: t.nick, Skip: t.skip, SkipMsg: t.msg} + continue + } + wg.Add(1) + go func(i int, t compTarget) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + row := CompensationRow{UID: t.uid, Nickname: t.nick} + st, err := s.cfg.Upstream.GetCompensationStatus(t.a) + if err != nil { + row.Error = err.Error() + } else { + row.Status = st + } + rows[i] = row + }(i, t) + } + wg.Wait() + return rows +} + +// ClaimCompensationNow 逐号「查状态 → 待领则领」,返回逐号结果。 +// +// 供面板手动按钮与签到排程共用;单号失败不影响其余账号。 +// +// 这里刻意**串行**而非并发:ClaimCompensation 对同一活动每号仅一次(重复领会拿到 +// 业务错误),串行让「前一个号失败」不影响后一个号的判定,且账号量是个位数。 +func (s *Scheduler) ClaimCompensationNow() []CompensationResult { + targets := s.compTargets() + out := make([]CompensationResult, 0, len(targets)) + for _, t := range targets { + if t.a == nil { + out = append(out, CompensationResult{UID: t.uid, Nickname: t.nick, Skip: t.skip, SkipMsg: t.msg}) + continue + } + out = append(out, s.claimOne(t.a)) + } + return out +} + +// ClaimCompensationFor 单号领取补偿(面板单号按钮);账号不在池中返回 error。 +// +// 门控与 compTargets 同一套判据,但直接查 Pool.Status/AuthByUID(均为 O(1) 的 +// 加锁查询,无副作用),不扫全表——pool 本来就提供单号查询,没必要造 O(n)。 +func (s *Scheduler) ClaimCompensationFor(uid string) (CompensationResult, error) { + st, ok := s.cfg.Pool.Status(uid) + if !ok { + return CompensationResult{UID: uid}, errors.New("account not found") + } + skip := func(reason, msg string) (CompensationResult, error) { + return CompensationResult{UID: uid, Nickname: st.Nickname, Skip: reason, SkipMsg: msg}, nil + } + if st.Disabled { + return skip(compSkipDisabled, "账号已禁用") + } + a := s.cfg.Pool.AuthByUID(uid) + if a == nil || a.AccessTokenValue() == "" { + return skip(compSkipNoAuth, "无凭证") + } + if a.IsGlobal() { + return skip(compSkipGlobal, "国际版无 CN 补偿体系") + } + if a.IsEnterprise() { + return skip(compSkipEnterprise, "企业版无补偿体系") + } + return s.claimOne(a), nil +} + +// claimOne 单账号「查状态 → 待领才领」的全部分支与日志。 +func (s *Scheduler) claimOne(a *auth.Auth) CompensationResult { + r := CompensationResult{UID: a.UID, Nickname: a.Nickname} + + st, err := s.cfg.Upstream.GetCompensationStatus(a) + if err != nil { + // 查状态失败是真问题(传输/鉴权/上游),必须显式记日志—— + // 旧盲领写法会把这类失败静默吞成「无事发生」。 + r.Error = err.Error() + log.Printf("compensation %s: 查状态失败: %v", logfmt.Label(a.UID, a.Nickname), err) + return r + } + r.Status = st + + // 判据用 upstream 的 Pending()(唯一权威实现),不要再在本文件复刻一份 + // active/claimed/credit 的与门——此前三处拷贝(这里 / Pending() / 前端 + // compPending)已经开始漂移。下方 switch 只为「不领」的三种形态给出不同文案, + // 不参与判定。 + if !st.Pending() { + switch { + case !st.Active: + r.Skip, r.SkipMsg = compSkipInactive, "当前无生效补偿" + case st.Claimed: + r.Skip, r.SkipMsg = compSkipClaimed, "本次活动已领取" + default: + r.Skip, r.SkipMsg = compSkipZeroCredit, "活动在但额度为 0" + } + return r + } + + credit, err := s.cfg.Upstream.ClaimCompensation(a) + if err != nil { + r.Error = err.Error() + log.Printf("compensation %s: 待领 %dc 但领取失败: %v", + logfmt.Label(a.UID, a.Nickname), st.CreditNum, err) + return r + } + r.Claimed, r.Credit = true, credit + log.Printf("compensation %s: 🎊 补偿领取 +%dc(截止 %s)", + logfmt.Label(a.UID, a.Nickname), credit, st.EndTime) + return r +} + +// CompensationSummary 批量领取的汇总计数。 +// +// 单独统计 Errored 是为了让调用方能区分「没有可领」与「一个都没查成」:上游故障 +// 或 token 全过期时,每个账号都会走 claimOne 的 error 分支。若只按 Claimed 计数, +// 面板会把「全部查询失败」渲染成「当前没有待领补偿」——正是本 PR 要消灭的那类 +// 静默失败,只是换了个位置。调用方必须先看 Errored 再看 Claimed。 +type CompensationSummary struct { + Total int `json:"total"` + Claimed int `json:"claimed"` + Skipped int `json:"skipped"` + Errored int `json:"errored"` + Credit int64 `json:"credit"` +} + +// OK 是否全部账号都拿到了确定结论(无错误)。 +func (s CompensationSummary) OK() bool { return s.Errored == 0 } + +// SummarizeCompensation 汇总逐号结果。 +func SummarizeCompensation(results []CompensationResult) CompensationSummary { + var s CompensationSummary + s.Total = len(results) + for _, r := range results { + switch { + case r.Error != "": + s.Errored++ + case r.Claimed: + s.Claimed++ + s.Credit += r.Credit + default: + s.Skipped++ + } + } + return s +} + +// claimCompensationQuiet 签到排程用:待领才领,无待领不记日志(避免每日刷屏), +// 但**真实失败一律记日志**——这正是旧盲领实现丢掉的信息。 +// +// 与面板手动按钮共用 claimOne,避免两条路径的分支/日志漂移。 +func (s *Scheduler) claimCompensationQuiet(a *auth.Auth) { + _ = s.claimOne(a) +} diff --git a/internal/scheduler/compensation_test.go b/internal/scheduler/compensation_test.go new file mode 100644 index 00000000..1e478e0e --- /dev/null +++ b/internal/scheduler/compensation_test.go @@ -0,0 +1,297 @@ +package scheduler + +import ( + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" + "github.com/linguo2625469/workbuddy2api-panel/internal/pool" + "github.com/linguo2625469/workbuddy2api-panel/internal/upstream" +) + +// compensationRecorder 记录上游路径,并返回可编排的补偿状态。 +type compensationRecorder struct { + mu sync.Mutex + // statusBody 是 compensation-status 返回的 data 部分;空则用默认的「待领取 500c」。 + statusBody string + claimHits int + paths []string +} + +func (r *compensationRecorder) server(t *testing.T) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + r.mu.Lock() + r.paths = append(r.paths, req.URL.Path) + switch { + case strings.HasSuffix(req.URL.Path, "/compensation-status"): + body := r.statusBody + if body == "" { + body = `{"active":true,"claimed":false,"credit_num":500,"end_time":"2026-10-22 23:59:59"}` + } + r.mu.Unlock() + w.Write([]byte(`{"code":0,"msg":"OK","data":` + body + `}`)) + case strings.HasSuffix(req.URL.Path, "/claim-compensation"): + r.claimHits++ + r.mu.Unlock() + w.Write([]byte(`{"code":0,"msg":"OK","data":{"credit":500}}`)) + default: + r.mu.Unlock() + w.Write([]byte(`{"code":0,"msg":"OK","data":{}}`)) + } + })) + t.Cleanup(srv.Close) + return srv +} + +func (r *compensationRecorder) claims() int { + r.mu.Lock() + defer r.mu.Unlock() + return r.claimHits +} + +func (r *compensationRecorder) pathsSnapshot() []string { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]string, len(r.paths)) + copy(out, r.paths) + return out +} + +// compensationScheduler 构造带指定账号的调度器。 +func compensationScheduler(t *testing.T, rec *compensationRecorder, accts ...*auth.Auth) *Scheduler { + t.Helper() + srv := rec.server(t) + p := pool.New("") + for _, a := range accts { + p.Add(a) + } + up := &upstream.Client{ + HTTP: srv.Client(), + ChatBaseCN: srv.URL, + BillingBaseCN: srv.URL, + WebBaseCN: srv.URL, + } + return New(Config{Pool: p, Upstream: up}) +} + +// TestCompensationStatusAllKeepsPoolOrder 钉住行序(S10)。 +// +// 这条不是为了好看:面板按返回顺序渲染「官方补偿」卡片,若顺序随机,同一批账号 +// 每轮刷新都在换位置,用户对不上哪行是自己那个号。实现上必须**按池顺序**返回, +// 与 Pool.List()(按 UID 稳定排序)一致——并发查询只能在各自下标上写,不能靠 +// 完成顺序 append。 +func TestCompensationStatusAllKeepsPoolOrder(t *testing.T) { + rec := &compensationRecorder{} + s := compensationScheduler(t, rec, + &auth.Auth{UID: "u1", AccessToken: "at1", Nickname: "甲", ExpiresAt: 9999999999}, + &auth.Auth{UID: "u2", AccessToken: "at2", Nickname: "乙", ExpiresAt: 9999999999}, + &auth.Auth{UID: "u3", AccessToken: "at3", Nickname: "丙", ExpiresAt: 9999999999}, + ) + + want := []string{"u1", "u2", "u3"} // Pool.List() 按 UID 排序 + for i := 0; i < 20; i++ { // 多跑几轮:单轮撞对的概率掩盖不了竞态 + rows := s.CompensationStatusAll() + if len(rows) != len(want) { + t.Fatalf("行数=%d want %d", len(rows), len(want)) + } + for j, uid := range want { + if rows[j].UID != uid { + t.Fatalf("第 %d 轮第 %d 行 uid=%s want %s(顺序漂移)", i, j, rows[j].UID, uid) + } + } + } +} + +// TestCompensationSkippedRowsCarryNickname 跳过行必须带昵称(S9 相关)。 +// +// 跳过行不参与上游查询,若不返回昵称,面板只能显示截断的 UID,用户根本认不出 +// 是哪个号被门控挡掉了。 +func TestCompensationSkippedRowsCarryNickname(t *testing.T) { + rec := &compensationRecorder{} + ent := &auth.Auth{UID: "u1", AccessToken: "at1", Nickname: "企业号", ExpiresAt: 9999999999} + ent.EnterpriseID = "ent-1" + s := compensationScheduler(t, rec, + &auth.Auth{UID: "u2", AccessToken: "at2", Nickname: "个人号", ExpiresAt: 9999999999}, + ent, + ) + + rows := s.CompensationStatusAll() + if len(rows) != 2 { + t.Fatalf("行数=%d want 2", len(rows)) + } + for i := range rows { + if rows[i].UID != "u1" { + continue + } + if rows[i].Skip != compSkipEnterprise { + t.Errorf("企业号 skip=%q want %q", rows[i].Skip, compSkipEnterprise) + } + if rows[i].Nickname != "企业号" { + t.Errorf("跳过行昵称=%q want 企业号(面板靠它显示账号)", rows[i].Nickname) + } + if got := rec.pathsSnapshot(); len(got) != 1 { + t.Errorf("企业号不应发起上游查询,实际请求=%v(个人号 1 次才对)", got) + } + } +} + +// TestEnterpriseGateSkipsCompensationEntries 企业版在**面板直接触发的三个新入口** +// 上同样不得发起任何上游调用。 +// +// 与 TestEnterpriseGateSkipsGrowthTasks 互补:那个用例覆盖五类排程任务,本用例 +// 覆盖面板按钮路径——门控写在一处不够,两个入口都要钉住。 +func TestEnterpriseGateSkipsCompensationEntries(t *testing.T) { + cases := []struct { + name string + run func(*Scheduler) + }{ + {"批量状态查询", func(s *Scheduler) { s.CompensationStatusAll() }}, + {"批量领取", func(s *Scheduler) { s.ClaimCompensationNow() }}, + {"单号领取", func(s *Scheduler) { + if _, err := s.ClaimCompensationFor("u1"); err != nil { + t.Errorf("单号领取不该报错: %v", err) + } + }}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + // 企业号 + recEnt := &compensationRecorder{} + ent := &auth.Auth{UID: "u1", AccessToken: "at1", Nickname: "企业号", ExpiresAt: 9999999999} + ent.EnterpriseID = "ent-1" + c.run(compensationScheduler(t, recEnt, ent)) + if got := recEnt.pathsSnapshot(); len(got) != 0 { + t.Errorf("企业号不应发起任何上游调用,实际=%v", got) + } + + // 个人号对照组:必须确实有动作,否则「没发请求」可能只是构造有误。 + recPer := &compensationRecorder{} + c.run(compensationScheduler(t, recPer, + &auth.Auth{UID: "u1", AccessToken: "at1", Nickname: "个人号", ExpiresAt: 9999999999})) + if len(recPer.pathsSnapshot()) == 0 { + t.Errorf("个人号对照应有上游调用,实际=%v", recPer.pathsSnapshot()) + } + }) + } +} + +// TestClaimCompensationForRejectsDisabled 单号入口必须挡住已禁用账号, +// 否则出现「批量按钮跳过禁用号、手动按钮却能领」的割裂。 +func TestClaimCompensationForRejectsDisabled(t *testing.T) { + rec := &compensationRecorder{} + s := compensationScheduler(t, rec, + &auth.Auth{UID: "u1", AccessToken: "at1", Nickname: "禁用号", ExpiresAt: 9999999999}) + + s.cfg.Pool.Disable("u1", "测试禁用") // 置为 disabled + + res, err := s.ClaimCompensationFor("u1") + if err != nil { + t.Fatalf("不应报错: %v", err) + } + if res.Skip != compSkipDisabled { + t.Errorf("skip=%q want %q", res.Skip, compSkipDisabled) + } + if rec.claims() != 0 { + t.Errorf("禁用号不应被领取,实际 claim 调用 %d 次", rec.claims()) + } + if got := rec.pathsSnapshot(); len(got) != 0 { + t.Errorf("禁用号不应发起任何上游调用,实际=%v", got) + } +} + +// TestClaimCompensationOnlyWhenPending 「先查后领」的核心判据:只有 +// active && !claimed && credit_num>0 才真的调领取接口。 +func TestClaimCompensationOnlyWhenPending(t *testing.T) { + cases := []struct { + name string + body string + wantClaim bool + wantSkip string + }{ + {"待领取", `{"active":true,"claimed":false,"credit_num":500}`, true, ""}, + {"已领取", `{"active":true,"claimed":true,"credit_num":500}`, false, compSkipClaimed}, + {"无活动", `{"active":false,"claimed":false,"credit_num":0}`, false, compSkipInactive}, + {"额度为零", `{"active":true,"claimed":false,"credit_num":0}`, false, compSkipZeroCredit}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rec := &compensationRecorder{statusBody: tc.body} + s := compensationScheduler(t, rec, + &auth.Auth{UID: "u1", AccessToken: "at1", Nickname: "甲", ExpiresAt: 9999999999}) + + res := s.claimOne(s.cfg.Pool.AuthByUID("u1")) + if tc.wantClaim { + if !res.Claimed || res.Credit != 500 { + t.Errorf("应领取 500c,实际 claimed=%v credit=%d", res.Claimed, res.Credit) + } + if rec.claims() != 1 { + t.Errorf("应调用领取 1 次,实际 %d 次", rec.claims()) + } + } else { + if res.Claimed { + t.Error("不应标记为已领取") + } + if res.Skip != tc.wantSkip { + t.Errorf("skip=%q want %q", res.Skip, tc.wantSkip) + } + if rec.claims() != 0 { + t.Errorf("不应调用领取接口,实际 %d 次", rec.claims()) + } + } + }) + } +} + +// TestSummarizeCompensationDistinguishesError 汇总必须把「查询失败」与 +// 「没有可领」分开统计——这是本 PR 最重要的一条语义:上游全挂时每个账号都走 +// error 分支,若只按 claimed 计数,调用方会把「一个都没查成」说成「没有待领补偿」。 +func TestSummarizeCompensationDistinguishesError(t *testing.T) { + cases := []struct { + name string + results []CompensationResult + want CompensationSummary + }{ + { + "全部查询失败", + []CompensationResult{ + {UID: "u1", Error: "boom"}, + {UID: "u2", Error: "boom"}, + }, + CompensationSummary{Total: 2, Errored: 2}, + }, + { + "无待领(已领完)", + []CompensationResult{ + {UID: "u1", Skip: compSkipClaimed}, + {UID: "u2", Skip: compSkipClaimed}, + }, + CompensationSummary{Total: 2, Skipped: 2}, + }, + { + "混合:两个领到、一个失败", + []CompensationResult{ + {UID: "u1", Claimed: true, Credit: 500}, + {UID: "u2", Claimed: true, Credit: 500}, + {UID: "u3", Error: "boom"}, + }, + CompensationSummary{Total: 3, Claimed: 2, Errored: 1, Credit: 1000}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := SummarizeCompensation(tc.results) + if got != tc.want { + t.Errorf("汇总=%+v want %+v", got, tc.want) + } + // 全失败时 OK() 必须为 false,否则调用方会当成「一切正常,只是没活动」。 + wantOK := tc.want.Errored == 0 + if got.OK() != wantOK { + t.Errorf("OK()=%v want %v(errored=%d)", got.OK(), wantOK, got.Errored) + } + }) + } +} diff --git a/internal/scheduler/enterprise_session_gate_test.go b/internal/scheduler/enterprise_session_gate_test.go new file mode 100644 index 00000000..50022416 --- /dev/null +++ b/internal/scheduler/enterprise_session_gate_test.go @@ -0,0 +1,95 @@ +package scheduler + +import ( + "encoding/base64" + "testing" + "time" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" + "github.com/linguo2625469/workbuddy2api-panel/internal/pool" + "github.com/linguo2625469/workbuddy2api-panel/internal/upstream" +) + +// sessionTestScheduler 与 enterpriseTestScheduler 同构:switched=true 时账号的 +// AccessToken 为带 token_source=enterprise_switch 的 JWT(企业**切换会话**, +// enterpriseId 为空——网关无法凭 enterpriseId 识别的形态);false 为个人号对照组。 +func sessionTestScheduler(t *testing.T, switched bool) (*Scheduler, *recorderUpstream) { + t.Helper() + r := &recorderUpstream{} + srv := r.server() + t.Cleanup(srv.Close) + + token := "at" + if switched { + payload := base64.RawURLEncoding.EncodeToString([]byte(`{"sub":"u1","token_source":"enterprise_switch"}`)) + token = "hdr." + payload + ".sig" + } + a := &auth.Auth{UID: "u1", AccessToken: token, RefreshToken: "rt", ExpiresAt: 9999999999} + p := pool.New("") + p.Add(a) + + up := &upstream.Client{ + HTTP: srv.Client(), + ChatBaseCN: srv.URL, + BillingBaseCN: srv.URL, + WebBaseCN: srv.URL, + } + s := New(Config{Pool: p, Upstream: up}) + return s, r +} + +// TestEnterpriseSessionGateSkipsBehaviorTasks 企业切换会话的行为类任务门控: +// /v2/report 等行为事件对该形态返回 200 却被上游静默丢弃(实测 2026-10-12, +// 15 个成长任务全部 0/x 且 90s 内进度从不跳动),执行即零收益白跑—— +// 活跃上报 / 连登管家 / 猫猫旅行 / 夜猫子必须一个上游调用都不发; +// 签到不在此列(对该形态未观测到静默失败,且失败是显式报错)。 +// 每个子用例配个人号对照,证明"没发请求"是门控生效而非构造有误。 +func TestEnterpriseSessionGateSkipsBehaviorTasks(t *testing.T) { + // 与 TestEnterpriseGateSkipsGrowthTasks 同款:把夜猫子窗口钉在窗口内。 + restoreClock := nowForNightWindow + nowForNightWindow = func() time.Time { return time.Date(2026, 10, 12, 23, 30, 0, 0, time.Local) } + t.Cleanup(func() { nowForNightWindow = restoreClock }) + + cases := []struct { + name string + run func(*Scheduler) + // endpoint 是该任务在**个人号**上必然发起的上游路径(对照组判据)。 + endpoint string + // gated=true 期望切换会话不发该请求;false 期望照常发(不门控)。 + gated bool + }{ + {"签到", func(s *Scheduler) { s.RunCheckinNow() }, "/daily-checkin", false}, + {"活跃上报", func(s *Scheduler) { s.RunActivityNow() }, "/v2/report", true}, + {"猫猫旅行", func(s *Scheduler) { s.RunTravelNow() }, "/activity/growth/buddy/info", true}, + {"夜猫子", func(s *Scheduler) { s.RunBlackcatNow() }, "/v2/activity/growth/tasks", true}, + {"连登管家", func(s *Scheduler) { s.RunStreakBonusNow() }, "/activity/growth/streak", true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + sSess, rSess := sessionTestScheduler(t, true) + c.run(sSess) + if c.gated && rSess.has(c.endpoint) { + t.Errorf("企业切换会话不应发起 %s,实际请求=%v", c.endpoint, rSess.snapshot()) + } + if !c.gated && !rSess.has(c.endpoint) { + t.Errorf("企业切换会话应照常发起 %s(不门控),实际请求=%v", c.endpoint, rSess.snapshot()) + } + + sPer, rPer := sessionTestScheduler(t, false) + c.run(sPer) + if !rPer.has(c.endpoint) { + t.Errorf("个人号对照应发起 %s,实际请求=%v", c.endpoint, rPer.snapshot()) + } + }) + } +} + +// TestEnterpriseSessionKeepsKeepalive 切换会话照常保活(token 会过期,会话 +// 形态不影响选号/刷新能力)。 +func TestEnterpriseSessionKeepsKeepalive(t *testing.T) { + s, r := sessionTestScheduler(t, true) + s.RunKeepaliveNow() + if !r.has("/token/refresh") { + t.Errorf("企业切换会话必须照常保活,实际请求=%v", r.snapshot()) + } +} diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index 163694fa..24c914e5 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -61,6 +61,11 @@ type Config struct { // 待办并执行,与面板「执行全部待办」按钮同管线)。调度器只管时点不管实现—— // panel 在 scheduler 之后构造,用 SetGrowthHook 事后挂载;nil 时到点跳过。 GrowthHook func() + + // TaskSweepHook 任务补领清扫回调(panel.RunTaskSweepOnce:把「已达标未领」 + // 的任务奖励领掉,不跑任何行为链路)。与 GrowthHook 同款延迟挂载;nil 时 + // ticker 到点跳过。见 issue #174:成长队列与「一键完成」都不负责这条补领链路。 + TaskSweepHook func() } // Scheduler 调度器。 @@ -73,15 +78,25 @@ type Scheduler struct { adoptTried map[string]string // schedMu 保护排程参数(时点/开关);Reconfigure 可在运行期热改(面板保存配置时调用)。 - // rearmSchedule/rearmBalance 是「排程已变,立即重算」通知:Run 与余额刷新循环各自消费, - // 分别用独立 channel(同 channel 被两个 select 消费会丢信号)。 + // rearmSchedule/rearmBalance/rearmSweep 是「排程已变,立即重算」通知:Run 与 + // 各周期循环各自消费,分别用独立 channel(同 channel 被多个 select 消费会丢信号)。 schedMu sync.Mutex rearmSchedule chan struct{} rearmBalance chan struct{} + rearmSweep chan struct{} // balanceInterval 余额刷新间隔(纳秒,0=暂停)。atomic 读写:执行循环每轮读当前值, // SetBalanceInterval 可任意时刻热改(面板保存配置)。 balanceInterval atomic.Int64 + + // lastBalanceRefresh 最近一次后台余额刷新的完成时刻(Unix 纳秒,0=尚未跑过)。 + // 供面板「到期提醒」标注数据年龄:卡片展示的是这份快照,必须让年龄可读, + // 否则界面 5 秒轮询会让人误以为数据也是 5 秒级新鲜。 + lastBalanceRefresh atomic.Int64 + + // sweepInterval 任务补领清扫间隔(纳秒,0=暂停)。语义同 balanceInterval: + // SetTaskSweepInterval 可热改(面板保存配置后下一轮生效)。 + sweepInterval atomic.Int64 } // New 构建。 @@ -106,6 +121,7 @@ func New(cfg Config) *Scheduler { adoptTried: make(map[string]string), rearmSchedule: make(chan struct{}, 1), rearmBalance: make(chan struct{}, 1), + rearmSweep: make(chan struct{}, 1), } } @@ -455,7 +471,10 @@ func (s *Scheduler) RunCheckinNow() { continue } a := s.cfg.Pool.AuthByUID(st.UID) - if a == nil || a.RefreshTokenValue() == "" { + // 无 refresh token 的账号默认跳过(保号以刷新为前提);但静态 API Key 例外—— + // 它没有 refresh token 也不需要刷新,下方余额刷新(企业口径)是 credit_floor + // 与面板额度的唯一数据源,必须照跑。带 access token 的 JWT 账号保持现状跳过。 + if a == nil || (a.RefreshTokenValue() == "" && !a.IsAPIKey()) { continue } // D4 门控:realm=global 账号无签到体系,直接跳过(不发起任何上游调用,避免风控)。 @@ -464,11 +483,11 @@ func (s *Scheduler) RunCheckinNow() { if a.IsGlobal() { continue } - // 企业版无签到体系:上游对 POST /v2/billing/meter/daily-checkin 直接 - // 400 code 10001「企业账号不支持该操作」。跳过签到,但**不 continue**—— + // 企业版与静态 API Key 账号均无签到体系(企业版上游 400 code 10001「企业账号 + // 不支持该操作」;API Key 无个人账号身份):跳过签到,但**不 continue**—— // 下方余额查询照常跑(企业额度走 get-enterprise-user-usage 口径,且是 // credit_floor 判定与面板额度展示的唯一数据源)。 - if !a.IsEnterprise() { + if a.SupportsPersonalGrowth() { if err := s.cfg.Upstream.DailyCheckin(a); err != nil { // "今天已签到"是幂等成功(上游对重复签到返回 code!=0),不再当失败打 error 行。 if upstream.IsAlreadyCheckin(err) { @@ -485,14 +504,22 @@ func (s *Scheduler) RunCheckinNow() { log.Printf("checkin %s: 签到成功", logfmt.Label(st.UID, st.Nickname)) } } - // 分桶查余额:配置窗口内的积分单独标记,同时记录最早未来到期批次。 - remain, total, expiring, earliestAt, earliestRemaining, err := s.cfg.Upstream.UserResourceDetailedWithExpiry(a, expiringSoon) + // 分桶查余额:配置窗口内的积分单独标记,同时记录最早未来到期批次与 + // 按自然日聚合的全部批次(首页快照与「检查」共用同一口径)。 + remain, total, expiring, earliestAt, earliestRemaining, buckets, err := s.cfg.Upstream.UserResourceSnapshot(a, expiringSoon) if err != nil { log.Printf("user-resource %s: %v", logfmt.Label(st.UID, st.Nickname), err) continue } s.cfg.Pool.ReenableIfCredits(st.UID, remain, total) - s.cfg.Pool.SetCreditsDetailed(st.UID, remain, total, expiring, earliestAt, earliestRemaining) + s.cfg.Pool.SetCreditsSnapshot(st.UID, pool.CreditsSnapshot{ + Remain: remain, + Total: total, + Expiring: expiring, + EarliestAt: earliestAt, + EarliestRemaining: earliestRemaining, + ExpiryBuckets: poolExpiryBuckets(buckets), + }) } s.RunStreakBonusNow() } @@ -526,7 +553,10 @@ func (s *Scheduler) runActivity(ctx context.Context) { // 点亮 growth 连登天数 / 解锁 first_buddy 领养——这两者对企业号都是 403 // (「growth system is only available for personal users」),即**零收益**。 // 该类"静默无效"最易被忽略(日志无异常),故显式跳过,不给上游多发一次请求。 - if a.IsEnterprise() { + // 企业切换会话(token_source=enterprise_switch)同跳:report 返回 200 却被 + // 上游静默丢弃(连登不涨),同样是零收益(见 auth.IsEnterpriseSession)。 + // 静态 API Key 账号同跳(无个人账号身份)。 + if !a.SupportsBehaviorEvents() { continue } // global 账号同样上报(PR #45 实测国际版 /v2/report 在 workbuddy.ai 上 code=0 OK, @@ -604,6 +634,19 @@ func (s *Scheduler) RunKeepaliveNow() { } } +// poolExpiryBuckets 把 upstream 的按日到期批次转换为 pool 结构。两个包字段同构, +// 手工转换是为了不让底层 pool 反向依赖 upstream。 +func poolExpiryBuckets(in []upstream.ExpiryBucket) []pool.ExpiryBucket { + if len(in) == 0 { + return nil + } + out := make([]pool.ExpiryBucket, len(in)) + for i, b := range in { + out[i] = pool.ExpiryBucket{Date: b.Date, ExpireAt: b.ExpireAt, Remain: b.Remain} + } + return out +} + // RunBalanceRefreshNow 并发查询余额并更新池内 credits。 // 账号范围:默认跳过禁用账号;schedule.include_disabled_in_tasks 打开时一并刷新 // (轮换用法下据此判断下一个该启用谁)。注意 ReenableIfCredits 对 disabled 是 @@ -626,16 +669,24 @@ func (s *Scheduler) RunBalanceRefreshNow() { wg.Add(1) go func(a *auth.Auth, uid string) { defer wg.Done() - remain, total, expiring, earliestAt, earliestRemaining, err := s.cfg.Upstream.UserResourceDetailedWithExpiry(a, expiringSoon) + remain, total, expiring, earliestAt, earliestRemaining, buckets, err := s.cfg.Upstream.UserResourceSnapshot(a, expiringSoon) if err != nil { log.Printf("balance %s: %v", logfmt.Label(uid, a.Nickname), err) return } s.cfg.Pool.ReenableIfCredits(uid, remain, total) - s.cfg.Pool.SetCreditsDetailed(uid, remain, total, expiring, earliestAt, earliestRemaining) + s.cfg.Pool.SetCreditsSnapshot(uid, pool.CreditsSnapshot{ + Remain: remain, + Total: total, + Expiring: expiring, + EarliestAt: earliestAt, + EarliestRemaining: earliestRemaining, + ExpiryBuckets: poolExpiryBuckets(buckets), + }) }(a, st.UID) } wg.Wait() + s.lastBalanceRefresh.Store(time.Now().UnixNano()) } // StartBalanceRefresh 后台周期性余额刷新(独立 ticker goroutine,ctx 取消即停)。 @@ -648,6 +699,10 @@ func (s *Scheduler) StartBalanceRefresh(ctx context.Context, interval time.Durat } s.balanceInterval.Store(int64(interval)) go func() { + // 启动即刷一轮:进程重启(含升级到带按日批次的版本)后旧 state 可能没有 + // credits_expiry_buckets;不等第一个 ticker,首页快照就能拿到与「检查」 + // 同口径的完整数据,避免升级后前 5 分钟仍显示旧口径。 + s.RunBalanceRefreshNow() var logged time.Duration for { cur := time.Duration(s.balanceInterval.Load()) @@ -678,6 +733,80 @@ func (s *Scheduler) StartBalanceRefresh(ctx context.Context, interval time.Durat }() } +// StartTaskSweep 后台周期性任务补领清扫(独立 ticker goroutine,ctx 取消即停)。 +// 与 StartBalanceRefresh 同款结构;interval<=0 不启动(面板关闭开关时 main 不 +// 调用即可),运行期可用 SetTaskSweepInterval 热改间隔(下一轮生效)。 +// +// 与成长队列(GrowthHook,小时制)的区别:队列执行的是**行为链路**(真实对话、 +// 事件上报)成本高;清扫只做 accept/claim 两条幂等读改请求,分钟级跑也几乎零 +// 负担,却能补上「达标的任务没人领奖」这条长期缺失的链路(issue #174)。 +func (s *Scheduler) StartTaskSweep(ctx context.Context, interval time.Duration) { + if interval <= 0 { + return + } + s.sweepInterval.Store(int64(interval)) + go func() { + var logged time.Duration + for { + cur := time.Duration(s.sweepInterval.Load()) + if cur != logged { + log.Printf("scheduler: 任务补领清扫每 %s(暂停中显示 0s)", cur) + logged = cur + } + if cur <= 0 { + // 被热改暂停:等重排通知(重新启用时唤醒)或退出。 + select { + case <-ctx.Done(): + return + case <-s.rearmSweep: + continue + } + } + timer := time.NewTimer(cur) + select { + case <-ctx.Done(): + timer.Stop() + return + case <-s.rearmSweep: + timer.Stop() // 间隔已变:立刻按新值重算 + case <-timer.C: + s.schedMu.Lock() + hook := s.cfg.TaskSweepHook + s.schedMu.Unlock() + if hook != nil { + hook() + } + } + } + }() +} + +// RunTaskSweepNow 立刻跑一轮任务补领清扫(手动触发/测试用)。 +func (s *Scheduler) RunTaskSweepNow() { + s.schedMu.Lock() + hook := s.cfg.TaskSweepHook + s.schedMu.Unlock() + if hook != nil { + hook() + } +} + +// SetTaskSweepHook 挂载/替换任务补领清扫回调(panel 构造晚于 scheduler,事后接线)。 +func (s *Scheduler) SetTaskSweepHook(fn func()) { + s.schedMu.Lock() + s.cfg.TaskSweepHook = fn + s.schedMu.Unlock() +} + +// SetTaskSweepInterval 热改任务补领清扫间隔;<=0 表示暂停循环(面板关闭该开关时)。 +func (s *Scheduler) SetTaskSweepInterval(d time.Duration) { + if d < 0 { + d = 0 + } + s.sweepInterval.Store(int64(d)) + poke(s.rearmSweep) +} + // SetBalanceInterval 热改余额刷新间隔;<=0 表示暂停循环(面板关闭该开关时)。 func (s *Scheduler) SetBalanceInterval(d time.Duration) { if d < 0 { @@ -686,3 +815,17 @@ func (s *Scheduler) SetBalanceInterval(d time.Duration) { s.balanceInterval.Store(int64(d)) poke(s.rearmBalance) } + +// BalanceInterval 当前余额后台刷新间隔(0=暂停)。面板 overview 据此告诉用户 +// 「到期提醒」卡片的数据节奏,而不是让 5 秒界面轮询冒充数据新鲜度。 +func (s *Scheduler) BalanceInterval() time.Duration { + return time.Duration(s.balanceInterval.Load()) +} + +// BalanceRefreshedAt 最近一次后台余额刷新的完成时刻(零值 = 尚未跑过)。 +func (s *Scheduler) BalanceRefreshedAt() time.Time { + if v := s.lastBalanceRefresh.Load(); v != 0 { + return time.Unix(0, v) + } + return time.Time{} +} diff --git a/internal/scheduler/streak.go b/internal/scheduler/streak.go index af3ca7af..4e976bfa 100644 --- a/internal/scheduler/streak.go +++ b/internal/scheduler/streak.go @@ -29,8 +29,8 @@ func (s *Scheduler) RunStreakBonusNow() { if a.IsGlobal() { continue // D4 门控:global 无 CN 任务体系,不发起任何上游调用 } - if a.IsEnterprise() { - continue // 企业版门控:无成长体系(/activity/growth/streak 上游一律 403) + if !a.SupportsBehaviorEvents() { + continue // 企业版 / 企业切换会话 / 静态 API Key 账号门控:无成长体系(/activity/growth/streak 上游一律 403;切换会话 200 但不计分) } s.streakBonusAccount(a) } @@ -44,9 +44,11 @@ func (s *Scheduler) streakBonusAccount(a *auth.Auth) { if credit, err := s.cfg.Upstream.ClaimGift(a); err == nil { log.Printf("streak-bonus %s: 🎊 新手礼包 +%dc", logfmt.Label(a.UID, a.Nickname), credit) } - if credit, err := s.cfg.Upstream.ClaimCompensation(a); err == nil { - log.Printf("streak-bonus %s: 🎊 补偿领取 +%dc", logfmt.Label(a.UID, a.Nickname), credit) - } + // 补偿改为「先查状态 → 待领才领」(issue #189):旧写法 `if credit, err := + // ClaimCompensation(...); err == nil` 把「没有补偿」「已经领过」与「领取真的 + // 失败」压成同一形态,补偿带截止日(实测那次为 10/22)时签到一旦停摆就静默 + // 过期。现在无待领不记日志,但状态查询失败与领取失败一律显式告警。 + s.claimCompensationQuiet(a) full, err := s.cfg.Upstream.GrowthStreakFull(a) if err != nil { diff --git a/internal/scheduler/travel.go b/internal/scheduler/travel.go index 9cc19ca8..eb5f8e50 100644 --- a/internal/scheduler/travel.go +++ b/internal/scheduler/travel.go @@ -59,8 +59,8 @@ func (s *Scheduler) RunTravelNow() { if a.IsGlobal() { continue // D4 门控:global 无 CN 任务体系,不发起任何上游调用 } - if a.IsEnterprise() { - continue // 企业版门控:无成长体系(/activity/growth/* 上游一律 403),不发起任何上游调用 + if !a.SupportsBehaviorEvents() { + continue // 企业版 / 企业切换会话 / 静态 API Key 账号门控:无成长体系(/activity/growth/* 上游一律 403;切换会话行为事件不计分),不发起任何上游调用 } if !first { time.Sleep(travelAccountDelay) @@ -70,6 +70,39 @@ func (s *Scheduler) RunTravelNow() { } } +// RunTravelNowFor 对**单个**账号执行一趟旅行巡检(issue #227)。 +// +// 与 RunTravelNow 的差别:只跑指定 uid,且**不做账号间限速**(单号触发 +// 没有并发批量压力,不需要 sleep 800ms)——面板「按号触发」按钮点下去应当 +// 立刻返回结果,而不是先干等一秒。 +// +// 返回值: +// - ran=false, skip_reason 非空 → 被门控跳过(禁用/无 RT/global/企业/API Key) +// - ran=true → 已执行一趟巡检(RPC 失败只在日志里体现,不影响返回值) +func (s *Scheduler) RunTravelNowFor(uid string) (ran bool, skipReason string) { + if uid == "" { + return false, "uid required" + } + a := s.cfg.Pool.AuthByUID(uid) + if a == nil { + return false, "account not found" + } + if st, ok := s.cfg.Pool.Status(uid); ok && st.Disabled { + return false, "account disabled" + } + if a.RefreshTokenValue() == "" { + return false, "no refresh token" + } + if a.IsGlobal() { + return false, "global account has no CN task system" + } + if !a.SupportsBehaviorEvents() { + return false, "account has no growth task system" + } + s.travelOne(a) + return true, "" +} + // travelOne 单账号单趟状态机:查有无猫 + 查状态 + 最多一个动作,不轮询不等待。 func (s *Scheduler) travelOne(a *auth.Auth) { buddy, err := s.cfg.Upstream.BuddyInfo(a) diff --git a/internal/server/cache_stats_test.go b/internal/server/cache_stats_test.go index 00479bd1..2f149b5e 100644 --- a/internal/server/cache_stats_test.go +++ b/internal/server/cache_stats_test.go @@ -42,6 +42,81 @@ func TestStatsReaderCacheTokens(t *testing.T) { } } +// issue #170:上游在接近全命中时可能回 hit=0 + miss=3(+ prompt 很大)——按 +// prompt - miss 对称反推 hit,不再误判为整段未命中;hit 字段完全缺失但 miss +// 存在时同理。prompt <= miss 时保持 hit=0(真实的整段未命中,不反推)。 +func TestStatsReaderCacheTokensDeriveHit(t *testing.T) { + // hit=0 + miss=3 + prompt=52703 → hit 反推为 52700。 + s := newChatStatsReaderSince(strings.NewReader(""), time.Now()) + feedStatsLines(s, + `data: {"usage":{"prompt_tokens":52703,"prompt_cache_hit_tokens":0,"prompt_cache_miss_tokens":3}}`, + ) + if hit, miss, ok := s.CacheTokens(); !ok || hit != 52700 || miss != 3 { + t.Fatalf("derived hit: hit=%d miss=%d ok=%v, want 52700/3/true", hit, miss, ok) + } + + // hit 字段完全缺失、只有 miss → 同样反推。 + s2 := newChatStatsReaderSince(strings.NewReader(""), time.Now()) + feedStatsLines(s2, + `data: {"usage":{"prompt_tokens":8500,"prompt_cache_miss_tokens":100}}`, + ) + if hit, miss, ok := s2.CacheTokens(); !ok || hit != 8400 || miss != 100 { + t.Fatalf("miss-only derive: hit=%d miss=%d ok=%v, want 8400/100/true", hit, miss, ok) + } + + // prompt == miss → 真实整段未命中,hit 保持 0。 + s3 := newChatStatsReaderSince(strings.NewReader(""), time.Now()) + feedStatsLines(s3, + `data: {"usage":{"prompt_tokens":8500,"prompt_cache_hit_tokens":0,"prompt_cache_miss_tokens":8500}}`, + ) + if hit, miss, ok := s3.CacheTokens(); !ok || hit != 0 || miss != 8500 { + t.Fatalf("full miss keep hit=0: hit=%d miss=%d ok=%v, want 0/8500/true", hit, miss, ok) + } +} + +// issue #170 非流式侧:cacheTokensFromUsage 与流式推导口径对称。 +func TestCacheTokensFromUsage(t *testing.T) { + // hit 显式为 0、miss=3、prompt 很大 → 反推 hit=prompt-miss,不误判。 + usage := map[string]any{"prompt_tokens": 52703.0, "prompt_cache_hit_tokens": 0.0, "prompt_cache_miss_tokens": 3.0} + if hit, miss, ok := cacheTokensFromUsage(usage); !ok || hit != 52700 || miss != 3 { + t.Fatalf("hit=%d miss=%d ok=%v, want 52700/3/true", hit, miss, ok) + } + // 正常命中:hit 为正,原样返回。 + usage2 := map[string]any{"prompt_tokens": 1000.0, "prompt_cache_hit_tokens": 900.0, "prompt_cache_miss_tokens": 100.0} + if hit, miss, ok := cacheTokensFromUsage(usage2); !ok || hit != 900 || miss != 100 { + t.Fatalf("hit=%d miss=%d ok=%v, want 900/100/true", hit, miss, ok) + } + // 真实整段未命中(miss == prompt)→ hit 保持 0。 + usage3 := map[string]any{"prompt_tokens": 8500.0, "prompt_cache_miss_tokens": 8500.0} + if hit, miss, ok := cacheTokensFromUsage(usage3); !ok || hit != 0 || miss != 8500 { + t.Fatalf("hit=%d miss=%d ok=%v, want 0/8500/true", hit, miss, ok) + } + // 无任何缓存维度 → ok=false。 + usage4 := map[string]any{"prompt_tokens": 100.0} + if _, _, ok := cacheTokensFromUsage(usage4); ok { + t.Fatal("无缓存字段应 ok=false") + } +} + +// 反推后 hit>0 的观测应只标记 everHit、不触发 WARN(钉住假阳性修复的下游效果)。 +func TestCacheMissSignalDerivedHitNoWarn(t *testing.T) { + old := cacheMissWarnEvery + cacheMissWarnEvery = time.Hour + t.Cleanup(func() { + cacheMissWarnEvery = old + cacheMissWarn = &cacheMissSignal{everHit: map[string]bool{}, last: map[string]time.Time{}} + }) + cacheMissWarn = &cacheMissSignal{everHit: map[string]bool{}, last: map[string]time.Time{}} + + // 先标记模型可命中,再送入「hit=0 + miss=3 + prompt 大」经反推后的观测 + // (hit=52697>0):不应触发 WARN。 + cacheMissWarn.noteCacheTokens("gpt-6-luna", 52703, 52000, 703) + cacheMissWarn.noteCacheTokens("gpt-6-luna", 52703, 52700, 3) + if !cacheMissWarn.last["gpt-6-luna"].IsZero() { + t.Fatal("反推后 hit>0 的观测不应触发 WARN(issue #170 假阳性)") + } +} + // WARN 信号状态机:先命中、再大前缀整段未命中 → 触发一次;冷却内重复未命中 // 不再触发;从未命中的模型(首请求天然全 miss)与小前缀不触发。 func TestCacheMissSignal(t *testing.T) { diff --git a/internal/server/channel_refused_rotation_test.go b/internal/server/channel_refused_rotation_test.go new file mode 100644 index 00000000..a74567f0 --- /dev/null +++ b/internal/server/channel_refused_rotation_test.go @@ -0,0 +1,148 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" +) + +// 11-128 / 11128「通道校验失败」的轮转语义(issue #197 / #188 / #231)。 +// +// 上游的风控是**按账号**判定的:issue #197 的实测(用与网关完全一致的出站头部 +// 直连上游)显示,同一时刻被记成「禁用」的号稳定 400 11-128,而**另一个健康号 +// 稳定 200**。所以这类错误必须**继续轮转**——轮转可能换到能过的号。 +// +// 早期实现在这里 fail-fast(立即回 400),等于把「池里明明还有健康号」的请求 +// 直接丢掉:实测 3 个健康号、只有第 1 个撞 11-128 时,fail-fast 版 status=400 +// 且只打了 1 次上游;轮转版 status=200 且打了 2 次。 +// +// 同时它**不罚号**(applyErrorPolicy 对 ErrChannelRefused 零动作):账号本身 +// 健康,冷却/禁用都是误伤——被禁用的号在官方客户端里能正常聊天。 + +// channelRefusedBody 上游 11-128 通道校验失败的真实 body(issue #197 抓包)。 +const channelRefusedBody = `{"code":11128,"msg":"request illegal","requestId":"XXXX",` + + `"displayMsg":{"en":"Request blocked. Please send it again","zh":"请求被拦截,请重新发送"},` + + `"displayTips":{"en":"This call failed channel verification, possibly because it was not sent from an official client. Please resend from the official client."}}` + +// TestChannelRefusedRotatesToHealthyAccount 池里第 1 个号撞 11-128、第 2 个号 +// 正常 → 必须轮转到第 2 个号并成功返回;**不得**在第 1 次失败时就回 400。 +func TestChannelRefusedRotatesToHealthyAccount(t *testing.T) { + var calls int + up := newFakeUpstream(t, func(authz string) (int, string, bool) { + calls++ + if calls == 1 { + return http.StatusBadRequest, channelRefusedBody, false + } + return http.StatusOK, sseOK, true + }) + p := testPoolWith( + &auth.Auth{UID: "u1", Domain: "www.workbuddy.ai", AccessToken: "at-1", ExpiresAt: 9999999999}, + &auth.Auth{UID: "u2", Domain: "www.workbuddy.ai", AccessToken: "at-2", ExpiresAt: 9999999999}, + ) + h := NewHandler(Config{Pool: p, Upstream: up}) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("POST", "/v1/chat/completions", + strings.NewReader(`{"model":"global:glm-5.2","messages":[{"role":"user","content":"hi"}]}`))) + + if rec.Code != http.StatusOK { + t.Fatalf("code=%d want 200(应轮转到健康号)body=%s", rec.Code, rec.Body) + } + if calls < 2 { + t.Fatalf("calls=%d want >=2(必须轮转,不能在首次 11-128 就放弃)", calls) + } + // 11-128 不罚号:两个号都不得被冷却/禁用。 + for _, uid := range []string{"u1", "u2"} { + st, ok := p.Status(uid) + if !ok { + t.Fatalf("账号 %s 状态缺失", uid) + } + if st.Disabled { + t.Errorf("账号 %s 被禁用(11-128 是请求级问题,账号健康): %+v", uid, st) + } + if st.Cooling { + t.Errorf("账号 %s 被冷却(11-128 不得罚号): %+v", uid, st) + } + } +} + +// TestChannelRefusedExhaustedRotationReturns400 所有号都撞 11-128 → 轮转耗尽后 +// 回 **400 + channel_refused**(不是 503 no_healthy_account——后者把确定失败的 +// 请求伪装成「服务端过载」,客户端会无限重试),且全程不罚号。 +func TestChannelRefusedExhaustedRotationReturns400(t *testing.T) { + var calls int + up := newFakeUpstream(t, func(authz string) (int, string, bool) { + calls++ + return http.StatusBadRequest, channelRefusedBody, false + }) + p := testPoolWith( + &auth.Auth{UID: "u1", Domain: "www.workbuddy.ai", AccessToken: "at-1", ExpiresAt: 9999999999}, + &auth.Auth{UID: "u2", Domain: "www.workbuddy.ai", AccessToken: "at-2", ExpiresAt: 9999999999}, + ) + h := NewHandler(Config{Pool: p, Upstream: up}) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("POST", "/v1/chat/completions", + strings.NewReader(`{"model":"global:glm-5.2","messages":[{"role":"user","content":"hi"}]}`))) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("code=%d want 400 body=%s", rec.Code, rec.Body) + } + var resp struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("bad response JSON: %v body=%s", err, rec.Body) + } + if resp.Error.Code != "channel_refused" { + t.Errorf("error.code=%q want channel_refused", resp.Error.Code) + } + if calls < 2 { + t.Errorf("calls=%d want >=2(应尝试所有可用号)", calls) + } + for _, uid := range []string{"u1", "u2"} { + st, _ := p.Status(uid) + if st.Disabled || st.Cooling { + t.Errorf("账号 %s 被惩罚(11-128 不得罚号): %+v", uid, st) + } + } +} + +// TestUnapprovedChannelVariantIsNotContentBlocked 11-128 + "unapproved channel" +// 是**渠道校验**变体(2026-10-20 真机实录:首条 developer → 400 11-128 +// Illegal API invocation from an unapproved channel),不得归 ErrContentBlocked: +// 归审核域会触发全局降级门(h.degrade.Trigger(),降级到次日 00:00 CST)—— +// 一次请求级形状问题被放大成整网关整天的降级。 +func TestUnapprovedChannelVariantIsNotContentBlocked(t *testing.T) { + body := `{"code":11128,"msg":"Illegal API invocation from an unapproved channel"}` + up := newFakeUpstream(t, func(authz string) (int, string, bool) { + return http.StatusBadRequest, body, false + }) + p := testPoolWith( + &auth.Auth{UID: "u1", Domain: "www.workbuddy.ai", AccessToken: "at-1", ExpiresAt: 9999999999}, + ) + h := NewHandler(Config{Pool: p, Upstream: up}) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("POST", "/v1/chat/completions", + strings.NewReader(`{"model":"global:glm-5.2","messages":[{"role":"user","content":"hi"}]}`))) + + var resp struct { + Error struct { + Code string `json:"code"` + } `json:"error"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("bad response JSON: %v body=%s", err, rec.Body) + } + if resp.Error.Code == "content_blocked" { + t.Errorf("11-128 渠道校验变体不得归 content_blocked(会触发全局降级门): %s", rec.Body) + } + if h.degrade.Active() { + t.Error("11-128 渠道校验不得触发全局降级门") + } +} diff --git a/internal/server/conversation_headers_test.go b/internal/server/conversation_headers_test.go new file mode 100644 index 00000000..2d6041fe --- /dev/null +++ b/internal/server/conversation_headers_test.go @@ -0,0 +1,40 @@ +package server + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" + "github.com/linguo2625469/workbuddy2api-panel/internal/upstream" +) + +func TestChatWithoutConversationKeyPassesChannelValidation(t *testing.T) { + up := &upstream.Client{ + HTTP: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + status, body := http.StatusOK, sseOK + for _, name := range []string{"X-Conversation-ID", "X-Conversation-Request-ID", "X-Conversation-Message-ID"} { + if r.Header.Get(name) == "" { + status, body = http.StatusBadRequest, `{"code":11128,"msg":"conversation id missing"}` + } + } + return &http.Response{StatusCode: status, Header: http.Header{"Content-Type": {"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(body))}, nil + })}, + ChatBaseGlobal: "https://fake.example", GlobalEnabled: true, + } + a, err := auth.Parse([]byte(`{"uid":"global-test","accessToken":"test-token","realm":"global","expiresAt":9999999999}`)) + if err != nil { + t.Fatal(err) + } + h := NewHandler(Config{Pool: testPoolWith(a), Upstream: up, GlobalEnabled: true}) + // A user identifier is not a conversation key; this also disables the + // session-key derivation used for otherwise unlabelled text conversations. + body := `{"model":"global:deepseek-v4.1-flash","stream":true,"metadata":{"user_id":"same-user"},"messages":[{"role":"user","content":"hi"}]}` + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body))) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "[DONE]") { + t.Fatalf("unlabelled conversation rejected: status=%d body=%s", rec.Code, rec.Body) + } +} diff --git a/internal/server/cross_domain_fallback_test.go b/internal/server/cross_domain_fallback_test.go new file mode 100644 index 00000000..ecbdd18e --- /dev/null +++ b/internal/server/cross_domain_fallback_test.go @@ -0,0 +1,130 @@ +// cross_domain_fallback_test.go 裸模型名跨域回退(issue #135)端到端测试: +// 开关默认关闭 = 零回归;开启后「首选域选不到号」与「首选域 11102」两条路径都要 +// 回退到另一域;显式 cn:/global: 前缀永不回退。 +package server + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" + "github.com/linguo2625469/workbuddy2api-panel/internal/pool" +) + +// newRealmPool 构造 cn + global 各一个账号的池子。 +// global 号按 domain 后缀归一化(www.workbuddy.ai → global);cn 号积分更高, +// 配合 testPoolWith 的确定性随机源,断言「先试 cn、失败后才回退 global」。 +func newRealmPool(t *testing.T) *pool.Pool { + t.Helper() + p := testPoolWith( + &auth.Auth{UID: "cn1", AccessToken: "at-cn", ExpiresAt: 9999999999}, + &auth.Auth{UID: "gl1", AccessToken: "at-gl", Domain: "www.workbuddy.ai", ExpiresAt: 9999999999}, + ) + p.SetCredits("cn1", 2000, 0) + p.SetCredits("gl1", 1000, 0) + return p +} + +// 11102「该后端无此模型」是 issue #135 的核心场景:模型只存在于 global 目录, +// 裸名请求先被送到 cn 号 → 11102;开启回退后应改用 global 号拿到 200。 +func TestBareModelCrossDomainFallbackOnModelBlocked(t *testing.T) { + calls := map[string]int{} + up := newFakeUpstream(t, func(authz string) (int, string, bool) { + calls[authz]++ + if authz == "Bearer at-cn" { + return 400, `{"code":11102,"msg":"model [deepseek-v4.1-flash-sg] service info not found"}`, false + } + return 200, sseOK, true + }) + p := newRealmPool(t) + p.SetCrossDomainFallback(true) + h := NewHandler(Config{Pool: p, Upstream: up}) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("POST", "/v1/chat/completions", + strings.NewReader(`{"model":"deepseek-v4.1-flash-sg","messages":[{"role":"user","content":"hi"}]}`))) + if rec.Code != http.StatusOK { + t.Fatalf("code=%d body=%s (want 200: 裸名在 cn 11102 后必须回退 global)", rec.Code, rec.Body) + } + if calls["Bearer at-cn"] != 1 || calls["Bearer at-gl"] != 1 { + t.Errorf("calls=%v want cn 1 次(11102)→ global 1 次(回退成功)", calls) + } +} + +// 开关缺省关闭:行为与引入前完全一致——11102 后不跨域,回 400 model_unavailable。 +func TestBareModelCrossDomainFallbackDisabledByDefault(t *testing.T) { + calls := map[string]int{} + up := newFakeUpstream(t, func(authz string) (int, string, bool) { + calls[authz]++ + if authz == "Bearer at-cn" { + return 400, `{"code":11102,"msg":"model [deepseek-v4.1-flash-sg] service info not found"}`, false + } + return 200, sseOK, true + }) + p := newRealmPool(t) + if p.CrossDomainFallback() { + t.Fatal("cross_domain_fallback 缺省必须为 false(零回归)") + } + h := NewHandler(Config{Pool: p, Upstream: up}) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("POST", "/v1/chat/completions", + strings.NewReader(`{"model":"deepseek-v4.1-flash-sg","messages":[{"role":"user","content":"hi"}]}`))) + if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "model_unavailable") { + t.Fatalf("code=%d body=%s (want 400 model_unavailable)", rec.Code, rec.Body) + } + if calls["Bearer at-gl"] != 0 { + t.Errorf("calls=%v want global 0 次(开关关闭不得跨域)", calls) + } +} + +// 显式 realm 前缀是确定性语义:开关开启也不回退(写 cn: 就只用 cn 号)。 +func TestExplicitRealmNeverFallsBack(t *testing.T) { + calls := map[string]int{} + up := newFakeUpstream(t, func(authz string) (int, string, bool) { + calls[authz]++ + if authz == "Bearer at-cn" { + return 400, `{"code":11102,"msg":"model [deepseek-v4.1-flash-sg] service info not found"}`, false + } + return 200, sseOK, true + }) + p := newRealmPool(t) + p.SetCrossDomainFallback(true) + h := NewHandler(Config{Pool: p, Upstream: up}) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("POST", "/v1/chat/completions", + strings.NewReader(`{"model":"cn:deepseek-v4.1-flash-sg","messages":[{"role":"user","content":"hi"}]}`))) + if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "model_unavailable") { + t.Fatalf("code=%d body=%s (want 400: 显式 cn: 前缀不得回退)", rec.Code, rec.Body) + } + if calls["Bearer at-gl"] != 0 { + t.Errorf("calls=%v want global 0 次(显式前缀零回退)", calls) + } +} + +// 第二条触发路径:首选域整域不可用(账号级冷却导致选号返回 nil),同样要回退。 +func TestBareModelCrossDomainFallbackOnNoHealthyAccount(t *testing.T) { + calls := map[string]int{} + up := newFakeUpstream(t, func(authz string) (int, string, bool) { + calls[authz]++ + return 200, sseOK, true + }) + p := newRealmPool(t) + p.SetCrossDomainFallback(true) + p.Cooldown("cn1", pool.CoolSoft, time.Hour, "test: account-level cooldown") + h := NewHandler(Config{Pool: p, Upstream: up}) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("POST", "/v1/chat/completions", + strings.NewReader(`{"model":"deepseek-v4.1-flash","messages":[{"role":"user","content":"hi"}]}`))) + if rec.Code != http.StatusOK { + t.Fatalf("code=%d body=%s (want 200: cn 整域冷却时裸名应回退 global)", rec.Code, rec.Body) + } + if calls["Bearer at-cn"] != 0 || calls["Bearer at-gl"] != 1 { + t.Errorf("calls=%v want cn 0 次、global 1 次", calls) + } +} diff --git a/internal/server/handler.go b/internal/server/handler.go index 4d3d7f07..14155134 100644 --- a/internal/server/handler.go +++ b/internal/server/handler.go @@ -62,6 +62,10 @@ type Config struct { // (modelList 不列 global 名单)。 GlobalEnabled bool + // ModelsHidden 模型黑名单原始条目(config models.hidden;Live 为 nil 的 + // 裸用/测试路径经 loadLive() 归一化后使用)。 + ModelsHidden []string + // Usage 逐请求用量记录器(可选;nil = 不记录)。 // 在 recordAttempt 这一唯一汇聚点调用,因此流式/非流式、成功/失败都会计入, // 且与 pool 的每账号累计器同源,两条口径不会漂移。 @@ -85,6 +89,7 @@ func (h *Handler) loadLive() livecfg.Snapshot { APIKey: h.cfg.APIKey, SoftCooldown: h.cfg.SoftCooldown, RecordClientInfo: h.cfg.RecordClientInfo, + HiddenModels: livecfg.NormalizeHiddenModels(h.cfg.ModelsHidden), } } @@ -359,7 +364,12 @@ func applyModelInfoFields(entry map[string]any, mi upstream.ModelInfo) map[strin // 纯动态:动态拉取失败/无号 → 该域空列表,无静态兜底。 func (h *Handler) modelList() []map[string]any { out := make([]map[string]any, 0) + // models.hidden 黑名单:快照外提,避免逐条目重复 Load。 + hidden := h.loadLive().HiddenModels for _, mi := range h.fetchDynamicModels() { + if _, ok := hidden["cn:"+mi.ID]; ok { + continue + } entry := map[string]any{ "id": "cn:" + mi.ID, "object": "model", @@ -403,6 +413,9 @@ func (h *Handler) modelList() []map[string]any { } globalEfforts, globalDefaults := h.cfg.Upstream.GlobalEffortSnapshot() for _, id := range globalIDs { + if _, ok := hidden["global:"+id]; ok { + continue + } entry := map[string]any{ "id": "global:" + id, "object": "model", @@ -481,6 +494,13 @@ func (h *Handler) fetchDynamicModels() []upstream.ModelInfo { // 拉取失败只进负缓存(5min lastFail),不 NoteError:NoteError 喂的是 chat // 熔断器,models 端点偶发 5xx 跨界惩罚 chat 通道健康的账号; // models 拉取失败 ≠ 账号 chat 不可用。 + // 只在真正打上游失败时记一行(负缓存期内前面已直接 return,不会刷屏): + // 此前这里静默,CN 域不可达时 /v1/models 只剩 global 模型却毫无线索。 + if err == nil { + err = fmt.Errorf("models api returned empty list") + } + log.Printf("WARN: [server] /v1/models: CN 模型目录拉取失败(%s 内不再重试,cn: 模型暂不列出): %v", + modelsFetchFailCooldown, err) dynamicModelsCache.Lock() dynamicModelsCache.lastFail = time.Now() dynamicModelsCache.Unlock() @@ -528,11 +548,16 @@ func (h *Handler) chatCompletions(w http.ResponseWriter, r *http.Request) { // realm 前缀解析(D6):model 名可能带 "[realm:]" 前缀。剥出 realm + bareModel, // bareModel 用于选号/粘性/出站 body 重写(前缀是网关侧路由协议,上游只认裸名)。 // 裸名 → ("cn", 原串),CN 现状零回归。 - realm, bareModel := resolveModel(peek.Model) + realm, bareModel, realmExplicit := resolveModelDetail(peek.Model) modelRate := "" if h.cfg.Upstream != nil { modelRate = h.cfg.Upstream.ModelRate(realm, bareModel) } + // 跨域回退(issue #135):只有裸模型名 + 显式开启 pool.cross_domain_fallback 时生效。 + // 语义 = 「裸名不绑定单一域」:首选域(裸名默认 cn)选不到可用号(账号级冷却/熔断/ + // 在途占满,或该域账号对这个模型 11102 后进入模型级负缓存)时,改用另一域再选一次; + // 显式 cn:/global: 前缀保持原语义,永不回退。 + crossRealm := !realmExplicit && h.cfg.Pool.CrossDomainFallback() // 请求级统计:出口即打一行表格日志(任何路径都会走到)。 st := newChatStat(time.Now(), body, peek.Stream) @@ -544,6 +569,16 @@ func (h *Handler) chatCompletions(w http.ResponseWriter, r *http.Request) { } defer st.done() + // models.hidden 黑名单:点名调用直接拒绝,不打上游、不占选号。 + // 与 /v1/models 的列表过滤对称(只藏列表不够,知道名字仍可硬调)。 + if _, hide := h.loadLive().HiddenModels[realm+":"+bareModel]; hide { + writeOpenAIErrorHint(w, http.StatusBadRequest, "model_unavailable", + "model is hidden by gateway configuration, try another model", "") + st.status = http.StatusBadRequest + st.outcome = reqlog.OutcomeHTTPError + return + } + tried := map[string]bool{} var lastErr error // modelBlock 记录本次是否因「模型级冷却」而选不到号(下方 acct==nil 分支填充)。 @@ -558,7 +593,11 @@ func (h *Handler) chatCompletions(w http.ResponseWriter, r *http.Request) { if h.cfg.Session != nil && sessKey != "" { // 按模型解析:绑定号在**当前模型**被 6004 限额时视为不可用 → 重新分配, // 而不是钉在限额号上反复失败("限额后换不动号"的正解)。 - if uid, ok := h.cfg.Session.ResolveForModel(sessKey, peek.Model); ok { + resolve := h.cfg.Session.ResolveForModel + if h.cfg.Pool.StrategyForRealm(realm) != pool.WeightedRandom { + resolve = h.cfg.Session.LookupForModel + } + if uid, ok := resolve(sessKey, peek.Model); ok { stickyUID = uid } } @@ -688,6 +727,12 @@ func (h *Handler) chatCompletions(w http.ResponseWriter, r *http.Request) { body = prompt.Rewrite(body, prompt.Degraded) degradedApplied = true } + // 渠道校验兜底:上游要求 messages[0] 必须是 system(首条 user 直接 400 + // 11-128「first message is not system prompt」,首条 developer 报 + // unapproved channel)——2026-10-20 真机实测三条对照。custom/append 天然 + // 以 system 开头,passthrough 透传客户端消息时可能首条就是 user,故这里统一 + // 兜一次。只补首条缺失这一种情况,其余消息逐字不动。 + body = prompt.EnsureSystemFirst(body, h.cfg.PromptText) // outbound model 名重写为 bareModel(D6):realm 前缀是网关侧路由协议, // 上游不认前缀(global 账号也请求裸模型名)。裸名时 bareModel==peek.Model 恒等。 @@ -728,7 +773,9 @@ func (h *Handler) chatCompletions(w http.ResponseWriter, r *http.Request) { var acct *auth.Auth if stickyUID != "" { acct = h.cfg.Pool.PickByUIDForModel(stickyUID, bareModel) - if acct == nil || (realm != "" && acct.Realm() != realm) { + // 跨域回退开启且请求为裸名时,粘性号可以落在任一域(回退成功后绑定的是另一域 + // 的账号,下一轮不该被 realm 校验立刻解绑)。 + if acct == nil || (realm != "" && acct.Realm() != realm && !crossRealm) { // 粘性号在当前模型不可用(冷却/占满/该模型被 6004 限额)或 realm 不符 → 解绑, // 本次回落普通轮换。 unbindSticky() @@ -738,21 +785,39 @@ func (h *Handler) chatCompletions(w http.ResponseWriter, r *http.Request) { if acct == nil { // 模型感知 + realm 感知选号:模型非空时启用 6004 模型级冷却豁免 // (healthyForModel),realm 谓词过滤跨域账号。 - acct = h.cfg.Pool.PickExcludingForRealm(tried, bareModel, realm) + pickRealm := realm + if crossRealm && h.cfg.Pool.ServableForModelRealm(bareModel, realm) == 0 && + h.cfg.Pool.ServableForModelRealm(bareModel, otherRealm(realm)) > 0 { + // 首选域此刻没有真正可服务的号(11102 模型级负缓存 / 账号级冷却 / + // 在途占满),而另一域有 → 直接换域选号(issue #135)。先看健康度再选, + // 避免拿全冷却兜底捞回来的旧域账号白撞一次上游。 + pickRealm = otherRealm(realm) + } + acct = h.cfg.Pool.PickExcludingForRealm(tried, bareModel, pickRealm) + if acct == nil && crossRealm { + // 该域选不到(并发抢名额/轮换耗尽/另一域刚冷却完)→ 另一域再试一次, + // 保持尽力而为语义;tried 已记录失败号,轮转仍由 MaxRotate 封顶。 + acct = h.cfg.Pool.PickExcludingForRealm(tried, bareModel, otherRealm(pickRealm)) + } } if acct == nil { st.status = http.StatusServiceUnavailable // 记下"是不是模型级阻塞"。选号返回 nil 有两种完全不同的成因: // (a) 池子真的没有可用号(账号级冷却/在途占满/积分保底); - // (b) 号都在,但每个号都对这个模型处于模型级冷却(11102/6004)。 + // (b) 号都在,但每个号都对这个模型处于模型级冷却(11102/11158/6004)。 // 两者此前都报 no_healthy_account,导致「模型不可用」被读成「号全挂了」。 // 在这里取一次快照,供下方错误构造区分(issue #102 附带发现 1)。 - modelBlock = h.cfg.Pool.ModelBlocked(bareModel) + modelBlock = h.cfg.Pool.ModelBlockedForRealm(bareModel, realm) break } st.uid = acct.UID // 同步昵称:请求流水行只写 uid8 时无法直观看是哪一号,昵称随本次选号带入日志行。 st.nick = acct.Nickname + if crossRealm && h.cfg.Upstream != nil && acct.Realm() != realm { + // 跨域回退命中:按实际服务域取积分倍率,用量/账本记的是真实域口径, + // 而不是请求前缀声明的域。 + modelRate = h.cfg.Upstream.ModelRate(acct.Realm(), bareModel) + } tried[acct.UID] = true // 占用在途名额:Pick 已跳过满额账号,此处 CAS 兜底并发抢名额的竞态。 @@ -878,6 +943,32 @@ func (h *Handler) chatCompletions(w http.ResponseWriter, r *http.Request) { st.outcome = reqlog.OutcomeHTTPError return } + // 11-128 / 11128「通道校验失败」:上游判定请求可能不是官方客户端发出 + //(displayMsg「请求被拦截,请重新发送」、displayTips「没通过渠道校验, + // 可能不是官方客户端发出的」)。这是**请求形状/出口通道**的问题,不是账号 + // 的问题——账号本身健康(官方客户端可正常使用同一账号),故 applyErrorPolicy + // 对 ErrChannelRefused **零动作**(不冷却/不熔断/不 NoteFailures)。 + // + // **必须继续轮转**:上游的 11-128/11128 判定并非只看出口 IP——issue #197 + // 的实测(用与网关完全一致的出站头部直连上游)显示:同一时刻,被记成 + // 「禁用」的号稳定 400 11-128,而**另一个健康号稳定 200**。上游风控是 + // **按账号**判定的(账号自身的历史风险画像),换号确实可能换到能过的号。 + // 另有出站头部本身按账号不同(企业号带 X-Userinfo,个人号不带), + // 故「所有号出站头一致」的前提也不成立。 + // + // 因此这里**不 fail-fast**:只换号、绝不罚号,让轮转去找那个能过的号。 + // 轮转耗尽后由末端映射给 400 + channel_refused(不是 503 + // no_healthy_account——后者把确定失败的请求伪装成「服务端过载」,客户端 + // 会无限重试)。 + if kind == upstream.ErrChannelRefused { + h.applyErrorPolicy(acct.UID, kind, string(respBody), bareModel, uerr) + fail(acct.UID) + lastErr = &upstream.Error{Kind: kind, Status: status, Msg: string(respBody), RetryAfter: uerr.RetryAfter} + if !rotateBackoff(i, r.Context()) { + break + } + continue + } // 11115「prompt is too long」:立即透传上游原文回客户端,**不罚号不轮转** // ——上下文超限是请求的问题(同一 body 换任何号都超限,白扔健康号配额; // 与 WAF IP fail-fast 同哲学:确定与账号无关的错误直接终止轮转)。 @@ -926,6 +1017,24 @@ func (h *Handler) chatCompletions(w http.ResponseWriter, r *http.Request) { st.outcome = reqlog.OutcomeHTTPError return } + // 6021「queue.full.title」渠道级容量满载:**不罚号不轮转**,立即透传 + // 上游原文回客户端(503 + upstream_busy)。上游集群排队打满时换任何 + // 账号都撞同一堵墙——按限流处置会逐号软冷却,实测 2 秒绞杀整池健康号 + // (2026-10-15 实案),且换号白撞把无效请求放大 MaxRotate 倍。客户端 + // 按 SDK 退避重试即可;等待窗口过去渠道恢复即成功。 + if kind == upstream.ErrUpstreamBusy { + h.applyErrorPolicy(acct.UID, kind, string(respBody), bareModel, uerr) + fail(acct.UID) + msg := string(respBody) + if strings.TrimSpace(msg) == "" { + msg = "upstream capacity is full (queue.full); retry after a short wait" + } + writeOpenAIErrorHint(w, http.StatusServiceUnavailable, "upstream_busy", msg, + h.hintOf(upstream.ErrUpstreamBusy, string(respBody), bareModel, reqHasImage, uerr)) + st.status = http.StatusServiceUnavailable + st.outcome = reqlog.OutcomeHTTPError + return + } // lastErr 携带完整 body(uerr.Msg 在 upstream 侧截断 200 字符,透传语义 // 要求原文全量)+ Kind/RetryAfter(末端映射与冷却时长共用)。 lastErr = &upstream.Error{Kind: kind, Status: status, Msg: string(respBody), RetryAfter: uerr.RetryAfter} @@ -1027,6 +1136,8 @@ func (h *Handler) chatCompletions(w http.ResponseWriter, r *http.Request) { if total, tok := stats.TotalTokens(); tok && total > 0 { h.cfg.Pool.NoteModelCost(acct.UID, bareModel, credit, total) } + // 积分燃尽熔断计账(issue #229):窗口内烧掉的积分超阈值 → 该号熔断。 + h.cfg.Pool.NoteBurn(acct.UID, credit) } rc.Close() return @@ -1043,9 +1154,8 @@ func (h *Handler) chatCompletions(w http.ResponseWriter, r *http.Request) { } credit, total, hasCredit := usageCreditTotal(resp) if usage, ok := resp["usage"].(map[string]any); ok { - if hit, okH := upstream.UsageCacheHitTokens(usage); okH { - miss, _ := upstream.UsageCacheMissTokens(usage) - st.cacheHit, st.cacheMiss, st.hasCache = int64(hit), int64(miss), true + if hit, miss, okC := cacheTokensFromUsage(usage); okC { + st.cacheHit, st.cacheMiss, st.hasCache = hit, miss, true } } recordAttempt(acct.UID, usageDeltaFromResponse(resp), credit, hasCredit, attemptStarted, 0) @@ -1067,6 +1177,8 @@ func (h *Handler) chatCompletions(w http.ResponseWriter, r *http.Request) { st.credit = credit st.hasCredit = true h.cfg.Pool.NoteModelCost(acct.UID, bareModel, credit, total) + // 积分燃尽熔断计账(issue #229)。 + h.cfg.Pool.NoteBurn(acct.UID, credit) } return } @@ -1107,6 +1219,22 @@ func (h *Handler) chatCompletions(w http.ResponseWriter, r *http.Request) { code = "waf_ip_blocked" msg = "waf ip-level block: upstream firewall is blocking the gateway IP, rotation stopped; retry after the block window expires" } + case upstream.ErrUpstreamBusy: + // 6021 queue.full 渠道级容量满载(防御路径:正常已在轮转循环内 + // fail-fast 拦截)。503 + 可重试语义——上游原文(下方统一透传) + // 含 requestId,客户端按 SDK 退避重试即可。 + code = "upstream_busy" + msg = "upstream capacity is full (queue.full); retry after a short wait" + case upstream.ErrChannelRefused: + // 11-128/11128 通道校验失败:**正常的末端路径**(轮转耗尽后才到这 + // 里;轮转循环内不 fail-fast,因为上游风控是按账号判定的,换号可能 + // 换到能过的号——issue #197 的实测:同一时刻被记成「禁用」的号稳定 + // 400 11-128,而另一个健康号稳定 200)。 + // 400 + 不可重试语义:轮转已把所有可用号试过一遍,同一客户端指纹 + // 再发一次还是同样失败,让客户端去查指纹/代理,而不是无脑重试。 + code = "channel_refused" + status = http.StatusBadRequest + msg = "upstream rejected the request channel (it may not look like the official client); check user-agent/headers or an intercepting proxy" case upstream.ErrModelBlocked: // 11102「该后端无此模型」:上游原文(下方统一透传)已经写清了原因,但 // code 此前停在默认的 no_healthy_account —— 那是"服务端过载"的语义, @@ -1202,16 +1330,22 @@ func promptTooLongMessage(body string) string { } // usageCreditTotal 从聚合响应取 usage.credit 与 total_tokens(成本台账非流式入口)。 -// 任一字段缺失/非法 → ok=false(不记录)。 +// +// 两个字段各自独立判定(issue #179 / #181):此前要求 total_tokens>0 才返回 ok, +// 于是「上游给了 credit 但没给 total_tokens」的非流式响应整条**不记积分**—— +// 「积分扣除历史」因此系统性少记(用户实测:消耗 2.4K 只记到 500~600)。 +// 现在:credit 存在即返回 hasCredit=true,total 缺失只影响 token 口径 +// (记 0 并不伪造 token 观测),两者互不牵连。 func usageCreditTotal(resp map[string]any) (credit float64, total int, ok bool) { usage, _ := resp["usage"].(map[string]any) if usage == nil { return 0, 0, false } - c, _ := usage["credit"].(float64) + c, hasCredit := usage["credit"].(float64) t, _ := usage["total_tokens"].(float64) if t <= 0 { - return 0, 0, false + // total_tokens 缺失:token 口径记 0,但**保留 credit 观测**。 + return c, 0, hasCredit } return c, int(t), true } @@ -1277,7 +1411,7 @@ func isUpstreamTimeout(err error, clientGone bool) bool { // - ErrImageInvalid → 图片格式/数据无效:请求的问题不是账号的问题(同一 body // 换任何号都会得到相同的解析错误)。零动作(不冷却/不熔断/不 NoteError、 // 不喂连败),chatCompletions 已直接透传原文返回不轮转。 -// - ErrModelBlocked → BlockModelBackoff:(账号, 模型) 11102 负缓存避让。 +// - ErrModelBlocked → BlockModelBackoff:(账号, 模型) 11102/11158 负缓存避让。 // - ErrServer → NoteError:喂单一连续失败计数器 fails + 累计错误 errTotal, // 达到 breakerThreshold 触发熔断(指数退避)。 // - 其他(default:ErrClient/ErrNone)→ 只换号不罚(防雪崩),不喂熔断;ErrClient @@ -1339,16 +1473,25 @@ func (h *Handler) applyErrorPolicy(uid string, kind upstream.ErrKind, body, mode // 偶发路径缺失不是限流信号,不该按限流惩罚升级。 h.cfg.Pool.Cooldown(uid, pool.CoolSoft, notFoundCooldown, "upstream 404") case upstream.ErrAccountFault: - // 账号级授权/配额故障按 msg 分野(口径与 Classify 的 accountFaultMarkers 一致): - // - "request illegal"(code 11140)→ 账号级**授权封禁**:硬禁用(Disable)。 + // 账号级授权/配额故障按**业务码**分野(口径与 Classify 一致): + // - 11140 + "request illegal" → 账号被上游限制(异常行为/风控):硬禁用。 + // 解除只能由用户在 WorkBuddy 官方渠道申诉,重登无效。 // - 14017(trial not activated)→ register 未完成,补完 register 后可能自愈, // **保持软冷却**(禁用会让用户补完 register 后仍无法用)。 - // 大小写不敏感(与 Classify 的 marker 匹配同口径)。 - if strings.Contains(strings.ToLower(body), "request illegal") { - h.cfg.Pool.Disable(uid, "account banned by upstream (11140 request illegal), re-login required") + // 注意 11-128/11128 的 msg 同为 "request illegal",但那是通道校验失败,已在 + // Classify 分流到 ErrChannelRefused,走不到这里。 + if upstream.IsAccountRestricted(body) { + h.cfg.Pool.Disable(uid, "account restricted by upstream (11140 request illegal); appeal in WorkBuddy (submit feedback) to lift the restriction") return } h.cfg.Pool.Cooldown(uid, pool.CoolSoft, h.softCooldown(), "account fault (14017)") + case upstream.ErrChannelRefused: + // 11-128 / 11128 通道校验失败(上游判定请求可能不是官方客户端发出,displayMsg + // 就是「请求被拦截,请重新发送」)。账号本身健康——官方客户端可正常使用同一账号, + // 这不是账号级故障,**不罚账号**(无冷却/熔断/NoteFailures)。与 + // ErrContentBlocked 同待遇:交给轮转继续尝试(上游风控按账号判定,换号 + // 可能换到能过的号——issue #197 实测);轮转耗尽后由末端映射回 + // 400 + channel_refused,并把上游原文透传给客户端,提示排查客户端指纹。 case upstream.ErrServer: // 5xx 上游故障:Classify 已把 ≥500 判为 ErrServer,在此喂熔断计数(不再手写 status>=500)。 h.cfg.Pool.NoteError(uid) @@ -1368,11 +1511,17 @@ func (h *Handler) applyErrorPolicy(uid string, kind upstream.ErrKind, body, mode // 不罚账号(无冷却/熔断/NoteError,同 ErrContentBlocked 待遇);chatCompletions // 已 fail-fast 400 透传原文、终止轮转——「换号可能有不同模型权限」属 11102 // (ErrModelBlocked)的分类域,与本类无关。 + case upstream.ErrUpstreamBusy: + // 6021「queue.full.title」渠道级容量满载:账号余额健康、未限流、session 未死, + // 换号也撞同一堵墙。零动作(不冷却/不熔断/不 NoteError/不喂连败,同 + // ErrContentBlocked 待遇);chatCompletions 已 fail-fast 503 透传原文、 + // 终止轮转——该分支只为文档完备。 case upstream.ErrModelBlocked: - // 11102「该后端无此模型」:(账号, 模型) 负缓存避让。复用 modelCooldowns 机制 - // (与 6004 同域),选号侧 healthyForModel 对该账号自动避开该模型。 + // 11102「该后端无此模型」/11158「该账号类型无此模型权限」: + // (账号, 模型) 负缓存避让。复用 modelCooldowns 机制(与 6004 同域), + // 选号侧 healthyForModel 对该账号自动避开该模型。 // 立即换号(本轮 continue),该账号该模型冷却,下次选号避开。 - h.cfg.Pool.BlockModelBackoff(uid, model, upstream.ModelBlockReason) + h.cfg.Pool.BlockModelBackoff(uid, model, upstream.ModelBlockReasonFor(body)) default: // 其余(ErrClient/ErrNone):只换号不罚(防雪崩),不喂熔断。 // ErrClient(未知 4xx)喂连败计数(issue #114):连续 N 次该形态失败 → diff --git a/internal/server/handler_strategy_test.go b/internal/server/handler_strategy_test.go new file mode 100644 index 00000000..eb74cfd6 --- /dev/null +++ b/internal/server/handler_strategy_test.go @@ -0,0 +1,58 @@ +package server + +import ( + "fmt" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" + "github.com/linguo2625469/workbuddy2api-panel/internal/pool" + "github.com/linguo2625469/workbuddy2api-panel/internal/session" +) + +func TestChatRegionSchedulingAndStickyPriority(t *testing.T) { + auth.SetGlobalEnabled(true) + t.Cleanup(func() { auth.SetGlobalEnabled(true) }) + p := testPoolWith( + &auth.Auth{UID: "a", AccessToken: "a", ExpiresAt: 9999999999}, + &auth.Auth{UID: "b", AccessToken: "b", ExpiresAt: 9999999999}, + &auth.Auth{UID: "x", AccessToken: "x", ExpiresAt: 9999999999, Domain: "workbuddy.ai"}, + &auth.Auth{UID: "y", AccessToken: "y", ExpiresAt: 9999999999, Domain: "workbuddy.ai"}, + ) + p.SetScheduling(pool.RegionStrategies{Domestic: pool.RoundRobin, International: pool.SequentialExhaust}, pool.RegionAccountOrder{}) + p.SetCostExploreInterval(0) + // 固定同层,避免成功后成本首观测影响本测试的路由断言。 + for _, uid := range []string{"a", "b", "x", "y"} { + p.NoteModelCost(uid, "m", 0, 1000) + } + selected := "" + up := newFakeUpstream(t, func(authz string) (int, string, bool) { + selected = strings.TrimPrefix(authz, "Bearer ") + return 200, sseOK, true + }) + sess := session.New(session.Config{TTL: time.Minute, Available: p.AvailableUIDs}) + h := NewHandler(Config{Pool: p, Upstream: up, Session: sess}) + for _, tc := range []struct{ model, conv, want string }{ + {"cn:m", "cn-one", "a"}, + {"cn:m", "cn-two", "b"}, + {"global:m", "global-one", "x"}, + {"global:m", "global-two", "x"}, + {"cn:m", "cn-two", "b"}, // 已绑定 b,轮询下一号 a 不抢占。 + {"cn:m", "cn-three", "a"}, + } { + rec := httptest.NewRecorder() + body := fmt.Sprintf(`{"model":%q,"messages":[],"metadata":{"conversation_id":%q}}`, tc.model, tc.conv) + h.ServeHTTP(rec, httptest.NewRequest("POST", "/v1/chat/completions", strings.NewReader(body))) + if rec.Code != 200 || selected != tc.want { + t.Fatalf("%+v: status=%d selected=%s body=%s", tc, rec.Code, selected, rec.Body.String()) + } + } + p.CooldownSoftForModel("x", time.Hour, time.Now().Add(time.Hour), "m", "6004") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("POST", "/v1/chat/completions", strings.NewReader(`{"model":"global:m","messages":[],"metadata":{"conversation_id":"global-one"}}`))) + if rec.Code != 200 || selected != "y" { + t.Fatalf("cooled sticky: status=%d selected=%s", rec.Code, selected) + } +} diff --git a/internal/server/handler_test.go b/internal/server/handler_test.go index 149490d5..3fb31028 100644 --- a/internal/server/handler_test.go +++ b/internal/server/handler_test.go @@ -167,6 +167,49 @@ func TestChatBadParamsFailsFastWithoutPenalty(t *testing.T) { } } +// TestChatQueueFullFailsFastWithoutPenalty 上游 429 + code 6021「queue.full.title」 +// 是渠道级容量满载(推理集群排队打满),**不是账号问题**(2026-10-15 实案:按 +// 限流逐号软冷却,2 秒绞杀整池健康号)。端到端断言:不轮转(只打一次上游)、 +// 直接回 503 + upstream_busy(可重试语义)、账号零惩罚(无冷却/无熔断/无连败)。 +func TestChatQueueFullFailsFastWithoutPenalty(t *testing.T) { + calls := map[string]int{} + up := newFakeUpstream(t, func(authz string) (int, string, bool) { + calls[authz]++ + return 429, `{"code":6021,"msg":"queue.full.title","requestId":"b1bc87ea3a771e835e388328713d47cf"}`, false + }) + p := testPoolWith( + &auth.Auth{UID: "busy1", AccessToken: "at-busy1", ExpiresAt: 9999999999}, + &auth.Auth{UID: "busy2", AccessToken: "at-busy2", ExpiresAt: 9999999999}, + ) + p.SetCredits("busy1", 2000, 0) // 确定性源 r=0 → 先选 busy1 + p.SetCredits("busy2", 1000, 0) + h := NewHandler(Config{Pool: p, Upstream: up}) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("POST", "/v1/chat/completions", strings.NewReader(`{"model":"glm-5.2","messages":[]}`))) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("code=%d body=%s (want 503: channel-level busy, client should back off and retry)", rec.Code, rec.Body) + } + if calls["Bearer at-busy1"] != 1 || calls["Bearer at-busy2"] != 0 { + t.Errorf("calls=%v want busy1 1 次、busy2 0 次(零轮转)", calls) + } + // 账号完好:无冷却、无熔断计数、无连败降权。 + st, _ := p.Status("busy1") + if st.Cooling || st.Disabled || st.ErrTotal != 0 || st.BreakerFails != 0 { + t.Errorf("ErrUpstreamBusy must not penalize account: %+v", st) + } + // 响应体:上游原文透传(requestId 在场)+ upstream_busy code。 + body := rec.Body.String() + if !strings.Contains(body, "6021") || !strings.Contains(body, "queue.full.title") { + t.Errorf("503 message should carry upstream 6021 info: %s", body) + } + if !strings.Contains(body, "upstream_busy") { + t.Errorf("503 body should carry upstream_busy code: %s", body) + } + if strings.Contains(body, "no_healthy_account") { + t.Errorf("channel-busy failure must not be reported as account exhaustion: %s", body) + } +} + // TestChatBadParams400CarriesUpstreamBody 11101 的 400 响应必须包含上游原始 // 11101 信息(含 requestId,客户端据此排查),且不再出现空洞的 no_healthy_account。 func TestChatBadParams400CarriesUpstreamBody(t *testing.T) { @@ -189,7 +232,6 @@ func TestChatBadParams400CarriesUpstreamBody(t *testing.T) { } } - func TestChatNonStreamAggregates(t *testing.T) { up := newFakeUpstream(t, func(authz string) (int, string, bool) { if authz != "Bearer at1" { @@ -841,6 +883,50 @@ func TestChatHTTP4xxClientDoesNotPenalize(t *testing.T) { } } +// TestChatModelForbiddenByAccountTypeReturns400 复现 global:gpt-5.6-sol 在免费账号上 +// 的 11158:网关应仅轮转 global 域、写 (账号,模型) 避让,最终返回明确的 400 +// model_unavailable,而不是被 CN 账号掩盖成 503 no_healthy_account。 +func TestChatModelForbiddenByAccountTypeReturns400(t *testing.T) { + var calls int + up := newFakeUpstream(t, func(authz string) (int, string, bool) { + calls++ + return 400, `{"code":11158,"msg":"model [gpt-5.6-sol] is not allowed for user type [personal_free_user]","requestId":"req-11158"}`, false + }) + p := testPoolWith( + &auth.Auth{UID: "cn1", Domain: "www.codebuddy.cn", AccessToken: "at-cn", ExpiresAt: 9999999999}, + &auth.Auth{UID: "g1", Domain: "www.workbuddy.ai", AccessToken: "at-global", ExpiresAt: 9999999999}, + ) + h := NewHandler(Config{Pool: p, Upstream: up}) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("POST", "/v1/chat/completions", + strings.NewReader(`{"model":"global:gpt-5.6-sol","messages":[]}`))) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("code=%d want 400 body=%s", rec.Code, rec.Body) + } + if calls != 1 { + t.Fatalf("calls=%d want 1(只能尝试 global 账号,不能跨域轮转)", calls) + } + var resp struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("bad response JSON: %v body=%s", err, rec.Body) + } + if resp.Error.Code != "model_unavailable" { + t.Errorf("error.code=%q want model_unavailable", resp.Error.Code) + } + if !strings.Contains(resp.Error.Message, "11158") { + t.Errorf("响应应透传上游 11158 原文: %q", resp.Error.Message) + } + if st := p.ModelBlockedForRealm("gpt-5.6-sol", "global"); !st.Blocked || st.Count != 1 { + t.Fatalf("global 账号应写入模型级避让: %+v", st) + } +} + func TestModelsEndpoint(t *testing.T) { h := NewHandler(Config{Pool: testPoolWith(&auth.Auth{UID: "u1", AccessToken: "at", ExpiresAt: 9999999999}), Upstream: upstream.New()}) req := httptest.NewRequest("GET", "/v1/models", nil) @@ -1568,3 +1654,92 @@ func TestCustomModeFingerprintSanitizePreserved(t *testing.T) { t.Errorf("want exactly 1 system message, got %d (all=%v)", systemCount, msgs) } } +func TestChatHiddenModelRejected(t *testing.T) { + var calls int + up := newFakeUpstream(t, func(authz string) (int, string, bool) { + calls++ + return 200, sseOK, true + }) + p := testPoolWith(&auth.Auth{UID: "u1", AccessToken: "at1", ExpiresAt: 9999999999}) + h := NewHandler(Config{Pool: p, Upstream: up, ModelsHidden: []string{"glm-5.2", "global:secret-model"}}) + + post := func(model string) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + body := []byte(`{"model":"` + model + `","messages":[]}`) + h.ServeHTTP(rec, httptest.NewRequest("POST", "/v1/chat/completions", bytes.NewReader(body))) + return rec + } + + // 裸名隐藏 → 裸名 / cn: / global: 三种写法都拒绝 + for _, model := range []string{"glm-5.2", "cn:glm-5.2", "global:glm-5.2"} { + rec := post(model) + if rec.Code != http.StatusBadRequest { + t.Errorf("model %q: code=%d, want 400", model, rec.Code) + } + if !strings.Contains(rec.Body.String(), "model_unavailable") { + t.Errorf("model %q: body=%s, want model_unavailable", model, rec.Body.String()) + } + } + // 前缀精确隐藏:只藏 global 域 + if rec := post("global:secret-model"); rec.Code != http.StatusBadRequest { + t.Errorf("global:secret-model: code=%d, want 400", rec.Code) + } + if calls != 0 { + t.Errorf("hidden models must not reach upstream: calls=%d", calls) + } + + // cn:secret-model 不在黑名单 → 放行并打到上游 + rec := post("cn:secret-model") + if rec.Code != http.StatusOK { + t.Fatalf("cn:secret-model: code=%d body=%s, want 200", rec.Code, rec.Body.String()) + } + if calls != 1 { + t.Errorf("allowed model: upstream calls=%d, want 1", calls) + } +} + +// TestModelsHiddenFiltered models.hidden 黑名单:/v1/models 列表里过滤掉隐藏模型。 +// 用包级 dynamicModelsCache 预置目录,避免联网拉取。 +func TestModelsHiddenFiltered(t *testing.T) { + dynamicModelsCache.Lock() + dynamicModelsCache.ids = []upstream.ModelInfo{{ID: "glm-5.2"}, {ID: "deepseek-v3"}} + dynamicModelsCache.fetched = time.Now() + dynamicModelsCache.Unlock() + defer func() { + dynamicModelsCache.Lock() + dynamicModelsCache.ids = nil + dynamicModelsCache.fetched = time.Time{} + dynamicModelsCache.lastFail = time.Time{} + dynamicModelsCache.Unlock() + }() + + up := newFakeUpstream(t, func(authz string) (int, string, bool) { + return 200, `{"data":[]}`, false + }) + p := testPoolWith(&auth.Auth{UID: "u1", AccessToken: "at1", ExpiresAt: 9999999999}) + h := NewHandler(Config{Pool: p, Upstream: up, ModelsHidden: []string{"deepseek-v3"}}) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("GET", "/v1/models", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("code=%d body=%s, want 200", rec.Code, rec.Body.String()) + } + var resp struct { + Data []struct { + ID string `json:"id"` + } `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode /v1/models: %v", err) + } + ids := map[string]bool{} + for _, m := range resp.Data { + ids[m.ID] = true + } + if !ids["cn:glm-5.2"] { + t.Errorf("visible model missing from list: ids=%v", ids) + } + if ids["cn:deepseek-v3"] { + t.Errorf("hidden model leaked into /v1/models: ids=%v", ids) + } +} diff --git a/internal/server/logging.go b/internal/server/logging.go index c6577baa..1e9c595a 100644 --- a/internal/server/logging.go +++ b/internal/server/logging.go @@ -209,9 +209,12 @@ func (s *chatStatsReader) parseSSELine(line string) { } // CacheTokens 返回末帧 usage 的缓存命中 / 未命中 token 数。miss 缺失时按 -// prompt - hit 推导;hit 与 miss 均不可得时 ok=false(不参与命中率统计)。 +// prompt - hit 推导;对称地,miss 显式存在而 hit 为 0 / 缺失时按 prompt - miss +// 反推 hit(issue #170:上游在「接近全命中」时可能回 hit=0 + miss=3 这类组合, +// 不反推会被误判为整段未命中)。两个方向都推导不出时 ok=false(不参与命中率 +// 统计)。 func (s *chatStatsReader) CacheTokens() (hit, miss int64, ok bool) { - if !s.hasCacheHit { + if !s.hasCacheHit && !s.hasCacheMiss { return 0, 0, false } hit = int64(s.cacheHit) @@ -221,6 +224,10 @@ func (s *chatStatsReader) CacheTokens() (hit, miss int64, ok bool) { return hit, 0, true } miss = int64(s.promptTokens - s.cacheHit) + } else if s.cacheHit == 0 && s.hasPromptTokens && s.promptTokens > s.cacheMiss { + // hit 为 0 / 缺失但 miss 可信(prompt > miss,即确有命中量)→ 反推。 + // prompt <= miss 时保持 hit=0,那是真实的整段未命中。 + hit = int64(s.promptTokens - s.cacheMiss) } return hit, miss, true } @@ -278,6 +285,25 @@ func parseModelFromBody(body []byte) string { return obj.Model } +// cacheTokensFromUsage 从非流式聚合响应的 usage 里提取缓存命中 / 未命中 token +// 数,与流式侧 CacheTokens 的推导口径对称:hit 不可得(缺失或 0)但 miss 显式 +// 存在时,按 prompt - miss 反推 hit(issue #170);prompt <= miss 时保持 hit=0 +// (真实的整段未命中)。两个方向都不可得时 ok=false。 +func cacheTokensFromUsage(usage map[string]any) (hit, miss int64, ok bool) { + h, okH := upstream.UsageCacheHitTokens(usage) + m, okM := upstream.UsageCacheMissTokens(usage) + switch { + case okH: + return int64(h), int64(m), true + case okM: + if p, okP := upstream.UsagePromptTokens(usage); okP && p > m { + return int64(p - m), int64(m), true + } + return 0, int64(m), true + } + return 0, 0, false +} + // usageDeltaFromResponse 从非流式聚合响应中提取明确存在的 token 字段。 func usageDeltaFromResponse(resp map[string]any) pool.TokenUsageDelta { delta := pool.TokenUsageDelta{} diff --git a/internal/server/models_fetch_log_test.go b/internal/server/models_fetch_log_test.go new file mode 100644 index 00000000..a3037350 --- /dev/null +++ b/internal/server/models_fetch_log_test.go @@ -0,0 +1,48 @@ +package server + +import ( + "bytes" + "log" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" +) + +// CN 模型目录拉取失败必须留下一行 WARN(此前静默,排障无线索),且负缓存期内 +// 的后续请求不重复记录。 +func TestModelsFetchFailureLogsOncePerCooldown(t *testing.T) { + dynamicModelsCache.Lock() + dynamicModelsCache.ids = nil + dynamicModelsCache.fetched = time.Time{} + dynamicModelsCache.lastFail = time.Time{} + dynamicModelsCache.Unlock() + + var buf bytes.Buffer + prev := log.Writer() + log.SetOutput(&buf) + t.Cleanup(func() { log.SetOutput(prev) }) + + up := newFakeUpstream(t, func(authz string) (int, string, bool) { + return 500, `boom`, false + }) + p := testPoolWith(&auth.Auth{UID: "u1", AccessToken: "at1", ExpiresAt: 9999999999}) + h := NewHandler(Config{Pool: p, Upstream: up}) + + for i := 0; i < 3; i++ { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("GET", "/v1/models", nil)) + if rec.Code != 200 { + t.Fatalf("req %d: code=%d", i, rec.Code) + } + } + const marker = "/v1/models: CN 模型目录拉取失败" + if got := strings.Count(buf.String(), marker); got != 1 { + t.Fatalf("want 1 failure log line, got %d; log:\n%s", got, buf.String()) + } + if !strings.Contains(buf.String(), "status 500") { + t.Errorf("failure log should carry upstream error; log:\n%s", buf.String()) + } +} diff --git a/internal/server/request_illegal_policy_test.go b/internal/server/request_illegal_policy_test.go new file mode 100644 index 00000000..fb8e31ff --- /dev/null +++ b/internal/server/request_illegal_policy_test.go @@ -0,0 +1,193 @@ +package server + +import ( + "strings" + "testing" + "time" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" + "github.com/linguo2625469/workbuddy2api-panel/internal/pool" + "github.com/linguo2625469/workbuddy2api-panel/internal/upstream" +) + +// 上游「request illegal」有两种形态,msg 文案**完全相同**,只有业务码不同: +// +// 11-128 / 11128 → 请求级「通道校验失败」,上游明示可重试("请重新发送")。 +// 11140 → 账号级授权限制(auth/auth_forbidden),要申诉解除。 +// +// 二者历史上被同一个 marker("request illegal")归为 ErrAccountFault,于是: +// - 11-128 形态被 Disable → 仍可用的账号被永久踢出池子,用户只能反复点 +// 「解冻」(issue #197 / #231 的用户侧症状:官方客户端能聊、面板里却标成 +// banned); +// - 一旦把 11-128 改成软冷却,11140 的真封禁也会跟着降级 → 账号永远轮换、 +// 永远不恢复。 +// +// 故按**结构化业务码**在分类层分野:11-128/11128 → ErrChannelRefused(不罚号), +// 11140 → ErrAccountFault(禁用)。 + +// channelVerifyBody 上游 11-128 通道校验失败的真实 body(issue #197 用户抓包实录)。 +const channelVerifyBody = `{"code":11128,"msg":"request illegal","requestId":"XXXX",` + + `"displayMsg":{"en":"Request blocked. Please send it again","zh":"请求被拦截,请重新发送"},` + + `"displayTips":{"en":"This call failed channel verification, possibly because it was not sent from an official client. Please resend from the official client.",` + + `"zh":"这次调用没通过渠道校验,可能不是官方客户端发出的。请用官方版本重新发送。"}}` + +// accountBannedBody 上游 11140 账号级授权限制(auth/auth_forbidden)body。 +const accountBannedBody = `{"code":11140,"msg":"request illegal"}` + +// TestApplyErrorPolicyChannelRefusedDoesNotDisable 11-128/11128 通道校验失败: +// 账号**不得**被禁用、不得被冷却、不得累计错误计数——账号本身健康。 +// 历史实现在此 Disable(issue #197),把仍可用的号永久踢出池子。 +func TestApplyErrorPolicyChannelRefusedDoesNotDisable(t *testing.T) { + p := pool.New("") + p.Add(&auth.Auth{UID: "u1"}) + h := NewHandler(Config{Pool: p, SoftCooldown: 60 * time.Second}) + + h.applyErrorPolicy("u1", upstream.ErrChannelRefused, channelVerifyBody, "glm-5.2", nil) + + st, ok := p.Status("u1") + if !ok { + t.Fatal("账号状态缺失") + } + if st.Disabled { + t.Fatalf("11-128 通道校验失败不得禁用账号(issue #197): %+v", st) + } + if st.Cooling { + t.Fatalf("11-128 通道校验失败不得冷却账号(账号本身健康): %+v", st) + } + if st.ErrTotal != 0 { + t.Errorf("不罚号路径不应累计错误计数: err_total=%d", st.ErrTotal) + } +} + +// TestApplyErrorPolicyAccountBannedStillDisables 11140 账号级封禁:必须 Disable。 +// 回归守卫:若有人把 11-128 的零罚号改动做成"整个 request illegal 都不罚", +// 真封禁会退化成永远轮换、永远不恢复。 +func TestApplyErrorPolicyAccountBannedStillDisables(t *testing.T) { + p := pool.New("") + p.Add(&auth.Auth{UID: "u1"}) + h := NewHandler(Config{Pool: p, SoftCooldown: 60 * time.Second}) + + h.applyErrorPolicy("u1", upstream.ErrAccountFault, accountBannedBody, "glm-5.2", nil) + + st, ok := p.Status("u1") + if !ok { + t.Fatal("账号状态缺失") + } + if !st.Disabled { + t.Fatalf("11140 账号级封禁必须禁用账号: %+v", st) + } + if !strings.Contains(strings.ToLower(st.DisabledReason), "request illegal") { + t.Errorf("disabled_reason=%q 应说明是 request illegal", st.DisabledReason) + } +} + +// TestClassifyRequestIllegalSplitsByBusinessCode 分类层按业务码分流: +// 11-128/11128 → ErrChannelRefused,11140 → ErrAccountFault。 +func TestClassifyRequestIllegalSplitsByBusinessCode(t *testing.T) { + cases := []struct { + name string + body string + want upstream.ErrKind + }{ + {"11-128 通道校验失败", channelVerifyBody, upstream.ErrChannelRefused}, + {"11-128 字符串业务码", `{"code":"11-128","msg":"request illegal"}`, upstream.ErrChannelRefused}, + {"11128 裸体", `{"code":11128,"msg":"request illegal"}`, upstream.ErrChannelRefused}, + {"11140 账号封禁", accountBannedBody, upstream.ErrAccountFault}, + // 11140 的模型级限流变体:必须仍是软限流,不能被误判成账号封禁。 + {"11140 限流变体", `{"code":11140,"msg":"The model provider is rate-limiting requests. Please wait a moment and try again."}`, upstream.ErrSoftRate}, + // 裸 "request illegal"(无业务码):宁可走通用 4xx 兜底,也不能凭一句 + // 文案硬禁用账号。 + {"裸 request illegal", `{"msg":"request illegal"}`, upstream.ErrClient}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := upstream.Classify(400, tc.body); got != tc.want { + t.Fatalf("Classify=%v want %v", got, tc.want) + } + }) + } +} + +// TestIsChannelRefused 通道校验失败判定:结构化业务码 + 文案合取。 +func TestIsChannelRefused(t *testing.T) { + cases := []struct { + name string + body string + want bool + }{ + {"11-128 业务码", `{"code":"11-128","msg":"request illegal"}`, true}, + {"11128 业务码", `{"code":11128,"msg":"request illegal"}`, true}, + {"嵌套业务码", `{"error":{"data":{"code":"11-128","msg":"request illegal"}}}`, true}, + // issue #188 实录:msg 是 "conversation id missing"(同一业务码的变体)。 + {"11128 conversation id missing", `{"code":11128,"msg":"conversation id missing"}`, true}, + // issue #231 实录:首条消息不是 system。 + {"11128 first message is not system", `{"code":11128,"msg":"first message is not system prompt"}`, true}, + // 11140 = 真封禁,不得降级成通道校验。 + {"11140 账号封禁", `{"code":11140,"msg":"request illegal"}`, false}, + {"11140 + 渠道校验文案", `{"code":11140,"msg":"request illegal","displayTips":{"en":"This call failed channel verification, possibly because it was not sent from an official client."}}`, false}, + // 真内容拦截("blocked by security policy")仍归审核域:它不带 11-128 + // 业务码(实测录像是纯文案),是**内容**问题,不是请求形状问题。 + {"11128 真内容审核变体", `{"code":11128,"msg":"blocked by security policy"}`, false}, + // 但 11-128 + "unapproved channel"/"illegal api invocation" 是**渠道校验** + // 变体(2026-10-20 真机实录:首条 developer → 400 11-128 Illegal API + // invocation from an unapproved channel),不得归审核域:归审核域会触发 + // 全局降级门(降级到次日 00:00 CST),一次请求级形状问题被放大成整网关 + // 整天的降级。 + {"11128 unapproved channel", `{"code":11128,"msg":"Illegal API invocation from an unapproved channel"}`, true}, + {"11128 unapproved channel(短文案)", `{"code":11128,"msg":"unapproved channel"}`, true}, + // 无业务码 → 不认(宁可通用兜底,不可凭文案硬禁用)。 + {"裸 request illegal(无业务码)", `{"msg":"request illegal"}`, false}, + {"无关 body", `{"code":0,"msg":"ok"}`, false}, + {"空 body", ``, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := upstream.IsChannelRefused(tc.body); got != tc.want { + t.Fatalf("IsChannelRefused(%s)=%v want %v", tc.body, got, tc.want) + } + }) + } +} + +// TestIsAccountRestricted 账号级限制判定:code + 文案双条件。 +func TestIsAccountRestricted(t *testing.T) { + cases := []struct { + name string + body string + want bool + }{ + {"11140 + request illegal", accountBannedBody, true}, + {"11140 + 限流文案(不得判为封禁)", `{"code":11140,"msg":"The model provider is rate-limiting requests."}`, false}, + {"11128 + request illegal(通道校验,非封禁)", `{"code":11128,"msg":"request illegal"}`, false}, + {"裸 request illegal(无业务码)", `{"msg":"request illegal"}`, false}, + {"空 body", ``, false}, + // msg 是封禁文案、但 displayMsg 带模型级限流说明:11140 的信封里 msg 与 + // displayMsg 是两个字段,上游可能这么组合。那是模型侧限流(几分钟自愈), + // 不是账号封禁——永久禁用好号并让用户重新登录是最重的误伤。 + {"11140 + 限流 displayMsg(不得判为封禁)", `{"code":11140,"msg":"request illegal","displayMsg":{"en":"The model provider is rate-limiting requests. Please wait a moment and try again."}}`, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := upstream.IsAccountRestricted(tc.body); got != tc.want { + t.Fatalf("IsAccountRestricted(%s)=%v want %v", tc.body, got, tc.want) + } + }) + } +} + +// TestClassify11140RateLimitVariantStaysSoftRate 回归守卫:11140 信封里同时带 +// "request illegal" 与模型级限流文案时,分类必须落在 ErrSoftRate(软冷却), +// 不得落到 ErrAccountFault(永久禁用)。 +// +// 这是本次修复实测抓到的真实误伤:上游把模型级限流文案塞进 11140 的 +// displayMsg,msg 仍是 "request illegal",于是 code+文案双条件双双命中, +// 分类层直接判成账号封禁 → 好号被永久踢出池子。 +func TestClassify11140RateLimitVariantStaysSoftRate(t *testing.T) { + body := `{"code":11140,"msg":"request illegal","displayMsg":{"en":"The model provider is rate-limiting requests. Please wait a moment and try again."}}` + if got := upstream.Classify(400, body); got != upstream.ErrSoftRate { + t.Fatalf("Classify=%v want %v(限流文案不得被当成账号封禁)", got, upstream.ErrSoftRate) + } + if upstream.IsAccountRestricted(body) { + t.Fatal("IsAccountRestricted 必须对限流变体返回 false") + } +} diff --git a/internal/server/resolve_model.go b/internal/server/resolve_model.go index 3a92352e..3752cdb1 100644 --- a/internal/server/resolve_model.go +++ b/internal/server/resolve_model.go @@ -2,25 +2,40 @@ package server import "strings" -// resolveModel 解析模型名协议(PLAN D6): +// resolveModelDetail 解析模型名协议(PLAN D6),并区分「realm 是否由调用方显式写出」: // // 分布式前缀: "[realm:]model" // // 取第一个 ":",前段恰为 "cn"/"global" 才剥离;否则视为裸名,realm=cn、bare=原串。 // 大小写敏感(前缀必须是精确的小写枚举)。bare 即出站/选号/账本使用的裸模型名。 // -// 导出为 ResolveModel(cmd/server/main.go 粘性闭包需要),包内简写 resolveModel。 -func resolveModel(model string) (realm, bare string) { +// 第三个返回值 realmExplicit 供跨域回退(issue #135)判定:只有裸名(false)才允许 +// 在首选域选不到号时改用另一域;显式 cn:/global: 前缀永不被回退改写。 +func resolveModelDetail(model string) (realm, bare string, realmExplicit bool) { idx := strings.IndexByte(model, ':') if idx < 0 { - return "cn", model + return "cn", model, false } prefix := model[:idx] if prefix != "cn" && prefix != "global" { - return "cn", model + return "cn", model, false } - return prefix, model[idx+1:] + return prefix, model[idx+1:], true +} + +// resolveModel 是 resolveModelDetail 的两返回值形态(既有调用方语义零改动)。 +func resolveModel(model string) (realm, bare string) { + realm, bare, _ = resolveModelDetail(model) + return realm, bare } // ResolveModel 是 resolveModel 的导出面(跨包调用)。 func ResolveModel(model string) (realm, bare string) { return resolveModel(model) } + +// otherRealm 返回另一个选号域("cn" ↔ "global"),供跨域回退使用。 +func otherRealm(realm string) string { + if realm == "global" { + return "cn" + } + return "global" +} diff --git a/internal/session/session.go b/internal/session/session.go index f867e89f..d33d3e38 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -137,6 +137,22 @@ func (r *Router) Resolve(key string) (string, bool) { return r.ResolveForModel(key, "") } +// LookupForModel 只复用已有有效绑定,不通过哈希为新会话分配账号。 +// 有序账号池策略用它保留粘性,未绑定会话由 Pool 选号并在成功后 Bind。 +func (r *Router) LookupForModel(key, model string) (string, bool) { + now := time.Now() + available := r.availableSet(model) + r.mu.Lock() + defer r.mu.Unlock() + e, found := r.entries[key] + if !found || expired(e, now, r.cfg.TTL) || !available[e.uid] { + return "", false + } + r.entries[key] = entry{uid: e.uid, lastActive: now} + r.cfg.Store.SetBind(key, e.uid, r.cfg.TTL) + return e.uid, true +} + // ResolveForModel 返回会话 key 在该模型上应绑定的账号 uid。 // 命中且账号在该模型可用 → 滚动 lastActive 并直接返回;否则(绑定号已冷却/占满/ // 被该模型限流)走重新分配。 diff --git a/internal/session/session_test.go b/internal/session/session_test.go index fbd6f111..70079ff5 100644 --- a/internal/session/session_test.go +++ b/internal/session/session_test.go @@ -377,3 +377,30 @@ func TestExtractKeyMultimodalContent(t *testing.T) { t.Error("pure-image url changes must keep same derived key") } } + +func TestLookupForModelOnlyReusesValidBinding(t *testing.T) { + available := []string{"a", "b"} + store := newCountingStore() + r := New(Config{TTL: time.Minute, Store: store, AvailableForModel: func(model string) []string { return available }}) + if _, ok := r.LookupForModel("new", "m"); ok || r.Count() != 0 { + t.Fatal("lookup allocated a new binding") + } + r.Bind("key", "a") + if uid, ok := r.LookupForModel("key", "m"); !ok || uid != "a" { + t.Fatalf("valid lookup=%s %v", uid, ok) + } + available = []string{"b"} + if _, ok := r.LookupForModel("key", "m"); ok { + t.Fatal("unavailable binding reused") + } + available = []string{"a", "b"} + r.mu.Lock() + r.entries["key"] = entry{uid: "a", lastActive: time.Now().Add(-2 * time.Minute)} + r.mu.Unlock() + if _, ok := r.LookupForModel("key", "m"); ok { + t.Fatal("expired binding reused") + } + if r.Count() != 1 { + t.Fatal("lookup allocated a replacement") + } +} diff --git a/internal/upstream/account_restricted_test.go b/internal/upstream/account_restricted_test.go new file mode 100644 index 00000000..7cc3ff9f --- /dev/null +++ b/internal/upstream/account_restricted_test.go @@ -0,0 +1,95 @@ +package upstream + +import "testing" + +// 11140 账号封禁判定的**排除项**边界(对抗性复核发现的过度宽泛问题)。 +// +// 11140 的信封里 msg 与 displayMsg 是两个字段:上游可能给出 +// msg="request illegal" 而 displayMsg 带**模型级限流**说明——那是模型侧限流, +// 几分钟自愈,永久禁用账号是错的(重登对限流无效)。 +// +// 但排除项**不能**用 softRateMarkers 整张宽词表("rate limit" / "too many" / +// "usage limit" / "限流"):那些词同样出现在真封禁文案里—— +// +// "Too many login attempts, please try again later." +// "Your usage limit has been reached." +// "This account was restricted. Rate limit abuse detected." +// +// 按宽词表否决 → 真封禁账号**永不禁用**:它一直带病服务,面板还显示「可用」, +// 用户也无从申诉。那比误封更糟(误封至少能被解冻)。 +func TestIsAccountRestrictedNarrowVeto(t *testing.T) { + cases := []struct { + name string + body string + want bool // true = 账号封禁(应被 Disable) + }{ + { + name: "纯封禁", + body: `{"code":11140,"msg":"request illegal"}`, + want: true, + }, + { + name: "封禁 + 登录尝试过多文案", + body: `{"code":11140,"msg":"request illegal","displayMsg":{"en":"Too many login attempts, please try again later."}}`, + want: true, + }, + { + name: "封禁 + 用量上限文案", + body: `{"code":11140,"msg":"request illegal","displayMsg":{"en":"Your usage limit has been reached."}}`, + want: true, + }, + { + name: "封禁 + 风控限流滥用文案", + body: `{"code":11140,"msg":"request illegal","displayMsg":{"en":"This account was restricted. Rate limit abuse detected."}}`, + want: true, + }, + { + name: "封禁 + 账号请求过多文案", + body: `{"code":11140,"msg":"request illegal","displayMsg":{"en":"Too many requests from this account."}}`, + want: true, + }, + { + // 上游固定的模型级限流句 → 不是账号封禁,保持 ErrSoftRate。 + name: "11140 信封里的模型级限流句", + body: `{"code":11140,"msg":"request illegal","displayMsg":{"en":"The model provider is rate-limiting requests."}}`, + want: false, + }, + { + name: "11140 无 request illegal 文案", + body: `{"code":11140,"msg":"The model provider is rate-limiting requests."}`, + want: false, + }, + { + // 通道校验码不是账号封禁。 + name: "11128 通道校验", + body: `{"code":11128,"msg":"request illegal"}`, + want: false, + }, + { + name: "裸文案无业务码", + body: `{"msg":"request illegal"}`, + want: false, + }, + } + for _, c := range cases { + if got := IsAccountRestricted(c.body); got != c.want { + t.Errorf("%s: IsAccountRestricted(%s)=%v want %v", c.name, c.body, got, c.want) + } + } +} + +// TestClassify11140BanVariantsStayAccountFault 封禁文案里含限流词时,分类链 +// 仍必须落到 ErrAccountFault(会被 Disable),不得滑到 ErrSoftRate。 +func TestClassify11140BanVariantsStayAccountFault(t *testing.T) { + bodies := []string{ + `{"code":11140,"msg":"request illegal"}`, + `{"code":11140,"msg":"request illegal","displayMsg":{"en":"Too many login attempts, please try again later."}}`, + `{"code":11140,"msg":"request illegal","displayMsg":{"en":"Your usage limit has been reached."}}`, + `{"code":11140,"msg":"request illegal","displayMsg":{"en":"This account was restricted. Rate limit abuse detected."}}`, + } + for _, b := range bodies { + if got := Classify(403, b); got != ErrAccountFault { + t.Errorf("Classify(403,%s)=%v want %v", b, got, ErrAccountFault) + } + } +} diff --git a/internal/upstream/apikey_headers_test.go b/internal/upstream/apikey_headers_test.go new file mode 100644 index 00000000..afa1ab86 --- /dev/null +++ b/internal/upstream/apikey_headers_test.go @@ -0,0 +1,52 @@ +package upstream + +import ( + "net/http" + "testing" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" +) + +// TestAPIKeyChatHeadersDoubleAuth 静态 API Key 账号 chat 出站:Authorization: Bearer +// 之外再注入 X-API-Key(官方 CLI 双头行为)。对照 OAuth 个人号不发 X-API-Key。 +func TestAPIKeyChatHeadersDoubleAuth(t *testing.T) { + c := &Client{} + const key = "ck_9f3a2b1c.deadbeef" + + req, _ := http.NewRequest(http.MethodPost, "https://copilot.tencent.com/v2/chat/completions", nil) + c.ChatHeaders(req, &auth.Auth{UID: "u1", AccessToken: key}, "", ChatMeta{ConversationRequestID: "abc"}) + if got := req.Header.Get("Authorization"); got != "Bearer "+key { + t.Errorf("Authorization=%q want Bearer <key>", got) + } + if got := req.Header.Get("X-API-Key"); got != key { + t.Errorf("X-API-Key=%q want %q", got, key) + } + // 静态 Key 无企业身份:不得注入 X-Enterprise-Id。 + if got := req.Header.Get("X-Enterprise-Id"); got != "" { + t.Errorf("API Key 账号不应注入 X-Enterprise-Id,实际=%q", got) + } + + // 对照:OAuth 个人号不发 X-API-Key。 + req2, _ := http.NewRequest(http.MethodPost, "https://copilot.tencent.com/v2/chat/completions", nil) + c.ChatHeaders(req2, &auth.Auth{UID: "u2", AccessToken: "at"}, "", ChatMeta{ConversationRequestID: "def"}) + if got := req2.Header.Get("X-API-Key"); got != "" { + t.Errorf("OAuth 账号不应注入 X-API-Key,实际=%q", got) + } +} + +// TestAPIKeyBillingHeadersNoEnterprise API Key 走企业口径额度端点时不带企业身份头 +// (实测不需要,带上反而暴露虚假身份)。 +func TestAPIKeyBillingHeadersNoEnterprise(t *testing.T) { + c := &Client{} + req, _ := http.NewRequest(http.MethodPost, "https://www.codebuddy.cn/v2/billing/meter/get-enterprise-user-usage", nil) + c.BillingHeaders(req, &auth.Auth{UID: "u1", AccessToken: "ck_9f3a2b1c.deadbeef"}) + if got := req.Header.Get("X-Enterprise-Id"); got != "" { + t.Errorf("X-Enterprise-Id=%q want empty", got) + } + if got := req.Header.Get("X-Tenant-Id"); got != "" { + t.Errorf("X-Tenant-Id=%q want empty", got) + } + if got := req.Header.Get("Authorization"); got != "Bearer ck_9f3a2b1c.deadbeef" { + t.Errorf("Authorization=%q", got) + } +} diff --git a/internal/upstream/billing_summary.go b/internal/upstream/billing_summary.go new file mode 100644 index 00000000..d9d340bd --- /dev/null +++ b/internal/upstream/billing_summary.go @@ -0,0 +1,277 @@ +// billing_summary.go 国际版(global realm)计费新口径:summary + paid/free packages。 +// +// 为什么单独一条链:官方 5.8.4 国际版的额度查询改成了三件套(真机抓包实测, +// 2026-10-20,www.workbuddy.ai): +// +// POST /billing/meter/get-user-resource-summary {} ← 当前周期聚合(remain/total) +// POST /billing/meter/get-user-resource-paid-packages {} ← 付费包明细(带到期时间) +// POST /billing/meter/get-user-resource-free-packages {} ← 免费包明细(带到期时间) +// +// 而本仓原先走的是 CN 那套 `/billing/meter/get-user-resource` 带分页 body +// (PageNumber/PageSize/ProductCode/Status/PackageEndTimeRange*)。国际版对该路径 +// 返回的形态已与 CN 不同(summary 里 CycleTotalCapacity / CycleRemainCapacity 是 +// **字符串小数**,且没有 Accounts 树),继续按 CN 解析会得到恒 0 的余额——积分保底 +// 随即把这些号当耗尽号挡出选号。 +// +// 分工:summary 给权威的 remain/total(当前周期口径),paid/free 明细只用来补齐 +// 「哪天到期、那一批还剩多少」。两者按 PackageCode 关联——summary 的 Packages 也带 +// PackageCode,故能精确对齐,不必靠顺序或名字猜。明细拉不到时降级为「只有总额没有 +// 到期批次」(面板少一行到期提醒),绝不因为明细失败把总额也丢掉。 +package upstream + +import ( + "encoding/json" + "fmt" + "sort" + "strconv" + "strings" + "time" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" +) + +const ( + billingSummaryPath = "/billing/meter/get-user-resource-summary" + billingPaidPath = "/billing/meter/get-user-resource-paid-packages" + billingFreePath = "/billing/meter/get-user-resource-free-packages" +) + +// flexFloat 解析「可能是 JSON 数字也可能是字符串」的容量字段。 +// +// 上游同一语义字段两套形态并存:summary 里是字符串小数("335.47000026"), +// 明细里是 JSON 整数 + 独立的 *Precise 字符串。用这个类型兜住,避免为每个字段 +// 写两遍解析,也避免字符串形态被 json.Unmarshal 直接判错整段失败。 +type flexFloat float64 + +func (f *flexFloat) UnmarshalJSON(raw []byte) error { + s := strings.TrimSpace(string(raw)) + if s == "null" || s == `""` { + *f = 0 + return nil + } + s = strings.Trim(s, `"`) + v, err := strconv.ParseFloat(s, 64) + if err != nil { + // 单个脏字段不该让整次余额查询失败:按 0 处理(该项计不进去,其余照算)。 + *f = 0 + return nil + } + *f = flexFloat(v) + return nil +} + +// summaryPackage summary.Packages[] 的单条:当前周期的用量聚合。 +type summaryPackage struct { + PackageCode string `json:"PackageCode"` + CycleTotalCapacity flexFloat `json:"CycleTotalCapacity"` + CycleRemainCapacity flexFloat `json:"CycleRemainCapacity"` + CycleUsedCapacity flexFloat `json:"CycleUsedCapacity"` + CapacityUnit string `json:"CapacityUnit"` +} + +// packageDetail paid/free packages 的单条:与 CN 的 Accounts[] 同构,另带 *Precise +// 字符串形态,以及用于判定到期的三个时间字段。 +type packageDetail struct { + PackageCode string `json:"PackageCode"` + PackageName string `json:"PackageName"` + DeductionEndTime int64 `json:"DeductionEndTime"` // epoch 毫秒,真实扣费截止 + ExpiredTime string `json:"ExpiredTime"` + CycleEndTime string `json:"CycleEndTime"` + CapacitySize flexFloat `json:"CapacitySize"` + CapacityRemain flexFloat `json:"CapacityRemain"` + CapacityUsed flexFloat `json:"CapacityUsed"` + CycleCapacitySize flexFloat `json:"CycleCapacitySize"` + CycleCapacityRemain flexFloat `json:"CycleCapacityRemain"` + CycleCapacityUsed flexFloat `json:"CycleCapacityUsed"` + // *Precise 优先:整数形态会把 0.47 这类小数截断,两者都在时以 Precise 为准。 + CapacitySizePrecise flexFloat `json:"CapacitySizePrecise"` + CapacityRemainPrecise flexFloat `json:"CapacityRemainPrecise"` + CycleCapacitySizePrecise flexFloat `json:"CycleCapacitySizePrecise"` + CycleCapacityRemainPrecise flexFloat `json:"CycleCapacityRemainPrecise"` +} + +// summaryReply / packagesReply 是两条端点的信封内层。 +type summaryReply struct { + Packages []summaryPackage `json:"Packages"` + SubscriptionPackageName string `json:"SubscriptionPackageName"` + IsPaidUser bool `json:"IsPaidUser"` +} + +type packagesReply struct { + Accounts []packageDetail `json:"Accounts"` + TotalCount int `json:"TotalCount"` +} + +// globalUserResource 国际版额度查询:summary 给总额,paid/free 明细补到期批次。 +// 返回值口径与 CN 路径完全一致(remain/total/expiring/earliest/buckets), +// 上游调用方无感知。 +func (c *Client) globalUserResource(a *auth.Auth, soon time.Duration) (remain, total, expiring int64, earliestAt time.Time, earliestRemaining int64, buckets []ExpiryBucket, err error) { + var raw json.RawMessage + // summary 是主口径,失败即整次失败(明细单独拿到也没总额可对)。 + raw, err = c.billingJSON(a, "POST", billingSummaryPath, map[string]any{}) + if err != nil { + return 0, 0, 0, time.Time{}, 0, nil, err + } + var sum summaryReply + if uerr := json.Unmarshal(raw, &sum); uerr != nil { + return 0, 0, 0, time.Time{}, 0, nil, fmt.Errorf("billing summary parse: %w", uerr) + } + now := time.Now() + // summary 的 Packages 是「当前周期的权威用量」:remain/total 以它为准。 + remainByCode := make(map[string]int64, len(sum.Packages)) + for _, p := range sum.Packages { + r := int64(p.CycleRemainCapacity) + s := int64(p.CycleTotalCapacity) + if r < 0 { + r = 0 + } + if r > s && s > 0 { + r = s + } + remain += r + total += s + if p.PackageCode != "" { + remainByCode[p.PackageCode] = r + } + } + + // 明细只为「到期批次」服务:失败不影响总额(降级为无到期提醒)。 + codes := make([]string, 0, len(sum.Packages)) + for _, p := range sum.Packages { + if p.PackageCode != "" { + codes = append(codes, p.PackageCode) + } + } + details := c.globalPackageDetails(a, codes) + // 同一 PackageCode 在明细里会出现**多条**(每个计量周期一条:ResourceCycleId 不同、 + // CycleEndTime 逐月递推)。按码归并——每个包只该贡献一次余额,且只认该码**最早 + // 的未来到期**那条(真正会先扣完的那一期)。不归并会把同一笔余额重复计入 N 次, + // 面板「到期提醒」于是显示一堆同额批次。 + type pick struct { + d packageDetail + end time.Time + } + best := make(map[string]pick, len(details)) + for _, d := range details { + end, ok := packageEffectiveEnd(d.DeductionEndTime, d.ExpiredTime, "", d.CycleEndTime) + if !ok || !end.After(now) { + continue + } + cur, seen := best[d.PackageCode] + if !seen || end.Before(cur.end) { + best[d.PackageCode] = pick{d: d, end: end} + } + } + byDate := make(map[string]*ExpiryBucket) + for code, p := range best { + d := p.d + r, ok := remainByCode[code] + if !ok { + // summary 没列这个包(已用完/过期/不同周期)→ 明细自报剩余,但仅在为正时计。 + r = int64(d.balanceRemain()) + } + if r <= 0 { + continue + } + end := p.end + if earliestAt.IsZero() || end.Before(earliestAt) { + earliestAt = end + } + day := end.In(softRateResetLoc).Format("2006-01-02") + b, seen := byDate[day] + if !seen { + b = &ExpiryBucket{Date: day, ExpireAt: end} + byDate[day] = b + } else if end.Before(b.ExpireAt) { + b.ExpireAt = end + } + b.Remain += r + if soon > 0 && !end.After(now.Add(soon)) { + expiring += r + } + } + dates := make([]string, 0, len(byDate)) + for d := range byDate { + dates = append(dates, d) + } + sort.Strings(dates) + if len(dates) > 0 { + buckets = make([]ExpiryBucket, 0, len(dates)) + for _, d := range dates { + buckets = append(buckets, *byDate[d]) + } + earliestRemaining = buckets[0].Remain + } + return remain, total, expiring, earliestAt, earliestRemaining, buckets, nil +} + +// globalPackageDetails 拉 paid + free 两份明细并合并。 +// 两条都失败 → nil(调用方降级为「无到期批次」);一条失败只损失那一类的到期信息。 +// +// **两条端点的请求体不同形**(2026-10-20 双版本真机抓包实测,CN/GL 完全一致): +// +// paid-packages: {PageNumber, PageSize, PackageCodes, Status:[0,3], NeedRenewInfo:true} +// —— **没有** SlicePeriod 时间切片 +// free-packages: {PageNumber, PageSize, PackageCodes, Status:[0,3], +// SlicePeriodStartTime:<当日> 00:00:00, SlicePeriodEndTime:<当日> 23:59:59} +// —— 带时间切片,**没有** NeedRenewInfo +// +// 此前两条共用同一个 body(都带时间切片、都不带 NeedRenewInfo),与官方客户端不符: +// `NeedRenewInfo` 是「是否要返回续订信息」的开关,paid 包靠它拿续订窗口。 +// PackageCodes 用 summary 已给的码缩小扫描范围(不按时间盲扫,避免漏包); +// Status [0,3] = 有效/已过期都要(过期包带权威 DeductionEndTime)。 +func (c *Client) globalPackageDetails(a *auth.Auth, codes []string) []packageDetail { + now := time.Now().In(softRateResetLoc) + paidBody := map[string]any{ + "PageNumber": 1, + "PageSize": 200, + "PackageCodes": codes, + "Status": []int{0, 3}, + "NeedRenewInfo": true, + } + freeBody := map[string]any{ + "PageNumber": 1, + "PageSize": 200, + "PackageCodes": codes, + "Status": []int{0, 3}, + "SlicePeriodStartTime": now.Format("2006-01-02") + " 00:00:00", + "SlicePeriodEndTime": now.Format("2006-01-02") + " 23:59:59", + } + var out []packageDetail + for _, req := range []struct { + path string + body map[string]any + }{ + {billingPaidPath, paidBody}, + {billingFreePath, freeBody}, + } { + raw, err := c.billingJSON(a, "POST", req.path, req.body) + if err != nil { + continue + } + var rep packagesReply + if json.Unmarshal(raw, &rep) != nil { + continue + } + out = append(out, rep.Accounts...) + } + return out +} + +// balanceRemain 明细自报的剩余额度:Cycle 期套餐优先,回退整包容量;Precise 形态优先。 +func (d packageDetail) balanceRemain() float64 { + prefer := func(precise, plain, preciseCycle, plainCycle flexFloat) float64 { + if preciseCycle > 0 || plainCycle > 0 { + if preciseCycle > 0 { + return float64(preciseCycle) + } + return float64(plainCycle) + } + if precise > 0 { + return float64(precise) + } + return float64(plain) + } + return prefer(d.CapacityRemainPrecise, d.CapacityRemain, + d.CycleCapacityRemainPrecise, d.CycleCapacityRemain) +} diff --git a/internal/upstream/cache_key.go b/internal/upstream/cache_key.go index 8fb4ea67..f7e07913 100644 --- a/internal/upstream/cache_key.go +++ b/internal/upstream/cache_key.go @@ -46,13 +46,24 @@ func InjectPromptCacheKey(body []byte, uid, conversationID string) []byte { } // 优先级 2:body 里的 conversation_id / conversationId 做会话哈希源。 conv := conversationID + bodyHasConv := false if v := strField(obj, "conversation_id"); v != "" { - conv = v + conv, bodyHasConv = v, true } else if v := strField(obj, "conversationId"); v != "" { - conv = v + conv, bodyHasConv = v, true } key := buildCacheKey(uid, conv) obj["prompt_cache_key"] = key + // 部分上游后端(hy4-preview-f / step-5-preview 等)按 **body 的 + // conversation_id** 分前缀缓存槽,并不认 prompt_cache_key:客户端 + // (zcode / Claude Code 系)不发 conversation_id 时,同一账号上所有 + // 会话塌进同一个缓存槽互相驱逐——实测整段未命中(0%)vs 补齐后 ~92% + // (issue #169 的 A/B 对照实验:只注入 prompt_cache_key 仍然 0%)。 + // 故在算出会话标识后同步补齐 body 级 conversation_id;客户端已自带 + // 时不覆盖(保持原语义)。 + if conv != "" && !bodyHasConv { + obj["conversation_id"] = conv + } out, err := json.Marshal(obj) if err != nil { return body diff --git a/internal/upstream/client.go b/internal/upstream/client.go index ed4137ba..2f688c88 100644 --- a/internal/upstream/client.go +++ b/internal/upstream/client.go @@ -12,6 +12,7 @@ import ( "log" "net/http" "net/url" + "os" "regexp" "sort" "strconv" @@ -37,10 +38,12 @@ const ( ErrContentBlocked // 内容策略拦截(400 + 审核文案)→ 不罚账号,走降级重试 ErrBadParams // 请求体解析失败(400 + Unmarshal chat params failed / 11101)→ 请求级错误:不罚号、不轮转,末端 400 透传原文 ErrAccountFault // 账号级授权/配额故障(11140 request illegal / 14017 trial not activated)→ 冷却轮换,不无限重试 + ErrChannelRefused // 11-128/11128 通道校验失败(请求形状没过官方渠道校验,账号本身健康)→ 不罚号、继续轮转 ErrModelBlocked // 11102「该后端无此模型」→ (账号,模型) 负缓存避让,切模型/切账号 ErrWafBlock // 403 + 非业务信封体(APISIX WAF 拦截页/空体)→ 账号软冷却 + 抖动退避 ErrPromptTooLong // 11115「prompt is too long」→ 请求级错误(上下文超限是请求的问题非账号的问题):不罚号、不轮转,末端透传原文 ErrImageInvalid // 图片请求格式/数据无效 → 请求级错误:不罚号、不轮转,末端透传原文 + ErrUpstreamBusy // 6021「queue.full.title」渠道级容量满载(非账号问题)→ 不罚号、不轮转,末端 503 + upstream_busy ErrClient // 其他 4xx / 业务错误 ) @@ -68,8 +71,12 @@ func (k ErrKind) String() string { return "prompt_too_long" case ErrImageInvalid: return "image_invalid" + case ErrUpstreamBusy: + return "upstream_busy" case ErrAccountFault: return "account_fault" + case ErrChannelRefused: + return "channel_refused" case ErrClient: return "client" default: @@ -122,25 +129,158 @@ var softRateMarkers = []string{ var sessionDeadMarkers = []string{"Offline user session not found", "12153"} +// isQueueFullBody 判定渠道级容量满载文案(6021 queue.full.title 家族)。 +// 该形态表明上游推理集群排队队列打满,与具体账号无关;换号在任何时刻都会撞 +// 同一堵墙,正确处置是不罚号不轮转(见 Classify 的 6021 分支)。子串口径但 +// 用完整固定文案("queue.full"),避免与普通限流文案误撞。 +func isQueueFullBody(lower string) bool { + return strings.Contains(lower, "queue.full") || strings.Contains(lower, "queue full") +} + // accountFaultMarkers 账号级授权/配额故障关键词(大小写不敏感子串匹配)。 // // 定位:这类错误是**账号本身状态**决定的本机故障,不是请求格式、不是临时限流、 // 也不是内容误报——继续重试只会反复刷上游风控/配额检查,必须把该账号冷却轮换。 -// - "request illegal"(code 11140)→ 上游 auth/auth_forbidden,账号级授权风控, -// 需重新 OAuth 登录才能恢复,短冷却只能阻止继续送死。 // - code 14017("trial not activated" / "The trial version is not yet activated")→ // 上游 quota/quota_not_activated,register 未完成的试用未激活账号,同样账号级。 // -// 注意 11140 **不能**按 code 判定:该 code 也承载模型级限流文案("The model provider -// is rate-limiting requests."),那种场景必须保持 ErrSoftRate(下方 softRateMarkers -// 后判定)。故此处只收 msg 关键词 "request illegal"(auth_forbidden 的真实文案)。 -// 14017 文案唯一(无软限流歧义),可安全收录。 +// 注意 **"request illegal" 不能作为通用 marker**:WorkBuddy 的 11-128/11128 通道 +// 校验失败与 11140 账号限制共用同一句 msg({"code":11128,"msg":"request illegal"} +// vs {"code":11140,"msg":"request illegal"}),displayTips 明确写「可能不是官方 +// 客户端发送」。只看文案会把一次通道校验失败误判成账号封禁并硬禁用健康账号 +// (issue #197 / #231 的用户侧症状:官方客户端能聊、面板里却被标成 banned)。 +// 11140 的判定统一走 IsAccountRestricted(code + 文案双条件),不进本词表。 +// 另:11140 **不能**按 code 单独判定——该 code 也承载模型级限流文案("The model +// provider is rate-limiting requests."),那种场景必须保持 ErrSoftRate(下方 +// softRateMarkers 后判定)。 var accountFaultMarkers = []string{ - "request illegal", "trial not activated", "trial version is not yet activated", } +// accountRestrictedMsg 是 WorkBuddy 11140 账号限制的正文关键词。 +const accountRestrictedMsg = "request illegal" + +// IsAccountRestricted 报告上游 body 是否为 WorkBuddy 的**账号级限制**(11140 +// "request illegal")。必须 code + 文案**双条件**: +// - 只看文案:"request illegal" 也出现在 11-128/11128 通道校验失败里(账号 +// 健康,issue #197),按文案一刀切会把可用账号误判成封禁并永久禁用; +// - 只看 code:11140 也承载模型级限流文案("The model provider is +// rate-limiting requests."),那种场景必须保持 ErrSoftRate。 +// +// 第三条排除项(限流文案):11140 的信封里 msg 与 displayMsg 是两个字段,上游 +// 可能给出 msg="request illegal" 而 displayMsg 带模型级限流说明。此时**不能** +// 永久禁用账号——那是模型侧限流,等几分钟自愈。命中**上游那一句固定限流句** +// 即放弃封禁判定,让分类链继续走到 softRateMarkers 归 ErrSoftRate。 +// +// 注意:排除项刻意**不**复用 softRateMarkers 整张词表(宽口径,含 "rate +// limit" / "too many" / "usage limit"),因为那些词同样出现在真封禁文案里 +// ("Too many login attempts…" / "Your usage limit has been reached."), +// 按宽词表否决会让真封禁账号永不禁用。 +func IsAccountRestricted(body string) bool { + lower := strings.ToLower(body) + if !strings.Contains(lower, accountRestrictedMsg) { + return false + } + if !hasBusinessCode(body, "11140") { + return false + } + // 模型级限流句优先:**只认上游那一句固定文案**(及其中文对应句)。上游的 + // 11140 信封里 msg 与 displayMsg 是两个字段,可能给出 msg="request illegal" + // 而 displayMsg 带模型级限流说明;那种场景是模型侧限流,等几分钟自愈, + // 不得永久禁用账号(重登对限流无效)。 + // + // 这里**不能**复用 softRateMarkers 整张词表(它含 "rate limit" / "too many" + // / "usage limit" / "限流" 等宽口径):那些词同样出现在真封禁文案里 + // ("Too many login attempts, please try again later." / + // "Your usage limit has been reached." / + // "This account was restricted. Rate limit abuse detected."), + // 一旦按宽词表否决,真封禁账号就**永不禁用**——它会一直带病服务,用户也 + // 无从申诉(面板只显示「可用」)。那比误封更糟。 + for _, m := range []string{ + "the model provider is rate-limiting requests", + "模型提供方正在限流", + "模型服务方正在限流", + } { + if strings.Contains(lower, m) { + return false + } + } + return true +} + +// IsChannelRefused 报告上游 body 是否为 WorkBuddy 的**通道校验失败** +// (业务码 11-128 / 11128)。这类错误的本质是**请求形状没过渠道校验**,账号 +// 本身健康:上游自己的 displayMsg 写的是「请求被拦截,请重新发送」、displayTips +// 写的是「这次调用没通过渠道校验,可能不是官方客户端发出的」。 +// +// 业务码是权威判据(11-128 与 11128 是同一码的两种书写:带连字符的字符串 +// "11-128" / 数字 11128)。文案只作**补充**,不参与否决——上游在同一个渠道 +// 校验业务码下有多个 msg 变体(见下),文案判据覆盖不全。 +// +// 已知 msg 变体(同一业务码,语义都是「请求形状」): +// - "request illegal"(issue #197 抓包实录) +// - "conversation id missing"(issue #188 实录:出站缺 X-Conversation-ID) +// - "first message is not system prompt"(issue #231 实录:首条消息非 system) +// - "Illegal API invocation from an unapproved channel"(issue #231 实录:首条 +// 消息是 developer) +// +// 11140 优先:真封禁不得被渠道文案降级(只有业务码能区分二者)。 +// +// 内容审核域的边界:真正的内容拦截("blocked by security policy")**不带** +// 11-128/11128 业务码;一旦带了该业务码,它就是渠道校验——哪怕文案里出现 +// "unapproved channel" / "illegal api invocation" 这两个与审核词表重叠的短语。 +// 早期实现按文案把 11-128 判成 ErrContentBlocked,后果是**触发全局降级门** +// (h.degrade.Trigger(),降级到次日 00:00 CST):一次形状不对的请求就让整个 +// 网关对所有账号、所有模型进入降级提示词模式,代价远大于问题本身。 +func IsChannelRefused(body string) bool { + if !hasBusinessCode(body, "11-128") && !hasBusinessCode(body, "11128") { + return false + } + if hasBusinessCode(body, "11140") { + return false + } + lower := strings.ToLower(body) + // 内容审核文案:只有**真正的内容拦截**才否决渠道校验判定。 + // + // contentBlockedMarkers 里的 "unapproved channel" / "illegal api invocation" + // 与渠道校验文案**逐字重叠**(2026-10-20 真机实录:首条 developer → + // 400 11-128 "Illegal API invocation from an unapproved channel"),但它是 + // **渠道校验**变体(请求形状),不是内容审核。按文案否决会把一次请求级 + // 形状问题变成 ErrContentBlocked → 触发全局降级门(h.degrade.Trigger(), + // 降级到次日 00:00 CST):整个网关对所有账号、所有模型进入降级提示词模式, + // 代价远大于问题本身。 + // + // 故此处只让真内容拦截("blocked by security policy")否决;它不带 + // 11-128/11128 业务码(实测录像是纯文案),故正常情况下根本走不到这里。 + if strings.Contains(lower, "blocked by security policy") { + return false + } + for _, m := range channelRefusedMarkers { + if strings.Contains(lower, m) { + return true + } + } + // 业务码兜底:11-128 本身就是渠道校验业务码。 + return true +} + +// channelRefusedMarkers 通道校验失败的文案特征(大小写不敏感)。 +// 全部取自上游 displayMsg / displayTips / msg 的固定文案,以及 issue #188 / #231 +// 实录的同码变体——每一组都说明「请求形状没过渠道校验」,是请求级而非账号级。 +var channelRefusedMarkers = []string{ + "request illegal", + "channel verification", + "not sent from an official client", + "please resend from the official client", + "this call failed channel verification", + "conversation id missing", + "first message is not system prompt", + "请求被拦截,请重新发送", + "没通过渠道校验", + "不是官方客户端发出的", +} + // contentBlockedMarkers 内容策略拦截关键词(大小写不敏感子串匹配)。 // // 定位:上游按逐字精确指纹审核,system 来源的模板句(如 Claude Code/Codex @@ -240,40 +380,73 @@ func IsModelRateLimit(body string) bool { // modelBlockCode 明确指向「该后端无此模型」的业务 code。 const modelBlockCode = "11102" +// modelForbiddenCode 明确指向「该账号类型无权使用该模型」的业务 code。 +// 同一模型在不同账号类型上权限不同,因此与 11102 同属 (账号, 模型) 级 +// 负缓存:换有权限的账号仍可能成功,全部账号都拒绝时再向客户端报模型不可用。 +const modelForbiddenCode = "11158" + // modelBlockMsgMarker 11102 答复的确定性文案(官方 error message 固定短语)。 // 只收这个窄短语,不收 "model ... not found" 宽正则——后者会误伤其他业务的 // not found 措辞。 const modelBlockMsgMarker = "service info not found" +const modelForbiddenMsgMarker = "is not allowed for user type" + // ModelBlockReason 11102 负缓存条目在 pool.modelCooldowns 里的 reason 前缀。 // handler 写 BlockModelBackoff;pool.BlockModelClear 按 "11102" 前缀识别条目 // (与 6004 条目的 "6004 model rate limit" reason 互不干扰)。 const ModelBlockReason = "11102 model not available" -// IsModelBlocked 报告 body 是否是「该后端无此模型」(11102) 的确定性答复。 +// ModelForbiddenReason 11158 负缓存条目的 reason 前缀。 +// pool.BlockModelClear 同时识别 11102/11158 两种确定性的模型级拒绝。 +const ModelForbiddenReason = "11158 model not allowed for account type" + +// IsModelBlocked 报告 body 是否是「该后端无此模型」(11102) 或「该账号类型无权 +// 使用该模型」(11158) 的确定性答复。 // // 只比对 code/msg 等独立字段,绝不做整段文本子串匹配:错误体还带 requestId 等字段, -// 拿整段文本匹配会把 "11102" 撞在 ID 上、误避让一个本来能用的模型。判定 = -// code 字段精确等于 "11102",或 msg/message 字段命中窄短语 "service info not -// found"(两者任一命中即真)。只看 400/404:429 带 11102 属限流语义。 +// 拿整段文本匹配会把 code 撞在 ID 上、误避让一个本来能用的模型。判定 = +// code 字段精确等于 "11102"/"11158",或 msg/message 字段命中对应窄短语。 +// 只看 400/404:429 带这些 code 仍属限流语义。 // 字段遍历覆盖顶层与 error 子对象两层。 func IsModelBlocked(status int, body string) bool { if (status != http.StatusBadRequest && status != http.StatusNotFound) || body == "" { return false } - // 轻量预检:body 既无 "11102" 又无 marker 时直接短路(大多数 4xx 零分配返回)。 - if !strings.Contains(body, modelBlockCode) && !strings.Contains(strings.ToLower(body), modelBlockMsgMarker) { + lower := strings.ToLower(body) + // 轻量预检:body 无任何已知 code/marker 时直接短路(大多数 4xx 零分配返回)。 + if !strings.Contains(body, modelBlockCode) && !strings.Contains(body, modelForbiddenCode) && + !strings.Contains(lower, modelBlockMsgMarker) && !strings.Contains(lower, modelForbiddenMsgMarker) { return false } + code, msg := modelBlockFields(body) + if code == modelBlockCode || code == modelForbiddenCode { + return true + } + lowerMsg := strings.ToLower(msg) + return strings.Contains(lowerMsg, modelBlockMsgMarker) || strings.Contains(lowerMsg, modelForbiddenMsgMarker) +} + +// ModelBlockReasonFor 返回确定性模型拒绝对应的负缓存 reason。 +// 11158 是账号类型权限问题;其余已由 IsModelBlocked 确认的形态沿用 11102。 +func ModelBlockReasonFor(body string) string { + code, msg := modelBlockFields(body) + if code == modelForbiddenCode || strings.Contains(strings.ToLower(msg), modelForbiddenMsgMarker) { + return ModelForbiddenReason + } + return ModelBlockReason +} + +// modelBlockFields 提取顶层或 error 子对象中的业务 code 与消息。 +func modelBlockFields(body string) (code, msg string) { var root map[string]any if err := json.Unmarshal([]byte(body), &root); err != nil { - return false + return "", "" } nodes := []map[string]any{root} if inner, ok := root["error"].(map[string]any); ok { nodes = append(nodes, inner) } - code, msg := "", "" for _, node := range nodes { for _, key := range []string{"code", "errCode", "error_code"} { if v, ok := node[key]; ok && v != nil && code == "" { @@ -286,10 +459,7 @@ func IsModelBlocked(status int, body string) bool { } } } - if code == modelBlockCode { - return true - } - return strings.Contains(strings.ToLower(msg), modelBlockMsgMarker) + return code, msg } // hasBusinessCode reports whether a JSON error envelope contains an exact @@ -463,29 +633,35 @@ func ParseRateReset(body string) (time.Time, bool) { // "rate limit"(如网关错误页混排),归 session_dead:短冷却救不活失效 session, // 误判为限流会让该死号留在池中反复被选中;且此层 marker 是精确词(12153 等), // 比限流层的大范围子串更具体,具体优先于宽泛。 -// 3. accountFaultMarkers —— 账号级授权/配额故障(11140 request illegal auth 风控、 -// 14017 trial not activated register 未完成)。必须先于 status==429 判定: -// 14017 常带 429 状态码,若落到 status==429 会误归 soft_rate("限流"语义不符: -// 限流可指数退避等自愈,账号级故障等不来)。11140 的 model 级限流变体 -// (rate-limiting 文案)因 marker 不含该文案而天然落到 softRateMarkers 层, -// 不受影响。 +// 3. accountFaultMarkers —— 账号级授权/配额故障(14017 trial not activated +// register 未完成)。必须先于 status==429 判定:14017 常带 429 状态码,若落 +// status==429 会误归 soft_rate("限流"语义不符:限流可指数退避等自愈,账号级 +// 故障等不来)。11140 的 model 级限流变体(rate-limiting 文案)因 +// IsAccountRestricted 要求 "request illegal" 文案而不命中,天然落到 +// softRateMarkers 层,不受影响。 +// 同一层先按业务码分流 11-128/11128 通道校验失败(文案同为 "request illegal"): +// 那是请求指纹/通道问题,账号本身健康,归 ErrChannelRefused 不罚号。 // 4. 429 + code 14018 —— 明确的账号积分耗尽,归 ErrHardCredit(issue #175)。 // 只认结构化业务码,不靠可能跨计费/限流两界的文案猜测。 -// 5. status==429 —— 限流状态码兜底(先于 hardMarkers):429 body 高频携带 +// 5. 429 + 6021 queue.full —— 渠道级容量满载(上游集群排队打满),归 +// ErrUpstreamBusy:非账号问题,不罚号不轮转(2026-10-15 实案:按限流处置 +// 2 秒绞杀整池 DeepSeek 号)。判在 status==429 兜底**之前**——它是比 +// 「限流」更具体的渠道级信号(同 14018 的具体优先哲学)。 +// 6. status==429 —— 限流状态码兜底(先于 hardMarkers):429 body 高频携带 // "quota exceeded"/"额度不足" 等跨计费/限流两界的措辞,hardMarkers 先判会把 // 限流误归 ErrHardCredit 硬冷却到次日 04:00,白扔号约 12h。状态码是比关键词 // 更权威的信号;真正的余额耗尽由 402(第 1 层)或 14018(第 4 层)捕获, -// 非 429 状态码的 quota 措辞仍走下方 hardMarkers(第 6 层)。 -// 6. hardMarkers —— 非 429 响应携带计费关键词(200 业务信封 / 403 信封等)。 -// 7. softRateMarkers —— 非 429 状态码携带限流文案(issue #28 修复点)。 +// 非 429 状态码的 quota 措辞仍走下方 hardMarkers(第 7 层)。 +// 7. hardMarkers —— 非 429 响应携带计费关键词(200 业务信封 / 403 信封等)。 +// 8. softRateMarkers —— 非 429 状态码携带限流文案(issue #28 修复点)。 // 位于此处可覆盖 200/400/403/5xx 各状态码。 -// 8. 11115 —— 「prompt is too long」请求级语义:判在 404/5xx 与通用 4xx 兜底 +// 9. 11115 —— 「prompt is too long」请求级语义:判在 404/5xx 与通用 4xx 兜底 // 之前(404 上打 11115 若落 ErrNotFound 会误冷却账号——上下文超限与账号无关)。 -// 9. 404 / 5xx —— 与限流无关的常规分类。 -// 10. IsWafBlocked —— 403 且无业务信封(HTML 拦截页/空体/纯文本):APISIX WAF +// 10. 404 / 5xx —— 与限流无关的常规分类。 +// 11. IsWafBlocked —— 403 且无业务信封(HTML 拦截页/空体/纯文本):APISIX WAF // 拦截形态。判在通用 4xx 兜底**之前**:此前该形态落 ErrClient → 只换号不罚 → // 连环 403。带业务信封的 403 已被上方各层捕获,走不到本层。 -// 11. 内容策略/参数错误/其他 4xx —— 通用兜底。 +// 12. 内容策略/参数错误/其他 4xx —— 通用兜底。 func Classify(status int, body string) ErrKind { // 11102「该后端无此模型」须最先判:它是「模型在后端不存在」的确定性答复,语义比 // 计费/限流都更具体——若不先判,msg 里的 "service info not found" 会被更宽的 @@ -507,6 +683,23 @@ func Classify(status int, body string) ErrKind { return ErrSessionDead } } + // WorkBuddy 11-128 / 11128 与 11140 的 msg 同为 "request illegal"(两种形态 + // 共用同一句 msg、同一套信封字段),必须按**结构化业务码**分流: + // - 11-128 / 11128 = 通道校验失败(displayTips: 可能不是官方客户端发送) + // → 请求形状没过渠道校验,账号本身健康(官方客户端可正常使用同一账号), + // 归 ErrChannelRefused 不罚号。误判成 account_fault 会硬禁用健康账号 + // (issue #197 / #231 的用户侧症状:官方客户端能聊、面板里却标成 banned)。 + // - 11140 = 账号被上游限制(异常行为/风控)→ ErrAccountFault,由 handler 禁用。 + // 判据分工:通道校验是 **code 优先**(文案只作补充,不参与否决——同一个 + // 业务码下上游有多个 msg 变体,见 IsChannelRefused 注释);账号封禁是 + // **code + 文案双条件**(文案 "request illegal" 与通道校验共用,必须靠 + // 11140 区分;且要排除 11140 的模型级限流变体,见 IsAccountRestricted 注释)。 + if IsChannelRefused(body) { + return ErrChannelRefused + } + if IsAccountRestricted(body) { + return ErrAccountFault + } for _, m := range accountFaultMarkers { if strings.Contains(lower, strings.ToLower(m)) || strings.Contains(body, m) { return ErrAccountFault @@ -518,6 +711,15 @@ func Classify(status int, body string) ErrKind { if status == http.StatusTooManyRequests && hasBusinessCode(body, "14018") { return ErrHardCredit } + // 6021「queue.full.title」是**渠道级容量满载**(上游推理集群排队队列打满), + // 不是账号问题:同一时刻换任何账号都会撞同一堵墙。必须先于通用 429 兜底, + // 否则会按 ErrSoftRate 逐号软冷却——实测 2 秒内即可把整池健康号全部误杀 + // 冷却(2026-10-15 实案),且换号白撞放大请求量。429 + 结构化 code 为主判据 + // (与 14018 同口径);"queue.full" 固定文案单列为外卡——该文案是上游 i18n + // key 原文,语义唯一,非 429 状态码出现同样归本类(防上游换状态码)。 + if (status == http.StatusTooManyRequests && hasBusinessCode(body, "6021")) || isQueueFullBody(lower) { + return ErrUpstreamBusy + } // status==429 先于 hardMarkers:限流响应 body 高频携带 "quota exceeded"/ // "额度不足" 等跨计费/限流两界的措辞,hardMarkers 先判会把限流误归 // ErrHardCredit 硬冷却到次日 04:00,白扔号约 12h。状态码是比关键词更权威的 @@ -681,6 +883,13 @@ type Client struct { // false 时即便用户 auth 写了 realm=global 也**不**路由到 global base—— // chatBase/billingBase 返回 CN base,路径也走 CN(双保险,与 auth.Realm() 的开关闸呼应)。 GlobalEnabled bool + + // refreshMu/refreshInFlight 同账号 refresh 单飞(按 UID 分桶):同一时刻只允许 + // 一个 refresh 在途,其余调用等待并复用首个结果。上游对 refreshToken 反重放, + // 并发第二发会带着已被消费的 RT 撞 12153 并整链作废(issue #215:账号从此恒 + // 401,只能人工重登)。单飞在这里是保号手段,不只是省流量。 + refreshMu sync.Mutex + refreshInFlight map[string]*refreshFlight } // New 生产默认值。Transport 由 newTransport() 集中构造(连接层加固:真正禁 h2 / @@ -901,13 +1110,92 @@ const refreshTokenExpiresInMax = 10 * 365 * 24 * time.Hour // RefreshToken 刷新 access token;成功时更新 a 的字段(缺省值保留旧值), // 调用方负责 SaveAtomic。 // -// 并发安全模型(两段式,缩小持锁窗口): -// - 锁内仅做「读 refreshToken 快照」与「校验未变后写回新 token」两小段内存操作; -// - 网络 I/O(doJSON)在**锁外**执行,带 30s ctx 超时——避免上游 hang 时长时间 -// 独占 a.mu,阻塞同账号的 SaveAtomic / 其他刷新(issue:持锁 120s I/O)。 -// - 写回前重新校验快照一致性:若锁外期间另一 goroutine 已完成刷新(refreshToken -// 已变),本次结果直接采用(新 token 已生效),不再重复写回。 +// 并发安全模型(单飞 + 两段式): +// - **单飞**:同一账号(UID)同一时刻只允许一个 refresh 在途,其余调用等待并 +// 原样复用首个结果。上游对 refreshToken 有反重放:RT 被消费后再用会撞 12153 +// (refresh token is invalid)并把整条 RT 链作废,账号从此恒 401、只能人工 +// 重新登录(issue #215:scheduler keepalive / handler 临近过期刷新 / signin +// 会并发触发同一账号)。所以单飞在这里是保号手段,而不只是省流量。 +// - 首个调用内部仍是两段式:锁内读快照 → 锁外 I/O(30s)→ 锁内校验快照一致后 +// 写回;写回前若发现另一刷新已完成(两 token 同时变化)则直接采用。 +// - 12153 兜底:若磁盘 auth 文件已被其他进程(server + signin 双进程部署)刷新 +// 过,则采纳磁盘 token 重试一次——进程内单飞挡不住跨进程竞态。 func (c *Client) RefreshToken(a *auth.Auth) error { + key := a.UID + if key == "" { + // 无 UID 的临时 auth:退化为按对象指针去重,仍保证同一对象串行。 + key = fmt.Sprintf("%p", a) + } + c.refreshMu.Lock() + if c.refreshInFlight == nil { + c.refreshInFlight = make(map[string]*refreshFlight) + } + if f, ok := c.refreshInFlight[key]; ok { + c.refreshMu.Unlock() + <-f.done + return f.err + } + f := &refreshFlight{done: make(chan struct{})} + c.refreshInFlight[key] = f + c.refreshMu.Unlock() + + err := c.refreshTokenOnce(a) + if err != nil && isSessionDead(err) && adoptNewerTokensFromDisk(a) { + // 跨进程竞态:磁盘 RT 已被别的进程 rotate 过,用新 RT 再试一次。 + err = c.refreshTokenOnce(a) + } + + // 先写结果再关 channel:等待者 <-f.done 之后读到的一定是最终 err。 + f.err = err + close(f.done) + c.refreshMu.Lock() + delete(c.refreshInFlight, key) + c.refreshMu.Unlock() + return err +} + +// refreshFlight 是同一账号一次 refresh 的单飞票据:done 关闭即结果就绪。 +type refreshFlight struct { + done chan struct{} + err error +} + +// isSessionDead 判定错误是否为上游 12153(会话 / RT 失效)。 +func isSessionDead(err error) bool { + var ue *Error + return errors.As(err, &ue) && ue.Kind == ErrSessionDead +} + +// adoptNewerTokensFromDisk 在 refresh 失败后读回 auth 文件:若磁盘 RT 与内存不同 +// (另一进程已刷新并写回),采纳磁盘 token 并返回 true,供调用方重试一次。 +// 读文件失败、解析失败、RT 一致均返回 false(不改内存状态),保持失败语义。 +func adoptNewerTokensFromDisk(a *auth.Auth) bool { + if a.FilePath == "" { + return false + } + raw, err := os.ReadFile(a.FilePath) + if err != nil { + return false + } + disk, err := auth.Parse(raw) + if err != nil || strings.TrimSpace(disk.RefreshToken) == "" { + return false + } + a.Lock() + defer a.Unlock() + if disk.RefreshToken == a.RefreshToken { + return false + } + a.AccessToken = disk.AccessToken + a.RefreshToken = disk.RefreshToken + if disk.ExpiresAt > 0 { + a.ExpiresAt = disk.ExpiresAt + } + return true +} + +// refreshTokenOnce 是单次刷新本体(调用方 RefreshToken 已保证同账号串行)。 +func (c *Client) refreshTokenOnce(a *auth.Auth) error { // 第 1 段(锁内):读快照。 a.Lock() rtSnapshot := a.RefreshToken @@ -1209,24 +1497,14 @@ func nonChatModel(id string, maxOutputTokens int64, tags []string) bool { return false } -// codeBuddyIDEUA /v3/config 要求能解析出 CodeBuddy 版本号的 UA。 -// CLI 三段式 WorkBuddy UA 会拿到精简目录(flash 输出 128K、无 supportedEfforts); -// 官方 IDE 头 `CodeBuddyIDE/4.12.0 CodeBuddy/4.12.0` 才返回完整能力 -// (flash:393216 + low/high/max)。 -// 版本号需随上游 IDE 发版跟进:UAn 版本过旧时该端点可能同样返回精简目录。 -const codeBuddyIDEUA = "CodeBuddyIDE/4.12.0 CodeBuddy/4.12.0" - -// codeBuddyCLIUA CLI 三段式 UA。**实测(2026-09-22)该端点对不同 UA 下发的模型集合不同**: -// - IDE UA → 14 条(10 个 chat:含 o4-mini / enhance-1.0 / auto-chat,**无 deepseek 系列**) -// - CLI UA → 22 条(22 个 chat:**含 deepseek-v4.1-flash / deepseek-v4.1-flash-sg / -// gpt-6-astra / kimi-k2.8-preview**,但无 o4-mini / enhance-1.0 / auto-chat) +// v3/config 官方客户端 UA 见 headers.go 的 v3ProbeUA / v3ProbeUALite。 // -// 注意两点,都与旧注释相反,勿再按旧注释推断: -// 1. 旧注释称「CLI UA 拿到精简目录、IDE UA 才返回完整能力」——实测模型数量恰好相反, -// 但 **IDE 响应体积更大**(26003B vs 21111B),故「完整能力」应理解为**单条字段更全**, -// 而非模型更多。两路各有独有模型,缺一不可。 -// 2. 该常量仅用于 global 侧第二路探测;CN 侧仍走 codeBuddyIDEUA 单路。 -const codeBuddyCLIUA = "CLI/2.63.2 CodeBuddy/2.63.2" +// 历史注记:本文件曾用 `CodeBuddyIDE/4.12.0 CodeBuddy/4.12.0` 与 +// `CLI/2.63.2 CodeBuddy/2.63.2` 两套 CodeBuddy 家族常量探测该端点,理由是 +// 「该端点对不同 UA 下发不同模型集合」。结论(按 UA 家族分发不同目录)仍然成立, +// 但 **UA 取值本身是错的**:2026-10-20 双版本 WorkBuddy 真机抓包显示,该端点收到 +// 的 UA 只有 `WorkBuddy/<ver> [<brand>/<ver>] CLI/2.161.0` 这两种形态,分发包里 +// 不存在 CodeBuddy 字面量。保留双路探测以维持目录覆盖,但两条都用官方形态。 // FetchModels 调上游动态模型接口(CN 侧;global 账号见 global_models.go 家族)。 // @@ -1392,7 +1670,7 @@ func (c *Client) fetchEnterpriseModels(a *auth.Auth) ([]ModelInfo, error) { // nonChatModel 规则剔除非对话条目(selected 会选模型报 code=11102)。 // 失败返回错误(调用方降级为仅企业端点)。 func (c *Client) fetchV3Models(a *auth.Auth) ([]ModelInfo, error) { - byID, err := c.fetchV3ConfigModelMap(a, codeBuddyIDEUA) + byID, err := c.fetchV3ConfigModelMap(a, c.v3ProbeUA(a)) if err != nil { return nil, err } @@ -1676,7 +1954,7 @@ func (c *Client) fetchV3ConfigModelMap(a *auth.Auth, ua string) (map[string]Mode req.Header.Set("X-Domain", v3ConfigDomain(a, c.chatBase(a))) req.Header.Set("X-Product", "SaaS") if ua == "" { - ua = codeBuddyIDEUA + ua = c.v3ProbeUA(a) } req.Header.Set("User-Agent", ua) c.injectCodeBuddyRequest(req) @@ -1796,6 +2074,33 @@ type CreditPackage struct { Cycle bool `json:"cycle,omitempty"` } +// ExpiryBucket 单个自然日到期的积分合计(按真实失效时刻 DeductionEndTime 聚合)。 +// 面板首页「积分到期提醒」快照与「检查」(逐包明细)共用同一口径: +// - Date 为该日(UTC+8,YYYY-MM-DD),前端按这个日期分组展示; +// - ExpireAt 是该日最早一批的精确时刻,供「最早到期优先」选号路由使用; +// - Remain 是该日所有正余额包的剩余量之和。 +type ExpiryBucket struct { + Date string `json:"date"` + ExpireAt time.Time `json:"expires_at,omitempty"` + Remain int64 `json:"remain"` +} + +// packageEffectiveEnd 返回套餐的真实失效时刻:DeductionEndTime(可抵扣窗口结束, +// epoch 毫秒)优先,缺失依次回落 ExpiredTime / PackageEndTime / CycleEndTime。 +// 与 CreditPackages 的字段优先级必须一致——快照与逐包明细两条路径口径分叉过一次 +// (快照把体验版的周期重置日 10-31 当成到期日,实际 DeductionEndTime 在 2035 年)。 +func packageEffectiveEnd(deductionEndMS int64, expired, packageEnd, cycleEnd string) (time.Time, bool) { + if deductionEndMS > 0 { + return time.UnixMilli(deductionEndMS).In(softRateResetLoc), true + } + for _, raw := range []string{expired, packageEnd, cycleEnd} { + if t, ok := parsePackageEndTime(raw); ok { + return t, true + } + } + return time.Time{}, false +} + // CreditPackages 返回账号当前的逐包构成。remain/size 为各包求和。 // // 字段选择与 UserResourceDetailed 的聚合口径一致:CycleCapacitySize > 0 时按 @@ -1862,25 +2167,19 @@ func (c *Client) CreditPackages(a *auth.Auth) ([]CreditPackage, int64, int64, er SubProductCode: p.SubProductCode, SubProductName: p.SubProductName, } + // EndTime 先给一个可读回落值(解析失败时前端仍可按字符串取日期); + // 真实失效时刻统一由 packageEffectiveEnd 判定,与快照路径同口径。 switch { - case p.DeductionEndTime > 0: - // 真失效时刻(可抵扣窗口结束),语义见上方字段注释:判「用不完就没了」 - // 用它而不是周期边界。epoch 毫秒 → RFC3339,与 CycleEndTime 字符串口径 - // 共存(前端统一 slice(0,10) 取日期)。ExpiresAt 直接用原始毫秒—— - // RFC3339 不是 packageEndLayout 形态,交给下方解析会静默失败得 0。 - cp.EndTime = time.UnixMilli(p.DeductionEndTime).Format(time.RFC3339) - cp.ExpiresAt = p.DeductionEndTime case p.ExpiredTime != "": cp.EndTime = p.ExpiredTime case p.PackageEndTime != "": cp.EndTime = p.PackageEndTime - default: + case p.CycleEndTime != "": cp.EndTime = p.CycleEndTime } - if cp.ExpiresAt == 0 && cp.EndTime != "" { - if end, perr := time.ParseInLocation(packageEndLayout, cp.EndTime, softRateResetLoc); perr == nil { - cp.ExpiresAt = end.UnixMilli() - } + if end, ok := packageEffectiveEnd(p.DeductionEndTime, p.ExpiredTime, p.PackageEndTime, p.CycleEndTime); ok { + cp.EndTime = end.Format(time.RFC3339) + cp.ExpiresAt = end.UnixMilli() } // CreateTime 是 epoch 毫秒;0 表示上游没给,留空而不是伪造 1970。 if p.CreateTime > 0 { @@ -2046,13 +2345,47 @@ func (c *Client) enterpriseResource(a *auth.Auth, soon time.Duration) (remain, t } // UserResourceDetailedWithExpiry 在 UserResourceDetailed 基础上返回最早未来到期批次: -// earliestAt 是最早的可用到期时刻,earliestRemaining 是同一时刻所有正余额包的剩余量之和。 -// 已过期、剩余为 0、缺少或无法解析到期时间的包都不会成为最早批次;无有效批次时返回零值。 +// earliestAt 是最早的可用到期时刻,earliestRemaining 是该自然日全部正余额包的剩余量 +// 之和(与面板「检查」按日汇总的口径一致)。已过期、剩余为 0、缺少或无法解析到期时间 +// 的包都不会成为最早批次;无有效批次时返回零值。 func (c *Client) UserResourceDetailedWithExpiry(a *auth.Auth, soon time.Duration) (remain, total, expiring int64, earliestAt time.Time, earliestRemaining int64, err error) { - // 企业版额度不在「个人资源包」体系内:get-user-resource* 对 enterpriseId 非空账号 - // 恒返回空 Accounts(实测 code 0 且 Accounts null),故分流到企业口径端点。 - if a.IsEnterprise() { - return c.enterpriseResource(a, soon) + remain, total, expiring, earliestAt, earliestRemaining, _, err = c.UserResourceSnapshot(a, soon) + return +} + +// UserResourceSnapshot 在 UserResourceDetailedWithExpiry 基础上额外返回按自然日聚合的 +// 到期批次(buckets)。日期/剩余量口径与面板「检查」(CreditPackages 前端按日汇总) +// 完全一致,首页快照据此可零上游请求渲染出同样的内容。 +// +// 关键:到期时刻统一走 packageEffectiveEnd(DeductionEndTime 优先)。此前本函数只读 +// CycleEndTime,把「个人体验版」的月度周期重置日(如 2026-10-31)当成了到期日,而它 +// 真实的可抵扣窗口到 2034/2035 年——首页快照因此长期错报最近到期时间与数量。 +func (c *Client) UserResourceSnapshot(a *auth.Auth, soon time.Duration) (remain, total, expiring int64, earliestAt time.Time, earliestRemaining int64, buckets []ExpiryBucket, err error) { + // 企业版与静态 API Key 额度都不在「个人资源包」体系内:get-user-resource* 对 + // 二者恒返回空 Accounts(企业号有 enterpriseId;静态 Key 无个人账号身份), + // 故统一分流到企业口径端点(见 auth.UsesEnterpriseBilling)。 + if a.UsesEnterpriseBilling() { + remain, total, expiring, earliestAt, earliestRemaining, err = c.enterpriseResource(a, soon) + if err == nil && earliestRemaining > 0 && !earliestAt.IsZero() && earliestAt.After(time.Now()) { + buckets = []ExpiryBucket{{ + Date: earliestAt.In(softRateResetLoc).Format("2006-01-02"), + ExpireAt: earliestAt, + Remain: earliestRemaining, + }} + } + return + } + // global realm 个人号:官方 5.8.4 国际版已改口径,走 summary + paid/free 明细 + // (见 billing_summary.go)。继续用下面 CN 那套分页 body 打国际站,拿回的形态 + // 与 CN 不同(无 Accounts 树、容量是字符串小数),会解出恒 0 余额 → 积分保底 + // 把可用号当耗尽号挡出选号。summary 失败才回落到 CN 路径(老路径兼容尝试)。 + if a.IsGlobal() { + rm, tt, ex, ea, er, bk, gerr := c.globalUserResource(a, soon) + if gerr == nil { + return rm, tt, ex, ea, er, bk, nil + } + log.Printf("WARN: billing %s: global summary 失败,回落个人资源包路径: %v", + logfmt.Label(a.UID, a.Nickname), gerr) } now := time.Now() body := map[string]any{ @@ -2072,7 +2405,7 @@ func (c *Client) UserResourceDetailedWithExpiry(a *auth.Auth, soon time.Duration return e }) if err != nil { - return 0, 0, 0, time.Time{}, 0, err + return 0, 0, 0, time.Time{}, 0, nil, err } var resp struct { Response struct { @@ -2080,6 +2413,9 @@ func (c *Client) UserResourceDetailedWithExpiry(a *auth.Auth, soon time.Duration Accounts []struct { PackageName string `json:"PackageName"` CycleEndTime string `json:"CycleEndTime"` // "2006-01-02 15:04:05",缺省/空 = 无到期 + ExpiredTime string `json:"ExpiredTime"` + PackageEndTime string `json:"PackageEndTime"` + DeductionEndTime int64 `json:"DeductionEndTime"` // 可抵扣窗口结束(epoch 毫秒),真失效时刻 CapacitySize int64 `json:"CapacitySize"` CapacityRemain int64 `json:"CapacityRemain"` CapacityUsed int64 `json:"CapacityUsed"` @@ -2091,8 +2427,9 @@ func (c *Client) UserResourceDetailedWithExpiry(a *auth.Auth, soon time.Duration } `json:"Response"` } if err := json.Unmarshal(data, &resp); err != nil { - return 0, 0, 0, time.Time{}, 0, fmt.Errorf("resource parse: %w", err) + return 0, 0, 0, time.Time{}, 0, nil, fmt.Errorf("resource parse: %w", err) } + byDate := make(map[string]*ExpiryBucket) for _, acct := range resp.Response.Data.Accounts { r, _, size := packageRemainUsed(respAccount{ CapacityRemain: acct.CapacityRemain, @@ -2113,22 +2450,42 @@ func (c *Client) UserResourceDetailedWithExpiry(a *auth.Auth, soon time.Duration if r <= 0 { continue } - end, ok := parsePackageEndTime(acct.CycleEndTime) + end, ok := packageEffectiveEnd(acct.DeductionEndTime, acct.ExpiredTime, acct.PackageEndTime, acct.CycleEndTime) if !ok || !end.After(now) { continue } if earliestAt.IsZero() || end.Before(earliestAt) { earliestAt = end - earliestRemaining = r - } else if end.Equal(earliestAt) { - earliestRemaining += r } + // 按自然日(UTC+8)聚合:与 CreditPackages → 前端 end_time.slice(0,10) 同口径。 + day := end.In(softRateResetLoc).Format("2006-01-02") + b, seen := byDate[day] + if !seen { + b = &ExpiryBucket{Date: day, ExpireAt: end} + byDate[day] = b + } else if end.Before(b.ExpireAt) { + b.ExpireAt = end + } + b.Remain += r // 分桶:仅 soon>0 且确实在窗口内 → expiring。 if soon > 0 && !end.After(now.Add(soon)) { expiring += r } } - return remain, total, expiring, earliestAt, earliestRemaining, nil + dates := make([]string, 0, len(byDate)) + for d := range byDate { + dates = append(dates, d) + } + sort.Strings(dates) + buckets = make([]ExpiryBucket, 0, len(dates)) + for _, d := range dates { + buckets = append(buckets, *byDate[d]) + } + // earliestRemaining 是该自然日全部正余额包的合计(不是单个包的剩余量)。 + if len(buckets) > 0 { + earliestRemaining = buckets[0].Remain + } + return remain, total, expiring, earliestAt, earliestRemaining, buckets, nil } // respAccount 供 packageRemainUsed 解析的套餐字段(CreditPackages 的逐包结构同构)。 diff --git a/internal/upstream/client_test.go b/internal/upstream/client_test.go index 0d67bd7b..de2d7b30 100644 --- a/internal/upstream/client_test.go +++ b/internal/upstream/client_test.go @@ -71,6 +71,11 @@ func TestClassify(t *testing.T) { {404, `{"code":11102,"msg":"model [deepseek-v3-2-volc] service info not found"}`, ErrModelBlocked}, {400, `{"error":{"code":"11102","message":"model service info not found"}}`, ErrModelBlocked}, {400, `{"msg":"service info not found"}`, ErrModelBlocked}, + // 11158 是账号类型级模型权限拒绝:同一模型换有权限的账号仍可能成功, + // 按 (账号, 模型) 避让;不能落入 ErrClient 轮转后误报 503。 + {400, `{"code":11158,"msg":"model [gpt-5.6-sol] is not allowed for user type [personal_free_user]"}`, ErrModelBlocked}, + {400, `{"error":{"code":"11158","message":"model is not allowed for user type"}}`, ErrModelBlocked}, + {429, `{"code":11158,"msg":"model is not allowed for user type"}`, ErrSoftRate}, // 11102 撞在 requestId 上不算(不得误避让可用模型)。 {404, `{"requestId":"11102","msg":"ok"}`, ErrNotFound}, // 429 + 11102 → 限流语义(ErrSoftRate),不是模型不存在。 @@ -95,6 +100,18 @@ func TestClassify(t *testing.T) { {429, `{"error":{"data":{"code":"14018","msg":"Credits exhausted"}}}`, ErrHardCredit}, {429, `{"requestId":"14018","msg":"Credits exhausted"}`, ErrSoftRate}, {429, `{"code":1,"msg":"Credits exhausted"}`, ErrSoftRate}, + // 6021「queue.full.title」渠道级容量满载(2026-10-15 实案):不是账号问题, + // 必须归 ErrUpstreamBusy(不罚号不轮转)——按 ErrSoftRate 逐号软冷却会在 + // 秒级把整池健康号误杀。结构化 code 与固定文案双判据。 + {429, `{"code":6021,"msg":"queue.full.title","requestId":"b1bc87ea3a771e835e388328713d47cf"}`, ErrUpstreamBusy}, + {429, `{"error":{"data":{"code":6021,"msg":"queue.full.title"}}}`, ErrUpstreamBusy}, + {429, `queue.full.title`, ErrUpstreamBusy}, + {429, `{"code":6021,"msg":"queue full"}`, ErrUpstreamBusy}, + // 文案外卡不绑 429(上游若换状态码仍须识别为渠道满载,不得落 hardMarkers 族)。 + {500, `{"code":6021,"msg":"queue.full.title"}`, ErrUpstreamBusy}, + // 防过宽:普通限流文案不带 queue.full,仍走软限流。 + {429, `{"code":1,"msg":"too many requests"}`, ErrSoftRate}, + {429, `{"requestId":"6021","msg":"rate limit"}`, ErrSoftRate}, // WAF 403(P0-1):403 + 无业务信封(无 "code":/"msg": 字段)→ ErrWafBlock。 // 空体 / HTML 拦截页 / 纯文本 / 非信封 JSON 均命中。 {403, ``, ErrWafBlock}, @@ -605,8 +622,8 @@ func TestFetchModelsOverlaysV3ConfigCapabilities(t *testing.T) { ],"agents":[{"name":"cli","models":["deepseek-v4.1-flash"]}]}}`), nil case strings.HasSuffix(r.URL.Path, "/v3/config"): sawIDE = true - if r.Header.Get("User-Agent") != codeBuddyIDEUA { - t.Errorf("v3/config UA=%q want %s", r.Header.Get("User-Agent"), codeBuddyIDEUA) + if r.Header.Get("User-Agent") != v3ProbeUACNForTest { + t.Errorf("v3/config UA=%q want %s", r.Header.Get("User-Agent"), v3ProbeUACNForTest) } if r.Header.Get("X-Product") != "SaaS" { t.Errorf("X-Product=%q want SaaS", r.Header.Get("X-Product")) diff --git a/internal/upstream/compensation.go b/internal/upstream/compensation.go new file mode 100644 index 00000000..72e6af1b --- /dev/null +++ b/internal/upstream/compensation.go @@ -0,0 +1,70 @@ +// compensation.go 官方补偿活动的状态查询(/billing/meter/compensation-status)。 +// +// 背景(issue #189,2026-10-16):官方在「套餐与积分」页挂限时补偿横幅 +// (例:「DeepSeek 模型服务异常额外补偿 500 积分」,带领取截止日),账号侧看得见, +// 网关这边却是**盲领**——ClaimCompensation 只在每日签到后顺带调一次,且写法是 +// +// if credit, err := ...; err == nil { log.Printf(...) } +// +// 于是三种完全不同的情形在日志里同构:① 没有补偿 ② 已经领过 ③ **真的领取失败** +// (401/5xx/网络)。补偿带截止日,签到被禁用 / 机器关机 / 账号被禁用时就会静默 +// 过期,用户全程无感知。本文件补上「先查状态再决定是否领」所缺的那一半。 +// +// 上游口径(实测 2026-10-16,cn 域 CLI 侧与 Web 侧均返回一致): +// +// {"code":0,"msg":"OK","data":{ +// "claimed":false, // 是否已领 +// "active":true, // 当前是否有生效中的补偿活动 +// "credit_num":500, // 可领额度 +// "validity_period":1, // 发放积分的有效期(单位上游未明示,前端也不渲染) +// "end_time":"2026-10-22 23:59:59"}} // 领取截止 +// +// active=false 是「当前无补偿活动」的正常 200 返回,不是错误——故此接口只在 +// 传输失败 / 非 2xx / 业务 code 非 0 时返回 error。 +package upstream + +import ( + "encoding/json" + "net/http" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" +) + +// CompensationStatus 单账号的补偿活动状态。 +type CompensationStatus struct { + // Active 当前是否有生效中的补偿活动(无活动时为 false,属正常返回)。 + Active bool `json:"active"` + // Claimed 本次活动是否已领取。 + Claimed bool `json:"claimed"` + // CreditNum 待领额度(积分)。 + CreditNum int64 `json:"credit_num"` + // ValidityPeriod 发放积分的有效期倍数。上游未明示单位,且前端 UI 从不渲染 + // 该字段(仅后端返回),故此处只透传不做换算,避免臆造语义。 + ValidityPeriod int64 `json:"validity_period"` + // EndTime 领取截止时间(上游为 "2006-01-02 15:04:05" 无时区字符串)。 + EndTime string `json:"end_time"` +} + +// Pending 是否存在「可领且未领」的补偿——即该调用 ClaimCompensation 的前置判据。 +// +// 三者与门:active 为 false 是没有活动;claimed 为 true 是已领完(再调只会拿到 +// 业务错误);credit_num <= 0 是活动存在但无额度。三种都归为「无需领取」。 +func (s CompensationStatus) Pending() bool { + return s.Active && !s.Claimed && s.CreditNum > 0 +} + +// GetCompensationStatus 查补偿活动状态。 +// +// 走 billingJSON(billingBase),与既有 ClaimCompensation / ClaimGift 同域同头; +// 实测 CLI 域与 Web 域对该端点返回一致,故不必像 growth 领奖那样切 webBase。 +func (c *Client) GetCompensationStatus(a *auth.Auth) (CompensationStatus, error) { + data, err := c.billingJSON(a, http.MethodPost, "/billing/meter/compensation-status", map[string]any{}) + if err != nil { + return CompensationStatus{}, err + } + var st CompensationStatus + if err := json.Unmarshal(data, &st); err != nil { + return CompensationStatus{}, err + } + return st, nil +} diff --git a/internal/upstream/compensation_test.go b/internal/upstream/compensation_test.go new file mode 100644 index 00000000..f27d70fd --- /dev/null +++ b/internal/upstream/compensation_test.go @@ -0,0 +1,122 @@ +package upstream + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" +) + +// compensationServer 起一个只回固定 body 的 billing 桩,返回指向它的 Client。 +func compensationServer(t *testing.T, body string) *Client { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/billing/meter/compensation-status" { + t.Errorf("path=%s want /billing/meter/compensation-status", r.URL.Path) + } + if r.Method != http.MethodPost { + t.Errorf("method=%s want POST", r.Method) + } + w.Write([]byte(body)) + })) + t.Cleanup(srv.Close) + return &Client{HTTP: srv.Client(), ChatBaseCN: srv.URL, BillingBaseCN: srv.URL} +} + +// TestGetCompensationStatusParsesAllFields 全字段解析(issue #189 实测口径)。 +func TestGetCompensationStatusParsesAllFields(t *testing.T) { + c := compensationServer(t, `{"code":0,"msg":"OK","data":{"claimed":false,"active":true,`+ + `"credit_num":500,"validity_period":1,"end_time":"2026-10-22 23:59:59"}}`) + + st, err := c.GetCompensationStatus(&auth.Auth{AccessToken: "at", UID: "u1"}) + if err != nil { + t.Fatalf("GetCompensationStatus: %v", err) + } + if !st.Active { + t.Error("Active=false want true") + } + if st.Claimed { + t.Error("Claimed=true want false") + } + if st.CreditNum != 500 { + t.Errorf("CreditNum=%d want 500", st.CreditNum) + } + if st.ValidityPeriod != 1 { + t.Errorf("ValidityPeriod=%d want 1", st.ValidityPeriod) + } + if st.EndTime != "2026-10-22 23:59:59" { + t.Errorf("EndTime=%q want 2026-10-22 23:59:59", st.EndTime) + } + if !st.Pending() { + t.Error("Pending()=false want true(active 且未领且有额度)") + } +} + +// TestGetCompensationStatusNoActivityIsNotError 「当前无补偿活动」是 200 正常返回, +// 不是错误——这条语义一旦搞错,日常无活动的账号会把排程日志刷成一片错误。 +func TestGetCompensationStatusNoActivityIsNotError(t *testing.T) { + c := compensationServer(t, `{"code":0,"msg":"OK","data":{"claimed":false,"active":false,"credit_num":0}}`) + + st, err := c.GetCompensationStatus(&auth.Auth{AccessToken: "at", UID: "u1"}) + if err != nil { + t.Fatalf("无活动不应返回 error,实际: %v", err) + } + if st.Active { + t.Error("Active=true want false") + } + if st.Pending() { + t.Error("Pending()=true want false(无活动)") + } +} + +// TestGetCompensationStatusServerError HTTP 非 2xx → error。 +func TestGetCompensationStatusServerError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(500) + w.Write([]byte(`boom`)) + })) + defer srv.Close() + + c := &Client{HTTP: srv.Client(), ChatBaseCN: srv.URL, BillingBaseCN: srv.URL} + if _, err := c.GetCompensationStatus(&auth.Auth{AccessToken: "at", UID: "u1"}); err == nil { + t.Fatal("want error on 500") + } +} + +// TestGetCompensationStatusBusinessCode 业务 code 非 0 → error。 +func TestGetCompensationStatusBusinessCode(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + w.Write([]byte(`{"code":10001,"msg":"企业账号不支持该操作"}`)) + })) + defer srv.Close() + + c := &Client{HTTP: srv.Client(), ChatBaseCN: srv.URL, BillingBaseCN: srv.URL} + if _, err := c.GetCompensationStatus(&auth.Auth{AccessToken: "at", UID: "u1"}); err == nil { + t.Fatal("want error on non-zero business code") + } +} + +// TestCompensationStatusPendingGate 三条与门各自独立成立,避免出现 +// 「active 但已领」「活动在但额度为 0」被误判成待领而反复调领取接口。 +func TestCompensationStatusPendingGate(t *testing.T) { + cases := []struct { + name string + st CompensationStatus + want bool + }{ + {"无活动", CompensationStatus{Active: false, Claimed: false, CreditNum: 500}, false}, + {"已领取", CompensationStatus{Active: true, Claimed: true, CreditNum: 500}, false}, + {"额度为零", CompensationStatus{Active: true, Claimed: false, CreditNum: 0}, false}, + {"待领取", CompensationStatus{Active: true, Claimed: false, CreditNum: 500}, true}, + {"全零值", CompensationStatus{}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := tc.st.Pending(); got != tc.want { + t.Errorf("Pending()=%v want %v(%+v)", got, tc.want, tc.st) + } + }) + } +} diff --git a/internal/upstream/conversation_headers_test.go b/internal/upstream/conversation_headers_test.go new file mode 100644 index 00000000..d6f6c31e --- /dev/null +++ b/internal/upstream/conversation_headers_test.go @@ -0,0 +1,77 @@ +package upstream + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" +) + +// The international chat endpoint rejects an incomplete conversation header trio +// with 400/code 11128, including calls made with a zero-value ChatMeta. +func TestChatStreamConversationChannelHeaders(t *testing.T) { + for _, realm := range []string{"cn", "global"} { + for _, tc := range []struct { + name string + meta ChatMeta + }{ + {name: "zero metadata"}, + {name: "request ID only", meta: ChatMeta{ConversationRequestID: "existing-turn"}}, + {name: "explicit conversation", meta: ChatMeta{ConversationID: "existing-dialog", ConversationRequestID: "existing-turn"}}, + } { + t.Run(realm+"/"+tc.name, func(t *testing.T) { + headers := make(chan http.Header, 2) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + headers <- r.Header.Clone() + for _, name := range []string{"X-Conversation-ID", "X-Conversation-Request-ID", "X-Conversation-Message-ID"} { + if r.Header.Get(name) == "" { + w.WriteHeader(http.StatusBadRequest) + io.WriteString(w, `{"code":11128,"msg":"conversation id missing"}`) + return + } + } + w.Header().Set("Content-Type", "text/event-stream") + io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"pong\"}}]}\n\ndata: [DONE]\n\n") + })) + defer srv.Close() + c := New() + c.ChatBaseCN, c.ChatBaseGlobal = srv.URL, srv.URL + a, err := auth.Parse([]byte(`{"uid":"test","accessToken":"test-token","realm":"` + realm + `"}`)) + if err != nil { + t.Fatal(err) + } + var previousID string + for i := 0; i < 2; i++ { + rc, status, body, err := c.ChatStream(a, []byte(`{"model":"deepseek-v4.1-flash","messages":[{"role":"user","content":"hi"}]}`), "", tc.meta) + if err != nil || status != http.StatusOK { + t.Fatalf("chat: status=%d body=%s err=%v", status, body, err) + } + stream, err := io.ReadAll(rc) + rc.Close() + if err != nil || !strings.Contains(string(stream), "pong") || !strings.Contains(string(stream), "[DONE]") { + t.Fatalf("incomplete chat stream: %s err=%v", stream, err) + } + h := <-headers + id := h.Get("X-Conversation-ID") + if tc.meta.ConversationID != "" { + if id != tc.meta.ConversationID { + t.Fatalf("explicit conversation ID changed: %q", id) + } + } else if len(id) != 32 || !validTraceID(id) || id == previousID { + t.Fatalf("fallback ID must be fresh 32 hex: %q (previous %q)", id, previousID) + } + if tc.meta.ConversationRequestID != "" && h.Get("X-Conversation-Request-ID") != tc.meta.ConversationRequestID { + t.Fatal("explicit request ID changed") + } + if h.Get("X-Conversation-Message-ID") != h.Get("X-Request-ID") { + t.Fatal("message and request IDs lost their existing relationship") + } + previousID = id + } + }) + } + } +} diff --git a/internal/upstream/desktop.go b/internal/upstream/desktop.go index 0271b4b8..3c99afb3 100644 --- a/internal/upstream/desktop.go +++ b/internal/upstream/desktop.go @@ -29,8 +29,10 @@ import ( "fmt" "io" "net/http" + "net/url" "os" "regexp" + "strings" "time" "github.com/linguo2625469/workbuddy2api-panel/internal/auth" @@ -39,13 +41,59 @@ import ( const ( desktopReportPath = "/v2/report" desktopAppearanceSet = "/v2/user-asset/appearance/set" - // desktopUA 实测桌面客户端 UA(5.5.6 内嵌 CLI 2.137.1)。 - desktopUA = "WorkBuddy/5.5.6 WorkBuddy/5.5.6 CLI/2.137.1" + // desktopVersion / desktopCommit / desktopReleaseDate 官方分发包实测值 + // (install-manifest.json 的 appVersion + /v2/report body 的 commit/releaseDate)。 + // 与面板自身 client_version 分开:/v2/report 是「桌面端行为」指纹,应恒为桌面端版本。 + // + // **按 realm 分开**(2026-10-20 双版本抓包实测):两版上报的版本三元组不同, + // 且 commit/releaseDate 是打包期常量、无法从版本号推导: + // + // CN 5.8.3 ideVersion 5.8.3 commit db0e9b51b7fa… releaseDate 1792029498676 + // global 5.8.4 ideVersion 5.8.4 commit 73952edb48da… releaseDate 1792063084107 + // + // 此前不分档、恒用 global 的三元组,等于给每个国内号都盖了国际版的包指纹 + // (版本号与安装清单自相矛盾),是最容易被设备侧写识破的一处。 + desktopVersionCN = "5.8.3" + desktopCommitCN = "db0e9b51b7fa0b383f41f5b0b83dd442c8557918" + desktopReleaseDateCN = int64(1792029498676) + desktopVersionGlobal = "5.8.4" + desktopCommitGlobal = "73952edb48da46bd0b244661efe22f4f247c4977" + desktopReleaseDateGlobal = int64(1792063084107) ) // desktopBase 桌面端 /v2/report 与 user-asset 走 chatBase(copilot.tencent.com)。 func (c *Client) desktopBase(a *auth.Auth) string { return c.chatBase(a) } +// desktopUAFor 桌面端 UA:两段品牌一致(与业务链路同源,都是 SDK 的 buildUserAgent)。 +// CN → `WorkBuddy/5.8.3 WorkBuddy/5.8.3 CLI/2.161.0`; +// global → `workbuddy-ai/5.8.4 workbuddy-ai/5.8.4 CLI/2.161.0`。 +func desktopUAFor(a *auth.Auth) string { + ver, _, _ := desktopTriple(a) + brand := uaBrandFor(a) + cli := defaultCliVersion + return brand + "/" + ver + " " + brand + "/" + ver + " CLI/" + cli +} + +// desktopTriple 返回该账号域的桌面端 (ideVersion, commit, releaseDate) 三元组。 +// 两版分发包不同 → 三元组不同;nil(默认 CN 形态)走 CN 值。 +func desktopTriple(a *auth.Auth) (ver, commit string, release int64) { + if a != nil && a.IsGlobal() { + return desktopVersionGlobal, desktopCommitGlobal, desktopReleaseDateGlobal + } + return desktopVersionCN, desktopCommitCN, desktopReleaseDateCN +} + +// desktopHost 返回 X-Domain 用的裸主机名(与真机一致:www.workbuddy.ai / +// copilot.tencent.com)。从 base 里剥 scheme 与路径;解析失败时退回 base 原串, +// 保证不会因为 URL 畸形而把该头发成空串(头缺失同样会触发渠道校验)。 +func (c *Client) desktopHost(a *auth.Auth) string { + base := c.desktopBase(a) + if u, err := url.Parse(base); err == nil && u.Hostname() != "" { + return u.Hostname() + } + return strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(base, "https://"), "http://"), "/") +} + // deriveID 由 uid 稳定派生一个 36 位 hex 设备标识(machineId/qimei36 复用), // 幂等:同一账号每次生成相同值,模拟固定设备。 func deriveID(a *auth.Auth, salt string) string { @@ -57,32 +105,45 @@ func deriveID(a *auth.Auth, salt string) string { type DesktopEvent map[string]any // desktopFingerprint 公共桌面指纹字段(注入每个事件,覆盖同名业务键)。 +// 版本三元组按 realm 取(见 desktopTriple);qimei36 仅国内版有(见下)。 func desktopFingerprint(a *auth.Auth) map[string]any { now := time.Now().UnixMilli() - return map[string]any{ + ver, commit, release := desktopTriple(a) + fp := map[string]any{ "timezone": "Asia/Shanghai", "reportDelay": 2000, "userId": a.UID, "username": a.Nickname, "userNickname": a.Nickname, "product": "SaaS", - "releaseDate": int64(1789036585355), - "commit": "5f9692923c93033111c51ad7b003eb80204a9b75", + "releaseDate": release, + "commit": commit, "ideName": "WorkBuddy", "ideType": "WorkBuddy", - "ideVersion": "5.5.6", + "ideVersion": ver, "machineId": deriveID(a, "machine"), "sessionId": deriveID(a, "session"), "extName": "workbuddy-desktop", - "extVersion": "5.5.6", + "extVersion": ver, "os": "win32", "arch": "x64", - "osVersion": "10.0.26220", - "cpuCores": 20, - "memorySize": 24, - "timestamp": now, - "presentAt": now, - } + // osVersion / cpuModel / cpuCores / memorySize 是真机上报名(5.8.4 实测): + // 这些字段上游侧用于设备侧写,缺 cpuModel 是最明显的「非桌面端」特征。 + "osVersion": "10.0.28000", + "cpuModel": "11th Gen Intel(R) Core(TM) i5-11320H @ 3.20GHz", + "cpuCores": 8, + "memorySize": 16, + "timestamp": now, + "presentAt": now, + } + // qimei36:腾讯设备标识(36 位小写十六进制)。2026-10-20 双版本抓包实测—— + // 国内版每个 report 事件都带,国际版**一条都不带**(CN 28 键 vs GL 27 键, + // 差集恰好只有 qimei36)。故按 realm 条件注入:给国际版补这个键反而是伪造。 + // 取值按 uid 稳定派生(同 deriveID 口径),保证同号跨重启恒同值。 + if a != nil && !a.IsGlobal() { + fp["qimei36"] = deriveID(a, "qimei") + } + return fp } // ReportDesktopEvent 以桌面客户端指纹向 copilot.tencent.com/v2/report 批量上报事件。 @@ -115,8 +176,12 @@ func (c *Client) ReportDesktopEvent(a *auth.Auth, events ...DesktopEvent) error req.Header.Set("Authorization", "Bearer "+a.AccessTokenValue()) req.Header.Set("Accept", "application/json, text/plain, */*") req.Header.Set("Content-Type", "application/json;charset=UTF-8") - req.Header.Set("User-Agent", desktopUA) - req.Header.Set("X-Domain", c.desktopBase(a)) + req.Header.Set("User-Agent", desktopUAFor(a)) + // X-Domain 是**主机名**不是 URL:真机实测 `X-Domain: www.workbuddy.ai`, + // 而 desktopBase 返回的是带 scheme 的 base,直接塞进去会发成 + // `https://www.workbuddy.ai`——与本仓其它头(chat/billing 用 DomainValue 的裸域) + // 也不自洽,是最容易被上游按「非客户端形态」识别出来的一处。 + req.Header.Set("X-Domain", c.desktopHost(a)) req.Header.Set("X-Product", "SaaS") req.Header.Set("X-Request-ID", deriveID(a, "req")+fmt.Sprintf("%d", time.Now().UnixNano()%1e6)) if a.UID != "" { @@ -216,7 +281,7 @@ func (c *Client) SetAppearanceTheme(a *auth.Auth, resourceKey string) error { req.Header.Set("Authorization", "Bearer "+a.AccessTokenValue()) req.Header.Set("Accept", "application/json, text/plain, */*") req.Header.Set("Content-Type", "application/json;charset=UTF-8") - req.Header.Set("User-Agent", desktopUA) + req.Header.Set("User-Agent", desktopUAFor(a)) req.Header.Set("X-Product", "SaaS") if a.UID != "" { req.Header.Set("X-User-Id", a.UID) @@ -393,7 +458,7 @@ func (c *Client) MarketExpertList(a *auth.Auth, expertType string) ([]MarketExpe } req.Header.Set("Authorization", "Bearer "+a.AccessTokenValue()) req.Header.Set("Content-Type", "application/json") - req.Header.Set("User-Agent", desktopUA) + req.Header.Set("User-Agent", desktopUAFor(a)) req.Header.Set("X-Domain", c.chatBase(a)) req.Header.Set("X-Product", "SaaS") if a.UID != "" { @@ -441,10 +506,16 @@ func (c *Client) DesktopChatWithExpert(a *auth.Auth, expertID string) (conversat h.Set("Authorization", "Bearer "+a.AccessTokenValue()) h.Set("Content-Type", "application/json") h.Set("Accept", "text/event-stream") - h.Set("User-Agent", desktopUA) - h.Set("X-Domain", c.chatBase(a)) + h.Set("User-Agent", desktopUAFor(a)) + // X-Domain 是主机名不是 URL(真机实测 www.workbuddy.ai)。原先塞的是带 scheme + // 的 chatBase,与 /v2/report 同一处笔误,一并改正。 + h.Set("X-Domain", c.desktopHost(a)) h.Set("X-Product", "SaaS") + h.Set("X-Private-Data", "true") h.Set("X-User-Id", a.UID) + // 身份头族(企业与带 token_source 的号才有):与 ChatHeaders 同源, + // 避免这条手搓头路径比主 chat 路径少一族指纹。 + c.injectIdentityHeaders(req, a) h.Set("X-Conversation-ID", conversationID) h.Set("X-Request-ID", fmt.Sprintf("%d", time.Now().UnixNano())) h.Set("X-Agent-Intent", "craft") diff --git a/internal/upstream/desktop_test.go b/internal/upstream/desktop_test.go index 8b654685..d9808b66 100644 --- a/internal/upstream/desktop_test.go +++ b/internal/upstream/desktop_test.go @@ -40,8 +40,9 @@ func TestReportDesktopEventFingerprint(t *testing.T) { if err != nil { t.Fatalf("report desktop: %v", err) } - if gotUA != desktopUA { - t.Errorf("UA=%q want %q", gotUA, desktopUA) + // CN 账号 → 国内版形态;国际版形态由 TestDesktopUARealmBrand 单独钉。 + if wantUA := desktopUAFor(&auth.Auth{AccessToken: "at", UID: "u-dt"}); gotUA != wantUA { + t.Errorf("UA=%q want %q", gotUA, wantUA) } if len(got) != 1 { t.Fatalf("events=%d want 1", len(got)) @@ -102,8 +103,8 @@ func TestSetAppearanceTheme(t *testing.T) { if r.URL.Path != "/v2/user-asset/appearance/set" { t.Errorf("path=%s want /v2/user-asset/appearance/set", r.URL.Path) } - if r.Header.Get("User-Agent") != desktopUA { - t.Errorf("UA=%q want desktop UA", r.Header.Get("User-Agent")) + if r.Header.Get("User-Agent") != desktopUAFor(&auth.Auth{AccessToken: "at", UID: "u1"}) { + t.Errorf("UA=%q want desktop CN UA", r.Header.Get("User-Agent")) } raw, _ := io.ReadAll(r.Body) json.Unmarshal(raw, &got) diff --git a/internal/upstream/enterprise_resource_test.go b/internal/upstream/enterprise_resource_test.go index fb679668..4ddac599 100644 --- a/internal/upstream/enterprise_resource_test.go +++ b/internal/upstream/enterprise_resource_test.go @@ -130,3 +130,35 @@ func TestPersonalResourceStaysOnPackageEndpoint(t *testing.T) { t.Fatal("应至少请求一次资源包端点") } } + +// TestAPIKeyRoutesToEnterpriseBilling 静态 API Key 与企业版同口径:额度查询分流到 +// get-enterprise-user-usage(个人 get-user-resource 对二者均返回空 Accounts), +// 且不注入 X-Enterprise-Id(无企业身份)。 +func TestAPIKeyRoutesToEnterpriseBilling(t *testing.T) { + var paths []string + var gotEnt string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + paths = append(paths, r.URL.Path) + gotEnt = r.Header.Get("X-Enterprise-Id") + w.Write([]byte(`{"code":0,"msg":"OK","data":{"credit":100,"limitNum":500}}`)) + })) + defer srv.Close() + + c := &Client{HTTP: srv.Client(), BillingBaseCN: srv.URL} + a := &auth.Auth{UID: "u1", AccessToken: "ck_9f3a2b1c.deadbeef"} + + remain, total, _, _, _, err := c.UserResourceDetailedWithExpiry(a, 0) + if err != nil { + t.Fatalf("err=%v", err) + } + if len(paths) == 0 || !strings.HasSuffix(paths[0], "/v2/billing/meter/get-enterprise-user-usage") { + t.Errorf("paths=%v want .../get-enterprise-user-usage", paths) + } + if gotEnt != "" { + t.Errorf("API Key 不应注入 X-Enterprise-Id,实际=%q", gotEnt) + } + // 500 - 100 = 400。 + if remain != 400 || total != 500 { + t.Errorf("remain=%d total=%d want 400/500", remain, total) + } +} diff --git a/internal/upstream/fingerprint_test.go b/internal/upstream/fingerprint_test.go new file mode 100644 index 00000000..94dc8f33 --- /dev/null +++ b/internal/upstream/fingerprint_test.go @@ -0,0 +1,427 @@ +// fingerprint_test.go 官方客户端指纹对齐真机(5.8.4 国际版 / 5.8.3 国内版)的回归。 +// +// 证据来源:2026-10-20 桌面客户端真实启动流量(mitmproxy 抓到 www.workbuddy.ai +// 183 条请求)+ 客户端 app.asar / cli/dist 静态反编译。以下每个断言都对应一处 +// 与真机不一致的地方——不一致本身是「被判定非官方客户端」的候选指纹。 +package upstream + +import ( + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" +) + +func globalAuth() *auth.Auth { + return &auth.Auth{UID: "u-g", AccessToken: "at", Domain: "www.workbuddy.ai", Nickname: "g"} +} + +// v3ProbeUACNForTest 国内版 /v3/config 实测 UA(默认 Client、CN 账号)。 +// 2026-10-20 抓包:`WorkBuddy/5.8.3 WorkBuddy/5.8.3 CLI/2.161.0`。 +const v3ProbeUACNForTest = "WorkBuddy/5.8.3 WorkBuddy/5.8.3 CLI/2.161.0" + +// TestUARealmBrand 两版品牌段形态**不同**,版本按 realm 分开。 +// +// 2026-10-20 双版本真机抓包(同一台机器、同一套代理): +// +// CN 5.8.3 → WorkBuddy/5.8.3 WorkBuddy/5.8.3 CLI/2.161.0 (驼峰原样,28 路径) +// global 5.8.4 → workbuddy-ai/5.8.4 workbuddy-ai/5.8.4 CLI/2.161.0 (小写连字符,22 路径) +// +// 关键:CN 的 brand 段是**驼峰**,不是 slug。曾把 CN 也写成 `workbuddy`(把 GL 的 +// 规则统一套过去),那是错的——CN 每个业务请求都是驼峰,写成 slug 反而成了最明显 +// 的非官方指纹。两版规则不同是因为 applicationName 在打包期被改写方式不同。 +func TestUARealmBrand(t *testing.T) { + c := New() + + cn := &auth.Auth{UID: "u-c", AccessToken: "at"} + if got, want := c.userAgent(cn), "WorkBuddy/5.8.3 WorkBuddy/5.8.3 CLI/2.161.0"; got != want { + t.Errorf("CN UA=%q want %q", got, want) + } + if got, want := c.userAgent(globalAuth()), "workbuddy-ai/5.8.4 workbuddy-ai/5.8.4 CLI/2.161.0"; got != want { + t.Errorf("global UA=%q want %q", got, want) + } + // 显式配置仍然赢(用户自定义品牌/版本)。 + c.ClientVersion = "9.9.9" + c.CliVersion = "1.2.3" + if got, want := c.userAgent(globalAuth()), "workbuddy-ai/9.9.9 workbuddy-ai/9.9.9 CLI/1.2.3"; got != want { + t.Errorf("override UA=%q want %q", got, want) + } +} + +// TestV3ProbeUAOfficial 目录探测 UA 必须两版各一处官方形态,不能是 CodeBuddy 家族。 +// +// 真机实测两版对同一 /v3/config 端点发的 UA 不同: +// +// CN → WorkBuddy/5.8.3 WorkBuddy/5.8.3 CLI/2.161.0 (三段,重复品牌) +// global → WorkBuddy/5.8.4 CLI/2.161.0 (两段,单品牌) +// +// 旧实现发 `CodeBuddyIDE/4.12.0 CodeBuddy/4.12.0` / `CLI/2.63.2 CodeBuddy/2.63.2`: +// 这两个串在 WorkBuddy 分发包里根本不存在(只有 WorkBuddy 形态),属最显眼的指纹。 +func TestV3ProbeUAOfficial(t *testing.T) { + c := New() + cn := &auth.Auth{UID: "u-c", AccessToken: "at"} + + if got, want := c.v3ProbeUA(cn), "WorkBuddy/5.8.3 WorkBuddy/5.8.3 CLI/2.161.0"; got != want { + t.Errorf("CN v3ProbeUA=%q want %q", got, want) + } + if got, want := c.v3ProbeUA(globalAuth()), "WorkBuddy/5.8.4 CLI/2.161.0"; got != want { + t.Errorf("global v3ProbeUA=%q want %q", got, want) + } + // 第二路(单品牌两段式)在 CN 下与主路不同,才有多目录覆盖的意义。 + if got, want := c.v3ProbeUALite(cn), "WorkBuddy/5.8.3 CLI/2.161.0"; got != want { + t.Errorf("CN v3ProbeUALite=%q want %q", got, want) + } + // 任何一路都不许再出现 CodeBuddy 字面量。 + for _, ua := range []string{c.v3ProbeUA(cn), c.v3ProbeUA(globalAuth()), c.v3ProbeUALite(cn), c.desktopProbeUA()} { + if strings.Contains(ua, "CodeBuddy") { + t.Errorf("探测 UA 仍含 CodeBuddy 家族字面量: %q", ua) + } + } +} + +// TestBillingUAAxiosBothRealms 两版计费请求的 UA 都是 axios 默认串。 +// +// 真机实测:CN 与 GL 的 `/billing/meter/get-user-resource-*`、`-summary`、 +// `checkin-activity-status` 的 UA 全是 `axios/1.16.1` —— billing 层走 axios 实例, +// 不带任何品牌段。旧实现 CN 侧发 `WorkBuddy/<ver>` 是按客户端 UA 推理的产物。 +func TestBillingUAAxiosBothRealms(t *testing.T) { + c := New() + if got := c.billingUA(globalAuth()); got != "axios/1.16.1" { + t.Errorf("global billing UA=%q want axios/1.16.1", got) + } + if got := c.billingUA(&auth.Auth{UID: "u-c", AccessToken: "at"}); got != "axios/1.16.1" { + t.Errorf("CN billing UA=%q want axios/1.16.1", got) + } +} + +// TestIdentityHeadersPersonalSendsNone 个人号三头全不发。 +// +// 反编译的 runIdentityHeaders() 只在「有 enterpriseId / idSource / token_source 任一」 +// 时才发 X-Userinfo;真机个人号抓包里 X-Userinfo / X-Auth-Method / X-Id-Source 全都不存在。 +// 凭「个人号也该有」去补空值是伪造指纹,比不发更容易被识别。 +func TestIdentityHeadersPersonalSendsNone(t *testing.T) { + c := New() + a := globalAuth() + req := httptest.NewRequest("POST", "https://www.workbuddy.ai/v2/chat/completions", nil) + c.injectIdentityHeaders(req, a) + for _, h := range []string{"X-Userinfo", "X-Auth-Method", "X-Id-Source"} { + if v := req.Header.Get(h); v != "" { + t.Errorf("个人号不该发 %s=%q", h, v) + } + } +} + +// TestIdentityHeadersEnterpriseUserinfo 企业号发 X-Userinfo,内容是 base64(JSON{uin,owner_uin})。 +// uin 段取 uid(反编译逻辑是 {uin: n.uid}),owner_uin 取 enterpriseId。 +func TestIdentityHeadersEnterpriseUserinfo(t *testing.T) { + c := New() + a := globalAuth() + a.EnterpriseID = "ent-1" + req := httptest.NewRequest("POST", "https://www.workbuddy.ai/v2/chat/completions", nil) + c.injectIdentityHeaders(req, a) + + raw := req.Header.Get("X-Userinfo") + if raw == "" { + t.Fatal("企业号应发 X-Userinfo") + } + dec, err := base64.StdEncoding.DecodeString(raw) + if err != nil { + t.Fatalf("X-Userinfo 不是标准 base64: %v", err) + } + var payload map[string]string + if err := json.Unmarshal(dec, &payload); err != nil { + t.Fatalf("X-Userinfo 不是 JSON: %v (%s)", err, dec) + } + if payload["uin"] != "u-g" { + t.Errorf("uin=%q want u-g(反编译逻辑是 {uin: uid})", payload["uin"]) + } + if payload["owner_uin"] != "ent-1" { + t.Errorf("owner_uin=%q want ent-1", payload["owner_uin"]) + } + if _, ok := payload["token_source"]; ok { + t.Errorf("无 token_source 时不该写入该键(不是写空串):%v", payload) + } +} + +// TestIdentityHeadersTokenSourceFromJWT X-Auth-Method 取自 JWT 的 token_source 声明。 +func TestIdentityHeadersTokenSourceFromJWT(t *testing.T) { + payload := base64.RawURLEncoding.EncodeToString([]byte(`{"token_source":"enterprise_switch"}`)) + a := &auth.Auth{UID: "u1", AccessToken: "h." + payload + ".s"} + req := httptest.NewRequest("POST", "https://example/v2/chat/completions", nil) + New().injectIdentityHeaders(req, a) + + if got := req.Header.Get("X-Auth-Method"); got != "enterprise_switch" { + t.Fatalf("X-Auth-Method=%q want enterprise_switch", got) + } + dec, _ := base64.StdEncoding.DecodeString(req.Header.Get("X-Userinfo")) + var m map[string]string + _ = json.Unmarshal(dec, &m) + if m["token_source"] != "enterprise_switch" || m["uin"] != "u1" { + t.Fatalf("X-Userinfo=%s", dec) + } +} + +// TestChatCarriesPrivateData 真机 chat 出站恒带 X-Private-Data: true。 +func TestChatCarriesPrivateData(t *testing.T) { + c := New() + c.ChatBaseCN = "https://cn.example" + a := &auth.Auth{UID: "u1", AccessToken: "at"} + req := httptest.NewRequest("POST", "https://cn.example/v2/chat/completions", nil) + c.ChatHeaders(req, a, "", ChatMeta{ConversationID: "c1", ConversationRequestID: "r1"}) + if got := req.Header.Get("X-Private-Data"); got != "true" { + t.Fatalf("X-Private-Data=%q want true", got) + } +} + +// TestDesktopReportDomainIsHost X-Domain 必须是裸主机名,不是带 scheme 的 URL。 +// 真机:`X-Domain: www.workbuddy.ai`;原实现塞的是 desktopBase(https://...)。 +func TestDesktopReportDomainIsHost(t *testing.T) { + var gotDomain string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotDomain = r.Header.Get("X-Domain") + w.Write([]byte(`{"code":0,"msg":"OK"}`)) + })) + defer srv.Close() + + // 用真 base 形式(带 scheme)验证剥离逻辑:client 的 base 就是 URL。 + c := &Client{HTTP: srv.Client(), ChatBaseGlobal: srv.URL, GlobalEnabled: true} + if err := c.ReportDesktopEvent(globalAuth(), DesktopEvent{"eventCode": "x"}); err != nil { + t.Fatalf("report: %v", err) + } + // Hostname() 剥掉 scheme 与端口(真机 X-Domain 是纯主机名,无端口)。 + u, _ := url.Parse(srv.URL) + want := u.Hostname() + if gotDomain != want { + t.Fatalf("X-Domain=%q want %q(裸 host,不带 scheme)", gotDomain, want) + } +} + +// TestDesktopReportFingerprintPerRealm /v2/report 的版本三元组按 realm 分档, +// 且 qimei36 只在国内版出现。 +// +// 2026-10-20 双版本真机抓包(同一台机器): +// +// CN ideVersion 5.8.3 commit db0e9b51b7fa… releaseDate 1792029498676,**带** qimei36 +// global ideVersion 5.8.4 commit 73952edb48da… releaseDate 1792063084107,**无** qimei36 +// +// 键数差集恰好只有 qimei36(CN 28 键 / GL 27 键)。此前不分档、恒用 GL 的三元组, +// 等于给每个国内号都盖了国际版的包指纹;给 GL 补 qimei36 则是无中生有。 +func TestDesktopReportFingerprintPerRealm(t *testing.T) { + cases := []struct { + name string + auth *auth.Auth + ver string + commit string + release int64 + qimei bool + }{ + {"CN", &auth.Auth{UID: "u-c", AccessToken: "at", Nickname: "cn"}, + "5.8.3", "db0e9b51b7fa0b383f41f5b0b83dd442c8557918", 1792029498676, true}, + {"global", globalAuth(), + "5.8.4", "73952edb48da46bd0b244661efe22f4f247c4977", 1792063084107, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + fp := desktopFingerprint(tc.auth) + if fp["ideVersion"] != tc.ver || fp["extVersion"] != tc.ver { + t.Errorf("ideVersion/extVersion=%v/%v want %s", fp["ideVersion"], fp["extVersion"], tc.ver) + } + if fp["commit"] != tc.commit { + t.Errorf("commit=%v want %s", fp["commit"], tc.commit) + } + if fp["releaseDate"] != tc.release { + t.Errorf("releaseDate=%v want %d", fp["releaseDate"], tc.release) + } + got, has := fp["qimei36"] + if has != tc.qimei { + t.Fatalf("qimei36 存在性=%v want %v(CN 有 / global 无)", has, tc.qimei) + } + if tc.qimei { + s, _ := got.(string) + if len(s) != 36 { + t.Errorf("qimei36=%q 长度=%d want 36 hex", s, len(s)) + } + // 同号幂等:设备标识跨调用必须恒同值。 + if again := desktopFingerprint(tc.auth)["qimei36"]; again != got { + t.Errorf("qimei36 非幂等: %v vs %v", got, again) + } + } + }) + } +} + +// TestDesktopUAPerRealm 桌面端 UA 的品牌段与客户端版本都跟着 realm 走。 +func TestDesktopUAPerRealm(t *testing.T) { + cn := &auth.Auth{UID: "u-c", AccessToken: "at"} + if got, want := desktopUAFor(cn), "WorkBuddy/5.8.3 WorkBuddy/5.8.3 CLI/2.161.0"; got != want { + t.Errorf("CN desktop UA=%q want %q", got, want) + } + if got, want := desktopUAFor(globalAuth()), "workbuddy-ai/5.8.4 workbuddy-ai/5.8.4 CLI/2.161.0"; got != want { + t.Errorf("global desktop UA=%q want %q", got, want) + } +} + +// TestAcceptLanguageSameBothRealms Accept-Language 不按账号域切。 +// +// 真机实测:跟随操作系统区域,同一台中文机器上 CN/GL 都是 `zh`(billing 层固定) +// 或 `zh-CN`(渲染层),多数请求干脆不带。旧实现「global → en-US」是臆测。 +func TestAcceptLanguageSameBothRealms(t *testing.T) { + cn, gl := acceptLanguageFor(&auth.Auth{UID: "u-c"}), acceptLanguageFor(globalAuth()) + if cn != gl { + t.Errorf("两域取值不一致: CN=%q GL=%q(真机同机同值)", cn, gl) + } + if cn == "en-US" { + t.Errorf("不应按域硬编码 en-US(实测仅出现 1 次,非常态)") + } +} + +// TestGlobalPackageDetailsBodyShapes paid / free 两份明细的请求体**不同形**。 +// +// 2026-10-20 双版本真机抓包(CN/GL 一致): +// +// paid-packages: {PageNumber, PageSize, PackageCodes, Status:[0,3], NeedRenewInfo:true} +// —— 无 SlicePeriod 时间切片 +// free-packages: … + SlicePeriodStartTime / SlicePeriodEndTime +// —— 无 NeedRenewInfo +// +// 此前两条共用同一 body(都带切片、都无 NeedRenewInfo),paid 包因此拿不到续订窗口。 +func TestGlobalPackageDetailsBodyShapes(t *testing.T) { + bodies := map[string]map[string]any{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var m map[string]any + _ = json.NewDecoder(r.Body).Decode(&m) + name := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:] + bodies[name] = m + w.Write([]byte(`{"code":0,"msg":"OK","data":{"Accounts":[]}}`)) + })) + defer srv.Close() + + c := &Client{HTTP: srv.Client(), BillingBaseGlobal: srv.URL, GlobalEnabled: true} + c.globalPackageDetails(globalAuth(), []string{"PKG-A"}) + + paid, ok := bodies["get-user-resource-paid-packages"] + if !ok { + t.Fatalf("未请求 paid-packages,实际=%v", bodies) + } + if _, has := paid["SlicePeriodStartTime"]; has { + t.Errorf("paid-packages 不该带时间切片: %v", paid) + } + if paid["NeedRenewInfo"] != true { + t.Errorf("paid-packages 必须带 NeedRenewInfo:true,实际=%v", paid["NeedRenewInfo"]) + } + + free, ok := bodies["get-user-resource-free-packages"] + if !ok { + t.Fatalf("未请求 free-packages,实际=%v", bodies) + } + if _, has := free["NeedRenewInfo"]; has { + t.Errorf("free-packages 不该带 NeedRenewInfo: %v", free) + } + if free["SlicePeriodStartTime"] == nil || free["SlicePeriodEndTime"] == nil { + t.Errorf("free-packages 必须带时间切片: %v", free) + } + + // 两条共有字段必须一致(差集只允许上面那两处)。 + for _, k := range []string{"PageNumber", "PageSize", "PackageCodes", "Status"} { + if paid[k] == nil || free[k] == nil { + t.Errorf("共有字段 %s 缺失: paid=%v free=%v", k, paid[k], free[k]) + } + } +} + +// TestGlobalBillingSummary 国际版余额走 summary + paid/free 明细: +// remain/total 取 summary 的 Cycle{Remain,Total}Capacity(字符串小数), +// 到期批次按 PackageCode 与明细关联。 +func TestGlobalBillingSummary(t *testing.T) { + var paths []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + paths = append(paths, r.URL.Path) + switch { + case strings.HasSuffix(r.URL.Path, "/get-user-resource-summary"): + w.Write([]byte(`{"code":0,"msg":"OK","data":{"Packages":[` + + `{"PackageCode":"PKG-A","CycleTotalCapacity":"540","CycleRemainCapacity":"335.47000026","CycleUsedCapacity":"204.52999974","CapacityUnit":"credit"},` + + `{"PackageCode":"PKG-B","CycleTotalCapacity":"100","CycleRemainCapacity":"0","CycleUsedCapacity":"100"}` + + `],"SubscriptionPackageName":"免费版","IsPaidUser":false}}`)) + case strings.HasSuffix(r.URL.Path, "/get-user-resource-free-packages"): + soon := time.Now().Add(48 * time.Hour).Format("2006-01-02 15:04:05") + w.Write([]byte(`{"code":0,"msg":"OK","data":{"Accounts":[` + + `{"PackageCode":"PKG-A","PackageName":"权益赠送包","CycleCapacitySize":540,"CycleCapacityRemain":335,` + + `"CycleEndTime":"` + soon + `","DeductionEndTime":0,"ExpiredTime":""}` + + `],"TotalCount":1}}`)) + case strings.HasSuffix(r.URL.Path, "/get-user-resource-paid-packages"): + w.Write([]byte(`{"code":0,"msg":"OK","data":{"Accounts":[],"TotalCount":0}}`)) + default: + w.WriteHeader(404) + } + })) + defer srv.Close() + + c := &Client{HTTP: srv.Client(), BillingBaseGlobal: srv.URL, GlobalEnabled: true} + remain, total, expiring, earliestAt, earliestRemaining, buckets, err := + c.UserResourceSnapshot(globalAuth(), 168*time.Hour) + if err != nil { + t.Fatalf("snapshot: %v", err) + } + // 540 + 100 = 640 total;335.47 + 0 → 截断 335 remain。 + if total != 640 { + t.Errorf("total=%d want 640", total) + } + if remain != 335 { + t.Errorf("remain=%d want 335(字符串小数按整数截断,与既有口径一致)", remain) + } + if len(buckets) != 1 || buckets[0].Remain != 335 { + t.Fatalf("buckets=%+v want 单批 335", buckets) + } + if earliestAt.IsZero() || earliestRemaining != 335 { + t.Errorf("earliestAt=%v earliestRemaining=%d want 非零/335", earliestAt, earliestRemaining) + } + // 48h 后到期、窗口 168h → 该批整笔计入 expiring。 + if expiring != 335 { + t.Errorf("expiring=%d want 335(48h < 168h 窗口)", expiring) + } + // 必须打的是国际版新三件套,不能退回 CN 的 get-user-resource。 + for _, p := range paths { + if strings.HasSuffix(p, "/billing/meter/get-user-resource") { + t.Fatalf("国际版不该再走 CN 分页路径:%v", paths) + } + } +} + +// TestGlobalBillingFallsBackToCNPath summary 失败时回落 CN 分页路径(老路径兼容尝试)。 +func TestGlobalBillingFallsBackToCNPath(t *testing.T) { + var sawCN bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/get-user-resource-summary") { + w.WriteHeader(500) + return + } + if strings.Contains(r.URL.Path, "get-user-resource") && + !strings.Contains(r.URL.Path, "paid-packages") && + !strings.Contains(r.URL.Path, "free-packages") { + sawCN = true + w.Write([]byte(`{"code":0,"data":{"Response":{"Data":{"Accounts":` + + `[{"CycleCapacitySize":1000,"CycleCapacityRemain":900,"CycleCapacityUsed":100}]}}}}`)) + return + } + w.WriteHeader(404) + })) + defer srv.Close() + + c := &Client{HTTP: srv.Client(), BillingBaseGlobal: srv.URL, GlobalEnabled: true} + remain, total, _, _, _, _, err := c.UserResourceSnapshot(globalAuth(), 0) + if err != nil { + t.Fatalf("snapshot: %v", err) + } + if !sawCN { + t.Fatal("summary 失败时应回落到 CN 分页路径") + } + if remain != 900 || total != 1000 { + t.Errorf("remain=%d total=%d want 900/1000", remain, total) + } +} diff --git a/internal/upstream/global_models.go b/internal/upstream/global_models.go index b34a0f85..7bfe9127 100644 --- a/internal/upstream/global_models.go +++ b/internal/upstream/global_models.go @@ -59,6 +59,46 @@ type fetchGlobalModelsCache struct { infos []ModelInfo // 成功缓存:对象形态的全字段条目(窄表/失败形态为 nil) fetched time.Time lastFail time.Time + // source 本次成功探测的目录来源:globalCatalogSourceDesktop / ...Union / + // ...Enterprise。面板用它把「拿到的是窄目录/旧目录」直接摆给运维看 + //(issue #216 现象 1 就是 v3 探测失败静默降级到企业端点,用户只看到 + //「少了 11 个模型」,却不知道自己拿的是哪一套目录)。 + source string +} + +// 目录来源标识(fetchGlobalModelsCache.source)。 +const ( + globalCatalogSourceDesktop = "v3-desktop" // 桌面端 UA 族权威目录(官方 picker 口径) + globalCatalogSourceUnion = "v3-union" // 桌面族探测失败的回落:IDE+CLI 并集 + globalCatalogSourceEnterprise = "enterprise" // v3 全失败,只剩企业端点(窄目录 + 旧倍率) +) + +// GlobalCatalogSource 返回最近一次成功探测的目录来源(空 = 尚未探测)。 +func (c *Client) GlobalCatalogSource() string { + if c == nil { + return "" + } + c.globalModels.Lock() + defer c.globalModels.Unlock() + return c.globalModels.source +} + +// InvalidateGlobalModelsCache 清空 global 目录探测缓存(names/infos/成功与失败 +// 时间戳/来源),使下一次 FetchGlobalModels/FetchGlobalModelInfos 立即重新探测上游。 +// +// 存在理由:面板「模型与档位 → 重新获取」此前只是重读 1h 缓存,点了数量纹丝不动 +// (issue #216 现象 3)。上游目录变动后运维需要一个立即生效的按钮,而不是等 TTL。 +func (c *Client) InvalidateGlobalModelsCache() { + if c == nil { + return + } + c.globalModels.Lock() + defer c.globalModels.Unlock() + c.globalModels.names = nil + c.globalModels.infos = nil + c.globalModels.fetched = time.Time{} + c.globalModels.lastFail = time.Time{} + c.globalModels.source = "" } // globalModelsTTL / globalModelsFailCooldown 探测缓存时长:成功 1h,失败 5min 负缓存。 @@ -120,7 +160,7 @@ func (c *Client) fetchGlobalModelsOnce(a *auth.Auth) (names []string, infos []Mo } c.globalModels.Unlock() - names, infos, efforts, defaults, err := c.probeGlobalModels(a) + names, infos, efforts, defaults, source, err := c.probeGlobalModels(a) if err != nil || len(names) == 0 { // 探测失败:负缓存 + 返回 nil(effort 桶不写,prepareBody 走 globalEffortMap 静态兜底)。 c.globalModels.Lock() @@ -155,11 +195,26 @@ func (c *Client) fetchGlobalModelsOnce(a *auth.Auth) (names []string, infos []Mo c.globalModels.infos = infos c.globalModels.fetched = time.Now() c.globalModels.lastFail = time.Time{} + c.globalModels.source = source c.globalModels.Unlock() return merged, infos } -// probeGlobalModels 发起一次 global 模型目录探测(v3-config-merge + desktop-ua): +// desktopProbeUA /v3/config 第三路(桌面端形态)探测用的 UA。 +// +// 该端点按 UA 家族下发不同目录,三族各有独有模型(2026-10-03 实测,同一账号 +// 同一端点:CN IDE 19 / CLI 32 / 桌面端 54,global 13 / 22 / 29)。global 侧 +// gpt-6-luna 只在桌面端目录出现,但请求实测可正常返回 200——只探 IDE + CLI 时 +// 它永远进不了 /v1/models(用户侧表现为「模型列表不全」)。版本号升降不改结果 +// (IDE 4.12→5.0、CLI 2.63→2.80 目录条数一致),故只补家族。 +// +// 平台段固定用 CN 形态(`WorkBuddy`,= defaultWorkBuddyUAFor(nil)):本次实测即 +// 该形态、两个 base 都返回最大目录;global 形态未实测,不臆造。 +func (c *Client) desktopProbeUA() string { + return c.defaultWorkBuddyUAFor(nil) +} + +// probeGlobalModels 发起一次 global 模型目录探测(v3-config-merge): // /v3/config(三路 UA:桌面端主路 + IDE + CLI)与企业端点家族(/v2 → /console 兜底, // 补缺)**并发**探测后并集合并。返回模型名列表(已合并、未再去重——去重在 // fetchGlobalModelsOnce)、全字段 ModelInfo(对象形态;窄表为 nil)及 effort @@ -175,7 +230,7 @@ func (c *Client) fetchGlobalModelsOnce(a *auth.Auth) (names []string, infos []Mo // 合并口径:桌面端条目字段权威,IDE/CLI 只补桌面端缺失的模型 id;去重 key = // 模型 id,输出顺序稳定。全部 v3 路失败且企业端点也失败才返回错误;单路失败 // 降级为其余路结果 + warn 日志,互不拖累。 -func (c *Client) probeGlobalModels(a *auth.Auth) (names []string, infos []ModelInfo, efforts map[string][]string, defaults map[string]string, err error) { +func (c *Client) probeGlobalModels(a *auth.Auth) (names []string, infos []ModelInfo, efforts map[string][]string, defaults map[string]string, source string, err error) { // probeV3 单次 /v3/config 探测(UA 参数化)。该端点对不同 UA 下发**不同模型集合**: // 桌面端 / IDE / CLI 三路各有独有模型(见 probeGlobalModels 注释),故并发三路取并集。 probeV3 := func(ua string) chan probeResult { @@ -201,11 +256,22 @@ func (c *Client) probeGlobalModels(a *auth.Auth) (names []string, infos []ModelI }() return ch } - // 桌面端 UA(主路):项目 chat 路径实际使用的 UA,与官方客户端看到的目录同源。 - // 按账号 realm 生成(global → `WorkBuddy AI` 平台段),故必须传 a 而非用常量。 - v3DesktopCh := probeV3(c.defaultWorkBuddyUAFor(a)) - v3IDECh := probeV3(codeBuddyIDEUA) - v3CLICh := probeV3(codeBuddyCLIUA) + // 三路 UA 都必须是**官方真实出现过**的形态:旧实现在这里发 + // `CodeBuddyIDE/4.12.0 CodeBuddy/4.12.0` 与 `CLI/2.63.2 CodeBuddy/2.63.2` + // 两套 CodeBuddy 家族串——那是旧版 IDE 客户端的 UA,WorkBuddy 分发包里根本 + // 不存在,属于最显眼的非官方指纹(真机抓包对照:CN 5.8.3 全链路发 + // `WorkBuddy/5.8.3 WorkBuddy/5.8.3 CLI/2.161.0`,global 5.8.4 发 + // `workbuddy-ai/5.8.4 workbuddy-ai/5.8.4 CLI/2.161.0`)。 + // + // 三路按「客户端形态」分工:IDE 族(v3ProbeUA)/ CLI 族(v3ProbeUALite)/ + // 桌面端族(desktopProbeUA)。该端点按 UA 家族下发**不同模型集合** + // (实测同一账号同一端点:CN 19 / 32 / 54 条,global 13 / 22 / 29 条), + // 三族各有独有模型,缺一路就少一批模型(issue #216)。 + v3IDECh := probeV3(c.v3ProbeUA(a)) + v3CLICh := probeV3(c.v3ProbeUALite(a)) + // 第三路:桌面端形态。缺它就会漏掉仅桌面端下发的模型——global 侧 gpt-6-luna + // 即此例(目录里没有、实际可调用),用户侧表现为「模型列表不全」。 + v3DesktopCh := probeV3(c.desktopProbeUA()) enterpriseCh := make(chan probeResult, 1) go func() { // 企业端点家族:/v2 首选 → /console 兜底(既有探活序,零回归)。 @@ -225,32 +291,84 @@ func (c *Client) probeGlobalModels(a *auth.Auth) (names []string, infos []ModelI v3IDE := <-v3IDECh v3CLI := <-v3CLICh enterprise := <-enterpriseCh - // v3 三路自合并:桌面端路字段权威(主路),IDE/CLI 只补主路缺失的模型 id - // (只补 id,字段仍取自各自条目——主路没有该 id 时才轮到它们)。 - // 逐路容错:全失败才带 err 进入下游降级判断,部分失败 warn 后继续。 - v3 := mergeV3Routes([]struct { + // v3 三路自合并:**桌面端 UA 族是目录权威**(issue #216)——官方桌面端 picker + // 就是这一族,2026-10-17 实测 global 侧桌面族 27 条与 picker 一一对应;而 + // IDE/CLI 族的独有模型(o4-mini / auto-chat / deep-model)在同一 chat 端点回 + // 11102「service info not found」——列出来只会让客户端选到死模型。 + // + // 故:桌面族成功时**模型 id 只取桌面族**;IDE/CLI 族降级为元数据(context/ + // efforts/credits 缺字段时补),不再贡献 id。桌面族探测失败时回落并集语义 + // (宁可多列也不空表),来源标成 union,面板据此提示「拿到的是回落目录」。 + var okProbes []probeResult + for _, p := range []struct { label string res probeResult }{ {"desktop-UA", v3Desktop}, {"IDE-UA", v3IDE}, {"CLI-UA", v3CLI}, - }) + } { + if p.res.err != nil { + log.Printf("WARN: [upstream] global models: v3/config %s probe failed: %v", p.label, p.res.err) + continue + } + okProbes = append(okProbes, p.res) + } + var v3 probeResult + // catalogSource 记录本次目录由哪一族给出(写入缓存供面板展示)。 + catalogSource := globalCatalogSourceDesktop + switch { + case len(okProbes) == 0: + // 三路全失败:err 非空,下游按既有「v3 失败」分支降级或负缓存。 + v3 = probeResult{err: v3Desktop.err} + case v3Desktop.err == nil: + v3 = probeResult{names: v3Desktop.names, infos: v3Desktop.infos} + var enrichSources [][]ModelInfo + if v3IDE.err == nil { + enrichSources = append(enrichSources, v3IDE.infos) + } + if v3CLI.err == nil { + enrichSources = append(enrichSources, v3CLI.infos) + } + if len(enrichSources) > 0 { + v3.infos = enrichModelInfos(v3.infos, enrichSources...) + } + case len(okProbes) == 1: + v3 = okProbes[0] + catalogSource = globalCatalogSourceUnion + default: + // 桌面族探测失败(或未返回):回落既有并集语义。 + vn, vi := okProbes[0].names, okProbes[0].infos + for _, p := range okProbes[1:] { + vn, vi = mergeGlobalCatalog(vn, vi, p.names, p.infos) + } + v3 = probeResult{names: vn, infos: vi} + catalogSource = globalCatalogSourceUnion + } if v3.err != nil && enterprise.err != nil { // 两路全失败 → 负缓存语义(等价原家族端点全非 2xx)。 - return nil, nil, nil, nil, v3.err + return nil, nil, nil, nil, "", v3.err } if v3.err != nil { // /v3 失败降级:不拖累企业端点结果(降级仅企业端点 + warn)。 + // 这条路径就是 issue #216 的现场:企业端点只有 18 条旧目录,且 hy4-preview + // 的 credits 在那里是 x0.00(v3 是 x0.29)——来源必须能被面板看见,否则 + // 用户只会看到「模型少了 11 个、倍率是 0」而不知道是降级。 log.Printf("WARN: [upstream] global models: v3/config probe failed (degraded to enterprise endpoint): %v", v3.err) names, infos, efforts, defaults = extractEfforts(enterprise.infos) - return names, infos, efforts, defaults, nil + if len(names) == 0 { + names = enterprise.names + } + return names, infos, efforts, defaults, globalCatalogSourceEnterprise, nil } if enterprise.err != nil { log.Printf("WARN: [upstream] global models: enterprise endpoint failed (v3/config only): %v", enterprise.err) names, infos, efforts, defaults = extractEfforts(v3.infos) - return names, infos, efforts, defaults, nil + if len(names) == 0 { + names = v3.names + } + return names, infos, efforts, defaults, catalogSource, nil } // 两路皆成功:v3 为主、企业端点补缺合并(含 effort 桶合并,v3 权威)。 v3Names, v3Infos, v3Efforts, v3Defaults := extractEfforts(v3.infos) @@ -261,10 +379,76 @@ func (c *Client) probeGlobalModels(a *auth.Auth) (names []string, infos []ModelI if len(entNames) == 0 { entNames = enterprise.names } - names, infos = mergeGlobalCatalog(v3Names, v3Infos, entNames, entInfos) + if catalogSource == globalCatalogSourceDesktop { + // 桌面族权威:企业端点**不贡献 id**(它带着 deep-model / gpt-5.3-codex 这类 + // 已下架或 11102 的旧条目),只按同 id 补元数据(它给的 credits 也可能过期, + // 故只补缺失字段,不覆盖桌面族已有值)。 + names, infos = v3Names, enrichModelInfos(v3Infos, entInfos) + } else { + names, infos = mergeGlobalCatalog(v3Names, v3Infos, entNames, entInfos) + } efforts = mergeEffortBuckets(v3Efforts, entEfforts) defaults = mergeEffortDefaults(v3Defaults, entDefaults) - return names, infos, efforts, defaults, nil + return names, infos, efforts, defaults, catalogSource, nil +} + +// enrichModelInfos 仅按同 id 补元数据:base 里已有非零值的字段一律保留(base 权威), +// 缺字段才从后续来源取第一个非零值。**不新增 id**——这是与 mergeGlobalCatalog 的关键 +// 差别:issue #216 要求模型目录对齐官方桌面端 picker,别的 UA 族/企业端点只用来给 +// 同一批模型补 context/efforts/credits 等字段。 +func enrichModelInfos(base []ModelInfo, sources ...[]ModelInfo) []ModelInfo { + if len(base) == 0 || len(sources) == 0 { + return base + } + index := make(map[string]ModelInfo, len(sources[0])) + for _, src := range sources { + for _, mi := range src { + if mi.ID == "" { + continue + } + if _, ok := index[mi.ID]; !ok { + index[mi.ID] = mi + } + } + } + out := make([]ModelInfo, 0, len(base)) + for _, mi := range base { + other, ok := index[mi.ID] + if !ok { + out = append(out, mi) + continue + } + merged := mi + if merged.MaxTokens == 0 { + merged.MaxTokens = other.MaxTokens + } + if merged.ContextWindow == 0 { + merged.ContextWindow = other.ContextWindow + } + if merged.MaxAllowedSize == 0 { + merged.MaxAllowedSize = other.MaxAllowedSize + } + if merged.Credits == "" { + merged.Credits = other.Credits + } + if len(merged.Efforts) == 0 { + merged.Efforts = other.Efforts + } + if merged.DefaultEffort == "" { + merged.DefaultEffort = other.DefaultEffort + } + if merged.Description == "" { + merged.Description = other.Description + } + if len(merged.Tags) == 0 { + merged.Tags = other.Tags + } + if merged.Vendor == "" { + merged.Vendor = other.Vendor + } + out = append(out, merged) + } + return out } // probeResult 单路模型目录探测结果(/v3/config 某 UA 路,或企业端点家族)。 diff --git a/internal/upstream/global_models_routes_test.go b/internal/upstream/global_models_routes_test.go index 4def4c4a..6869e18e 100644 --- a/internal/upstream/global_models_routes_test.go +++ b/internal/upstream/global_models_routes_test.go @@ -140,25 +140,39 @@ func TestNonChatModelGenerationTags(t *testing.T) { } } -// TestDesktopUAForGlobalAccount 桌面端 UA 按 realm 切平台段:global 账号必须是 -// `WorkBuddy AI`(送错平台段会触发上游 403 code=11140 风控)。探测主路用这个 UA, -// 与 chat 路径同源——global 目录探测因此拿到客户端同款模型集。 +// TestDesktopUAForGlobalAccount 桌面端 UA 按 realm 切品牌段。 +// +// 品牌段的**真值**来自 2026-10-20 同机同代理双版本真机抓包(CN 115 条 / GL 151 条): +// +// CN 5.8.3 → WorkBuddy/5.8.3 WorkBuddy/5.8.3 CLI/2.161.0 (驼峰原样) +// global 5.8.4 → workbuddy-ai/5.8.4 workbuddy-ai/5.8.4 CLI/2.161.0(小写连字符 slug) +// +// 旧断言要求 global 含 `WorkBuddy AI`(展示名本体),那是**按「客户端 UA 推理」** +// 得出的猜测,与真机不符:global 走 Electron userAgentFallback 的 asciiSlug 路径, +// `WorkBuddy AI` → `workbuddy-ai`。送错品牌段正是 #231「渠道校验拦截」的一类诱因, +// 故此处按实测值钉死。 func TestDesktopUAForGlobalAccount(t *testing.T) { c := New() global := &auth.Auth{UID: "u1", Domain: "www.workbuddy.ai"} global.BackfillRealm() ua := c.defaultWorkBuddyUAFor(global) - if want := "WorkBuddy AI"; !contains(ua, want) { + if want := "workbuddy-ai/"; !contains(ua, want) { t.Errorf("global desktop UA = %q, want it to contain %q", ua, want) } + if contains(ua, "WorkBuddy AI") { + t.Errorf("global desktop UA = %q, must not contain the display-name form %q", ua, "WorkBuddy AI") + } if !contains(ua, "CLI/") { t.Errorf("global desktop UA = %q, want CLI segment", ua) } - // CN 账号保持 WorkBuddy(零回归)。 + // CN 账号保持驼峰 WorkBuddy(零回归,且 slug 化才是非官方指纹)。 cn := &auth.Auth{UID: "u2", Domain: "copilot.tencent.com"} cn.BackfillRealm() - if ua := c.defaultWorkBuddyUAFor(cn); contains(ua, "WorkBuddy AI") { - t.Errorf("cn desktop UA = %q, must not contain 'WorkBuddy AI'", ua) + if ua := c.defaultWorkBuddyUAFor(cn); !contains(ua, "WorkBuddy/") { + t.Errorf("cn desktop UA = %q, want the camelCase brand segment", ua) + } + if ua := c.defaultWorkBuddyUAFor(cn); contains(ua, "workbuddy-ai/") { + t.Errorf("cn desktop UA = %q, must not use the global slug brand", ua) } } diff --git a/internal/upstream/global_models_test.go b/internal/upstream/global_models_test.go new file mode 100644 index 00000000..834f760e --- /dev/null +++ b/internal/upstream/global_models_test.go @@ -0,0 +1,263 @@ +// global_models_test.go global 模型目录探测的 UA 家族覆盖回归。 +package upstream + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" +) + +// TestGlobalCatalogProbesDesktopUA 锁定「/v3/config 目录按 UA 家族分档」这一上游事实: +// 仅桌面端 UA 下发的模型必须出现在并集里。 +// +// 回归目标(上游 #102 / PR #103):gpt-6-luna 只在桌面端目录下发、实际可调用 +// (实测 HTTP 200),只探 IDE + CLI 两路时它永远进不了 /v1/models。 +func TestGlobalCatalogProbesDesktopUA(t *testing.T) { + prev := auth.GlobalEnabled() + auth.SetGlobalEnabled(true) + defer auth.SetGlobalEnabled(prev) + + const desktopOnly = "gpt-6-luna" + // 三路 /v3/config 并发探测 → handler 并发写,必须加锁(CI 曾现 concurrent map writes)。 + var seenMu sync.Mutex + seenUA := map[string]bool{} + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasSuffix(r.URL.Path, "/v3/config") { + // 企业端点家族一律 500:本用例只验证 v3 多路并集,同时覆盖 + // 「家族路失败不拖累 v3 结果」的降级语义。 + w.WriteHeader(http.StatusInternalServerError) + return + } + ua := r.Header.Get("User-Agent") + seenMu.Lock() + seenUA[ua] = true + seenMu.Unlock() + ids := []string{"shared-model"} + // 官方 UA 只有两种形态(2026-10-20 真机抓包):三段重复品牌(桌面族)与 + // 两段单品牌(CLI 族)。旧的 CodeBuddyIDE/CLI 家族字面量在分发包里不存在。 + switch { + case repeatedBrand(ua): + ids = append(ids, desktopOnly) + case strings.HasPrefix(ua, "WorkBuddy/"), strings.HasPrefix(ua, "workbuddy-ai/"): + ids = append(ids, "cli-only") + default: + t.Errorf("v3/config 探测带了非预期 UA: %q", ua) + } + models := make([]map[string]any, 0, len(ids)) + for _, id := range ids { + models = append(models, map[string]any{ + "id": id, "maxInputTokens": 100000, "maxOutputTokens": 32000, + }) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "code": 0, + "data": map[string]any{"models": models}, + }) + })) + defer srv.Close() + + c := New() + c.HTTP = srv.Client() + c.ChatBaseGlobal = srv.URL + c.GlobalEnabled = true + a := &auth.Auth{UID: "g1", AccessToken: "at", Domain: "www.workbuddy.ai"} + + names := c.FetchGlobalModels(a) + got := make(map[string]bool, len(names)) + for _, n := range names { + got[n] = true + } + for _, want := range []string{"shared-model", desktopOnly} { + if !got[want] { + t.Errorf("FetchGlobalModels 缺少 %q(并集=%v)", want, names) + } + } + // issue #216:桌面族成功时它是目录权威,IDE/CLI 独有模型不再进目录 + //(实测它们在 chat 端点回 11102 “service info not found”,列出来只会让 + // 客户端选到死模型;官方桌面端 picker 也只显示桌面族那 27 个)。 + for _, unwanted := range []string{"ide-only", "cli-only"} { + if got[unwanted] { + t.Errorf("桌面族成功时不应列出非桌面族独有模型 %q(目录=%v)", unwanted, names) + } + } + if src := c.GlobalCatalogSource(); src != globalCatalogSourceDesktop { + t.Errorf("目录来源=%q want %q", src, globalCatalogSourceDesktop) + } + seenMu.Lock() + seen := make([]string, 0, len(seenUA)) + for ua := range seenUA { + seen = append(seen, ua) + } + seenMu.Unlock() + if len(seen) != 2 { + t.Errorf("v3/config 应探 2 种官方 UA 形态,实际 %d 种: %v", len(seen), seen) + } +} + +// TestGlobalCatalogDesktopProbeFailureFallsBackToUnion 桌面族探测失败时回落 +// IDE+CLI 并集(宁可多列也不空表),并把来源标成 v3-union 供面板提示。 +func TestGlobalCatalogDesktopProbeFailureFallsBackToUnion(t *testing.T) { + prev := auth.GlobalEnabled() + auth.SetGlobalEnabled(true) + defer auth.SetGlobalEnabled(prev) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasSuffix(r.URL.Path, "/v3/config") { + w.WriteHeader(http.StatusInternalServerError) + return + } + ua := r.Header.Get("User-Agent") + var ids []string + switch { + case repeatedBrand(ua): + // 桌面族失败(模拟上游对该族 500)。 + w.WriteHeader(http.StatusInternalServerError) + return + case strings.HasPrefix(ua, "WorkBuddy/"): + ids = []string{"cli-model"} + default: + ids = []string{"other-model"} + } + models := make([]map[string]any, 0, len(ids)) + for _, id := range ids { + models = append(models, map[string]any{"id": id, "maxInputTokens": 1000, "maxOutputTokens": 1000}) + } + _ = json.NewEncoder(w).Encode(map[string]any{"code": 0, "data": map[string]any{"models": models}}) + })) + defer srv.Close() + + c := New() + c.HTTP = srv.Client() + c.ChatBaseGlobal = srv.URL + c.GlobalEnabled = true + names := c.FetchGlobalModels(&auth.Auth{UID: "g1", AccessToken: "at", Domain: "www.workbuddy.ai"}) + got := map[string]bool{} + for _, n := range names { + got[n] = true + } + if !got["cli-model"] { + t.Fatalf("桌面族失败时应回落 CLI 族结果,实际=%v", names) + } + if src := c.GlobalCatalogSource(); src != globalCatalogSourceUnion { + t.Errorf("目录来源=%q want %q", src, globalCatalogSourceUnion) + } +} + +// TestGlobalCatalogDesktopAuthorityExcludesEnterpriseLegacy 桌面族成功时,企业端点 +// **不得**再往目录里加 id(它带着 deep-model / gpt-5.3-codex 这类已下架或 11102 的 +// 旧条目)——这正是 issue #216 里「多出两个官方目录没有的模型」的来源。 +// 企业端点的同 id 条目仍用于补元数据。 +func TestGlobalCatalogDesktopAuthorityExcludesEnterpriseLegacy(t *testing.T) { + prev := auth.GlobalEnabled() + auth.SetGlobalEnabled(true) + defer auth.SetGlobalEnabled(prev) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/v3/config"): + _ = json.NewEncoder(w).Encode(map[string]any{"code": 0, "data": map[string]any{ + "models": []map[string]any{{"id": "desk-model", "maxOutputTokens": 32000}}, + }}) + case strings.HasSuffix(r.URL.Path, "/v2/enterprises/personal/models"): + _ = json.NewEncoder(w).Encode(map[string]any{"code": 0, "data": map[string]any{ + "models": []map[string]any{ + {"id": "desk-model", "maxInputTokens": 176000}, + {"id": "deep-model"}, {"id": "gpt-5.3-codex"}, + }, + }}) + default: + w.WriteHeader(http.StatusInternalServerError) + } + })) + defer srv.Close() + + c := New() + c.HTTP = srv.Client() + c.ChatBaseGlobal = srv.URL + c.GlobalEnabled = true + names := c.FetchGlobalModels(&auth.Auth{UID: "g1", AccessToken: "at", Domain: "www.workbuddy.ai"}) + got := map[string]bool{} + for _, n := range names { + got[n] = true + } + if !got["desk-model"] { + t.Fatalf("桌面族模型缺失: %v", names) + } + for _, legacy := range []string{"deep-model", "gpt-5.3-codex"} { + if got[legacy] { + t.Errorf("企业端点旧条目 %q 不应进目录: %v", legacy, names) + } + } +} + +// TestInvalidateGlobalModelsCacheReprobes 锁定「重新获取」按钮的语义(issue #216 现象 3): +// 清缓存后必须真的再打一次上游,而不是继续读 1h 缓存。 +func TestInvalidateGlobalModelsCacheReprobes(t *testing.T) { + prev := auth.GlobalEnabled() + auth.SetGlobalEnabled(true) + defer auth.SetGlobalEnabled(prev) + + var v3Hits, entHits int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/v3/config"): + atomic.AddInt32(&v3Hits, 1) + _ = json.NewEncoder(w).Encode(map[string]any{"code": 0, "data": map[string]any{ + "models": []map[string]any{{"id": "m1"}}, + }}) + case strings.HasSuffix(r.URL.Path, "/v2/enterprises/personal/models"): + atomic.AddInt32(&entHits, 1) + _ = json.NewEncoder(w).Encode(map[string]any{"code": 0, "data": map[string]any{ + "models": []map[string]any{{"id": "m1"}}, + }}) + default: + w.WriteHeader(http.StatusInternalServerError) + } + })) + defer srv.Close() + + c := New() + c.HTTP = srv.Client() + c.ChatBaseGlobal = srv.URL + c.GlobalEnabled = true + a := &auth.Auth{UID: "g1", AccessToken: "at", Domain: "www.workbuddy.ai"} + + c.FetchGlobalModels(a) + first := atomic.LoadInt32(&v3Hits) + if first == 0 { + t.Fatal("首次探测没有打上游") + } + // 缓存命中:不再打上游。 + c.FetchGlobalModels(a) + if got := atomic.LoadInt32(&v3Hits); got != first { + t.Fatalf("缓存命中期间不应再探上游:before=%d after=%d", first, got) + } + // 清缓存后必须重探。 + c.InvalidateGlobalModelsCache() + if src := c.GlobalCatalogSource(); src != "" { + t.Errorf("清缓存后来源应为空,got %q", src) + } + c.FetchGlobalModels(a) + if got := atomic.LoadInt32(&v3Hits); got <= first { + t.Fatalf("清缓存后应重新探测:before=%d after=%d", first, got) + } +} + +// repeatedBrand 报告 UA 是否形态为「品牌段重复两次」(官方桌面族三段式特征): +// `WorkBuddy/5.8.3 WorkBuddy/5.8.3 CLI/2.161.0` / +// `workbuddy-ai/5.8.4 workbuddy-ai/5.8.4 CLI/2.161.0`。 +func repeatedBrand(ua string) bool { + for _, b := range []string{"WorkBuddy/", "workbuddy-ai/"} { + if strings.Count(ua, b) >= 2 { + return true + } + } + return false +} diff --git a/internal/upstream/headers.go b/internal/upstream/headers.go index a121a8ea..4d10c538 100644 --- a/internal/upstream/headers.go +++ b/internal/upstream/headers.go @@ -4,7 +4,9 @@ package upstream import ( "crypto/sha256" + "encoding/base64" "encoding/hex" + "encoding/json" "net/http" "strings" @@ -13,13 +15,19 @@ import ( ) const ( - // defaultClientVersion 出站 WorkBuddy 客户端版本段(UA 的 `WorkBuddy/<ver>` 与 - // 白名单头组的 X-IDE-Version)。对齐官方 WorkBuddy Desktop 分发包版本(5.5.4)。 - // config upstream.client_version 可覆盖(空 = 内置默认)。 - defaultClientVersion = "5.5.4" - // defaultCliVersion 出站 UA 中 `CLI/<ver>` 段版本。对齐官方内置 CLI(2.137.1)。 - // config upstream.cli_version 可覆盖(空 = 内置默认)。 - defaultCliVersion = "2.137.1" + // defaultClientVersionCN / defaultClientVersionGlobal 出站 WorkBuddy 客户端版本段 + // (UA 的 `<brand>/<ver>` 与白名单头组的 X-IDE-Version)。 + // + // 取值来源:官方两个分发包的 resources/install-manifest.json —— 国内版 WorkBuddy + // 5.8.3(endpoint www.workbuddy.cn)、国际版 WorkBuddy AI 5.8.4(endpoint + // www.workbuddy.ai)。两版发版节奏不同,故**按 realm 分开**而不是共用一值: + // 国际版号用 CN 的 5.8.3 会与上游实际分发包版本对不上。 + // config upstream.client_version 可覆盖(空 = 按 realm 取内置默认)。 + defaultClientVersionCN = "5.8.3" + defaultClientVersionGlobal = "5.8.4" + // defaultCliVersion 出站 UA 中 `CLI/<ver>` 段版本。对齐官方内置 CLI(2.161.0, + // 两版分发包内置同一版本,见 cli/package.json)。config upstream.cli_version 可覆盖。 + defaultCliVersion = "2.161.0" originRefererCN = "https://www.codebuddy.cn" originRefererGlobal = "https://www.workbuddy.ai" @@ -35,12 +43,15 @@ func originRefererFor(a *auth.Auth) string { } // clientVersion 生效的 WorkBuddy 客户端版本:Client.ClientVersion 非空则取之, -// 否则内置默认 defaultClientVersion。 -func (c *Client) clientVersion() string { +// 否则按 realm 取内置默认(CN 5.8.3 / global 5.8.4)。 +func (c *Client) clientVersion(a *auth.Auth) string { if c != nil && c.ClientVersion != "" { return c.ClientVersion } - return defaultClientVersion + if a != nil && a.IsGlobal() { + return defaultClientVersionGlobal + } + return defaultClientVersionCN } // cliVersion 生效的 CLI 版本:Client.CliVersion 非空则取之,否则内置默认 defaultCliVersion。 @@ -51,19 +62,44 @@ func (c *Client) cliVersion() string { return defaultCliVersion } -// defaultWorkBuddyUAFor 组装默认客户端出站 UA(官方桌面端 RestOperations 层形状): -// `WorkBuddy/<clientVersion> <platform>/<clientVersion> CLI/<cliVersion>`。 -// 平台段(第二段)品牌按 realm 切换——CN 用 applicationName 同值 `WorkBuddy`, -// global 用官方国际版 productName `WorkBuddy AI`(intl 项目逆向证据: -// `WorkBuddy/5.5.2 WorkBuddy AI/5.5.2 CLI/5.5.2`)。 -// global 账号送错平台段(`WorkBuddy` 非 `WorkBuddy AI`)可能触发上游 403 code 11140 -// "request illegal" 风控。官方无任何 UA 随机化,故默认确定性。 +// defaultWorkBuddyUAFor 组装默认客户端出站 UA(官方客户端 buildUserAgent 形态): +// `WorkBuddy/<ver> <brand>/<ver> CLI/<cliVer>`(CN)或 +// `workbuddy-ai/<ver> workbuddy-ai/<ver> CLI/<cliVer>`(global)。 +// +// 首段与第二段用**同一个**品牌段(真机两段一致):CN `WorkBuddy/5.8.3 WorkBuddy/5.8.3`, +// global `workbuddy-ai/5.8.4 workbuddy-ai/5.8.4`。2026-10-20 双版本真机抓包实测: +// +// CN 5.8.3 → WorkBuddy/5.8.3 WorkBuddy/5.8.3 CLI/2.161.0 +// global 5.8.4 → workbuddy-ai/5.8.4 workbuddy-ai/5.8.4 CLI/2.161.0 +// +// global 账号送错品牌段可能触发上游 403 code 11140 "request illegal" 风控。 +// 官方无任何 UA 随机化,故默认确定性。 func (c *Client) defaultWorkBuddyUAFor(a *auth.Auth) string { - platform := "WorkBuddy" + brand := uaBrandFor(a) + ver := c.clientVersion(a) + return brand + "/" + ver + " " + brand + "/" + ver + " CLI/" + c.cliVersion() +} + +// uaBrandFor 出站 UA 的**第二段**品牌:按 realm 取,两版形态不同(不同分发包)。 +// +// 2026-10-20 真机双版本抓包实测(同一台机器、同一套代理): +// - 国内版 5.8.3(productName `WorkBuddy`)→ `WorkBuddy/5.8.3`,**驼峰原样**, +// 业务链路 28 个路径全部如此; +// - 国际版 5.8.4(productName `WorkBuddy AI`)→ `workbuddy-ai/5.8.4`,**小写连字符 +// slug**,业务链路 22 个路径全部如此。 +// +// 为什么两版规则不同:brand 段来自 buildUserAgent() 的 applicationName/productName, +// 而 applicationName 在打包期被改写。CN 包直接沿用驼峰 productName;GL 包走了 +// Electron 的 userAgentFallback 替换路径(asciiSlug:非字母数字换成 `-` 并转小写), +// 于是 `WorkBuddy AI` → `workbuddy-ai`。 +// +// 反例警示:曾把 CN 也写成小写 `workbuddy`(按 GL 规则统一),与实测不符——CN 的 +// 每个业务请求都是驼峰,写成 slug 反而成了最明显的非官方指纹。 +func uaBrandFor(a *auth.Auth) string { if a != nil && a.IsGlobal() { - platform = "WorkBuddy AI" + return "workbuddy-ai" } - return "WorkBuddy/" + c.clientVersion() + " " + platform + "/" + c.clientVersion() + " CLI/" + c.cliVersion() + return "WorkBuddy" } // defaultWorkBuddyUA 返回 CN 形态的默认 UA(默认账号形态即 CN,零回归兼容既有调用/测试)。 @@ -71,6 +107,37 @@ func (c *Client) defaultWorkBuddyUA() string { return c.defaultWorkBuddyUAFor(nil) } +// v3ProbeUA /v3/config 目录探测的官方 UA(真机实测形态)。 +// +// 2026-10-20 双版本抓包实测,两版对同一个 /v3/config 端点发的 UA 并不同: +// +// CN 5.8.3 → WorkBuddy/5.8.3 WorkBuddy/5.8.3 CLI/2.161.0 (三段,重复品牌) +// global 5.8.4 → WorkBuddy/5.8.4 CLI/2.161.0 (两段,单品牌) +// +// 旧实现在这里发 `CodeBuddyIDE/4.12.0 CodeBuddy/4.12.0` 与 +// `CLI/2.63.2 CodeBuddy/2.63.2` 两套 CodeBuddy 家族 UA —— 那是从旧版 IDE +// 客户端抄来的,WorkBuddy 分发包里根本不存在这两个串,属于最显眼的非官方指纹。 +func (c *Client) v3ProbeUA(a *auth.Auth) string { + ver := c.clientVersion(a) + if a != nil && a.IsGlobal() { + return "WorkBuddy/" + ver + " CLI/" + c.cliVersion() + } + return "WorkBuddy/" + ver + " " + uaBrandFor(a) + "/" + ver + " CLI/" + c.cliVersion() +} + +// v3ProbeUALite /v3/config 第二路探测 UA:单品牌两段式。 +// +// 保留双路探测是因为该端点按 UA 家族下发**不同模型集合**(实测 CN 19 / 32 / 54 条, +// global 13 / 22 / 29 条,三族各有独有模型),只探一族会让 /v1/models 少模型。 +// 但两路都必须是官方真实出现过的形态——这里取 global 的 config UA 形态 +// (`WorkBuddy/<ver> CLI/<cli>`,与 v3ProbeUA 在 CN 下的三段式互补)。 +// +// 注意:global 账号下本函数与 v3ProbeUA 返回同一串(真机 global 只发这一种形态), +// 于是两路探测退化为重复请求——无害(并集去重),但不要据此认为 global 有第三族。 +func (c *Client) v3ProbeUALite(a *auth.Auth) string { + return "WorkBuddy/" + c.clientVersion(a) + " CLI/" + c.cliVersion() +} + // userAgent 返回当前出站 UA(客户端出站路径:chat/refresh/FetchModels)。 // 优先级:Client.UserAgent(config user_agent)显式覆盖 > 按账号 realm 的默认 WorkBuddy 三段式。 // 显式覆盖兼容既有覆盖逻辑:用户配了即以用户值为准(自定义品牌/版本), @@ -82,14 +149,20 @@ func (c *Client) userAgent(a *auth.Auth) string { return c.defaultWorkBuddyUAFor(a) } -// billingUA 白名单类(billing/checkin/banner)出站 UA:单段 `WorkBuddy/<clientVersion>` -// (官方 banner 显式覆写形态,不带 CLI 段)。默认生效(伪造官方桌面端指纹); -// 显式 client_name="SaaS" 才不设 UA(还原旧行为,Go 默认 UA)。 -func (c *Client) billingUA() string { +// billingUA 白名单类(billing/checkin/banner)出站 UA:**恒为 axios 默认串**。 +// +// 2026-10-20 双版本真机抓包实测:两版 billing 层(summary / paid-packages / +// free-packages / checkin-activity-status / ambassador status)发的都是 +// `axios/1.16.1`,与客户端自身 UA 完全不同——billing 域的 RestOperations 走 +// axios 实例而非 SDK 客户端,所以不带任何品牌段。 +// +// 此前 CN 侧发 `WorkBuddy/<ver>`:那是「按客户端 UA 推理」的产物,实测不符。 +// 显式 client_name="SaaS" 时仍不设 UA(保留既有逃生开关,还原 Go 默认 UA)。 +func (c *Client) billingUA(a *auth.Auth) string { if c == nil || c.attributionClientName() == "SaaS" { return "" } - return "WorkBuddy/" + c.clientVersion() + return "axios/1.16.1" } // resolveDeviceToken 解析本次请求的 X-Device-Token 取值。 @@ -165,12 +238,62 @@ func (c *Client) CommonHeaders(req *http.Request, a *auth.Auth) { c.injectAccountStableHeaders(req, a) } -// acceptLanguageFor 按账号 realm 返回 Accept-Language:global → en-US,cn → zh-CN。 +// acceptLanguageFor 返回 Accept-Language。 +// +// 2026-10-20 双版本真机抓包实测:这个头**不按账号域切**,而是跟随操作系统区域。 +// 同一台中文系统机器上,CN 与 GL 的表现一致:多数请求根本不带该头(CN 128 条里 +// 71 条无、GL 218 条里 80 条无),显式带上时以 `zh`(billing 层固定)与 +// `zh-CN`(Chromium 渲染层)为主,`en-US` 只在 GL 出现 1 次。 +// +// 旧实现「global → en-US」是按账号域臆测的,实测不成立(曾据此把国际版所有请求 +// 都打成 en-US,反而成了与真机不符的指纹)。网关没有操作系统区域可言,统一取 +// billing 层实测的 `zh`,两端同值。 func acceptLanguageFor(a *auth.Auth) string { - if a != nil && a.IsGlobal() { - return "en-US" + return "zh" +} + +// injectIdentityHeaders 注入官方客户端的身份头族(X-Auth-Method / X-Id-Source / +// X-Userinfo)。三个头都是**按账号形态条件发送**,不是常量——官方客户端的 +// runIdentityHeaders()(cli 5.8.4 模块 30876 反编译)等价于: +// +// 有 uid → X-User-Id = uid +// 有 enterpriseId → X-Enterprise-Id = enterpriseId +// 有 auth.method → X-Auth-Method = method(= JWT 的 token_source 声明) +// 有 idSource → X-Id-Source = idSource +// 有 uid 且 (enterpriseId 或 idSource 或 method 任一存在) → +// X-Userinfo = base64(JSON{uin, owner_uin?, id_source?, token_source?}) +// +// X-Userinfo 的三段都是**条件拼接**:缺哪个字段就不出现在 JSON 里,而不是写空串。 +// 个人号三个维度全缺——实测国际版个人账号的出站头里这三头都不存在,故本函数对纯 +// 个人号零注入。这不是省略,是与客户端一致:凭「个人号也该有 X-Userinfo」去补空值 +// 反而是伪造指纹,比不发更容易被识别。 +// +// uin 段取 uid(不是账号的 uin 数字串):反编译逻辑写的就是 {uin: n.uid}。 +func (c *Client) injectIdentityHeaders(req *http.Request, a *auth.Auth) { + if a == nil || a.UID == "" { + return + } + method := a.TokenSource() + if method != "" { + req.Header.Set("X-Auth-Method", method) + } + enterprise := strings.TrimSpace(a.EnterpriseID) + if enterprise == "" && method == "" { + // 个人号且 token 无 token_source:与官方客户端同样三头全不发。 + return } - return "zh-CN" + payload := map[string]any{"uin": a.UID} + if enterprise != "" { + payload["owner_uin"] = enterprise + } + if method != "" { + payload["token_source"] = method + } + raw, err := json.Marshal(payload) + if err != nil { + return + } + req.Header.Set("X-Userinfo", base64.StdEncoding.EncodeToString(raw)) } // injectCodeBuddyRequest 在 req 注入 X-CodeBuddy-Request: 1。 @@ -185,7 +308,7 @@ func (c *Client) injectCodeBuddyRequest(req *http.Request) { // 复用同值;TraceID 透传入站值(空 = 回落 conversationRequestID)。 // messageID(消息级,每条独立)由 ChatHeaders 内部生成,无需外部可见。 type ChatMeta struct { - ConversationID string // X-Conversation-ID:body 提取的入站值,空则不发(透传优先,不伪造) + ConversationID string // X-Conversation-ID:透传入站值,空则生成请求级兜底 ConversationRequestID string // X-Conversation-Request-ID / X-Root-Request-ID:聚合主键,必发 TraceID string // X-Trace-ID:入站透传值,空则回落 conversationRequestID } @@ -203,6 +326,11 @@ func (c *Client) ChatHeaders(req *http.Request, a *auth.Auth, clientIP string, m // (见 auth.AccessTokenValue 注释)。 if at := a.AccessTokenValue(); at != "" { req.Header.Set("Authorization", "Bearer "+at) + // 静态 API Key 账号:官方 CLI 两个头都发(Authorization 与 X-API-Key 任一 + // 均可鉴权),双头对齐官方形态,避免只看 X-API-Key 的边缘网关判定缺头。 + if a.IsAPIKey() { + req.Header.Set("X-API-Key", at) + } } else { req.Header.Set("X-No-Authorization", "1") } @@ -231,7 +359,13 @@ func (c *Client) ChatHeaders(req *http.Request, a *auth.Auth, clientIP string, m c.injectGlobalChatHeaders(req, a) } // 用量归属头:默认伪造 WorkBuddy 桌面端指纹(client_name="SaaS" 还原旧行为)。 - c.injectAttribution(req) + c.injectAttribution(req, a) + // X-Private-Data: true —— 官方 chat 出站恒带(applyGatewayHeaders 实测)。 + // 语义是「请求含私有数据」,客户端无条件声明,故这里也常发。 + req.Header.Set("X-Private-Data", "true") + // 身份头族(X-Auth-Method / X-Userinfo):按账号形态条件注入,见 + // injectIdentityHeaders——个人号三头全不发才是官方形态。 + c.injectIdentityHeaders(req, a) // 客户端 IP 透传(仅 PassthroughIP=true 且本次请求带 IP)。 c.injectClientIP(req, clientIP) // 设备风控头:auth 每号 > config 全局 > 文件兜底;空则不注入。 @@ -256,8 +390,9 @@ func (c *Client) injectGlobalChatHeaders(req *http.Request, a *auth.Auth) { // injectConversationHeaders 注入官方客户端会话头族(issue #35 后台聚合)。 // 头族分四层,各司其职: -// - X-Conversation-ID:会话级,多轮稳定(body 的 conversationId)。空则不发—— -// 透传客户端原值优先,客户端没给就不伪造,避免误导后台建错会话。 +// - X-Conversation-ID:优先透传调用方的会话 ID;缺失时生成请求级兜底。 +// 国际版渠道校验要求三个 X-Conversation-* 头齐全,缺任一个会报 +// 400 code=11128 "conversation id missing"(issue #187 / #188)。 // - X-Conversation-Request-ID:**对话轮级聚合主键**,必发。一次 user send 内的 // 所有 tool call/重试/换号/降级复用同一个 → 后台按它聚合成一条(不再碎片化)。 // - X-Conversation-Message-ID = X-Request-ID:消息级,每条独立(32 位 hex)。 @@ -274,9 +409,11 @@ func (c *Client) injectConversationHeaders(req *http.Request, meta ChatMeta) { convReqID = session.NewMessageID() } messageID := session.NewMessageID() - if meta.ConversationID != "" { - req.Header.Set("X-Conversation-ID", meta.ConversationID) + conversationID := meta.ConversationID + if conversationID == "" { + conversationID = session.NewMessageID() } + req.Header.Set("X-Conversation-ID", conversationID) req.Header.Set("X-Conversation-Request-ID", convReqID) req.Header.Set("X-Conversation-Message-ID", messageID) req.Header.Set("X-Request-ID", messageID) @@ -327,7 +464,7 @@ func (c *Client) attributionClientName() string { // + X-IDE-Name/Type/Product="WorkBuddy" + X-IDE-Version=client_version,上游用量归因 // 不再出现 client/agentPurpose 为空的「网关特征」。显式 ClientName="SaaS" 还原旧行为 // (仅 X-Product="SaaS",不设 X-IDE-*);配其他值则四头跟随该值。 -func (c *Client) injectAttribution(req *http.Request) { +func (c *Client) injectAttribution(req *http.Request, a *auth.Auth) { name := c.attributionClientName() if name == "SaaS" { req.Header.Set("X-Product", "SaaS") @@ -336,7 +473,7 @@ func (c *Client) injectAttribution(req *http.Request) { req.Header.Set("X-Agent-Purpose", "conversation") req.Header.Set("X-IDE-Name", name) req.Header.Set("X-IDE-Type", name) - req.Header.Set("X-IDE-Version", c.clientVersion()) + req.Header.Set("X-IDE-Version", c.clientVersion(a)) req.Header.Set("X-Product", name) } @@ -382,7 +519,7 @@ func (c *Client) BillingHeaders(req *http.Request, a *auth.Auth) { req.Header.Set("Accept-Language", acceptLanguageFor(a)) if c != nil && c.UserAgent != "" { req.Header.Set("User-Agent", c.UserAgent) - } else if ua := c.billingUA(); ua != "" { + } else if ua := c.billingUA(a); ua != "" { req.Header.Set("User-Agent", ua) } if a.UID != "" { diff --git a/internal/upstream/hint.go b/internal/upstream/hint.go index 55bfa248..04e88783 100644 --- a/internal/upstream/hint.go +++ b/internal/upstream/hint.go @@ -55,10 +55,17 @@ func GatewayHint(kind ErrKind, msg string, ctx HintContext) string { return "rate limited by upstream; retry after reset" case ErrAccountFault: return "account-level fault at upstream (auth/quota state); the gateway will rotate or disable this account" + case ErrChannelRefused: + // 11-128/11128 通道校验失败:账号健康,问题在请求指纹/出口通道。给可操作的排查指向。 + return "upstream rejected the request channel (it may not look like the official client); check user-agent/headers or an intercepting proxy, then retry" case ErrSessionDead: return "account session expired at upstream; the account is disabled until re-login" case ErrHardCredit: return "account credits exhausted at upstream; waiting for daily check-in to restore" + case ErrUpstreamBusy: + // 6021 queue.full:渠道级容量满载。措辞明确「不是账号问题、换号无意义」, + // 指引客户端等待后重试(SDK 指数退避天然适配)。 + return "upstream capacity is full (queue.full); this is a channel-level issue, not an account problem; retry after a short wait" case ErrModelBlocked: return "upstream has no such model on this backend; switch model or retry on another account" case ErrContentBlocked: diff --git a/internal/upstream/payload.go b/internal/upstream/payload.go index abd48de2..d18d1713 100644 --- a/internal/upstream/payload.go +++ b/internal/upstream/payload.go @@ -52,6 +52,7 @@ func PrepareBodyOptWithEffortsAndDefault(src []byte, sanitize bool, efforts map[ normalizeToolPatterns(obj) normalizeRoles(obj) normalizeImageURL(obj) + dropReasoningContentParts(obj) // tool 配对三步(见 tool_pairing.go):先合并再重排再清理。所有模型一律执行(独立于 // deepseek-only 的 sanitize 开关)。这是「让请求通过」的安全网——不完整配对的 // tool_calls/tool 结果会让上游对之后每条消息都返 400,必须先行剔除; @@ -291,6 +292,57 @@ func normalizeImageURL(obj map[string]any) { } } +// dropReasoningContentParts 剔除 chat content 数组里的 reasoning_text / reasoning 片段。 +// +// Codex 只走 /v1/responses:new-api 的 responses→chat 转换器 +// (openai_responses_to_openai_chat_completions)会把助手历史里的思考片段原样透传进 +// /v1/chat/completions。上游 content 类型白名单不含 reasoning_text,命中即 HTTP 400 +// code=11101 "unsupported content type at index 0: reasoning_text"。 +// +// 思考片段只是模型私有推理,下游续写不需要,剔除无副作用: +// - 删除 type 为 reasoning_text / reasoning 的片段(TrimSpace 后比较) +// - 若删除后 content 为空且该消息无 tool_calls,整条消息一并删除 +func dropReasoningContentParts(obj map[string]any) { + msgs, ok := obj["messages"].([]any) + if !ok { + return + } + out := make([]any, 0, len(msgs)) + for _, rawMsg := range msgs { + msg, ok := rawMsg.(map[string]any) + if !ok { + out = append(out, rawMsg) + continue + } + parts, ok := msg["content"].([]any) + if !ok { + out = append(out, rawMsg) + continue + } + kept := make([]any, 0, len(parts)) + for _, rawPart := range parts { + part, ok := rawPart.(map[string]any) + if !ok { + kept = append(kept, rawPart) + continue + } + ptype, _ := part["type"].(string) + if strings.TrimSpace(ptype) == "reasoning_text" || strings.TrimSpace(ptype) == "reasoning" { + continue + } + kept = append(kept, rawPart) + } + if len(kept) == 0 { + if _, hasToolCalls := msg["tool_calls"]; !hasToolCalls { + continue + } + } + msg["content"] = kept + out = append(out, rawMsg) + } + obj["messages"] = out +} + // ensureConsoleSystem global realm 兜底 system 注入(吸收 PR #45,防 console 域上游 code 11-128): // 首条消息非 system 时在 messages 最前补一条 fallback system("You are a helpful assistant.")。 // 仅对 global 请求调用(CN 现状不动;即使首条就是 system 也不重复注入)。 diff --git a/internal/upstream/payload_test.go b/internal/upstream/payload_test.go index 250b6f7b..bb370f7e 100644 --- a/internal/upstream/payload_test.go +++ b/internal/upstream/payload_test.go @@ -465,3 +465,114 @@ func TestClampGPTMinMaxTokens(t *testing.T) { }) } } + +// TestDropReasoningContentParts 覆盖思考片段剥离规则本身(直调,隔离其它归一化步骤)。 +// 背景:Codex 只走 /v1/responses,new-api 的 responses→chat 转换器会把助手历史里的 +// reasoning_text 原样透传进 /v1/chat/completions;上游 content 类型白名单不含它, +// 命中即 HTTP 400 code=11101 "unsupported content type at index 0: reasoning_text"。 +func TestDropReasoningContentParts(t *testing.T) { + cases := []struct { + name string + body string + want string // 期望的 messages JSON(null 表示字段缺失) + }{ + { + "reasoning_text 剥离、text 保留", + `{"messages":[{"role":"assistant","content":[{"type":"reasoning_text","text":"think"},{"type":"text","text":"ans"}]}]}`, + `[{"role":"assistant","content":[{"type":"text","text":"ans"}]}]`, + }, + { + "reasoning 别名同样剥离", + `{"messages":[{"role":"assistant","content":[{"type":"reasoning","text":"think"},{"type":"output_text","text":"ans"}]}]}`, + `[{"role":"assistant","content":[{"type":"output_text","text":"ans"}]}]`, + }, + { + "type 前后空白同样命中", + `{"messages":[{"role":"assistant","content":[{"type":" reasoning_text ","text":"think"},{"type":"text","text":"ans"}]}]}`, + `[{"role":"assistant","content":[{"type":"text","text":"ans"}]}]`, + }, + { + "剥离后 content 为空且无 tool_calls:整条消息删除", + `{"messages":[{"role":"assistant","content":[{"type":"reasoning_text","text":"think"}]},{"role":"user","content":"hi"}]}`, + `[{"role":"user","content":"hi"}]`, + }, + { + "剥离后 content 为空但有 tool_calls:消息保留(空数组 content)", + `{"messages":[{"role":"assistant","content":[{"type":"reasoning_text","text":"think"}],"tool_calls":[{"id":"call_1","type":"function","function":{"name":"exec_command","arguments":"{}"}}]}]}`, + `[{"role":"assistant","content":[],"tool_calls":[{"id":"call_1","type":"function","function":{"name":"exec_command","arguments":"{}"}}]}]`, + }, + { + "字符串 content 不受影响", + `{"messages":[{"role":"assistant","content":"plain"},{"role":"user","content":"hi"}]}`, + `[{"role":"assistant","content":"plain"},{"role":"user","content":"hi"}]`, + }, + { + "非对象片段原样保留", + `{"messages":[{"role":"assistant","content":["raw",{"type":"text","text":"t"}]}]}`, + `[{"role":"assistant","content":["raw",{"type":"text","text":"t"}]}]`, + }, + { + "messages 缺失不 panic", + `{"model":"deepseek-v4.1-flash"}`, + `null`, + }, + { + "messages 非数组不 panic", + `{"messages":"weird"}`, + `"weird"`, + }, + { + "全部消息均被剥离:messages 变空数组", + `{"messages":[{"role":"assistant","content":[{"type":"reasoning_text","text":"t"}]}]}`, + `[]`, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + obj, err := decodeBody([]byte(c.body)) + if err != nil { + t.Fatalf("unmarshal: %v", err) + } + dropReasoningContentParts(obj) + var want any + if err := json.Unmarshal([]byte(c.want), &want); err != nil { + t.Fatalf("unmarshal want: %v", err) + } + if !reflect.DeepEqual(obj["messages"], want) { + t.Fatalf("messages = %#v, want %#v", obj["messages"], want) + } + }) + } +} + +// TestPrepareBodyDropsReasoningTextParts 全链路断言:PrepareBodyOptWithEfforts 管线已挂接 +// 剥离步骤,Codex 历史报文里的 reasoning_text 不再出站(sanitize 两态一致)。 +func TestPrepareBodyDropsReasoningTextParts(t *testing.T) { + body := `{"model":"deepseek-v4.1-flash","messages":[ + {"role":"user","content":"hi"}, + {"role":"assistant","content":[{"type":"reasoning_text","text":"think"},{"type":"text","text":"ans"}]}, + {"role":"user","content":"again"} + ]}` + for _, sanitize := range []bool{false, true} { + out := PrepareBodyOptWithEfforts([]byte(body), sanitize, nil) + if strings.Contains(string(out), "reasoning_text") { + t.Fatalf("sanitize=%v: reasoning_text 未剥离: %s", sanitize, out) + } + obj, err := decodeBody(out) + if err != nil { + t.Fatalf("sanitize=%v unmarshal: %v", sanitize, err) + } + msgs := obj["messages"].([]any) + if len(msgs) != 3 { + t.Fatalf("sanitize=%v: messages 数 = %d, want 3", sanitize, len(msgs)) + } + content := msgs[1].(map[string]any)["content"].([]any) + if len(content) != 1 { + t.Fatalf("sanitize=%v: 助手 content 片段数 = %d, want 1", sanitize, len(content)) + } + if got := content[0].(map[string]any)["text"]; got != "ans" { + t.Fatalf("sanitize=%v: 保留片段 text = %v, want ans", sanitize, got) + } + } +} diff --git a/internal/upstream/refresh_singleflight_test.go b/internal/upstream/refresh_singleflight_test.go new file mode 100644 index 00000000..2af21f56 --- /dev/null +++ b/internal/upstream/refresh_singleflight_test.go @@ -0,0 +1,174 @@ +package upstream + +import ( + "io" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" +) + +// TestRefreshTokenSingleFlightPerAccount 验证同一账号并发 refresh 只打一次上游: +// 上游对 refreshToken 反重放,第二发带已消费的 RT 会撞 12153 并把整条 RT 链作废 +// (issue #215,账号只能人工重登)。单飞失败时本用例会看到 hits>1 且集中报 12153。 +func TestRefreshTokenSingleFlightPerAccount(t *testing.T) { + var hits, inFlight, maxInFlight int32 + c := testClient(func(r *http.Request) (*http.Response, error) { + atomic.AddInt32(&hits, 1) + n := atomic.AddInt32(&inFlight, 1) + for { + old := atomic.LoadInt32(&maxInFlight) + if n <= old || atomic.CompareAndSwapInt32(&maxInFlight, old, n) { + break + } + } + time.Sleep(50 * time.Millisecond) // 拉长窗口:无单飞时 8 个调用必然并发 + atomic.AddInt32(&inFlight, -1) + return jsonResp(200, `{"code":0,"msg":"ok","data":{"accessToken":"at1","refreshToken":"rt1","expiresIn":3600}}`), nil + }) + a := &auth.Auth{UID: "uid-a", AccessToken: "at0", RefreshToken: "rt0", ExpiresAt: 1} + + const callers = 8 + var wg sync.WaitGroup + errs := make([]error, callers) + for i := 0; i < callers; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + errs[i] = c.RefreshToken(a) + }(i) + } + wg.Wait() + + for i, err := range errs { + if err != nil { + t.Fatalf("caller %d: %v", i, err) + } + } + if got := atomic.LoadInt32(&hits); got != 1 { + t.Errorf("upstream refresh hits = %d, want 1 (single-flight)", got) + } + if got := atomic.LoadInt32(&maxInFlight); got != 1 { + t.Errorf("max concurrent refresh = %d, want 1", got) + } + if a.AccessToken != "at1" || a.RefreshToken != "rt1" { + t.Errorf("tokens not updated: at=%q rt=%q", a.AccessToken, a.RefreshToken) + } +} + +// TestRefreshTokenSingleFlightIsPerAccount 验证单飞按账号隔离:不同 UID 的刷新 +// 并行执行,不被同一把锁串行化(否则 keepalive 全量刷新会退化成串行慢刷)。 +func TestRefreshTokenSingleFlightIsPerAccount(t *testing.T) { + var inFlight, maxInFlight int32 + c := testClient(func(r *http.Request) (*http.Response, error) { + n := atomic.AddInt32(&inFlight, 1) + for { + old := atomic.LoadInt32(&maxInFlight) + if n <= old || atomic.CompareAndSwapInt32(&maxInFlight, old, n) { + break + } + } + time.Sleep(50 * time.Millisecond) + atomic.AddInt32(&inFlight, -1) + return jsonResp(200, `{"code":0,"data":{"accessToken":"at1","refreshToken":"rt1","expiresIn":3600}}`), nil + }) + accounts := []*auth.Auth{ + {UID: "uid-a", AccessToken: "at0", RefreshToken: "rt0", ExpiresAt: 1}, + {UID: "uid-b", AccessToken: "at0", RefreshToken: "rt0", ExpiresAt: 1}, + {UID: "uid-c", AccessToken: "at0", RefreshToken: "rt0", ExpiresAt: 1}, + } + var wg sync.WaitGroup + for _, a := range accounts { + wg.Add(1) + go func(a *auth.Auth) { + defer wg.Done() + if err := c.RefreshToken(a); err != nil { + t.Errorf("refresh %s: %v", a.UID, err) + } + }(a) + } + wg.Wait() + if got := atomic.LoadInt32(&maxInFlight); got < 2 { + t.Errorf("max concurrent refresh across accounts = %d, want >= 2 (per-account single-flight)", got) + } +} + +// TestRefreshAdoptsNewerDiskTokenAfterSessionDead 验证跨进程兜底:内存 RT 已被上游 +// 判 12153,但磁盘上的 auth 文件已被另一进程(如 signin 工具)rotate 过时,采纳 +// 磁盘 token 重试一次即可自救,而不是直接把账号判死。 +func TestRefreshAdoptsNewerDiskTokenAfterSessionDead(t *testing.T) { + dir := t.TempDir() + fp := filepath.Join(dir, "uid-a.json") + disk := `{"auth":{"accessToken":"at-disk","refreshToken":"rt-disk","expiresAt":1999999999,"domain":"copilot.tencent.com","realm":"cn"},"account":{"uid":"uid-a"}}` + if err := os.WriteFile(fp, []byte(disk), 0o600); err != nil { + t.Fatal(err) + } + + var mu sync.Mutex + var seen []string + c := testClient(func(r *http.Request) (*http.Response, error) { + rt := r.Header.Get("X-Refresh-Token") + mu.Lock() + seen = append(seen, rt) + mu.Unlock() + if rt != "rt-disk" { + return &http.Response{ + StatusCode: 401, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"code":12153,"msg":"refresh token is invalid"}`)), + }, nil + } + return jsonResp(200, `{"code":0,"data":{"accessToken":"at2","refreshToken":"rt2","expiresIn":3600}}`), nil + }) + a := &auth.Auth{UID: "uid-a", AccessToken: "at0", RefreshToken: "rt0", ExpiresAt: 1, FilePath: fp} + + if err := c.RefreshToken(a); err != nil { + t.Fatalf("refresh should recover from disk token: %v", err) + } + if a.RefreshToken != "rt2" || a.AccessToken != "at2" { + t.Errorf("tokens not updated after retry: at=%q rt=%q", a.AccessToken, a.RefreshToken) + } + mu.Lock() + defer mu.Unlock() + if len(seen) != 2 || seen[0] != "rt0" || seen[1] != "rt-disk" { + t.Errorf("refresh attempts = %v, want [rt0 rt-disk]", seen) + } +} + +// TestRefreshDoesNotRetryWhenDiskTokenUnchanged 验证兜底不改变失败语义:磁盘 RT 与 +// 内存一致(无跨进程刷新)时,12153 原样返回,不会无限重试打上游。 +func TestRefreshDoesNotRetryWhenDiskTokenUnchanged(t *testing.T) { + dir := t.TempDir() + fp := filepath.Join(dir, "uid-a.json") + disk := `{"auth":{"accessToken":"at0","refreshToken":"rt0","expiresAt":1,"domain":"copilot.tencent.com","realm":"cn"},"account":{"uid":"uid-a"}}` + if err := os.WriteFile(fp, []byte(disk), 0o600); err != nil { + t.Fatal(err) + } + var hits int32 + c := testClient(func(r *http.Request) (*http.Response, error) { + atomic.AddInt32(&hits, 1) + return &http.Response{ + StatusCode: 401, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"code":12153,"msg":"refresh token is invalid"}`)), + }, nil + }) + a := &auth.Auth{UID: "uid-a", AccessToken: "at0", RefreshToken: "rt0", ExpiresAt: 1, FilePath: fp} + + err := c.RefreshToken(a) + if err == nil { + t.Fatal("want error") + } + if !isSessionDead(err) { + t.Errorf("want ErrSessionDead, got %v", err) + } + if got := atomic.LoadInt32(&hits); got != 1 { + t.Errorf("refresh attempts = %d, want 1 (no retry when disk token unchanged)", got) + } +} diff --git a/internal/upstream/sse_test.go b/internal/upstream/sse_test.go index 2297c4d5..7a65172d 100644 --- a/internal/upstream/sse_test.go +++ b/internal/upstream/sse_test.go @@ -5,6 +5,7 @@ import ( "errors" "net/http" "net/http/httptest" + "strconv" "strings" "testing" "time" @@ -932,3 +933,67 @@ func TestCreditPackagesExpiryTimestamp(t *testing.T) { t.Fatalf("gift pack missing Unix-ms expiry: %+v", packs) } } + +// TestUserResourceSnapshotDeductionEndTime 钉住快照与「检查」同口径: +// 1. 体验版包必须用 DeductionEndTime(真失效时刻),不能把 CycleEndTime(周期 +// 重置日)当成到期日——线上表现就是 10-31 错报、实际 2035 年才失效; +// 2. 同一天多个包要合并成一个自然日批次,earliestRemaining 是该日合计; +// 3. CreditPackages(检查)与 UserResourceSnapshot(首页快照)对同一个包给出 +// 同一个失效时刻。 +func TestUserResourceSnapshotDeductionEndTime(t *testing.T) { + now := time.Now().In(softRateResetLoc) + today := now + day2 := time.Date(today.Year(), today.Month(), today.Day()+2, 9, 0, 0, 0, softRateResetLoc) + day2Later := day2.Add(3 * time.Hour) // 同一天 12:00 + cycleOnly := day2.Add(14 * 24 * time.Hour) + far := time.Date(today.Year()+9, today.Month(), today.Day(), 9, 0, 0, 0, softRateResetLoc) + payload := `{"code":0,"data":{"Response":{"Data":{"Accounts":[` + + `{"PackageName":"gift-a","CycleCapacitySize":100,"CycleCapacityRemain":100,"CycleEndTime":"` + day2.Format(packageEndLayout) + `"},` + + `{"PackageName":"gift-b","CycleCapacitySize":50,"CycleCapacityRemain":50,"CycleEndTime":"` + day2Later.Format(packageEndLayout) + `"},` + + `{"PackageName":"experience","CycleCapacitySize":500,"CycleCapacityRemain":488,"CycleEndTime":"` + + cycleOnly.Format(packageEndLayout) + `","DeductionEndTime":` + strconv.FormatInt(far.UnixMilli(), 10) + `}` + + `]}}}}` + c := testClient(func(r *http.Request) (*http.Response, error) { + if !strings.HasSuffix(r.URL.Path, "/v2/billing/meter/get-user-resource") { + return nil, errors.New("wrong path: " + r.URL.Path) + } + return jsonResp(200, payload), nil + }) + + remain, total, expiring, earliestAt, earliestRemaining, buckets, err := c.UserResourceSnapshot( + &auth.Auth{AccessToken: "at", UID: "u1"}, 72*time.Hour, + ) + if err != nil { + t.Fatalf("snapshot: %v", err) + } + if remain != 638 || total != 650 { + t.Fatalf("remain/total=%d/%d want 638/650", remain, total) + } + // 体验版不在 72h 窗口内;只有 day2 的两个包计入。 + if expiring != 150 { + t.Fatalf("expiring=%d want 150", expiring) + } + if !earliestAt.Equal(day2) || earliestRemaining != 150 { + t.Fatalf("earliest=%v/%d want %v/150(同日两包应合计)", earliestAt, earliestRemaining, day2) + } + if len(buckets) != 2 { + t.Fatalf("buckets=%+v want 2 个自然日", buckets) + } + day := day2.In(softRateResetLoc).Format("2006-01-02") + if buckets[0].Date != day || buckets[0].Remain != 150 || !buckets[0].ExpireAt.Equal(day2) { + t.Fatalf("bucket[0]=%+v want date=%s remain=150 expire=%v", buckets[0], day, day2) + } + if buckets[1].Remain != 488 || !buckets[1].ExpireAt.Equal(far) { + t.Fatalf("experience bucket=%+v want remain=488 expire=%v(不是 CycleEndTime %v)", buckets[1], far, cycleOnly) + } + + packs, _, _, err := c.CreditPackages(&auth.Auth{AccessToken: "at", UID: "u1"}) + if err != nil { + t.Fatalf("packages: %v", err) + } + for _, p := range packs { + if p.Name == "experience" && p.ExpiresAt != far.UnixMilli() { + t.Fatalf("CreditPackages experience expires_at=%d want %d(两条路径必须同口径)", p.ExpiresAt, far.UnixMilli()) + } + } +} diff --git a/internal/upstream/tasks.go b/internal/upstream/tasks.go index b48c9755..155c8efa 100644 --- a/internal/upstream/tasks.go +++ b/internal/upstream/tasks.go @@ -147,7 +147,11 @@ func parseClaimReward(data json.RawMessage) (credit, energy int64, err error) { func parseGrowthTasks(data json.RawMessage) ([]Task, error) { var resp struct { Tasks []struct { - TaskCode string `json:"task_code"` + TaskCode string `json:"task_code"` + // Code 是国际版(workbuddy.ai)任务列表的编号字段名;国内版 + // (workbuddy.cn)用 task_code。两者只生效其一,故并列声明, + // 解析后由下方 code() 归一(issue #163)。 + Code string `json:"code"` Title string `json:"title"` Description string `json:"description"` TaskDesc string `json:"task_desc"` @@ -183,8 +187,15 @@ func parseGrowthTasks(data json.RawMessage) ([]Task, error) { } } claimed := t.AcceptStatus == "claimed" + // 任务编号归一:国内版 task_code / 国际版 code(issue #163)。 + // 国际版上游返回 {"task_id":1,"code":"first_chat",...},只认 task_code + // 会解析出空编号 → 面板按钮编号为空、accept 静默失败。 + code := t.TaskCode + if code == "" { + code = t.Code + } out = append(out, Task{ - TaskCode: t.TaskCode, + TaskCode: code, Title: t.Title, Description: t.Description, TaskDesc: t.TaskDesc, diff --git a/internal/upstream/tool_pairing.go b/internal/upstream/tool_pairing.go index b76034a0..b957154c 100644 --- a/internal/upstream/tool_pairing.go +++ b/internal/upstream/tool_pairing.go @@ -11,6 +11,10 @@ // 的条目让会话自愈,宁可丢一轮工具上下文,也好过整条会话死亡。 package upstream +import ( + "fmt" +) + // repackToolResultBlocks 把插在 assistant.tool_calls 与其 tool 结果之间的非 tool 消息 // 挪到整组之后,保证同一批 tool_call 的结果在 wire 上连续。 // @@ -107,6 +111,93 @@ func repackToolResultBlocks(messages []any) ([]any, bool) { return out, true } +// hasDuplicateCallIDs 报告是否有 id 被多个 tool_call 复用(issue #81)。 +func hasDuplicateCallIDs(callCount map[string]int) bool { + for _, n := range callCount { + if n > 1 { + return true + } + } + return false +} + +// renameDuplicateToolCallIDs 给重复出现的 tool_call id 加唯一后缀并同步改写 +// role:tool 结果的 tool_call_id(issue #81)。返回是否发生了改名。 +// +// 规则:同一 id 的第 1 次出现保持原样,第 k 次(k≥2)改为 "<id>_d<k>"; +// role:tool 结果按出现顺序消费一个待配对的改名槽位——即第 k 个引用该 id 的 +// 结果消息拿到 "<id>_d<k>"。这样「调用 ↔ 结果」重新一一对应,上游不再因 +// 多余调用报 11148。 +// +// 幂等:改名后的 id 形如 "<id>_d2",若再次跑本函数,其计数为 1(不再重复), +// 故不会二次改名。 +func renameDuplicateToolCallIDs(messages []any, callCount map[string]int) bool { + // 每个 id 的新名字序列(按调用侧出现顺序):["X", "X_d2", "X_d3", ...]。 + names := map[string][]string{} + // 结果侧:id → 已消费到的序号(结果与调用按出现顺序一一配对)。 + used := map[string]int{} + changed := false + // 先扫 assistant:算出每个调用的新 id。 + for _, m := range messages { + msg, ok := m.(map[string]any) + if !ok { + continue + } + if role, _ := msg["role"].(string); role != "assistant" { + continue + } + tcs, ok := msg["tool_calls"].([]any) + if !ok || len(tcs) == 0 { + continue + } + for _, tci := range tcs { + tc, ok := tci.(map[string]any) + if !ok { + continue + } + id, _ := tc["id"].(string) + if id == "" { + continue + } + k := len(names[id]) + 1 + newID := id + if k > 1 { + newID = fmt.Sprintf("%s_d%d", id, k) + } + names[id] = append(names[id], newID) + if newID != id { + tc["id"] = newID + changed = true + } + } + } + // 再扫 role:tool:第 k 条引用该 id 的结果 → 拿第 k 个名字。 + for _, m := range messages { + msg, ok := m.(map[string]any) + if !ok { + continue + } + if role, _ := msg["role"].(string); role != "tool" { + continue + } + id, _ := msg["tool_call_id"].(string) + if id == "" { + continue + } + used[id]++ + k := used[id] + seq := names[id] + if k < 1 || k > len(seq) { + continue // 没有对应的第 k 个调用:保持原样(后续由孤儿裁剪处理) + } + if seq[k-1] != id { + msg["tool_call_id"] = seq[k-1] + changed = true + } + } + return changed +} + // cleanupOrphanToolCalls 剔除无法配对的 tool_call 与 tool 结果(所有模型,独立于 // deepseek-only 的 sanitize 开关)。语义对齐参考仓库 resolveToolPairing: // @@ -125,6 +216,13 @@ func cleanupOrphanToolCalls(messages []any) ([]any, bool) { callIDs := map[string]bool{} resultIDs := map[string]bool{} hasTraffic := false + // 重复 id 计数:同一 id 被多个 tool_call 复用时,上游按「调用/结果必须 + // 一一对应」校验,多余的调用没有对应结果 → 400 / 11148 + // "tool calls and tool results do not match",整条会话持续 503 + // (issue #81:客户端历史里 SSHManager:0 之类 id 每条消息从 0 重来, + // 一个会话能攒出几十个同名 id)。集合层面看不出重复,故这里按出现 + // 次数计数,供下面的改名步骤使用。 + callCount := map[string]int{} for _, m := range messages { msg, ok := m.(map[string]any) if !ok { @@ -145,6 +243,7 @@ func cleanupOrphanToolCalls(messages []any) ([]any, bool) { } if id, ok := tc["id"].(string); ok && id != "" { callIDs[id] = true + callCount[id]++ hasTraffic = true } } @@ -154,6 +253,43 @@ func cleanupOrphanToolCalls(messages []any) ([]any, bool) { if !hasTraffic { return messages, false } + // 重复 id 改名:第 1 次出现的 id 保持原样,第 2..n 次出现改名为 + // "<id>_d<n>",并同步改写其后的 role:tool 结果的 tool_call_id, + // 让「调用 ↔ 结果」重新一一对应(issue #81 建议的口径)。 + // 改名按下标全局计数:同一 id 的第 k 次出现固定叫 _d<k>, + // 结果消息按出现顺序一一跟随(上游/客户端的配对语义是「按序」, + // 改名保持相对顺序不变)。 + renamed := false + if hasDuplicateCallIDs(callCount) { + renamed = renameDuplicateToolCallIDs(messages, callCount) + // 改名后 id 集合变化:重算两侧集合,供 keepCalls 使用。 + callIDs = map[string]bool{} + resultIDs = map[string]bool{} + for _, m := range messages { + msg, ok := m.(map[string]any) + if !ok { + continue + } + switch msg["role"] { + case "tool": + if id, ok := msg["tool_call_id"].(string); ok && id != "" { + resultIDs[id] = true + } + case "assistant": + if tcs, ok := msg["tool_calls"].([]any); ok { + for _, tci := range tcs { + tc, ok := tci.(map[string]any) + if !ok { + continue + } + if id, ok := tc["id"].(string); ok && id != "" { + callIDs[id] = true + } + } + } + } + } + } // keepCalls:调用 id 是否双侧齐全(调用存在且结果存在)。重复 id 与乱序均按集合处理。 keepCalls := map[string]bool{} for id := range callIDs { @@ -162,6 +298,10 @@ func cleanupOrphanToolCalls(messages []any) ([]any, bool) { } } changed := false + if renamed { + // 改名本身就是一次出站变更(即使配对集合没变,id 也不同了)。 + changed = true + } // 1) assistant.tool_calls:按 keepCalls 对称裁剪——只留有结果的调用,过滤后为空则删键。 // // 历史实现是「批内每个 id 都齐才整批保留,否则删掉整个 tool_calls 键」。那会留下 diff --git a/internal/upstream/tool_pairing_dup_test.go b/internal/upstream/tool_pairing_dup_test.go new file mode 100644 index 00000000..0c68848c --- /dev/null +++ b/internal/upstream/tool_pairing_dup_test.go @@ -0,0 +1,110 @@ +// tool_pairing_dup_test.go 重复 tool_call id 的改名行为钉桩(issue #81)。 +// +// 背景:某些客户端(Kimi 渠道)每条 assistant 消息都用「工具名:序号」形式 +// 从 0 重新编号 tool_call id(SSHManager:0 / Bash:0 / MemoryManager:0 …), +// 一个会话里能攒出几十个同名 id。上游按「调用↔结果一一对应」校验, +// 多余的调用没有对应结果 → 400 code=11148 "tool calls and tool results do +// not match",整条会话持续 503 no_healthy_account。 +// +// 修法:第 2..n 次出现的同名 id 改名为 "<id>_d<n>",并同步改写对应 +// role:tool 结果的 tool_call_id,使配对重新一一对应。 + +package upstream + +import "testing" + +// TestCleanupOrphanToolCallsDuplicateIDs 重复 id 必须改名并保持配对。 +func TestCleanupOrphanToolCallsDuplicateIDs(t *testing.T) { + t.Run("重复 id 改名且结果同步改写", func(t *testing.T) { + // 两次都用 "Bash:0":第一次保持原样,第二次改名为 "Bash:0_d2"; + // 两条 role:tool 结果按出现顺序消费改名槽位。 + in := msgs(t, `[ + {"role":"assistant","content":null,"tool_calls":[{"id":"Bash:0"}]}, + {"role":"tool","tool_call_id":"Bash:0","content":"r1"}, + {"role":"assistant","content":null,"tool_calls":[{"id":"Bash:0"}]}, + {"role":"tool","tool_call_id":"Bash:0","content":"r2"} + ]`) + out, changed := cleanupOrphanToolCalls(in) + if !changed { + t.Fatal("changed=false,重复 id 应触发改名") + } + assertSummary(t, summarize(out), []string{ + "assistant(Bash:0)", + "tool(Bash:0)", + "assistant(Bash:0_d2)", + "tool(Bash:0_d2)", + }) + }) + + t.Run("三次同名 id:依次 _d2/_d3", func(t *testing.T) { + in := msgs(t, `[ + {"role":"assistant","content":null,"tool_calls":[{"id":"X:0"}]}, + {"role":"tool","tool_call_id":"X:0","content":"r1"}, + {"role":"assistant","content":null,"tool_calls":[{"id":"X:0"}]}, + {"role":"tool","tool_call_id":"X:0","content":"r2"}, + {"role":"assistant","content":null,"tool_calls":[{"id":"X:0"}]}, + {"role":"tool","tool_call_id":"X:0","content":"r3"} + ]`) + out, changed := cleanupOrphanToolCalls(in) + if !changed { + t.Fatal("changed=false,重复 id 应触发改名") + } + assertSummary(t, summarize(out), []string{ + "assistant(X:0)", + "tool(X:0)", + "assistant(X:0_d2)", + "tool(X:0_d2)", + "assistant(X:0_d3)", + "tool(X:0_d3)", + }) + }) + + t.Run("改名后无孤儿:调用与结果仍然一一对应", func(t *testing.T) { + in := msgs(t, `[ + {"role":"user","content":"go"}, + {"role":"assistant","content":null,"tool_calls":[{"id":"Bash:0"}]}, + {"role":"tool","tool_call_id":"Bash:0","content":"r1"}, + {"role":"assistant","content":null,"tool_calls":[{"id":"Bash:0"}]}, + {"role":"tool","tool_call_id":"Bash:0","content":"r2"} + ]`) + out, _ := cleanupOrphanToolCalls(in) + // 统计每个 id 出现在调用侧与结果侧的次数,必须相等。 + calls := map[string]int{} + results := map[string]int{} + for _, m := range out { + msg, _ := m.(map[string]any) + switch msg["role"] { + case "assistant": + if tcs, ok := msg["tool_calls"].([]any); ok { + for _, tci := range tcs { + tc, _ := tci.(map[string]any) + id, _ := tc["id"].(string) + calls[id]++ + } + } + case "tool": + id, _ := msg["tool_call_id"].(string) + results[id]++ + } + } + if len(calls) != len(results) { + t.Fatalf("调用 id 集合与结果 id 集合不一致: calls=%v results=%v", calls, results) + } + for id, n := range calls { + if results[id] != n { + t.Errorf("id %q 调用 %d 次但结果 %d 次", id, n, results[id]) + } + } + }) + + t.Run("id 不重复时零改动(不误伤正常会话)", func(t *testing.T) { + in := msgs(t, `[ + {"role":"assistant","content":null,"tool_calls":[{"id":"c1"},{"id":"c2"}]}, + {"role":"tool","tool_call_id":"c1","content":"r1"}, + {"role":"tool","tool_call_id":"c2","content":"r2"} + ]`) + if _, changed := cleanupOrphanToolCalls(in); changed { + t.Error("changed=true,id 不重复不应改动") + } + }) +} diff --git a/internal/upstream/transport.go b/internal/upstream/transport.go index ac5920b0..1b939d27 100644 --- a/internal/upstream/transport.go +++ b/internal/upstream/transport.go @@ -84,6 +84,8 @@ func newTransport() *http.Transport { MaxIdleConnsPerHost: maxIdleConnsPerHost, IdleConnTimeout: idleConnTimeout, ResponseHeaderTimeout: responseHeaderTimeout, + // 自动读取环境变量中的代理配置 + Proxy: http.ProxyFromEnvironment, } } diff --git a/internal/upstream/usage.go b/internal/upstream/usage.go index 74a757fc..57c3c9ff 100644 --- a/internal/upstream/usage.go +++ b/internal/upstream/usage.go @@ -100,6 +100,14 @@ func cloneUsageDetails(usage map[string]any, key string) map[string]any { return out } +// UsagePromptTokens 返回 usage 顶层的 prompt_tokens;缺失或非数值时 ok=false。 +func UsagePromptTokens(usage map[string]any) (float64, bool) { + if usage == nil { + return 0, false + } + return usageNumber(usage, "prompt_tokens") +} + // UsageCacheHitTokens 返回 usage 里的缓存命中 token 数(多别名取最优,口径与 // 回写给客户端的 normalizeUsageCacheAliases 一致)。供网关统计层(usage 桶 / // reqlog)观测命中率使用;usage 缺失该维度时 ok=false。 diff --git a/internal/upstream/useragent_test.go b/internal/upstream/useragent_test.go index e583b1e2..22b21b0a 100644 --- a/internal/upstream/useragent_test.go +++ b/internal/upstream/useragent_test.go @@ -48,7 +48,7 @@ func TestUserAgentDefaultEmptyKeepsClientUA(t *testing.T) { call: func(c *Client) error { return c.ReportChatActivity(&auth.Auth{AccessToken: "at", UID: "u1"}, "cid", "") }, - wantUA: "WorkBuddy/5.5.4", + wantUA: billingUAWorkBuddy, }, } { t.Run(tc.name, func(t *testing.T) { @@ -139,8 +139,8 @@ func TestFetchModelsUsesConfiguredUA(t *testing.T) { t.Errorf("personal/models UA = %q want FetchAgent/2", got) } case strings.HasSuffix(r.URL.Path, "/v3/config"): - if got := r.Header.Get("User-Agent"); got != codeBuddyIDEUA { - t.Errorf("v3/config UA = %q want %s", got, codeBuddyIDEUA) + if got := r.Header.Get("User-Agent"); got != v3ProbeUACNForTest { + t.Errorf("v3/config UA = %q want %s", got, v3ProbeUACNForTest) } return jsonResp(200, `{"code":0,"data":{"models":[]}}`), nil default: @@ -160,11 +160,11 @@ func TestFetchModelsUsesConfiguredUA(t *testing.T) { // --- A 段:UA 对齐官方 WorkBuddy 三段式 --- const ( - defaultUAString = "WorkBuddy/5.5.4 WorkBuddy/5.5.4 CLI/2.137.1" + defaultUAString = "WorkBuddy/5.8.3 WorkBuddy/5.8.3 CLI/2.161.0" explicitString = "MyCustomAgent/3.1" - clientVerUAString = "WorkBuddy/6.0.0 WorkBuddy/6.0.0 CLI/2.137.1" - billingUAWorkBuddy = "WorkBuddy/5.5.4" - billingUACustomVer = "WorkBuddy/6.0.0" + clientVerUAString = "WorkBuddy/6.0.0 WorkBuddy/6.0.0 CLI/2.161.0" + billingUAWorkBuddy = "axios/1.16.1" + billingUACustomVer = "axios/1.16.1" billingUAAgentString = "BillingAgent/1" ) @@ -250,10 +250,10 @@ func TestUserAgentClientVersionOverride(t *testing.T) { } // TestBillingUA_WhenClientNameSet billing/checkin 路径:client_name 非空时用单段 -// `WorkBuddy/<clientVersion>`(不带 CLI 段,对齐官方 banner/check-in 显式头组)。 +// `axios/1.16.1`(真机实测:billing 域走 axios 实例,与客户端 UA 无关,也不随 client_version 变)。 func TestBillingUA_WhenClientNameSet(t *testing.T) { a := &auth.Auth{AccessToken: "at", UID: "u1"} - // 默认 client_version → WorkBuddy/5.5.4 + // 默认 client_version → axios/1.16.1 c := &Client{ HTTP: &http.Client{Transport: rtFunc(func(r *http.Request) (*http.Response, error) { if got := r.Header.Get("User-Agent"); got != billingUAWorkBuddy { @@ -268,7 +268,7 @@ func TestBillingUA_WhenClientNameSet(t *testing.T) { if _, _, err := c.UserResource(a); err != nil { t.Errorf("userResource: %v", err) } - // 自定义 client_version → WorkBuddy/6.0.0 + // 自定义 client_version 不影响 billing UA(仍是 axios/1.16.1) c2 := &Client{ HTTP: &http.Client{Transport: rtFunc(func(r *http.Request) (*http.Response, error) { if got := r.Header.Get("User-Agent"); got != billingUACustomVer { @@ -320,15 +320,15 @@ func TestBillingUA_WhenClientNameEmpty(t *testing.T) { if _, _, err := c.UserResource(a); err != nil { t.Errorf("userResource: %v", err) } - if ua != "WorkBuddy/6.0.0" { - t.Errorf("billing UA = %q want WorkBuddy/6.0.0 (default desktop fingerprint)", ua) + if ua != "axios/1.16.1" { + t.Errorf("billing UA = %q want axios/1.16.1 (billing 层走 axios,无品牌段)", ua) } - if got := c.billingUA(); got != "WorkBuddy/6.0.0" { - t.Errorf("billingUA() = %q want WorkBuddy/6.0.0", got) + if got := c.billingUA(a); got != "axios/1.16.1" { + t.Errorf("billingUA() = %q want axios/1.16.1", got) } // 显式 SaaS 还原旧行为(不设 UA)。 c.ClientName = "SaaS" - if got := c.billingUA(); got != "" { + if got := c.billingUA(a); got != "" { t.Errorf("billingUA() SaaS = %q want empty", got) } } From 399104c8108814a3de992498efc97284eb4610a6 Mon Sep 17 00:00:00 2001 From: DSH Agent <dsh@local> Date: Tue, 20 Oct 2026 13:33:51 +0800 Subject: [PATCH 02/57] =?UTF-8?q?feat(server):=206021=20queue.full=20?= =?UTF-8?q?=E7=9A=84=20503=20=E5=93=8D=E5=BA=94=E9=80=8F=E4=BC=A0=E4=B8=8A?= =?UTF-8?q?=E6=B8=B8=20Retry-After=EF=BC=88issue=20#184=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 源分支 fix/all-known-issues-v1.13.0 在本分支构建期间新增了 311c617 (issue #184:6021 快速失败时把上游的 Retry-After 写回响应头)。该提交与本 分支无冲突,直接移植过来,使 v1.13.0 基线分支与源分支保持同一修复集。 - internal/server/handler.go:ErrUpstreamBusy 分支回 503 upstream_busy 时, 若上游给了 Retry-After / retry-after-ms / x-ratelimit-reset,按秒向上取整 后写回响应头;上游没给就不写(宁缺勿滥)。 - internal/server/upstream_busy_retry_after_test.go:钉住「给了就透传」与 「没给就不编造」两个用例。 --- internal/server/handler.go | 7 ++ .../server/upstream_busy_retry_after_test.go | 67 +++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 internal/server/upstream_busy_retry_after_test.go diff --git a/internal/server/handler.go b/internal/server/handler.go index 14155134..6e667adb 100644 --- a/internal/server/handler.go +++ b/internal/server/handler.go @@ -11,6 +11,7 @@ import ( "net" "net/http" "os" + "strconv" "strings" "sync" @@ -1029,6 +1030,12 @@ func (h *Handler) chatCompletions(w http.ResponseWriter, r *http.Request) { if strings.TrimSpace(msg) == "" { msg = "upstream capacity is full (queue.full); retry after a short wait" } + // 上游若给了 Retry-After(秒 / retry-after-ms / x-ratelimit-reset), + // 原样带给客户端,让 SDK 用上游的真实等待窗口退避,而不是自己瞎猜。 + // 上游没给就不编造(宁缺勿滥:臆造一个等待时长比不给更糟)。 + if uerr != nil && uerr.RetryAfter > 0 { + w.Header().Set("Retry-After", strconv.Itoa(int((uerr.RetryAfter+time.Second-1)/time.Second))) + } writeOpenAIErrorHint(w, http.StatusServiceUnavailable, "upstream_busy", msg, h.hintOf(upstream.ErrUpstreamBusy, string(respBody), bareModel, reqHasImage, uerr)) st.status = http.StatusServiceUnavailable diff --git a/internal/server/upstream_busy_retry_after_test.go b/internal/server/upstream_busy_retry_after_test.go new file mode 100644 index 00000000..b0f2cdfd --- /dev/null +++ b/internal/server/upstream_busy_retry_after_test.go @@ -0,0 +1,67 @@ +package server + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" + "github.com/linguo2625469/workbuddy2api-panel/internal/upstream" +) + +// TestQueueFullCarriesUpstreamRetryAfter 6021 queue.full 的 503 响应必须把上游 +// 明示的 Retry-After 头带给客户端。 +// +// 理由:这一路是**渠道级**容量满载(不是账号问题),客户端唯一的正确动作就是 +// 等上游窗口过去再重试。上游给了真实等待窗口就原样透传,SDK 才能用上游的 +// 节奏退避;自己编一个数反而更糟。 +// +// 边界:上游没给 Retry-After 时**不得**编造(宁缺勿滥)。 +func TestQueueFullCarriesUpstreamRetryAfter(t *testing.T) { + newUp := func(retryAfter string) *upstream.Client { + return &upstream.Client{ + HTTP: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + h := http.Header{"Content-Type": []string{"application/json"}} + if retryAfter != "" { + h.Set("Retry-After", retryAfter) + } + return &http.Response{ + StatusCode: 429, + Header: h, + Body: io.NopCloser(strings.NewReader(`{"code":6021,"msg":"queue.full.title"}`)), + }, nil + })}, + ChatBaseCN: "https://fake.example", + BillingBaseCN: "https://fake.example", + } + } + newReq := func() *http.Request { + return httptest.NewRequest("POST", "/v1/chat/completions", strings.NewReader(`{"model":"glm-5.2","messages":[]}`)) + } + + // 上游给了 5 秒 → 响应必须带 Retry-After: 5。 + p := testPoolWith(&auth.Auth{UID: "ra1", AccessToken: "at-ra1", ExpiresAt: 9999999999}) + h := NewHandler(Config{Pool: p, Upstream: newUp("5")}) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, newReq()) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("code=%d body=%s (want 503)", rec.Code, rec.Body) + } + if got := rec.Header().Get("Retry-After"); got != "5" { + t.Errorf("Retry-After=%q want %q (上游明示的等待窗口必须透传给客户端)", got, "5") + } + + // 上游没给 → 不得编造(臆造等待时长比不给更糟)。 + p2 := testPoolWith(&auth.Auth{UID: "ra2", AccessToken: "at-ra2", ExpiresAt: 9999999999}) + h2 := NewHandler(Config{Pool: p2, Upstream: newUp("")}) + rec2 := httptest.NewRecorder() + h2.ServeHTTP(rec2, newReq()) + if rec2.Code != http.StatusServiceUnavailable { + t.Fatalf("code=%d body=%s (want 503)", rec2.Code, rec2.Body) + } + if got := rec2.Header().Get("Retry-After"); got != "" { + t.Errorf("Retry-After=%q want empty (上游未明示时不得编造等待时长)", got) + } +} From 10e476db9f94714e581658700e3674794968e14d Mon Sep 17 00:00:00 2001 From: pengyouak <pengyouak@163.com> Date: Mon, 19 Oct 2026 21:34:20 +0800 Subject: [PATCH 03/57] =?UTF-8?q?go=E4=B8=8B=E8=BD=BD=E6=BA=90=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E5=9B=BD=E5=86=85=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 调整官方的go下载源为国内的,避免因为使用官方源导致无法下载的问题。 (cherry picked from commit 33f2a3355e4bb9d0940d556c103e7fd8c60b1f8e) --- Dockerfile | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 121d21d7..a7b46176 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,10 @@ FROM golang:1.23-alpine AS build WORKDIR /src COPY go.mod ./ -RUN go mod download +# 防止因为直接使用官方源导致下载失败 +RUN go env -w GOPROXY=https://goproxy.cn,direct && \ + go env GOPROXY && \ + go mod download COPY . . # 一次编译全部二进制(工具进镜像,容器内可直接跑脚本)。全部 -trimpath -s -w。 RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/wb2api ./cmd/server \ @@ -32,4 +35,4 @@ USER app EXPOSE 7863 HEALTHCHECK --interval=30s --timeout=5s --start-period=5s \ CMD wget -qO- http://127.0.0.1:7863/healthz || exit 1 -ENTRYPOINT ["/app/wb2api", "-config", "/app/config.json"] \ No newline at end of file +ENTRYPOINT ["/app/wb2api", "-config", "/app/config.json"] From 8eae0d43c9645d696e06a017ecf8b08015be1ff9 Mon Sep 17 00:00:00 2001 From: yuanxing109 <yuanxing109@users.noreply.github.com> Date: Fri, 9 Oct 2026 20:43:14 +0800 Subject: [PATCH 04/57] =?UTF-8?q?feat(upstream):=20=E5=87=BA=E7=AB=99=20DN?= =?UTF-8?q?S=20=E5=8F=AF=E6=8C=87=E5=AE=9A=EF=BC=88dns=5Fservers=20/=20WB2?= =?UTF-8?q?A=5FDNS=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 没有 /etc/resolv.conf 的环境(Android、精简容器)里 Go 解析器会退回 [::1]:53,查任何域名都变成连本机 53 端口。新增 dns_servers 配置与 WB2A_DNS 环境变量,只换两个 http.Client 的 Transport.DialContext,空值维持系统解析器。 (cherry picked from commit 92c16fd66f0db8bfb66b7e8dff6d694eb116c665) --- README.md | 3 +- cmd/server/config.go | 18 +++++ cmd/server/main.go | 12 +++ config.example.json | 1 + internal/upstream/dns.go | 56 ++++++++++++++ internal/upstream/dns_test.go | 141 ++++++++++++++++++++++++++++++++++ 6 files changed, 230 insertions(+), 1 deletion(-) create mode 100644 internal/upstream/dns.go create mode 100644 internal/upstream/dns_test.go diff --git a/README.md b/README.md index 2b512eb5..b451ef49 100644 --- a/README.md +++ b/README.md @@ -358,6 +358,7 @@ curl -s http://localhost:7863/v1/chat/completions \ | `api_key` | 空 | 网关鉴权密钥;**空 = 不鉴权直接放行**(公网必须设置) | | `auth_dir` | `./auths` | 账号凭证目录 | | `state_file` | `./data/state.json` | 账号池状态持久化文件 | +| `dns_servers` | 空 | **出站解析**用的 DNS 服务器(数组,按顺序试,都用不上才报错);空 = 系统解析器。/etc/resolv.conf 不存在或不可用的环境(Android、精简容器)必须显式配,否则 Go 解析器会退回 `127.0.0.1:53`,查任何域名都变成"连本机 53 端口"(`dial tcp: lookup ... on [::1]:53: connection refused`)。只换 `Transport.DialContext`,不动超时与连接池 | | `server.read_timeout` | `300s` | 入站请求读取(含 body 上传)总时长上限;大上下文/文件块经反代转发超时会 400 `read body: i/o timeout`;`0` = 不限制;改动需重启(#100) | | `panel.package_detail_limit` | `5` | 积分构成页单账号默认展示的最早到期包数;其余未用完包与已用完包聚合折叠 | | `logging.request_archive_enabled` | `true` | 请求元数据 JSONL 归档开关;不记录提示词、响应正文或 Authorization | @@ -420,7 +421,7 @@ curl -s http://localhost:7863/v1/chat/completions \ 加载顺序:JSON 文件 → `WB2A_*` 环境变量(变量非空才覆盖): -`WB2A_LISTEN` · `WB2A_API_KEY` · `WB2A_AUTH_DIR` · `WB2A_STATE_FILE` · `WB2A_MAX_BODY_MB` · `WB2A_SOFT_RATE`(duration) · `WB2A_SOFT_RATE_MAX`(duration) · `WB2A_TIMEOUT_SECONDS` · `WB2A_HEADER_TIMEOUT_SECONDS` · `WB2A_IDLE_TIMEOUT_SECONDS` · `WB2A_USER_AGENT` · `WB2A_SANITIZE_FINGERPRINTS`(bool) · `WB2A_PROMPT_MODE` · `WB2A_PROMPT_FILE` +`WB2A_LISTEN` · `WB2A_API_KEY` · `WB2A_AUTH_DIR` · `WB2A_STATE_FILE` · `WB2A_DNS`(逗号分隔多个) · `WB2A_MAX_BODY_MB` · `WB2A_SOFT_RATE`(duration) · `WB2A_SOFT_RATE_MAX`(duration) · `WB2A_TIMEOUT_SECONDS` · `WB2A_HEADER_TIMEOUT_SECONDS` · `WB2A_IDLE_TIMEOUT_SECONDS` · `WB2A_USER_AGENT` · `WB2A_SANITIZE_FINGERPRINTS`(bool) · `WB2A_PROMPT_MODE` · `WB2A_PROMPT_FILE` ## 核心行为语义 diff --git a/cmd/server/config.go b/cmd/server/config.go index 651b0df0..43c8e506 100644 --- a/cmd/server/config.go +++ b/cmd/server/config.go @@ -23,6 +23,9 @@ type Config struct { APIKey string `json:"api_key"` // 空 = 不鉴权 AuthDir string `json:"auth_dir"` // ./auths StateFile string `json:"state_file"` // ./data/state.json + // DNSServers 出站解析用的 DNS 服务器(空 = 系统解析器);也可用 WB2A_DNS 覆盖。 + // Android/magisk 模块里没有 /etc/resolv.conf,必须显式指定,否则解析全落本机 :53。 + DNSServers []string `json:"dns_servers,omitempty"` Panel struct { // PackageDetailLimit 积分构成页单账号默认展示的最近到期包数;<=0 回落 5。 @@ -511,6 +514,9 @@ func applyEnv(c *Config) { c.Pool.PreferExpiring = b } } + if v := os.Getenv("WB2A_DNS"); v != "" { + c.DNSServers = splitCSV(v) + } } func (c *Config) normalize() error { @@ -759,3 +765,15 @@ func checkHourRange(field, switchKey string, hours []int) error { } return nil } + +// splitCSV 逗号分隔 → 去空白、丢空项(WB2A_DNS 用)。 +func splitCSV(s string) []string { + parts := strings.Split(s, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} diff --git a/cmd/server/main.go b/cmd/server/main.go index 02812ec2..2d38dc88 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -189,6 +189,18 @@ func main() { up.BillingBaseGlobal = cfg.Global.BillingBase applyCNChatBase(up, cfg) auth.SetGlobalEnabled(cfg.Global.Enabled) + // DNS:Android 没有 /etc/resolv.conf,Go 的纯 Go 解析器会退回 127.0.0.1/::1, + // 于是查任何域名都变成"连本机 53 端口"(dial tcp: lookup copilot.tencent.com + // on [::1]:53: connection refused)。配了 dns_servers / WB2A_DNS 就显式指定。 + if len(cfg.DNSServers) > 0 { + d := upstream.NewDNSDialer(cfg.DNSServers) + up.SetDNSDialer(d) + // 推送 webhook / Upstash 镜像等走 http.DefaultTransport,一并换掉 + if t, ok := http.DefaultTransport.(*http.Transport); ok { + t.DialContext = d.DialContext + } + log.Printf("DNS: 出站解析用 %v(不走系统 /etc/resolv.conf)", cfg.DNSServers) + } // model.json 本地缓存接线(context_length/max_output_tokens 四级查找链第 3 级): // 数据目录与 state.json 同风格(Docker volume 持久化路径)。首次缺失/损坏自动 // 回落仓库内嵌种子;models.dev 按需拉取成功后原子写回。 diff --git a/config.example.json b/config.example.json index 367c9f94..82e85aa6 100644 --- a/config.example.json +++ b/config.example.json @@ -3,6 +3,7 @@ "api_key": "test_key", "auth_dir": "./auths", "state_file": "./data/state.json", + "dns_servers": [], "server": { "read_timeout": "300s" }, diff --git a/internal/upstream/dns.go b/internal/upstream/dns.go new file mode 100644 index 00000000..b48190b2 --- /dev/null +++ b/internal/upstream/dns.go @@ -0,0 +1,56 @@ +package upstream + +import ( + "context" + "errors" + "net" + "net/http" + "time" +) + +// dnsDialTimeout 单台 DNS 服务器的建连上限(多台依次尝试)。 +const dnsDialTimeout = 5 * time.Second + +// NewDNSDialer 构造使用指定 DNS 服务器的出站拨号器(空 = 系统解析器)。 +// Android 没有 /etc/resolv.conf:Go 解析器会退回 [::1]:53 导致所有域名解析失败, +// 传真实 DNS(223.5.5.5 / 119.29.29.29)即绕开。 +func NewDNSDialer(servers []string) *net.Dialer { + d := newDialer() + if len(servers) > 0 { + d.Resolver = &net.Resolver{PreferGo: true, Dial: func(ctx context.Context, network, _ string) (net.Conn, error) { + dd := net.Dialer{Timeout: dnsDialTimeout} + var lastErr error + for _, s := range servers { + addr := s + if _, _, err := net.SplitHostPort(addr); err != nil { + addr = net.JoinHostPort(s, "53") + } + c, err := dd.DialContext(ctx, network, addr) + if err == nil { + return c, nil + } + lastErr = err + } + if lastErr == nil { + lastErr = errors.New("dns: 未配置服务器") + } + return nil, lastErr + }} + } + return d +} + +// SetDNSDialer 把拨号器换到已有的两个 http.Client 上(只覆盖 Transport.DialContext)。 +func (c *Client) SetDNSDialer(d *net.Dialer) { + if d == nil { + return + } + for _, cl := range []*http.Client{c.HTTP, c.ChatHTTP} { + if cl == nil { + continue + } + if t, ok := cl.Transport.(*http.Transport); ok { + t.DialContext = d.DialContext + } + } +} diff --git a/internal/upstream/dns_test.go b/internal/upstream/dns_test.go new file mode 100644 index 00000000..9102f1fb --- /dev/null +++ b/internal/upstream/dns_test.go @@ -0,0 +1,141 @@ +package upstream + +import ( + "context" + "encoding/binary" + "net" + "net/http" + "reflect" + "testing" + "time" +) + +// fakeDNS 起一个只回 A=127.0.0.9 的极简 DNS 服务器,返回地址与"收到查询"的信号。 +func fakeDNS(t *testing.T) (string, <-chan struct{}) { + t.Helper() + pc, err := net.ListenPacket("udp", "127.0.0.1:0") + if err != nil { + t.Skipf("UDP 不可用:%v", err) + } + t.Cleanup(func() { _ = pc.Close() }) + got := make(chan struct{}, 1) + go func() { + buf := make([]byte, 512) + for { + n, addr, err := pc.ReadFrom(buf) + if err != nil { + return + } + select { + case got <- struct{}{}: + default: + } + if resp := dnsReply(buf[:n]); resp != nil { + _, _ = pc.WriteTo(resp, addr) + } + } + }() + return pc.LocalAddr().String(), got +} + +// dnsReply 把查询原样改成一条 A=127.0.0.9 的响应(够测试用,不解析问题段)。 +func dnsReply(q []byte) []byte { + if len(q) < 12 { + return nil + } + // 只回带问题段:查询里可能还有 EDNS0 之类的附加记录,整段照抄会被 + // 当成第一条 answer(type=41)解析,客户端就只看到"没有 A 记录"。 + i := 12 + for i < len(q) { + l := int(q[i]) + if l == 0 { + i++ + break + } + if l&0xC0 == 0xC0 { // 压缩指针(查询里一般不会出现) + i += 2 + break + } + i += 1 + l + } + i += 4 // qtype + qclass + if i > len(q) { + return nil + } + out := make([]byte, 12, 64) + copy(out, q[:2]) // 同一个事务 ID + out[2], out[3] = 0x81, 0x80 // 标准响应 + 递归可用 + 无错误 + binary.BigEndian.PutUint16(out[4:6], 1) // QDCOUNT + binary.BigEndian.PutUint16(out[6:8], 1) // ANCOUNT + out = append(out, q[12:i]...) // 问题段 + out = append(out, 0xC0, 0x0C) // 名字指针指向问题段 + out = append(out, 0x00, 0x01, 0x00, 0x01) + out = append(out, 0x00, 0x00, 0x00, 0x3C) + out = append(out, 0x00, 0x04, 127, 0, 0, 9) + return out +} + +// TestNewDNSDialerUsesConfiguredServer 配了 DNS 就必须查它,而不是系统解析器。 +func TestNewDNSDialerUsesConfiguredServer(t *testing.T) { + addr, got := fakeDNS(t) + d := NewDNSDialer([]string{addr}) + if d.Resolver == nil { + t.Fatal("配了 DNS 却没换 Resolver") + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + ips, err := d.Resolver.LookupHost(ctx, "example.com") + if err != nil { + t.Fatalf("解析失败:%v", err) + } + if len(ips) != 1 || ips[0] != "127.0.0.9" { + t.Fatalf("解析结果=%v,期望 [127.0.0.9](说明没走配置的 DNS)", ips) + } + select { + case <-got: + default: + t.Fatal("配置的 DNS 服务器没收到查询") + } +} + +// TestNewDNSDialerEmptyUsesSystemResolver 不配 DNS 时保持系统解析器。 +func TestNewDNSDialerEmptyUsesSystemResolver(t *testing.T) { + for _, servers := range [][]string{nil, {}} { + if d := NewDNSDialer(servers); d.Resolver != nil { + t.Fatalf("servers=%v 时换了 Resolver,应该维持系统解析器", servers) + } + } +} + +// TestSetDNSDialerPatchesBothClients 两个 http.Client 都要换拨号器,且不动超时等既有参数。 +func TestSetDNSDialerPatchesBothClients(t *testing.T) { + httpTr, chatTr := &http.Transport{}, &http.Transport{} + c := &Client{ + HTTP: &http.Client{Timeout: 5 * time.Second, Transport: httpTr}, + ChatHTTP: &http.Client{Transport: chatTr}, + } + d := &net.Dialer{} + c.SetDNSDialer(d) + want := reflect.ValueOf(d.DialContext).Pointer() + for name, tr := range map[string]*http.Transport{"HTTP": httpTr, "ChatHTTP": chatTr} { + if tr.DialContext == nil { + t.Fatalf("%s 的 DialContext 没换", name) + } + if got := reflect.ValueOf(tr.DialContext).Pointer(); got != want { + t.Fatalf("%s 的 DialContext 不是传入的拨号器", name) + } + } + if c.HTTP.Timeout != 5*time.Second { + t.Fatal("既有 Timeout 被改掉了") + } +} + +// TestSetDNSDialerNilIsNoop 传 nil 不动任何东西。 +func TestSetDNSDialerNilIsNoop(t *testing.T) { + tr := &http.Transport{} + c := &Client{HTTP: &http.Client{Transport: tr}} + c.SetDNSDialer(nil) + if tr.DialContext != nil { + t.Fatal("传 nil 却换了 DialContext") + } +} From 7058593ae011f74bcd54158f7ee011951e2a758a Mon Sep 17 00:00:00 2001 From: Yong Gong <63760459+Tinkler-i@users.noreply.github.com> Date: Fri, 9 Oct 2026 00:10:08 +0800 Subject: [PATCH 05/57] =?UTF-8?q?feat(panel):=20=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E4=B8=8B=E6=96=B0=E5=A2=9E=E3=80=8C=E7=A7=AF?= =?UTF-8?q?=E5=88=86=E5=8E=86=E5=8F=B2=E3=80=8D=EF=BC=8C=E9=80=90=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E8=AE=B0=E5=BD=95=E4=BD=99=E9=A2=9D=E5=8F=98=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 upstream 客户端的余额查询出口挂观察者,每次真实查到余额与上次比对留痕; 新增 internal/credithist 账本(原子落盘、重启续接、上限 2000 条),面板新增 GET /panel/api/credit_history,前端在运行日志视图下展示。 Closes #137 (cherry picked from commit 37f593945b3fa339b82f83aa725fbd7a59c145f5) --- README.md | 2 +- cmd/server/main.go | 11 + internal/credithist/credithist.go | 196 ++++ internal/credithist/credithist_test.go | 181 +++ internal/credithist/fault_test.go | 424 +++++++ internal/panel/app.js | 78 +- .../panel/credit_frontend_adversarial_test.go | 180 +++ .../panel/credit_history_boundary_test.go | 202 ++++ internal/panel/credit_history_test.go | 169 +++ internal/panel/credit_upgrade_test.go | 1006 +++++++++++++++++ internal/panel/frontend_test.go | 47 + internal/panel/index.html | 25 + internal/panel/panel.go | 73 +- internal/scheduler/credit_upgrade_test.go | 206 ++++ internal/upstream/client.go | 45 + internal/upstream/credit_observer_test.go | 109 ++ 16 files changed, 2946 insertions(+), 8 deletions(-) create mode 100644 internal/credithist/credithist.go create mode 100644 internal/credithist/credithist_test.go create mode 100644 internal/credithist/fault_test.go create mode 100644 internal/panel/credit_frontend_adversarial_test.go create mode 100644 internal/panel/credit_history_boundary_test.go create mode 100644 internal/panel/credit_history_test.go create mode 100644 internal/panel/credit_upgrade_test.go create mode 100644 internal/scheduler/credit_upgrade_test.go create mode 100644 internal/upstream/credit_observer_test.go diff --git a/README.md b/README.md index b451ef49..f33f5be6 100644 --- a/README.md +++ b/README.md @@ -626,7 +626,7 @@ http://127.0.0.1:7863/panel/ | **任务中心** | 成长任务队列:一键扫描全部账号待办 → 按账号排队执行(账号内串行,账号间可选并发 1-3),进度实时更新;另含开学季券码查询。详见 [任务中心](#-任务中心面板新视图) | | **模型与档位** | 实时查询上游:每模型的积分倍率(含限时免费 / 夜间折扣等**生效价**与划线牌价)、默认思考档、支持的档位(含「off(可关)」)、上下文长度与最大输出;存在探测数据时最大输出列显示**实测上限与钳制告警**(见「探测模型真实输出上限」)。支持**按条件查询**:关键词(ID / 名称 / 描述 / 厂商 / 标签,空格分词 AND)、域(CN / Global)、能力(工具 / 视觉 / 思考 / 默认)、思考档位、价格(折扣 / 限时免费 / 打折),以及按倍率 / 上下文 / 最大输出 / ID 排序 | | **配置** | 在线编辑 config.json:服务(监听地址 / API 密钥 / 单账号明细条数)、定时任务(签到 / 保活 / 旅行 / 活跃 / 成长任务五类时点与开关 + 余额刷新间隔)、账号池与流量治理、上游与高级(超时 / 出站 UA / 提示词模式 / 指纹脱敏 / 粘性路由)、日志(调用来源记录开关) | -| **运行日志** | **请求记录表**(时间 / 结果 / 模型 / 账号 / **来源 IP** / **User-Agent** / 耗时 / Token / 积分 / 请求 ID),支持按 IP·UA·模型·账号·请求 ID 与结果筛选、读取条数、时间范围;下方 **运行日志** 按「任务 / 对话 / 系统」分频道,可开关自动滚动 | +| **运行日志** | **请求记录表**(时间 / 结果 / 模型 / 账号 / **来源 IP** / **User-Agent** / 耗时 / Token / 积分 / 请求 ID),支持按 IP·UA·模型·账号·请求 ID 与结果筛选、读取条数、时间范围;下方 **运行日志** 按「任务 / 对话 / 系统」分频道,可开关自动滚动;新增 **积分历史**(每次真实查到余额与上次比对,变动即留痕) | 顶部另有「刷新」(向上游全量查询真实余额并回写;5 秒自动轮询只读内存,不打上游)与「添加账号」(浏览器内完成 OAuth 设备授权:显示授权链接 + 自动轮询,登录后凭证落盘并**热加载进池,免重启**,也支持导入 cockpit tools 导出的 JSON)。 diff --git a/cmd/server/main.go b/cmd/server/main.go index 2d38dc88..013aea9c 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -19,6 +19,7 @@ import ( "github.com/linguo2625469/workbuddy2api-panel/internal/atomicfile" "github.com/linguo2625469/workbuddy2api-panel/internal/auth" + "github.com/linguo2625469/workbuddy2api-panel/internal/credithist" "github.com/linguo2625469/workbuddy2api-panel/internal/livecfg" "github.com/linguo2625469/workbuddy2api-panel/internal/panel" "github.com/linguo2625469/workbuddy2api-panel/internal/pool" @@ -305,6 +306,14 @@ func main() { log.Printf("[reqlog] 请求指标已启用;JSONL 归档已关闭") } + // 积分历史:把每次真实查到的余额与上一次比对,变动即留痕(新账号首次只建 + // 基线)。挂载在余额查询路径上(upstream 的余额观察者),签到 / 活跃上报 / + // 旅行 / 保活 / 面板手动刷新全覆盖——上游只在少数渠道落金额日志,比对余额 + // 是唯一可靠的口径。与 state/usage/request-logs 同目录,随 state_file 搬移。 + creditHist := credithist.New(stateSibling(cfg.StateFile, "credit-history.json"), 2000) + up.SetCreditObserver(creditHist.Observe) + log.Printf("[credithist] 积分历史已启用: %s", stateSibling(cfg.StateFile, "credit-history.json")) + pn := panel.New(panel.Config{ Pool: p, Usage: rec, @@ -327,6 +336,8 @@ func main() { SaveConfig: func(raw []byte) ([]string, error) { return saveConfig(raw, *cfgPath, live, p, up, sch, fallbackDirs(cfg.StateFile)...) }, + // 积分历史账本(只读展示;写入由上面的余额观察者完成)。 + CreditHistory: creditHist, }) // 成长任务队列每日自动执行(与「执行全部待办」同管线):Sequential 族零点解锁后 // 无需手动扫描;hook 返回即启动(异步执行),已在跑时内部跳过。 diff --git a/internal/credithist/credithist.go b/internal/credithist/credithist.go new file mode 100644 index 00000000..7f7d86af --- /dev/null +++ b/internal/credithist/credithist.go @@ -0,0 +1,196 @@ +// Package credithist 积分历史:把「每次真实查到的余额」与上一次比对,变动即留痕。 +// +// 为什么需要:签到 / 活跃上报 / 猫猫旅行 / 成长任务都会让余额增加,但上游只在少数 +// 渠道留下可读金额;面板「用量」折算的是**消耗**,此前没有任何逐账号的余额时间线。 +// 把余额当观测量逐次比对,就能覆盖全部获取渠道(做法与 workbuddy-manager 的 +// 「积分变动流水」一致)。 +// +// 与参考实现的有意差异:参考实现只记增加(减少属"消耗",已有用量视图覆盖)。本 +// 实现两个方向都留痕(Delta 带符号),因为本面板缺的正是"什么时候变成多少"这条 +// 时间线;展示层用正负号区分获取与消耗。 +// +// 首次见到某个 uid 只建立基线、不记流水——否则会把历史余额误报成"刚获得"。 +package credithist + +import ( + "encoding/json" + "log" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +// maxDelta 单次变动的留痕上限(对齐参考实现的 _MAX_DELTA):超过它基本可断定是 +// 上游脏数据或口径切换(如企业版"不限量"哨兵值),只更新基线、不留痕,避免一条 +// 假流水污染整段历史。 +const maxDelta = 1_000_000 + +// defaultMaxEntries 流水条数上限(超出丢最旧):按每日数十条估计可留数月。 +const defaultMaxEntries = 2000 + +// Entry 一条积分变动流水。Before/After 是变动前后余额,Delta = After - Before +// (正 = 获取,负 = 消耗)。 +type Entry struct { + Time time.Time `json:"time"` + UID string `json:"uid"` + Delta int64 `json:"delta"` + Before int64 `json:"before"` + After int64 `json:"after"` +} + +// fileVersion 落盘格式版本(与 usage.json 同风格,便于将来迁移)。 +const fileVersion = 1 + +// diskFile 落盘结构:余额快照(uid → 最近余额)与流水同文件——重启后基线不丢, +// 不会把"上次进程结束时的余额"当成新增。 +type diskFile struct { + Version int `json:"version"` + Saved string `json:"saved,omitempty"` + Snapshot map[string]int64 `json:"snapshot"` + Entries []Entry `json:"entries"` +} + +// Ledger 并发安全的积分账本。path 为空 = 纯内存(不落盘)。 +type Ledger struct { + mu sync.Mutex + path string + max int + snapshot map[string]int64 + entries []Entry // 时间升序 +} + +// New 创建账本;path 非空且文件存在时加载既有快照与流水。加载失败只记日志、 +// 从空账本开始——丢历史好过让面板起不来。maxEntries <= 0 回落默认上限。 +func New(path string, maxEntries int) *Ledger { + if maxEntries <= 0 { + maxEntries = defaultMaxEntries + } + l := &Ledger{path: path, max: maxEntries, snapshot: map[string]int64{}} + l.load() + return l +} + +// load 读回快照与流水(无文件 = 空账本,不是错误)。 +func (l *Ledger) load() { + if l.path == "" { + return + } + raw, err := os.ReadFile(l.path) + if err != nil { + if !os.IsNotExist(err) { + log.Printf("[credithist] 读取 %s 失败(从空账本开始): %v", l.path, err) + } + return + } + var f diskFile + if err := json.Unmarshal(raw, &f); err != nil { + log.Printf("[credithist] 解析 %s 失败(从空账本开始): %v", l.path, err) + return + } + if f.Snapshot != nil { + l.snapshot = f.Snapshot + } + l.entries = f.Entries + if len(l.entries) > l.max { + l.entries = append([]Entry(nil), l.entries[len(l.entries)-l.max:]...) + } + log.Printf("[credithist] 已恢复 %d 条积分流水 / %d 个余额基线(%s)", + len(l.entries), len(l.snapshot), l.path) +} + +// Observe 记录一次真实查到的余额(uid + 余额绝对值)。 +// +// 语义: +// - 首次见到该 uid:只建基线,不记流水; +// - 余额与基线相同:直接返回(不落盘); +// - 余额变化且 |Delta| <= maxDelta:追加一条流水; +// - 余额变化但 |Delta| > maxDelta:只更新基线(脏数据不留痕)。 +// +// 同步落盘:留痕频率是"每次余额刷新"级(分钟级),文件很小,无需防抖队列。 +func (l *Ledger) Observe(uid string, credits int64) { + // 纯空白 uid 与空 uid 一样无效:放进去只会留下一个面板永远筛不到的幽灵基线 + // (uid 过滤参数为空 = 不过滤,所以这种条目既无用也删不掉)。 + if l == nil || strings.TrimSpace(uid) == "" { + return + } + l.mu.Lock() + prev, seen := l.snapshot[uid] + if seen && prev == credits { + l.mu.Unlock() + return + } + l.snapshot[uid] = credits + if seen { + if delta := credits - prev; delta >= -maxDelta && delta <= maxDelta { + l.entries = append(l.entries, Entry{ + Time: time.Now(), + UID: uid, + Delta: delta, + Before: prev, + After: credits, + }) + if len(l.entries) > l.max { + // 丢最旧:copy 到新底层数组,避免 append 复用旧数组导致内存不释放。 + l.entries = append([]Entry(nil), l.entries[len(l.entries)-l.max:]...) + } + } + } + raw, err := json.Marshal(diskFile{ + Version: fileVersion, + Saved: time.Now().Format(time.RFC3339), + Snapshot: l.snapshot, + Entries: l.entries, + }) + if err != nil { + l.mu.Unlock() + log.Printf("[credithist] 序列化失败: %v", err) + return + } + // 落盘仍在锁内:多个账号并发刷新余额时,"快照+流水"必须按同一顺序写盘, + // 否则慢的那次会把新的文件内容覆盖成旧的(内存正确、重启后少一条)。 + // 留痕频率是分钟级且仅在余额变化时触发,锁内小文件原子写无性能顾虑。 + l.write(raw) + l.mu.Unlock() +} + +// Read 返回最近 limit 条流水(新的在前);limit <= 0 = 全部。 +func (l *Ledger) Read(limit int) []Entry { + if l == nil { + return []Entry{} + } + l.mu.Lock() + defer l.mu.Unlock() + n := len(l.entries) + if limit <= 0 || limit > n { + limit = n + } + out := make([]Entry, 0, limit) + for i := n - 1; i >= n-limit; i-- { + out = append(out, l.entries[i]) + } + return out +} + +// write 原子写盘(临时文件 + rename,与 usage.Recorder 同口径)。失败只记日志: +// 留痕是旁路观测,不能反过来影响余额刷新主流程。 +func (l *Ledger) write(raw []byte) { + if l.path == "" { + return + } + if dir := filepath.Dir(l.path); dir != "" && dir != "." { + if err := os.MkdirAll(dir, 0o755); err != nil { + log.Printf("[credithist] 建目录失败: %v", err) + return + } + } + tmp := l.path + ".tmp" + if err := os.WriteFile(tmp, raw, 0o600); err != nil { + log.Printf("[credithist] 写临时文件失败: %v", err) + return + } + if err := os.Rename(tmp, l.path); err != nil { + log.Printf("[credithist] 原子替换失败: %v", err) + } +} diff --git a/internal/credithist/credithist_test.go b/internal/credithist/credithist_test.go new file mode 100644 index 00000000..fb6651f5 --- /dev/null +++ b/internal/credithist/credithist_test.go @@ -0,0 +1,181 @@ +package credithist + +import ( + "fmt" + "os" + "path/filepath" + "sync" + "testing" +) + +// 首次见到账号只建基线:把历史余额误报成「刚获得」是最刺眼的错误, +// 也是参考实现明确写下的一条约束。 +func TestFirstObserveOnlyBaselines(t *testing.T) { + l := New("", 100) + l.Observe("u1", 1000) + if got := len(l.Read(0)); got != 0 { + t.Fatalf("首次观测应只建基线,得到 %d 条流水", got) + } + // 基线生效:下一次同值不记、变化才记。 + l.Observe("u1", 1000) + if got := len(l.Read(0)); got != 0 { + t.Fatalf("余额未变不应记流水,得到 %d 条", got) + } + l.Observe("u1", 1100) + got := l.Read(0) + if len(got) != 1 { + t.Fatalf("余额 +100 应记 1 条,得到 %d 条", len(got)) + } + if got[0].Delta != 100 || got[0].Before != 1000 || got[0].After != 1100 || got[0].UID != "u1" { + t.Fatalf("流水字段不符: %+v", got[0]) + } +} + +// 消耗(余额下降)同样留痕,Delta 为负:面板缺的是逐账号余额时间线, +// 只记增加会让"什么时候变少"永远无从查证。 +func TestDecreaseIsRecorded(t *testing.T) { + l := New("", 100) + l.Observe("u1", 1000) + l.Observe("u1", 940) + got := l.Read(0) + if len(got) != 1 || got[0].Delta != -60 { + t.Fatalf("余额 -60 应记一条 delta=-60 的流水,得到 %+v", got) + } +} + +// |Delta| 超过 maxDelta 视为上游脏数据:只更新基线、不留痕(对齐参考实现 +// _MAX_DELTA),且不得把基线留在旧值上导致下一轮继续误判。 +func TestDirtyDeltaOnlyUpdatesBaseline(t *testing.T) { + l := New("", 100) + l.Observe("u1", 100) + l.Observe("u1", 100+maxDelta+1) // 超过上限 → 不留痕 + if got := len(l.Read(0)); got != 0 { + t.Fatalf("超限变动不应留痕,得到 %d 条", got) + } + // 基线已更新到新值:回落 100 应记一条 -1000001 之外的正常流水。 + l.Observe("u1", 200) + got := l.Read(0) + if len(got) != 1 || got[0].Before != 100+maxDelta+1 || got[0].After != 200 { + t.Fatalf("基线未更新,得到 %+v", got) + } +} + +// maxEntries 截断:只保留最新的 N 条,丢的是最旧的。 +func TestMaxEntriesTrimsOldest(t *testing.T) { + l := New("", 3) + l.Observe("u1", 0) + for i := int64(1); i <= 5; i++ { + l.Observe("u1", i*10) + } + got := l.Read(0) + if len(got) != 3 { + t.Fatalf("上限 3 应只保留 3 条,得到 %d 条", len(got)) + } + // 新的在前:10→20→30→40→50 里应剩 30/40/50。 + if got[0].After != 50 || got[1].After != 40 || got[2].After != 30 { + t.Fatalf("截断应保留最新 3 条,得到 %+v", got) + } +} + +// 落盘后重建实例仍能续接基线:快照与流水同文件,重启不会重复记基线、 +// 也不会把重启当成一次变动。 +func TestSnapshotSurvivesRestart(t *testing.T) { + path := filepath.Join(t.TempDir(), "credit-history.json") + l := New(path, 100) + l.Observe("u1", 1000) // 基线落盘 + if _, err := os.Stat(path); err != nil { + t.Fatalf("Observe 后应已落盘: %v", err) + } + + l2 := New(path, 100) + l2.Observe("u1", 1000) // 同值:不记 + if got := len(l2.Read(0)); got != 0 { + t.Fatalf("重启后同值不应记流水,得到 %d 条", got) + } + l2.Observe("u1", 1250) + got := l2.Read(0) + if len(got) != 1 || got[0].Delta != 250 || got[0].Before != 1000 { + t.Fatalf("重启后应能续接基线并记 +250,得到 %+v", got) + } +} + +// Read 的倒序与 limit 语义:新的在前,limit<=0 = 全部。 +func TestReadOrderAndLimit(t *testing.T) { + l := New("", 100) + l.Observe("u1", 0) + l.Observe("u1", 10) + l.Observe("u1", 20) + l.Observe("u1", 30) + all := l.Read(0) + if len(all) != 3 || all[0].After != 30 || all[2].After != 10 { + t.Fatalf("全量应新的在前: %+v", all) + } + one := l.Read(1) + if len(one) != 1 || one[0].After != 30 { + t.Fatalf("limit=1 应取最新一条: %+v", one) + } +} + +// 空 uid 不建基线:面板存在 uid 为空的异常条目,不能污染账本。 +func TestEmptyUIDIgnored(t *testing.T) { + l := New("", 100) + l.Observe("", 100) + l.Observe("", 200) + if got := len(l.Read(0)); got != 0 { + t.Fatalf("空 uid 不应记账,得到 %d 条", got) + } +} + +// 损坏文件不能拖垮启动:从空账本开始,后续观测照常工作。 +func TestCorruptFileFallsBackToEmpty(t *testing.T) { + path := filepath.Join(t.TempDir(), "credit-history.json") + if err := os.WriteFile(path, []byte("{not json"), 0o600); err != nil { + t.Fatal(err) + } + l := New(path, 100) + l.Observe("u1", 500) + l.Observe("u1", 600) + got := l.Read(0) + if len(got) != 1 || got[0].Delta != 100 { + t.Fatalf("损坏文件后应能正常建基线并记账,得到 %+v", got) + } +} + +// 并发 Observe:调度器全量余额刷新是多账号并行的,观测不能丢记录, +// 落盘也不能被慢写覆盖(内存与磁盘最终必须一致)。 +func TestConcurrentObserve(t *testing.T) { + path := filepath.Join(t.TempDir(), "credit-history.json") + l := New(path, 500) + const accounts = 8 + const rounds = 20 + for i := 0; i < accounts; i++ { + l.Observe(fmt.Sprintf("u%d", i), 100) // 基线 + } + var wg sync.WaitGroup + for i := 0; i < accounts; i++ { + uid := fmt.Sprintf("u%d", i) + wg.Add(1) + go func() { + defer wg.Done() + for n := int64(1); n <= rounds; n++ { + l.Observe(uid, 100+n*10) + } + }() + } + wg.Wait() + + want := accounts * rounds + if got := len(l.Read(0)); got != want { + t.Fatalf("并发观测应产生 %d 条流水,得到 %d", want, got) + } + // 重启读取必须与内存一致:慢写覆盖会让磁盘少条、快照回退。 + l2 := New(path, 500) + if got := len(l2.Read(0)); got != want { + t.Fatalf("重启后应保留 %d 条,得到 %d(落盘被覆盖)", want, got) + } + for i := 0; i < accounts; i++ { + if v := l2.snapshot[fmt.Sprintf("u%d", i)]; v != 300 { + t.Fatalf("u%d 快照应为 300,得到 %d", i, v) + } + } +} diff --git a/internal/credithist/fault_test.go b/internal/credithist/fault_test.go new file mode 100644 index 00000000..598de86b --- /dev/null +++ b/internal/credithist/fault_test.go @@ -0,0 +1,424 @@ +package credithist + +// T5 故障注入与边界测试(账本层)。只新增测试,不改业务代码。 + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "sync" + "testing" +) + +// ── 1. 落盘目标不可写 ────────────────────────────────────────────────────── + +// 目标路径任何一段不可写(这里用「父路径是普通文件」构造,Windows / POSIX 都成立) +// 时,Observe 只能记日志:不得 panic、不得影响内存账本;故障排除后必须恢复落盘。 +func TestFaultUnwritableTargetKeepsMemoryAndRecovers(t *testing.T) { + dir := t.TempDir() + blocker := filepath.Join(dir, "blocker") + if err := os.WriteFile(blocker, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + path := filepath.Join(blocker, "credit-history.json") // 父路径是普通文件 → MkdirAll 与写盘必失败 + + l := New(path, 100) + l.Observe("u1", 100) // 基线:落盘失败 + l.Observe("u1", 250) // +150:落盘失败 + got := l.Read(0) + if len(got) != 1 || got[0].Delta != 150 || got[0].After != 250 { + t.Fatalf("落盘失败不应影响内存账本,得到 %+v", got) + } + if l.snapshot["u1"] != 250 { + t.Fatalf("内存基线应为 250,得到 %d", l.snapshot["u1"]) + } + if _, err := os.Stat(path); err == nil { + t.Fatal("父路径是普通文件时不应写出账本文件") + } + + // 排除故障:删掉阻塞文件、建同名目录,再观察必须恢复落盘。 + if err := os.Remove(blocker); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(blocker, 0o755); err != nil { + t.Fatal(err) + } + l.Observe("u1", 300) // +50 + l2 := New(path, 100) + got = l2.Read(0) + if len(got) != 2 || got[0].Delta != 50 || got[1].Delta != 150 { + t.Fatalf("恢复可写后应落盘并保留两条流水,得到 %+v", got) + } + if l2.snapshot["u1"] != 300 { + t.Fatalf("恢复后快照应为 300,得到 %d", l2.snapshot["u1"]) + } +} + +// POSIX 目录只读路径。Windows 的 os.Chmod 只改文件只读属性,没有目录写保护语义, +// 故在 Windows 上跳过(不可写路径已由上面的父路径为文件用例覆盖)。 +func TestFaultReadOnlyDirPOSIX(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows 无 POSIX 目录权限语义") + } + if os.Geteuid() == 0 { + t.Skip("root 不受目录权限限制") + } + dir := t.TempDir() + sub := filepath.Join(dir, "ro") + if err := os.Mkdir(sub, 0o755); err != nil { + t.Fatal(err) + } + path := filepath.Join(sub, "credit-history.json") + l := New(path, 100) + l.Observe("u1", 10) // 正常落盘 + if err := os.Chmod(sub, 0o500); err != nil { + t.Fatal(err) + } + l.Observe("u1", 20) // 写临时文件失败 → 只记日志 + if got := l.Read(0); len(got) != 1 || got[0].Delta != 10 { + t.Fatalf("只读目录下内存账本仍应正确,得到 %+v", got) + } + if err := os.Chmod(sub, 0o755); err != nil { + t.Fatal(err) + } + l.Observe("u1", 30) + l2 := New(path, 100) + if got := l2.Read(0); len(got) != 2 || got[1].Delta != 10 { + t.Fatalf("恢复可写后应落盘两条,得到 %+v", got) + } +} + +// ── 2. 退化/损坏文件 ────────────────────────────────────────────────────── + +// 损坏 JSON、null 快照/流水、空文件、类型不符、顶层数组、路径本身是目录—— +// 一律从空账本安全启动,后续 Observe 正常,不得 panic。 +func TestFaultDegenerateFilesStartEmpty(t *testing.T) { + cases := []struct { + name string + setup func(t *testing.T, path string) + }{ + {"invalid_json", func(t *testing.T, p string) { + if err := os.WriteFile(p, []byte("{not json"), 0o600); err != nil { + t.Fatal(err) + } + }}, + {"empty_file", func(t *testing.T, p string) { + if err := os.WriteFile(p, nil, 0o600); err != nil { + t.Fatal(err) + } + }}, + {"null_snapshot_and_entries", func(t *testing.T, p string) { + if err := os.WriteFile(p, []byte(`{"version":1,"snapshot":null,"entries":null}`), 0o600); err != nil { + t.Fatal(err) + } + }}, + {"entries_null", func(t *testing.T, p string) { + if err := os.WriteFile(p, []byte(`{"version":1,"snapshot":{"u1":5},"entries":null}`), 0o600); err != nil { + t.Fatal(err) + } + }}, + {"wrong_types", func(t *testing.T, p string) { + if err := os.WriteFile(p, []byte(`{"version":1,"snapshot":"x","entries":42}`), 0o600); err != nil { + t.Fatal(err) + } + }}, + {"top_level_array", func(t *testing.T, p string) { + if err := os.WriteFile(p, []byte("[]"), 0o600); err != nil { + t.Fatal(err) + } + }}, + {"path_is_directory", func(t *testing.T, p string) { + if err := os.Mkdir(p, 0o755); err != nil { + t.Fatal(err) + } + }}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "credit-history.json") + c.setup(t, path) + l := New(path, 100) // 必须不 panic + base := len(l.Read(0)) + l.Observe("fresh", 100) // 基线 + l.Observe("fresh", 130) // +30 + got := l.Read(0) + if len(got) != base+1 { + t.Fatalf("退化输入后应能正常观测:base=%d 期望 %d 条,得到 %d(%+v)", base, base+1, len(got), got) + } + if got[0].UID != "fresh" || got[0].Delta != 30 || got[0].Before != 100 || got[0].After != 130 { + t.Fatalf("新增流水字段不符: %+v", got[0]) + } + }) + } +} + +// ── 3. maxEntries 回落与截断 ────────────────────────────────────────────── + +// maxEntries <= 0 回落默认上限,不产生无界增长;=1 只留最新一条。 +func TestFaultMaxEntriesFallbackAndTrim(t *testing.T) { + for _, m := range []int{0, -1, -1000} { + l := New("", m) + if l.max != defaultMaxEntries { + t.Fatalf("maxEntries=%d 应回落默认 %d,得到 %d", m, defaultMaxEntries, l.max) + } + } + + // 不无界增长:写 defaultMaxEntries+300 次,条数必须恰为默认上限。 + l := New("", 0) + l.Observe("u1", 0) + for i := int64(1); i <= int64(defaultMaxEntries)+300; i++ { + l.Observe("u1", i) + } + got := l.Read(0) + if len(got) != defaultMaxEntries { + t.Fatalf("默认上限 %d 应截断,得到 %d", defaultMaxEntries, len(got)) + } + if got[0].After != int64(defaultMaxEntries)+300 { + t.Fatalf("截断后最新一条应保留,得到 %+v", got[0]) + } + + // maxEntries=1:只留最新一条,且丢的是最旧。 + l1 := New("", 1) + l1.Observe("u1", 0) + l1.Observe("u1", 10) + l1.Observe("u1", 20) + one := l1.Read(0) + if len(one) != 1 || one[0].After != 20 || one[0].Delta != 10 { + t.Fatalf("上限 1 应只留最新一条,得到 %+v", one) + } +} + +// ── 4. Read 边界 ────────────────────────────────────────────────────────── + +// limit<=0 = 全部;大于总数 = 全部;1 = 最新;顺序严格新的在前。 +func TestFaultReadBoundaries(t *testing.T) { + l := New("", 100) + l.Observe("u1", 0) + for i := int64(1); i <= 3; i++ { + l.Observe("u1", i*10) + } + cases := []struct { + name string + limit int + want int + }{ + {"zero_is_all", 0, 3}, + {"negative_is_all", -7, 3}, + {"over_total_is_all", 99, 3}, + {"exact_total", 3, 3}, + {"two", 2, 2}, + {"one", 1, 1}, + } + for _, c := range cases { + if got := l.Read(c.limit); len(got) != c.want { + t.Fatalf("%s: Read(%d) 得到 %d 条,期望 %d", c.name, c.limit, len(got), c.want) + } + } + got := l.Read(0) + if got[0].After != 30 || got[1].After != 20 || got[2].After != 10 { + t.Fatalf("必须严格新的在前: %+v", got) + } + got = l.Read(1) + if len(got) != 1 || got[0].After != 30 { + t.Fatalf("Read(1) 应取最新一条: %+v", got) + } +} + +// ── 5. Δ 边界 ──────────────────────────────────────────────────────────── + +// 恰好 ±maxDelta 记账;±(maxDelta+1) 只更新基线、不留痕,且基线必须推进到新值。 +func TestFaultDeltaBoundaries(t *testing.T) { + // +++ 恰好 +1000000 记账 + l := New("", 100) + l.Observe("u1", 0) + l.Observe("u1", maxDelta) + if got := l.Read(0); len(got) != 1 || got[0].Delta != maxDelta || got[0].Before != 0 || got[0].After != maxDelta { + t.Fatalf("恰好 +%d 应记账: %+v", maxDelta, got) + } + // --- 恰好 -1000000 记账 + l = New("", 100) + l.Observe("u1", maxDelta) + l.Observe("u1", 0) + if got := l.Read(0); len(got) != 1 || got[0].Delta != -maxDelta { + t.Fatalf("恰好 -%d 应记账: %+v", maxDelta, got) + } + // +++ 超限 +1000001 不留痕,但基线推进 + l = New("", 100) + l.Observe("u1", 0) + l.Observe("u1", maxDelta+1) + if got := len(l.Read(0)); got != 0 { + t.Fatalf("超限 +%d 不应留痕,得到 %d 条", maxDelta+1, got) + } + if l.snapshot["u1"] != maxDelta+1 { + t.Fatalf("超限后基线应推进到 %d,得到 %d", maxDelta+1, l.snapshot["u1"]) + } + l.Observe("u1", 1) // Δ = 1-(maxDelta+1) = -maxDelta → 正常记账 + got := l.Read(0) + if len(got) != 1 || got[0].Delta != -maxDelta || got[0].Before != maxDelta+1 || got[0].After != 1 { + t.Fatalf("超限后应按新基线记账: %+v", got) + } + // --- 超限 -1000001 不留痕,基线推进 + l = New("", 100) + l.Observe("u1", 0) + l.Observe("u1", -(maxDelta + 1)) + if got := len(l.Read(0)); got != 0 { + t.Fatalf("超限 -%d 不应留痕,得到 %d 条", maxDelta+1, got) + } + if l.snapshot["u1"] != -(maxDelta + 1) { + t.Fatalf("超限后基线应推进到 %d,得到 %d", -(maxDelta + 1), l.snapshot["u1"]) + } + l.Observe("u1", -1) // Δ = -1-(-(maxDelta+1)) = +maxDelta + got = l.Read(0) + if len(got) != 1 || got[0].Delta != maxDelta || got[0].Before != -(maxDelta+1) { + t.Fatalf("超限后应按新基线记账: %+v", got) + } +} + +// ── 6. uid 边界 ─────────────────────────────────────────────────────────── + +// 空 uid(上游异常条目)不建基线、不记账。 +func TestFaultEmptyUIDIgnored(t *testing.T) { + l := New("", 100) + l.Observe("", 100) + l.Observe("", 200) + if got := len(l.Read(0)); got != 0 { + t.Fatalf("空 uid 不应记账,得到 %d 条", got) + } + if len(l.snapshot) != 0 { + t.Fatalf("空 uid 不应建基线,得到 %v", l.snapshot) + } +} + +// 任务描述要求纯空白 uid(上游脏数据)同样不建基线。当前实现只判 uid == "", +// 纯空白会进快照——此用例作为复现保留,等 lead 裁决。 +func TestFaultWhitespaceUIDNotBaselined(t *testing.T) { + l := New("", 100) + l.Observe(" ", 100) + l.Observe("\t\n", 100) + if got := len(l.Read(0)); got != 0 { + t.Fatalf("纯空白 uid 不应记账,得到 %d 条", got) + } + if len(l.snapshot) != 0 { + t.Fatalf("纯空白 uid 不应建基线,但快照里有 %d 个键: %v", len(l.snapshot), l.snapshot) + } +} + +// ── 7. 并发 ────────────────────────────────────────────────────────────── + +// 多 goroutine(不同 uid 定量 + 同一 uid 混跑)不 panic、不同 uid 条数不丢、 +// 磁盘与内存逐条一致;同一 uid 并发后再顺序写一个唯一哨兵,必须是最终基线。 +func TestFaultConcurrentMixedUIDs(t *testing.T) { + path := filepath.Join(t.TempDir(), "credit-history.json") + l := New(path, 4000) + const accounts = 6 + const rounds = 30 + for i := 0; i < accounts; i++ { + l.Observe(fmt.Sprintf("acct-%d", i), 0) + } + l.Observe("shared", 0) + + var wg sync.WaitGroup + for i := 0; i < accounts; i++ { + uid := fmt.Sprintf("acct-%d", i) + wg.Add(1) + go func() { + defer wg.Done() + for n := int64(1); n <= rounds; n++ { + l.Observe(uid, n*10) + } + }() + } + for w := 0; w < 8; w++ { + base := int64(w) * 1000 + wg.Add(1) + go func() { + defer wg.Done() + for n := int64(1); n <= 20; n++ { + l.Observe("shared", base+n) + } + }() + } + wg.Wait() + + // 顺序写入唯一哨兵 = 「最后一次写入」,并发结束后必须生效。 + l.Observe("shared", 777777) + if l.snapshot["shared"] != 777777 { + t.Fatalf("最后写入应成为基线,得到 %d", l.snapshot["shared"]) + } + + mem := l.Read(0) + per := map[string]int{} + for _, e := range mem { + per[e.UID]++ + } + for i := 0; i < accounts; i++ { + uid := fmt.Sprintf("acct-%d", i) + if per[uid] != rounds { + t.Fatalf("%s 应有 %d 条流水,得到 %d", uid, rounds, per[uid]) + } + } + + // 磁盘必须与内存逐条一致(慢写覆盖会让磁盘少条/快照回退)。 + l2 := New(path, 4000) + disk := l2.Read(0) + if len(disk) != len(mem) { + t.Fatalf("重启后条数不一致:内存 %d / 磁盘 %d", len(mem), len(disk)) + } + for i := range mem { + m, d := mem[i], disk[i] + // 不能直接比较 struct:内存里的 time.Time 带单调时钟读数,落盘再读回后没有。 + if m.UID != d.UID || m.Delta != d.Delta || m.Before != d.Before || m.After != d.After || !m.Time.Equal(d.Time) { + t.Fatalf("第 %d 条内存/磁盘不一致: 内存 %+v / 磁盘 %+v", i, m, d) + } + } + if l2.snapshot["shared"] != 777777 { + t.Fatalf("重启后 shared 基线应为 777777,得到 %d", l2.snapshot["shared"]) + } + for i := 0; i < accounts; i++ { + uid := fmt.Sprintf("acct-%d", i) + if l2.snapshot[uid] != rounds*10 { + t.Fatalf("重启后 %s 基线应为 %d,得到 %d", uid, rounds*10, l2.snapshot[uid]) + } + } +} + +// ── 8. 临时文件残留与文件权限 ────────────────────────────────────────────── + +// 正常落盘(临时文件 + rename)不得在目录里留下 .tmp 残留。 +func TestFaultNoTempResidueAfterWrite(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "credit-history.json") + l := New(path, 100) + l.Observe("u1", 1) + l.Observe("u1", 2) + l.Observe("u1", 3) + left, err := filepath.Glob(filepath.Join(dir, "*.tmp")) + if err != nil { + t.Fatal(err) + } + if len(left) != 0 { + t.Fatalf("正常落盘不应残留临时文件,发现 %v", left) + } + if _, err := os.Stat(path); err != nil { + t.Fatalf("应有账本文件: %v", err) + } +} + +// 账本文件权限不得宽于 0600。Windows 的 os.WriteFile 不套用 POSIX 权限位, +// Stat().Mode().Perm() 恒为 0666,故该断言只在 POSIX 生效。 +func TestFaultFileModeNotWiderThan0600(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows 不应用 POSIX 权限位(Perm() 恒为 0666);该断言只在 POSIX 生效") + } + path := filepath.Join(t.TempDir(), "credit-history.json") + l := New(path, 100) + l.Observe("u1", 1) + fi, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if perm := fi.Mode().Perm(); perm&0o077 != 0 { + t.Fatalf("账本文件权限不应宽于 0600,得到 %04o", perm) + } +} diff --git a/internal/panel/app.js b/internal/panel/app.js index 6387def3..4c1e5bbb 100644 --- a/internal/panel/app.js +++ b/internal/panel/app.js @@ -881,10 +881,11 @@ async function loadLogs() { const rq = trangeQuery('reqRange', false); rq.set('limit', limit); try { - const [d, metrics, requestRows] = await Promise.all([ + const [d, metrics, requestRows, credit] = await Promise.all([ api('logs'), api('request_metrics').catch(() => ({})), api('request_logs?' + rq.toString()).catch(() => ({ entries: [] })), + api('credit_history?limit=' + creditLimitValue()).catch(() => null), ]); // 归档开启时以归档为准——「区间内没有记录」是一个真实结果,不能回落成内存里 // 的最近 100 条(那会把筛选条件之外、时间范围之外的请求显示出来)。 @@ -907,6 +908,7 @@ async function loadLogs() { $('logNote').textContent = logCh === 'all' ? '任务 ' + (counts.task || 0) + ' · 对话 ' + (counts.chat || 0) + ' · 系统 ' + (counts.sys || 0) : (logCh === 'task' ? '任务' : logCh === 'chat' ? '对话' : '系统') + ' ' + entries.length + ' 行'; + renderCreditHistory(credit); } catch (e) { /* 概览已提示 */ } } @@ -928,6 +930,78 @@ function renderRequestMetrics(m, entries) { renderRequestTable(); } +/* ── 积分历史(每次真实查到余额与上次比对,变动即留痕)───────────────── + 纯格式化函数集中在此,便于前端测试按切片断言;变动列 +N / −N(U+2212, + 与请求记录一致),余额用千分位。 */ +function creditNum(n) { + return Number(n || 0).toLocaleString('zh-CN'); +} +function creditDeltaText(delta) { + const d = Number(delta || 0); + if (d > 0) return '+' + creditNum(d); + if (d < 0) return '−' + creditNum(-d); + return '0'; +} +function creditTimeText(e) { + if (!e || !e.time) return '—'; + const d = new Date(e.time); + // 正常链路 time 由 Go 的 time.Time 序列化,恒为 RFC3339;这里挡的是手工改文件/ + // 将来换格式等脏数据——toLocaleTimeString 遇到非法日期不抛异常,只会渲染出 + // "Invalid Date" 这种没意义的字样。 + return isNaN(d.getTime()) ? '—' : d.toLocaleTimeString('zh-CN', { hour12: false }); +} +function creditAccountText(e) { + e = e || {}; + return e.account ? String(e.account) : (e.uid || '—'); +} +function creditBalanceText(e) { + return creditNum(e && e.after); +} +function creditDescText(e) { + return '余额 ' + creditNum(e && e.before) + ' → ' + creditNum(e && e.after); +} +function creditEntryText(e) { + return creditTimeText(e) + ' | ' + creditAccountText(e) + ' | ' + creditDeltaText(e && e.delta) + + ' | ' + creditBalanceText(e) + ' | ' + creditDescText(e); +} +function creditHistoryNote(entries) { + entries = entries || []; + if (!entries.length) return '暂无积分变动记录'; + let net = 0; + for (const e of entries) net += Number(e && e.delta || 0); + return entries.length + ' 条 · 净 ' + (net > 0 ? '+' : net < 0 ? '−' : '') + creditNum(Math.abs(net)); +} +function renderCreditHistory(d) { + const body = $('creditBody'), note = $('creditNote'); + if (!body) return; + if (!d) { + if (note) note.textContent = '积分历史不可用'; + body.innerHTML = '<tr><td colspan="5" style="color:var(--ink-3)">积分历史不可用</td></tr>'; + return; + } + const entries = d.entries || []; + if (note) note.textContent = creditHistoryNote(entries); + body.innerHTML = entries.length + ? entries.map(e => '<tr>' + + '<td>' + esc(creditTimeText(e)) + '</td>' + + '<td>' + esc(creditAccountText(e)) + '</td>' + + '<td class="num">' + esc(creditDeltaText(e && e.delta)) + '</td>' + + '<td class="num">' + esc(creditBalanceText(e)) + '</td>' + + '<td>' + esc(creditDescText(e)) + '</td>' + + '</tr>').join('') + : '<tr><td colspan="5" style="color:var(--ink-3)">暂无积分变动记录</td></tr>'; +} +function creditLimitValue() { + const el = $('creditLimit'); + const n = el ? Number(el.value) : 100; + return n === 300 || n === 1000 ? n : 100; +} +async function loadCreditHistory() { + let d = null; + try { d = await api('credit_history?limit=' + creditLimitValue()); } catch (e) { d = null; } + renderCreditHistory(d); +} + /* reqMatch 请求记录筛选:q 对 IP/UA/模型/账号/请求 ID 做空格分词的 AND 包含匹配, outcome 精确匹配。两者都在已拉取的条目上做(最多 1000 条),不发新请求。 */ function reqMatch(e, f) { @@ -1017,6 +1091,8 @@ if ($('btnReqReload')) $('btnReqReload').onclick = loadLogs; // 时间范围:默认「全部历史」——请求记录页的历史行为就是"取最近 N 条", // 加一个默认收窄的区间会让打开页面时看到的条数凭空变少。 if ($('reqRange')) trangeBind('reqRange', loadLogs, '0'); +if ($('creditLimit')) $('creditLimit').onchange = loadCreditHistory; +if ($('btnCreditReload')) $('btnCreditReload').onclick = loadCreditHistory; function requestLogText(e) { const when = e && e.time ? new Date(e.time).toLocaleTimeString('zh-CN', { hour12: false }) : '—'; diff --git a/internal/panel/credit_frontend_adversarial_test.go b/internal/panel/credit_frontend_adversarial_test.go new file mode 100644 index 00000000..6bae4782 --- /dev/null +++ b/internal/panel/credit_frontend_adversarial_test.go @@ -0,0 +1,180 @@ +package panel + +// T5 前端对抗测试:node + vm 切片(仿 frontend_test.go 的 TestAppJSCreditHistoryFormatting)。 +// 脏数据 / XSS / 501 分支不得抛异常、不得输出未转义 HTML。只新增测试,不改业务代码。 + +import ( + "encoding/json" + "os" + "os/exec" + "strings" + "testing" +) + +// bndCreditNode 把脚本写到临时文件并用 node 执行(工作目录 = 包目录,读 app.js), +// 返回去掉首尾空白的 stdout;无 node 时跳过。 +func bndCreditNode(t *testing.T, script string) string { + t.Helper() + node, err := exec.LookPath("node") + if err != nil { + t.Skip("node not installed; credit history adversarial test skipped") + } + f, err := os.CreateTemp(t.TempDir(), "credit-adv-*.cjs") + if err != nil { + t.Fatal(err) + } + if _, err := f.WriteString(script); err != nil { + t.Fatal(err) + } + f.Close() + out, err := exec.Command(node, f.Name(), "app.js").CombinedOutput() + if err != nil { + t.Fatalf("credit history adversarial node test failed: %v\n%s", err, out) + } + return strings.TrimSpace(string(out)) +} + +// 脏数据 / XSS / 不可用分支。返回 JSON: +// +// *Ok = true 表示未抛异常;其余字段是渲染结果,供 Go 侧断言转义与文案。 +const bndCreditAdversarialScript = `const fs = require('fs'); +const vm = require('vm'); +const src = fs.readFileSync(process.argv[2], 'utf8'); +const escStart = src.indexOf('function esc('); +const escEnd = src.indexOf('function ago('); +const start = src.indexOf('function creditNum'); +const end = src.indexOf('function creditLimitValue'); +if ([escStart, escEnd, start, end].some(v => v < 0)) throw new Error('credit history helpers not found'); +const els = {}; +const $ = id => (els[id] || (els[id] = { innerHTML: '', textContent: '' })); +const ctx = { Date, Number, String, Math, RegExp, isNaN, $ }; +vm.createContext(ctx); +vm.runInContext(src.slice(escStart, escEnd) + src.slice(start, end) + + '\nthis.renderCreditHistory=renderCreditHistory;', ctx); +const t = new Date(2026, 8, 28, 14, 5, 6).toISOString(); +const out = {}; +const run = (key, fn) => { try { fn(); out[key] = true; } catch (e) { out[key] = String((e && e.stack) || e); } }; + +run('nullEntriesOk', () => ctx.renderCreditHistory({ entries: null })); +out.nullEntriesNote = els.creditNote.textContent; +out.nullEntriesBody = els.creditBody.innerHTML; + +run('missingFieldsOk', () => ctx.renderCreditHistory({ entries: [{}] })); +out.missingFieldsBody = els.creditBody.innerHTML; + +run('badDeltaOk', () => ctx.renderCreditHistory({ entries: [ + { time: t, uid: 'u1', delta: '50', before: '0', after: '50' }, + { time: t, uid: 'u2', delta: 'abc', before: 0, after: 0 }, + { time: t, uid: 'u3', delta: NaN, before: 0, after: 0 }, +] })); +out.badDeltaBody = els.creditBody.innerHTML; + +const evilAccount = '<script>alert(1)</script>"\' onmouseover="x'; +run('xssOk', () => ctx.renderCreditHistory({ entries: [ + { time: t, uid: '<img src=x onerror=alert(1)>', account: evilAccount, delta: 1, before: 0, after: 1 }, +] })); +out.xssBody = els.creditBody.innerHTML; + +run('unavailableOk', () => ctx.renderCreditHistory(null)); +out.unavailableNote = els.creditNote.textContent; +out.unavailableBody = els.creditBody.innerHTML; + +process.stdout.write(JSON.stringify(out));` + +func TestBndCreditFrontendAdversarial(t *testing.T) { + out := bndCreditNode(t, bndCreditAdversarialScript) + var got map[string]any + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("node 输出不是 JSON: %v\n%s", err, out) + } + str := func(k string) string { + s, _ := got[k].(string) + return s + } + ok := func(k string) { + t.Helper() + if got[k] != true { + t.Fatalf("%s: 渲染抛异常或未执行: %v", k, got[k]) + } + } + + // entries=null:不抛异常,回「暂无」而不是「不可用」。 + ok("nullEntriesOk") + if note := str("nullEntriesNote"); note != "暂无积分变动记录" { + t.Fatalf("entries=null 的提示应为暂无,得到 %q", note) + } + if body := str("nullEntriesBody"); !strings.Contains(body, "暂无积分变动记录") { + t.Fatalf("entries=null 的表格应为暂无,得到 %s", body) + } + + // 字段全缺:不抛异常,以 — / 0 兜底。 + ok("missingFieldsOk") + if body := str("missingFieldsBody"); !strings.Contains(body, "余额 0 → 0") || !strings.Contains(body, "—") { + t.Fatalf("缺字段行应以 — / 0 兜底,得到 %s", body) + } + + // delta 为字符串 / 非法字符串 / NaN:不抛异常,不渲染出 NaN。 + ok("badDeltaOk") + badDelta := str("badDeltaBody") + if !strings.Contains(badDelta, "+50") { + t.Fatalf("字符串 50 应按数字渲染为 +50,得到 %s", badDelta) + } + if strings.Contains(badDelta, "NaN") { + t.Fatalf("delta 非法时不应渲染出 NaN,得到 %s", badDelta) + } + if n := strings.Count(badDelta, "<tr>"); n != 3 { + t.Fatalf("应渲染 3 行,得到 %d: %s", n, badDelta) + } + + // account / uid 含 <script>、<img>、引号:必须全部转义。 + ok("xssOk") + xss := str("xssBody") + for _, raw := range []string{"<script>", "<img", `onmouseover="x"`} { + if strings.Contains(xss, raw) { + t.Fatalf("未转义的 %q 出现在输出中: %s", raw, xss) + } + } + for _, enc := range []string{"<script>", """, "'"} { + if !strings.Contains(xss, enc) { + t.Fatalf("输出缺少转义结果 %q: %s", enc, xss) + } + } + + // 不可用分支(catch 到 null):文案必须是「积分历史不可用」,不能混成「暂无」。 + ok("unavailableOk") + if note := str("unavailableNote"); note != "积分历史不可用" { + t.Fatalf("不可用提示应为「积分历史不可用」,得到 %q", note) + } + if body := str("unavailableBody"); !strings.Contains(body, "积分历史不可用") || strings.Contains(body, "暂无") { + t.Fatalf("不可用表格文案不符: %s", body) + } +} + +// time 非法字符串:不抛异常(由 node 退出码保证),并应以 — 兜底显示。 +const bndCreditInvalidTimeScript = `const fs = require('fs'); +const vm = require('vm'); +const src = fs.readFileSync(process.argv[2], 'utf8'); +const start = src.indexOf('function creditNum'); +const end = src.indexOf('function creditLimitValue'); +if (start < 0 || end < 0) throw new Error('credit history helpers not found'); +const ctx = { Date, Number, String, Math, RegExp, isNaN }; +vm.createContext(ctx); +vm.runInContext(src.slice(start, end) + '\nthis.creditEntryText=creditEntryText;', ctx); +process.stdout.write(JSON.stringify({ + bad: ctx.creditEntryText({ time: 'not-a-date', uid: 'u1', delta: 1, before: 0, after: 1 }), + empty: ctx.creditEntryText({ time: '', uid: 'u1', delta: 1, before: 0, after: 1 }), +}));` + +func TestBndCreditFrontendInvalidTimeFallback(t *testing.T) { + out := bndCreditNode(t, bndCreditInvalidTimeScript) + var got map[string]string + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("node 输出不是 JSON: %v\n%s", err, out) + } + if !strings.HasPrefix(got["empty"], "—") { + t.Fatalf("空 time 应以 — 兜底,得到 %q", got["empty"]) + } + if !strings.HasPrefix(got["bad"], "—") { + t.Fatalf("非法 time 应以 — 兜底(不抛异常、不显示 Invalid Date),得到 %q", got["bad"]) + } +} diff --git a/internal/panel/credit_history_boundary_test.go b/internal/panel/credit_history_boundary_test.go new file mode 100644 index 00000000..c039e23b --- /dev/null +++ b/internal/panel/credit_history_boundary_test.go @@ -0,0 +1,202 @@ +package panel + +// T5 接口层边界与故障测试。只新增测试,不改业务代码。 + +import ( + "encoding/json" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" + + "github.com/linguo2625469/workbuddy2api-panel/internal/credithist" + "github.com/linguo2625469/workbuddy2api-panel/internal/pool" +) + +// limit 口径:默认 200、上限 1000;0 / 负数 / 非法 / 溢出 / 空 一律回落 200。 +func TestBndCreditHistoryLimitBoundaries(t *testing.T) { + l := credithist.New("", 100) + l.Observe("u1", 0) + for i := int64(1); i <= 3; i++ { + l.Observe("u1", i) + } + p := creditPanel(t, l) + cases := []struct { + query string + want int + }{ + {"", 200}, + {"?limit=", 200}, + {"?limit=0", 200}, + {"?limit=-1", 200}, + {"?limit=abc", 200}, + {"?limit=1e9", 200}, + {"?limit=99999999999999999999", 200}, // Atoi 溢出 + {"?limit=1", 1}, + {"?limit=1000", 1000}, + {"?limit=1001", 1000}, + {"?limit=1000000000", 1000}, + } + for _, c := range cases { + code, body := creditGet(t, p, c.query) + if code != 200 { + t.Fatalf("credit_history%s -> %d %s", c.query, code, body) + } + var out creditResp + if err := json.Unmarshal([]byte(body), &out); err != nil { + t.Fatalf("credit_history%s 响应不是合法 JSON: %v %s", c.query, err, body) + } + if out.Limit != c.want { + t.Fatalf("credit_history%s limit=%d want %d", c.query, out.Limit, c.want) + } + if out.Entries == nil { + t.Fatalf("credit_history%s entries 不应为 null: %s", c.query, body) + } + } +} + +// uid 过滤:命中 / 不命中 / 空串;且账本条数超过 limit 时,目标账号的较旧流水 +// 仍必须被取到(验证服务端先全量取回再过滤,而不是先截断再过滤)。 +func TestBndCreditHistoryUIDFilterBoundaries(t *testing.T) { + l := credithist.New("", 2000) + l.Observe("u1", 0) + l.Observe("u2", 0) + l.Observe("u1", 10) // u1 的变动先发生 + for i := int64(1); i <= 300; i++ { + l.Observe("u2", i) // 之后是 300 条更新的 u2 流水 + } + p := creditPanel(t, l) + + // 命中:u1 那条比「最新 100 条」更旧,仍必须返回。 + code, body := creditGet(t, p, "?uid=u1&limit=100") + if code != 200 { + t.Fatalf("uid=u1 -> %d %s", code, body) + } + var out creditResp + if err := json.Unmarshal([]byte(body), &out); err != nil { + t.Fatalf("响应不是合法 JSON: %v %s", err, body) + } + if len(out.Entries) != 1 || out.Entries[0].UID != "u1" || out.Entries[0].Delta != 10 { + t.Fatalf("uid 过滤应取到较旧的 u1 流水: %+v", out.Entries) + } + if out.Limit != 100 { + t.Fatalf("limit 应回显 100,得到 %d", out.Limit) + } + + // 不命中:必须是 [],不能是 null。 + code, body = creditGet(t, p, "?uid=nope&limit=100") + if code != 200 { + t.Fatalf("uid=nope -> %d %s", code, body) + } + if !strings.Contains(body, `"entries":[]`) { + t.Fatalf("不命中应回 [],得到 %s", body) + } + + // 空串 = 不过滤。 + code, body = creditGet(t, p, "?uid=&limit=5") + if code != 200 { + t.Fatalf("uid 空串 -> %d %s", code, body) + } + if err := json.Unmarshal([]byte(body), &out); err != nil { + t.Fatalf("响应不是合法 JSON: %v %s", err, body) + } + if len(out.Entries) != 5 { + t.Fatalf("空 uid 应视为不过滤并回 5 条,得到 %d", len(out.Entries)) + } +} + +// 账本满 2000 条时 limit=100 只回 100 条;字段类型必须正确 +// (time 为 RFC3339 字符串,delta/before/after 为 JSON 数字)。 +func TestBndCreditHistoryFullLedgerFieldTypes(t *testing.T) { + l := credithist.New("", 2000) + l.Observe("u1", 0) + for i := int64(1); i <= 2000; i++ { + l.Observe("u1", i) + } + if got := len(l.Read(0)); got != 2000 { + t.Fatalf("账本应为 2000 条,得到 %d", got) + } + code, body := creditGet(t, creditPanel(t, l), "?limit=100") + if code != 200 { + t.Fatalf("-> %d %s", code, body) + } + // 用 json.RawMessage 看原始类型,避免反序列化到强类型后把类型错误吞掉。 + var rawResp struct { + Entries []map[string]json.RawMessage `json:"entries"` + Limit int `json:"limit"` + } + if err := json.Unmarshal([]byte(body), &rawResp); err != nil { + t.Fatalf("响应不是合法 JSON: %v\n%s", err, body) + } + if rawResp.Limit != 100 { + t.Fatalf("limit 应回显 100,得到 %d", rawResp.Limit) + } + if len(rawResp.Entries) != 100 { + t.Fatalf("2000 条账本 limit=100 应只回 100 条,得到 %d", len(rawResp.Entries)) + } + first := rawResp.Entries[0] + for _, k := range []string{"time", "uid", "account", "delta", "before", "after"} { + if _, ok := first[k]; !ok { + t.Fatalf("字段 %s 缺失: %s", k, body) + } + } + var tstr string + if err := json.Unmarshal(first["time"], &tstr); err != nil { + t.Fatalf("time 应为 JSON 字符串: %v", err) + } + if _, err := time.Parse(time.RFC3339, tstr); err != nil { + t.Fatalf("time 应为 RFC3339,得到 %q: %v", tstr, err) + } + for _, k := range []string{"delta", "before", "after"} { + s := strings.TrimSpace(string(first[k])) + if s == "" || s[0] == '"' { + t.Fatalf("%s 应为 JSON 数字,得到 %s", k, s) + } + if _, err := strconv.ParseInt(s, 10, 64); err != nil { + t.Fatalf("%s 应为整数,得到 %s", k, s) + } + } + var after0, after1 int64 + if err := json.Unmarshal(first["after"], &after0); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(rawResp.Entries[1]["after"], &after1); err != nil { + t.Fatal(err) + } + if after0 != 2000 || after1 != 1999 { + t.Fatalf("新的应在前: 首条 after=%d 次条 after=%d", after0, after1) + } +} + +// 账本未装配 → 501 固定文案;空账本 → entries=[];api_key 为空 → 不鉴权。 +func TestBndCreditHistoryNilLedgerAndOpenAuth(t *testing.T) { + // 未装配账本 = 501(与 RequestLog 同语义)。 + missing := New(Config{Version: "test", APIKey: "test-key", Pool: pool.New("")}) + code, body := creditGet(t, missing, "") + if code != 501 || !strings.Contains(body, "credit history not available") { + t.Fatalf("nil 账本应回 501 + 固定文案,得到 %d %s", code, body) + } + + // 空账本 → entries 必须是 [],不是 null。 + l := credithist.New("", 100) + empty := New(Config{Version: "test", APIKey: "test-key", Pool: pool.New(""), CreditHistory: l}) + code, body = creditGet(t, empty, "") + if code != 200 || !strings.Contains(body, `"entries":[]`) { + t.Fatalf("空账本应回 200 且 entries=[],得到 %d %s", code, body) + } + + // api_key 为空 = 不启用鉴权(既有语义):无 Authorization 头也应放行。 + l.Observe("u1", 0) + l.Observe("u1", 5) + open := New(Config{Version: "test", Pool: pool.New(""), CreditHistory: l}) + req := httptest.NewRequest("GET", "/panel/api/credit_history", nil) + rr := httptest.NewRecorder() + open.ServeHTTP(rr, req) + if rr.Code != 200 { + t.Fatalf("无 api_key 应放行,得到 %d %s", rr.Code, rr.Body.String()) + } + if !strings.Contains(rr.Body.String(), `"delta":5`) { + t.Fatalf("应返回流水,得到 %s", rr.Body.String()) + } +} diff --git a/internal/panel/credit_history_test.go b/internal/panel/credit_history_test.go new file mode 100644 index 00000000..266d9562 --- /dev/null +++ b/internal/panel/credit_history_test.go @@ -0,0 +1,169 @@ +package panel + +import ( + "encoding/json" + "net/http/httptest" + "strings" + "testing" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" + "github.com/linguo2625469/workbuddy2api-panel/internal/credithist" + "github.com/linguo2625469/workbuddy2api-panel/internal/pool" +) + +// creditPanel 构造带积分账本与一个具名账号的面板(鉴权走 test-key)。 +func creditPanel(t *testing.T, ledger *credithist.Ledger) *Panel { + t.Helper() + p := pool.New("") + p.Add(&auth.Auth{UID: "u1", Nickname: "小明"}) + return New(Config{Version: "test", APIKey: "test-key", Pool: p, CreditHistory: ledger}) +} + +func creditGet(t *testing.T, p *Panel, query string) (int, string) { + t.Helper() + req := httptest.NewRequest("GET", "/panel/api/credit_history"+query, nil) + req.Header.Set("Authorization", "Bearer test-key") + rr := httptest.NewRecorder() + p.ServeHTTP(rr, req) + return rr.Code, rr.Body.String() +} + +type creditResp struct { + Entries []struct { + Time string `json:"time"` + UID string `json:"uid"` + Account string `json:"account"` + Delta int64 `json:"delta"` + Before int64 `json:"before"` + After int64 `json:"after"` + } `json:"entries"` + Limit int `json:"limit"` +} + +// 接口形状:新的在前、字段齐全、昵称由池快照填充(账本只存 uid)。 +func TestCreditHistoryEndpointShape(t *testing.T) { + l := credithist.New("", 100) + l.Observe("u1", 1000) // 基线 + l.Observe("u1", 1100) // +100 + l.Observe("u2", 5) // 无昵称账号:基线 + l.Observe("u2", 25) // +20(晚于 u1) + + code, body := creditGet(t, creditPanel(t, l), "?limit=10") + if code != 200 { + t.Fatalf("GET credit_history -> %d %s", code, body) + } + var out creditResp + if err := json.Unmarshal([]byte(body), &out); err != nil { + t.Fatalf("响应不是合法 JSON: %v\n%s", err, body) + } + if out.Limit != 10 { + t.Fatalf("limit 应回显 10,得到 %d", out.Limit) + } + if len(out.Entries) != 2 { + t.Fatalf("应有 2 条流水(基线不记),得到 %d: %s", len(out.Entries), body) + } + if out.Entries[0].UID != "u2" || out.Entries[0].Delta != 20 || out.Entries[0].Before != 5 || out.Entries[0].After != 25 { + t.Fatalf("新的应在前且字段齐全,得到 %+v", out.Entries[0]) + } + if out.Entries[1].UID != "u1" || out.Entries[1].Delta != 100 || out.Entries[1].Before != 1000 || out.Entries[1].After != 1100 { + t.Fatalf("u1 流水不符,得到 %+v", out.Entries[1]) + } + if out.Entries[1].Account != "小明" { + t.Fatalf("昵称应由池快照填充,得到 %q", out.Entries[1].Account) + } + if out.Entries[0].Account != "" { + t.Fatalf("池内无此账号时应回空串,得到 %q", out.Entries[0].Account) + } + if out.Entries[0].Time == "" { + t.Fatal("time 不应为空") + } +} + +// limit 口径与 requestLogs 一致:默认 200、上限 1000、非法值回落默认。 +func TestCreditHistoryLimitClamp(t *testing.T) { + l := credithist.New("", 100) + l.Observe("u1", 0) + for i := int64(1); i <= 5; i++ { + l.Observe("u1", i*10) + } + p := creditPanel(t, l) + cases := []struct { + query string + want int + }{ + {"", 200}, + {"?limit=2", 2}, + {"?limit=5000", 1000}, + {"?limit=0", 200}, + {"?limit=abc", 200}, + {"?limit=-3", 200}, + } + for _, c := range cases { + code, body := creditGet(t, p, c.query) + if code != 200 { + t.Fatalf("credit_history%s -> %d %s", c.query, code, body) + } + var out creditResp + if err := json.Unmarshal([]byte(body), &out); err != nil { + t.Fatal(err) + } + if out.Limit != c.want { + t.Fatalf("credit_history%s limit=%d want %d", c.query, out.Limit, c.want) + } + } +} + +// uid 过滤按精确匹配:先全量取回再筛,不会被 limit 截断掉目标账号的流水。 +func TestCreditHistoryUIDFilter(t *testing.T) { + l := credithist.New("", 100) + l.Observe("u1", 0) + l.Observe("u2", 0) + l.Observe("u1", 10) + l.Observe("u2", 20) + code, body := creditGet(t, creditPanel(t, l), "?uid=u1&limit=1") + if code != 200 { + t.Fatalf("-> %d %s", code, body) + } + var out creditResp + if err := json.Unmarshal([]byte(body), &out); err != nil { + t.Fatal(err) + } + if len(out.Entries) != 1 || out.Entries[0].UID != "u1" || out.Entries[0].Delta != 10 { + t.Fatalf("uid 过滤结果不符: %+v", out.Entries) + } +} + +// 空账本必须回 [](而不是 null):前端把 null 与"接口不可用"混在一起会显示成加载失败。 +func TestCreditHistoryEmptyReturnsArray(t *testing.T) { + code, body := creditGet(t, creditPanel(t, credithist.New("", 100)), "") + if code != 200 { + t.Fatalf("-> %d %s", code, body) + } + if !strings.Contains(body, `"entries":[]`) { + t.Fatalf("空结果应为 [],得到 %s", body) + } +} + +// 账本未装配 = 501(与 RequestLog 同语义),不是 500 也不是空列表—— +// "功能不可用"与"没有记录"必须在协议层可区分。 +func TestCreditHistoryNotConfigured(t *testing.T) { + p := New(Config{Version: "test", APIKey: "test-key", Pool: pool.New("")}) + code, body := creditGet(t, p, "") + if code != 501 { + t.Fatalf("未装配账本应回 501,得到 %d %s", code, body) + } + if !strings.Contains(body, "credit history not available") { + t.Fatalf("501 文案不符: %s", body) + } +} + +// 接口与其他 /panel/api/* 同口径鉴权:不带 key 必须 401。 +func TestCreditHistoryRequiresAuth(t *testing.T) { + p := creditPanel(t, credithist.New("", 100)) + req := httptest.NewRequest("GET", "/panel/api/credit_history", nil) + rr := httptest.NewRecorder() + p.ServeHTTP(rr, req) + if rr.Code != 401 { + t.Fatalf("无 key 应回 401,得到 %d", rr.Code) + } +} diff --git a/internal/panel/credit_upgrade_test.go b/internal/panel/credit_upgrade_test.go new file mode 100644 index 00000000..166bfe57 --- /dev/null +++ b/internal/panel/credit_upgrade_test.go @@ -0,0 +1,1006 @@ +package panel + +// T4 升级场景集成测试(面板侧)。 +// +// 覆盖:老部署升级首启只建基线 / 第二次查询双向留痕 / 升级后新增账号(导入路径) +// 不污染老基线 / 多账号快照互相独立 / 重启续接 / 企业版额度与不限量哨兵 / +// 上游失败不留痕 / 二进制级升级 smoke。 +// +// 硬约束:只读业务代码,不改任何生产文件;全部临时文件走 t.TempDir()。 + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" + "time" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" + "github.com/linguo2625469/workbuddy2api-panel/internal/credithist" + "github.com/linguo2625469/workbuddy2api-panel/internal/pool" + "github.com/linguo2625469/workbuddy2api-panel/internal/upstream" +) + +// --------------------------------------------------------------------------- +// 假上游:按 accessToken 识别账号,可分别控制个人版余额、企业版已用/额度与业务错误。 +// --------------------------------------------------------------------------- + +const upgFakeSize = int64(1) << 40 + +type upgFakeBilling struct { + mu sync.Mutex + tokens map[string]string // accessToken -> uid + remain map[string]int64 // uid -> 个人版 CycleCapacityRemain + entUsed map[string]int64 // uid -> 企业版 credit(已用) + entLim map[string]int64 // uid -> 企业版 limitNum(额度) + fail map[string]bool // uid -> 返回业务错误 + calls map[string]int // uid -> 请求次数 + srv *httptest.Server +} + +func upgNewFakeBilling(t *testing.T) *upgFakeBilling { + t.Helper() + f := &upgFakeBilling{ + tokens: map[string]string{}, + remain: map[string]int64{}, + entUsed: map[string]int64{}, + entLim: map[string]int64{}, + fail: map[string]bool{}, + calls: map[string]int{}, + } + f.srv = httptest.NewServer(http.HandlerFunc(f.handle)) + t.Cleanup(f.srv.Close) + return f +} + +func (f *upgFakeBilling) register(uid string, remain int64) { + f.mu.Lock() + defer f.mu.Unlock() + f.tokens["at-"+uid] = uid + f.remain[uid] = remain +} + +func (f *upgFakeBilling) setPersonal(uid string, remain int64) { + f.mu.Lock() + defer f.mu.Unlock() + f.remain[uid] = remain +} + +func (f *upgFakeBilling) setFail(uid string, fail bool) { + f.mu.Lock() + defer f.mu.Unlock() + f.fail[uid] = fail +} + +func (f *upgFakeBilling) registerEnterprise(uid string, used, limit int64) { + f.mu.Lock() + defer f.mu.Unlock() + f.tokens["at-"+uid] = uid + f.entUsed[uid] = used + f.entLim[uid] = limit +} + +func (f *upgFakeBilling) setEnterprise(uid string, used, limit int64) { + f.mu.Lock() + defer f.mu.Unlock() + f.entUsed[uid] = used + f.entLim[uid] = limit +} + +func (f *upgFakeBilling) callCount(uid string) int { + f.mu.Lock() + defer f.mu.Unlock() + return f.calls[uid] +} + +func (f *upgFakeBilling) handle(w http.ResponseWriter, r *http.Request) { + tok := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + f.mu.Lock() + uid := f.tokens[tok] + f.calls[uid]++ + fail := f.fail[uid] + remain := f.remain[uid] + used := f.entUsed[uid] + limit := f.entLim[uid] + f.mu.Unlock() + + w.Header().Set("Content-Type", "application/json") + switch { + case strings.HasSuffix(r.URL.Path, "/get-enterprise-user-usage"): + if fail { + io.WriteString(w, `{"code":400,"msg":"bad request"}`) + return + } + fmt.Fprintf(w, `{"code":0,"msg":"OK","data":{"credit":%d,"limitNum":%d}}`, used, limit) + case strings.HasSuffix(r.URL.Path, "/get-user-resource"): + if fail { + io.WriteString(w, `{"code":400,"msg":"bad request"}`) + return + } + fmt.Fprintf(w, `{"code":0,"data":{"Response":{"Data":{"Accounts":[{"CycleCapacitySize":%d,"CycleCapacityRemain":%d,"CycleCapacityUsed":0}]}}}}`, upgFakeSize, remain) + case strings.HasSuffix(r.URL.Path, "/daily-checkin"): + io.WriteString(w, `{"code":0,"msg":"ok","data":{}}`) + default: + http.Error(w, "not found", 404) + } +} + +// --------------------------------------------------------------------------- +// 升级环境装配:老部署的 auths/ + state.json → 新版 pool/upstream/ledger/panel +// --------------------------------------------------------------------------- + +type upgSeed struct { + uid string + nickname string + enterpriseID string + domain string + realm string + credits int64 +} + +type upgEnv struct { + t *testing.T + dir string + authDir string + stateFile string + ledgerPath string + fake *upgFakeBilling + pool *pool.Pool + up *upstream.Client + ledger *credithist.Ledger + panel *Panel +} + +// upgNewEnv 按老部署的磁盘形态装配:auths/*.json 与 state.json 先落盘,再走 +// auth.LoadDir + pool.SyncToDir(与 cmd/server 启动序一致)。 +func upgNewEnv(t *testing.T, seeds ...upgSeed) *upgEnv { + t.Helper() + dir := t.TempDir() + authDir := filepath.Join(dir, "auths") + dataDir := filepath.Join(dir, "data") + for _, d := range []string{authDir, dataDir} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } + fake := upgNewFakeBilling(t) + accounts := map[string]any{} + for _, s := range seeds { + realm := s.realm + if realm == "" { + realm = "cn" + } + doc := map[string]any{ + "accessToken": "at-" + s.uid, + "refreshToken": "rt-" + s.uid, + "expiresAt": time.Now().Add(365 * 24 * time.Hour).Unix(), + "uid": s.uid, + "nickname": s.nickname, + "realm": realm, + } + if s.enterpriseID != "" { + doc["enterpriseId"] = s.enterpriseID + } + if s.domain != "" { + doc["domain"] = s.domain + } + raw, _ := json.Marshal(doc) + if err := os.WriteFile(filepath.Join(authDir, "workbuddy-"+s.uid+".json"), raw, 0o600); err != nil { + t.Fatal(err) + } + accounts[s.uid] = map[string]any{"credits": s.credits} + fake.register(s.uid, s.credits) + } + stateRaw, _ := json.Marshal(map[string]any{"accounts": accounts}) + stateFile := filepath.Join(dataDir, "state.json") + if err := os.WriteFile(stateFile, stateRaw, 0o600); err != nil { + t.Fatal(err) + } + + e := &upgEnv{ + t: t, + dir: dir, + authDir: authDir, + stateFile: stateFile, + ledgerPath: filepath.Join(dataDir, "credit-history.json"), + fake: fake, + } + e.pool = pool.New(stateFile) + t.Cleanup(e.pool.Close) + auths, err := auth.LoadDir(authDir) + if err != nil { + t.Fatalf("LoadDir: %v", err) + } + e.pool.SyncToDir(auths) + e.up = &upstream.Client{HTTP: fake.srv.Client(), ChatBaseCN: fake.srv.URL, BillingBaseCN: fake.srv.URL} + e.ledger = credithist.New(e.ledgerPath, 2000) + e.up.SetCreditObserver(e.ledger.Observe) + e.panel = New(Config{ + Version: "test", + APIKey: "test-key", + Pool: e.pool, + Upstream: e.up, + CreditHistory: e.ledger, + AuthDir: e.authDir, + }) + return e +} + +// addAccount 模拟「升级后新增账号」:落 auth 文件 + pool.Add(不走导入/登录)。 +func (e *upgEnv) addAccount(uid, nickname string) { + e.t.Helper() + doc := map[string]any{ + "accessToken": "at-" + uid, + "refreshToken": "rt-" + uid, + "expiresAt": time.Now().Add(365 * 24 * time.Hour).Unix(), + "uid": uid, + "nickname": nickname, + "realm": "cn", + } + raw, _ := json.Marshal(doc) + path := filepath.Join(e.authDir, "workbuddy-"+uid+".json") + if err := os.WriteFile(path, raw, 0o600); err != nil { + e.t.Fatal(err) + } + a, err := auth.Parse(raw) + if err != nil { + e.t.Fatal(err) + } + a.FilePath = path + e.fake.register(uid, 0) + e.pool.Add(a) +} + +// balance 走面板单号余额刷新接口,触发一次真实上游余额查询(观察者由此留痕)。 +func (e *upgEnv) balance(uid string) (int, string) { + e.t.Helper() + return upgPost(e.t, e.panel, "/panel/api/accounts/"+uid+"/balance") +} + +// importCockpit 走面板导入路径(cockpit multipart),导入成功后上游会顺带查一次余额。 +func (e *upgEnv) importCockpit(accounts []map[string]any) (int, string) { + e.t.Helper() + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + fw, err := mw.CreateFormFile("file", "accounts.json") + if err != nil { + e.t.Fatal(err) + } + raw, _ := json.Marshal(accounts) + if _, err := fw.Write(raw); err != nil { + e.t.Fatal(err) + } + if err := mw.Close(); err != nil { + e.t.Fatal(err) + } + req := httptest.NewRequest("POST", "/panel/api/import/cockpit", &buf) + req.Header.Set("Content-Type", mw.FormDataContentType()) + req.Header.Set("Authorization", "Bearer test-key") + rr := httptest.NewRecorder() + e.panel.ServeHTTP(rr, req) + return rr.Code, rr.Body.String() +} + +// --------------------------------------------------------------------------- +// 通用断言辅助 +// --------------------------------------------------------------------------- + +func upgGet(t *testing.T, p *Panel, path string) (int, string) { + t.Helper() + req := httptest.NewRequest("GET", path, nil) + req.Header.Set("Authorization", "Bearer test-key") + rr := httptest.NewRecorder() + p.ServeHTTP(rr, req) + return rr.Code, rr.Body.String() +} + +func upgPost(t *testing.T, p *Panel, path string) (int, string) { + t.Helper() + req := httptest.NewRequest("POST", path, nil) + req.Header.Set("Authorization", "Bearer test-key") + rr := httptest.NewRecorder() + p.ServeHTTP(rr, req) + return rr.Code, rr.Body.String() +} + +type upgStateAccount struct { + Credits int64 `json:"credits"` +} + +type upgStateFile struct { + Accounts map[string]upgStateAccount `json:"accounts"` +} + +func upgReadState(t *testing.T, path string) upgStateFile { + t.Helper() + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("读 state.json: %v", err) + } + var sf upgStateFile + if err := json.Unmarshal(raw, &sf); err != nil { + t.Fatalf("state.json 不可解析(被破坏?): %v\n%s", err, raw) + } + return sf +} + +type upgDiskLedger struct { + Snapshot map[string]int64 `json:"snapshot"` + Entries []struct { + Time string `json:"time"` + UID string `json:"uid"` + Delta int64 `json:"delta"` + Before int64 `json:"before"` + After int64 `json:"after"` + } `json:"entries"` +} + +func upgReadLedger(t *testing.T, path string) upgDiskLedger { + t.Helper() + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("读 credit-history.json: %v", err) + } + var d upgDiskLedger + if err := json.Unmarshal(raw, &d); err != nil { + t.Fatalf("credit-history.json 不可解析: %v\n%s", err, raw) + } + return d +} + +func upgCreditEntries(t *testing.T, p *Panel, query string) creditResp { + t.Helper() + code, body := upgGet(t, p, "/panel/api/credit_history"+query) + if code != 200 { + t.Fatalf("GET credit_history%s -> %d %s", query, code, body) + } + var out creditResp + if err := json.Unmarshal([]byte(body), &out); err != nil { + t.Fatalf("credit_history 响应非法: %v\n%s", err, body) + } + return out +} + +// --------------------------------------------------------------------------- +// A. 升级首启:账本不存在 + 已有账号 → 第一次真实查询只建基线 +// --------------------------------------------------------------------------- + +func TestUpgUpgradeFirstStartBuildsBaselineOnly(t *testing.T) { + e := upgNewEnv(t, upgSeed{uid: "u1", nickname: "老账号", credits: 1300}) + + if _, err := os.Stat(e.ledgerPath); !os.IsNotExist(err) { + t.Fatalf("升级前不应存在账本文件,stat err=%v", err) + } + st, ok := e.pool.Status("u1") + if !ok || st.Credits != 1300 { + t.Fatalf("老 state.json 的 credits 未加载: ok=%v status=%+v", ok, st) + } + + e.fake.setPersonal("u1", 1300) // 与旧部署最后一次余额相同 + code, body := e.balance("u1") + if code != 200 { + t.Fatalf("升级后首次余额查询 -> %d %s", code, body) + } + if n := len(e.ledger.Read(0)); n != 0 { + t.Fatalf("首次见到账号只应建基线,得到 %d 条流水", n) + } + d := upgReadLedger(t, e.ledgerPath) + if d.Snapshot["u1"] != 1300 { + t.Fatalf("credit-history.json 快照应含 u1=1300,得到 %+v", d.Snapshot) + } + if len(d.Entries) != 0 { + t.Fatalf("基线不应产生流水,得到 %+v", d.Entries) + } + + out := upgCreditEntries(t, e.panel, "?limit=10") + if len(out.Entries) != 0 { + t.Fatalf("面板接口应回空流水,得到 %+v", out.Entries) + } + code, body = upgGet(t, e.panel, "/panel/api/credit_history?limit=10") + if code != 200 || !strings.Contains(body, `"entries":[]`) { + t.Fatalf("空结果必须是 [](不是 null): %d %s", code, body) + } + + sf := upgReadState(t, e.stateFile) + if _, ok := sf.Accounts["u1"]; !ok { + t.Fatalf("升级后旧 state.json 账号丢失: %+v", sf.Accounts) + } +} + +// --------------------------------------------------------------------------- +// B. 第二次查询:+N / −N 各记一条,不变不记 +// --------------------------------------------------------------------------- + +func TestUpgSecondQueryRecordsDeltaBothDirections(t *testing.T) { + e := upgNewEnv(t, upgSeed{uid: "u1", credits: 1300}) + + e.fake.setPersonal("u1", 1300) + if code, body := e.balance("u1"); code != 200 { + t.Fatalf("baseline -> %d %s", code, body) + } + e.fake.setPersonal("u1", 1400) + if code, body := e.balance("u1"); code != 200 { + t.Fatalf("+100 -> %d %s", code, body) + } + e.fake.setPersonal("u1", 1370) + if code, body := e.balance("u1"); code != 200 { + t.Fatalf("-30 -> %d %s", code, body) + } + e.fake.setPersonal("u1", 1370) // 不变 + if code, body := e.balance("u1"); code != 200 { + t.Fatalf("no-change -> %d %s", code, body) + } + + entries := e.ledger.Read(0) + if len(entries) != 2 { + t.Fatalf("应有 2 条流水(+100 / -30),得到 %d: %+v", len(entries), entries) + } + // Read 新的在前 + if entries[0].Delta != -30 || entries[0].Before != 1400 || entries[0].After != 1370 { + t.Fatalf("最新一条应为 -30(1400→1370),得到 %+v", entries[0]) + } + if entries[1].Delta != 100 || entries[1].Before != 1300 || entries[1].After != 1400 { + t.Fatalf("较早一条应为 +100(1300→1400),得到 %+v", entries[1]) + } + + out := upgCreditEntries(t, e.panel, "?limit=10") + if len(out.Entries) != 2 || out.Entries[0].Delta != -30 || out.Entries[0].UID != "u1" { + t.Fatalf("面板流水不符: %+v", out.Entries) + } +} + +// --------------------------------------------------------------------------- +// C. 升级后新增账号(导入路径):新账号只建基线,老账号基线不被污染 +// --------------------------------------------------------------------------- + +func TestUpgImportNewAccountKeepsOldBaseline(t *testing.T) { + e := upgNewEnv(t, upgSeed{uid: "u1", nickname: "老账号", credits: 1000}) + + e.fake.setPersonal("u1", 1000) + if code, body := e.balance("u1"); code != 200 { + t.Fatalf("u1 baseline -> %d %s", code, body) + } + + // 新账号走面板 cockpit 导入路径(内部会顺带查一次余额) + e.fake.register("u2", 500) + code, body := e.importCockpit([]map[string]any{{ + "uid": "u2", + "nickname": "新号", + "access_token": "at-u2", + "refresh_token": "rt-u2", + "expires_at": time.Now().Add(365 * 24 * time.Hour).UnixMilli(), + }}) + if code != 200 { + t.Fatalf("导入 -> %d %s", code, body) + } + if !strings.Contains(body, `"imported":1`) { + t.Fatalf("导入未成功: %s", body) + } + + d := upgReadLedger(t, e.ledgerPath) + if d.Snapshot["u2"] != 500 { + t.Fatalf("新账号应只建基线 500,得到 %+v", d.Snapshot) + } + if len(d.Entries) != 0 { + t.Fatalf("导入新账号不应产生流水,得到 %+v", d.Entries) + } + + // 老账号随后变化,仍按升级前的基线 1000 记 + e.fake.setPersonal("u1", 1050) + if code, body := e.balance("u1"); code != 200 { + t.Fatalf("u1 after import -> %d %s", code, body) + } + entries := e.ledger.Read(0) + if len(entries) != 1 { + t.Fatalf("应恰好 1 条老账号流水,得到 %d: %+v", len(entries), entries) + } + if entries[0].UID != "u1" || entries[0].Delta != 50 || entries[0].Before != 1000 || entries[0].After != 1050 { + t.Fatalf("老账号基线被污染(before 应为 1000): %+v", entries[0]) + } + + d = upgReadLedger(t, e.ledgerPath) + if d.Snapshot["u1"] != 1050 || d.Snapshot["u2"] != 500 { + t.Fatalf("两号快照应互相独立: %+v", d.Snapshot) + } +} + +// --------------------------------------------------------------------------- +// D. 部分账号先导入、升级后再加新账号:快照互相独立、互不覆盖 +// --------------------------------------------------------------------------- + +func TestUpgLateAddedAccountSnapshotsIndependent(t *testing.T) { + e := upgNewEnv(t, + upgSeed{uid: "u1", credits: 100}, + upgSeed{uid: "u2", credits: 200}, + ) + + e.fake.setPersonal("u1", 100) + if code, body := e.balance("u1"); code != 200 { + t.Fatalf("u1 baseline -> %d %s", code, body) + } + e.fake.setPersonal("u2", 200) + if code, body := e.balance("u2"); code != 200 { + t.Fatalf("u2 baseline -> %d %s", code, body) + } + + // 升级后再加 u3(非导入路径) + e.addAccount("u3", "三号") + e.fake.setPersonal("u3", 300) + if code, body := e.balance("u3"); code != 200 { + t.Fatalf("u3 baseline -> %d %s", code, body) + } + if n := len(e.ledger.Read(0)); n != 0 { + t.Fatalf("三个账号首次查询都应只建基线,得到 %d 条", n) + } + + e.fake.setPersonal("u1", 150) + e.fake.setPersonal("u2", 250) + e.fake.setPersonal("u3", 330) + for _, uid := range []string{"u1", "u2", "u3"} { + if code, body := e.balance(uid); code != 200 { + t.Fatalf("%s change -> %d %s", uid, code, body) + } + } + + entries := e.ledger.Read(0) + if len(entries) != 3 { + t.Fatalf("应 3 条流水,得到 %d: %+v", len(entries), entries) + } + wantBefore := map[string]int64{"u1": 100, "u2": 200, "u3": 300} + wantDelta := map[string]int64{"u1": 50, "u2": 50, "u3": 30} + for _, en := range entries { + if wantBefore[en.UID] != en.Before || wantDelta[en.UID] != en.Delta { + t.Fatalf("%s 基线/增量不符: %+v(want before=%d delta=%d)", + en.UID, en, wantBefore[en.UID], wantDelta[en.UID]) + } + } + + d := upgReadLedger(t, e.ledgerPath) + if d.Snapshot["u1"] != 150 || d.Snapshot["u2"] != 250 || d.Snapshot["u3"] != 330 { + t.Fatalf("磁盘快照互相覆盖: %+v", d.Snapshot) + } + + // 重启读盘后仍一致 + l2 := credithist.New(e.ledgerPath, 2000) + if n := len(l2.Read(0)); n != 3 { + t.Fatalf("重启后流水条数 %d want 3", n) + } +} + +// --------------------------------------------------------------------------- +// E. 重启续接:同路径新建 Ledger + pool,基线不丢、同值不记 +// --------------------------------------------------------------------------- + +func TestUpgRestartContinuesBaseline(t *testing.T) { + e := upgNewEnv(t, upgSeed{uid: "u1", credits: 100}) + + e.fake.setPersonal("u1", 100) + if code, body := e.balance("u1"); code != 200 { + t.Fatalf("baseline -> %d %s", code, body) + } + e.fake.setPersonal("u1", 150) + if code, body := e.balance("u1"); code != 200 { + t.Fatalf("+50 -> %d %s", code, body) + } + e.pool.Close() // 模拟进程退出:Flush state.json + + // 重启:同路径重建 Ledger + pool(与 main 的装配一致) + l2 := credithist.New(e.ledgerPath, 2000) + if n := len(l2.Read(0)); n != 1 { + t.Fatalf("重启后应恢复 1 条流水,得到 %d", n) + } + p2 := pool.New(e.stateFile) + t.Cleanup(p2.Close) + auths, err := auth.LoadDir(e.authDir) + if err != nil { + t.Fatalf("LoadDir: %v", err) + } + p2.SyncToDir(auths) + st, ok := p2.Status("u1") + if !ok || st.Credits != 150 { + t.Fatalf("重启后余额快照应保留 150,得到 ok=%v %+v", ok, st) + } + + up2 := &upstream.Client{HTTP: e.fake.srv.Client(), ChatBaseCN: e.fake.srv.URL, BillingBaseCN: e.fake.srv.URL} + up2.SetCreditObserver(l2.Observe) + pn2 := New(Config{Version: "test", APIKey: "test-key", Pool: p2, Upstream: up2, CreditHistory: l2, AuthDir: e.authDir}) + + // 同值不记 + e.fake.setPersonal("u1", 150) + if code, body := upgPost(t, pn2, "/panel/api/accounts/u1/balance"); code != 200 { + t.Fatalf("restart same value -> %d %s", code, body) + } + if n := len(l2.Read(0)); n != 1 { + t.Fatalf("重启后同值不应新增流水,得到 %d", n) + } + // 变化按重启前基线记 + e.fake.setPersonal("u1", 160) + if code, body := upgPost(t, pn2, "/panel/api/accounts/u1/balance"); code != 200 { + t.Fatalf("restart change -> %d %s", code, body) + } + entries := l2.Read(0) + if len(entries) != 2 { + t.Fatalf("重启后变化应记一条,得到 %d: %+v", len(entries), entries) + } + if entries[0].Delta != 10 || entries[0].Before != 150 || entries[0].After != 160 { + t.Fatalf("重启续接的 before 应为 150,得到 %+v", entries[0]) + } +} + +// --------------------------------------------------------------------------- +// F. 企业版:额度变动记一条;不限量哨兵不产生假流水 +// --------------------------------------------------------------------------- + +func TestUpgEnterpriseQuotaDelta(t *testing.T) { + e := upgNewEnv(t, upgSeed{uid: "ue", nickname: "企业号", enterpriseID: "ent-1", credits: 2000}) + e.fake.registerEnterprise("ue", 0, 2000) // credit=0, limitNum=2000 → remain=2000 + + if code, body := e.balance("ue"); code != 200 { + t.Fatalf("企业版 baseline -> %d %s", code, body) + } + if n := len(e.ledger.Read(0)); n != 0 { + t.Fatalf("企业版首查只应建基线,得到 %d", n) + } + + e.fake.setEnterprise("ue", 100, 2000) // remain=1900 + if code, body := e.balance("ue"); code != 200 { + t.Fatalf("企业版 1900 -> %d %s", code, body) + } + entries := e.ledger.Read(0) + if len(entries) != 1 { + t.Fatalf("企业额度 2000→1900 应记 1 条,得到 %d: %+v", len(entries), entries) + } + if entries[0].Delta != -100 || entries[0].Before != 2000 || entries[0].After != 1900 { + t.Fatalf("企业版流水不符: %+v", entries[0]) + } +} + +func TestUpgEnterpriseUnlimitedSentinelNoFakeEntry(t *testing.T) { + e := upgNewEnv(t, upgSeed{uid: "uu", enterpriseID: "ent-2", credits: 0}) + e.fake.registerEnterprise("uu", 0, -1) // limitNum=-1 → remain=1<<40 哨兵 + + if code, body := e.balance("uu"); code != 200 { + t.Fatalf("不限量 baseline -> %d %s", code, body) + } + if code, body := e.balance("uu"); code != 200 { + t.Fatalf("不限量重复查询 -> %d %s", code, body) + } + if n := len(e.ledger.Read(0)); n != 0 { + t.Fatalf("不限量同值不应留痕,得到 %d", n) + } + // 从不限量切到有限额:巨大跳变属脏数据,只更新基线 + e.fake.setEnterprise("uu", 0, 2000) + if code, body := e.balance("uu"); code != 200 { + t.Fatalf("切换有限额 -> %d %s", code, body) + } + if n := len(e.ledger.Read(0)); n != 0 { + t.Fatalf("哨兵跳变不应留痕,得到 %d", n) + } + d := upgReadLedger(t, e.ledgerPath) + if d.Snapshot["uu"] != 2000 { + t.Fatalf("哨兵跳变后基线应更新为 2000,得到 %+v", d.Snapshot) + } +} + +// --------------------------------------------------------------------------- +// G. 上游失败:不记流水;恢复后恰好记一次 +// --------------------------------------------------------------------------- + +func TestUpgUpstreamFailureLeavesNoTrace(t *testing.T) { + e := upgNewEnv(t, upgSeed{uid: "u1", credits: 1000}) + + e.fake.setPersonal("u1", 1000) + if code, body := e.balance("u1"); code != 200 { + t.Fatalf("baseline -> %d %s", code, body) + } + + // 业务错误(非瞬时,避免触发 2s/4s 有界重试) + e.fake.setFail("u1", true) + code, body := e.balance("u1") + if code != 502 { + t.Fatalf("上游失败应回 502,得到 %d %s", code, body) + } + if n := len(e.ledger.Read(0)); n != 0 { + t.Fatalf("失败不应留痕,得到 %d 条", n) + } + + e.fake.setFail("u1", false) + e.fake.setPersonal("u1", 1100) + if code, body := e.balance("u1"); code != 200 { + t.Fatalf("恢复后 -> %d %s", code, body) + } + entries := e.ledger.Read(0) + if len(entries) != 1 { + t.Fatalf("恢复后应恰好 1 条流水(不重复),得到 %d: %+v", len(entries), entries) + } + if entries[0].Delta != 100 || entries[0].Before != 1000 || entries[0].After != 1100 { + t.Fatalf("恢复后流水不符: %+v", entries[0]) + } +} + +// --------------------------------------------------------------------------- +// I. 二进制级升级 smoke:旧二进制 → 新二进制,同目录、不同端口 +// --------------------------------------------------------------------------- + +func upgRepoRoot(t *testing.T) string { + t.Helper() + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller 失败") + } + root := filepath.Dir(filepath.Dir(filepath.Dir(file))) + if _, err := os.Stat(filepath.Join(root, "go.mod")); err != nil { + t.Fatalf("无法从 %s 定位仓库根: %v", file, err) + } + return root +} + +func upgBuild(t *testing.T, srcDir, out string) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + cmd := exec.CommandContext(ctx, "go", "build", "-o", out, "./cmd/server") + cmd.Dir = srcDir + combined, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("go build (%s) 失败: %v\n%s", srcDir, err, combined) + } +} + +type upgSafeBuf struct { + mu sync.Mutex + b bytes.Buffer +} + +func (s *upgSafeBuf) Write(p []byte) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.b.Write(p) +} + +func (s *upgSafeBuf) String() string { + s.mu.Lock() + defer s.mu.Unlock() + return s.b.String() +} + +type upgProc struct { + cmd *exec.Cmd + log *upgSafeBuf + once sync.Once +} + +func upgStart(t *testing.T, exe, workDir, cfgPath string) *upgProc { + t.Helper() + p := &upgProc{log: &upgSafeBuf{}} + p.cmd = exec.Command(exe, "-config", cfgPath) + p.cmd.Dir = workDir + p.cmd.Stdout = p.log + p.cmd.Stderr = p.log + if err := p.cmd.Start(); err != nil { + t.Fatalf("启动 %s 失败: %v", exe, err) + } + t.Cleanup(p.kill) + return p +} + +func (p *upgProc) kill() { + p.once.Do(func() { + if p.cmd.Process != nil { + _ = p.cmd.Process.Kill() + _ = p.cmd.Wait() + } + }) +} + +func upgFreePort(t *testing.T) int { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + return ln.Addr().(*net.TCPAddr).Port +} + +func upgWriteConfig(t *testing.T, path string, port int, base, authDir, stateFile string) { + t.Helper() + cfg := map[string]any{ + "listen": fmt.Sprintf("127.0.0.1:%d", port), + "api_key": "smoke-key", + "auth_dir": authDir, + "state_file": stateFile, + "global": map[string]any{ + "enabled": true, + "chat_base": base, + "billing_base": base, + }, + "schedule": map[string]any{ + "checkin_enabled": false, + "travel_enabled": false, + "activity_enabled": false, + "keepalive_enabled": false, + "blackcat_enabled": false, + "growth_enabled": false, + "balance_refresh_enabled": false, + }, + } + raw, _ := json.MarshalIndent(cfg, "", " ") + if err := os.WriteFile(path, raw, 0o600); err != nil { + t.Fatal(err) + } +} + +func upgWaitHTTP(t *testing.T, method, url, key string, want int, timeout time.Duration) (int, string) { + t.Helper() + deadline := time.Now().Add(timeout) + var last string + for time.Now().Before(deadline) { + req, err := http.NewRequest(method, url, nil) + if err != nil { + t.Fatal(err) + } + if key != "" { + req.Header.Set("Authorization", "Bearer "+key) + } + resp, err := http.DefaultClient.Do(req) + if err == nil { + b, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if resp.StatusCode == want { + return resp.StatusCode, string(b) + } + last = fmt.Sprintf("status=%d body=%s", resp.StatusCode, b) + } else { + last = err.Error() + } + time.Sleep(200 * time.Millisecond) + } + t.Fatalf("等待 %s %s 超时(want %d): %s", method, url, want, last) + return 0, "" +} + +func upgWaitStateCredits(t *testing.T, path, uid string, want int64, timeout time.Duration) { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + raw, err := os.ReadFile(path) + if err == nil { + var sf upgStateFile + if json.Unmarshal(raw, &sf) == nil && sf.Accounts[uid].Credits == want { + return + } + } + time.Sleep(200 * time.Millisecond) + } + t.Fatalf("等待 %s 落盘 credits[%s]=%d 超时", path, uid, want) +} + +func upgWaitFile(t *testing.T, path string, timeout time.Duration) { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if _, err := os.Stat(path); err == nil { + return + } + time.Sleep(200 * time.Millisecond) + } + t.Fatalf("等待文件 %s 生成超时", path) +} + +func TestUpgBinaryUpgradeSmoke(t *testing.T) { + if testing.Short() { + t.Skip("二进制升级 smoke 在 -short 下跳过") + } + if _, err := exec.LookPath("go"); err != nil { + t.Skipf("PATH 中无 go,跳过: %v", err) + } + // 旧版源码路径由环境变量提供:仓库里不该硬编码某个人的本地目录。 + // 要跑这个 smoke: + // WB2API_UPGRADE_OLD_SRC=<旧版 checkout> go test ./internal/panel/ -run TestUpgBinaryUpgradeSmoke -v + oldSrc := os.Getenv("WB2API_UPGRADE_OLD_SRC") + if oldSrc == "" { + t.Skip("未设置 WB2API_UPGRADE_OLD_SRC,跳过二进制升级 smoke") + } + if st, err := os.Stat(oldSrc); err != nil || !st.IsDir() { + t.Skipf("旧版源码不可用(%s),跳过: %v", oldSrc, err) + } + root := upgRepoRoot(t) + + tmp := t.TempDir() + oldExe := filepath.Join(tmp, "wb2api-old.exe") + newExe := filepath.Join(tmp, "wb2api-new.exe") + upgBuild(t, oldSrc, oldExe) + upgBuild(t, root, newExe) + + deploy := filepath.Join(tmp, "deploy") + authDir := filepath.Join(deploy, "auths") + dataDir := filepath.Join(deploy, "data") + for _, d := range []string{authDir, dataDir} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } + uid := "smoke1" + authDoc := map[string]any{ + "auth": map[string]any{ + "accessToken": "at-" + uid, + "refreshToken": "rt-" + uid, + "expiresAt": time.Now().Add(365 * 24 * time.Hour).Unix(), + "domain": "workbuddy.ai", + "realm": "global", + }, + "account": map[string]any{"uid": uid, "nickname": "smoke"}, + } + authRaw, _ := json.MarshalIndent(authDoc, "", " ") + if err := os.WriteFile(filepath.Join(authDir, "workbuddy-"+uid+".json"), authRaw, 0o600); err != nil { + t.Fatal(err) + } + + fake := upgNewFakeBilling(t) + fake.register(uid, 1300) + stateFile := filepath.Join(dataDir, "state.json") + cfgPath := filepath.Join(deploy, "config.json") + + // --- 旧二进制:启动、真实查一次余额、落 state.json --- + oldPort := upgFreePort(t) + upgWriteConfig(t, cfgPath, oldPort, fake.srv.URL, authDir, stateFile) + oldProc := upgStart(t, oldExe, deploy, cfgPath) + oldBase := fmt.Sprintf("http://127.0.0.1:%d", oldPort) + upgWaitHTTP(t, "GET", oldBase+"/panel/", "", 200, 30*time.Second) + if code, body := upgWaitHTTP(t, "POST", oldBase+"/panel/api/accounts/"+uid+"/balance", "smoke-key", 200, 20*time.Second); code != 200 { + t.Fatalf("旧版 balance -> %d %s\n旧版日志:\n%s", code, body, oldProc.log.String()) + } + upgWaitStateCredits(t, stateFile, uid, 1300, 20*time.Second) + oldProc.kill() + + before := upgReadState(t, stateFile) + if before.Accounts[uid].Credits != 1300 { + t.Fatalf("旧版生成的 state.json 不符: %+v", before.Accounts) + } + + // --- 新二进制:同目录升级、换端口 --- + newPort := upgFreePort(t) + upgWriteConfig(t, cfgPath, newPort, fake.srv.URL, authDir, stateFile) + newProc := upgStart(t, newExe, deploy, cfgPath) + newBase := fmt.Sprintf("http://127.0.0.1:%d", newPort) + upgWaitHTTP(t, "GET", newBase+"/panel/", "", 200, 30*time.Second) + + after := upgReadState(t, stateFile) + if after.Accounts[uid].Credits != 1300 { + t.Fatalf("升级后旧 state.json 被破坏: %+v\n新版日志:\n%s", after.Accounts, newProc.log.String()) + } + + // 升级前不存在的接口,升级后可用 + code, body := upgWaitHTTP(t, "GET", newBase+"/panel/api/credit_history", "smoke-key", 200, 20*time.Second) + if !strings.Contains(body, `"entries"`) { + t.Fatalf("credit_history 响应形状不符: %d %s", code, body) + } + + // 触发一次真实余额查询 → 建基线并落 credit-history.json + if code, body := upgWaitHTTP(t, "POST", newBase+"/panel/api/accounts/"+uid+"/balance", "smoke-key", 200, 20*time.Second); code != 200 { + t.Fatalf("新版 balance -> %d %s\n新版日志:\n%s", code, body, newProc.log.String()) + } + histPath := filepath.Join(dataDir, "credit-history.json") + upgWaitFile(t, histPath, 20*time.Second) + d := upgReadLedger(t, histPath) + if d.Snapshot[uid] != 1300 { + t.Fatalf("credit-history.json 基线不符: %+v", d.Snapshot) + } + if len(d.Entries) != 0 { + t.Fatalf("升级后首次查询不应有流水: %+v", d.Entries) + } + newProc.kill() +} diff --git a/internal/panel/frontend_test.go b/internal/panel/frontend_test.go index 5ab8efc1..337b2940 100644 --- a/internal/panel/frontend_test.go +++ b/internal/panel/frontend_test.go @@ -272,6 +272,53 @@ process.stdout.write(JSON.stringify({ } } +// 积分历史区块:变动列 +N / −N(U+2212)、余额千分位、空列表提示。 +func TestAppJSCreditHistoryFormatting(t *testing.T) { + node, err := exec.LookPath("node") + if err != nil { + t.Skip("node not installed; credit history formatting test skipped") + } + script := `const fs = require('fs'); +const vm = require('vm'); +const src = fs.readFileSync(process.argv[2], 'utf8'); +const start = src.indexOf('function creditNum'); +const end = src.indexOf('function renderCreditHistory'); +if (start < 0 || end < 0) throw new Error('credit history helpers not found'); +const ctx = { Date, Number, String, Math, RegExp, isNaN }; +vm.createContext(ctx); +vm.runInContext( + src.slice(start, end) + + '\nthis.creditEntryText=creditEntryText;this.creditHistoryNote=creditHistoryNote;', + ctx +); +const time = new Date(2026, 8, 28, 14, 5, 6).toISOString(); +process.stdout.write(JSON.stringify({ + gain: ctx.creditEntryText({ time, uid: '89374120', account: '小明', delta: 100, before: 1300, after: 1400 }), + loss: ctx.creditEntryText({ time, uid: '89374120', account: '', delta: -30, before: 1400, after: 1370 }), + note: ctx.creditHistoryNote([{ delta: 1300 }, { delta: -60 }]), + empty: ctx.creditHistoryNote([]), +}));` + f, err := os.CreateTemp(t.TempDir(), "credit-history-format-*.cjs") + if err != nil { + t.Fatal(err) + } + if _, err := f.WriteString(script); err != nil { + t.Fatal(err) + } + f.Close() + out, err := exec.Command(node, f.Name(), "app.js").CombinedOutput() + if err != nil { + t.Fatalf("credit history formatting node test failed: %v\n%s", err, out) + } + gain := "14:05:06 | 小明 | +100 | 1,400 | 余额 1,300 → 1,400" + loss := "14:05:06 | 89374120 | −30 | 1,370 | 余额 1,400 → 1,370" + want := `{"gain":` + strconv.Quote(gain) + `,"loss":` + strconv.Quote(loss) + + `,"note":"2 条 · 净 +1,240","empty":"暂无积分变动记录"}` + if strings.TrimSpace(string(out)) != want { + t.Fatalf("credit history formatting=%s want %s", out, want) + } +} + // 请求记录筛选:IP / UA / 模型 / 账号 / 请求 ID 的包含匹配(空格分词 AND)+ 结果精确匹配。 func TestAppJSRequestMatch(t *testing.T) { node, err := exec.LookPath("node") diff --git a/internal/panel/index.html b/internal/panel/index.html index 9da35b7c..2f0b0525 100644 --- a/internal/panel/index.html +++ b/internal/panel/index.html @@ -1207,6 +1207,31 @@ <h3>运行日志</h3> </header> <pre id="logBox"></pre> </div> + + <div class="box"> + <header> + <h3>积分历史 <span class="hint">每次真实查到余额与上次比对,变动即留痕</span></h3> + <span class="grow"></span> + <span class="note" id="creditNote">—</span> + </header> + <div class="fbar"> + <select id="creditLimit" aria-label="读取条数"> + <option value="100" selected>最近 100 条</option> + <option value="300">最近 300 条</option> + <option value="1000">最近 1000 条</option> + </select> + <span class="grow"></span> + <button class="xs" id="btnCreditReload">重新读取</button> + </div> + <div class="tbl-wrap"> + <table class="acc"> + <thead><tr> + <th>时间</th><th>账号</th><th class="num">变动</th><th class="num">变动后余额</th><th>说明</th> + </tr></thead> + <tbody id="creditBody"></tbody> + </table> + </div> + </div> </section> </div> </div> diff --git a/internal/panel/panel.go b/internal/panel/panel.go index 050323ac..b2e630c7 100644 --- a/internal/panel/panel.go +++ b/internal/panel/panel.go @@ -22,6 +22,7 @@ import ( "time" "github.com/linguo2625469/workbuddy2api-panel/internal/auth" + "github.com/linguo2625469/workbuddy2api-panel/internal/credithist" "github.com/linguo2625469/workbuddy2api-panel/internal/httpauth" "github.com/linguo2625469/workbuddy2api-panel/internal/livecfg" "github.com/linguo2625469/workbuddy2api-panel/internal/pool" @@ -60,6 +61,9 @@ type Config struct { Usage *usage.Recorder // RequestLog 请求指标与归档(nil = 对应接口返回 501)。 RequestLog *reqlog.Recorder + // CreditHistory 积分变动账本(nil = 积分历史接口返回 501)。只读展示: + // 留痕由余额查询路径(upstream.Client 的余额观察者)写入。 + CreditHistory *credithist.Ledger // ProbeFile 模型输出上限探测结果文件(scripts/probe_max_tokens.py --panel-out // 写入;空或文件不存在 = model_probes 端点返回空集,面板不显示任何实测标注)。 @@ -153,6 +157,7 @@ func (p *Panel) routes() { p.mux.HandleFunc("GET /panel/api/logs", p.withAuth(p.logsHandler)) p.mux.HandleFunc("GET /panel/api/request_metrics", p.withAuth(p.requestMetrics)) p.mux.HandleFunc("GET /panel/api/request_logs", p.withAuth(p.requestLogs)) + p.mux.HandleFunc("GET /panel/api/credit_history", p.withAuth(p.creditHistory)) p.mux.HandleFunc("GET /panel/api/models", p.withAuth(p.models)) p.mux.HandleFunc("POST /panel/api/login/start", p.withAuth(p.loginStart)) p.mux.HandleFunc("GET /panel/api/login/poll", p.withAuth(p.loginPoll)) @@ -348,6 +353,67 @@ func (p *Panel) requestLogs(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"entries": rows, "limit": limit}) } +// creditHistory 返回积分变动流水(新的在前),供「运行日志 → 积分历史」展示。 +// limit 默认 200、上限 1000(与 requestLogs 同口径);可带 uid 精确过滤。 +// 账号昵称在读取时用池快照填充——账本只存 uid,上游改名后旧流水也跟着更新, +// 且留痕路径不必碰 auth 的昵称锁。 +func (p *Panel) creditHistory(w http.ResponseWriter, r *http.Request) { + if p.cfg.CreditHistory == nil { + writeErr(w, http.StatusNotImplemented, "credit history not available") + return + } + limit := 200 + if v := r.URL.Query().Get("limit"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + limit = n + } + } + if limit > 1000 { + limit = 1000 + } + uid := r.URL.Query().Get("uid") + nicks := p.nicknames() + // 有 uid 过滤时必须先全量取回(账本上限默认 2000 条):先截断再过滤会让 + // 筛选后的条数看起来像历史缺失。无过滤时只取 limit 条,不为一次展示拷贝整个账本。 + readLimit := limit + if uid != "" { + readLimit = 0 + } + all := p.cfg.CreditHistory.Read(readLimit) + out := make([]map[string]any, 0, limit) + for _, e := range all { + if uid != "" && e.UID != uid { + continue + } + out = append(out, map[string]any{ + "time": e.Time, + "uid": e.UID, + "account": nicks[e.UID], + "delta": e.Delta, + "before": e.Before, + "after": e.After, + }) + if len(out) >= limit { + break + } + } + writeJSON(w, http.StatusOK, map[string]any{"entries": out, "limit": limit}) +} + +// nicknames 池内 uid → 昵称映射(仅展示用,不含任何凭证)。 +func (p *Panel) nicknames() map[string]string { + nicks := map[string]string{} + if p.cfg.Pool == nil { + return nicks + } + for _, s := range p.cfg.Pool.List() { + if s.Nickname != "" { + nicks[s.UID] = s.Nickname + } + } + return nicks +} + // models 实时查询上游模型列表与 reasoning 实际档位(直连上游,不读路由层 1h 缓存): // 回答"该模型到底支持哪几档思考"。顺带刷新 client 的 effort 降级能力缓存。 // 与 /v1/models 同口径的双域输出:CN 域模型加 "cn:" 前缀、global 域加 "global:" 前缀 @@ -927,12 +993,7 @@ func (p *Panel) usage(w http.ResponseWriter, r *http.Request) { } } // 昵称仅用于展示,取自池快照(不含任何凭证)。 - nicks := map[string]string{} - for _, s := range p.cfg.Pool.List() { - if s.Nickname != "" { - nicks[s.UID] = s.Nickname - } - } + nicks := p.nicknames() var currentRate func(realm, model string) string if p.cfg.Upstream != nil { currentRate = p.cfg.Upstream.ModelRate diff --git a/internal/scheduler/credit_upgrade_test.go b/internal/scheduler/credit_upgrade_test.go new file mode 100644 index 00000000..66271529 --- /dev/null +++ b/internal/scheduler/credit_upgrade_test.go @@ -0,0 +1,206 @@ +package scheduler + +// T4 升级场景集成测试(调度器侧):并发全量余额刷新下的积分留痕一致性。 +// +// RunBalanceRefreshNow 对每个账号各起一个 goroutine,观察者在各自调用栈上同步回调; +// 账本必须做到「流水条数 = 各账号真实变化次数之和」,且重启读盘与内存一致。 + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" + "github.com/linguo2625469/workbuddy2api-panel/internal/credithist" + "github.com/linguo2625469/workbuddy2api-panel/internal/pool" + "github.com/linguo2625469/workbuddy2api-panel/internal/upstream" +) + +// upgBalanceFake 按 accessToken 识别账号,逐号返回可变的个人版余额。 +type upgBalanceFake struct { + mu sync.Mutex + tokens map[string]string // accessToken -> uid + remain map[string]int64 + calls map[string]int + srv *httptest.Server +} + +func upgNewBalanceFake(t *testing.T) *upgBalanceFake { + t.Helper() + f := &upgBalanceFake{ + tokens: map[string]string{}, + remain: map[string]int64{}, + calls: map[string]int{}, + } + f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tok := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + f.mu.Lock() + uid := f.tokens[tok] + f.calls[uid]++ + remain := f.remain[uid] + f.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + if !strings.HasSuffix(r.URL.Path, "/get-user-resource") { + http.Error(w, "not found", 404) + return + } + fmt.Fprintf(w, `{"code":0,"data":{"Response":{"Data":{"Accounts":[{"CycleCapacitySize":%d,"CycleCapacityRemain":%d,"CycleCapacityUsed":0}]}}}}`, int64(1)<<40, remain) + })) + t.Cleanup(f.srv.Close) + return f +} + +func (f *upgBalanceFake) add(uid string, remain int64) { + f.mu.Lock() + defer f.mu.Unlock() + f.tokens["at-"+uid] = uid + f.remain[uid] = remain +} + +func (f *upgBalanceFake) set(uid string, remain int64) { + f.mu.Lock() + defer f.mu.Unlock() + f.remain[uid] = remain +} + +func (f *upgBalanceFake) callCount(uid string) int { + f.mu.Lock() + defer f.mu.Unlock() + return f.calls[uid] +} + +// TestUpgConcurrentFullRefreshLedgerConsistent 覆盖 H: +// 多账号并发全量刷新 → 流水条数 = 变化账号数之和;重启读盘与内存一致。 +func TestUpgConcurrentFullRefreshLedgerConsistent(t *testing.T) { + dir := t.TempDir() + ledgerPath := filepath.Join(dir, "data", "credit-history.json") + fake := upgNewBalanceFake(t) + + const total = 8 + const changed = 5 + uids := make([]string, 0, total) + for i := 0; i < total; i++ { + uids = append(uids, fmt.Sprintf("u%02d", i)) + } + + p := pool.New(filepath.Join(dir, "data", "state.json")) + t.Cleanup(p.Close) + for _, uid := range uids { + p.Add(&auth.Auth{ + UID: uid, + AccessToken: "at-" + uid, + RefreshToken: "rt-" + uid, + ExpiresAt: time.Now().Add(24 * time.Hour).Unix(), + }) + fake.add(uid, 1000) + } + + ledger := credithist.New(ledgerPath, 2000) + up := &upstream.Client{HTTP: fake.srv.Client(), ChatBaseCN: fake.srv.URL, BillingBaseCN: fake.srv.URL} + up.SetCreditObserver(ledger.Observe) + s := New(Config{Pool: p, Upstream: up}) + + // 首轮:全部建立基线,0 条流水 + s.RunBalanceRefreshNow() + if got := len(ledger.Read(0)); got != 0 { + t.Fatalf("首轮只应建基线,得到 %d 条流水", got) + } + for _, uid := range uids { + st, ok := p.Status(uid) + if !ok || st.Credits != 1000 { + t.Fatalf("首轮后 %s credits=%d ok=%v want 1000", uid, st.Credits, ok) + } + if fake.callCount(uid) != 1 { + t.Fatalf("%s 首轮应查一次余额,实际 %d 次", uid, fake.callCount(uid)) + } + } + + // 第二轮:前 5 个变化,其余不变 → 恰好 5 条 + for i := 0; i < changed; i++ { + fake.set(uids[i], 1100) + } + s.RunBalanceRefreshNow() + + entries := ledger.Read(0) + if len(entries) != changed { + t.Fatalf("流水条数应等于真实变化次数之和 %d,得到 %d: %+v", changed, len(entries), entries) + } + seen := map[string]bool{} + for _, en := range entries { + if en.Before != 1000 || en.After != 1100 || en.Delta != 100 { + t.Fatalf("流水字段不符: %+v", en) + } + if seen[en.UID] { + t.Fatalf("%s 重复留痕: %+v", en.UID, entries) + } + seen[en.UID] = true + } + for i := 0; i < changed; i++ { + if !seen[uids[i]] { + t.Fatalf("变化的账号 %s 未留痕: %+v", uids[i], entries) + } + if st, _ := p.Status(uids[i]); st.Credits != 1100 { + t.Fatalf("%s credits=%d want 1100", uids[i], st.Credits) + } + } + for i := changed; i < total; i++ { + if st, _ := p.Status(uids[i]); st.Credits != 1000 { + t.Fatalf("未变化账号 %s credits=%d want 1000", uids[i], st.Credits) + } + } + + // 第三轮:同值 → 不新增 + s.RunBalanceRefreshNow() + if got := len(ledger.Read(0)); got != changed { + t.Fatalf("同值轮不应新增流水,want %d got %d", changed, got) + } + + // 重启读盘:与内存一致(跨账号顺序非确定,按条目键比较) + l2 := credithist.New(ledgerPath, 2000) + disk := l2.Read(0) + if len(disk) != len(entries) { + t.Fatalf("重启读盘条数 %d != 内存 %d", len(disk), len(entries)) + } + key := func(e credithist.Entry) string { + return fmt.Sprintf("%s|%d|%d|%d", e.UID, e.Delta, e.Before, e.After) + } + diskSet := map[string]int{} + for _, e := range disk { + diskSet[key(e)]++ + } + for _, e := range entries { + if diskSet[key(e)] != 1 { + t.Fatalf("重启读盘与内存不一致,缺 %s(磁盘=%+v)", key(e), disk) + } + } + // 磁盘快照:8 个账号齐全,变化账号为新值、未变化账号保持旧值 + rawFile, err := os.ReadFile(ledgerPath) + if err != nil { + t.Fatal(err) + } + var diskFile struct { + Snapshot map[string]int64 `json:"snapshot"` + } + if err := json.Unmarshal(rawFile, &diskFile); err != nil { + t.Fatalf("credit-history.json 不可解析: %v", err) + } + if len(diskFile.Snapshot) != total { + t.Fatalf("磁盘快照应含 %d 个账号,得到 %d: %+v", total, len(diskFile.Snapshot), diskFile.Snapshot) + } + for i, uid := range uids { + want := int64(1000) + if i < changed { + want = 1100 + } + if diskFile.Snapshot[uid] != want { + t.Fatalf("磁盘快照 %s=%d want %d", uid, diskFile.Snapshot[uid], want) + } + } +} diff --git a/internal/upstream/client.go b/internal/upstream/client.go index 2f688c88..11672700 100644 --- a/internal/upstream/client.go +++ b/internal/upstream/client.go @@ -890,8 +890,18 @@ type Client struct { // 401,只能人工重登)。单飞在这里是保号手段,不只是省流量。 refreshMu sync.Mutex refreshInFlight map[string]*refreshFlight + + // creditObserver 余额查询观察者(见 CreditObserver / SetCreditObserver)。 + // atomic:启动期挂载、运行期并发读,不参与任何出站决策。 + creditObserver atomic.Pointer[CreditObserver] } +// CreditObserver 余额查询观察者:每次**真实查到**余额后回调(uid + 余额绝对值)。 +// 旁路通知,不改变任何透传字节;未挂载时零开销。用于积分历史留痕 +// (internal/credithist):余额查询是"积分变动"唯一可靠的观测口——签到 / 活跃 +// 上报等渠道上游不落金额日志,只能靠比对余额覆盖。 +type CreditObserver func(uid string, credits int64) + // New 生产默认值。Transport 由 newTransport() 集中构造(连接层加固:真正禁 h2 / // TLS 握手超时 / 短 keepalive 探测 / 失败清池,参数见 transport.go——吸收上游 // kongjianguan 4 连击实测经验)。 @@ -2366,6 +2376,7 @@ func (c *Client) UserResourceSnapshot(a *auth.Auth, soon time.Duration) (remain, // 故统一分流到企业口径端点(见 auth.UsesEnterpriseBilling)。 if a.UsesEnterpriseBilling() { remain, total, expiring, earliestAt, earliestRemaining, err = c.enterpriseResource(a, soon) + c.notifyCredits(a, remain, err) if err == nil && earliestRemaining > 0 && !earliestAt.IsZero() && earliestAt.After(time.Now()) { buckets = []ExpiryBucket{{ Date: earliestAt.In(softRateResetLoc).Format("2006-01-02"), @@ -2485,9 +2496,43 @@ func (c *Client) UserResourceSnapshot(a *auth.Auth, soon time.Duration) (remain, if len(buckets) > 0 { earliestRemaining = buckets[0].Remain } + c.notifyCredits(a, remain, nil) return remain, total, expiring, earliestAt, earliestRemaining, buckets, nil } +// SetCreditObserver 挂载/注销(nil)余额观察者。可在启动后任意时刻调用; +// 观察者必须自身并发安全且快速返回——它在余额查询的调用栈上同步执行。 +func (c *Client) SetCreditObserver(fn CreditObserver) { + if c == nil { + return + } + if fn == nil { + c.creditObserver.Store(nil) + return + } + c.creditObserver.Store(&fn) +} + +// notifyCredits 查询成功(err == nil)后旁路通知观察者。失败不是观测值: +// 网络抖动返回的 0 余额若被留痕,会在下一次成功时造出一条假变动。 +func (c *Client) notifyCredits(a *auth.Auth, credits int64, err error) { + if c == nil || err != nil || a == nil { + return + } + fn := c.creditObserver.Load() + if fn == nil { + return + } + // 观察者是纯旁路:它 panic 绝不能打断余额刷新/签到这条主流程(调度器 goroutine + // 里未恢复的 panic 会直接带走整个进程)。与"落盘失败只记日志"同一哲学。 + defer func() { + if r := recover(); r != nil { + log.Printf("WARN: [upstream] 余额观察者 panic 已忽略: %v", r) + } + }() + (*fn)(a.UID, credits) +} + // respAccount 供 packageRemainUsed 解析的套餐字段(CreditPackages 的逐包结构同构)。 type respAccount struct { CapacityRemain int64 diff --git a/internal/upstream/credit_observer_test.go b/internal/upstream/credit_observer_test.go new file mode 100644 index 00000000..ae8563cd --- /dev/null +++ b/internal/upstream/credit_observer_test.go @@ -0,0 +1,109 @@ +package upstream + +import ( + "errors" + "net/http" + "strings" + "testing" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" +) + +// 余额查询成功必须通知观察者一次(uid + 余额):这是积分历史留痕的唯一数据入口。 +func TestCreditObserverNotifiedOncePerQuery(t *testing.T) { + payload := `{"code":0,"data":{"Response":{"Data":{"Accounts":[` + + `{"PackageName":"p","CycleCapacitySize":30,"CycleCapacityRemain":30,"CycleCapacityUsed":0}` + + `]}}}}` + c := testClient(func(r *http.Request) (*http.Response, error) { + if !strings.HasSuffix(r.URL.Path, "/v2/billing/meter/get-user-resource") { + return nil, errors.New("wrong path: " + r.URL.Path) + } + return jsonResp(200, payload), nil + }) + type call struct { + uid string + credits int64 + } + var calls []call + c.SetCreditObserver(func(uid string, credits int64) { calls = append(calls, call{uid, credits}) }) + + remain, _, _, _, _, err := c.UserResourceDetailedWithExpiry(&auth.Auth{AccessToken: "at", UID: "u1"}, 0) + if err != nil { + t.Fatalf("resource: %v", err) + } + if remain != 30 { + t.Fatalf("remain=%d want 30", remain) + } + if len(calls) != 1 || calls[0].uid != "u1" || calls[0].credits != 30 { + t.Fatalf("观察者应被回调一次 (u1,30),得到 %+v", calls) + } + + // UserResource / UserResourceDetailed 都是委托调用:每次查询只回调一次,不重复。 + if _, _, err := c.UserResource(&auth.Auth{AccessToken: "at", UID: "u2"}); err != nil { + t.Fatalf("resource: %v", err) + } + if len(calls) != 2 || calls[1].uid != "u2" || calls[1].credits != 30 { + t.Fatalf("委托路径应恰好再回调一次,得到 %+v", calls) + } +} + +// 查询失败不通知:错误路径返回的 0 余额若被留痕,会在下一次成功时造出一条假变动。 +// 用业务错误(code!=0)注入而不是非法 JSON:后者会被重试策略当成瞬时错误, +// 白白等满 6s 退避(见 retryBillingTransient)。 +func TestCreditObserverSilentOnBusinessError(t *testing.T) { + c := testClient(func(r *http.Request) (*http.Response, error) { + return jsonResp(200, `{"code":400,"msg":"bad request"}`), nil + }) + n := 0 + c.SetCreditObserver(func(string, int64) { n++ }) + if _, _, _, _, _, err := c.UserResourceDetailedWithExpiry(&auth.Auth{AccessToken: "at", UID: "u1"}, 0); err == nil { + t.Fatal("业务错误应返回错误") + } + if n != 0 { + t.Fatalf("失败不应通知观察者,得到 %d 次", n) + } +} + +// 未挂载 / 注销观察者时必须零开销且不 panic(观察者是可选旁路,不得改变既有行为)。 +func TestCreditObserverOptionalAndDetachable(t *testing.T) { + payload := `{"code":0,"data":{"Response":{"Data":{"Accounts":[]}}}}` + c := testClient(func(r *http.Request) (*http.Response, error) { + return jsonResp(200, payload), nil + }) + if _, _, _, _, _, err := c.UserResourceDetailedWithExpiry(&auth.Auth{AccessToken: "at", UID: "u1"}, 0); err != nil { + t.Fatalf("未挂载观察者时查询应正常: %v", err) + } + n := 0 + c.SetCreditObserver(func(string, int64) { n++ }) + if _, _, _, _, _, err := c.UserResourceDetailedWithExpiry(&auth.Auth{AccessToken: "at", UID: "u1"}, 0); err != nil { + t.Fatal(err) + } + if n != 1 { + t.Fatalf("挂载后应通知 1 次,得到 %d", n) + } + c.SetCreditObserver(nil) + if _, _, _, _, _, err := c.UserResourceDetailedWithExpiry(&auth.Auth{AccessToken: "at", UID: "u1"}, 0); err != nil { + t.Fatal(err) + } + if n != 1 { + t.Fatalf("注销后不应再通知,得到 %d", n) + } +} + +// 观察者是纯旁路:它 panic 绝不能打断余额查询主流程——调度器 goroutine 里未恢复的 +// panic 会直接带走整个进程。recover 之后查询结果必须照常返回。 +func TestCreditObserverPanicIsContained(t *testing.T) { + payload := `{"code":0,"data":{"Response":{"Data":{"Accounts":[{"PackageName":"p","CycleCapacitySize":30,"CycleCapacityRemain":30,"CycleCapacityUsed":0}]}}}}` + c := testClient(func(r *http.Request) (*http.Response, error) { + return jsonResp(200, payload), nil + }) + c.SetCreditObserver(func(string, int64) { panic("observer boom") }) + + remain, _, _, _, _, err := c.UserResourceDetailedWithExpiry(&auth.Auth{AccessToken: "at", UID: "u1"}, 0) + if err != nil { + t.Fatalf("观察者 panic 不应影响查询: %v", err) + } + if remain != 30 { + t.Fatalf("remain=%d want 30", remain) + } +} From e6eae110d9db35af56767da94b96af733983bb30 Mon Sep 17 00:00:00 2001 From: yuanxing109 <yuanxing109@users.noreply.github.com> Date: Fri, 9 Oct 2026 20:42:21 +0800 Subject: [PATCH 06/57] =?UTF-8?q?feat(panel):=20=E6=89=8B=E6=9C=BA?= =?UTF-8?q?=E7=AB=AF=E9=80=82=E9=85=8D=EF=BC=88=E6=8A=BD=E5=B1=89=E4=BE=A7?= =?UTF-8?q?=E6=A0=8F=20+=20=E5=AE=BD=E8=A1=A8=E5=8D=A1=E7=89=87=E5=8C=96?= =?UTF-8?q?=20+=20=E7=AA=84=E5=B1=8F=E6=97=B6=E5=BA=8F=E5=9B=BE=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ≤760px 生效,实现全部在自有文件 mobile.css / mobile.js 里,index.html 只多一行 link、一行 script;>760px 逐像素不变。 (cherry picked from commit 25378f5a8fd8b4f6e64fde72d6a61ee16fd22d8f) --- README.md | 10 + internal/panel/frontend_test.go | 90 ++++++++- internal/panel/index.go | 27 ++- internal/panel/index.html | 12 +- internal/panel/mobile.css | 317 ++++++++++++++++++++++++++++++++ internal/panel/mobile.js | 229 +++++++++++++++++++++++ internal/panel/panel.go | 2 + 7 files changed, 676 insertions(+), 11 deletions(-) create mode 100644 internal/panel/mobile.css create mode 100644 internal/panel/mobile.js diff --git a/README.md b/README.md index f33f5be6..2228e92e 100644 --- a/README.md +++ b/README.md @@ -630,6 +630,16 @@ http://127.0.0.1:7863/panel/ 顶部另有「刷新」(向上游全量查询真实余额并回写;5 秒自动轮询只读内存,不打上游)与「添加账号」(浏览器内完成 OAuth 设备授权:显示授权链接 + 自动轮询,登录后凭证落盘并**热加载进池,免重启**,也支持导入 cockpit tools 导出的 JSON)。 +### 手机端(≤760px) + +无需配置:窗口宽度 ≤760px 时自动生效,>760px 完全不参与,桌面端逐像素不变。 + +- **侧栏 → 抽屉**:顶栏出现汉堡按钮,点遮罩 / 选中导航项 / `Esc` / 窗口拉宽都会自动关闭(拉宽不复位会留一层 fixed 遮罩把页面盖住点不动) +- **宽表 → 卡片**:表头隐藏,每列字段名由 `mobile.js` 从 `<th>` 的纯文本抄进同列 `td[data-l]`,CSS 用 `content: attr(data-l)` 渲染——写死在 CSS 里会随表头改名漂移 +- **Token 时序图窄屏重画**:上游那张图固定 viewBox `1200×200` 靠 `width:100%` 缩放,360px 宽下缩放比只剩 ~0.28(内部 10px 刻度实际 ~2.8px,看着是一排小点);窄屏改画 `440×230` 并抽稀刻度,tooltip 同样跟随柱子 +- 顶栏 / 筛选栏 / 弹层 / 统计条 / KPI 卡按同一密度口径收紧;`color-scheme: only light|dark` 退出手机浏览器的「自动暗化」(否则主题按钮显示浅色、页面实际还是黑的) +- 实现全在 `mobile.css` / `mobile.js` 两个独立文件里(`index.html` 只多一行 `<link>`、一行 `<script>`),改手机端不会与上游对 `index.html` / `app.js` 的改动冲突 + ### 时间范围查询 「用量」与「运行日志 → 请求记录」共用同一个时间范围控件,预设 **今天 / 近 24 小时 / 近 3 天 / 近 7 天 / 近 30 天 / 全部历史 / 自定义**(自定义展开起止时间输入,精确到分钟;起止颠倒就地标红): diff --git a/internal/panel/frontend_test.go b/internal/panel/frontend_test.go index 337b2940..30dbe89c 100644 --- a/internal/panel/frontend_test.go +++ b/internal/panel/frontend_test.go @@ -13,9 +13,9 @@ import ( "time" ) -// TestAppJSSyntax app.js 必须能通过 JS 解析器语法校验。 +// TestAppJSSyntax app.js / mobile.js 必须能通过 JS 解析器语法校验。 // -// 为什么需要:app.js 是 go:embed 进二进制的静态资源,Go 编译器不检查其内容—— +// 为什么需要:两者都是 go:embed 进二进制的静态资源,Go 编译器不检查其内容—— // 一次对象字面量键名未加引号(Model_chat_GLM5.2 被解析成属性访问 + 数字字面量) // 就让整个面板白屏,而所有 Go 测试依然全绿。此测试把语法校验前移到 CI。 // 无 node 环境时跳过(不阻塞无 Node 的构建机)。 @@ -24,13 +24,15 @@ func TestAppJSSyntax(t *testing.T) { if err != nil { t.Skip("node not available; skipping JS syntax check") } - path, err := filepath.Abs("app.js") - if err != nil { - t.Fatal(err) - } - out, err := exec.Command(node, "--check", path).CombinedOutput() - if err != nil { - t.Fatalf("app.js syntax error:\n%s", out) + for _, name := range []string{"app.js", "mobile.js"} { + path, err := filepath.Abs(name) + if err != nil { + t.Fatal(err) + } + out, err := exec.Command(node, "--check", path).CombinedOutput() + if err != nil { + t.Fatalf("%s syntax error:\n%s", name, out) + } } } @@ -91,6 +93,9 @@ const sandbox = new Proxy({ fetch: () => new Promise(() => {}), addEventListener() {}, removeEventListener() {}, matchMedia: () => ({ matches: false, addEventListener() {} }), + // mobile.js 顶层直接 new MutationObserver(...)(移动端表格字段名补标签): + // 该文件不在本沙箱内执行,但保留此桩,将来把 mobile.js 纳入冒烟时可直接复用。 + MutationObserver: class { constructor() {} observe() {} disconnect() {} takeRecords() { return []; } }, setInterval, clearInterval, setTimeout, clearTimeout, console, JSON, Math, Date, Number, String, Boolean, Object, Array, Promise, Map, Set, RegExp, Error, TypeError, isNaN, parseInt, parseFloat, encodeURIComponent, decodeURIComponent, URL, Symbol, Proxy, Reflect, }, { get(t, k) { return t[k]; }, has() { return true; } }); @@ -1446,3 +1451,70 @@ func TestRegionSchedulingFormRoundTrip(t *testing.T) { t.Fatalf("form round trip: %v\n%s", err, out) } } + +// TestMobileJSChartTooltipInsideBar 手机端自绘图表的 tooltip 同样要挂在柱子内部(issue #128)。 +// +// mobile.js 在窄屏另画一张图(440×230),不走 app.js 的 renderUsageChart:上游修好 +// 桌面实现不会覆盖它。断言与 TestAppJSChartTooltipInsideBar 同构,只取文件里 +// 「接管 renderUsageChart」之后的部分,DOM/全局用桩喂进去。 +func TestMobileJSChartTooltipInsideBar(t *testing.T) { + node, err := exec.LookPath("node") + if err != nil { + t.Skip("node not installed; mobile chart tooltip test skipped") + } + script := `const fs = require('fs'); +const vm = require('vm'); +const src = fs.readFileSync(process.argv[2], 'utf8'); +const start = src.indexOf('const deskUsageChart'); +if (start < 0) throw new Error('mobile chart override not found'); +const sinks = {}; +const mk = id => (sinks[id] = { innerHTML: '', textContent: '' }); +const ctx = { + MOBILE_MQ: { matches: true }, + renderUsageChart: () => {}, + esc: s => String(s == null ? '' : s), + fmtTok: v => String(v == null ? 0 : v), + fmtTokTimeLabel: p => { const d = new Date(p.t.length === 13 ? p.t + ':00:00' : p.t + 'T00:00:00'); + return p.scope === 'day' ? (d.getMonth() + 1) + '-' + String(d.getDate()).padStart(2, '0') + : String(d.getHours()).padStart(2, '0') + ':00'; }, + parsePointTime: p => { const d = new Date(p.t.length === 13 ? p.t + ':00:00' : p.t + 'T00:00:00'); + return isNaN(d.getTime()) ? null : d.getTime(); }, + $: id => (sinks[id] || mk(id)), + Date, Math, Number, String, Map, Array, Object, isNaN, Infinity, isFinite, Set, +}; +vm.createContext(ctx); +vm.runInContext(src.slice(start) + '\nthis.renderUsageChart = renderUsageChart;', ctx); +const series = []; +for (let h = 0; h < 5; h++) { + const d = new Date(); d.setHours(d.getHours() - (4 - h), 0, 0, 0); + const iso = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + + String(d.getDate()).padStart(2, '0') + 'T' + String(d.getHours()).padStart(2, '0'); + series.push({ t: iso, scope: 'hour', prompt_tokens: (h + 1) * 100, + completion_tokens: (h + 1) * 10, total_tokens: (h + 1) * 110, requests: h + 1 }); +} +ctx.renderUsageChart(series); +const svg = sinks['usChart'] ? sinks['usChart'].innerHTML : ''; +const groups = svg.match(/<g><title>/g) || []; +const titles = svg.match(/<title>[^<]*<\/title>/g) || []; +const loose = (svg.match(/(?:<rect[^>]*\/>|<\/g>)<title>/g) || []).length; +process.stdout.write(JSON.stringify({ + points: series.length, groups: groups.length, titles: titles.length, loose: loose, +}));` + f, err := os.CreateTemp(t.TempDir(), "mobilecharttip-*.cjs") + if err != nil { + t.Fatal(err) + } + if _, err := f.WriteString(script); err != nil { + t.Fatal(err) + } + f.Close() + out, err := exec.Command(node, f.Name(), "mobile.js").CombinedOutput() + if err != nil { + t.Fatalf("mobile chart tooltip node test failed: %v\n%s", err, out) + } + const want = `{"points":5,"groups":5,"titles":5,"loose":0}` + if strings.TrimSpace(string(out)) != want { + t.Fatalf("mobile chart tooltip structure=%s\nwant %s(groups 应等于数据点数,loose 应为 0)", + strings.TrimSpace(string(out)), want) + } +} diff --git a/internal/panel/index.go b/internal/panel/index.go index 030ee567..97ceee66 100644 --- a/internal/panel/index.go +++ b/internal/panel/index.go @@ -3,6 +3,8 @@ // 资源经 go:embed 打进二进制(随服务部署,无外部构建步骤): // - index.html 页面骨架 // - app.js 全部前端逻辑(独立文件而非内联,为了启用无需 unsafe-inline 的严格 CSP) +// - mobile.css 手机端(≤760px)适配样式(本仓库自有,上游不维护 → 同步上游不冲突) +// - mobile.js 手机端联动逻辑(抽屉开合、宽表卡片化字段名),须在 app.js 之后加载 // // 安全头对"面板页面与全部 /panel/api/* 响应"统一生效:CSP 限制脚本只能来自本服务, // 禁止被 iframe 嵌套(防点击劫持),禁 MIME 嗅探,并声明不泄露 Referer 出去。 @@ -19,9 +21,15 @@ var indexHTML []byte //go:embed app.js var appJS []byte +//go:embed mobile.css +var mobileCSS []byte + +//go:embed mobile.js +var mobileJS []byte + // csp 内容安全策略(严格版,无需 unsafe-inline): // - default-src 'none' 默认全禁,逐个开口 -// - script-src 'self' 只跑同源脚本(app.js);页面无内联事件处理器/内联脚本 +// - script-src 'self' 只跑同源脚本(app.js / mobile.js);页面无内联事件处理器/内联脚本 // - style-src 'self' 'unsafe-inline' // style 的内联是设计取舍:页面有少量 style="..." 属性(进度条宽度、表格列宽), // 允许内联样式不会导致脚本执行;仍禁止外部样式域与 @import 外链。 @@ -59,3 +67,20 @@ func (p *Panel) appScript(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write(appJS) } + +// mobileStyle 输出手机端适配样式(同源样式表,CSP style-src 'self' 加载)。 +// 单独成文而非写进 index.html:上游频繁改 index.html,独立文件可避免同步上游时反复冲突。 +func (p *Panel) mobileStyle(w http.ResponseWriter, r *http.Request) { + setSecurityHeaders(w) + w.Header().Set("Content-Type", "text/css; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(mobileCSS) +} + +// mobileScript 输出手机端联动逻辑(同源脚本,须在 app.js 之后加载)。 +func (p *Panel) mobileScript(w http.ResponseWriter, r *http.Request) { + setSecurityHeaders(w) + w.Header().Set("Content-Type", "text/javascript; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(mobileJS) +} diff --git a/internal/panel/index.html b/internal/panel/index.html index 2f0b0525..5d1820fc 100644 --- a/internal/panel/index.html +++ b/internal/panel/index.html @@ -604,10 +604,12 @@ .rm-row input { flex: 1; } </style> +<!-- 移动端适配:独立文件,必须在主 <style> 之后(@media 覆盖依赖层叠顺序) --> +<link rel="stylesheet" href="mobile.css"> </head> <body> <div class="shell"> - <nav class="nav"> + <nav class="nav" id="navDrawer"> <div class="brand"> <div class="name">WorkBuddy2API</div> <div class="sub" id="navSub">控制台</div> @@ -643,6 +645,10 @@ <div class="main"> <div class="topbar"> + <!-- 汉堡按钮:桌面端由 .nav-btn{display:none} 隐藏,手机端才出现 --> + <button class="icon ghost nav-btn" id="navBtn" aria-label="打开导航" aria-controls="navDrawer" aria-expanded="false"> + <svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.4" aria-hidden="true"><path d="M2.5 4h11M2.5 8h11M2.5 12h11"/></svg> + </button> <h2 id="ttl">账号池</h2> <span class="sep">/</span> <span class="meta" id="subMeta">-</span> @@ -1236,6 +1242,9 @@ <h3>积分历史 <span class="hint">每次真实查到余额与上次比对, </div> </div> +<!-- 抽屉遮罩:仅 ≤760px 由 app.js 切 .on;桌面端 display:none,不参与布局 --> +<div class="nav-veil" id="navVeil"></div> + <!-- 积分任务 --> <div class="veil" id="taskVeil"> <div class="dlg" style="width:940px"> @@ -1378,5 +1387,6 @@ <h3>账号备注</h3> <div id="toasts"></div> <script src="app.js"></script> +<script src="mobile.js"></script> </body> </html> diff --git a/internal/panel/mobile.css b/internal/panel/mobile.css new file mode 100644 index 00000000..bb86bf92 --- /dev/null +++ b/internal/panel/mobile.css @@ -0,0 +1,317 @@ +/* mobile.css — 手机端(≤760px)适配层。 + * + * 本文件是本仓库自有文件,上游不维护:以后同步上游(git fetch && git rebase)时它不参与冲突, + * 移动端样式改动只落在这一处。index.html 里只留一行 <link rel="stylesheet" href="mobile.css">, + * 且必须排在主 <style> 之后 —— @media 块要能覆盖基础规则。 + * + * 约束:只放 ≤760px 生效的规则。>760px(桌面)必须与上游逐像素一致, + * 故「块外」只允许一条新增 DOM 默认不渲染的声明(.nav-btn / .nav-veil)。 + */ + +/* ══ 移动端适配(≤760px)═════════════════════════════════════════════════ + 全部手机端规则集中在这一块,追加在既有 CSS 的末尾:不插进上游规则中间, + 以后同步上游时冲突面积最小。>760px 时整块不生效 → 桌面端与改动前逐像素一致。 + + 块外只多了一条"新元素默认不渲染"的声明:.nav-btn(顶栏汉堡)与 .nav-veil + (抽屉遮罩)是本方案新增的 DOM,桌面端必须完全不出现。它不覆盖任何既有选择器, + 故不影响任何现存样式。 */ +.nav-btn, .nav-veil { display: none; } +@media (max-width: 760px) { + /* ── 0. 明暗主题跟随面板设置(不再强制深色)──────────────────────── + 手机端与桌面端共用同一套主题变量:顶栏主题按钮照常可用,选浅色就是浅色。 + + 这里两条必须写:手机浏览器(Chrome/国产内核)普遍带"自动暗化/强制深色", + 它会在 UA 层把浅色页面重新压暗——此时 getComputedStyle 报的是浅色背景, + 实际像素却是黑的(主题按钮显示太阳、页面却还是深色,就是这个原因)。 + color-scheme: only light 是标准的退出开关(only 关键字 = 页面只支持这一种), + 同时让原生控件(下拉/勾选/滚动条/日期选择器)跟随面板主题而不是系统偏好。 */ + html[data-theme="dark"], html[data-theme="dark"] body { color-scheme: only dark; } + html[data-theme="light"], html[data-theme="light"] body { color-scheme: only light; } + /* 基础两行:视口高度链路 + 禁止手机浏览器自动放大字号(避免横向溢出) */ + html, body { height: 100%; -webkit-text-size-adjust: 100%; } + + /* ── 1. 侧栏 → 抽屉 ──────────────────────────────────────────────── + position:fixed + transform(走合成层,不触发重排);开合由 app.js 切 .open。 + 用 transform 而不是 left:left 每帧都要重排整页。 */ + .nav { + position: fixed; top: 0; left: 0; bottom: 0; z-index: 70; + width: 244px; max-width: 84vw; height: 100%; + flex-direction: column; align-items: stretch; + overflow-x: hidden; overflow-y: auto; + transform: translateX(-100%); transition: transform .22s ease; + border-right: 1px solid var(--line); + } + .nav.open { transform: translateX(0); } + .nav ul { display: block; padding: 10px 8px; } + .nav .sig { border-top: 1px solid var(--line-soft); } + .nav-btn { display: inline-flex; align-items: center; padding: 6px 8px; } + .nav-veil { + display: block; position: fixed; inset: 0; z-index: 65; + background: rgba(6,8,13,.55); opacity: 0; pointer-events: none; + transition: opacity .22s ease; + } + .nav-veil.on { opacity: 1; pointer-events: auto; } + + /* ── 2. 顶栏:紧凑换行,只隐藏分隔符与副标题(主题按钮保留可用)────── */ + .topbar { flex-wrap: wrap; gap: 6px 8px; padding: 8px 10px; } + .topbar h2 { font-size: 15px; } + .topbar .sep, .topbar .meta { display: none; } + .topbar button { padding: 5px 9px; font-size: 12.5px; border-radius: 8px; } + .topbar button.icon { padding: 5px 8px; } + .topbar #btnAdd { padding: 5px 12px; } + + /* ── 3. 宽表 → 卡片 ──────────────────────────────────────────────── + 关键一条:.tbl-wrap 必须覆盖桌面端的 overflow-x:auto,否则卡片列再好看, + 容器照样横向滚动。表头隐藏,单元格字段名由 JS 写入的 data-l 渲染。 */ + .tbl-wrap { overflow: visible; } + /* .acc 与 .acc.md 在桌面端都带 min-width(640px / 700px):卡片模式必须一并清掉, + 否则模型表不是"卡片"而是"被裁掉右半边"。 */ + .acc, .acc.md { display: block; width: 100%; min-width: 0; } + .acc thead { display: none; } + .acc tbody { display: block; } + /* 装表格的 .box 退成透明容器,卡片直接浮在页面底上(参考图口径)。 + 不支持 :has() 的浏览器退化为"卡片列表套在面板里",功能不受影响。 */ + .box:has(> .tbl-wrap) { background: none; border: 0; box-shadow: none; border-radius: 0; overflow: visible; } + .box:has(> .tbl-wrap) > header { padding: 2px 2px 8px; border-bottom: 0; } + /* 12 列 grid:每个 td 用 grid-column 占位 → 行分组完全确定(flex-wrap 做不到) */ + .acc tr { + display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); + grid-auto-rows: min-content; align-items: center; gap: 4px 8px; + padding: 8px 10px; margin-bottom: 8px; + background: var(--surface); border: 1px solid var(--line); border-radius: 12px; + } + .acc tr:last-child { margin-bottom: 0; } + .acc tbody tr:hover { background: var(--surface); } + .acc td { + display: flex; align-items: center; flex-wrap: wrap; min-width: 0; padding: 0; border: 0; + font-size: 11.5px; line-height: 1.3; overflow-wrap: anywhere; + } + /* 字段名 = 表头该列纯文本(app.js 从 <th> 的文本节点抄进 data-l)。 + 没有标签的单元格(行首色条、跨列提示、账号/模型名)保持干净。 */ + .acc td::before { + content: attr(data-l); flex: none; color: var(--ink-3); + font-size: 9.5px; line-height: 1.2; margin-right: 3px; + white-space: nowrap; letter-spacing: .2px; + } + .acc td:not([data-l])::before, .acc td.who::before { content: none; } + /* 行首健康色条:桌面端是 3px 竖条,卡片模式改成卡片顶部通栏横条 */ + .acc .mark { display: none; } + .acc.md .mark { display: none; } + /* 跨列单元格(空态提示、包分组汇总)整行铺满 */ + .acc td[colspan] { grid-column: 1 / -1; } + /* 兜底列宽:没被下面逐表映射覆盖的单元格默认 3 格/行。上游加新表时(v1.12.0 的 + 「模型锁池」就是一例)宁可三格一行,也不会因为"没有 grid-column"退化成一列碎字。 + 逐表映射都带 id 或额外类限定符,特异性更高,不受这条兜底影响。 */ + .acc tr > td { grid-column: span 4; } + .acc .who { display: block; min-width: 0; max-width: none; } + .acc .who .nm { font-size: 13.5px; line-height: 1.25; } + .acc .who .id { font-size: 10px; line-height: 1.3; margin-top: 1px; } + .acc .num { white-space: normal; } + .acc .tag { font-size: 10px; padding: 1px 7px; } + .acc .bar { flex: 1 1 auto; min-width: 18px; max-width: none; height: 2px; margin-top: 0; border-radius: 2px; } + .acc .usage-line { flex-wrap: wrap; gap: 3px; white-space: normal; } + .acc .usage-item { padding: 1px 5px; border-radius: 5px; } + .acc .usage-item b { font-size: 10px; } + .acc .usage-item em { font-size: 8.5px; } + /* ④ 操作按钮:固定占满一行并平分宽度 → 全部按钮永远一行(主按钮蓝) */ + .acc .acts { grid-column: 1 / -1; display: flex; gap: 5px; } + .acc .acts button { flex: 1 1 0; min-width: 0; padding: 5px 2px; font-size: 11.5px; white-space: nowrap; border-radius: 8px; } + + /* 账号卡:① 标题/ID + 状态 ② 积分细条+积分+成功失败+在途 ③ 用量标签+最近成功 ④ 按钮 */ + #accBody tr > .who { grid-column: 1 / 9; } + #accBody tr > td:nth-child(3) { grid-column: 9 / 13; flex-direction: column; align-items: flex-end; gap: 1px; } + #accBody tr > td:nth-child(3)::before { content: none; } + #accBody tr > td:nth-child(3) .hint { font-size: 9.5px; margin-top: 0; text-align: right; } + #accBody tr > .cred { grid-column: 1 / 7; gap: 5px; } + #accBody tr > .cred .n { font-size: 10.5px; } + #accBody tr > td:nth-child(5) { grid-column: 7 / 10; } + #accBody tr > td:nth-child(6) { grid-column: 10 / 13; } + #accBody tr > td:nth-child(8) { grid-column: 1 / 4; order: 1; } + #accBody tr > .usage-cell { grid-column: 4 / 13; order: 2; } + #accBody tr > .acts { order: 3; } + + /* 模型锁池(v1.12.0 新增,8 列):模型 / 域 / 状态 / 可选·总数 / 锁定账号 / 最早解锁 / 全池解锁 / 原因 */ + #mlBody tr > td:nth-child(1) { grid-column: 1 / 9; } + #mlBody tr > td:nth-child(2) { grid-column: 9 / 13; justify-content: flex-end; } + #mlBody tr > td:nth-child(3) { grid-column: 1 / 7; } + #mlBody tr > td:nth-child(4), #mlBody tr > td:nth-child(5) { grid-column: span 3; } + #mlBody tr > td:nth-child(6), #mlBody tr > td:nth-child(7) { grid-column: span 6; } + #mlBody tr > td:nth-child(8) { grid-column: 1 / 13; } + + /* 模型卡:① 模型 ID + 名称 + 能力标签 ② 倍率/默认档/上下文/输出 ③ 支持档位 */ + #mdBody tr > .who { grid-column: 1 / 13; display: flex; flex-wrap: wrap; gap: 0 6px; } + /* 模型 ID 与展示名同一行,能力标签(默认/工具/视觉)独占第二行 → 标题区两行。 + 注意不能给 .id 用 flex:0 0 auto:长模型名会拒绝收缩,整页横向溢出。 */ + #mdBody tr > .who .nm { flex: 0 1 auto; } + #mdBody tr > .who .id { flex: 0 1 auto; } + #mdBody tr > .who .id:has(.tag) { flex: 1 1 100%; } + #mdBody tr > td:nth-child(3), #mdBody tr > td:nth-child(4), + #mdBody tr > td:nth-child(6), #mdBody tr > td:nth-child(7) { grid-column: span 3; } + #mdBody tr > .efs { grid-column: 1 / 13; order: 2; min-width: 0; flex-wrap: wrap; gap: 3px; } + + /* 用量明细 / 积分扣除(.acc.zebra):列由 JS 按维度动态生成(账号/模型/域列数不同), + 不能写死 nth-child。第一个可见列(名称)整行,其余指标每格 3/12 自动流式排布。 + 上游 .acc.zebra 的斑马纹特异性比 .acc tr 高,卡片模式下必须压掉,否则卡片底色不统一。 + 上游 .acc.req 带 min-width:1080px,手机端不清掉会直接横向溢出。 */ + .acc.zebra tr > td.mark + td, .acc.zebra tr > td:first-child { grid-column: 1 / -1; } + .acc.zebra tr > td { grid-column: span 4; } + .acc.zebra tbody tr, .acc.zebra tbody tr:nth-child(even) { background: var(--surface); } + .acc.req { min-width: 0; } + .acc.req tr > td { grid-column: span 3; } /* 兜底:上游将来加列也不至于错位 */ + .acc.req .clip, .acc.req .clip.ip, .acc.req .clip.rid { max-width: none; white-space: normal; overflow-wrap: anywhere; } + /* 请求记录(10 列,列序在 index.html 里固定):把长字段摊开,别挤成一条竖排碎字。 + 时间+结果 / 模型 / 账号+来源 / UA(整行,实测一列只有 74px 宽会折成 12 行)/ 三个指标 / 请求 ID */ + #reqBody tr > td:nth-child(1) { grid-column: 1 / 9; } + #reqBody tr > td:nth-child(2) { grid-column: 9 / 13; justify-content: flex-end; } + #reqBody tr > td:nth-child(3) { grid-column: 1 / 13; } + #reqBody tr > td:nth-child(4) { grid-column: 1 / 8; } + #reqBody tr > td:nth-child(5) { grid-column: 8 / 13; justify-content: flex-end; } + #reqBody tr > td:nth-child(6) { grid-column: 1 / 13; } + #reqBody tr > td:nth-child(7), + #reqBody tr > td:nth-child(8), + #reqBody tr > td:nth-child(9) { grid-column: span 4; } + #reqBody tr > td:nth-child(10) { grid-column: 1 / 13; } + /* 卡片模式给外层 .tbl-wrap 关了滚动(见上面第 3 节),但请求记录必须是「一屏高 + + 内部纵向滚动」:上游 .req-wrap 就是这个语义,overflow 被覆盖后卡片会溢出去压住 + 下面的运行日志(实测卡片 bottom 1897 vs box bottom 904)。 */ + .req-wrap { max-height: 70vh; overflow: auto; -webkit-overflow-scrolling: touch; } + + /* 上游新增的筛选栏 / 时间范围 / 维度切换 / KPI 卡:同密度口径收紧 */ + .fbar { padding: 8px 10px; gap: 6px; } + .fbar > input, .fbar > select { padding: 5px 8px; font-size: 12px; } + .fbar > input[type=search] { min-width: 0; flex: 1 1 140px; } + .trange { gap: 4px; } + .trange select, .trange input { padding: 4px 7px; font-size: 12px; } + .vtabs { padding: 2px; flex-wrap: wrap; } + .vtabs button { padding: 3px 9px; font-size: 12px; } + .kpis { gap: 8px; padding: 10px 11px 0; } + .kpi { padding: 8px 10px 9px 12px; border-radius: 10px; } + + /* ── 用量「Token 时序」:窄屏在 mobile.js 里重画成 440×230 ──────────── + 上游那张固定 viewBox 1200×200、靠 width:100% 缩放的图,在 360px 宽下缩放比只有 + ~0.28,内部 10px 刻度实际只剩 ~2.8px(看着就是一排小点)。这里只配套字号与配色, + 几何在 mobile.js 的 drawMobileUsageChart 里算。 */ + .uschart-hd { flex-wrap: wrap; gap: 4px 8px; padding: 8px 10px; } + .uschart-hd .lb { flex: 1 1 auto; } + .uschart-hd .grow { display: none; } + .uschart-hd .legend { flex: 0 0 auto; } + .uschart-hd .note { flex: 1 1 100%; order: 3; font-size: 11px; line-height: 1.35; } + .uschart-body { padding: 6px 8px 2px; } + .uschart-body .tk, .uschart-body .tk-avg { font-size: 13px; } + .uschart-body .tk-peak { font-size: 13.5px; } + .uschart-body .usbar-p { fill: var(--accent); } + .uschart-body .usbar-c { fill: var(--ok); } + + /* 任务弹窗卡:任务+状态 / 进度·奖励 / 按钮 */ + #taskBody tr > .who { grid-column: 1 / 9; } + #taskBody tr > td:nth-child(5) { grid-column: 9 / 13; justify-content: flex-end; } + #taskBody tr > td:nth-child(5)::before { content: none; } + #taskBody tr > td:nth-child(3), #taskBody tr > td:nth-child(4) { grid-column: span 6; } + + /* 积分包明细卡:包名+到期 / 面额·剩余·已用·发放 */ + #pkDetail tr > td:nth-child(2) { grid-column: 1 / 9; order: -2; } + #pkDetail tr > td:nth-child(7) { grid-column: 9 / 13; justify-content: flex-end; order: -1; } + #pkDetail tr > td:nth-child(3), #pkDetail tr > td:nth-child(4), + #pkDetail tr > td:nth-child(5), #pkDetail tr > td:nth-child(6) { grid-column: span 3; } + + /* ── 4. 弹层 / toast:铺满可用宽度 ───────────────────────────────── */ + .veil.on { padding: 10px; } + .dlg { max-width: 100%; max-height: 92vh; } + .dlg header { padding: 14px 14px 11px; } + .dlg .body { padding: 14px; } + .dlg footer { padding: 12px 14px; flex-wrap: wrap; } + .dlg .tabs { margin: 8px 0 0; } + .tab-panel + .tab-panel { margin: 0 -14px; padding: 12px 14px 0; } + .realm-row { flex-wrap: wrap; gap: 8px; } + .savebar { padding: 11px 12px; flex-wrap: wrap; } + .vc .ft { flex-wrap: wrap; gap: 8px; } + .vc .ft code { font-size: 13px; letter-spacing: .6px; padding: 6px 9px; } + .vc .ft .acts { margin-left: 0; } + #toasts { left: 12px; right: 12px; bottom: 12px; align-items: stretch; } + /* 服务端错误消息里可能夹着长 token/URL,不能只靠 max-width 就把内容裁掉 */ + .tst { max-width: 100%; min-width: 0; overflow-wrap: anywhere; } + /* 弹层内部同样收紧(与卡片台同一密度口径) */ + .dlg header { padding: 11px 12px 9px; } + .dlg header h3 { font-size: 13.5px; } + .dlg .body { padding: 11px; } + .dlg footer { padding: 9px 11px; gap: 7px; } + .dlg footer button { padding: 6px 10px; font-size: 12.5px; border-radius: 8px; } + .vc { margin-bottom: 8px; border-radius: 10px; } + .vc .hd { padding: 9px 11px 3px; } + .vc .meta { padding: 1px 11px 8px; } + .vc .ft { padding: 8px 11px; } + /* 配置页:字段行更矮,标签/提示更小 */ + label.fld { margin-bottom: 9px; } + label.fld > .lb { font-size: 11.5px; margin-bottom: 3px; } + label.fld > .hint, .hint { font-size: 10px; margin-top: 2px; } + input, select { padding: 6px 9px; font-size: 12.5px; border-radius: 8px; } + button { padding: 6px 10px; font-size: 12.5px; border-radius: 8px; } + .switch { padding: 6px 0; } + .switch .lb { font-size: 12.5px; } + .hr { margin: 2px 0 11px; } + legend { font-size: 11px; padding-bottom: 7px; } + + /* ── 5. 其余栅格与列表:全部收紧间距,统一 12px 圆角卡片感 ──────── */ + .view { padding: 10px 10px 40px; } + .stats { grid-template-columns: repeat(3, minmax(0, 1fr)); border-radius: 12px; margin-bottom: 10px; } + .stat { padding: 7px 9px; min-width: 0; } + .stat .v { font-size: 15px; line-height: 1.2; white-space: normal; overflow-wrap: anywhere; } + .stat .k { font-size: 9.5px; margin-top: 1px; } + .box { border-radius: 12px; margin-bottom: 12px; } + .box > header { flex-wrap: wrap; gap: 6px 8px; padding: 9px 11px; } + .box > header h3 { font-size: 12.5px; white-space: normal; } + /* 上游 .box > header .note 也是 nowrap(L200):v1.12.0 起 packages 页每个账号盒头部的 + 余额口径文字变长,窄屏直接撑破整页(实测 563px)。用量页那条 id 限定的「单行截断」 + 规则特异性更高,仍按上游保持省略号,不受这里影响。 */ + .box > header .note { white-space: normal; } + .box .pad { padding: 11px; } + .grid2, .grid3 { grid-template-columns: minmax(0, 1fr); } + /* 任务中心:每项也是一张卡(标题+状态 / 进度+编码 / 信息) */ + .qgroup { padding: 0 0 12px; } + .qgroup header { padding: 6px 2px 6px; } + .qgroup header .nm { font-size: 12.5px; } + .qrow { + display: flex; flex-wrap: wrap; align-items: center; gap: 3px 8px; + padding: 8px 10px; margin-bottom: 6px; background: var(--surface); + border: 1px solid var(--line); border-radius: 12px; + } + .qrow .name { flex: 1 1 100%; order: -1; font-size: 12.5px; } + .qrow .name .t { white-space: normal; } + .qrow .code { flex: 1 1 auto; white-space: normal; overflow-wrap: anywhere; } + .qrow .prog { flex: none; text-align: left; font-size: 11.5px; } + .qrow .st { flex: none; font-size: 11.5px; } + .qrow .msg { flex: 1 1 100%; font-size: 10.5px; white-space: normal; overflow-wrap: anywhere; } + #logBox { padding: 10px; max-height: 60vh; font-size: 11px; } + .pk-card { padding: 9px 11px; border-radius: 12px; } + .pk-card .big { font-size: 19px; } + .pk-card .sub { font-size: 10.5px; } + .qprog { padding: 8px 11px 0; } + .pk-expiry-hdr, .pk-expiry-row { grid-template-columns: 52px minmax(60px, 1fr) 62px; gap: 6px; } + .pk-expiry-chart { padding-right: 4px; } + + /* ── 上游 v1.12.0 新增:首页「积分到期提醒」卡片(.exp-row)────────── + 桌面端是 [色点][账号名 min-width:9em][正文] 的单行布局;窄屏上 9em 硬下限会把 + 正文挤成一条,故让名字可收缩、正文整行换行。 */ + .exp-row { flex-wrap: wrap; gap: 4px 8px; padding: 7px 0; } + .exp-nm { min-width: 0; flex: 0 1 auto; overflow-wrap: anywhere; } + .exp-main { flex: 1 1 100%; min-width: 0; overflow-wrap: anywhere; } + + /* ── 6. 长不可断字符串兜底 ──────────────────────────────────────── + 长昵称 / 长模型名 / 长券码这类"没有一个断点"的串会撑破 flex 行:flex 子项默认 + min-width:auto(= 内容的 min-content 宽)拒绝收缩到比它更窄。父级 overflow 为 + hidden 的就被硬裁掉(内容看不全),为 auto 的(券码弹窗 body)直接长出一条真实 + 横向滚动条。overflow-wrap 必须用 anywhere 而不是 break-word:只有 anywhere 参与 + min-content 计算,flex 项才肯收缩。*/ + .qgroup header, .vc .hd, .vc-acct, .pk-card .who, .nav .sig .row { flex-wrap: wrap; } + .box > header h3, .qgroup header .nm, .qrow .name .t, + .vc .hd .nm, .vc-acct .nm, .vc .ft code, .vc .meta, + .pk-card .nm, .pk-card .realm, .pk-legend span, .pk-expiry-legend span, + .brand .sub, .nav .sig, .dlg header h3, .dlg header .hint, + .hint, .note, .state { + min-width: 0; overflow-wrap: anywhere; + } + /* 名称类单元格/标题:卡片里空间够就换行显示全文,不学桌面端省略号截断 */ + .who .nm, .who .id { min-width: 0; white-space: normal; overflow-wrap: anywhere; text-overflow: clip; } + /* 标签类(限流冷却 · 1时02分 / 模型档位)窄列里换行,而不是 nowrap 撑着容器溢出 */ + .tag { max-width: 100%; min-width: 0; white-space: normal; overflow-wrap: anywhere; } +} diff --git a/internal/panel/mobile.js b/internal/panel/mobile.js new file mode 100644 index 00000000..6f57cc8b --- /dev/null +++ b/internal/panel/mobile.js @@ -0,0 +1,229 @@ +/* mobile.js — 手机端(≤760px)联动逻辑:抽屉开合 + 宽表卡片化的字段名标签。 + * + * 本文件是本仓库自有文件,上游不维护(同步上游时不参与冲突)。 + * 必须在 app.js 之后加载:依赖 app.js 的全局($、go、esc、各 render*)与已渲染的 DOM。 + * + * 分工:样式在 mobile.css;CSS 表达不了的联动(抽屉开关、th 文本 → td[data-l])放这里。 + */ + +/* ── 移动端适配(≤760px)────────────────────────────────────────────── + 分工:所有手机端**样式**都在 mobile.css;这里只做 CSS 表达不了的联动: + + 1) 侧栏抽屉开合(汉堡 / 遮罩 / 导航项 / ESC / 转回宽屏复位)。 + 「转回宽屏复位」不是可选项:遮罩是 fixed 全屏层,窗口拉宽后若不摘掉 .on, + 它会永远盖在面板上,整个页面点不动。 + + 2) 表格 → 卡片的**字段名**。卡片模式隐藏了表头,用户就看不出哪个值是哪一列。 + 做法不是把字段名写死在 CSS 里(表头一改名就漂移),而是把每个 <th> 的纯文本 + 抄进同列 <td> 的 data-l,CSS 用 content: attr(data-l) 渲染。 + 行全是 JS 动态 innerHTML 出来的(含弹层里的任务表、积分构成里每账号一张表), + 故用 MutationObserver 监听 body 子树,任何一次重渲染后自动补标签。 + + 两件事在 >760px 时都空转:matchMedia 不匹配;data-l 只是多一个桌面端不参与 + 渲染的属性。桌面端表现与改动前完全一致。 */ +const MOBILE_MQ = matchMedia('(max-width: 760px)'); + +const navDrawer = document.querySelector('.nav'); +const navBtn = $('navBtn'), navVeil = $('navVeil'); + +/* drawerSet 幂等设置抽屉三态载体:侧栏 .open / 遮罩 .on / 按钮 aria-expanded。 */ +function drawerSet(open) { + if (navDrawer) navDrawer.classList.toggle('open', open); + if (navVeil) navVeil.classList.toggle('on', open); + if (navBtn) navBtn.setAttribute('aria-expanded', open ? 'true' : 'false'); +} +function drawerClose() { drawerSet(false); } +function drawerToggle() { drawerSet(!(navDrawer && navDrawer.classList.contains('open'))); } + +if (navBtn) navBtn.addEventListener('click', drawerToggle); // 汉堡:开/关 +if (navVeil) navVeil.addEventListener('click', drawerClose); // 点遮罩关 +// 点导航项关:事件委托,不去改上方既有的 go() 绑定 +if (navDrawer) navDrawer.addEventListener('click', ev => { if (ev.target.closest('a')) drawerClose(); }); +addEventListener('keydown', ev => { if (ev.key === 'Escape') drawerClose(); }); // ESC 关 +// 转回 >760px 自动复位:matchMedia 的 change 在跨过断点时触发,resize 兜底 +function drawerResetIfWide() { if (!MOBILE_MQ.matches) drawerClose(); } +if (MOBILE_MQ.addEventListener) MOBILE_MQ.addEventListener('change', drawerResetIfWide); +else if (MOBILE_MQ.addListener) MOBILE_MQ.addListener(drawerResetIfWide); // 旧 Safari +addEventListener('resize', drawerResetIfWide); + +/* ── 表格字段名 → data-l ─────────────────────────────────────────────── */ +/* thLabel 只取表头里的**纯文本节点**:表头里可能还有排序箭头之类的装饰 <span>, + innerText 会把它们的字符一并算成字段名。 */ +function thLabel(th) { + let t = ''; + for (const n of th.childNodes) if (n.nodeType === 3) t += n.nodeValue; + return t.replace(/\s+/g, ' ').trim(); +} +/* labelTable 按列把表头文字抄进每一行:td[data-l] = 同列 th 的纯文本。 + 跨列单元格(colspan:空态提示、包分组汇总)不参与列对齐,也不打标签。 */ +/* SHORT_LABEL 手机卡片用的短字段名:一行要并排放好几个指标,表头原名太长会挤成 + 两行(「成功 / 失败」「支持的思考档位」「上下文长度」)。未列出的沿用表头纯文本。 */ +const SHORT_LABEL = { + '成功 / 失败': '成功', + '支持的思考档位': '档位', + '上下文长度': '上下文', + '最大输出': '输出', + '积分倍率': '倍率', + '均延迟': '延迟', + '均速率': '速率', + '最近成功': '最近', +}; +function labelTable(table) { + const head = table.tHead; + if (!head || !head.rows.length) return; + const labels = []; + for (const th of head.rows[head.rows.length - 1].cells) labels.push(thLabel(th)); + for (const body of table.tBodies) { + for (const tr of body.rows) { + let col = 0; + for (const td of tr.cells) { + const span = td.colSpan > 1 ? td.colSpan : 1; + const full = span === 1 ? (labels[col] || '') : ''; + const label = SHORT_LABEL[full] || full; + if (label) td.setAttribute('data-l', label); + else td.removeAttribute('data-l'); + col += span; + } + } + } +} +function labelTables() { + document.querySelectorAll('table').forEach(labelTable); +} +/* 账号卡底部的操作按钮要在一行里平分宽度(设计要求:全部按钮单行)。上游按钮文案会变长 + (v1.12.0 起多了「暂停选号」):320px 下 6 个按钮一行每个只有 ~40px,4 个字放不下、 + 文字被硬裁。这里把超过 3 个字的文案压到 2 个字,完整文案挂到 title,保持单行不裁字。 */ +const ACT_SHORT = { '暂停选号': '暂停', '恢复选号': '恢复' }; +function shortenActLabels() { + if (!MOBILE_MQ.matches) return; + for (const b of document.querySelectorAll('.acc .acts button')) { + const t = (b.textContent || '').trim(); + if (t.length <= 3) continue; + if (!b.title) b.title = t; + b.textContent = ACT_SHORT[t] || t.slice(0, 2); + } +} +/* 一次重渲染会连发多条 mutation(整段 tbody 换掉),合并成一次批处理。 */ +let labelPending = false; +function queueLabels() { + if (labelPending) return; + labelPending = true; + setTimeout(() => { labelPending = false; labelTables(); shortenActLabels(); }, 0); +} +labelTables(); // 首屏:把已存在的行先标好 +shortenActLabels(); +new MutationObserver(queueLabels).observe(document.body, { childList: true, subtree: true }); + +/* ── 用量「Token 时序」:窄屏另画一张 ───────────────────────────────── + 上游那张图是固定 viewBox 1200×200 的 SVG,靠 width:100% 整体缩放:360px 宽的手机上 + 缩放比只有 ~0.28,内部 10px 刻度实际只剩 ~2.8px(看着就是一排小点),柱宽也只剩 5px。 + 窄屏改用 440×230 的 viewBox(缩放 ~0.77)并抽稀刻度,字号/柱宽按 CSS 像素算, + 不需要横向拖动。>760px 仍走上游实现,桌面端不受影响。 + 实现放在本文件而不是改 app.js:上游常改前端,独立文件才不会每次同步都冲突。 */ +const deskUsageChart = renderUsageChart; + +function mobileUsageChart(series) { + if (!MOBILE_MQ.matches) { deskUsageChart(series); return; } + try { + drawMobileUsageChart(series); + } catch (e) { + deskUsageChart(series); // 自绘出错不能让整页用量视图空掉 + } +} + +function drawMobileUsageChart(series) { + const host = $('usChart'); + if (!host) return; + const pts = []; + for (const p of series || []) { + const t = parsePointTime(p); + if (t === null) continue; + const pt = Number(p.prompt_tokens || 0), ct = Number(p.completion_tokens || 0); + pts.push({ t: t, raw: p.t, scope: p.scope, pt: pt, ct: ct, + tt: Number(p.total_tokens || 0) || (pt + ct), req: p.requests || 0 }); + } + if (!pts.length) { + host.innerHTML = '<div class="us-empty">暂无用量数据。发起一次对话后再刷新。</div>'; + $('usChartNote').textContent = '—'; + return; + } + const W = 440, H = 230, PL = 56, PR = 12, PT = 18, PB = 30; + const iw = W - PL - PR, ih = H - PT - PB, yBase = PT + ih; + const t0 = pts[0].t, span = Math.max(1, pts[pts.length - 1].t - t0); + const max = Math.max(1, ...pts.map(p => p.tt)); + const peak = pts.reduce((a, b) => (b.tt > a.tt ? b : a), pts[0]); + const avg = pts.reduce((s, p) => s + p.tt, 0) / pts.length; + $('usChartNote').textContent = pts.length + ' 个点 · 峰值 ' + fmtTok(peak.tt) + ' @ ' + + fmtTokTimeLabel(peak) + ' · 均值 ' + fmtTok(avg); + + let minGap = Infinity; + for (let i = 1; i < pts.length; i++) minGap = Math.min(minGap, pts[i].t - pts[i - 1].t); + if (!isFinite(minGap) || minGap <= 0) minGap = span; + const bw = Math.max(3, Math.min(22, iw * (minGap / span) * 0.7)); + const xOf = t => PL + bw / 2 + (t - t0) / span * Math.max(1, iw - bw); + const yOf = v => PT + ih - ih * (v / max); + + let out = '<svg viewBox="0 0 ' + W + ' ' + H + '" role="img" preserveAspectRatio="xMidYMid meet">'; + // 只画 4 条网格:窄屏塞 5 条时刻度文字比柱子还挤 + for (let i = 0; i <= 3; i++) { + const y = PT + ih - (ih * i / 3); + out += '<line class="gl" x1="' + PL + '" y1="' + y.toFixed(1) + '" x2="' + (W - PR) + '" y2="' + y.toFixed(1) + '"/>' + + '<text class="tk" x="' + (PL - 6) + '" y="' + (y + 4).toFixed(1) + '" text-anchor="end">' + fmtTok(max * i / 3) + '</text>'; + } + if (avg > 0 && avg < max) { + const y = yOf(avg); + out += '<line class="avg" x1="' + PL + '" y1="' + y.toFixed(1) + '" x2="' + (W - PR) + '" y2="' + y.toFixed(1) + '"/>' + + '<text class="tk-avg" x="' + (PL + 4) + '" y="' + (y - 5).toFixed(1) + '" text-anchor="start">均值 ' + fmtTok(avg) + '</text>'; + } + for (const p of pts) { + const x = xOf(p.t) - bw / 2; + const hTot = ih * (p.tt / max); + const hP = p.tt ? hTot * (p.pt / p.tt) : 0; + const hC = Math.max(p.tt && p.ct ? 1 : 0, hTot - hP); + // 每根柱子包一个 <g>:SVG 里 <title> 描述的是父元素,平铺在根下会全图共用一个 + // tooltip(悬停任何柱子都是同一份数据,issue #128)。 + out += '<g><title>' + esc(p.raw) + ' ' + fmtTok(p.pt) + ' prompt / ' + fmtTok(p.ct) + ' completion / ' + p.req + ' 次'; + if (hP > 0) out += ' 0 ? '' : ' rx="1.5"') + '/>'; + if (hC > 0) out += ''; + out += ''; + } + { + const px = xOf(peak.t), py = yOf(peak.tt); + const anchor = px > W - PR - 84 ? 'end' : 'middle'; + out += '峰值 ' + fmtTok(peak.tt) + ''; + } + out += ''; + // x 刻度抽到 4 个(上游 6 个),且取"离目标最近的真实柱子",标签永远落在有数据的点上 + const TICKS = Math.min(4, pts.length); + const used = new Set(); + for (let k = 0; k < TICKS; k++) { + const target = t0 + span * (TICKS === 1 ? 0.5 : k / (TICKS - 1)); + let bi = 0, best = Infinity; + for (let i = 0; i < pts.length; i++) { + const d = Math.abs(pts[i].t - target); + if (d < best) { best = d; bi = i; } + } + if (used.has(bi)) continue; + used.add(bi); + const p = pts[bi], cx = xOf(p.t); + const anchor = cx < PL + 16 ? 'start' : (cx > W - PR - 16 ? 'end' : 'middle'); + out += '' + esc(fmtTokTimeLabel(p)) + ''; + } + let prevDay = null; // 跨天分隔线:长窗口里能看出日界 + for (const p of pts) { + const d = new Date(p.t).getDate(); + if (prevDay !== null && d !== prevDay) { + const x = xOf(p.t).toFixed(1); + out += ''; + } + prevDay = d; + } + out += ''; + host.innerHTML = out; +} + +renderUsageChart = mobileUsageChart; diff --git a/internal/panel/panel.go b/internal/panel/panel.go index b2e630c7..99c44b31 100644 --- a/internal/panel/panel.go +++ b/internal/panel/panel.go @@ -153,6 +153,8 @@ func (p *Panel) Logs() *Ring { return p.logs } func (p *Panel) routes() { p.mux.HandleFunc("GET /panel/{$}", p.index) p.mux.HandleFunc("GET /panel/app.js", p.appScript) + p.mux.HandleFunc("GET /panel/mobile.css", p.mobileStyle) + p.mux.HandleFunc("GET /panel/mobile.js", p.mobileScript) p.mux.HandleFunc("GET /panel/api/overview", p.withAuth(p.overview)) p.mux.HandleFunc("GET /panel/api/logs", p.withAuth(p.logsHandler)) p.mux.HandleFunc("GET /panel/api/request_metrics", p.withAuth(p.requestMetrics)) From 941a0db604c419976ff1b188b3d5e770ce1d3d3d Mon Sep 17 00:00:00 2001 From: Saito-Sakuya <121111572+Saito-Sakuya@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:57:12 +0800 Subject: [PATCH 07/57] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E5=90=8E?= =?UTF-8?q?=E5=8F=B0=E4=BB=BB=E5=8A=A1=E5=B9=B6=E4=BF=AE=E6=AD=A3=E8=AE=A1?= =?UTF-8?q?=E8=B4=B9=E4=B8=8E=E4=BB=BB=E5=8A=A1=E7=BB=93=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cherry-pick of upstream PR #104 (e761ce5), adapted to this branch. Upstream change: persistent background task job manager (internal/panel/taskjobs.go), task ring buffer, rewritten auto-task state machine (executeAutoTask/claimAutoTask), credit sub-cent remainder accounting (internal/pool), diff-based restart-required field detection, serialized config saves. Branch-specific adaptations (conflicts resolved keeping both intents): - internal/panel/autotask.go: kept branch's SupportsBehaviorEvents() 422 gate alongside upstream's IsGlobal() 501 gate; kept branch's progress-only taskProgressReached criterion (Current>=Target) so upstream's broader accept_status=="completed" check cannot mis-claim rewards that were not actually credited (branch issue #174). - internal/panel/taskcenter.go: moved branch's SupportsPersonalGrowth() gate into scanAccountGrowth; kept branch's enterprise-switch session warning. - cmd/server/main.go: saveConfig keeps branch's atomicfile.Write fallback dirs and upstream's serialized-save mutex + running-config baseline; restartRequiredFields switched to upstream's diff-based form and extended with branch's cn.chat_base / server.read_timeout. - internal/pool: kept branch's Status/paused fields alongside upstream's credits_remainder accounting; upstream's stale SetCreditsDetailed copy replaced by branch's SetCreditsSnapshot with creditsRemainder reset. - internal/panel/app.js: noGrowthUI hides the check-in button for global accounts; branch's credit-history rendering kept. - internal/scheduler: kept IncludeDisabledInTasks. - Tests updated: branch's autotask_claim_test.go now expects upstream's done/claimed state machine; frontend_test.go stub gains cfgKey node for upstream's env-managed API key field; strategy_config_test.go and cn_chat_base_test.go adapted to the new saveConfig/restartRequiredFields signatures. Co-Authored-By: Claude Opus 4.8 (1M context) (cherry picked from commit 9676e445cfb099ac0d3cc1c2f0a4be6e4adbfa58) --- .github/workflows/docker-ghcr.yml | 13 + README.md | 110 +-- cmd/server/cn_chat_base_test.go | 20 +- cmd/server/config.go | 37 + cmd/server/config_save_test.go | 120 +++ cmd/server/main.go | 133 +++- cmd/server/strategy_config_test.go | 4 +- internal/panel/app.js | 314 ++++++-- internal/panel/autotask.go | 565 +++++++++------ internal/panel/autotask_claim_test.go | 20 +- internal/panel/autotask_outcome_test.go | 288 ++++++++ internal/panel/config.go | 28 +- internal/panel/config_env_test.go | 27 + internal/panel/frontend_test.go | 4 +- internal/panel/import.go | 4 + internal/panel/index.html | 43 +- internal/panel/login.go | 4 + internal/panel/panel.go | 34 +- internal/panel/ring.go | 136 +++- internal/panel/ring_test.go | 72 ++ internal/panel/task_frontend_test.go | 76 ++ internal/panel/task_queue_outcome_test.go | 28 + internal/panel/task_realm_test.go | 289 ++++++++ internal/panel/taskcenter.go | 303 ++++---- internal/panel/taskjobs.go | 725 +++++++++++++++++++ internal/panel/taskjobs_frontend_test.go | 77 ++ internal/panel/taskjobs_test.go | 416 +++++++++++ internal/panel/taskjobs_triggers_test.go | 109 +++ internal/panel/tasks.go | 49 +- internal/pool/billing_cost_test.go | 172 +++++ internal/pool/cooldown.go | 4 + internal/pool/entry.go | 95 ++- internal/pool/persist.go | 24 +- internal/pool/pick.go | 2 +- internal/pool/state.go | 25 +- internal/scheduler/scheduler.go | 10 +- internal/server/handler.go | 52 +- internal/server/handler_usage_credit_test.go | 116 +++ internal/server/logging.go | 44 +- internal/upstream/tasks.go | 172 ++++- internal/upstream/tasks_test.go | 93 +++ 41 files changed, 4152 insertions(+), 705 deletions(-) create mode 100644 cmd/server/config_save_test.go create mode 100644 internal/panel/autotask_outcome_test.go create mode 100644 internal/panel/config_env_test.go create mode 100644 internal/panel/task_frontend_test.go create mode 100644 internal/panel/task_queue_outcome_test.go create mode 100644 internal/panel/task_realm_test.go create mode 100644 internal/panel/taskjobs.go create mode 100644 internal/panel/taskjobs_frontend_test.go create mode 100644 internal/panel/taskjobs_test.go create mode 100644 internal/panel/taskjobs_triggers_test.go create mode 100644 internal/pool/billing_cost_test.go create mode 100644 internal/server/handler_usage_credit_test.go diff --git a/.github/workflows/docker-ghcr.yml b/.github/workflows/docker-ghcr.yml index 80b454ea..2bcfd41a 100644 --- a/.github/workflows/docker-ghcr.yml +++ b/.github/workflows/docker-ghcr.yml @@ -34,7 +34,20 @@ env: IMAGE_NAME: ${{ github.repository }} jobs: + test: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - run: go test ./... + - run: go vet ./... + build-and-publish: + needs: test runs-on: ubuntu-latest permissions: contents: read diff --git a/README.md b/README.md index 2228e92e..d5e980e9 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@

把腾讯 CodeBuddy 账号变成 OpenAI 兼容 API 的多账号网关 · 附 Web 管理面板
- Web 面板 · OAuth 浏览器登录 · 账号池轮转 · 熔断与冷却 · 会话粘性 · 定时签到 / 活跃 / 旅行 / 保活 · 成长任务一键完成(17/18) · 流式 / 非流式 + Web 面板 · OAuth 浏览器登录 · 账号池轮转 · 熔断与冷却 · 会话粘性 · 定时签到 / 活跃 / 旅行 / 保活 · 成长任务推进与领奖 · 流式 / 非流式

@@ -61,62 +61,37 @@ WorkBuddy2API 是一个自托管的 **OpenAI 兼容反向代理网关**,将腾 | 🔄 **多账号池** | 快过期积分加权 + 成本分层 + 加权随机选号,Top-5 候选 + 防惊群 | | 🛡️ **熔断与冷却** | 429 软冷却 600s 起指数退避(封顶 `soft_rate_max`)、404 固定 60s 短冷却、402 硬冷却至次日 04:00、连续失败熔断、在途租约限流 | | 🧲 **会话粘性** | 同一会话(`conversation_id`)尽量绑定同一账号,TTL 滚动续期,失败自动解绑,可镜像 Redis 防重启丢失 | -| ⏰ **定时任务** | 签到(09/21 点,末尾自动跑**连登管家**:兑换已解锁档位 + 抽完抽奖次数)+ 活跃上报(10 点,点亮连登 / 解锁领养 + streak 自检)+ 猫猫旅行(09/21 点,独立排程)+ token 保活(22 点),四类独立开关 | +| ⏰ **定时任务** | 签到、活跃上报、猫猫旅行、token 保活、夜猫子与成长任务六类独立排程和开关;签到末尾运行连登管家,余额另按分钟间隔刷新 | | ⚡ **流式 + 非流式** | 出站强制 `stream:true`;SSE 帧按规范白名单重建;非流式由本地聚合为单响应 | | 🧠 **推理模型兼容** | DeepSeek 思维链注入(`thinking.type=enabled` + 默认档)、`reasoning_content` 多轮回填、effort 档位自动降级 | -| 💬 **系统提示词体系** | 网关自有提示词替换客户端 system(默认 `custom`),从源头消灭 system 来源的内容误报;`passthrough` 遇拦截自动降级重试 | +| 💬 **系统提示词体系** | 默认 `passthrough` 保留客户端 system;可选 `custom` 替换或 `append` 追加网关提示词;遇拦截可自动降级重试 | | 🗑️ **指纹脱敏** | 出站请求体黑名单指纹字段清洗(可关闭),与提示词体系两层叠加 | | 📊 **可观测** | 每请求一行表格日志(TTFB / token 速率 / uid);`/healthz` 带 `service` 身份标识可接负载均衡 / 宿主探活 | | 💾 **状态持久化** | 池状态本地原子落盘 + Upstash Redis 异步镜像(可选),重启择新恢复 | | 🖥️ **Web 管理面板** | 内嵌单页面板(明暗主题,七个视图):账号运维 / 用量与积分分析 / 模型档位条件查询 / 在线改配置(热生效)/ 运行日志(含**调用来源 IP·UA** 与时间区间)/ 积分任务,见 [Web 管理面板](#-web-管理面板) | -## 🎯 成长任务一键完成(17/18) +## 🎯 成长任务推进与领奖 -官方「成长计划」的 18 个成长任务中,**17 个可在面板上一键纯 API 完成**——无需安装官方客户端、无需人工交互,点一下「一键完成」即自动推进进度、等待异步计分落定并**自动领奖**。剩余任务展示操作指引。 +国内区可由面板尝试执行代码中已有的任务动作,再读取服务端进度;只有服务端确认达标后才会领奖。可执行项由账号当前任务列表、解锁状态及已实现动作共同决定,活动内容和奖励也以上游当前返回为准,因此不承诺固定任务数或固定收益。动作请求成功本身不表示计分或领奖成功。国际区任务目前只支持查询,accept、领奖和自动动作均关闭。 -### 任务覆盖与奖励 +任务列表带 `progress_known` 与 `reward_known`:为 `false` 时,上游没有提供对应信息,省略的 `current`、`target` 或奖励字段代表未知,不代表 0。逐项结果中,`done` 表示已确认领取或领奖接口成功;`awaiting_progress` 表示动作已发送但尚未确认进度;`claim_pending` 表示进度达标但领奖失败;`skipped` 和 `error` 分别表示未执行和执行/查询失败。批量接受结果 `accepted` 只表示报名成功。已领取或服务端已确认达标的任务会跳过动作;但再次运行仍可能触发真实模型对话等操作并消耗积分,不能假定所有动作都幂等或零成本。 -| 任务 | 奖励 | 一键完成方式 | -|---|---|---| -| `first_buddy` | +300c +8e | 解锁上报 → 同意协议 → 领养第一只 Buddy | -| `create_canvas` | +300c +5e | 设计画布创建事件组(Ardot 遥测) | -| `chat_5` | +100c | 对话活跃上报 ×5(自动补足差额) | -| `Model_chat_GLM5.2` | +100c +5e | glm-5.2 真实对话一次(发一条短消息) | -| `RichMeow_Chat` | +100c +5e +UR Buddy | 桌面端对话事件链(6 事件,含成功回执) | -| `Buddy_App` | +100c +5e | Buddy 应用「发现→进入→授权」事件链 | -| `Buddy_App_QQ` | +50c +5e | 企鹅教师助手进入事件链(与上一条共用) | -| `automation_1` | +100c +5e | 定时任务创建成功事件 | -| `Library_read` | +100c +5e | 资料库阅读点击(web 域上报) | -| `template_5` | +100c +5e | 模板使用事件组 ×5 | -| `playbook_prompt` | +100c +5e | 灵感案例「做同款」发送事件 | -| `expert_5` | +100c +5e | 真实专家召唤+使用链 ×5(专家市场拉真实专家 → 真实对话 → 使用事件) | -| `Expert_team_use_3` | +100c +5e | 专家团召唤+使用链 ×3 | -| `Hp_Appearance` | +100c +5e | 主题设置 + 皮肤生效事件 | -| `Expert_lighthouse` | +100c +5e | 轻量云专家召唤+使用链(真实对话 requestId,**可免费领一个月轻量服务器**) | -| `skill_1` | +100c +5e | 真实对话 + 技能加载事件(skill_info) | - -**全新账号一键全做完 ≈ +1950 credits +78 能量**,其中仅数个任务涉及真实对话(`Model_chat_GLM5.2` 一条、`expert_5`/`Expert_team_use_3`/`skill_1` 各数条 fast-model 短对话),其余全部为行为事件上报,零对话消耗。 - -### 不可自动的 1 个 - -| 任务 | 原因 | -|---|---| -| `Expert_Philanthropy` | 需真实捐款(服务端领奖时校验捐赠回执,已实测无法绕过) | +国区新账号通过面板登录或导入时会自动排入后台任务;已有国区账号按 `schedule.growth_hours` 每日排入(默认本地时区 01:00),也可从账号任务页手动启动。新增账号和每日触发共用 `schedule.growth_enabled`(默认开启);关闭后暂停这两种自动触发,手动启动仍可用。每个账号同一时间只运行一个任务作业,后台最多并发处理 2 个账号。 -### 实现原理(简述) +`POST /panel/api/accounts/{uid}/tasks/auto_all` 现在只负责启动作业并立即返回 HTTP 202:`{"ok":true,"started":true,"job":{...}}`。`started:false` 表示该账号已有活动作业并返回现有作业。HTTP 202 表示已受理,不表示作业或任务已完成;已有客户端应改为轮询 `GET /panel/api/accounts/{uid}/tasks/job`(返回该账号最新作业)或 `GET /panel/api/tasks/jobs`(返回 `auto_enabled` 和各账号最新作业)。关闭页面不会停止作业。`job.status` 描述作业生命周期(`pending`、`running`、`finished`、`interrupted`、`error`),`job.completed` 是本轮已处理的任务项数,不代表成功数;请查看 `job.results[].status` 和进度字段判断结果。作业结束只表示本轮处理完毕。 -任务计分走 `/v2/report` 行为上报,但**不同任务认不同客户端指纹**:CLI 指纹(`www.codebuddy.cn`)、桌面指纹(`copilot.tencent.com` + `WorkBuddy/5.5.6` UA + `workbuddy-desktop` 事件族)、web 指纹(`www.workbuddy.cn` + `x-client-platform: web`)。网关为每类任务构造对应指纹的判据事件链(`internal/upstream/desktop.go`);专家类任务额外要求真实专家 id 与真实对话回执(`internal/upstream/streak.go` 之外的 expert 序列)。上报 200 ≠ 计分——面板在执行后轮询任务进度,达标即自动调用 Web 域领奖接口。 +作业原子保存在 `state_file` 同目录的 `task-jobs.json`,每个账号保留最新作业,最多 256 个账号。重启时遗留的 `pending` / `running` 作业会标成 `interrupted`,随后恢复账号仍存在、未禁用且可取得任务锁的作业:手动作业不受自动开关限制;新账号和每日作业仅在 `schedule.growth_enabled` 开启时恢复。恢复会重新读取服务端状态,不会把“动作已发送”当作完成。 -> ⚠️ 行为事件按天幂等:重复点「一键完成」不会重复扣资源,已达标的任务自动跳过。 +成本账本升级时,旧状态里没有可信扣费来源标记的零价观测会迁移为未知;已有积分余额、用量与冷却状态不受影响,正价观测保留。只有带有效用量信息的上游 `usage.credit` 才会形成新的实测价格,缺失费用不视为免费。账号余额估算会保留小额费用的小数部分,避免逐次取整漏记,并用于积分保底判断。 ### 🧭 任务中心(面板新视图) 「任务中心」视图把散落的任务能力收拢成一处: -- **全账号任务扫描**:一键拉取每个账号的成长任务(未完成且可自动化的 19 项,含小程序口径的「校园日」与「小程序首对话」)+ 开学季待办,列表一目了然 -- **执行队列**:把待办按账号排队执行——账号内串行(与单任务/一键完成共用互斥锁),账号间可选并发(1-3);执行进度实时更新到每个条目 -- **开学季独立状态卡**:每账号 5 任务(分享/桌面/对话×3/专家/学生认证)的状态矩阵 + 剩余抽奖次数,一键触发全账号闭环 -- **日志分频道**:运行日志按「任务 / 对话 / 系统」三个频道筛选——对话流量再大,任务结果也不会被冲掉;日志条目带频道徽标与时间 +- **全账号任务扫描**:对启用的国区账号只读查询默认和小程序口径的成长任务并按任务码去重,保留跳过原因和查询错误;仅把已配置自动动作且未领取、未锁定的任务列为待办,不表示所有任务都可自动完成 +- **执行队列**:按账号串行执行已扫描待办,与单项动作共用账号锁;账号间并发可设为 1–4。队列状态可轮询 +- **后台作业**:显示新账号、每日和手动触发的国区自动作业,按账号查看当前项和结果;每个账号的最新作业持久化,最多保存 256 个账号。作业最多并发处理 2 个账号,页面刷新或关闭不影响执行 +- **日志分频道**:任务 / 对话 / 系统各保留最近 500 行,互不淘汰;任务日志独立保存到 `state_file` 同目录的 `task-logs.json`,重启后可查询最近执行结果。

🎒 开学季活动(5/5 全自动)——活动期至 2026-09-24,已结束;逆向成果存档,点击展开 @@ -160,7 +135,7 @@ WorkBuddy2API 是一个自托管的 **OpenAI 兼容反向代理网关**,将腾 | **请求指标与脱敏日志** | 面板展示完成成功率 / HTTP 成功率 / 平均耗时 / 最近请求,响应带 `X-Request-Id`;JSONL 只归档请求元数据,不写提示词、响应正文或凭证 | | **浏览器内 OAuth 添加账号** | 面板「添加账号」按钮完成设备授权 → 凭证落盘 → **热加载进池(免重启)**,替代命令行 `login.sh` 流程 | | **在线配置编辑(热生效)** | 面板直接改 `config.json`:API 密钥 / `soft_rate` / 脱敏开关 / 池参数 / 任务排程**立即生效**;装配期字段(listen 等)保存后提示需重启。写入采用深合并 + 原子替换,保留未知键 | -| **积分任务体系** | 任务列表 / 接受 / 领取接口 + 面板弹窗;「一键完成」覆盖 **17 个任务**(对话 / 领养 / 桌面行为链 / 模板 / 灵感案例 / 画布 / 专家召唤 / 技能尝鲜 / 主题 / 资料库 / 夜猫子等),推进进度、等待异步计分落定后**自动领奖**,纯 API 零客户端依赖 | +| **积分任务体系** | 任务列表 / 接受 / 领取接口 + 面板弹窗和异步后台作业;对有对应动作的国区任务尝试推进(「一键完成」覆盖 **17 个任务**:对话 / 领养 / 桌面行为链 / 模板 / 灵感案例 / 画布 / 专家召唤 / 技能尝鲜 / 主题 / 资料库 / 夜猫子等),回读服务端进度并在**确认达标后领奖**,纯 API 零客户端依赖;任务范围、进度和奖励取决于上游实际返回。国际区仅查询 | | **限时补偿可观测(issue #189)** | 官方补偿(例:「模型服务异常额外补偿 500 积分」)在账号侧可见,网关改为**先查 `compensation-status` 再决定是否领**:面板「官方补偿」卡片列出待领账号 + 领取截止倒计时(≤48h 标红)+ 逐号/全量领取按钮;**查询失败的账号在卡片上显形为红色行,不会与「没有补偿」混为一谈**;每日签到排程同步走「查→领」,**状态查询失败与领取失败一律显式告警**(旧实现把「没有 / 已领 / 领取失败」三态压成同一形态,带截止日的补偿会静默过期) | | **首启自动生成配置** | 目录下无 `config.json` 时自动生成推荐配置(含 `crypto/rand` 随机 `api_key`),双击即开 | | **粘性会话内容回退** | 客户端不发 `conversation_id` 时,用 `system + 首条 user` 哈希派生会话键(`d-` 前缀),通用 OpenAI 客户端也能享受粘性 | @@ -195,7 +170,7 @@ flowchart LR Client["客户端 / SDK\nOpenAI 兼容请求"] --> H subgraph GWI["WorkBuddy2API 网关 :7863"] - H["HTTP Handler\n鉴权 · 请求体上限 · 提示词改写 · 轮转"] --> P + H["HTTP Handler\n鉴权 · 请求体解析 · 提示词改写 · 轮转"] --> P H --> S P["账号池\n快过期加权 · 成本分层 · 熔断 · 冷却 · 租约"] --> U S["会话粘性路由"] -.绑定镜像.-> REDIS @@ -371,19 +346,23 @@ curl -s http://localhost:7863/v1/chat/completions \ | `schedule.activity_hours` | `[10]` | 每日本地时区整点对话活跃上报(点亮连登 + 解锁 `first_buddy`) | | `schedule.keepalive_hours` | `[22]` | 每日本地时区整点刷新 token 保活 | | `schedule.blackcat_hours` | `[23]` | 每日本地时区整点夜猫子补足(23:00–08:00 计数窗口) | +| `schedule.growth_hours` | `[1]` | 每日本地时区整点为符合条件的国区账号排入成长任务后台作业 | | `schedule.checkin_enabled` | `true` | 签到总开关;`false` 真正关闭 | | `schedule.travel_enabled` | `true` | 猫猫旅行总开关(独立于签到) | | `schedule.activity_enabled` | `true` | 活跃上报总开关 | | `schedule.keepalive_enabled` | `true` | token 保活总开关 | | `schedule.blackcat_enabled` | `true` | 夜猫子总开关 | | `schedule.include_disabled_in_tasks` | `false` | 让**保号类**四任务(签到 / 活跃上报 / token 保活 / 余额刷新)对**已禁用**账号也执行——「禁用」只关选号,不停保号。`false`(默认)保持「禁用的跳过」 | +| `schedule.growth_enabled` | `true` | 新增国区账号和每日成长任务的自动触发总开关;关闭后手动启动仍可用 | +| `schedule.balance_refresh_enabled` | `true` | 独立的周期余额刷新开关,不随签到或成长任务开关变化 | +| `schedule.balance_refresh_minutes` | `5` | 周期余额刷新间隔(分钟);小于等于 0 时回落到 5 分钟 | | `upstream.timeout_seconds` | `120` | 短 RPC(刷新 / 签到 / 余额 / 模型列表)总时长上限 | | `upstream.header_timeout_seconds` | 回落 `timeout_seconds` | 聊天首字节前(响应头)上限 | | `upstream.idle_timeout_seconds` | `300` | 聊天流中空闲上限(活跃续命,静默断流) | | `upstream.user_agent` | 空 | 出站 User-Agent 覆盖(空 = 现状 `CLI/2.63.2 CodeBuddy/2.63.2`)。官网「使用端」列按出站 UA 服务端归因;官方 WorkBuddy 桌面 UA 为 `WorkBuddy/`,需要时可配 | | `features.sanitize_blacklist_fingerprints` | `true` | 出站请求体黑名单指纹脱敏 | | `models.hidden` | `[]` | 模型黑名单:不对外暴露的模型。裸名=两域都藏;`"cn:xxx"`/`"global:xxx"`=只藏该域。`GET /v1/models` 列表过滤 + `POST /v1/chat/completions` 点名拒绝(400 `model_unavailable`)。面板保存后热生效 | -| `prompt.mode` | `custom` | 系统提示词模式:`custom` = 网关用自有提示词替换客户端 system;`append` = 开头连续 system/developer 块后插网关提示词(既有消息逐字不动);`passthrough` = 透传客户端原始 system(降级重试仍切中性提示词) | +| `prompt.mode` | `passthrough` | 系统提示词模式:`custom` = 网关用自有提示词替换客户端 system;`append` = 开头连续 system/developer 块后插网关提示词(既有消息逐字不动);`passthrough` = 透传客户端原始 system(降级重试仍切中性提示词) | | `prompt.file` | 空 | 提示词文件路径;空 = 内置默认(约 2KB);路径非空但不可读 → 启动报错 | | `upstash.url` / `upstash.token` | 空 | 空 = 纯内存模式(Noop 降级,功能照常) | | `pool.max_in_flight` | `3` | 单账号最大在途请求数(`0` = 不限) | @@ -496,7 +475,7 @@ curl -s http://localhost:7863/v1/chat/completions \ ### 定时任务 -五类任务各自独立排程、各有开关,互不影响。容器时区由 `TZ` 控制(compose 默认 `Asia/Shanghai`)。 +六类定时任务各自独立排程、各有开关,互不影响。周期余额刷新另由分钟间隔控制,不占用小时排程。容器时区由 `TZ` 控制(compose 默认 `Asia/Shanghai`)。 | 任务 | 开关(默认 true) | 时刻(默认) | 行为 | |---|---|---|---| @@ -505,6 +484,7 @@ curl -s http://localhost:7863/v1/chat/completions \ | 猫猫旅行 | `schedule.travel_enabled` | `travel_hours` `[9, 21]` 整点 | 独立排程:无猫领养 / `idle` 派出 / `arrived` 领奖 | | 保活 | `schedule.keepalive_enabled` | `keepalive_hours` `[22]` 整点 | 全账号刷新 token;session 失效**连续 3 次**才自动禁用 | | 夜猫子 | `schedule.blackcat_enabled` | `blackcat_hours` `[23]` 整点 | **先查任务进度再决定**:`black_cat` 未达标才在 23:00–08:00 计数窗口内补足 glm-5.2 短对话(每天 1 次累计 3 天,漏跑次日窗口自动补) | +| 成长任务 | `schedule.growth_enabled` | `growth_hours` `[1]` 整点 | 为启用的国区账号启动持久化后台作业;按账号互斥,调度器不等待任务执行完成 | #### 禁用账号与保号任务(`schedule.include_disabled_in_tasks`) @@ -570,7 +550,7 @@ CodeBuddy 平台签发的**静态 API Key**(`ck_` / `pt_` 前缀,控制台 A 无需配置,跟随签到排程;到天数那天自动完成「兑换 → 抽奖」,无需人工盯。 -**关闭定时任务**:用 `schedule.*_enabled: false` 显式关闭(四个都设 `false` 则调度器不空转,直接阻塞等待退出信号)。注意两点语义: +**关闭定时任务**:用对应的 `schedule.*_enabled: false` 显式关闭(六类小时任务都关闭后,调度器直接等待退出信号)。注意两点语义: - **空数组与 `null` 表示「未配置 → 回落默认」**,不是「禁用」;真正关闭请用 `*_enabled: false` - **禁用不会擦除小时配置**:`*_hours` 原样保留,改回 `true` 即恢复原时点;小时值必须是 0-23,非法值启动即报错 @@ -605,7 +585,7 @@ CodeBuddy 平台签发的**静态 API Key**(`ck_` / `pt_` 前缀,控制台 A ## API 端点 -**余额后台刷新**(`schedule.balance_refresh_enabled`,缺省开启):每 `balance_refresh_minutes`(缺省 5)分钟并发查询全部账号余额并更新池内积分——两次签到时点之间 credits 保持新鲜,余额恢复的冷却账号也会自动解冻(语义同签到,但不做签到不刷 token)。面板「立即刷新」按钮也是全量刷余额;5 秒自动轮询只读内存,不打上游。 +**余额后台刷新**(`schedule.balance_refresh_enabled`,缺省开启):独立于六类小时排程,每 `schedule.balance_refresh_minutes`(缺省 5)分钟并发查询全部账号余额并更新池内积分;它不执行签到、不刷新 token,也不受 `growth_enabled` 控制。余额恢复时会按池规则处理积分冷却账号。面板「立即刷新」按钮也是全量刷余额;5 秒自动轮询只读内存,不打上游。 ## 🖥️ Web 管理面板 @@ -621,39 +601,15 @@ http://127.0.0.1:7863/panel/ | 视图 | 功能 | |---|---| | **账号池** | 统计条(账号总数 / 可用 / 冷却中 / 已禁用 / 积分剩余·总额 / 粘性会话)+ 账号表:健康色条与状态标签(可用 / 限流冷却 / 积分冷却 / 熔断 / 连败降权 / 已禁用)、积分量条、成功·失败计数、在途、最近一次用量(次数 / token / 延迟 / 速率)、最近成功时间;单号操作 签到 / 余额 / 任务 / 解冻 / 禁用 / 移除;批量「全部签到」「旅行巡检」「活跃上报」「全部保活」;下方**模型锁池**表列出被上游按模型限流的模型(整池不可用 / 没号可用 / 部分限流)、可选与总数、锁定账号数、最早解锁与全池解锁倒计时、限流原因,无锁定时显示「所有模型均可选」 | -| **用量** | 指标卡(请求数 / 总 token / prompt / completion / 失败尝试 / 平均延迟,含占比与吐字速率)+ **Token 时序图**(渐变堆叠柱、均值参考线、峰值标注、悬停看单桶明细)+ **用量明细**(按账号 / 按模型 / 按域切换,可按合计 Token / 请求数 / 失败数 / 平均延迟排序,含 prompt·completion 占比条)+ **积分扣除历史**(按账号 / 按模型,折算「积分 / 1M Token」) | +| **添加账号**(顶部按钮) | 浏览器内完成 OAuth 设备授权(显示授权链接 + 自动轮询),登录后凭证落盘并**热加载进池,免重启** | +| **积分任务**(账号行内「任务」按钮) | 展示上游返回的任务、已知进度和奖励;国区支持接受、单项执行和后台全量作业,只有服务端确认达标后才领奖;未知进度/奖励会标明未知,国际区只查询 | +| **后台作业**(任务中心) | 查看账号最新的成长任务作业与逐项结果;`POST .../tasks/auto_all` 返回 HTTP 202 后应轮询 `GET /panel/api/accounts/{uid}/tasks/job` 或 `GET /panel/api/tasks/jobs`,不能再等待 POST 请求代表整轮完成 | +| **用量** | 指标卡(请求数 / 总 token / prompt / completion / 失败尝试 / 平均延迟,含占比与吐字速率)+ **Token 时序图**(渐变堆叠柱、均值参考线、峰值标注、悬停看单桶明细)+ **用量明细**(按账号 / 按模型 / 按域切换,可按合计 Token / 请求数 / 失败数 / 平均延迟排序,含 prompt·completion 占比条)+ **积分扣除历史**(按账号 / 按模型,折算「积分 / 1M Token」)。**时间范围**支持 今天 / 近 24 小时 / 近 3 天 / 近 7 天 / 近 30 天 / 全部历史 / **自定义区间**(精确到分钟),卡片、表格与图表全部按同一窗口统计 | | **积分构成** | **积分到期分布**(按批次到期日聚合,颜色区分账号)+ **账号对比**(按来源归并的面额 / 余额 / 包数卡片与混合构成条)+ 逐包明细(按到期日排序,默认展开最近到期的 `panel.package_detail_limit` 个,其余与已用完的包折叠) | | **任务中心** | 成长任务队列:一键扫描全部账号待办 → 按账号排队执行(账号内串行,账号间可选并发 1-3),进度实时更新;另含开学季券码查询。详见 [任务中心](#-任务中心面板新视图) | | **模型与档位** | 实时查询上游:每模型的积分倍率(含限时免费 / 夜间折扣等**生效价**与划线牌价)、默认思考档、支持的档位(含「off(可关)」)、上下文长度与最大输出;存在探测数据时最大输出列显示**实测上限与钳制告警**(见「探测模型真实输出上限」)。支持**按条件查询**:关键词(ID / 名称 / 描述 / 厂商 / 标签,空格分词 AND)、域(CN / Global)、能力(工具 / 视觉 / 思考 / 默认)、思考档位、价格(折扣 / 限时免费 / 打折),以及按倍率 / 上下文 / 最大输出 / ID 排序 | | **配置** | 在线编辑 config.json:服务(监听地址 / API 密钥 / 单账号明细条数)、定时任务(签到 / 保活 / 旅行 / 活跃 / 成长任务五类时点与开关 + 余额刷新间隔)、账号池与流量治理、上游与高级(超时 / 出站 UA / 提示词模式 / 指纹脱敏 / 粘性路由)、日志(调用来源记录开关) | -| **运行日志** | **请求记录表**(时间 / 结果 / 模型 / 账号 / **来源 IP** / **User-Agent** / 耗时 / Token / 积分 / 请求 ID),支持按 IP·UA·模型·账号·请求 ID 与结果筛选、读取条数、时间范围;下方 **运行日志** 按「任务 / 对话 / 系统」分频道,可开关自动滚动;新增 **积分历史**(每次真实查到余额与上次比对,变动即留痕) | - -顶部另有「刷新」(向上游全量查询真实余额并回写;5 秒自动轮询只读内存,不打上游)与「添加账号」(浏览器内完成 OAuth 设备授权:显示授权链接 + 自动轮询,登录后凭证落盘并**热加载进池,免重启**,也支持导入 cockpit tools 导出的 JSON)。 - -### 手机端(≤760px) - -无需配置:窗口宽度 ≤760px 时自动生效,>760px 完全不参与,桌面端逐像素不变。 - -- **侧栏 → 抽屉**:顶栏出现汉堡按钮,点遮罩 / 选中导航项 / `Esc` / 窗口拉宽都会自动关闭(拉宽不复位会留一层 fixed 遮罩把页面盖住点不动) -- **宽表 → 卡片**:表头隐藏,每列字段名由 `mobile.js` 从 `` 的纯文本抄进同列 `td[data-l]`,CSS 用 `content: attr(data-l)` 渲染——写死在 CSS 里会随表头改名漂移 -- **Token 时序图窄屏重画**:上游那张图固定 viewBox `1200×200` 靠 `width:100%` 缩放,360px 宽下缩放比只剩 ~0.28(内部 10px 刻度实际 ~2.8px,看着是一排小点);窄屏改画 `440×230` 并抽稀刻度,tooltip 同样跟随柱子 -- 顶栏 / 筛选栏 / 弹层 / 统计条 / KPI 卡按同一密度口径收紧;`color-scheme: only light|dark` 退出手机浏览器的「自动暗化」(否则主题按钮显示浅色、页面实际还是黑的) -- 实现全在 `mobile.css` / `mobile.js` 两个独立文件里(`index.html` 只多一行 ``、一行 `