diff --git a/.github/workflows/docker-ghcr.yml b/.github/workflows/docker-ghcr.yml
index 80b454ea..851d680d 100644
--- a/.github/workflows/docker-ghcr.yml
+++ b/.github/workflows/docker-ghcr.yml
@@ -34,7 +34,35 @@ 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 ./...
+
+ # Windows 专项测试:Windows 与 POSIX 的错误码语义不同(ERROR_ACCESS_DENIED /
+ # ERROR_NOT_SAME_DEVICE vs EXDEV / EBUSY),而仓库**同时发布 Windows 二进制**
+ # → 只在 ubuntu 上跑测试会让 Windows 专属分支永远不被执行到。
+ test-windows:
+ runs-on: windows-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, test-windows]
runs-on: ubuntu-latest
permissions:
contents: read
diff --git a/.github/workflows/go-binaries.yml b/.github/workflows/go-binaries.yml
index faee0889..209d7325 100644
--- a/.github/workflows/go-binaries.yml
+++ b/.github/workflows/go-binaries.yml
@@ -37,8 +37,23 @@ jobs:
# ubuntu runner 自带 node → panel 包的 JS 运行时冒烟测试(TestAppJSTopLevelSmoke)会真实执行
- run: go test ./...
+ # Windows 专项测试:Windows 与 POSIX 的错误码语义不同(ERROR_ACCESS_DENIED /
+ # ERROR_NOT_SAME_DEVICE vs EXDEV / EBUSY),而仓库发布**包含 Windows 二进制**
+ # → 只在 ubuntu 上跑测试会让 Windows 专属分支永远不被执行到。
+ # 实测教训:internal/auth 的 rename 降级判据在 Windows 上恒为 false
+ # → 刷新后的 accessToken 不落盘(issue #226 修复的 Windows 侧缺口)。
+ test-windows:
+ runs-on: windows-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-go@v5
+ with:
+ go-version-file: go.mod
+ - run: go vet ./...
+ - run: go test ./...
+
build:
- needs: test
+ needs: [test, test-windows]
runs-on: ubuntu-latest
strategy:
fail-fast: false
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"]
diff --git a/README.md b/README.md
index 254fa20e..4c19e171 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@
把腾讯 CodeBuddy 账号变成 OpenAI 兼容 API 的多账号网关 · 附 Web 管理面板
- Web 面板 · OAuth 浏览器登录 · 账号池轮转 · 熔断与冷却 · 会话粘性 · 定时签到 / 活跃 / 旅行 / 保活 · 成长任务一键完成(17/18) · 流式 / 非流式
+ Web 面板 · OAuth 浏览器登录 · 账号池轮转 · 熔断与冷却 · 会话粘性 · 定时签到 / 活跃 / 旅行 / 保活 · 成长任务推进与领奖 · 流式 / 非流式
@@ -61,62 +61,38 @@ 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` 遇拦截自动降级重试 |
+| 🔍 **联网搜索** | `POST /v1/search/messages` 把上游 `/agenttool/v1/search` 包成 Anthropic Messages 兼容形态(DSH 等客户端的搜索 provider 零改动接入);复用账号池选号 / 在途租约 / token 刷新 / 错误冷却,出站请求头对齐官方客户端 |
+| 💬 **系统提示词体系** | 默认 `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 +136,8 @@ 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 客户端也能享受粘性 |
| **余额后台刷新** | `schedule.balance_refresh_minutes`(默认 5)周期查余额并更新池,冷却账号余额恢复自动解冻 |
@@ -194,7 +171,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
@@ -244,6 +221,26 @@ curl -s http://localhost:7863/healthz
> 镜像 tag 规则:`main` 分支推送 `latest` / `main` / `sha-xxxxxx`;打 `v*` tag 额外发布
> `1.2.3` / `1.2` / `1` 语义化版本;PR 仅构建验证、不推送。
+### 方式〇之二:本地构建(本 fork 的修复版镜像)
+
+上游 GHCR 镜像仍是**未修复**的版本(`11-128` 会被误判为账号封禁而永久禁用好号、
+`6021` 队列满被当限流冷却等)。要部署本 fork 的修复版,从源码构建:
+
+```bash
+git clone https://github.com/Huiaei/workbuddy2api-panel.git
+cd workbuddy2api-panel
+git checkout release/v1.13.0-fixed # 以 1.13.0 发行版为基线的修复版
+docker build -t workbuddy2api-fixed:latest .
+docker run -d --name workbuddy2api \
+ -p 7863:7863 -e TZ=Asia/Shanghai \
+ -v ./auths:/app/auths -v ./data:/app/data -v ./config.json:/app/config.json \
+ workbuddy2api-fixed:latest
+```
+
+> `release/v1.13.0-fixed` = **精确的 1.13.0 发行版**(tag `d66384d`)+ 全部已知问题修复,
+> 不携带 1.13.0 之后的上游改动;`fix/all-known-issues-v1.13.0` = 上游 `main` + 同一套修复。
+> 两条分支的修复集相同,区别只在基线。
+
### 方式一:Docker Compose(推荐服务器部署)
```bash
@@ -343,6 +340,13 @@ curl -s http://localhost:7863/v1/chat/completions \
-H "Authorization: Bearer your-api-key" \
-H "Content-Type: application/json" \
-d '{"model":"deepseek-v4-flash","messages":[{"role":"user","content":"hi"}],"stream":false}'
+
+# 联网搜索(Anthropic Messages 兼容形态,走上游 /agenttool/v1/search)
+curl -s http://localhost:7863/v1/search/messages \
+ -H "Authorization: Bearer your-api-key" \
+ -H "Content-Type: application/json" \
+ -d '{"model":"deepseek-v4-flash","tools":[{"type":"web_search_20250305","name":"web_search"}],
+ "messages":[{"role":"user","content":"Perform a web search for the query: OpenAI 最新模型"}]}'
```
## 配置说明
@@ -357,11 +361,15 @@ 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 |
| `logging.request_retention_days` | `7` | 请求归档保留天数;超期文件在启动和周期清理时删除 |
| `logging.request_archive_max_mb` | `100` | 请求归档总容量上限(MiB);超限优先删除最旧文件 |
+| `logging.log_file` | 空 | 运行日志落盘路径;空 = 只输出到控制台(不落盘)。Windows 上没有控制台窗口时排查只能靠猜,配上这个开关就能直接翻日志文件。相对路径按进程工作目录解析,父目录自动创建,追加写不截断。改动需重启 |
+| `logging.log_file_max_mb` | `64` | 单个日志文件上限(MiB);超限轮转为 `.1` / `.2` … |
+| `logging.log_file_backups` | `3` | 日志轮转保留份数;最老的自动删除 |
| `cooldown.soft_rate` | `600s` | 软限流(429 / 限流文案)冷却基数;同一账号连续触发按 2 倍指数退避 |
| `cooldown.soft_rate_max` | `2h` | 软冷却指数退避封顶 |
| `schedule.checkin_hours` | `[9, 21]` | 每日本地时区整点签到 + 余额查询解冻。空数组 / `null` = 未配置回落默认(不是禁用) |
@@ -369,18 +377,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` | 出站请求体黑名单指纹脱敏 |
-| `prompt.mode` | `custom` | 系统提示词模式:`custom` = 网关用自有提示词替换客户端 system;`append` = 开头连续 system/developer 块后插网关提示词(既有消息逐字不动);`passthrough` = 透传客户端原始 system(降级重试仍切中性提示词) |
+| `models.hidden` | `[]` | 模型黑名单:不对外暴露的模型。裸名=两域都藏;`"cn:xxx"`/`"global:xxx"`=只藏该域。`GET /v1/models` 列表过滤 + `POST /v1/chat/completions` 点名拒绝(400 `model_unavailable`)。面板保存后热生效 |
+| `prompt.mode` | `passthrough` | 系统提示词模式:`custom` = 网关用自有提示词替换客户端 system;`append` = 开头连续 system/developer 块后插网关提示词(既有消息逐字不动);`passthrough` = 透传客户端原始 system(降级重试仍切中性提示词) |
| `prompt.file` | 空 | 提示词文件路径;空 = 内置默认(约 2KB);路径非空但不可读 → 启动报错 |
| `upstash.url` / `upstash.token` | 空 | 空 = 纯内存模式(Noop 降级,功能照常) |
| `pool.max_in_flight` | `3` | 单账号最大在途请求数(`0` = 不限) |
@@ -389,13 +402,19 @@ 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` 关闭 |
+| `pool.burn_guard_limit` | `0` | **积分燃尽熔断**(issue #229):计账窗口(`pool.burn_guard_window`,默认 `10m`)内单账号累计烧掉的积分超过该值 → 该账号熔断冷却(`pool.burn_guard_cooldown`,默认 `10m`,随连续触发递增,上限 `pool.burn_guard_cooldown_max` 默认 `2h`)。解决「只剩余额下限、没有消费速率闸」导致的疯狂烧号。**注意**:停摆(零 tool_calls)那一跳**不参与**熔断计账(那是上游行为缺陷,不是账号在烧钱);`0` = 关闭 |
+| `upstream.tool_stall_retry` | `1` | **零 tool_calls 停摆重试**(issue #149):请求声明了 tools 却收到 `finish_reason=stop` + 零 tool_calls(工具一次没跑,客户端却按「正常完成」收尾)时,**换号**再问一次(同一号大概率再次停摆)。**永不罚号**(不冷却/不熔断/不禁用),重试预算用尽则原样返回上游那一跳的响应。`0` = 只观测不重试;上限 3。改动需重启 |
| `session_sticky.enabled` | `true` | 会话粘性路由开关 |
| `session_sticky.ttl` | `30m` | 会话绑定 TTL(滚动续期) |
| `session_sticky.gc_interval` | `5m` | 过期绑定 GC 周期 |
@@ -414,7 +433,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`
## 核心行为语义
@@ -489,7 +508,7 @@ curl -s http://localhost:7863/v1/chat/completions \
### 定时任务
-五类任务各自独立排程、各有开关,互不影响。容器时区由 `TZ` 控制(compose 默认 `Asia/Shanghai`)。
+六类定时任务各自独立排程、各有开关,互不影响。周期余额刷新另由分钟间隔控制,不占用小时排程。容器时区由 `TZ` 控制(compose 默认 `Asia/Shanghai`)。
| 任务 | 开关(默认 true) | 时刻(默认) | 行为 |
|---|---|---|---|
@@ -498,6 +517,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`)
@@ -528,6 +548,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 +559,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 天)兑换后发放积分 / 能量 / 补签卡 / **抽奖次数**,抽奖次数只能从兑换获得。管家在每日签到后自动跑一遍闭环(幂等,未解锁静默跳过):
@@ -547,7 +583,7 @@ curl -s http://localhost:7863/v1/chat/completions \
无需配置,跟随签到排程;到天数那天自动完成「兑换 → 抽奖」,无需人工盯。
-**关闭定时任务**:用 `schedule.*_enabled: false` 显式关闭(四个都设 `false` 则调度器不空转,直接阻塞等待退出信号)。注意两点语义:
+**关闭定时任务**:用对应的 `schedule.*_enabled: false` 显式关闭(六类小时任务都关闭后,调度器直接等待退出信号)。注意两点语义:
- **空数组与 `null` 表示「未配置 → 回落默认」**,不是「禁用」;真正关闭请用 `*_enabled: false`
- **禁用不会擦除小时配置**:`*_hours` 原样保留,改回 `true` 即恢复原时点;小时值必须是 0-23,非法值启动即报错
@@ -582,7 +618,73 @@ curl -s http://localhost:7863/v1/chat/completions \
## API 端点
-**余额后台刷新**(`schedule.balance_refresh_enabled`,缺省开启):每 `balance_refresh_minutes`(缺省 5)分钟并发查询全部账号余额并更新池内积分——两次签到时点之间 credits 保持新鲜,余额恢复的冷却账号也会自动解冻(语义同签到,但不做签到不刷 token)。面板「立即刷新」按钮也是全量刷余额;5 秒自动轮询只读内存,不打上游。
+### 联网搜索 `POST /v1/search/messages`
+
+把上游 `/agenttool/v1/search` 包成 **Anthropic Messages 兼容**的搜索端点。这样任何按
+`${baseURL}/messages` 发起搜索的客户端(如 DSH 的 `web-search-deepseek`)只需把搜索
+baseURL 指向 `<网关>/v1/search`,即可零代码接入本网关的搜索能力。
+
+与 chat 共享同一套账号池语义:选号轮转、在途租约、token 临近过期自动刷新、失败按错误
+类型冷却或熔断。
+
+**为什么不用 `/v1/messages`**:那是 Anthropic 模型对话的标准路径,留给未来的 chat 转发;
+搜索挂在 `/v1/search/messages`,语义清晰且互不干扰。
+
+请求(Anthropic Messages 形态):
+
+```json
+{
+ "model": "deepseek-v4-flash",
+ "max_tokens": 4096,
+ "tools": [{ "type": "web_search_20250305", "name": "web_search", "max_uses": 5 }],
+ "messages": [
+ { "role": "user", "content": [{ "type": "text", "text": "Perform a web search for the query: OpenAI 最新模型" }] }
+ ]
+}
+```
+
+- `tools` 必须声明 web_search(`type` 以 `web_search` 开头或 `name` 为 `web_search`),
+ 否则返回 400——本端点只服务搜索,不处理模型对话
+- 搜索词取自 user 消息文本,固定前缀 `Perform a web search for the query:` 会被剥离
+- 结果条数固定为官方默认值 5(服务端实际上限约 10)
+
+响应为 Anthropic Messages 形态,搜索结果在 `web_search_tool_result` block 里:
+
+```json
+{
+ "id": "msg_wb2api_...",
+ "type": "message",
+ "role": "assistant",
+ "model": "deepseek-v4-flash",
+ "content": [
+ {
+ "type": "web_search_tool_result",
+ "tool_use_id": "srvtoolu_...",
+ "content": [
+ { "type": "web_search_result", "url": "https://...", "title": "..." }
+ ]
+ },
+ {
+ "type": "text",
+ "text": "",
+ "citations": [
+ { "type": "web_search_result_location", "url": "https://...", "cited_text": "摘要" }
+ ]
+ }
+ ],
+ "stop_reason": "end_turn",
+ "usage": { "input_tokens": 0, "output_tokens": 0 }
+}
+```
+
+> **摘要只走 citations**:Anthropic 的 `web_search_result` 本身不带摘要,摘要由 `text`
+> block 的 `citations[].cited_text` 承载(与官方行为一致)。
+>
+> **上游未实现的参数不要暴露给用户**:实测 `allowed_domains`、`freshness`、
+> `blocked_domains`(折成 `(-site:)` 拼进 query)在服务端均**静默失效**——传了不报错也
+> 不生效。本端点因此不接受这三个字段,避免调用方产生「已过滤」的错误预期。
+
+**余额后台刷新**(`schedule.balance_refresh_enabled`,缺省开启):独立于六类小时排程,每 `schedule.balance_refresh_minutes`(缺省 5)分钟并发查询全部账号余额并更新池内积分;它不执行签到、不刷新 token,也不受 `growth_enabled` 控制。余额恢复时会按池规则处理积分冷却账号。面板「立即刷新」按钮也是全量刷余额;5 秒自动轮询只读内存,不打上游。
## 🖥️ Web 管理面板
@@ -598,32 +700,18 @@ 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)。
-
-### 时间范围查询
-
-「用量」与「运行日志 → 请求记录」共用同一个时间范围控件,预设 **今天 / 近 24 小时 / 近 3 天 / 近 7 天 / 近 30 天 / 全部历史 / 自定义**(自定义展开起止时间输入,精确到分钟;起止颠倒就地标红):
-
-- 「今天」与「自定义」的区间由**浏览器按本地时区**算好后发给服务端——服务端时区未必与浏览器一致(容器常挂 `TZ=Asia/Shanghai`),让服务端算"今天"会在跨时区时切错日子;
-- 滚动预设走服务端的 `hours` 口径(当前整点往回 N-1 小时),与既有统计口径逐位一致(前端自己减 N 小时会在边界多算/少算一个桶);
-- 用量侧由 `usage.Window` 统一三种窗口口径,并在响应里回显实际生效区间(`window_from` / `window_to`),总览右上角因此会显示形如 `9-30 00:00 → 9-30 15:02 · 14 个分桶 · 数据自 …` 的口径说明;
-- 请求记录的时间过滤在**归档侧**完成(`reqlog.Filter.From/To`,而不是筛已拉取的条目),所以区间落在更早时段时也能取到记录。
-
-### 调用来源(客户端 IP / User-Agent)
-
-请求记录表会显示每次调用的来源:IP 取 `X-Forwarded-For` 首段 → `X-Real-IP` → 回落 TCP 对端(直连部署下这是唯一线索);User-Agent 截断 200 字节入库,stdout 流水行同时追加压缩后的客户端标签(`src=203.0.113.7 ua="Chrome/120.0.0.0"`,浏览器 UA 不会整段铺开)。
-
-来源信息比 token 计数敏感(IP 属个人信息),因此由 `logging.request_client_info` 控制,**缺省开启、热生效、面板「配置 → 日志」可关**;关闭后归档与面板都不再出现来源字段(老归档里没有该字段的条目显示 `—`)。
+| **运行日志** | **请求记录表**(时间 / 结果 / 模型 / 账号 / **来源 IP** / **User-Agent** / 耗时 / Token / 积分 / 请求 ID),支持按 IP·UA·模型·账号·请求 ID 与结果筛选、读取条数、时间范围;下方 **运行日志** 按「任务 / 对话 / 系统」分频道,每频道各保留最近 500 行,任务结果独立落盘、可跨重启查询;新增 **积分历史**(每次真实查到余额与上次比对,变动即留痕) |
**配置热生效**:保存配置后,`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 +724,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` 观测口径一致。
@@ -652,7 +741,7 @@ http://127.0.0.1:7863/panel/
| 端点 | 鉴权 | 说明 |
|---|---|---|
-| `POST /v1/chat/completions` | Bearer(`api_key` 非空时) | OpenAI 兼容补全;流式/非流式;请求体上限 8 MiB |
+| `POST /v1/chat/completions` | Bearer(`api_key` 非空时) | OpenAI 兼容补全;流式/非流式;网关不设固定请求体大小上限 |
| `GET /v1/models` | Bearer(`api_key` 非空时) | 模型列表(纯动态拉取,缓存 1h;失败返回空列表 + 5min 负缓存);每模型带 `context_length`/`max_output_tokens`(四级查找链:上游目录 → 内置知识表 → model.json 缓存 → models.dev)、`reasoning_supported_efforts`/`reasoning_default_effort` 思考档位及描述/标签/倍率等全字段(上游有返回时) |
| `GET /status` | Bearer(`api_key` 非空时) | 账号状态汇总 + 每账号详情(积分/冷却/熔断/在途/粘性)+ `model_locks`(按「域 + 模型」聚合的限流视图:可选 / 总数、锁定账号数、`state`、最早解锁与全池解锁时间、限流原因;无锁定为空) |
| `GET /healthz` | 无 | 健康检查:有 healthy 且未占满账号返回 200,否则 503;响应带身份标识(见下) |
@@ -689,6 +778,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 |
@@ -860,7 +950,7 @@ sudo chown -R 10001:10001 ./auths ./data ./config.json
### 系统提示词被内容策略误杀怎么办?
-默认 `prompt.mode=custom` 已用网关自有提示词替换客户端 system,从源头消除大部分误报;用户 / assistant 消息中的指纹串由 `features.sanitize_blacklist_fingerprints` 清洗,两层叠加。`passthrough` 模式下首遇拦截会自动换 Degraded 中性提示词同请求重试一次。
+默认 `prompt.mode=passthrough` 保留客户端 system;遇到内容策略拦截可自动换 Degraded 中性提示词重试。需要固定网关提示词时可选择 `custom`,需要追加时选择 `append`;用户 / assistant 消息中的指纹清理由 `features.sanitize_blacklist_fingerprints` 控制。
### 如何让官网「使用端」列显示为 WorkBuddy?
@@ -870,7 +960,7 @@ sudo chown -R 10001:10001 ./auths ./data ./config.json
| 断言 | 出处 |
|---|---|
-| `prompt.mode` 默认 `custom` | `cmd/server/config.go:148` |
+| `prompt.mode` 默认 `passthrough` | `cmd/server/config.go` 的 `Default` |
| 请求体无网关侧上限(max_body_mb 已移除) | `internal/server/handler.go` chatCompletions 读 body 段 |
| 出站强制 `stream:true` | `internal/upstream/payload.go:28` |
| DeepSeek 思维链注入(`thinking.type=enabled`) | `internal/upstream/thinking.go:110` |
@@ -904,3 +994,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..38df42cc
--- /dev/null
+++ b/cmd/server/cn_chat_base_test.go
@@ -0,0 +1,60 @@
+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 只在装配期生效,面板保存后若被改动须提示重启。
+//
+// 语义随 #104 改为「按与运行中配置的实际差异列出」:cn.chat_base 没变就不必
+// 提示重启,变了就必须提示。
+func TestCNChatBaseRequiresRestart(t *testing.T) {
+ current, err := ParseConfig([]byte(`{}`))
+ if err != nil {
+ t.Fatalf("parse current: %v", err)
+ }
+ next, err := ParseConfig([]byte(`{"cn":{"chat_base":"https://www.workbuddy.cn"}}`))
+ if err != nil {
+ t.Fatalf("parse next: %v", err)
+ }
+ if !slices.Contains(restartRequiredFields(current, next), "cn.chat_base") {
+ t.Fatal("cn.chat_base missing from restart-required fields")
+ }
+ // 没改动时不应提示(装配期字段的提示只在真的变了时出现)。
+ if slices.Contains(restartRequiredFields(current, current), "cn.chat_base") {
+ t.Fatal("cn.chat_base reported although unchanged")
+ }
+}
diff --git a/cmd/server/config.go b/cmd/server/config.go
index 1c9d5b2a..570e54d5 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"
)
@@ -21,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。
@@ -42,6 +47,22 @@ type Config struct {
// 关掉。关闭后 Event.ClientIP/UserAgent 保持为空,归档里不出现该字段。
// 热生效(经 livecfg 快照),无需重启。
RequestClientInfo bool `json:"request_client_info"`
+ // LogFile 日志落盘路径。缺省空 = 只输出到 stderr/stdout(不落盘)。
+ //
+ // 为什么需要:网关的运行日志(启动信息、上游错误、账号池事件)此前只往
+ // stderr/stdout 写。在 Windows 上双击/托盘启动时根本没有控制台窗口可看,
+ // 出了「上游返回 400 但客户端只看到一个错」这类问题就完全无从查证。
+ // 落盘后可直接翻日志文件。
+ //
+ // 相对路径按**进程工作目录**解析(与 config.json 相对路径一致的口径)。
+ // 父目录不存在会自动创建;追加写(不截断历史)。与 stdout 共存,互不影响。
+ // 改动需重启进程。
+ LogFile string `json:"log_file"`
+ // LogFileMaxMB 单个日志文件上限(MiB)。缺省 64;<=0 回落默认。
+ // 超限后轮转为 log.1 / log.2 …(最多保留 LogFileBackups 份)。
+ LogFileMaxMB int `json:"log_file_max_mb"`
+ // LogFileBackups 轮转保留份数,缺省 3;<=0 回落默认。
+ LogFileBackups int `json:"log_file_backups"`
} `json:"logging"`
Server struct {
@@ -106,6 +127,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 +149,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"`
@@ -147,6 +184,18 @@ type Config struct {
DeviceTokenFile string `json:"device_token_file"`
// PassthroughIP 是否透传客户端 IP 给上游(默认 false,反代安全边界)。
PassthroughIP bool `json:"passthrough_ip"`
+ // ToolStallRetry 非流式请求命中「声明了 tools 却零 tool_calls 且 stop 收尾」
+ // 时的自动重试次数(issue #149)。0 = 关闭重试(仍记录观测)。
+ //
+ // 这是**上游行为缺陷**(模型只说了话就收尾,工具一次没跑),不是账号问题,
+ // 所以重试**不罚账号**:只换号再问一次。实测停摆率约 13%~25%(按模型不同),
+ // 一次重试通常能换到正常的一轮。
+ //
+ // 为什么默认 1 而不是 0:零重试等于把停摆原样交给客户端,客户端按
+ // 「正常完成」收尾 → 工具没跑、任务停在原地,用户必须手动催一句。
+ // 一次重试的成本是一次上游调用,换来的是这一轮真的把工具跑起来。
+ // 上限由 normalize() 钳到 3(再高会明显放大上游消耗)。
+ ToolStallRetry int `json:"tool_stall_retry"`
} `json:"upstream"`
Features struct {
@@ -154,6 +203,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 +229,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 +263,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 +298,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:"-"`
}
@@ -245,6 +326,8 @@ func Default() *Config {
c.Logging.RequestArchiveEnabled = true
c.Logging.RequestRetentionDays = 7
c.Logging.RequestArchiveMaxMB = 100
+ c.Logging.LogFileMaxMB = 64
+ c.Logging.LogFileBackups = 3
// 缺省 true 靠显式赋值实现(同 Schedule 开关):JSON 里键缺席时字段保留此值,
// 只有显式 false 才关闭来源记录。
c.Logging.RequestClientInfo = true
@@ -264,15 +347,21 @@ 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
c.Upstream.IdleTimeoutSeconds = 0
+ // ToolStallRetry 缺省 1(issue #149):非流式请求声明了 tools 却收到「只回正文
+ // + stop 收尾」时换号再问一次(不罚账号)。0 = 关闭重试(仍记录观测)。
+ c.Upstream.ToolStallRetry = 1
// Global.Enabled 缺省 true(纯 CN 行为不变:CN 账号恒判 cn,global base 不被使用);
// ChatBase/BillingBase 缺省空(回落内置默认)。
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,13 +379,32 @@ 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"
return c
}
+// configBackupSuffix 是配置备份文件的后缀。
+//
+// 为什么需要:落盘的降级路径(rename 不可用时的原地覆盖)是 O_TRUNC 覆写,
+// **非原子**——进程在写一半时被杀 ⇒ 磁盘上留下**半截** config.json。半截配置
+// 解析失败 ⇒ main.go log.Fatalf ⇒ **服务起不来**(issue #226 场景下用户改
+// 配置改到一半断电,下次启动直接起不来,且旧值已被覆盖、无法找回)。
+//
+// 留一份「上一份完整配置」备份,启动时主配置解析失败就回退到它(并打 WARN),
+// 服务照常起来;备份也坏掉时才报错,且错误信息给出恢复路径。
+const configBackupSuffix = ".bak"
+
// Load 从文件读,再用 WB2A_* env 覆盖。
+//
+// 主配置**解析失败**时自动回退到 ".bak"(上一份完整配置),避免
+// 「半截配置 ⇒ 服务起不来」。回退只在备份**本身可解析**时发生——绝不拿半截
+// 配置启动(那比启动失败更难排查:面板/调度器会拿着缺字段的配置跑)。
func Load(path string) (*Config, error) {
c := Default()
if path != "" {
@@ -312,7 +420,15 @@ func Load(path string) (*Config, error) {
return nil, fmt.Errorf("read config: %w", err)
}
if _, err := ParseConfigInto(raw, c); err != nil {
- return nil, err
+ // 主配置坏了 → 尝试回退到备份。
+ if bak, berr := loadConfigBackup(path); berr == nil {
+ log.Printf("WARN: [config] %s 解析失败(%v),已回退到备份 %s —— "+
+ "上一次保存过程中的写入可能被打断;确认无误后请重新保存配置", path, err, path+configBackupSuffix)
+ return bak, nil
+ }
+ return nil, fmt.Errorf("config %s 解析失败: %w(备份 %s%s 也不可用:"+
+ "请从 config.example.json 复制一份重新配置,或删除该文件让程序自动生成推荐配置)",
+ path, err, path, configBackupSuffix)
}
}
applyEnv(c)
@@ -322,6 +438,39 @@ func Load(path string) (*Config, error) {
return c, nil
}
+// loadConfigBackup 读取并解析 ".bak"。备份不存在/也坏了 → 返回错误。
+func loadConfigBackup(path string) (*Config, error) {
+ raw, err := os.ReadFile(path + configBackupSuffix)
+ if err != nil {
+ return nil, err
+ }
+ c := Default()
+ if _, err := ParseConfigInto(raw, c); err != nil {
+ return nil, err
+ }
+ applyEnv(c)
+ if err := c.normalize(); err != nil {
+ return nil, err
+ }
+ return c, nil
+}
+
+// backupConfigBeforeWrite 在覆盖配置**之前**把当前内容复制到 ".bak"。
+//
+// 只在当前内容**可解析**时备份(半截内容不值得备份成「上一份完整配置」),
+// 备份失败不阻断保存(备份是保险,不是主路径)。
+func backupConfigBeforeWrite(path string) {
+ raw, err := os.ReadFile(path)
+ if err != nil || len(raw) == 0 {
+ return
+ }
+ var probe any
+ if json.Unmarshal(raw, &probe) != nil {
+ return // 当前就是坏的,不备份
+ }
+ _ = os.WriteFile(path+configBackupSuffix, raw, 0o600)
+}
+
// ParseConfigInto 把 JSON 覆盖到 c 上并 normalize(不做 env、不读文件)。
// 面板保存配置走这条路径:与 Load 完全同一套解析/校验逻辑,避免两处漂移。
func ParseConfigInto(raw []byte, c *Config) (*Config, error) {
@@ -378,6 +527,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 != "" {
@@ -449,10 +603,53 @@ func applyEnv(c *Config) {
c.Pool.PreferExpiring = b
}
}
+ if v := os.Getenv("WB2A_DNS"); v != "" {
+ c.DNSServers = splitCSV(v)
+ }
+}
+
+// EnvironmentManagedFields names fields whose startup environment overrides the file.
+// Values are never returned: the panel uses these names to prevent ineffective edits.
+func (c *Config) EnvironmentManagedFields() []string {
+ fields := []struct{ env, path, kind string }{
+ {"WB2A_LISTEN", "listen", ""}, {"WB2A_API_KEY", "api_key", ""},
+ {"WB2A_AUTH_DIR", "auth_dir", ""}, {"WB2A_STATE_FILE", "state_file", ""},
+ {"WB2A_SOFT_RATE", "cooldown.soft_rate", ""}, {"WB2A_SOFT_RATE_MAX", "cooldown.soft_rate_max", ""},
+ {"WB2A_TIMEOUT_SECONDS", "upstream.timeout_seconds", "int"}, {"WB2A_HEADER_TIMEOUT_SECONDS", "upstream.header_timeout_seconds", "int"},
+ {"WB2A_IDLE_TIMEOUT_SECONDS", "upstream.idle_timeout_seconds", "int"}, {"WB2A_USER_AGENT", "upstream.user_agent", ""},
+ {"WB2A_CLIENT_VERSION", "upstream.client_version", ""}, {"WB2A_CLI_VERSION", "upstream.cli_version", ""},
+ {"WB2A_CLIENT_NAME", "upstream.client_name", ""}, {"WB2A_DEVICE_TOKEN", "upstream.device_token", ""},
+ {"WB2A_DEVICE_TOKEN_FILE", "upstream.device_token_file", ""}, {"WB2A_PASSTHROUGH_IP", "upstream.passthrough_ip", "bool"},
+ {"WB2A_SANITIZE_FINGERPRINTS", "features.sanitize_blacklist_fingerprints", "bool"},
+ {"WB2A_PROMPT_MODE", "prompt.mode", ""}, {"WB2A_PROMPT_FILE", "prompt.file", ""},
+ {"WB2A_EXPIRING_SOON", "pool.expiring_soon", ""}, {"WB2A_PREFER_EXPIRING", "pool.prefer_expiring", "bool"},
+ }
+ var out []string
+ for _, field := range fields {
+ value := os.Getenv(field.env)
+ if value == "" {
+ continue
+ }
+ if field.kind == "int" {
+ if _, err := strconv.Atoi(value); err != nil {
+ continue
+ }
+ }
+ if field.kind == "bool" {
+ if _, err := strconv.ParseBool(value); err != nil {
+ continue
+ }
+ }
+ out = append(out, field.path)
+ }
+ return out
}
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
}
@@ -462,6 +659,12 @@ func (c *Config) normalize() error {
if c.Logging.RequestArchiveMaxMB <= 0 {
c.Logging.RequestArchiveMaxMB = 100
}
+ if c.Logging.LogFileMaxMB <= 0 {
+ c.Logging.LogFileMaxMB = 64
+ }
+ if c.Logging.LogFileBackups <= 0 {
+ c.Logging.LogFileBackups = 3
+ }
// 入站读取上限(issue #100):空值回落默认 300s;"0" 合法(不限制);
// 负值无语义,fail fast(静默钳 0 会把保护悄悄关掉)。
if c.Server.ReadTimeout == "" {
@@ -527,6 +730,45 @@ func (c *Config) normalize() error {
if c.Pool.CreditFloor < 0 {
c.Pool.CreditFloor = 0
}
+ // 零 tool_calls 停摆重试(issue #149):负值钳 0(= 关闭);上限 3(再高会
+ // 明显放大上游消耗,且实测一次重试通常就能换到正常的一轮)。
+ if c.Upstream.ToolStallRetry < 0 {
+ c.Upstream.ToolStallRetry = 0
+ }
+ if c.Upstream.ToolStallRetry > 3 {
+ c.Upstream.ToolStallRetry = 3
+ }
+ // 积分燃尽熔断(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 +833,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
}
@@ -656,3 +905,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/config_backup_test.go b/cmd/server/config_backup_test.go
new file mode 100644
index 00000000..156c3298
--- /dev/null
+++ b/cmd/server/config_backup_test.go
@@ -0,0 +1,167 @@
+package main
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "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"
+)
+
+// 本文件钉住**交叉审计第二轮 Q4.5** 抓到的启动级数据风险:
+//
+// 降级路径(rename 不可用时的原地覆盖)是 **O_TRUNC 覆写**,**非原子**:
+// 进程在写一半时被杀 ⇒ 磁盘上留下**半截** config.json。而 config.json 半截
+// ⇒ Load 解析失败 ⇒ main.go log.Fatalf("load config: …") ⇒ **服务起不来**:
+//
+// [half-config] 完整配置加载 ok listen=:7863
+// [half-config] 半截配置加载 err=parse config: unexpected end of JSON input
+// [half-config] 空文件 加载 err=parse config: unexpected end of JSON input
+//
+// 修法(三层):
+// 1) 落盘前先留一份 **.bak** 备份(上一份**完整**配置);
+// 2) 启动时解析失败 → 自动回退到 .bak,并打 WARN(服务照常起来);
+// 3) .bak 也没有 → 明确提示「配置已损坏」并指出恢复路径(而不是裸 JSON 错误)。
+//
+// 注意方向:**绝不**用「半截配置」启动(那会让面板/调度器拿着缺字段的配置跑,
+// 比启动失败更难排查);宁可回退到上一份完整配置。
+
+// TestLoadFallsBackToBackupWhenConfigTruncated 主配置半截 ⇒ 自动回退 .bak。
+func TestLoadFallsBackToBackupWhenConfigTruncated(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "config.json")
+
+ // 先落一份完整配置(走 WriteDefault 的同款序列化路径)。
+ good := Default()
+ good.Listen = ":7899"
+ raw := mustMarshalConfig(t, good)
+ if err := os.WriteFile(path, raw, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ // 备份 = 上一份完整配置。
+ if err := os.WriteFile(path+".bak", raw, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ // 模拟「原地覆盖写到一半进程被杀」:主文件变成半截。
+ if err := os.WriteFile(path, []byte(`{"listen":`), 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ cfg, err := Load(path)
+ if err != nil {
+ t.Fatalf("半截配置应回退到备份并正常启动,实际报错: %v", err)
+ }
+ if cfg.Listen != ":7899" {
+ t.Fatalf("回退后应使用备份里的配置,listen=%q(want :7899)", cfg.Listen)
+ }
+}
+
+// TestLoadWithTruncatedConfigAndNoBackup 备份也没有 ⇒ 明确报错而不是静默起。
+func TestLoadWithTruncatedConfigAndNoBackup(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "config.json")
+ if err := os.WriteFile(path, []byte(`{"listen":`), 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ _, err := Load(path)
+ if err == nil {
+ t.Fatal("半截配置且无备份时必须报错(不得静默按默认值启动)")
+ }
+ msg := err.Error()
+ if !strings.Contains(msg, "config.json") {
+ t.Fatalf("错误信息应指出是哪个配置文件坏了: %q", msg)
+ }
+ // 错误信息必须可操作:告诉用户怎么恢复。
+ if !strings.Contains(msg, ".bak") && !strings.Contains(msg, "修复") && !strings.Contains(msg, "恢复") {
+ t.Fatalf("错误信息必须给出恢复指引: %q", msg)
+ }
+}
+
+// TestLoadWithEmptyConfigFile 空文件同样按「损坏」处理(.bak 可救)。
+func TestLoadWithEmptyConfigFile(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "config.json")
+ good := Default()
+ good.Listen = ":7900"
+ raw := mustMarshalConfig(t, good)
+ if err := os.WriteFile(path+".bak", raw, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, nil, 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ cfg, err := Load(path)
+ if err != nil {
+ t.Fatalf("空配置应回退到备份: %v", err)
+ }
+ if cfg.Listen != ":7900" {
+ t.Fatalf("回退后 listen=%q(want :7900)", cfg.Listen)
+ }
+}
+
+// TestWriteDefaultKeepsPreviousConfigAsBackup WriteDefault 之外,落盘路径
+// (saveConfig)也必须在覆盖前留备份。
+func TestSaveConfigKeepsBackupBeforeOverwrite(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "config.json")
+
+ old := Default()
+ old.Listen = ":7001"
+ old.APIKey = "sk-old"
+ rawOld := mustMarshalConfig(t, old)
+ if err := os.WriteFile(path, rawOld, 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ // 面板提交新配置(改 listen)。
+ submitted := []byte(`{"listen":":7002"}`)
+ live := livecfg.New(livecfg.Snapshot{})
+ p := pool.New("")
+ defer p.Close()
+ up := upstream.New()
+ sch := scheduler.New(scheduler.Config{Pool: p, Upstream: up})
+ if _, err := saveConfig(submitted, path, live, p, up, sch, nil, dir); err != nil {
+ t.Fatalf("saveConfig 失败: %v", err)
+ }
+
+ // 备份应存在且内容是**旧**的完整配置。
+ backup, err := os.ReadFile(path + ".bak")
+ if err != nil {
+ t.Fatalf("保存后应留下 .bak 备份: %v", err)
+ }
+ if !strings.Contains(string(backup), ":7001") {
+ t.Fatalf("备份应是覆盖前的旧配置,实际: %s", string(backup)[:min(200, len(backup))])
+ }
+ // 新配置已生效。
+ cur, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(string(cur), ":7002") {
+ t.Fatalf("新配置未落盘: %s", string(cur)[:min(200, len(cur))])
+ }
+}
+
+func min(a, b int) int {
+ if a < b {
+ return a
+ }
+ return b
+}
+
+// mustMarshalConfig 把配置序列化成 JSON(测试用)。
+func mustMarshalConfig(t *testing.T, c *Config) []byte {
+ t.Helper()
+ raw, err := json.MarshalIndent(c, "", " ")
+ if err != nil {
+ t.Fatalf("序列化配置失败: %v", err)
+ }
+ return raw
+}
diff --git a/cmd/server/config_save_test.go b/cmd/server/config_save_test.go
new file mode 100644
index 00000000..123ebb40
--- /dev/null
+++ b/cmd/server/config_save_test.go
@@ -0,0 +1,120 @@
+package main
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "reflect"
+ "testing"
+
+ "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"
+)
+
+func TestSaveConfigReportsActualAndPendingRestarts(t *testing.T) {
+ cfg := Default()
+ if err := cfg.normalize(); err != nil {
+ t.Fatal(err)
+ }
+ raw, err := json.Marshal(cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ path := filepath.Join(t.TempDir(), "config.json")
+ if err := os.WriteFile(path, raw, 0600); err != nil {
+ t.Fatal(err)
+ }
+ live := livecfg.New(livecfg.Snapshot{})
+ p := pool.New("")
+ defer p.Close()
+ up := upstream.New()
+ sch := scheduler.New(scheduler.Config{Pool: p, Upstream: up})
+ save := func(body string) []string {
+ t.Helper()
+ fields, err := saveConfig([]byte(body), path, live, p, up, sch, cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return fields
+ }
+ if fields := save(`{"api_key":"changed"}`); len(fields) != 0 {
+ t.Fatalf("hot save restart fields=%v", fields)
+ }
+ if live.Load().APIKey != "changed" {
+ t.Fatal("key was not applied immediately")
+ }
+ if fields := save(`{"listen":":9999"}`); !reflect.DeepEqual(fields, []string{"listen"}) {
+ t.Fatalf("changed fields=%v", fields)
+ }
+ if fields := save(`{"api_key":"second"}`); !reflect.DeepEqual(fields, []string{"listen"}) {
+ t.Fatalf("pending restart disappeared: %v", fields)
+ }
+ if fields := save(`{"listen":":7863"}`); len(fields) != 0 {
+ t.Fatalf("reverted pending fields=%v", fields)
+ }
+}
+
+func TestRestartRequiredIncludesAllAssemblyFields(t *testing.T) {
+ current, err := ParseConfig([]byte(`{}`))
+ if err != nil {
+ t.Fatal(err)
+ }
+ next, err := ParseConfig([]byte(`{"global":{"enabled":false},"prompt":{"mode":"custom"},"upstream":{"user_agent":"new-agent"},"session_sticky":{"enabled":false},"upstash":{"url":"https://example.upstash.io","token":"sample"}}`))
+ if err != nil {
+ t.Fatal(err)
+ }
+ fields := restartRequiredFields(current, next)
+ for _, want := range []string{"global.enabled", "prompt.mode", "upstream.user_agent", "session_sticky.enabled", "upstash.url", "upstash.token"} {
+ found := false
+ for _, field := range fields {
+ found = found || field == want
+ }
+ if !found {
+ t.Fatalf("missing %s in %v", want, fields)
+ }
+ }
+}
+
+func TestSaveConfigRetainsEnvironmentManagedKey(t *testing.T) {
+ t.Setenv("WB2A_API_KEY", "environment-key")
+ path := filepath.Join(t.TempDir(), "config.json")
+ if err := os.WriteFile(path, []byte(`{"api_key":"file-key"}`), 0600); err != nil {
+ t.Fatal(err)
+ }
+ cfg, err := Load(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ live := livecfg.New(livecfg.Snapshot{APIKey: cfg.APIKey})
+ p := pool.New("")
+ defer p.Close()
+ up := upstream.New()
+ sch := scheduler.New(scheduler.Config{Pool: p, Upstream: up})
+ fields, err := saveConfig([]byte(`{"api_key":"submitted-key"}`), path, live, p, up, sch, cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(fields) != 0 || live.Load().APIKey != "environment-key" {
+ t.Fatalf("fields=%v key changed=%v", fields, live.Load().APIKey != "environment-key")
+ }
+}
+
+func TestEnvironmentManagedFieldsMatchesValidOverrides(t *testing.T) {
+ t.Setenv("WB2A_API_KEY", "env-key")
+ t.Setenv("WB2A_SOFT_RATE", "30s")
+ t.Setenv("WB2A_TIMEOUT_SECONDS", "invalid")
+ fields := Default().EnvironmentManagedFields()
+ has := func(want string) bool {
+ for _, field := range fields {
+ if field == want {
+ return true
+ }
+ }
+ return false
+ }
+ if !has("api_key") || !has("cooldown.soft_rate") || has("upstream.timeout_seconds") {
+ t.Fatalf("managed=%v", fields)
+ }
+}
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/logfile_config_test.go b/cmd/server/logfile_config_test.go
new file mode 100644
index 00000000..7051c117
--- /dev/null
+++ b/cmd/server/logfile_config_test.go
@@ -0,0 +1,74 @@
+package main
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+// TestLogFileConfigDefaults 日志落盘默认关闭(不落盘),但体积上限有默认值。
+func TestLogFileConfigDefaults(t *testing.T) {
+ c := Default()
+ if c.Logging.LogFile != "" {
+ t.Fatalf("默认不应落盘,实际: %q", c.Logging.LogFile)
+ }
+ if c.Logging.LogFileMaxMB <= 0 {
+ t.Fatalf("log_file_max_mb 应有默认,实际: %d", c.Logging.LogFileMaxMB)
+ }
+ if c.Logging.LogFileBackups <= 0 {
+ t.Fatalf("log_file_backups 应有默认,实际: %d", c.Logging.LogFileBackups)
+ }
+}
+
+// TestLogFileConfigNormalize 非法值回落默认。
+func TestLogFileConfigNormalize(t *testing.T) {
+ c := Default()
+ c.Logging.LogFileMaxMB = -5
+ c.Logging.LogFileBackups = 0
+ if err := c.normalize(); err != nil {
+ t.Fatalf("normalize: %v", err)
+ }
+ if c.Logging.LogFileMaxMB <= 0 || c.Logging.LogFileBackups <= 0 {
+ t.Fatalf("非法值应回落默认: max=%d backups=%d",
+ c.Logging.LogFileMaxMB, c.Logging.LogFileBackups)
+ }
+}
+
+// TestLogFileConfigSurvivesJSONRoundTrip 落盘路径能从 config.json 读回来。
+func TestLogFileConfigSurvivesJSONRoundTrip(t *testing.T) {
+ raw := `{"listen":":7863","logging":{"log_file":"./data/gateway.log","log_file_max_mb":16,"log_file_backups":5}}`
+ var c Config
+ if err := json.Unmarshal([]byte(raw), &c); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if c.Logging.LogFile != "./data/gateway.log" {
+ t.Fatalf("log_file 读取失败: %q", c.Logging.LogFile)
+ }
+ if c.Logging.LogFileMaxMB != 16 || c.Logging.LogFileBackups != 5 {
+ t.Fatalf("落盘参数读取失败: %d / %d",
+ c.Logging.LogFileMaxMB, c.Logging.LogFileBackups)
+ }
+}
+
+// TestConfigExampleHasLogFileKeys config.example.json 里有这两个键(可发现性)。
+func TestConfigExampleHasLogFileKeys(t *testing.T) {
+ b, err := os.ReadFile(filepath.Join("..", "..", "config.example.json"))
+ if err != nil {
+ t.Fatalf("read config.example.json: %v", err)
+ }
+ var m map[string]any
+ if err := json.Unmarshal(b, &m); err != nil {
+ t.Fatalf("config.example.json 不是合法 JSON: %v", err)
+ }
+ lg, ok := m["logging"].(map[string]any)
+ if !ok {
+ t.Fatal("config.example.json 缺 logging 段")
+ }
+ if _, ok := lg["log_file"]; !ok {
+ t.Fatal("config.example.json 的 logging 段缺 log_file")
+ }
+ if _, ok := lg["log_file_max_mb"]; !ok {
+ t.Fatal("config.example.json 的 logging 段缺 log_file_max_mb")
+ }
+}
diff --git a/cmd/server/main.go b/cmd/server/main.go
index f2056dbc..bbe332a3 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -14,11 +14,16 @@ import (
"os"
"os/signal"
"path/filepath"
+ "reflect"
+ "sync"
"syscall"
"time"
+ "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/logfile"
"github.com/linguo2625469/workbuddy2api-panel/internal/panel"
"github.com/linguo2625469/workbuddy2api-panel/internal/pool"
"github.com/linguo2625469/workbuddy2api-panel/internal/redisstore"
@@ -31,7 +36,11 @@ import (
)
// appVersion 网关版本(fork 版:面板 + 任务体系),透出到 /panel/api/overview。
-const appVersion = "1.13.0-panel"
+//
+// 1.13.1-panel = 本 fork 在 1.13.0 发行版之上的修复版。CI(.github/workflows/go-binaries.yml)
+// 会断言「打 tag 的版本号 == 源码 appVersion(剥掉 -panel 后缀)」,所以发布前必须先改这里,
+// 否则任何 v* tag 都会在发布作业里失败。
+const appVersion = "1.13.1-panel"
// usagePathFor 由 state 文件路径推出用量文件路径:同目录、文件名 usage.json。
// 这样 config 里改 state_file 时用量数据跟着走,不需要额外配置项。
@@ -47,6 +56,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 +130,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,7 +195,20 @@ 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)
+ // 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 按需拉取成功后原子写回。
@@ -225,6 +269,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 +287,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 文件路径推出,避免再加一个配置项。
@@ -262,18 +313,30 @@ 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"))
+
+ // 配置保存串行化(#104):面板并发保存时,read-modify-write 竞态会丢掉先到的
+ // 改动;用互斥锁把整个读改写串成一条。
+ var configSaveMu sync.Mutex
pn := panel.New(panel.Config{
- Pool: p,
- Usage: rec,
- RequestLog: requestLog,
- Upstream: up,
- Scheduler: sch,
- AuthDir: cfg.AuthDir,
- APIKey: cfg.APIKey,
- RedisMode: redisMode,
- StickyCount: sessCount,
- Version: appVersion,
- Live: live,
+ Pool: p,
+ Usage: rec,
+ RequestLog: requestLog,
+ Upstream: up,
+ Scheduler: sch,
+ AutoTasksEnabled: sch.GrowthEnabled,
+ AuthDir: cfg.AuthDir,
+ APIKey: cfg.APIKey,
+ RedisMode: redisMode,
+ StickyCount: sessCount,
+ Version: appVersion,
+ Live: live,
// 模型上限探测数据(scripts/probe_max_tokens.py --panel-out 写入):
// 与 state 文件同目录,缺省 data/output_probes.json。
ProbeFile: stateSibling(cfg.StateFile, "output_probes.json"),
@@ -282,14 +345,48 @@ func main() {
return Load(*cfgPath)
},
SaveConfig: func(raw []byte) ([]string, error) {
- return saveConfig(raw, *cfgPath, live, p, up, sch)
+ configSaveMu.Lock()
+ defer configSaveMu.Unlock()
+ return saveConfig(raw, *cfgPath, live, p, up, sch, cfg, fallbackDirs(cfg.StateFile)...)
},
+ // 积分历史账本(只读展示;写入由上面的余额观察者完成)。
+ CreditHistory: creditHist,
})
- // 成长任务队列每日自动执行(与「执行全部待办」同管线):Sequential 族零点解锁后
- // 无需手动扫描;hook 返回即启动(异步执行),已在跑时内部跳过。
+ if err := pn.Logs().SetTaskArchive(stateSibling(cfg.StateFile, "task-logs.json")); err != nil {
+ log.Printf("WARN: task log archive: %v", err)
+ }
+ // 每日将国区账号交给持久化后台任务;账号内互斥,返回不等待上游操作。
sch.SetGrowthHook(pn.RunGrowthQueueOnce)
- log.SetOutput(io.MultiWriter(os.Stderr, pn.Logs()))
- server.SetChatLogOutput(io.MultiWriter(os.Stdout, pn.Logs()))
+ // 清扫回调返回统计汇总(测试/日志可观测),调度器 hook 只要求 func():
+ // 这里收掉返回值——汇总已由 panel 侧内部日志输出。
+ sch.SetTaskSweepHook(func() { pn.RunTaskSweepOnce() })
+ // 日志落盘(logging.log_file):Windows 上没有控制台窗口时,出问题只能靠猜。
+ // 打不开文件就降级(只 WARN,不让网关起不来)——stderr 仍然有输出。
+ var logFile *logfile.Writer
+ if cfg.Logging.LogFile != "" {
+ lf, err := logfile.New(cfg.Logging.LogFile, cfg.Logging.LogFileMaxMB, cfg.Logging.LogFileBackups)
+ if err != nil {
+ log.Printf("WARN: 日志落盘不可用(仅输出到控制台): %v", err)
+ } else {
+ logFile = lf
+ defer logFile.Close()
+ }
+ }
+ var logWriters []io.Writer
+ logWriters = append(logWriters, os.Stderr, pn.Logs())
+ if logFile != nil {
+ logWriters = append(logWriters, logFile)
+ }
+ log.SetOutput(io.MultiWriter(logWriters...))
+ var chatWriters []io.Writer
+ chatWriters = append(chatWriters, os.Stdout, pn.Logs())
+ if logFile != nil {
+ chatWriters = append(chatWriters, logFile)
+ }
+ server.SetChatLogOutput(io.MultiWriter(chatWriters...))
+ if logFile != nil {
+ log.Printf("INFO: 日志落盘已启用: %s", logFile.Path())
+ }
h := server.NewHandler(server.Config{
Pool: p,
@@ -310,12 +407,24 @@ func main() {
RecordClientInfo: cfg.Logging.RequestClientInfo,
// handler 侧第三道闸(global realm):false(显式逃生门)时不列 global: 模型名。
GlobalEnabled: cfg.Global.Enabled,
+ // 模型黑名单原始条目;运行期以 livecfg 快照为准(热生效),此处供
+ // Live 为 nil 的裸用/测试路径。
+ ModelsHidden: cfg.Models.Hidden,
+ // 零 tool_calls 停摆重试(issue #149):非流式请求声明了 tools 却收到
+ // 「只回正文 + stop 收尾」时换号再问一次。**不罚账号**(上游行为缺陷)。
+ ToolStallRetry: cfg.Upstream.ToolStallRetry,
})
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
+ if err := pn.StartTaskJobs(ctx, stateSibling(cfg.StateFile, "task-jobs.json")); err != nil {
+ log.Fatalf("task jobs: %v", err)
+ }
+ defer pn.StopTaskJobs()
+ pn.ResumeTaskJobs()
go sch.Run(ctx)
sch.StartBalanceRefresh(ctx, cfg.BalanceRefreshInterval)
+ sch.StartTaskSweep(ctx, cfg.TaskSweepInterval)
// 启动即预热模型积分倍率表:倍率只在 FetchModels/FetchGlobalModelInfos 成功时
// 填充(两者均懒触发),重启后到首次 /v1/models 或面板模型页被访问之前,
@@ -419,12 +528,29 @@ 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) {
+// saveConfig 保存面板提交的配置:合并 → 校验 → 原子落盘 → 热应用,
+// 并返回「本次改动中需要重启进程才能生效」的字段名清单。
+//
+// running 是**进程启动时**那份配置(可传 nil):它与新配置做差集,得到真正
+// 需要重启的字段;已改但尚未重启的字段会一直保留在清单里,直到改回原值
+// 或重启(#104)。
+//
+// fallbackDirs 是同目录 tmp 不可写时的暂存目录候选(issue #226 / #134),
+// 交给 atomicfile.Write 逐个尝试。
+func saveConfig(raw []byte, path string, live *livecfg.Holder, p *pool.Pool, up *upstream.Client, sch *scheduler.Scheduler, running *Config, fallbackDirs ...string) ([]string, error) {
// 1) 解析原始 JSON 为 map(保留用户手写的未知键),再叠加面板提交的键。
oldRaw, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read current config: %w", err)
}
+ previous, err := ParseConfig(oldRaw)
+ if err != nil {
+ return nil, fmt.Errorf("parse current config: %w", err)
+ }
+ baseline := previous
+ if running != nil {
+ baseline = running
+ }
var cur, incoming map[string]any
if err := json.Unmarshal(oldRaw, &cur); err != nil {
cur = map[string]any{}
@@ -439,43 +565,27 @@ func saveConfig(raw []byte, path string, live *livecfg.Holder, p *pool.Pool, up
if err != nil {
return nil, err
}
+ // Environment overrides have the same precedence on save as on startup.
+ applyEnv(newCfg)
+ if err := newCfg.normalize(); err != nil {
+ return nil, err
+ }
// 3) 落盘(原子替换)。
out, err := json.MarshalIndent(merged, "", " ")
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。
+ //
+ // 覆盖前先留一份 .bak 备份:降级路径(rename 不可用时的原地覆盖)是
+ // O_TRUNC 覆写、非原子,写一半被打断 ⇒ 磁盘上留下半截 config.json ⇒
+ // 下次启动解析失败、服务起不来。留了备份就能自动回退(见 config.go Load)。
+ backupConfigBeforeWrite(path)
+ if err := atomicfile.Write(path, out, 0o600, fallbackDirs...); err != nil {
+ return nil, err
}
// 4) 热应用:能立即生效的字段全部应用,并列出仍需重启的字段。
@@ -484,6 +594,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 +606,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,36 +619,72 @@ 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
+ return restartRequiredFields(baseline, newCfg), nil
}
-// restartRequiredFields 返回本次改动中无法热生效、需要重启进程的字段名。
-// 恒返回完整清单中的"与当前进程装配期依赖相关"的项——面板据此提示用户。
-func restartRequiredFields(c *Config) []string {
- var out []string
- // 这些字段在进程内被监听地址/HTTP client/目录句柄等装配期对象捕获。
- if c.Listen != "" {
- out = append(out, "listen")
- }
- if c.AuthDir != "" {
- out = append(out, "auth_dir")
+// applyCNChatBase 把 config cn.chat_base 装配进上游 client;空则保留内置默认,
+// 老配置(无 cn 段)行为不变。
+func applyCNChatBase(up *upstream.Client, cfg *Config) {
+ if cfg.CN.ChatBase != "" {
+ up.ChatBaseCN = cfg.CN.ChatBase
}
- if c.StateFile != "" {
- out = append(out, "state_file")
+}
+
+// restartRequiredFields 比较运行中装配值与新配置,也保留此前尚未重启的改动。
+//
+// current = 进程启动时那份配置(baseline);next = 本次保存后的新配置。
+// 两者不同的字段即「改了但热生效不了、必须重启」的字段。
+//
+// 恒列出的字段(无论改没改都提示):这些字段在进程内被监听地址 / HTTP client /
+// 目录句柄等**装配期对象**捕获,保存后不会自动生效——面板据此提示用户重启。
+// changed() 只负责「新增的」需要重启的字段;恒列字段另外无条件追加。
+func restartRequiredFields(current, next *Config) []string {
+ var out []string
+ changed := func(name string, before, after any) {
+ if !reflect.DeepEqual(before, after) {
+ out = append(out, name)
+ }
}
- out = append(out, "upstream.timeout_seconds", "upstream.header_timeout_seconds", "upstream.idle_timeout_seconds")
+ changed("listen", current.Listen, next.Listen)
+ changed("auth_dir", current.AuthDir, next.AuthDir)
+ changed("state_file", current.StateFile, next.StateFile)
+ changed("upstream.timeout_seconds", current.Upstream.TimeoutSeconds, next.Upstream.TimeoutSeconds)
+ changed("upstream.header_timeout_seconds", current.Upstream.HeaderTimeoutSeconds, next.Upstream.HeaderTimeoutSeconds)
+ changed("upstream.idle_timeout_seconds", current.Upstream.IdleTimeoutSeconds, next.Upstream.IdleTimeoutSeconds)
// upstream.user_agent 在装配期被写进出站 client(main.go 的 up.UserAgent = ...),
// 之后不再读取——不在 livecfg 热快照里,也无法热改。此前漏列,导致面板改完
// 显示"已保存"却不提示需要重启,用户以为没生效(issue #102 附带发现 2)。
- out = append(out, "upstream.user_agent")
- if c.Upstash.URL != "" || c.Upstash.Token != "" {
- out = append(out, "upstash")
- }
- out = append(out, "session_sticky.ttl", "session_sticky.gc_interval")
- out = append(out, "logging.request_archive_enabled", "logging.request_retention_days", "logging.request_archive_max_mb")
- out = append(out, "server.read_timeout")
+ changed("upstream.user_agent", current.Upstream.UserAgent, next.Upstream.UserAgent)
+ changed("upstream.client_version", current.Upstream.ClientVersion, next.Upstream.ClientVersion)
+ changed("upstream.cli_version", current.Upstream.CliVersion, next.Upstream.CliVersion)
+ changed("upstream.client_name", current.Upstream.ClientName, next.Upstream.ClientName)
+ changed("upstream.device_token", current.Upstream.DeviceToken, next.Upstream.DeviceToken)
+ changed("upstream.device_token_file", current.Upstream.DeviceTokenFile, next.Upstream.DeviceTokenFile)
+ changed("upstream.passthrough_ip", current.Upstream.PassthroughIP, next.Upstream.PassthroughIP)
+ // 零 tool_calls 停摆重试(issue #149):装配期读进 server.Config,进程内不再
+ // 读取——改了必须重启才生效(漏列会让面板显示「已保存」却不生效)。
+ changed("upstream.tool_stall_retry", current.Upstream.ToolStallRetry, next.Upstream.ToolStallRetry)
+ changed("global.enabled", current.Global.Enabled, next.Global.Enabled)
+ changed("global.chat_base", current.Global.ChatBase, next.Global.ChatBase)
+ changed("global.billing_base", current.Global.BillingBase, next.Global.BillingBase)
+ changed("prompt.mode", current.Prompt.Mode, next.Prompt.Mode)
+ changed("prompt.file", current.Prompt.File, next.Prompt.File)
+ changed("prompt.text", current.PromptText, next.PromptText)
+ changed("upstash.url", current.Upstash.URL, next.Upstash.URL)
+ changed("upstash.token", current.Upstash.Token, next.Upstash.Token)
+ changed("session_sticky.enabled", current.SessionSticky.Enabled, next.SessionSticky.Enabled)
+ changed("session_sticky.ttl", current.SessionTTL, next.SessionTTL)
+ changed("session_sticky.gc_interval", current.SessionGCInterval, next.SessionGCInterval)
+ changed("logging.request_archive_enabled", current.Logging.RequestArchiveEnabled, next.Logging.RequestArchiveEnabled)
+ changed("logging.request_retention_days", current.Logging.RequestRetentionDays, next.Logging.RequestRetentionDays)
+ changed("logging.request_archive_max_mb", current.Logging.RequestArchiveMaxMB, next.Logging.RequestArchiveMaxMB)
+ // cn.chat_base 同样只在装配期写进 up.ChatBaseCN(请求路径并发读取,不做热改)。
+ changed("cn.chat_base", current.CN.ChatBase, next.CN.ChatBase)
+ // server.read_timeout 写进 http.Server.ReadTimeout(装配期)。
+ changed("server.read_timeout", current.Server.ReadTimeout, next.Server.ReadTimeout)
return out
}
diff --git a/cmd/server/strategy_config_test.go b/cmd/server/strategy_config_test.go
new file mode 100644
index 00000000..0038b1f0
--- /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, nil)
+ 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, nil)
+ 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..832a8117 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"
},
@@ -13,7 +14,11 @@
"request_archive_enabled": true,
"request_retention_days": 7,
"request_archive_max_mb": 100,
- "request_client_info": true
+ "request_client_info": true,
+ "_comment_log_file": "运行日志落盘路径。留空 = 只输出到控制台(不落盘)。Windows 上没有控制台窗口时,排查问题只能靠猜;配上这个开关就能直接翻日志文件。相对路径按进程工作目录解析,父目录会自动创建。改动需重启。",
+ "log_file": "",
+ "log_file_max_mb": 64,
+ "log_file_backups": 3
},
"cooldown": {
"soft_rate": "600s",
@@ -41,6 +46,9 @@
"chat_base": "",
"billing_base": ""
},
+ "cn": {
+ "chat_base": ""
+ },
"upstream": {
"timeout_seconds": 120,
"header_timeout_seconds": 120,
@@ -51,11 +59,17 @@
"client_name": "",
"device_token": "",
"device_token_file": "",
- "passthrough_ip": false
+ "passthrough_ip": false,
+ "_comment_tool_stall": "tool_stall_retry: 非流式请求声明了 tools 却收到「只回正文 + stop 收尾」时的自动重试次数(issue #149)。0 = 关闭重试(仍记录观测)。重试换号进行,不罚账号(上游行为缺陷,不是账号问题)。缺省 1。",
+ "tool_stall_retry": 1
},
"features": {
"sanitize_blacklist_fingerprints": true
},
+ "models": {
+ "_comment": "hidden: 不对外暴露的模型黑名单。裸名=两域都藏;\"cn:xxx\"/\"global:xxx\"=只藏该域。/v1/models 列表过滤 + chat 点名拒绝,面板保存后热生效。",
+ "hidden": []
+ },
"prompt": {
"mode": "passthrough",
"file": ""
@@ -65,6 +79,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 +100,13 @@
"prefer_expiring": true,
"expiring_soon": "168h",
"cost_explore_interval": "30m",
- "credit_floor": 100
+ "credit_floor": 100,
+ "cross_domain_fallback": false,
+ "_comment_burn_guard": "积分燃尽熔断(issue #229):窗口内单账号累计烧掉的积分超过 burn_guard_limit → 该账号熔断冷却(避免把还在烧钱的号继续派单)。0 = 关闭。",
+ "burn_guard_limit": 0,
+ "burn_guard_window": "10m",
+ "burn_guard_cooldown": "10m",
+ "burn_guard_cooldown_max": "2h"
},
"session_sticky": {
"enabled": true,
diff --git a/go.mod b/go.mod
index df581450..ae1d6385 100644
--- a/go.mod
+++ b/go.mod
@@ -2,10 +2,11 @@ module github.com/linguo2625469/workbuddy2api-panel
go 1.22.5
+require github.com/redis/go-redis/v9 v9.18.0
+
require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
- github.com/redis/go-redis/v9 v9.18.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/sys v0.30.0 // indirect
)
diff --git a/go.sum b/go.sum
index ca30962c..7c7f5fdd 100644
--- a/go.sum
+++ b/go.sum
@@ -1,11 +1,18 @@
+github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
+github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
+github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0=
github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
diff --git a/internal/atomicfile/atomicfile.go b/internal/atomicfile/atomicfile.go
new file mode 100644
index 00000000..78424c9a
--- /dev/null
+++ b/internal/atomicfile/atomicfile.go
@@ -0,0 +1,234 @@
+// 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 (
+ "crypto/rand"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "syscall"
+ "time"
+)
+
+// stagingName 决定暂存文件名。默认是 tmpName(唯一名)。
+//
+// 抽成变量是为了让测试能观测「每次写用了哪个暂存名」——并发写的核心不变量
+// 就是「不同写者不得共用同一个暂存名」。仅测试使用,生产路径恒为 tmpName。
+var stagingName = tmpName
+
+// Write 把 data 写入 path。
+//
+// mode 为**新建**文件时的权限位(已存在的文件保持原权限)。
+// fallbackDirs 是同目录 tmp 不可写时依次尝试的暂存目录(可为 nil)。
+// 返回 nil 表示 path 已包含 data 的全部内容。
+//
+// 失败时返回的错误里会带上可操作信息(哪个路径不可写、完整新内容留在哪)。
+func Write(path string, data []byte, mode os.FileMode, fallbackDirs ...string) error {
+ dir := filepath.Dir(path)
+ base := filepath.Base(path)
+
+ primary, nameErr := stagingName(dir, base)
+ primaryErr := nameErr
+ if nameErr == nil {
+ primaryErr = os.WriteFile(primary, data, mode)
+ }
+ if primaryErr == nil {
+ // 正常路径:同目录 tmp + rename(原子)。
+ //
+ // rename 可能因为**另一个写者正在替换同一目标**而失败 —— Windows 上
+ // MoveFileEx(..., MOVEFILE_REPLACE_EXISTING) 在目标文件被其它句柄占用
+ // 时返回 ERROR_ACCESS_DENIED(5) / ERROR_SHARING_VIOLATION(32),
+ // 这在「两个写者并发写同一文件」时是**必然会撞上**的(不是罕见故障)。
+ // 此时退化为原地覆盖往往也会失败(目标正被另一个句柄独占),
+ // 于是并发写会**偶发失败**(Windows CI 实测:60 轮里第 5 轮就挂)。
+ //
+ // 正确做法:这类「目标侧临时占用」的错误**重试几次**(每次间隔很短),
+ // 让另一个写者先完成。重试仍失败才降级原地覆盖。
+ renameErr := retryRename(primary, path)
+ if renameErr == nil {
+ return nil
+ }
+ // rename 失败(EXDEV 跨设备 / EBUSY 单文件挂载点 / 目标被占用…):
+ // 退化为原地覆盖;tmp 里已是完整新内容,失败时留在原地供恢复。
+ //
+ // 注意错误信息里**必须带上 rename 的原始原因**:否则像 Windows
+ // 「源文件被独占」这类真实原因会被原地覆盖的次生错误盖住
+ //(实测踩过:rename 因共享冲突失败 → 原地覆盖因目标尚不存在
+ // 报 ERROR_FILE_NOT_FOUND → 排查时看到的是一个完全误导的错误)。
+ if werr := writeInPlace(path, data); werr == nil {
+ _ = os.Remove(primary)
+ return nil
+ } else {
+ return fmt.Errorf("replace %s: rename 失败(%v),原地覆盖也失败: %w(完整新内容保留在 %s)", path, renameErr, 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, err := stagingName(dir, base)
+ if err != nil {
+ continue
+ }
+ 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", path, primaryErr)
+ if primary != "" {
+ msg += fmt.Sprintf("(临时文件 %s 无法创建:所在目录不可写)", 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)
+}
+
+// renameRetryAttempts / renameRetryDelay 是 rename 因「目标侧临时占用」而失败时的
+// 重试次数与间隔。
+//
+// 为什么需要重试:Windows 上 MoveFileEx(..., MOVEFILE_REPLACE_EXISTING) 在目标
+// 文件**正被另一个句柄打开**时返回 ERROR_ACCESS_DENIED(5) /
+// ERROR_SHARING_VIOLATION(32)。两个写者并发替换同一目标时,这必然会撞上
+// (不是罕见故障)——另一个写者的 rename 窗口只有几微秒,但重试机制是让
+// 双方最终都成功的唯一办法:
+//
+// 写者 A: rename(tmpA, target) ← 成功
+// 写者 B: rename(tmpB, target) ← 撞上 A 的窗口 → ERROR_ACCESS_DENIED
+//
+// 不重试时 B 的原地覆盖也会失败(目标正被 A 的句柄独占),结果就是
+// 「一次本该成功的并发写变成失败」(Windows CI 实测:60 轮里第 5 轮挂)。
+const (
+ renameRetryAttempts = 40
+ renameRetryDelay = 5 * time.Millisecond
+)
+
+// retryRename 执行 rename,遇到「目标侧临时占用」类错误时短暂重试。
+//
+// 返回 nil 表示成功;否则返回**最后一次**的错误(供调用方决定是否降级)。
+func retryRename(tmp, path string) error {
+ var err error
+ for i := 0; i < renameRetryAttempts; i++ {
+ err = os.Rename(tmp, path)
+ if err == nil {
+ return nil
+ }
+ if !isTargetBusyErr(err) {
+ return err // 跨设备 / 挂载点等,重试没有意义
+ }
+ time.Sleep(renameRetryDelay)
+ }
+ return err
+}
+
+// tmpName 为暂存文件生成一个**唯一**名字(放在 dir 下)。
+// 为什么不能用固定的 path+".tmp":两个并发写同一目标会互相踩——
+// 1. 后写者的 os.WriteFile 会把前写者**已暂存好的内容截断重写**,
+// 前写者随后 rename,落盘的是后写者的内容(甚至两者的混合);
+// 2. 前写者 rename 成功后暂存文件消失,后写者 rename 报「源文件不存在」;
+// 3. Windows 上更糟:文件正被另一个进程打开时 MoveFileEx 会因共享冲突失败
+// (ERROR_SHARING_VIOLATION / ERROR_ACCESS_DENIED),
+// 随后的原地覆盖又因「目标文件尚不存在」报 ERROR_FILE_NOT_FOUND
+// → 一次本该成功的并发写变成失败。
+//
+// 唯一名字让每个写者只操作自己的暂存文件,rename 仍然是同文件系统内的
+// 原子替换,互不干扰。
+//
+// 代价:进程被强杀可能留下形如 config.json.tmp-<16位十六进制> 的残留文件。
+// 这是「宁可留下可识别的残留,也不写坏主文件」的取舍。
+func tmpName(dir, base string) (string, error) {
+ var b [8]byte
+ for attempt := 0; attempt < 4; attempt++ {
+ if _, err := rand.Read(b[:]); err != nil {
+ return "", err
+ }
+ name := filepath.Join(dir, base+".tmp-"+hex.EncodeToString(b[:]))
+ if _, err := os.Lstat(name); os.IsNotExist(err) {
+ return name, nil
+ }
+ }
+ return "", fmt.Errorf("无法为 %s 在 %s 生成唯一暂存文件名", base, dir)
+}
+
+// writeInPlace 原地覆盖目标文件(不新建)。用于「rename 不可用」的降级路径:
+// 单文件 bind mount(EBUSY)与跨设备(EXDEV)。
+//
+// 注意:目标文件**可能还不存在**(例如并发写同一个新文件时,
+// 另一个写者已把目标 rename 走、或目标本来就是新建的)。
+// Windows 上「以 O_WRONLY|O_TRUNC 打开一个不存在的文件」报
+// ERROR_FILE_NOT_FOUND → 必须用 O_CREATE 补上,否则一次本该成功的写
+// 会以「系统找不到指定的文件」失败。
+func writeInPlace(path string, data []byte) error {
+ f, err := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC|os.O_CREATE, 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 挂载点)。
+//
+// ⚠️ **这是 POSIX 口径**。Windows 上 rename 的失败原因是 ERROR_NOT_SAME_DEVICE
+// (跨卷)与 ERROR_ACCESS_DENIED(目标被占用/只读),而 Windows 的
+// syscall.EXDEV 是臆造占位值(0x20000000,永远匹配不上真实 errno)
+// → 这个函数在 Windows 上**认不出**跨卷与占用两种情况。
+// **跨平台的调用方请用包内的 CanFallbackToInPlace**
+// (见 atomicfile_windows.go / atomicfile_other.go),它按平台补齐了错误码。
+func IsReplaceableErr(err error) bool {
+ return errors.Is(err, syscall.EXDEV) || errors.Is(err, syscall.EBUSY)
+}
diff --git a/internal/atomicfile/atomicfile_other.go b/internal/atomicfile/atomicfile_other.go
new file mode 100644
index 00000000..057b5da3
--- /dev/null
+++ b/internal/atomicfile/atomicfile_other.go
@@ -0,0 +1,22 @@
+//go:build !windows
+
+package atomicfile
+
+// CanFallbackToInPlace 报告 rename 失败是否属于「应当降级为原地覆盖」。
+//
+// 非 Windows 平台:只有 EXDEV(跨设备)与 EBUSY(单文件 bind mount 挂载点)。
+func CanFallbackToInPlace(err error) bool {
+ if err == nil {
+ return false
+ }
+ return IsReplaceableErr(err)
+}
+
+// isTargetBusyErr 报告 rename 失败是否属于「**目标侧临时占用**」(值得重试)。
+//
+// 非 Windows 平台:POSIX 的 rename(2) 在目标被其它进程打开时**不会**失败
+// (它只要求目录可写),失败只来自 EXDEV(跨设备)与 EBUSY(单文件挂载点)
+// —— 这两个都是**结构性问题**,重试没有意义。
+//
+// 因此非 Windows 上恒返回 false:retryRename 不会空转,行为与从前完全一致。
+func isTargetBusyErr(err error) bool { return false }
diff --git a/internal/atomicfile/atomicfile_retry_test.go b/internal/atomicfile/atomicfile_retry_test.go
new file mode 100644
index 00000000..bfbddfdc
--- /dev/null
+++ b/internal/atomicfile/atomicfile_retry_test.go
@@ -0,0 +1,83 @@
+package atomicfile
+
+import (
+ "os"
+ "path/filepath"
+ "sync"
+ "testing"
+)
+
+// TestWriteConcurrentSameTarget 多写者并发替换**同一**目标时,每次写都必须成功,
+// 且落盘内容必须是某一个写者的**完整**内容。
+//
+// Windows CI 实测过这个坑:MoveFileEx(..., MOVEFILE_REPLACE_EXISTING) 在目标
+// 文件正被另一个句柄打开时返回 ERROR_ACCESS_DENIED(5) /
+// ERROR_SHARING_VIOLATION(32) ⇒ 并发写**偶发失败**(60 轮里第 5 轮就挂)。
+//
+// 不重试时:rename 撞上另一个写者的窗口 → 失败 → 原地覆盖也失败(目标正被
+// 独占)⇒ 一次本该成功的写变成失败。修法是 retryRename 对「目标侧临时占用」
+// 短暂重试。
+//
+// 非 Windows 平台上 POSIX rename(2) 不会因目标被打开而失败,所以这个用例在
+// Linux 上是回归基线;在 Windows 上是真实回归。
+func TestWriteConcurrentSameTarget(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "config.json")
+ // 先落一个基线文件(后续 rename 覆盖的是已存在的目标)。
+ if err := Write(path, []byte("baseline"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ const rounds = 40
+ for i := 0; i < rounds; i++ {
+ payload := []byte("payload-" + string(rune('A'+i%26)) + "-0123456789")
+ var wg sync.WaitGroup
+ errs := make([]error, 2)
+ wg.Add(2)
+ go func() { defer wg.Done(); errs[0] = Write(path, payload, 0o600) }()
+ go func() { defer wg.Done(); errs[1] = Write(path, payload, 0o600) }()
+ wg.Wait()
+ for j, err := range errs {
+ if err != nil {
+ t.Fatalf("round %d writer %d: 并发写失败: %v", i, j, err)
+ }
+ }
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("round %d: 读回失败: %v", i, err)
+ }
+ if string(raw) != string(payload) {
+ t.Fatalf("round %d: 落盘内容被写坏: %q want %q", i, string(raw), string(payload))
+ }
+ }
+}
+
+// TestWriteConcurrentNoStagingLeftover 并发写结束后不得残留暂存文件
+// (暂存文件里有明文 token)。
+func TestWriteConcurrentNoStagingLeftover(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "config.json")
+ if err := Write(path, []byte("baseline"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ var wg sync.WaitGroup
+ for i := 0; i < 4; i++ {
+ wg.Add(1)
+ go func(n int) {
+ defer wg.Done()
+ _ = Write(path, []byte("payload"), 0o600)
+ }(i)
+ }
+ wg.Wait()
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(entries) != 1 {
+ var names []string
+ for _, e := range entries {
+ names = append(names, e.Name())
+ }
+ t.Errorf("目录里应只剩目标文件,实际=%v", names)
+ }
+}
diff --git a/internal/atomicfile/atomicfile_test.go b/internal/atomicfile/atomicfile_test.go
new file mode 100644
index 00000000..fdbe2bac
--- /dev/null
+++ b/internal/atomicfile/atomicfile_test.go
@@ -0,0 +1,415 @@
+package atomicfile
+
+import (
+ "errors"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "syscall"
+ "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")
+ }
+ // 暂存文件用的是**唯一**名(config.json.tmp-),正常完成后一个都不该留下。
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, e := range entries {
+ if e.Name() != "config.json" {
+ t.Errorf("暂存文件不该残留:%q", e.Name())
+ }
+ }
+}
+
+// TestWriteFallbackDirWhenPrimaryDirUnwritable 复现 issue #226/#134 的核心现场:
+// 目标文件所在目录不可写(单文件 bind mount + 非属主 uid),但 fallback 目录可写。
+// 此时必须落盘成功,且内容完整。
+//
+// 平台说明:Windows 的 os.Chmod 只能改「只读属性」,没有目录写保护语义
+// → chmod(0o555) 之后目录照样可写 → 本用例在 Windows 上退化成「正常写入」,
+// 断言仍成立但覆盖不到 fallback 路径(fallback 路径由
+// TestWriteTempDirFallback 与 Windows 专项用例覆盖)。故 Windows 上直接跳过,
+// 避免出现「假通过且看不出在测什么」的情况。
+func TestWriteFallbackDirWhenPrimaryDirUnwritable(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("Windows 没有目录写权限语义,无法构造「目录不可写」现场")
+ }
+ 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) {
+ if runtime.GOOS == "windows" {
+ t.Skip("Windows 没有目录写权限语义,无法构造「目录不可写」现场")
+ }
+ 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")
+ }
+ // 错误信息必须点名不可写的暂存路径(让用户知道该给哪个目录写权限)。
+ // 暂存名现在是唯一的(config.json.tmp-),所以只能校验前缀。
+ if !strings.Contains(err.Error(), filepath.Join(roDir, "config.json.tmp-")) {
+ t.Fatalf("error should name the unwritable tmp path, got: %v", err)
+ }
+}
+
+// TestWriteTempDirFallback 系统临时目录兜底:同目录不可写、无显式 fallback 时,
+// 仍应通过 os.TempDir() 完成落盘。
+func TestWriteTempDirFallback(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("Windows 没有目录写权限语义,无法构造「目录不可写」现场")
+ }
+ 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)
+ }
+}
+
+// TestCanFallbackToInPlaceRecognisesWindowsErrno 锁定「按平台补齐错误码」这一行为。
+//
+// 回归背景(Windows 10/11 + Go 1.22+ 实测):MoveFileEx(..., MOVEFILE_REPLACE_EXISTING)
+// 在目标文件被占用时返回 ERROR_ACCESS_DENIED(5),跨卷时返回 ERROR_NOT_SAME_DEVICE(17)。
+// Windows 的 syscall.EXDEV 是臆造占位值(0x20000000),errors.Is 永远匹配不上
+// → 旧判据(只看 EXDEV/EBUSY)在 Windows 上认不出这两种情况
+// → auth.SaveAtomic 不降级、直接报错 → **刷新后的 accessToken 不落盘**。
+//
+// 本用例必须在 Windows 上跑(POSIX 上没有这些错误码语义)。
+func TestCanFallbackToInPlaceRecognisesWindowsErrno(t *testing.T) {
+ if runtime.GOOS != "windows" {
+ t.Skip("Windows 专属错误码语义")
+ }
+ // 跨卷 rename 的错误码(Windows 独有,Go 的 syscall 没有为它定义常量)。
+ notSameDevice := syscall.Errno(17)
+ if !CanFallbackToInPlace(notSameDevice) {
+ t.Errorf("跨卷错误码 ERROR_NOT_SAME_DEVICE(17) 必须判为「可降级原地覆盖」,否则 Windows 跨卷时刷新令牌不落盘")
+ }
+ // 目标被占用 / 只读。
+ accessDenied := syscall.Errno(5)
+ if !CanFallbackToInPlace(accessDenied) {
+ t.Errorf("ERROR_ACCESS_DENIED(5) 必须判为「可降级原地覆盖」,否则 Windows 上文件被占用时刷新令牌不落盘")
+ }
+ // fs.ErrPermission(Go 把 ERROR_ACCESS_DENIED 映射成它)。
+ if !CanFallbackToInPlace(fs.ErrPermission) {
+ t.Errorf("fs.ErrPermission 必须判为「可降级原地覆盖」")
+ }
+ // POSIX 语义仍然有效。
+ if !CanFallbackToInPlace(syscall.EXDEV) {
+ t.Errorf("EXDEV 必须判为「可降级原地覆盖」")
+ }
+ if !CanFallbackToInPlace(syscall.EBUSY) {
+ t.Errorf("EBUSY 必须判为「可降级原地覆盖」")
+ }
+ // 无关错误不能降级(否则会掩盖真正的故障)。
+ if CanFallbackToInPlace(nil) {
+ t.Errorf("nil 不是错误,不应判为可降级")
+ }
+ if CanFallbackToInPlace(errors.New("boom")) {
+ t.Errorf("未知错误不应判为可降级")
+ }
+ if CanFallbackToInPlace(syscall.ENOENT) {
+ t.Errorf("ENOENT 不应判为可降级(目标不存在是另一类问题)")
+ }
+}
+
+// TestCanFallbackToInPlaceRejectsUnrelatedErrors 在所有平台都跑:确保判据不会
+// 把无关错误误判为「可降级」(那会掩盖真正的故障)。
+func TestCanFallbackToInPlaceRejectsUnrelatedErrors(t *testing.T) {
+ if CanFallbackToInPlace(nil) {
+ t.Errorf("nil 不是错误,不应判为可降级")
+ }
+ if CanFallbackToInPlace(errors.New("boom")) {
+ t.Errorf("未知错误不应判为可降级")
+ }
+ if CanFallbackToInPlace(syscall.ENOENT) {
+ t.Errorf("ENOENT 不应判为可降级(目标不存在是另一类问题)")
+ }
+}
+
+// TestCanFallbackToInPlaceMatchesIsReplaceableErrOnPosix 在非 Windows 平台保证
+// 两个判据等价,避免平台分叉带来行为漂移。
+//
+// 注意:Windows 上两者**故意不等价** —— IsReplaceableErr 只认 POSIX 的
+// EXDEV/EBUSY,而 CanFallbackToInPlace 还要认 Windows 独有的
+// ERROR_NOT_SAME_DEVICE / ERROR_ACCESS_DENIED / fs.ErrPermission。
+func TestCanFallbackToInPlaceMatchesIsReplaceableErrOnPosix(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("Windows 上两者故意不等价(Windows 独有错误码),见 atomicfile_windows.go")
+ }
+ for _, err := range []error{syscall.EXDEV, syscall.EBUSY, syscall.EACCES, syscall.ENOENT, errors.New("x")} {
+ if got, want := CanFallbackToInPlace(err), IsReplaceableErr(err); got != want {
+ t.Errorf("POSIX 上 CanFallbackToInPlace(%v)=%v,应与 IsReplaceableErr=%v 一致", err, got, want)
+ }
+ }
+}
+
+// TestWriteConcurrentSamePath 验证并发写同一路径不会互相污染内容。
+// 两个 goroutine 各写不同内容,最终内容必须是其中一个的完整值(不能是混合体)。
+//
+// 回归背景(Windows CI 首次跑出来的真 bug):暂存文件曾用固定的
+// path+".tmp" → 两个写者共用同一个暂存名:
+// - 后写者 WriteFile 会截断重写前写者已备好的内容 → 前写者随后 rename
+// 落盘的是**后写者的内容**(静默数据错乱,不是报错);
+// - 前写者 rename 成功后暂存文件消失 → 后写者 rename 报「源文件不存在」;
+// - Windows 上更糟:文件正被另一个进程打开时 MoveFileEx 因共享冲突失败
+// (ERROR_SHARING_VIOLATION)→ 随后的原地覆盖又因目标尚不存在报
+// ERROR_FILE_NOT_FOUND → 一次本该成功的并发写变成失败。
+//
+// 修法:每个写者用**唯一**暂存名(config.json.tmp-<16位十六进制>)。
+func TestWriteConcurrentSamePath(t *testing.T) {
+ dir := t.TempDir()
+ p := filepath.Join(dir, "config.json")
+ a := []byte("aaaaaaaaaaaaaaaaaaaaaaaa")
+ b := []byte("bbbbbbbbbbbbbbbbbbbbbbbb")
+
+ errCh := make(chan error, 2)
+ go func() { errCh <- Write(p, a, 0o600) }()
+ go func() { errCh <- Write(p, b, 0o600) }()
+ for i := 0; i < 2; i++ {
+ if err := <-errCh; err != nil {
+ t.Fatalf("并发 Write: %v", err)
+ }
+ }
+ raw, err := os.ReadFile(p)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(raw) != string(a) && string(raw) != string(b) {
+ t.Fatalf("并发写后内容 = %q,应是 a 或 b 的完整值(不能是混合)", string(raw))
+ }
+}
+
+// TestWriteConcurrentSamePathNoLeftoverTmp 并发写不得留下暂存残留。
+//
+// 唯一暂存名意味着「进程被强杀」会留下 config.json.tmp- 残留;
+// 但**正常完成**的写必须把自己的暂存文件清干净(rename 走掉,或
+// 降级路径里 os.Remove 掉)——否则每次保存配置都会攒一个垃圾文件。
+func TestWriteConcurrentSamePathNoLeftoverTmp(t *testing.T) {
+ dir := t.TempDir()
+ p := filepath.Join(dir, "config.json")
+
+ errCh := make(chan error, 2)
+ go func() { errCh <- Write(p, []byte("a"), 0o600) }()
+ go func() { errCh <- Write(p, []byte("b"), 0o600) }()
+ for i := 0; i < 2; i++ {
+ if err := <-errCh; err != nil {
+ t.Fatalf("并发 Write: %v", err)
+ }
+ }
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, e := range entries {
+ if e.Name() != "config.json" {
+ t.Errorf("并发写后残留了 %q(暂存文件应被 rename 走或清理掉)", e.Name())
+ }
+ }
+}
+
+// TestWriteUniqueTmpName 锁定暂存文件命名规则:与目标同目录、基名相同、
+// 带 .tmp- 前缀与随机后缀,且两次调用**不同名**(不共享)。
+func TestWriteUniqueTmpName(t *testing.T) {
+ dir := t.TempDir()
+ a, err := tmpName(dir, "config.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ b, err := tmpName(dir, "config.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if a == b {
+ t.Errorf("两次 tmpName 必须不同,否则并发写会互相踩:%q", a)
+ }
+ for _, n := range []string{a, b} {
+ if filepath.Dir(n) != dir {
+ t.Errorf("暂存文件必须与目标同目录(保证 rename 同文件系统、原子):got %q want dir %q", filepath.Dir(n), dir)
+ }
+ if !strings.HasPrefix(filepath.Base(n), "config.json.tmp-") {
+ t.Errorf("暂存文件名应以 config.json.tmp- 开头,got %q", filepath.Base(n))
+ }
+ }
+}
+
+// TestWriteInPlaceCreatesMissingTarget 原地覆盖降级路径必须能**新建**目标文件。
+//
+// 现场:rename 失败(跨设备 / 目标被占用)时降级为原地覆盖;若目标文件此时
+// **根本不存在**(首次写入、或并发写者已把目标 rename 走),以
+// O_WRONLY|O_TRUNC 打开会报 ENOENT(Windows: ERROR_FILE_NOT_FOUND
+// 「系统找不到指定的文件」)→ 一次本该成功的写变成失败。
+//
+// 这正是 Windows CI 首跑抓到的那个 bug 的第二段。
+func TestWriteInPlaceCreatesMissingTarget(t *testing.T) {
+ dir := t.TempDir()
+ p := filepath.Join(dir, "brand-new.json")
+ if err := writeInPlace(p, []byte("hello")); err != nil {
+ t.Fatalf("writeInPlace 必须能新建目标文件: %v", err)
+ }
+ got, err := os.ReadFile(p)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(got) != "hello" {
+ t.Fatalf("content=%q want hello", got)
+ }
+}
+
+// TestWriteUsesUniqueStagingNamePerWrite 锁定「**每次写**都用自己的暂存名」。
+//
+// 这条不变量就是并发写的全部要害:共用暂存名时,后写者会把前写者已备好的
+// 内容截断重写 → 前写者随后 rename 落盘的是后写者的内容(静默数据错乱),
+// 或前写者 rename 成功后暂存文件消失 → 后写者 rename 报「源文件不存在」。
+//
+// 为什么用 hook 而不是靠并发压测撞概率:竞态窗口极窄(Linux 上 4 写者 ×
+// 20 轮 × 200KB 负载都撞不出来),靠概率的回归测试等于没有测试。
+// 这里直接把「生成暂存名」这一步换成一个记录调用、且**故意每次都返回同���**
+// 名」的桩 —— 若实现退回到共用暂存名,用例立刻失败。
+func TestWriteUsesUniqueStagingNamePerWrite(t *testing.T) {
+ dir := t.TempDir()
+ p := filepath.Join(dir, "config.json")
+
+ orig := stagingName
+ t.Cleanup(func() { stagingName = orig })
+
+ var names []string
+ stagingName = func(d, b string) (string, error) {
+ // 故意每次都返回**同一个**名字 —— 模拟「退回到固定 path+".tmp"」。
+ n := filepath.Join(d, b+".tmp")
+ names = append(names, n)
+ return n, nil
+ }
+
+ if err := Write(p, []byte("x"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if len(names) != 1 {
+ t.Fatalf("stagingName 应被调用一次,实际 %d 次", len(names))
+ }
+ // 现在把桩改成「每次返回不同名」,验证实现确实**依赖**桩返回的名字。
+ // 若实现内部写死了 path+".tmp",那么第二次调用的名字不会出现在实际使用的
+ // 暂存路径上 —— 通过观测 os.Stat 找不到该文件来判定。
+ unique := filepath.Join(dir, "config.json.tmp-unique")
+ used := false
+ stagingName = func(d, b string) (string, error) {
+ used = true
+ return unique, nil
+ }
+ p2 := filepath.Join(dir, "other.json")
+ if err := Write(p2, []byte("y"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if !used {
+ t.Fatalf("Write 必须走 stagingName 生成暂存名")
+ }
+ // 暂存名必须被真正用上:rename 走掉后原路径不存在,但**内容**已落盘。
+ raw, err := os.ReadFile(p2)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(raw) != "y" {
+ t.Fatalf("content=%q want y", raw)
+ }
+}
diff --git a/internal/atomicfile/atomicfile_windows.go b/internal/atomicfile/atomicfile_windows.go
new file mode 100644
index 00000000..67701938
--- /dev/null
+++ b/internal/atomicfile/atomicfile_windows.go
@@ -0,0 +1,87 @@
+//go:build windows
+
+package atomicfile
+
+import (
+ "errors"
+ "io/fs"
+ "syscall"
+)
+
+// Windows 上的 rename 相关错误码(直接给字面量,避免把 golang.org/x/sys/windows
+// 从间接依赖提成直接依赖)。
+const (
+ winErrNotSameDevice syscall.Errno = 17 // ERROR_NOT_SAME_DEVICE:跨卷 rename
+ winErrSharingViolation syscall.Errno = 32 // ERROR_SHARING_VIOLATION:目标被独占打开
+ winErrLockViolation syscall.Errno = 33 // ERROR_LOCK_VIOLATION:目标被锁
+)
+
+// windowsReplaceableErrno 列出「rename 失败后仍可以降级为原地覆盖」的 Windows 错误码。
+//
+// 为什么需要单独一份(Windows 10/11 + Go 1.22+ 实测结论):
+//
+// - MoveFileEx(..., MOVEFILE_REPLACE_EXISTING) 在**目标文件被其它句柄占用**时
+// 返回 ERROR_ACCESS_DENIED (5),而不是 ERROR_SHARING_VIOLATION (32)。
+// Go 的 syscall.Errno.Is 把 ERROR_ACCESS_DENIED 映射成 fs.ErrPermission。
+// - 跨卷 rename 返回 ERROR_NOT_SAME_DEVICE (17),Go **没有**为它定义
+// syscall.EXDEV(Windows 上的 syscall.EXDEV 是「臆造错误」列表里的占位值
+// 0x20000000,永远不等于任何真实 errno)。
+// 于是 errors.Is(err, syscall.EXDEV) 在 Windows 上**恒为 false**。
+//
+// 后果(issue #226 修复的 Windows 侧缺口):跨卷或文件被占用时,
+// IsReplaceableErr 认不出来 → auth.SaveAtomic 不降级、直接报错 →
+// **刷新后的 accessToken 不落盘**(磁盘上仍是旧 token)→ 下次启动用旧 token。
+var windowsReplaceableErrno = []syscall.Errno{
+ winErrNotSameDevice,
+ winErrSharingViolation,
+ winErrLockViolation,
+ syscall.ERROR_ACCESS_DENIED,
+}
+
+// CanFallbackToInPlace 报告 rename 失败是否属于「应当降级为原地覆盖」。
+//
+// Windows 版额外识别 ERROR_NOT_SAME_DEVICE / ERROR_ACCESS_DENIED /
+// ERROR_SHARING_VIOLATION / ERROR_LOCK_VIOLATION 与 fs.ErrPermission ——
+// 见 windowsReplaceableErrno 的注释。
+//
+// 安全边界:rename 的失败只可能来自**目标侧**(源 tmp 刚刚写成功),所以
+// 「权限类错误 → 降级为原地覆盖」是安全的:目标文件本身可写时能写进去;
+// 不可写时 writeInPlace 会返回真实错误,不会静默丢数据。
+func CanFallbackToInPlace(err error) bool {
+ if err == nil {
+ return false
+ }
+ if IsReplaceableErr(err) { // EXDEV / EBUSY(POSIX 语义,Windows 上一般不命中)
+ return true
+ }
+ for _, errno := range windowsReplaceableErrno {
+ if errors.Is(err, errno) {
+ return true
+ }
+ }
+ return errors.Is(err, fs.ErrPermission)
+}
+
+// isTargetBusyErr 报告 rename 失败是否属于「**目标侧临时占用**」——
+// 也就是「另一个进程/写者此刻正持有目标文件的句柄」,稍后重试就能成。
+//
+// Windows 上的典型来源:MoveFileEx(..., MOVEFILE_REPLACE_EXISTING) 在目标
+// 被独占打开时返回 ERROR_ACCESS_DENIED(5) / ERROR_SHARING_VIOLATION(32) /
+// ERROR_LOCK_VIOLATION(33)。这在「两个写者并发替换同一目标」时是**必然会
+// 撞上**的(另一个写者的 rename 窗口只有几微秒,但足以让本写者失败)。
+//
+// 与 CanFallbackToInPlace 的区别:后者回答「能不能降级为原地覆盖」,
+// 本函数回答「值不值得**重试**」。ERROR_ACCESS_DENIED 两边都命中 ——
+// 它既可能是「目标被占用(重试能成)」,也可能是「目标只读/无权限
+// (重试没用,只能降级)」,所以两个判定都保守地包含它。
+func isTargetBusyErr(err error) bool {
+ if err == nil {
+ return false
+ }
+ for _, errno := range windowsReplaceableErrno {
+ if errors.Is(err, errno) {
+ return true
+ }
+ }
+ return errors.Is(err, fs.ErrPermission)
+}
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..bae04dad 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
}
@@ -338,11 +476,19 @@ func (a *Auth) SaveAtomic() error {
if err != nil {
return err
}
- tmp := a.FilePath + ".tmp"
- if err := os.WriteFile(tmp, raw, 0o600); err != nil {
+ // 暂存名必须**唯一**:固定 ".tmp" 会让两个持有同一 FilePath 的
+ // *Auth 实例互相踩(a.mu 只保护同一个对象;scheduler 的 keepalive 刷新与
+ // handler 的按需刷新持有的是**不同**对象)。
+ // 实测(300 轮 × 2 写者):SaveAtomic 返回 nil 但内容未落盘 282 次
+ // (被对方 rename 走),另有 266 次报「源文件不存在」的假失败。
+ // 改成唯一名后,ENOAENT 归零(剩下正常的 last-writer-wins)。
+ //
+ // 复用 internal/atomicfile:同一套暂存名生成 + 原子替换 + 降级原地覆盖,
+ // 避免两处各写一份、日后各改各的。
+ if err := atomicfile.Write(a.FilePath, raw, 0o600); err != nil {
// Docker bind-mount 权限问题的典型现场:容器内 app 用户(uid 10001)
// 对宿主机挂载目录无写权限。给出可操作指引而不是裸 syscall 错误。
- msg := fmt.Sprintf("写入 %s 失败: %v", tmp, err)
+ msg := err.Error()
if errors.Is(err, fs.ErrPermission) {
msg += "\n(Docker 部署:容器内用户对宿主机挂载目录无写权限。解法任选:" +
"1) 以本机 uid 运行容器:PUID=$(id -u) PGID=$(id -g) docker compose up -d;" +
@@ -351,7 +497,7 @@ func (a *Auth) SaveAtomic() error {
}
return errors.New(msg)
}
- return os.Rename(tmp, a.FilePath)
+ return nil
}
// LoadDir 扫描并解析 dir 下 workbuddy*.json;解析失败的文件静默跳过(启动日志由调用方统计)。
diff --git a/internal/auth/auth_concurrent_test.go b/internal/auth/auth_concurrent_test.go
new file mode 100644
index 00000000..1b0f1bcd
--- /dev/null
+++ b/internal/auth/auth_concurrent_test.go
@@ -0,0 +1,95 @@
+package auth
+
+import (
+ "os"
+ "path/filepath"
+ "sync"
+ "testing"
+)
+
+// TestSaveAtomicConcurrentDistinctInstances 两个**不同**的 *Auth 实例持有同一个
+// FilePath 时并发保存,不得出现「返回成功但内容没落盘」。
+//
+// 现场(issue:token 刷新不落盘):SaveAtomic 之前固定用 FilePath+".tmp" 作为
+// 暂存名。两个实例并发写时:
+// - A 写完 tmp → B 也写同一个 tmp(覆盖)→ A rename 走 → B rename 报 ENOENT;
+// - 或 A rename 走 → B rename 时目标已存在(Windows 报占用),内容是 A 的。
+//
+// 实测 300 轮 × 2 写者:SaveAtomic 返回 nil 但内容未落盘 **282 次**(被对方
+// rename 走),另有 **266 次**报「源文件不存在」的假失败。
+//
+// a.mu 只保护**同一个对象**;scheduler 的 keepalive 刷新(scheduler.go)与
+// handler 的按需刷新(handler.go)持有的是**不同**对象 ⇒ 互不排斥。
+//
+// 修法:暂存名唯一(同一目录下由目标路径派生 + 随机后缀),并复用
+// internal/atomicfile 的原子替换路径。
+func TestSaveAtomicConcurrentDistinctInstances(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "workbuddy-concurrent.json")
+
+ // 基准文件先存在(模拟磁盘上已有的账号文件)。
+ seed := &Auth{UID: "u1", AccessToken: "seed", ExpiresAt: 100, FilePath: path}
+ if err := seed.SaveAtomic(); err != nil {
+ t.Fatal(err)
+ }
+
+ const rounds = 60
+ for i := 0; i < rounds; i++ {
+ // 每次用**不同**的 *Auth 对象(不同 mu),指向同一 FilePath。
+ a := &Auth{UID: "u1", AccessToken: "token-A", ExpiresAt: int64(200 + i), FilePath: path}
+ b := &Auth{UID: "u1", AccessToken: "token-B", ExpiresAt: int64(300 + i), FilePath: path}
+
+ var wg sync.WaitGroup
+ errs := make([]error, 2)
+ wg.Add(2)
+ go func() { defer wg.Done(); errs[0] = a.SaveAtomic() }()
+ go func() { defer wg.Done(); errs[1] = b.SaveAtomic() }()
+ wg.Wait()
+
+ // 两次保存都不得**虚假失败**(ENOENT 那种)。
+ for j, err := range errs {
+ if err != nil {
+ t.Fatalf("round %d writer %d: 虚假失败: %v", i, j, err)
+ }
+ }
+ // 落盘内容必须是**某一个**写者的完整内容(不能是半截/空/别人的混合体)。
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("round %d: 读回失败: %v", i, err)
+ }
+ got, err := Parse(raw)
+ if err != nil {
+ t.Fatalf("round %d: 落盘内容不是合法 JSON(半截文件?): %v\n内容=%q", i, err, string(raw))
+ }
+ if got.AccessToken != "token-A" && got.AccessToken != "token-B" {
+ t.Fatalf("round %d: 落盘内容既不是 A 也不是 B: %q", i, got.AccessToken)
+ }
+ if got.UID != "u1" {
+ t.Fatalf("round %d: UID 被写坏: %q", i, got.UID)
+ }
+ }
+}
+
+// TestSaveAtomicLeavesNoStagingFiles 保存后不得残留暂存文件。
+//
+// 残留的 .tmp 里有**明文 token**;虽然 auths 目录的 glob 扫不到它(不会当配置
+// 加载),但它会一直躺在磁盘上。
+func TestSaveAtomicLeavesNoStagingFiles(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "workbuddy-staging.json")
+ a := &Auth{UID: "u1", AccessToken: "t1", ExpiresAt: 100, FilePath: path}
+ if err := a.SaveAtomic(); err != nil {
+ t.Fatal(err)
+ }
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(entries) != 1 || entries[0].Name() != "workbuddy-staging.json" {
+ var names []string
+ for _, e := range entries {
+ names = append(names, e.Name())
+ }
+ t.Errorf("目录里应只剩目标文件,实际=%v", names)
+ }
+}
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/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/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/logfile/logfile.go b/internal/logfile/logfile.go
new file mode 100644
index 00000000..7e3e3c1f
--- /dev/null
+++ b/internal/logfile/logfile.go
@@ -0,0 +1,151 @@
+// Package logfile 提供网关运行日志的落盘写入器:带体积上限的轮转追加写。
+//
+// 为什么需要:网关的运行日志(启动信息、上游错误、账号池事件)此前只往
+// stderr/stdout 写。Windows 上双击或托盘启动时没有控制台窗口,出问题只能
+// 靠猜;落盘后可以直接翻日志文件定位。
+//
+// 设计要点:
+// - 追加写,不截断历史(同一天多次重启的日志连成一条时间线)。
+// - 体积上限 + 轮转:超过上限后把当前文件改名成 .1,.1 变 .2 …,超过
+// 保留份数的最老的被删除。避免日志无限增长把 U 盘/系统盘写满。
+// - 写失败不 panic:落盘是辅助能力,磁盘满/权限不足时不能让网关起不来,
+// 降级为只打一行警告并继续(stderr 仍然有输出)。
+// - 并发安全:多个 goroutine(server/panel/scheduler)都会写。
+package logfile
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strconv"
+ "sync"
+)
+
+// Writer 是轮转追加写文件。
+type Writer struct {
+ mu sync.Mutex
+ path string
+ maxSize int64
+ backups int
+ f *os.File
+ size int64
+ written int64
+}
+
+// New 创建写入器并打开(追加模式)目标文件。
+//
+// maxSize <= 0 时用 64MiB,backups <= 0 时用 3。父目录不存在会被创建。
+// 打不开文件时返回错误——调用方决定是否降级。
+func New(path string, maxSizeMB int, backups int) (*Writer, error) {
+ if path == "" {
+ return nil, fmt.Errorf("logfile: empty path")
+ }
+ if maxSizeMB <= 0 {
+ maxSizeMB = 64
+ }
+ if backups <= 0 {
+ backups = 3
+ }
+ if dir := filepath.Dir(path); dir != "" && dir != "." {
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return nil, fmt.Errorf("logfile: mkdir %s: %w", dir, err)
+ }
+ }
+ f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
+ if err != nil {
+ return nil, fmt.Errorf("logfile: open %s: %w", path, err)
+ }
+ w := &Writer{
+ path: path,
+ maxSize: int64(maxSizeMB) * 1024 * 1024,
+ backups: backups,
+ f: f,
+ }
+ if st, err := f.Stat(); err == nil {
+ w.size = st.Size()
+ }
+ return w, nil
+}
+
+// Write 写入一段日志(自动补换行)。
+func (w *Writer) Write(p []byte) (int, error) {
+ if w == nil {
+ return len(p), nil
+ }
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ if w.f == nil {
+ return len(p), nil
+ }
+ n, err := w.f.Write(p)
+ w.size += int64(n)
+ w.written += int64(n)
+ if err == nil && w.size >= w.maxSize {
+ // 轮转失败不致命:文件继续写,只是可能超限。
+ w.rotate()
+ }
+ return n, err
+}
+
+// rotate 把当前文件轮转为 .1(.1→.2…),并重新打开一个新的当前文件。
+func (w *Writer) rotate() {
+ // 先关掉旧句柄,否则 Windows 上改名会失败(文件被占用)。
+ if w.f != nil {
+ w.f.Close()
+ w.f = nil
+ }
+ // 丢弃最老的备份。
+ oldest := w.path + "." + strconv.Itoa(w.backups)
+ if err := os.Remove(oldest); err == nil {
+ _ = err
+ }
+ // 其余依次后移(从大到小,避免覆盖)。
+ for i := w.backups - 1; i >= 1; i-- {
+ src := w.path + "." + strconv.Itoa(i)
+ dst := w.path + "." + strconv.Itoa(i+1)
+ if _, err := os.Stat(src); err == nil {
+ _ = os.Rename(src, dst)
+ }
+ }
+ // 当前文件 → .1
+ if err := os.Rename(w.path, w.path+".1"); err != nil {
+ // 改名失败(权限/占用):重新打开原文件继续写。
+ if f, err2 := os.OpenFile(w.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644); err2 == nil {
+ w.f = f
+ if st, err3 := f.Stat(); err3 == nil {
+ w.size = st.Size()
+ }
+ }
+ return
+ }
+ f, err := os.OpenFile(w.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
+ if err != nil {
+ w.f = nil
+ return
+ }
+ w.f = f
+ w.size = 0
+}
+
+// Close 关闭文件句柄。
+func (w *Writer) Close() error {
+ if w == nil {
+ return nil
+ }
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ if w.f == nil {
+ return nil
+ }
+ err := w.f.Close()
+ w.f = nil
+ return err
+}
+
+// Path 返回日志文件路径。
+func (w *Writer) Path() string {
+ if w == nil {
+ return ""
+ }
+ return w.path
+}
diff --git a/internal/logfile/logfile_test.go b/internal/logfile/logfile_test.go
new file mode 100644
index 00000000..ab039bc5
--- /dev/null
+++ b/internal/logfile/logfile_test.go
@@ -0,0 +1,133 @@
+package logfile
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestNewCreatesParentDirAndAppends(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "nested", "deep", "gateway.log")
+ w, err := New(path, 1, 2)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ defer w.Close()
+ if _, err := w.Write([]byte("hello\n")); err != nil {
+ t.Fatalf("Write: %v", err)
+ }
+ w.Close()
+ // 再开一次:应追加而非截断。
+ w2, err := New(path, 1, 2)
+ if err != nil {
+ t.Fatalf("reopen: %v", err)
+ }
+ defer w2.Close()
+ w2.Write([]byte("world\n"))
+ w2.Close()
+ b, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("read: %v", err)
+ }
+ s := string(b)
+ if !strings.Contains(s, "hello") || !strings.Contains(s, "world") {
+ t.Fatalf("应为追加写,实际内容: %q", s)
+ }
+}
+
+func TestWriteAppendsToExistingFile(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "a.log")
+ os.WriteFile(path, []byte("existing\n"), 0o644)
+ w, err := New(path, 1, 2)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ defer w.Close()
+ w.Write([]byte("new\n"))
+ w.Close()
+ b, _ := os.ReadFile(path)
+ if !strings.Contains(string(b), "existing") || !strings.Contains(string(b), "new") {
+ t.Fatalf("既有内容被截断: %q", string(b))
+ }
+}
+
+func TestRotateKeepsBackupsAndDropsOldest(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "rot.log")
+ // maxSize 1MiB,但为了快测试,直接手工把 size 顶到上限。
+ w, err := New(path, 1, 2)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ defer w.Close()
+ // 直接写满 1MiB 触发轮转。
+ chunk := strings.Repeat("x", 1024*1024)
+ if _, err := w.Write([]byte(chunk)); err != nil {
+ t.Fatalf("Write: %v", err)
+ }
+ w.mu.Lock()
+ w.rotate()
+ w.mu.Unlock()
+ w.Close()
+ // 当前文件应存在,.1 与 .2 也在(backups=2 ⇒ 最多 .1/.2)。
+ for _, suffix := range []string{"", ".1", ".2"} {
+ p := path + suffix
+ if _, err := os.Stat(p); err != nil {
+ t.Fatalf("期望存在 %s: %v", p, err)
+ }
+ }
+ // .3(超出保留份数)不应存在。
+ if _, err := os.Stat(path + ".3"); err == nil {
+ t.Fatalf("超出保留份数的备份不该存在: %s", path+".3")
+ }
+}
+
+func TestNewRejectsEmptyPath(t *testing.T) {
+ if _, err := New("", 1, 1); err == nil {
+ t.Fatal("空路径应报错")
+ }
+}
+
+func TestNewOnUnwritableDirFails(t *testing.T) {
+ if os.Geteuid() == 0 {
+ t.Skip("root 可绕过目录权限,跳过")
+ }
+ dir := t.TempDir()
+ sub := filepath.Join(dir, "ro")
+ os.MkdirAll(sub, 0o555)
+ defer os.Chmod(sub, 0o755)
+ if _, err := New(filepath.Join(sub, "x.log"), 1, 1); err == nil {
+ t.Fatal("不可写目录应报错")
+ }
+}
+
+func TestCloseIsIdempotent(t *testing.T) {
+ dir := t.TempDir()
+ w, err := New(filepath.Join(dir, "c.log"), 1, 1)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ if err := w.Close(); err != nil {
+ t.Fatalf("Close: %v", err)
+ }
+ if err := w.Close(); err != nil {
+ t.Fatalf("二次 Close 应无错: %v", err)
+ }
+}
+
+func TestNilWriterIsNoop(t *testing.T) {
+ var w *Writer
+ n, err := w.Write([]byte("x"))
+ if n != 1 || err != nil {
+ t.Fatalf("nil writer 应是无害 no-op: n=%d err=%v", n, err)
+ }
+ if w.Path() != "" {
+ t.Fatal("nil writer Path 应为空")
+ }
+ if err := w.Close(); err != nil {
+ t.Fatalf("nil writer Close 应无错: %v", err)
+ }
+}
diff --git a/internal/panel/apikey.go b/internal/panel/apikey.go
new file mode 100644
index 00000000..66fc3ed8
--- /dev/null
+++ b/internal/panel/apikey.go
@@ -0,0 +1,138 @@
+// 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,不落盘。
+ //
+ // 探测必须带上 uid:余额观察者(credithist 积分历史)按 uid 建基线,uid 为空
+ // 的观测会被直接丢弃——那样静态 Key 账号**永远没有任何积分历史行**,面板的
+ // 「积分历史」对它是一片空白(看起来像额度从没变化过)。uid 在上面已解析好,
+ // 这里带上即可。
+ probe := &auth.Auth{AccessToken: key, UID: uid}
+ 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_credit_observer_test.go b/internal/panel/apikey_credit_observer_test.go
new file mode 100644
index 00000000..c21ed251
--- /dev/null
+++ b/internal/panel/apikey_credit_observer_test.go
@@ -0,0 +1,99 @@
+package panel
+
+import (
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/linguo2625469/workbuddy2api-panel/internal/credithist"
+)
+
+// TestAddAPIKeyFeedsCreditHistory 静态 API Key 账号也必须出现在「积分历史」里。
+//
+// 缺陷:添加 API Key 时,为了探测额度构造的 probe 只带 AccessToken、不带 UID:
+//
+// probe := &auth.Auth{AccessToken: key}
+//
+// 而余额观察者(credithist.Ledger.Observe)以 uid 建基线,uid 为空的观测会被
+// **直接丢弃**("纯空白 uid 与空 uid 一样无效")。于是静态 Key 账号的额度变化
+// 永远进不了积分历史——面板「积分历史」对它是一片空白,看起来像"额度从没变过"。
+//
+// 注意这里探的是**企业口径**端点(静态 Key 无个人账号身份,get-user-resource 对
+// 它恒返回空 Accounts),额度数据本身是对的;缺的只是 uid。
+func TestAddAPIKeyFeedsCreditHistory(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}}`))
+ return
+ }
+ w.Write([]byte(`{"code":0,"msg":"ok","data":{}}`))
+ })
+
+ // 挂上积分历史观察者(生产由 cmd/server/main.go 挂:up.SetCreditObserver(creditHist.Observe))。
+ ledger := credithist.New("", 100)
+ p.cfg.Upstream.SetCreditObserver(ledger.Observe)
+
+ 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())
+ }
+
+ // 第一次观测只建基线(不产生流水行),所以基线必须落在**这个账号的 uid** 上。
+ // 旧实现(probe 无 uid)会让基线被丢弃 → 之后所有观测都变成"首次观测",
+ // 积分历史永远为空。
+ if got := ledger.Read(0); len(got) != 0 {
+ t.Fatalf("首次观测只应建基线,不应产生流水行,得到 %d 条", len(got))
+ }
+
+ // 额度变化后应产生一条流水,且 uid 是这个账号的 uid。
+ ledger.Observe("apikey-9f3a2b1c", 1000)
+ rows := ledger.Read(0)
+ if len(rows) != 1 {
+ t.Fatalf("额度变化应记 1 条流水,得到 %d 条:%+v", len(rows), rows)
+ }
+ if rows[0].UID != "apikey-9f3a2b1c" {
+ t.Errorf("流水 uid=%q want apikey-9f3a2b1c", rows[0].UID)
+ }
+}
+
+// TestAddAPIKeyProbeCarriesUID 直接钉住根因:探测用的 probe 必须带 uid。
+//
+// 走不到账本那层也能测:观察者收到的 uid 必须非空。这是 credithist 丢弃
+// 空 uid 观测的**唯一入口**——堵住它,空 uid 的观测就再也进不了账本。
+func TestAddAPIKeyProbeCarriesUID(t *testing.T) {
+ var seenUID string
+ 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":100,"limitNum":2000}}`))
+ return
+ }
+ w.Write([]byte(`{"code":0,"msg":"ok","data":{}}`))
+ })
+ p.cfg.Upstream.SetCreditObserver(func(uid string, credits int64) {
+ if seenUID == "" {
+ seenUID = uid
+ }
+ })
+
+ const key = "ck_9f3a2b1c.deadbeef"
+ rec := postJSON(t, p, "/panel/api/accounts/add_apikey", map[string]any{"access_token": key})
+ if rec.Code != http.StatusOK {
+ t.Fatalf("code=%d want 200 body=%s", rec.Code, rec.Body.String())
+ }
+ if seenUID != "apikey-9f3a2b1c" {
+ t.Errorf("余额观察者收到的 uid=%q want apikey-9f3a2b1c(空 uid 的观测会被积分历史丢弃)", seenUID)
+ }
+}
+
+// 防回归:观察者挂载后不应影响正常添加流程(探测失败仍 400、不落盘)。
+func TestAddAPIKeyObserverDoesNotBreakFlow(t *testing.T) {
+ p := panelWithUpstream(t, func(w http.ResponseWriter, r *http.Request) {
+ w.Write([]byte(`{"code":99999,"msg":"boom"}`))
+ })
+ p.cfg.Upstream.SetCreditObserver(func(uid string, credits int64) {})
+ rec := postJSON(t, p, "/panel/api/accounts/add_apikey", map[string]any{"access_token": "ck_bad.secret"})
+ if rec.Code != http.StatusBadRequest {
+ t.Errorf("上游失败应 400,实际 code=%d body=%s", rec.Code, rec.Body.String())
+ }
+}
diff --git a/internal/panel/apikey_test.go b/internal/panel/apikey_test.go
new file mode 100644
index 00000000..5521b3f5
--- /dev/null
+++ b/internal/panel/apikey_test.go
@@ -0,0 +1,187 @@
+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")
+ }
+ // 上游 credit=812.45 是**已用**(企业口径与个人相反),池内取整数用 **ceil**
+ // (issue #222 建议 1:向上取整是保守方向——宁可少报剩余额度不可多报,多报
+ // 会让调度器以为还有额度而继续派发,实际打过去就是 402)→ used=813,
+ // remain = 2000-813 = 1187。
+ if st.Credits != 1187 || st.CreditsTotal != 2000 {
+ t.Errorf("额度映射错误:credits=%d total=%d want 1187/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..eda10381 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') };
}
@@ -349,7 +361,9 @@ function go(v) {
if (v === 'logs') loadLogs();
if (v === 'usage') loadUsage();
if (v === 'packages') loadPackages();
- if (v === 'accounts') loadExpiry();
+ // 到期提醒卡片:默认可见,进页面即用 overview 快照渲染(零上游请求,随首页统计
+ // 每轮刷新)。不再「打开账号池就打上游」——逐账号实时查在账号多时会卡死面板。
+ // 完整逐包明细由卡片上的「检查」按钮显式触发(loadPackages/loadExpiry)。
if (v === 'taskscenter') reattachQueueView();
}
document.querySelectorAll('.nav a').forEach(a => a.onclick = e => { e.preventDefault(); go(a.dataset.view); history.replaceState(null, '', '#' + a.dataset.view); });
@@ -392,16 +406,23 @@ function renderAccounts(list) {
// 企业版不限量:上游 limitNum == -1,网关以 credits_total=-1 透出(见 upstream
// enterpriseUnlimitedTotal)。此时剩余额度不参与展示,直接标「不限」。
const unlimited = s.credits_total === -1;
+ // 「额度未知」与「额度 0」必须可区分(credits_known):前者是上游额度查询
+ // 失败 / 账号刚导入还没跑过余额刷新,后者是真的没额度。显示成同一个「0」
+ // 会让运维去充值,而真正的问题在上游接口。
+ const credUnknown = !unlimited && s.credits_known === false;
const cred = unlimited ? '不限'
- : (s.credits == null ? '—' : (s.credits_total > 0 ? s.credits + '/' + s.credits_total + '' : String(s.credits)));
+ : (credUnknown ? '未知'
+ : (s.credits == null ? '—' : (s.credits_total > 0 ? s.credits + '/' + s.credits_total + '' : String(s.credits))));
const pct = unlimited ? 100
- : (s.credits_total > 0
+ : (credUnknown ? 0
+ : (s.credits_total > 0
? Math.min(100, Math.round((s.credits || 0) / s.credits_total * 100))
- : Math.round((s.credits || 0) / maxCred * 100));
+ : Math.round((s.credits || 0) / maxCred * 100)));
// 成本台账 tooltip(model_costs):每模型实测单价(≤0 = 实测免费),运维据此
// 看「为什么总选它」——免费号垄断 / 单价排序一眼可见。
let credTip;
if (unlimited) credTip = '企业版不限量(上游 limitNum=-1)';
+ else if (credUnknown) credTip = '额度未知:还没成功读到过上游余额(刚导入或上游额度查询失败)';
else if (s.credits_total > 0) {
credTip = (s.enterprise ? '企业版剩余额度 ' : '剩余 ')
+ s.credits + ' / ' + (s.enterprise ? '分配 ' : '总额 ') + s.credits_total + '(' + pct + '%)';
@@ -412,6 +433,13 @@ 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;
+ // 国际区(global)账号也无国内区签到/成长任务体系:同样隐藏这两个按钮
+ // (#104)。区别:global 账号的余额仍走个人口径,故按钮文案仍是「余额」。
+ const isGlobalRealm = s.realm === 'global';
+ const noGrowthUI = noGrowth || isGlobalRealm;
const tu = s.token_usage || {};
const req = tu.request_count || 0;
const totalTok = formatTokenCount(tu.total_tokens);
@@ -421,7 +449,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 +462,21 @@ function renderAccounts(list) {
'' +
'' + ago(s.last_success) + ' | ' +
'' +
- // 企业版无个人成长体系(签到 400「企业账号不支持该操作」/ 成长任务 403):
+ // 企业版与静态 API Key 账号无个人成长体系(签到 400「企业账号不支持该操作」/ 成长任务 403):
// 不渲染「签到」「任务」按钮,只留「额度」——点它走 /balance,企业额度由
// upstream 的 get-enterprise-user-usage 口径填充。
- (s.enterprise ? '' :
+ // 国际区(global)账号无国内区签到体系,同样不渲染「签到」(#104);但其
+ // 「任务」按钮保留——国际区任务仅支持查询(写操作由后端返回 501 提示)。
+ (noGrowthUI ? '' :
'') +
- '' +
- (s.enterprise ? '' :
+ '' +
+ (noGrowth ? '' :
'') +
+ (noGrowth || s.realm === 'global' ? '' :
+ '') +
(frozen ? ''
: (s.paused ? ''
- : '')) +
+ : '')) +
(s.disabled ? '' : '') +
'' +
' |
';
@@ -491,15 +523,25 @@ async function loadOverview(quiet) {
try {
const d = await api('overview');
overviewData = d;
+ const hasCNAccount = (d.accounts || []).some(account => account.realm !== 'global' && !account.disabled);
+ for (const id of ['btnCheckinAll', 'btnTravelAll']) {
+ $(id).disabled = !hasCNAccount;
+ $(id).title = hasCNAccount ? '' : '当前没有适用此操作的国内区账号';
+ }
$('sTotal').textContent = d.total;
$('sHealthy').textContent = d.healthy;
$('sCooling').textContent = d.cooling;
$('sDisabled').textContent = d.disabled;
// 暂停选号单列(issue #125):它只关选号、照常签到保活,与禁用是两种状态。
if ($('sPaused')) $('sPaused').textContent = d.paused == null ? '-' : d.paused;
- const remSum = (d.accounts || []).reduce((a, s) => a + (s.credits || 0), 0);
- const totSum = (d.accounts || []).reduce((a, s) => a + (s.credits_total || 0), 0);
- $('sCredits').textContent = totSum > 0 ? remSum + ' / ' + totSum : remSum;
+ // 积分汇总:只累加**额度已知**的账号(credits_known)。额度未知的账号
+ // credits=0,累加进去会让「一部分号没读到余额」看起来像「余额变成 0 了」。
+ const knownAcc = (d.accounts || []).filter(s => s.credits_known !== false);
+ const unknownCount = (d.accounts || []).length - knownAcc.length;
+ const remSum = knownAcc.reduce((a, s) => a + (s.credits || 0), 0);
+ const totSum = knownAcc.reduce((a, s) => a + (s.credits_total || 0), 0);
+ $('sCredits').textContent = (totSum > 0 ? remSum + ' / ' + totSum : String(remSum))
+ + (unknownCount > 0 ? '(' + unknownCount + ' 个额度未知)' : '');
$('sSticky').textContent = d.sticky_sessions;
$('navSub').textContent = 'v' + d.version;
$('navVer').textContent = 'v' + d.version;
@@ -512,6 +554,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'); }
}
@@ -525,7 +572,8 @@ $('accBody').addEventListener('click', async ev => {
try {
if (a === 'checkin') {
const r = await api('accounts/' + encodeURIComponent(u) + '/checkin', { method: 'POST' });
- toast('签到完成' + (r.credits != null ? ',积分 ' + r.credits + (r.credits_total > 0 ? '/' + r.credits_total : '') : '') + (r.checkin_message ? '(' + r.checkin_message + ')' : ''), 'ok');
+ const note = checkinResultNote(r);
+ toast(note.message, note.severity);
} else if (a === 'balance') {
const r = await api('accounts/' + encodeURIComponent(u) + '/balance', { method: 'POST' });
toast('余额已更新:' + r.credits + (r.credits_total > 0 ? ' / ' + r.credits_total : ''), 'ok');
@@ -543,6 +591,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 +669,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 +697,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 +770,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 +845,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 +887,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';
@@ -778,10 +907,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 条(那会把筛选条件之外、时间范围之外的请求显示出来)。
@@ -801,21 +931,28 @@ async function loadLogs() {
if (logPin && atEnd) box.scrollTop = box.scrollHeight;
const counts = {};
for (const e of (d.entries || [])) counts[e.ch] = (counts[e.ch] || 0) + 1;
- $('logNote').textContent = logCh === 'all'
+ $('logNote').textContent = (logCh === 'all'
? '任务 ' + (counts.task || 0) + ' · 对话 ' + (counts.chat || 0) + ' · 系统 ' + (counts.sys || 0)
- : (logCh === 'task' ? '任务' : logCh === 'chat' ? '对话' : '系统') + ' ' + entries.length + ' 行';
+ : (logCh === 'task' ? '任务' : logCh === 'chat' ? '对话' : '系统') + ' ' + entries.length + ' 行') +
+ (d.archive_error ? ' · 任务日志保存失败:' + d.archive_error : ' · 每频道独立保留');
+ renderCreditHistory(credit);
} catch (e) { /* 概览已提示 */ }
}
function renderRequestMetrics(m, entries) {
m = m || {};
const a = m.archive || {};
+ // 零 tool_calls 停摆计数(issue #149):上游只回了正文、没调工具。上游行为质量
+ // 指标 —— 不是账号问题(所以没有冷却/禁用),但它意味着「那一轮任务没推进」,
+ // 运维需要在请求表里一眼看到有多少。
+ const stallCount = (entries || []).filter(e => e && e.tool_stall).length;
$('reqSummary').textContent =
'已完成 ' + fmtTok(m.completed) +
' · 成功 ' + (m.success_rate == null ? '—' : Number(m.success_rate).toFixed(1) + '%') +
' · HTTP ' + (m.http_success_rate == null ? '—' : Number(m.http_success_rate).toFixed(1) + '%') +
' · 平均 ' + fmtMs(m.avg_duration_ms) +
- ' · 进行中 ' + String(m.in_flight || 0);
+ ' · 进行中 ' + String(m.in_flight || 0) +
+ (stallCount ? ' · 停摆 ' + stallCount : '');
$('reqNote').textContent = a.enabled
? 'JSONL 归档 ' + fmtBytes(a.bytes) + (a.dropped_writes ? ' · 丢弃 ' + a.dropped_writes + ' 条' : '') +
(a.last_error ? ' · 错误:' + a.last_error : '')
@@ -825,6 +962,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 = '
| 积分历史不可用 |
';
+ return;
+ }
+ const entries = d.entries || [];
+ if (note) note.textContent = creditHistoryNote(entries);
+ body.innerHTML = entries.length
+ ? entries.map(e => '' +
+ '| ' + esc(creditTimeText(e)) + ' | ' +
+ '' + esc(creditAccountText(e)) + ' | ' +
+ '' + esc(creditDeltaText(e && e.delta)) + ' | ' +
+ '' + esc(creditBalanceText(e)) + ' | ' +
+ '' + esc(creditDescText(e)) + ' | ' +
+ '
').join('')
+ : '| 暂无积分变动记录 |
';
+}
+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) {
@@ -849,6 +1058,16 @@ function reqOutcomeTag(e) {
return '' + esc(String(e && e.status || '—') + ' ' + label) + '';
}
+// 零 tool_calls 停摆标记(issue #149):上游只回了正文、没调工具 —— 工具一次没跑,
+// 客户端会按「正常完成」收尾。停摆率是上游行为质量指标,不是账号问题,所以做成
+// 一个独立的角标挂在结果列旁边(不改变 outcome 本身:那一跳可能最终成功)。
+function reqStallTag(e) {
+ if (!e || !e.tool_stall) return '';
+ const n = Number(e.tool_stall_text || 0);
+ return '停摆';
+}
+
function reqTokenCell(e) {
const total = Number(e && e.total_tokens || 0) ||
(Number(e && e.prompt_tokens || 0) + Number(e && e.completion_tokens || 0));
@@ -874,7 +1093,7 @@ function renderRequestTable() {
const rid = e && e.request_id ? e.request_id : '';
return '' +
'| ' + esc(when) + ' | ' +
- '' + reqOutcomeTag(e) + ' | ' +
+ '' + reqOutcomeTag(e) + reqStallTag(e) + ' | ' +
'' + esc(e && e.model || '—') + ' | ' +
'' + esc(e && e.account || '—') + ' | ' +
'' + (ip ? '' + esc(ip) + '' : '—') + ' | ' +
@@ -914,6 +1133,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 }) : '—';
@@ -936,6 +1157,7 @@ function requestLogText(e) {
fmtTok(token) + ' tok',
credit,
cacheRateText(e && e.cache_hit_tokens, e && e.cache_miss_tokens) === '—' ? '' : '命中 ' + cacheRateText(e && e.cache_hit_tokens, e && e.cache_miss_tokens),
+ e && e.tool_stall ? '零 tool_calls 停摆(正文 ' + (Number(e.tool_stall_text || 0)) + ' 字符)' : '',
e && e.request_id || '—',
].filter(Boolean).join(' | ');
}
@@ -961,6 +1183,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 +1194,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'],
@@ -1013,16 +1240,21 @@ async function loadConfig() {
cfgLoaded = d.config;
$('cfgPath').textContent = d.path || '';
const f = $('cfgForm');
+ const managed = d.environment_managed_fields || [];
for (const [name, path] of Object.entries(CFG_MAP)) {
const el = f.elements[name];
if (!el) continue;
+ el.disabled = managed.includes(path.join('.'));
+ el.title = el.disabled ? '此项由启动环境变量设置,请在部署配置中修改。' : '';
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 = '';
+ $('cfgKey').disabled = !!d.api_key_env_managed;
+ $('cfgKey').title = d.api_key_env_managed ? '访问密钥由启动环境变量设置,请在部署配置中修改。' : '';
+ $('cfgNote').textContent = managed.length ? '由启动环境管理:' + managed.join('、') : '';
} catch (e) { toast('读取配置失败:' + e.message, 'err'); }
}
function collectConfig() {
@@ -1030,13 +1262,15 @@ function collectConfig() {
for (const [name, path] of Object.entries(CFG_MAP)) {
const el = f.elements[name];
if (!el) continue;
+ if (el.disabled) continue;
let v;
if (el.type === 'checkbox') v = el.checked;
else if (el.type === 'number') { v = el.value.trim() === '' ? undefined : Number(el.value); }
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;
}
@@ -1092,12 +1326,14 @@ $('cfgForm').onsubmit = async ev => {
btn.disabled = true; btn.textContent = '保存中…';
try {
const r = await api('config', { method: 'POST', body: JSON.stringify(collectConfig()) });
- const n = (r.restart_required || []).length;
- toast(n ? '配置已保存,其中 ' + n + ' 项需重启进程生效' : '配置已保存并立即生效', 'ok');
+ const fields = r.restart_required || [];
+ const restartNote = fields.length ? '需重启生效:' + fields.join('、') : '';
+ toast(fields.length ? '配置已保存;部分改动需重启生效' : '配置已保存并立即生效', 'ok');
// 密钥可能已改:本次会话沿用新值,避免下一次轮询被 401。
const k = $('cfgKey').value.trim();
- if (k) localStorage.setItem(LS_KEY, k);
- loadConfig();
+ if (k && !r.api_key_env_managed) localStorage.setItem(LS_KEY, k);
+ await loadConfig();
+ $('cfgNote').textContent = [restartNote, (r.environment_managed_fields || []).length ? '由启动环境管理:' + r.environment_managed_fields.join('、') : ''].filter(Boolean).join(';');
loadOverview(true);
} catch (e) { toast('保存失败:' + e.message, 'err'); }
finally { btn.disabled = false; btn.textContent = '保存配置'; }
@@ -1112,6 +1348,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 +1357,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 +1410,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,26 +1474,270 @@ $('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();
/* ── 积分任务 ─────────────────────────────────────────────────────── */
-let taskUID = null;
+let taskUID = null, taskCapabilities = { accept: false, claim: false, automate: false };
+let taskJobTimer = null, taskJobPolling = null, taskJobID = null, taskJobGeneration = 0;
+let taskJobsLoading = false;
+
+// 服务端状态与界面反馈共用的纯函数,避免把已提交/失败当成已完成。
+function checkinResultNote(r) {
+ if (r.skipped) return { message: r.skip_reason || '该账号不适用签到', severity: '' };
+ const balance = r.credits != null ? ',积分 ' + r.credits + (r.credits_total > 0 ? '/' + r.credits_total : '') : '';
+ // balance_error 优先于 checkin_done 判断:企业账号没有签到体系
+ // (checkin_done 恒为 false、checkin_error 是固定的解释性文案),若先判
+ // checkin_done,用户永远只看到「签到未完成:企业账号无签到体系…」,**看不到
+ // 余额查询真正失败的原因**(例如上游返回 data:null → 「余额刷新失败」)。
+ if (r.balance_error) return { message: '余额刷新失败:' + r.balance_error, severity: 'err' };
+ // 跳过态(企业账号无签到体系):**不是错误**。后端已用 checkin_skipped 显式
+ // 标记;此处也要兜住「后端还没升、只有 checkin_message 的企业号」——
+ // 否则健康的企业账号会被渲染成「签到未完成」红色报错。
+ if (r.checkin_skipped) return { message: (r.checkin_message || '该账号不适用签到') + balance, severity: '' };
+ if (r.enterprise && !r.checkin_error && r.checkin_message) return { message: r.checkin_message + balance, severity: '' };
+ if (!r.checkin_done) return { message: '签到未完成:' + (r.checkin_error || r.checkin_message || '请稍后重试') + balance, severity: 'err' };
+ return { message: '签到已完成' + balance, severity: 'ok' };
+}
+
+function taskOutcomeStatus(item) {
+ if (item.claim_error) return 'claim_pending';
+ return item.status || (item.claimed ? 'done' : item.skipped ? 'skipped' : 'awaiting_progress');
+}
+
+function taskResultSummary(results) {
+ const counts = { done: 0, skipped: 0, error: 0, awaiting_progress: 0, claim_pending: 0, accepted: 0 };
+ for (const item of results || []) {
+ const state = taskOutcomeStatus(item);
+ if (Object.prototype.hasOwnProperty.call(counts, state)) counts[state]++;
+ else counts.error++;
+ }
+ const parts = ['已完成 ' + counts.done + ' 项'];
+ if (counts.accepted) parts.push('报名已提交 ' + counts.accepted + ' 批');
+ if (counts.awaiting_progress) parts.push('等待计分 ' + counts.awaiting_progress + ' 项');
+ if (counts.claim_pending) parts.push('领奖待重试 ' + counts.claim_pending + ' 项');
+ if (counts.skipped) parts.push('跳过 ' + counts.skipped + ' 项');
+ if (counts.error) parts.push('失败 ' + counts.error + ' 项');
+ return { message: parts.join(','), severity: counts.error || counts.claim_pending ? 'err' : counts.awaiting_progress || counts.skipped || !counts.done ? '' : 'ok' };
+}
+
+function taskRowPresentation(t, caps) {
+ const code = t.task_code || t.code || '';
+ const cur = t.current ?? 0, tgt = t.target ?? 0;
+ const progress = t.progress_known === false ? '未提供' : tgt ? cur + ' / ' + tgt : cur > 0 ? String(cur) : '—';
+ const rewards = [];
+ if (t.credit) rewards.push('+' + t.credit + ' 分');
+ if (t.energy) rewards.push('+' + t.energy + ' 能');
+ if (t.reward_buddy) rewards.push('Buddy');
+ const reward = t.reward_known === false ? '未提供' : rewards.join(' ') || '—';
+ const state = t.claimed ? '已领取' : t.claimable ? '可领取' : t.locked ? '未解锁'
+ : t.accept_status === 'accepted' ? '进行中' : t.status === 'completed' || t.status === 'complete' ? '已完成'
+ : t.status === 'available' ? '可在客户端完成' : '未接受';
+ const action = !code || t.claimed || t.locked ? '' : t.claimable && caps.claim ? 'claim'
+ : caps.automate && AUTO_TASKS[code] ? 'auto' : t.accept_status !== 'accepted' && caps.accept ? 'accept' : '';
+ return { code, progress, reward, state, action };
+}
// 可自动完成的任务(与后端 autoActions 表一致):判据为行为事件、可经网关复现。
// 其余任务需在官方客户端内交互,面板只展示指引(行 title 提示)。
@@ -1261,19 +1771,36 @@ const AUTO_TASKS = {
};
function openTasks(uid) {
+ taskJobGeneration++;
+ if (taskJobTimer) clearInterval(taskJobTimer);
+ taskJobTimer = null;
+ taskJobID = null;
taskUID = uid;
+ taskCapabilities = { accept: false, claim: false, automate: false };
+ $('btnTaskAcceptAll').hidden = true;
+ $('btnTaskAutoAll').hidden = true;
+ $('taskNote').hidden = true;
+ $('taskJobView').hidden = true;
+ $('btnTaskAutoAll').disabled = false;
+ $('btnTaskAutoAll').textContent = '后台完成可自动任务';
$('taskWho').textContent = uid.slice(0, 16);
$('taskVeil').classList.add('on');
$('btnTaskReload').hidden = false;
loadTasks();
+ loadAccountTaskJob(uid);
+}
+function closeTasks() {
+ taskJobGeneration++;
+ $('taskVeil').classList.remove('on'); taskUID = null; taskJobID = null;
+ if (taskJobTimer) clearInterval(taskJobTimer);
+ taskJobTimer = null;
}
-function closeTasks() { $('taskVeil').classList.remove('on'); taskUID = null; }
$('btnCloseTask').onclick = closeTasks;
-$('btnTaskReload').onclick = loadTasks;
+$('btnTaskReload').onclick = () => { loadTasks(); if (taskUID) loadAccountTaskJob(taskUID); };
// 全部接受:把该账号未接受的任务一次性报名(幂等,跳过已接受/已领取)。
$('btnTaskAcceptAll').onclick = async () => {
- if (!taskUID) return;
+ if (!taskUID || !taskCapabilities.accept) return;
const btn = $('btnTaskAcceptAll');
btn.disabled = true; btn.textContent = '接受中…';
try {
@@ -1288,32 +1815,127 @@ $('btnTaskAcceptAll').onclick = async () => {
finally { btn.disabled = false; btn.textContent = '全部接受'; loadTasks(); }
};
-// 一键完成全部可自动任务(耗时较长:含真实对话,逐项回读验证)。
+// 后台执行:请求仅启动任务,页面轮询进度;关闭弹窗不影响服务端执行。
$('btnTaskAutoAll').onclick = async () => {
- if (!taskUID) return;
+ if (!taskUID || !taskCapabilities.automate) return;
+ const uid = taskUID;
const btn = $('btnTaskAutoAll');
- if (!confirm('将依次执行:补报对话事件、领取 Buddy、glm-5.2 对话、尝试上报。\n过程约 1-2 分钟(含真实对话),确认继续?')) return;
- btn.disabled = true; btn.textContent = '执行中…';
+ btn.disabled = true; btn.textContent = '启动中…';
try {
- const r = await api('accounts/' + encodeURIComponent(taskUID) + '/tasks/auto_all', { method: 'POST' });
- const okN = (r.results || []).filter(x => x.status === 'done').length;
- const skipN = (r.results || []).filter(x => x.status === 'skipped').length;
- const errN = (r.results || []).filter(x => x.status === 'error').length;
- toast(`执行完成:成功 ${okN} 项,跳过 ${skipN} 项${errN ? ',失败 ' + errN + ' 项' : ''}`, errN ? 'err' : 'ok');
- console.log('auto_all results:', r.results);
- } catch (e) { toast(e.message, 'err'); }
- finally { btn.disabled = false; btn.textContent = '一键完成可自动任务'; loadTasks(); }
+ const r = await api('accounts/' + encodeURIComponent(uid) + '/tasks/auto_all', { method: 'POST' });
+ toast(r.started ? '后台任务已启动,关闭页面后仍会继续执行' : '该账号已有后台任务,已恢复进度显示', 'ok');
+ if (taskUID === uid) { taskJobGeneration++; renderAccountTaskJob(r.job); startAccountTaskJobPolling(uid); }
+ if (view === 'taskscenter') loadTaskJobs();
+ } catch (e) {
+ toast(e.message, 'err');
+ if (taskUID === uid) { btn.disabled = false; btn.textContent = '后台完成可自动任务'; }
+ }
};
+function taskJobPresentation(job) {
+ const active = ['pending', 'running'].includes(job.status);
+ const labels = { pending: '排队中', running: '执行中', finished: '已结束', interrupted: '已中断', error: '执行失败' };
+ const trigger = { new_account: '新增账号', daily: '每日定时', manual: '手动启动', resume: '重启续跑' }[job.trigger] || job.trigger || '';
+ const progress = '处理 ' + (job.completed || 0) + ' / ' + (job.total || 0) + ' 项';
+ const summary = taskResultSummary(job.results || []);
+ const current = active && job.current_task ? '正在处理:' + job.current_task : '';
+ return { active, trigger, progress, label: labels[job.status] || job.status,
+ message: [labels[job.status] || job.status, progress, current, job.error || job.message, summary.message].filter(Boolean).join(' · '),
+ severity: job.status === 'error' || job.status === 'interrupted' ? 'err' : summary.severity };
+}
+
+function taskJobRows(job) {
+ return (job.results || []).map(item => qrowHTML({ code: item.task_code || '', title: item.title,
+ status: taskOutcomeStatus(item), message: item.message || item.claim_error,
+ prog: item.progress_after || item.progress_before || '' })).join('');
+}
+
+function renderAccountTaskJob(job) {
+ $('taskJobView').hidden = !job;
+ if (!job) return;
+ taskJobID = job.id;
+ const info = taskJobPresentation(job);
+ $('taskJobState').className = 'state ' + info.severity;
+ $('taskJobState').textContent = info.message;
+ $('taskJobResults').innerHTML = taskJobRows(job) || '等待后台任务开始处理
';
+ $('btnTaskAutoAll').disabled = info.active;
+ $('btnTaskAutoAll').textContent = info.active ? '后台执行中…' : '后台完成可自动任务';
+}
+
+async function loadAccountTaskJob(uid) {
+ if (taskJobPolling === uid) return;
+ taskJobPolling = uid;
+ const generation = taskJobGeneration;
+ try {
+ const d = await api('accounts/' + encodeURIComponent(uid) + '/tasks/job');
+ if (taskUID !== uid || taskJobGeneration !== generation) return;
+ const previous = taskJobID;
+ renderAccountTaskJob(d.job);
+ if (d.job && taskJobPresentation(d.job).active) startAccountTaskJobPolling(uid);
+ else {
+ if (taskJobTimer) clearInterval(taskJobTimer);
+ taskJobTimer = null;
+ if (previous && d.job && previous === d.job.id) loadTasks();
+ }
+ } catch (e) {
+ if (taskUID === uid && taskJobGeneration === generation) { $('taskJobView').hidden = false; $('taskJobState').className = 'state err'; $('taskJobState').textContent = '后台进度查询失败:' + e.message; }
+ } finally {
+ if (taskJobPolling === uid) taskJobPolling = null;
+ if (taskUID && (taskUID !== uid || taskJobGeneration !== generation) && !taskJobTimer) loadAccountTaskJob(taskUID);
+ }
+}
+
+function startAccountTaskJobPolling(uid) {
+ if (taskJobTimer) clearInterval(taskJobTimer);
+ taskJobTimer = setInterval(() => { if (taskUID === uid) loadAccountTaskJob(uid); }, 3000);
+}
+
+async function loadTaskJobs() {
+ // go() 顶层调用也会进入此函数,首次 await 前不读取后声明的状态。
+ await Promise.resolve();
+ if (taskJobsLoading) return;
+ taskJobsLoading = true;
+ try {
+ const d = await api('tasks/jobs');
+ const jobs = d.jobs || [];
+ $('taskJobsSummary').textContent = jobs.length ? jobs.filter(j => taskJobPresentation(j).active).length + ' 个账号执行中' : '';
+ $('taskJobsNote').hidden = !!jobs.length && d.auto_enabled !== false;
+ $('taskJobsNote').textContent = d.auto_enabled === false ? '自动成长任务已关闭,可在账号任务中手动启动后台执行。' : '新增国区账号后自动执行,每日按成长任务排程继续推进;可在账号任务中手动启动。';
+ const expanded = new Set(Array.from($('taskJobsList').querySelectorAll('details[open][data-job-id]'), el => el.dataset.jobId));
+ $('taskJobsList').innerHTML = jobs.map(job => {
+ const info = taskJobPresentation(job);
+ return '' + esc(job.nickname || job.uid.slice(0, 12)) + '' +
+ '' + esc(info.trigger + ' · ' + info.label + ' · ' + info.progress) + '' +
+ '' +
+ '
' + esc(info.message) + '
' +
+ '
查看结果 · 更新于 ' + esc(ago(job.updated_at)) + '
' + taskJobRows(job) + ' ';
+ }).join('');
+ } catch (e) {
+ $('taskJobsNote').hidden = false; $('taskJobsNote').textContent = '后台任务查询失败:' + e.message;
+ } finally { taskJobsLoading = false; }
+}
+$('btnTaskJobsReload').onclick = loadTaskJobs;
+$('taskJobsList').addEventListener('click', ev => {
+ const b = ev.target.closest('button[data-job-uid]');
+ if (b) openTasks(b.dataset.jobUid);
+});
+
async function loadTasks() {
if (!taskUID) return;
+ const uid = taskUID;
const st = $('taskState'), tb = $('taskTable');
st.hidden = false;
st.className = 'state';
st.innerHTML = '查询中';
tb.hidden = true;
try {
- const d = await api('accounts/' + encodeURIComponent(taskUID) + '/tasks');
+ const d = await api('accounts/' + encodeURIComponent(uid) + '/tasks');
+ if (taskUID !== uid) return;
+ taskCapabilities = d.capabilities || { accept: d.realm !== 'global', claim: d.realm !== 'global', automate: d.realm !== 'global' };
+ $('btnTaskAcceptAll').hidden = !taskCapabilities.accept;
+ $('btnTaskAutoAll').hidden = !taskCapabilities.automate;
+ $('taskNote').textContent = d.message || (d.realm === 'global' ? '国际区任务当前仅支持查询,请在官方客户端完成。' : '');
+ $('taskNote').hidden = !$('taskNote').textContent;
const list = d.tasks || [];
if (!list.length) {
st.className = 'state';
@@ -1324,35 +1946,25 @@ async function loadTasks() {
list.sort((a, b) => (a.claimed - b.claimed) || (b.claimable - a.claimable) || String(a.task_code).localeCompare(String(b.task_code)));
$('taskBody').innerHTML = list.map(t => {
// 进度:current 可能缺失(0 或被上游省略)——用 ?? 兜底,避免渲染成 "undefined / N"
- const cur = t.current ?? 0, tgt = t.target ?? 0;
- const prog = tgt ? cur + ' / ' + tgt : (tgt === 0 && cur > 0 ? String(cur) : '—');
- const parts = [];
- if (t.credit) parts.push('+' + t.credit + ' 分');
- if (t.energy) parts.push('+' + t.energy + ' 能');
- if (t.reward_buddy) parts.push('Buddy');
- const reward = parts.length ? parts.join(' ') : '—';
- const badge = t.claimed ? '已领取'
- : t.claimable ? '可领取'
- : t.locked ? '未解锁'
- : t.accept_status === 'accepted' ? '进行中'
- : '未接受';
- const acted = t.claimed || t.locked ? ''
- : t.claimable ? ''
- : AUTO_TASKS[t.task_code] ? ''
- : t.accept_status === 'accepted' ? ''
- : '';
+ const row = taskRowPresentation(t, taskCapabilities);
+ const badge = '' + esc(row.state) + '';
+ const acted = row.action ? '' : '';
// 操作指引(description/task_desc)挂 title 提示:如何完成交给用户看
const tip = [t.title, t.task_desc || t.description, t.jump_url ? '跳转:' + t.jump_url : ''].filter(Boolean).join('\n');
return '
| ' +
- '' + esc(t.title || t.task_code) + ' ' + esc(t.task_code) + (t.tag ? ' · ' + esc(t.tag) : '') + ' | ' +
- '' + esc(prog) + ' | ' +
- '' + esc(reward) + ' | ' +
+ '' + esc(t.title || row.code) + ' ' + esc(row.code) + (t.tag ? ' · ' + esc(t.tag) : '') + ' | ' +
+ '' + esc(row.progress) + ' | ' +
+ '' + esc(row.reward) + ' | ' +
'' + badge + ' | ' +
'' + acted + ' |
';
}).join('');
st.hidden = true;
tb.hidden = false;
} catch (e) {
+ if (taskUID !== uid) return;
+ taskCapabilities = { accept: false, claim: false, automate: false };
+ $('btnTaskAcceptAll').hidden = true;
+ $('btnTaskAutoAll').hidden = true;
st.className = 'state err';
st.textContent = e.message;
}
@@ -1362,6 +1974,7 @@ $('taskBody').addEventListener('click', async ev => {
const b = ev.target.closest('button[data-t]');
if (!b || !taskUID) return;
const kind = b.dataset.t, code = b.dataset.c;
+ if (!code || !(kind === 'auto' ? taskCapabilities.automate : taskCapabilities[kind])) return;
b.disabled = true;
try {
if (kind === 'auto') {
@@ -1370,17 +1983,9 @@ $('taskBody').addEventListener('click', async ev => {
const r = await api('accounts/' + encodeURIComponent(taskUID) + '/tasks/auto', {
method: 'POST', body: JSON.stringify({ task_code: code })
});
- if (r.skipped) {
- toast(r.message || '已跳过', 'ok');
- } else {
- const advanced = r.progress_before !== r.progress_after;
- let msg = r.message || '已执行';
- if (r.progress_after) msg += `(进度 ${r.progress_before} → ${r.progress_after})`;
- if (r.claimed) msg += ',奖励已自动到账';
- else if (r.claimable) msg += r.claim_error ? ',可点「领取」重试' : '';
- else if (r.attempt && !advanced) msg += ';进度未动,该任务可能需要官方客户端';
- toast(msg, (r.claimed || advanced) ? 'ok' : 'err');
- }
+ const status = taskOutcomeStatus(r);
+ const severity = status === 'done' ? 'ok' : status === 'error' || status === 'claim_pending' ? 'err' : '';
+ toast(r.message || taskResultSummary([r]).message, severity);
loadOverview(true);
} else {
const path = 'accounts/' + encodeURIComponent(taskUID) + '/tasks/' + (kind === 'claim' ? 'claim' : 'accept');
@@ -1659,7 +2264,7 @@ $('btnScanAll').onclick = async () => {
b.disabled = true; b.textContent = '扫描中…';
try {
const d = await api('tasks/scan_all', { method: 'POST' });
- renderQueue(groupItems(d), null, '没有待办任务 🎉', '全部账号的成长任务与开学季活动都已完成,明日再来。');
+ renderQueue(groupItems(d), null, '未发现可自动执行的待办', '当前扫描范围没有可自动执行的任务;跳过和扫描失败的账号会单独列出。');
} catch (e) { toast(e.message, 'err'); }
finally { b.disabled = false; b.textContent = '扫描待办'; }
};
@@ -1670,7 +2275,11 @@ $('btnRunQueue').onclick = async () => {
b.disabled = true; b.textContent = '启动中…';
try {
const r = await api('tasks/run_queue', { method: 'POST', body: JSON.stringify({ concurrency: conc }) });
- if (!r.started) { toast(r.message || '没有待办任务', 'ok'); return; }
+ if (!r.started) {
+ if (r.scan_accounts) renderQueue(groupItems({ accounts: r.scan_accounts }), null, '未启动任务队列', r.message || '没有可自动执行的待办');
+ toast(r.message || '没有可自动执行的待办', r.scan_error_count ? 'err' : '');
+ return;
+ }
lastQueueSeq = r.seq || 0;
toast('队列已启动:' + r.total + ' 项(并发 ' + conc + ')', 'ok');
startQueuePolling();
@@ -1686,21 +2295,28 @@ function groupItems(d) {
GROWTH_TITLES[t.task_code] = t.title || t.task_code;
rows.push({ kind: 'growth', code: t.task_code, prog: t.target ? t.current + '/' + t.target : '—', status: 'scan' });
}
+ if (a.growth_error) rows.push({ kind: 'account', code: '', title: '扫描失败', status: 'error', message: a.growth_error });
+ if (a.skipped) rows.push({ kind: 'account', code: '', title: '已跳过', status: 'skipped', message: a.skip_reason || '该账号不适用当前自动任务' });
if (rows.length) groups.push({ uid: a.uid, nick: a.nickname, rows });
}
return groups;
}
-const ST_WORDS = { done: '完成', running: '执行中', error: '失败', skipped: '跳过', pending: '排队', scan: '待执行' };
+const ST_WORDS = { done: '完成', running: '执行中', error: '失败', skipped: '跳过', pending: '排队', scan: '待执行', awaiting_progress: '等待计分', claim_pending: '领奖待重试', accepted: '已接受' };
+function taskProgressLabel(value) {
+ const progress = String(value ?? '').trim();
+ return /^\d+(?:\s*\/\s*\d+)?$/.test(progress) ? progress : '—';
+}
function qrowHTML(it) {
const isSchool = it.kind === 'school';
- const title = isSchool ? '开学季闭环' : (GROWTH_TITLES[it.code] || it.code);
+ const title = it.title || (isSchool ? '开学季闭环' : (GROWTH_TITLES[it.code] || it.code));
const dotCls = it.status === 'scan' ? 'wait' : it.status === 'running' ? 'run' : it.status === 'error' ? 'err' : it.status === 'skipped' ? 'skip' : it.status === 'done' ? 'done' : 'wait';
const stWord = it.status === 'scan' ? '待执行' : (ST_WORDS[it.status] || it.status);
+ const progress = taskProgressLabel(it.prog);
return '' +
'' + esc(it.code) + '' +
'' + esc(title) + '' + (isSchool ? '开学季' : '') + '' +
- '' + esc(it.prog || '') + '' +
- '' + stWord + '' +
+ '' + esc(progress) + '' +
+ '' + esc(stWord) + '' +
'' + esc(it.message || '') + '' +
'
';
}
@@ -1719,16 +2335,18 @@ function renderQueue(groups, progress, emptyTitle, emptyDesc) {
let total = 0;
list.innerHTML = groups.map(g => {
total += g.rows.length;
- return '' + esc(g.nick || g.uid.slice(0, 12)) + '' + g.rows.length + ' 项待办' +
+ const tasks = g.rows.filter(row => row.kind !== 'account').length;
+ return '
' + esc(g.nick || g.uid.slice(0, 12)) + '' + (tasks ? tasks + ' 项任务' : '账号状态') + '' +
g.rows.map(qrowHTML).join('') + '
';
}).join('');
- $('qcSummary').textContent = total + ' 项';
+ const taskCount = groups.reduce((sum, group) => sum + group.rows.filter(row => row.kind !== 'account').length, 0);
+ $('qcSummary').textContent = taskCount + ' 项任务' + (total > taskCount ? ' · ' + (total - taskCount) + ' 条账号状态' : '');
updateProgress(progress);
}
function updateProgress(q) {
if (!q || !q.items) { $('qProg').hidden = true; return; }
const total = q.items.length;
- const done = q.items.filter(it => it.status === 'done' || it.status === 'error' || it.status === 'skipped').length;
+ const done = q.items.filter(it => ['done', 'error', 'skipped', 'awaiting_progress', 'claim_pending'].includes(it.status)).length;
$('qProg').hidden = false;
$('qBarFill').style.width = (total ? Math.round(done / total * 100) : 0) + '%';
$('qProgText').textContent = (q.running ? '执行中 ' : '已结束 ') + done + ' / ' + total;
@@ -1762,12 +2380,14 @@ function startQueuePolling() {
// 回写视图——曾把用户刚点开的「扫描待办」结果在下一个 tick 冲掉。
renderQueue(groupsFromQueue(q.items || []), q);
clearInterval(queueTimer); queueTimer = null;
- toast('任务队列执行结束', 'ok');
+ const summary = taskResultSummary(q.items || []);
+ toast('任务队列已结束:' + summary.message, summary.severity);
}, 3000);
}
// reattachQueueView 切回任务中心视图时恢复队列进度:仅当本页启动的队列仍在
// 执行才重新开轮询(残留态/别页队列不接管——视图不被旧结果冲掉)。
function reattachQueueView() {
+ loadTaskJobs();
// 全程异步:go() 在顶层(app.js ~143 行)被调用时,本文件下方 let/const
//(queueTimer/lastQueueSeq 等)尚未初始化——同步读取即 TDZ ReferenceError
// 使整个脚本中断。await 之后才碰它们(旧 pollQueueOnce 正是靠开头的 await
@@ -1857,10 +2477,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 +2550,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 +2610,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) + ' | ' +
@@ -2730,7 +3355,16 @@ if ($('pkSort')) {
};
}
-async function loadPackages() {
+// loadPackages 按需拉取:有缓存(上次任意入口拉到的 packages 数据)先直接渲染,
+// 不再「切进积分构成页就打一次上游」——逐账号实时查询在账号多时既慢又压上游。
+// 无缓存(首次进入)才自动查一次;显式刷新(force=true,点「刷新」按钮)始终实时查。
+async function loadPackages(force) {
+ if (!force && lastPackages) {
+ renderPackages(lastPackages, lastDetailLimit);
+ const ageMin = Math.floor((Date.now() - lastPackagesAt) / 60000);
+ if (ageMin > 0) $('pkNote').textContent = lastPackages.accounts.length + ' 个账号 · ' + ageMin + ' 分钟前的数据,点「刷新」更新';
+ return;
+ }
$('pkSummary').innerHTML = '查询中…(逐账号向上游实时查询)
';
$('pkDetail').innerHTML = '';
$('pkExpiry').innerHTML = '查询中…
';
@@ -2774,8 +3408,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 +3432,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 +3471,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 +3479,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 +3493,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;
@@ -2865,4 +3621,93 @@ async function loadExpiry(force) {
if ($('btnExp')) $('btnExp').onclick = () => loadExpiry(true);
-if ($('btnPk')) $('btnPk').onclick = loadPackages;
+// 「刷新」始终实时查(显式 force,不依赖事件对象真值)
+if ($('btnPk')) $('btnPk').onclick = () => loadPackages(true);
+
+/* ══ 账号备注(只写本机 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..29ebf04d 100644
--- a/internal/panel/autotask.go
+++ b/internal/panel/autotask.go
@@ -18,7 +18,7 @@
// 仍未破解:skill_1(疑似要求真实 Skill 工具调用)。
// 不做:Expert_lighthouse(需真实连接器授权)、Expert_Philanthropy(真实捐款)。
//
-// 所有动作幂等:已 claimed/已达标的任务直接跳过,不重复消耗上游配额。
+// 已领取任务不重复执行,已达标未领奖的任务只领奖,不重复消耗对话配额。
package panel
import (
@@ -45,6 +45,24 @@ type autoAction struct {
run func(p *Panel, a *auth.Auth) (string, error)
}
+const globalTaskWriteMessage = "国际区任务暂仅支持查询,请在官方客户端操作"
+
+// autoTaskOutcome 是统一任务执行器的结果。Status 是任务状态机的唯一完成判据:
+// done 只表示服务端已确认领取或领奖请求已成功;未入账、领奖失败、读错均保留各自状态。
+type autoTaskOutcome struct {
+ Status string
+ Message string
+ ProgressBefore string
+ ProgressAfter string
+ Claimable bool
+ Claimed bool
+ Credit int64
+ Energy int64
+ ClaimError string
+ Attempt bool
+ ActionRun bool
+}
+
// autoActions 已实现的任务动作表(顺序即执行顺序:先解锁依赖项)。
// first_buddy 依赖活跃上报解锁,故 chat_5/first_buddy 的执行都自带 report 步骤。
var autoActions = []autoAction{
@@ -215,6 +233,18 @@ 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 能)。
+//
+// #104 起「一键完成」链路的领奖统一走 claimAutoTask(同一处判断 mp/普通码);
+// 本 helper 是等价封装,供旁路(周期补领清扫等)按任务码领奖时复用。
+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 码,
// 未知码不多打一次上游)。
@@ -257,7 +287,7 @@ func (p *Panel) taskByCodeWaiting(a *auth.Auth, code string) (*upstream.Task, er
time.Sleep(claimPollGap)
t2, err2 := p.taskByCode(a, code)
if err2 != nil {
- return t, nil // 轮询期间的查询失败不覆盖已拿到的结果
+ return t, err2 // 回读失败不能把旧进度当作本轮已确认结果
}
if t2 != nil {
t = t2
@@ -342,7 +372,7 @@ func (p *Panel) runMPMiniChatTask(a *auth.Auth, code string, withActivityId bool
}
if t.AcceptStatus == "not_accepted" || t.AcceptStatus == "" {
if !p.acceptWithVerifyMP(a, code) {
- return "accept 未登记生效(上游 200+OK 但未落账形态),待下次重试", nil
+ return "", fmt.Errorf("accept 未登记生效,上游未确认任务已接受")
}
// accept 前的任务进度为 null(target 下发 0),兜底 target=1 会少报——
// Tasks_6 首轮实测:accept 后真实 target=10,只补 1 条就误判达标去领奖
@@ -376,7 +406,7 @@ func (p *Panel) runMPMiniChatTask(a *auth.Auth, code string, withActivityId bool
ev = upstream.SchoolChatTimesEvents(conv)
}
if err := p.cfg.Upstream.ReportMPEvent(a, ev); err != nil {
- return fmt.Sprintf("完成 %d/%d 次上报后中断: %v", i, need, err), nil
+ return "", fmt.Errorf("完成 %d/%d 次上报后中断: %w", i, need, err)
}
}
// 回读(异步计分,有界轮询复用 claimPoll 预算的紧凑版:两轮各隔 3s)。
@@ -463,11 +493,11 @@ func (p *Panel) runSequentialEventTask(a *auth.Auth, code string, primary, fallb
}
if t.AcceptStatus == "not_accepted" || t.AcceptStatus == "" {
if !p.acceptWithVerifyMP(a, code) {
- return "accept 未登记生效(任务可能处于每日锁定窗口,等解锁后自动重试)", nil
+ return "", fmt.Errorf("accept 未登记生效,任务可能处于每日锁定窗口")
}
}
if err := primary(); err != nil {
- return fmt.Sprintf("判据上报失败: %v", err), nil
+ return "", fmt.Errorf("判据上报失败: %w", err)
}
// 回读(两轮各隔 3s);未点亮且有 fallback 时补报一轮再读。
for round := 0; round < 2; round++ {
@@ -482,7 +512,7 @@ func (p *Panel) runSequentialEventTask(a *auth.Auth, code string, primary, fallb
}
if round == 0 && fallback != nil {
if err := fallback(); err != nil {
- return fmt.Sprintf("备选判据上报失败: %v", err), nil
+ return "", fmt.Errorf("备选判据上报失败: %w", err)
}
}
}
@@ -573,8 +603,11 @@ func runMiniExpert(p *Panel, a *auth.Auth) (string, error) {
}
// 判据载体前置(accept 之前):市场真实专家 id。
experts, merr := p.cfg.Upstream.MarketExpertList(a, "")
- if merr != nil || len(experts) == 0 {
- return fmt.Sprintf("专家市场不可用(%v),跳过以防半程态", merr), nil
+ if merr != nil {
+ return "", fmt.Errorf("专家市场不可用,跳过以防半程态: %w", merr)
+ }
+ if len(experts) == 0 {
+ return "", fmt.Errorf("专家市场列表为空,跳过以防半程态")
}
e := experts[0]
name := e.DisplayNameZH
@@ -583,12 +616,12 @@ func runMiniExpert(p *Panel, a *auth.Auth) (string, error) {
}
if t.AcceptStatus == "not_accepted" || t.AcceptStatus == "" {
if !p.acceptWithVerifyMP(a, code) {
- return "accept 未登记生效(上游 200+OK 但未落账形态),待下次重试", nil
+ return "", fmt.Errorf("accept 未登记生效,上游未确认任务已接受")
}
}
ev := upstream.MiniExpertUseEvent(e.ExpertID, name, e.ExpertType)
if err := p.cfg.Upstream.ReportMPEvent(a, ev); err != nil {
- return fmt.Sprintf("上报 expert_actual_use 失败: %v", err), nil
+ return "", fmt.Errorf("上报 expert_actual_use 失败: %w", err)
}
// 回读(异步计分,两轮各隔 3s——与 runMPMiniChatTask 同预算)。
for i := 0; i < 2; i++ {
@@ -622,6 +655,20 @@ 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
+ }
+ // 国际区(global)账号:成长任务写操作仅官方客户端支持(#104)。
+ if a.IsGlobal() {
+ writeErr(w, http.StatusNotImplemented, globalTaskWriteMessage)
+ return
+ }
var body struct {
TaskCode string `json:"task_code"`
}
@@ -642,77 +689,10 @@ func (p *Panel) accountTaskAuto(w http.ResponseWriter, r *http.Request) {
return
}
defer p.unlockAccount(uid)
- // 前置读取:已完成的任务直接跳过(幂等,不浪费上游调用)。
- // taskByCode 已双口径(mp 专属码自动回落 mp 列表)。
- before, err := p.taskByCode(a, act.TaskCode)
- if err != nil {
- writeErr(w, http.StatusBadGateway, "list tasks: "+err.Error())
- return
- }
- if before == nil {
- writeErr(w, http.StatusNotFound, "该账号没有此任务")
- return
- }
- isMP := isMPTaskCode(act.TaskCode)
- if before.Claimed {
- writeJSON(w, http.StatusOK, map[string]any{"ok": true, "skipped": true, "message": "该任务已领取过奖励"})
- return
- }
- msg, err := act.run(p, a)
- if err != nil {
- writeErr(w, http.StatusBadGateway, "执行失败: "+err.Error())
- return
- }
- // 回读验证:上报 200 ≠ 计分(上游可能静默丢弃 + 计分异步),
- // 用有界轮询等异步计时落定,再决定是否自动领奖(mp 任务同口径回读)。
- var after *upstream.Task
- var aerr error
- if isMP {
- after, aerr = p.taskByCodeMP(a, act.TaskCode)
- } else {
- after, aerr = p.taskByCodeWaiting(a, act.TaskCode)
- }
- progressBefore, progressAfter := taskProgressText(before), ""
- claimable := false
- if aerr == nil && after != nil {
- progressAfter = taskProgressText(after)
- claimable = after.Claimable
- }
- resp := map[string]any{
- "ok": true,
- "message": msg,
- "progress_before": progressBefore,
- "progress_after": progressAfter,
- "claimable": claimable,
- "attempt": act.Attempt,
- "verify_supported": true,
- }
- // 达标即自动领奖(Web 端 claim):把"完成→领奖"收敛成一步,无需用户再点一次。
- if claimable {
- var credit, energy int64
- var cerr error
- if isMP {
- credit, energy, cerr = p.cfg.Upstream.ClaimRewardMP(a, act.TaskCode)
- } else {
- credit, energy, cerr = p.cfg.Upstream.ClaimReward(a, act.TaskCode)
- }
- if cerr == nil {
- resp["claimed"] = true
- resp["credit"] = credit
- resp["energy"] = energy
- if credit > 0 || energy > 0 {
- resp["message"] = msg + fmt.Sprintf(";已自动领奖 +%d 分 +%d 能", credit, energy)
- } else {
- resp["message"] = msg + ";奖励此前已领取"
- }
- } else {
- resp["claim_error"] = cerr.Error()
- resp["message"] = msg + ";达标但领奖失败,可在任务列表手动点「领取」重试"
- }
- }
- log.Printf("panel: 任务动作 uid=%s code=%s progress %s -> %s claimable=%v claimed=%v",
- uid, act.TaskCode, progressBefore, progressAfter, claimable, resp["claimed"])
- writeJSON(w, http.StatusOK, resp)
+ outcome := p.executeAutoTask(a, act)
+ log.Printf("panel: 任务动作 uid=%s code=%s status=%s progress %s -> %s claimed=%v",
+ uid, act.TaskCode, outcome.Status, outcome.ProgressBefore, outcome.ProgressAfter, outcome.Claimed)
+ writeJSON(w, http.StatusOK, outcome.resultMap(act))
}
// taskProgressText 任务进度的可读表示(回读对比用)。
@@ -729,6 +709,170 @@ func taskProgressText(t *upstream.Task) string {
return t.AcceptStatus
}
+func (o autoTaskOutcome) resultMap(act *autoAction) map[string]any {
+ result := map[string]any{
+ "ok": o.Status != "error",
+ "status": o.Status,
+ "message": o.Message,
+ "progress_before": o.ProgressBefore,
+ "progress_after": o.ProgressAfter,
+ "claimable": o.Claimable,
+ "claimed": o.Claimed,
+ "attempt": o.Attempt,
+ "verify_supported": true,
+ }
+ if act != nil {
+ result["task_code"] = act.TaskCode
+ result["desc"] = act.Desc
+ }
+ if o.Credit != 0 || o.Energy != 0 {
+ result["credit"] = o.Credit
+ result["energy"] = o.Energy
+ }
+ if o.ClaimError != "" {
+ result["claim_error"] = o.ClaimError
+ }
+ return result
+}
+
+// taskProgressReached 报告任务进度是否已达标。
+//
+// 判据**只用 progress 口径**(Current>=Target):上游回读时 accept_status 会短暂
+// 转 "completed" 再回滚,status 字段也会出现 "complete",据此判达标会误领
+// 还没真正入账的奖励(分支 issue #174 的教训)。Target<=0 = 上游没给目标数,
+// 不算达标。
+func taskProgressReached(t *upstream.Task) bool {
+ return t != nil && t.Target > 0 && t.Current >= t.Target
+}
+
+// executeAutoTask 统一执行任务:只在服务端确认进度达标后领奖,只有服务端确认已领
+// 或领奖接口成功才返回 done。已达标未领取时先领奖,避免重复执行真实对话动作。
+func (p *Panel) executeAutoTask(a *auth.Auth, act *autoAction) (out autoTaskOutcome) {
+ taskCode := "?"
+ uid := "?"
+ if act != nil {
+ taskCode = act.TaskCode
+ }
+ if a != nil {
+ uid = a.UID
+ }
+ defer func() {
+ log.Printf("panel: task auto uid=%s code=%s status=%s progress=%s->%s claimed=%v claim_error=%q",
+ uid, taskCode, out.Status, truncateStr(out.ProgressBefore, 64),
+ truncateStr(out.ProgressAfter, 64), out.Claimed, truncateStr(out.ClaimError, 180))
+ }()
+ if a == nil {
+ return autoTaskOutcome{Status: "error", Message: "账号凭证不可用"}
+ }
+ if a.IsGlobal() {
+ return autoTaskOutcome{Status: "error", Message: globalTaskWriteMessage}
+ }
+ if act == nil {
+ return autoTaskOutcome{Status: "skipped", Message: "未配置该任务的自动动作"}
+ }
+ out = autoTaskOutcome{Attempt: act.Attempt}
+ before, err := p.taskByCode(a, act.TaskCode)
+ if err != nil {
+ out.Status = "error"
+ out.Message = "查询任务失败: " + err.Error()
+ return out
+ }
+ if before == nil {
+ out.Status = "skipped"
+ out.Message = "该账号没有此任务"
+ return out
+ }
+ out.ProgressBefore = taskProgressText(before)
+ if before.Locked {
+ out.Status = "skipped"
+ out.Message = "任务尚未解锁"
+ return out
+ }
+ if before.Claimed {
+ out.Status = "done"
+ out.Claimed = true
+ out.Message = "该任务已领取过奖励"
+ out.ProgressAfter = taskProgressText(before)
+ return out
+ }
+ if taskProgressReached(before) {
+ return p.claimAutoTask(a, act, "任务已达标", before, before)
+ }
+ if act.run == nil {
+ out.Status = "skipped"
+ out.Message = "该任务没有可执行的自动动作"
+ return out
+ }
+ message, err := act.run(p, a)
+ out.ActionRun = true
+ if err != nil {
+ out.Status = "error"
+ out.Message = "执行失败: " + err.Error()
+ return out
+ }
+ after, err := p.taskByCodeWaiting(a, act.TaskCode)
+ if err != nil {
+ out.Status = "error"
+ out.Message = message + ";回读进度失败: " + err.Error()
+ if after != nil {
+ out.ProgressAfter = taskProgressText(after)
+ out.Claimable = taskProgressReached(after)
+ }
+ return out
+ }
+ if after == nil {
+ out.Status = "awaiting_progress"
+ out.Message = message + ";回读时未找到该任务,暂不能确认完成"
+ return out
+ }
+ out.ProgressAfter = taskProgressText(after)
+ out.Claimable = taskProgressReached(after)
+ if after.Claimed {
+ out.Status = "done"
+ out.Claimed = true
+ out.Message = message + ";服务端已确认领取"
+ return out
+ }
+ if !out.Claimable {
+ out.Status = "awaiting_progress"
+ out.Message = message + ";服务端进度尚未达标,等待入账后再确认"
+ return out
+ }
+ return p.claimAutoTask(a, act, message, before, after)
+}
+
+func (p *Panel) claimAutoTask(a *auth.Auth, act *autoAction, message string, before, after *upstream.Task) autoTaskOutcome {
+ out := autoTaskOutcome{
+ Status: "done",
+ Message: message,
+ ProgressBefore: taskProgressText(before),
+ ProgressAfter: taskProgressText(after),
+ Claimable: true,
+ Attempt: act.Attempt,
+ }
+ var credit, energy int64
+ var err error
+ if isMPTaskCode(act.TaskCode) {
+ credit, energy, err = p.cfg.Upstream.ClaimRewardMP(a, act.TaskCode)
+ } else {
+ credit, energy, err = p.cfg.Upstream.ClaimReward(a, act.TaskCode)
+ }
+ if err != nil {
+ out.Status = "claim_pending"
+ out.ClaimError = err.Error()
+ out.Message = message + ";任务已达标,但领奖失败: " + err.Error()
+ return out
+ }
+ out.Claimed = true
+ out.Credit, out.Energy = credit, energy
+ if credit > 0 || energy > 0 {
+ out.Message = message + fmt.Sprintf(";已自动领奖 +%d 分 +%d 能", credit, energy)
+ } else {
+ out.Message = message + ";领奖接口已确认(奖励此前可能已领取)"
+ }
+ return out
+}
+
// truncateStr 截断错误文本(避免把上游长响应原样透给前端)。
func truncateStr(s string, n int) string {
if len(s) <= n {
@@ -764,7 +908,7 @@ func runChat5(p *Panel, a *auth.Auth) (string, error) {
for i := int64(0); i < need; i++ {
cid := fmt.Sprintf("wb2api-chat5-%d-%d", time.Now().UnixMilli(), i)
if err := p.cfg.Upstream.ReportChatActivity(a, cid, ""); err != nil {
- return fmt.Sprintf("上报第 %d/%d 条失败: %v", i+1, need, err), nil
+ return "", fmt.Errorf("上报第 %d/%d 条失败: %w", i+1, need, err)
}
if i < need-1 {
time.Sleep(reportGap)
@@ -821,7 +965,7 @@ func runModelChat(p *Panel, a *auth.Auth) (string, error) {
time.Sleep(reportGap)
// 3. 对齐模型的上报(触发进度)
if err := p.cfg.Upstream.ReportChatActivityModel(a, fmt.Sprintf("wb2api-glm52-%d", time.Now().UnixMilli()), "", modelID, modelName); err != nil {
- return "对话已完成,但进度上报失败:" + err.Error(), nil
+ return "", fmt.Errorf("对话已完成,但进度上报失败: %w", err)
}
return "已完成 glm-5.2 对话并上报", nil
}
@@ -896,7 +1040,7 @@ func runBlackCat(p *Panel, a *auth.Auth) (string, error) {
}
ok, err := p.cfg.Upstream.RunNightChats(a, int(need))
if err != nil {
- return fmt.Sprintf("完成 %d/%d 次后中断: %v", ok, need, err), nil
+ return "", fmt.Errorf("完成 %d/%d 次后中断: %w", ok, need, err)
}
return fmt.Sprintf("已完成 %d 次夜间对话并上报", ok), nil
}
@@ -1014,7 +1158,7 @@ func runTemplateUse(p *Panel, a *auth.Auth) (string, error) {
req := fmt.Sprintf("wb2api-tpl-req-%d-%d", ms, i)
events := upstream.DesktopTemplateUseSequence(conv, req, tp[0], tp[1])
if err := p.cfg.Upstream.ReportDesktopEvent(a, events...); err != nil {
- return fmt.Sprintf("第 %d 组模板事件上报失败: %v", i+1, err), nil
+ return "", fmt.Errorf("第 %d 组模板事件上报失败: %w", i+1, err)
}
time.Sleep(300 * time.Millisecond)
}
@@ -1101,7 +1245,9 @@ func runExpertBatch(p *Panel, a *auth.Auth, expertType string, count int) (strin
time.Sleep(expertSummonGap)
}
}
- _ = fail
+ if ok < count {
+ return "", fmt.Errorf("专家召唤+使用链仅成功 %d/%d 位(%d 位失败)", ok, count, fail)
+ }
return fmt.Sprintf("已对 %d 位真实专家完成召唤+使用链(类型 %s)", ok, expertType), nil
}
@@ -1115,124 +1261,152 @@ func runExpertBatch(p *Panel, a *auth.Auth, expertType string, count int) (strin
// 流程:先把所有未接受的任务批量 accept(规范状态机;上游脚本建议"先 accept"),
// 再逐项执行行为链路。accept 不是进度产生的必要条件,但让后续状态流转规范。
func (p *Panel) runAutoAll(a *auth.Auth) []map[string]any {
+ return p.runAutoAllObserved(context.Background(), a, nil)
+}
+
+type autoTaskRunEvent struct {
+ Kind string // phase, task_start, result
+ TaskCode string
+ Result map[string]any
+ Completed bool // true only for an autoAction result, not batch acceptance
+}
+
+// runAutoAllObserved 对全量任务执行流程提供进度事件。ctx 在每个网络阶段、
+// 自动任务项开始前检查;已有上游调用本身保持原超时语义,取消后不再发起下一项。
+func (p *Panel) runAutoAllObserved(ctx context.Context, a *auth.Auth, observe func(autoTaskRunEvent)) []map[string]any {
var out []map[string]any
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ emit := func(event autoTaskRunEvent) {
+ if observe != nil {
+ observe(event)
+ }
+ }
+ if a == nil {
+ return []map[string]any{{"task_code": "(全部)", "status": "error", "message": "账号凭证不可用"}}
+ }
+ if a.IsGlobal() {
+ return []map[string]any{{"task_code": "(全部)", "status": "error", "message": globalTaskWriteMessage}}
+ }
+ if ctx.Err() != nil {
+ return out
+ }
// 阶段 0:批量接受尚未接受的任务(失败不阻塞——行为事件才是进度唯一判据)。
- if tasks, err := p.cfg.Upstream.ListTasks(a); err == nil {
- var codes []string
- for _, t := range tasks {
- if !t.Claimed && !t.Locked && t.AcceptStatus != "accepted" && t.AcceptStatus != "completed" {
- codes = append(codes, t.TaskCode)
+ emit(autoTaskRunEvent{Kind: "phase", TaskCode: "(批量接受)"})
+ if ctx.Err() == nil {
+ if tasks, err := p.cfg.Upstream.ListTasks(a); err == nil && ctx.Err() == nil {
+ var codes []string
+ for _, t := range tasks {
+ if !t.Claimed && !taskProgressReached(&t) && !t.Locked && t.AcceptStatus != "accepted" && t.AcceptStatus != "completed" {
+ codes = append(codes, t.TaskCode)
+ }
}
- }
- if len(codes) > 0 {
- if err := p.cfg.Upstream.AcceptTasks(a, codes); err != nil {
- out = append(out, map[string]any{
- "task_code": "(批量接受)", "status": "error",
- "message": "接受任务失败(不阻塞后续): " + err.Error(),
- })
- } else {
- out = append(out, map[string]any{
- "task_code": "(批量接受)", "status": "done",
- "message": fmt.Sprintf("已接受 %d 个任务", len(codes)),
- })
- time.Sleep(reportGap)
+ if len(codes) > 0 && ctx.Err() == nil {
+ emit(autoTaskRunEvent{Kind: "phase", TaskCode: "(批量接受)"})
+ if ctx.Err() != nil {
+ return out
+ }
+ if err := p.cfg.Upstream.AcceptTasks(a, codes); err != nil {
+ result := map[string]any{
+ "task_code": "(批量接受)", "status": "error",
+ "message": "接受任务失败(不阻塞后续): " + err.Error(),
+ }
+ out = append(out, result)
+ emit(autoTaskRunEvent{Kind: "result", TaskCode: "(批量接受)", Result: result})
+ } else {
+ result := map[string]any{
+ "task_code": "(批量接受)", "status": "accepted",
+ "message": fmt.Sprintf("已接受 %d 个任务", len(codes)),
+ }
+ out = append(out, result)
+ emit(autoTaskRunEvent{Kind: "result", TaskCode: "(批量接受)", Result: result})
+ waitForContext(ctx, reportGap)
+ }
}
}
}
// 阶段 0b:小程序口径任务单独接受(默认列表不含 mp 码;失败不阻塞)。
- if mpTasks, err := p.cfg.Upstream.ListTasksMP(a); err == nil {
- var mpCodes []string
- for _, t := range mpTasks {
- if !t.Claimed && !t.Locked && t.AcceptStatus != "accepted" && t.AcceptStatus != "completed" {
- mpCodes = append(mpCodes, t.TaskCode)
- }
- }
- if len(mpCodes) > 0 {
- if err := p.cfg.Upstream.AcceptTasksMP(a, mpCodes); err != nil {
- out = append(out, map[string]any{
- "task_code": "(批量接受-mp)", "status": "error",
- "message": "接受小程序任务失败(不阻塞后续): " + err.Error(),
- })
- } else {
- out = append(out, map[string]any{
- "task_code": "(批量接受-mp)", "status": "done",
- "message": fmt.Sprintf("已接受 %d 个小程序任务", len(mpCodes)),
- })
- time.Sleep(reportGap)
+ if ctx.Err() == nil {
+ emit(autoTaskRunEvent{Kind: "phase", TaskCode: "(批量接受-mp)"})
+ if ctx.Err() == nil {
+ if mpTasks, err := p.cfg.Upstream.ListTasksMP(a); err == nil && ctx.Err() == nil {
+ var mpCodes []string
+ for _, t := range mpTasks {
+ if !t.Claimed && !taskProgressReached(&t) && !t.Locked && t.AcceptStatus != "accepted" && t.AcceptStatus != "completed" {
+ mpCodes = append(mpCodes, t.TaskCode)
+ }
+ }
+ if len(mpCodes) > 0 && ctx.Err() == nil {
+ emit(autoTaskRunEvent{Kind: "phase", TaskCode: "(批量接受-mp)"})
+ if ctx.Err() != nil {
+ return out
+ }
+ if err := p.cfg.Upstream.AcceptTasksMP(a, mpCodes); err != nil {
+ result := map[string]any{
+ "task_code": "(批量接受-mp)", "status": "error",
+ "message": "接受小程序任务失败(不阻塞后续): " + err.Error(),
+ }
+ out = append(out, result)
+ emit(autoTaskRunEvent{Kind: "result", TaskCode: "(批量接受-mp)", Result: result})
+ } else {
+ result := map[string]any{
+ "task_code": "(批量接受-mp)", "status": "accepted",
+ "message": fmt.Sprintf("已接受 %d 个小程序任务", len(mpCodes)),
+ }
+ out = append(out, result)
+ emit(autoTaskRunEvent{Kind: "result", TaskCode: "(批量接受-mp)", Result: result})
+ waitForContext(ctx, reportGap)
+ }
+ }
}
}
}
for _, act := range autoActions {
- item := map[string]any{"task_code": act.TaskCode, "desc": act.Desc}
- before, err := p.taskByCode(a, act.TaskCode)
- if err != nil {
- item["status"] = "error"
- item["message"] = "查询失败: " + err.Error()
- out = append(out, item)
- continue
- }
- if before == nil {
- item["status"] = "skipped"
- item["message"] = "该账号无此任务"
- out = append(out, item)
- continue
- }
- if before.Claimed || before.Current >= before.Target && before.Target > 0 {
- item["status"] = "skipped"
- item["message"] = "已完成(" + taskProgressText(before) + ")"
- out = append(out, item)
- continue
- }
- msg, err := act.run(p, a)
- if err != nil {
- item["status"] = "error"
- item["message"] = err.Error()
- out = append(out, item)
- continue
+ if ctx.Err() != nil {
+ break
}
- var after *upstream.Task
- if isMPTaskCode(act.TaskCode) {
- after, _ = p.taskByCodeMP(a, act.TaskCode)
- } else {
- after, _ = p.taskByCodeWaiting(a, act.TaskCode)
+ emit(autoTaskRunEvent{Kind: "task_start", TaskCode: act.TaskCode})
+ if ctx.Err() != nil {
+ break
}
- item["status"] = "done"
- item["message"] = msg
- item["progress_after"] = taskProgressText(after)
- // 进度达标即自动领奖(mp 任务走 chat 域 mp 口径,其余 Web 端接口)。
- // 领奖失败不掩盖主流程结果: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)
- }
- if cerr == nil {
- item["claimed"] = true
- item["credit"] = credit
- item["energy"] = energy
- if credit > 0 || energy > 0 {
- item["message"] = msg + fmt.Sprintf(";已自动领奖 +%d 分 +%d 能", credit, energy)
- } else {
- item["message"] = msg + ";奖励此前已领取"
- }
- } else {
- item["claim_error"] = cerr.Error()
- item["message"] = msg + ";达标但领奖失败(可在列表手动重试)"
+ // 统一走 executeAutoTask:只有服务端确认进度达标才领奖,只有服务端确认
+ // 已领或领奖接口成功才报 done(#104 修正「任务结果」)。
+ //
+ // 保留分支的 issue #174 语义:已达标未领的任务必须**补领**再跳过——
+ // executeAutoTask 内部走 claimAutoTask 恰好达成同一效果(达标未领 →
+ // 领奖 → done/claimed),比旧的 skipped+补领 更严格:领奖失败报
+ // claim_pending 而不是静默 skipped。
+ outcome := p.executeAutoTask(a, &act)
+ result := outcome.resultMap(&act)
+ out = append(out, result)
+ emit(autoTaskRunEvent{Kind: "result", TaskCode: act.TaskCode, Result: result, Completed: true})
+ if outcome.ActionRun {
+ if !waitForContext(ctx, reportGap) {
+ break
}
}
- out = append(out, item)
- time.Sleep(reportGap) // 项间节流
}
return out
}
+func waitForContext(ctx context.Context, d time.Duration) bool {
+ if d <= 0 {
+ return ctx.Err() == nil
+ }
+ timer := time.NewTimer(d)
+ defer timer.Stop()
+ select {
+ case <-ctx.Done():
+ return false
+ case <-timer.C:
+ return true
+ }
+}
+
// accountTaskAutoAll 一键完成该账号全部可自动任务。
func (p *Panel) accountTaskAutoAll(w http.ResponseWriter, r *http.Request) {
uid := r.PathValue("uid")
@@ -1240,26 +1414,23 @@ func (p *Panel) accountTaskAutoAll(w http.ResponseWriter, r *http.Request) {
if a == nil {
return
}
- // per-account 互斥(与单任务动作共用一把锁):重复点击 409。
- if !p.tryLockAccount(uid) {
- writeErr(w, http.StatusConflict, "该账号有任务动作正在执行中,请等本轮结束后再试")
+ // 企业上下文门控:与 accountTaskAuto 同口径(企业版 / 企业切换会话 / API Key
+ // 账号下成长任务行为事件被上游静默丢弃,跑全量只会白耗真实对话配额)。
+ // 「一键完成全部」与单任务入口共用同一套动作,必须一并挡上。
+ if !a.SupportsBehaviorEvents() {
+ writeErr(w, http.StatusUnprocessableEntity,
+ "该账号为企业版/企业切换会话(token_source=enterprise_switch)或 API Key 账号,无个人成长体系,成长任务行为事件会被上游忽略;请移除账号后以个人身份重新登录")
return
}
- // 用 context 兜底超时(多项任务串联 + 每项含真实对话,可能耗时较长)。
- ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
- defer cancel()
- done := make(chan []map[string]any, 1)
- go func() {
- defer p.unlockAccount(uid) // 流水线真正结束(而非 HTTP 超时返回)才放锁
- done <- p.runAutoAll(a)
- }()
- select {
- case results := <-done:
- log.Printf("panel: 一键完成可自动任务 uid=%s 共 %d 项", uid, len(results))
- writeJSON(w, http.StatusOK, map[string]any{"ok": true, "results": results})
- case <-ctx.Done():
- // HTTP 侧超时返回,但后台流水线仍在跑——锁在流水线 goroutine 内释放,
- // 期间重复点击会被 409 挡住,不会出现两轮并发。
- writeErr(w, http.StatusGatewayTimeout, "执行超时(任务仍在后台继续)")
+ // 国际区(global)账号:成长任务写操作仅官方客户端支持(#104)。
+ if a.IsGlobal() {
+ writeErr(w, http.StatusNotImplemented, globalTaskWriteMessage)
+ return
+ }
+ job, started, err := p.StartAccountTaskJob(uid, "manual")
+ if err != nil {
+ writeTaskJobError(w, err)
+ return
}
+ writeJSON(w, http.StatusAccepted, map[string]any{"ok": true, "started": started, "job": job})
}
diff --git a/internal/panel/autotask_claim_test.go b/internal/panel/autotask_claim_test.go
new file mode 100644
index 00000000..bfdf285b
--- /dev/null
+++ b/internal/panel/autotask_claim_test.go
@@ -0,0 +1,119 @@
+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 遇到
+// 「已达标但未领取」的任务时,必须先把奖领下来——真实对话等链路点亮的任务
+// 不经过任何自动动作,若只跳过不领,积分永远卡在半路。
+//
+// #104 之后领奖由 executeAutoTask/claimAutoTask 统一负责:达标未领 → 领奖 →
+// done + claimed=true;领奖失败报 claim_pending(不再静默 skipped)。
+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"])
+ }
+ // #104 起状态机统一:达标未领 → 先领奖 → done + claimed=true(不再是旧实现
+ // 的「skipped + 已补领」两段式——领奖失败要显式报 claim_pending,不能静默
+ // skipped)。issue #174 的核心诉求「达标未领必须领奖」由 claims==1 + claimed
+ // 保证,状态词本身随上游 #104 的状态机演进。
+ if hit["status"] != "done" {
+ t.Errorf("status = %v, want done", 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/autotask_outcome_test.go b/internal/panel/autotask_outcome_test.go
new file mode 100644
index 00000000..73f54856
--- /dev/null
+++ b/internal/panel/autotask_outcome_test.go
@@ -0,0 +1,288 @@
+package panel
+
+import (
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/linguo2625469/workbuddy2api-panel/internal/auth"
+ "github.com/linguo2625469/workbuddy2api-panel/internal/pool"
+ "github.com/linguo2625469/workbuddy2api-panel/internal/upstream"
+)
+
+type autoTaskTestState struct {
+ mu sync.Mutex
+ current int64
+ target int64
+ acceptStatus string
+ listCalls int
+ failListAt int
+ claimError bool
+ claimCalls int
+ requests int
+}
+
+func (s *autoTaskTestState) counters() (listCalls, claimCalls, requests int) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.listCalls, s.claimCalls, s.requests
+}
+
+func newAutoTaskTestPanel(t *testing.T, current, target int64, acceptStatus string) (*Panel, *auth.Auth, *autoTaskTestState) {
+ t.Helper()
+ state := &autoTaskTestState{current: current, target: target, acceptStatus: acceptStatus}
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ state.mu.Lock()
+ state.requests++
+ if strings.HasSuffix(r.URL.Path, "/claim") || strings.Contains(r.URL.Path, "/reward/claim") {
+ state.claimCalls++
+ fail := state.claimError
+ state.mu.Unlock()
+ if fail {
+ http.Error(w, "upstream claim rejected", http.StatusBadGateway)
+ return
+ }
+ fmt.Fprint(w, `{"code":0,"msg":"OK","data":{"credit":100,"energy":0}}`)
+ return
+ }
+ if r.URL.Path == "/v2/activity/growth/tasks" {
+ state.listCalls++
+ fail := state.failListAt > 0 && state.listCalls >= state.failListAt
+ current, target, status := state.current, state.target, state.acceptStatus
+ state.mu.Unlock()
+ if fail {
+ http.Error(w, "upstream list failed", http.StatusBadGateway)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ fmt.Fprintf(w, `{"code":0,"msg":"OK","data":{"tasks":[{"task_code":"chat_5","title":"对话5次","target":%d,"current":%d,"accept_status":%q}]}}`, target, current, status)
+ return
+ }
+ state.mu.Unlock()
+ http.NotFound(w, r)
+ }))
+ t.Cleanup(server.Close)
+ oldGap := claimPollGap
+ claimPollGap = 0
+ t.Cleanup(func() { claimPollGap = oldGap })
+ client := upstream.New()
+ client.HTTP = &http.Client{Timeout: 2 * time.Second}
+ client.ChatBaseCN, client.WebBaseCN, client.BillingBaseCN = server.URL, server.URL, server.URL
+ p := New(Config{Upstream: client})
+ a := &auth.Auth{UID: "autotask-test", AccessToken: "token"}
+ return p, a, state
+}
+
+func TestExecuteAutoTaskDoesNotMarkUncreditedProgressDone(t *testing.T) {
+ p, a, state := newAutoTaskTestPanel(t, 0, 5, "accepted")
+ actions := 0
+ act := &autoAction{TaskCode: "chat_5", run: func(*Panel, *auth.Auth) (string, error) {
+ actions++
+ return "action returned 200", nil
+ }}
+
+ out := p.executeAutoTask(a, act)
+ if out.Status != "awaiting_progress" {
+ t.Fatalf("status = %q, want awaiting_progress (progress %s)", out.Status, out.ProgressAfter)
+ }
+ if out.ProgressAfter != "0/5" {
+ t.Fatalf("progress_after = %q, want 0/5", out.ProgressAfter)
+ }
+ if actions != 1 {
+ t.Fatalf("action calls = %d, want 1", actions)
+ }
+ _, claims, _ := state.counters()
+ if claims != 0 {
+ t.Fatalf("claim calls = %d, want 0", claims)
+ }
+}
+
+func TestExecuteAutoTaskClaimsCompletedUnclaimedWithoutRunningAction(t *testing.T) {
+ p, a, state := newAutoTaskTestPanel(t, 5, 5, "completed")
+ actions := 0
+ act := &autoAction{TaskCode: "chat_5", run: func(*Panel, *auth.Auth) (string, error) {
+ actions++
+ return "must not run", nil
+ }}
+
+ out := p.executeAutoTask(a, act)
+ if out.Status != "done" || !out.Claimed {
+ t.Fatalf("outcome = %#v, want done and claimed", out)
+ }
+ if actions != 0 {
+ t.Fatalf("action calls = %d, want 0", actions)
+ }
+ _, claims, _ := state.counters()
+ if claims != 1 {
+ t.Fatalf("claim calls = %d, want 1", claims)
+ }
+}
+
+func TestExecuteAutoTaskReportsClaimFailureAsPending(t *testing.T) {
+ p, a, state := newAutoTaskTestPanel(t, 5, 5, "completed")
+ state.claimError = true
+ act := &autoAction{TaskCode: "chat_5", run: func(*Panel, *auth.Auth) (string, error) {
+ t.Fatal("completed task action must not run")
+ return "", nil
+ }}
+
+ out := p.executeAutoTask(a, act)
+ if out.Status != "claim_pending" {
+ t.Fatalf("status = %q, want claim_pending; outcome=%#v", out.Status, out)
+ }
+ if strings.TrimSpace(out.ClaimError) == "" || !strings.Contains(out.Message, out.ClaimError) {
+ t.Fatalf("claim failure is not readable: %#v", out)
+ }
+ _, claims, _ := state.counters()
+ if claims != 1 {
+ t.Fatalf("claim calls = %d, want 1", claims)
+ }
+}
+
+func TestExecuteAutoTaskActionErrorIsNotDone(t *testing.T) {
+ p, a, state := newAutoTaskTestPanel(t, 0, 5, "accepted")
+ act := &autoAction{TaskCode: "chat_5", run: func(*Panel, *auth.Auth) (string, error) {
+ return "", fmt.Errorf("action failed")
+ }}
+
+ out := p.executeAutoTask(a, act)
+ if out.Status != "error" || !strings.Contains(out.Message, "action failed") {
+ t.Fatalf("outcome = %#v, want action error status", out)
+ }
+ listCalls, claims, _ := state.counters()
+ if listCalls != 1 || claims != 0 {
+ t.Fatalf("list calls=%d claim calls=%d, want 1 and 0", listCalls, claims)
+ }
+}
+
+func TestExecuteAutoTaskReadErrorIsNotDone(t *testing.T) {
+ p, a, state := newAutoTaskTestPanel(t, 0, 5, "accepted")
+ act := &autoAction{TaskCode: "chat_5", run: func(*Panel, *auth.Auth) (string, error) {
+ state.mu.Lock()
+ state.failListAt = state.listCalls + 1
+ state.mu.Unlock()
+ return "action returned 200", nil
+ }}
+
+ out := p.executeAutoTask(a, act)
+ if out.Status != "error" || !strings.Contains(out.Message, "回读进度失败") {
+ t.Fatalf("outcome = %#v, want read error status", out)
+ }
+ _, claims, _ := state.counters()
+ if claims != 0 {
+ t.Fatalf("claim calls = %d, want 0", claims)
+ }
+}
+
+func TestExecuteAutoTaskDoesNotRepeatClaimedTask(t *testing.T) {
+ p, a, state := newAutoTaskTestPanel(t, 5, 5, "claimed")
+ actions := 0
+ act := &autoAction{TaskCode: "chat_5", run: func(*Panel, *auth.Auth) (string, error) {
+ actions++
+ return "must not run", nil
+ }}
+
+ out := p.executeAutoTask(a, act)
+ if out.Status != "done" || !out.Claimed {
+ t.Fatalf("outcome = %#v, want already claimed done", out)
+ }
+ _, claims, _ := state.counters()
+ if actions != 0 || claims != 0 {
+ t.Fatalf("action calls=%d claim calls=%d, want both 0", actions, claims)
+ }
+}
+
+func TestAutoTaskGlobalWriteEntryPointsDoNotCallUpstream(t *testing.T) {
+ state := &autoTaskTestState{}
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ state.mu.Lock()
+ state.requests++
+ state.mu.Unlock()
+ http.Error(w, "unexpected upstream request", http.StatusInternalServerError)
+ }))
+ t.Cleanup(server.Close)
+ a := &auth.Auth{UID: "global-test", Domain: "api.workbuddy.ai", AccessToken: "token"}
+ accounts := pool.New("")
+ accounts.Add(a)
+ client := upstream.New()
+ client.HTTP = &http.Client{Timeout: time.Second}
+ client.ChatBaseCN, client.WebBaseCN, client.BillingBaseCN = server.URL, server.URL, server.URL
+ p := New(Config{Pool: accounts, Upstream: client})
+
+ for _, handler := range []http.HandlerFunc{p.accountTaskAuto, p.accountTaskAutoAll} {
+ r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"task_code":"chat_5"}`))
+ r.SetPathValue("uid", a.UID)
+ w := httptest.NewRecorder()
+ handler(w, r)
+ if w.Code != http.StatusNotImplemented || !strings.Contains(w.Body.String(), globalTaskWriteMessage) {
+ t.Fatalf("response = %d %s, want 501 with global task explanation", w.Code, w.Body.String())
+ }
+ }
+ results := p.runAutoAll(a)
+ if len(results) != 1 || results[0]["status"] != "error" {
+ t.Fatalf("runAutoAll(global) = %#v, want one guarded error", results)
+ }
+ _, _, requests := state.counters()
+ if requests != 0 {
+ t.Fatalf("upstream requests = %d, want 0", requests)
+ }
+}
+
+func TestRunAutoAllDoesNotMarkUncreditedTaskDone(t *testing.T) {
+ p, a, _ := newAutoTaskTestPanel(t, 0, 5, "accepted")
+ oldAction, oldGap := autoActions[0], reportGap
+ autoActions[0].run = func(*Panel, *auth.Auth) (string, error) { return "action returned 200", nil }
+ reportGap = 0
+ t.Cleanup(func() {
+ autoActions[0] = oldAction
+ reportGap = oldGap
+ })
+
+ found := false
+ for _, item := range p.runAutoAll(a) {
+ if item["task_code"] == "chat_5" && item["status"] == "done" {
+ t.Fatalf("0/5 task was reported done: %#v", item)
+ }
+ if item["task_code"] == "chat_5" {
+ found = true
+ if item["status"] != "awaiting_progress" {
+ t.Fatalf("chat_5 result = %#v, want awaiting_progress", item)
+ }
+ }
+ }
+ if !found {
+ t.Fatal("runAutoAll did not report chat_5")
+ }
+}
+
+func TestRunAutoAllClaimsCompletedUnclaimedTaskWithoutAction(t *testing.T) {
+ p, a, state := newAutoTaskTestPanel(t, 5, 5, "completed")
+ actions := 0
+ oldAction := autoActions[0]
+ autoActions[0].run = func(*Panel, *auth.Auth) (string, error) {
+ actions++
+ return "must not run", nil
+ }
+ t.Cleanup(func() { autoActions[0] = oldAction })
+
+ found := false
+ for _, item := range p.runAutoAll(a) {
+ if item["task_code"] == "chat_5" {
+ found = true
+ if item["status"] != "done" || item["claimed"] != true {
+ t.Fatalf("chat_5 result = %#v, want done and claimed", item)
+ }
+ }
+ }
+ if !found {
+ t.Fatal("runAutoAll did not report chat_5")
+ }
+ _, claims, _ := state.counters()
+ if actions != 0 || claims != 1 {
+ t.Fatalf("action calls=%d claim calls=%d, want 0 and 1", actions, claims)
+ }
+}
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/config.go b/internal/panel/config.go
index efda8ae3..851a92ee 100644
--- a/internal/panel/config.go
+++ b/internal/panel/config.go
@@ -9,6 +9,7 @@ import (
"io"
"log"
"net/http"
+ "os"
)
// getConfig 返回当前配置文件内容与路径(前端按 schema 渲染表单)。
@@ -23,9 +24,11 @@ func (p *Panel) getConfig(w http.ResponseWriter, r *http.Request) {
return
}
writeJSON(w, http.StatusOK, map[string]any{
- "ok": true,
- "path": p.cfg.ConfigPath,
- "config": cfg,
+ "ok": true,
+ "path": p.cfg.ConfigPath,
+ "config": cfg,
+ "api_key_env_managed": os.Getenv("WB2A_API_KEY") != "",
+ "environment_managed_fields": configManagedFields(cfg),
})
}
@@ -50,8 +53,23 @@ func (p *Panel) saveConfig(w http.ResponseWriter, r *http.Request) {
restartRequired = []string{}
}
log.Printf("panel: 配置已保存(热生效完成;需重启字段 %d 个)", len(restartRequired))
+ var managedFields []string
+ if p.cfg.LoadConfig != nil {
+ if config, err := p.cfg.LoadConfig(); err == nil {
+ managedFields = configManagedFields(config)
+ }
+ }
writeJSON(w, http.StatusOK, map[string]any{
- "ok": true,
- "restart_required": restartRequired,
+ "ok": true,
+ "restart_required": restartRequired,
+ "api_key_env_managed": os.Getenv("WB2A_API_KEY") != "",
+ "environment_managed_fields": managedFields,
})
}
+
+func configManagedFields(config any) []string {
+ if provider, ok := config.(interface{ EnvironmentManagedFields() []string }); ok {
+ return provider.EnvironmentManagedFields()
+ }
+ return nil
+}
diff --git a/internal/panel/config_env_test.go b/internal/panel/config_env_test.go
new file mode 100644
index 00000000..a041dd9f
--- /dev/null
+++ b/internal/panel/config_env_test.go
@@ -0,0 +1,27 @@
+package panel
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func TestConfigAPIReportsEnvironmentManagedKey(t *testing.T) {
+ t.Setenv("WB2A_API_KEY", "environment-key")
+ p := New(Config{APIKey: "environment-key", LoadConfig: func() (any, error) { return map[string]any{"api_key": "environment-key"}, nil }, SaveConfig: func([]byte) ([]string, error) { return nil, nil }})
+ for _, method := range []string{http.MethodGet, http.MethodPost} {
+ req := httptest.NewRequest(method, "/panel/api/config", strings.NewReader(`{"api_key":"other"}`))
+ req.Header.Set("Authorization", "Bearer environment-key")
+ rec := httptest.NewRecorder()
+ p.ServeHTTP(rec, req)
+ var response map[string]any
+ if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
+ t.Fatal(err)
+ }
+ if rec.Code != 200 || response["api_key_env_managed"] != true {
+ t.Fatalf("method=%s status=%d response=%v", method, rec.Code, response)
+ }
+ }
+}
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 = '"\' onmouseover="x';
+run('xssOk', () => ctx.renderCreditHistory({ entries: [
+ { time: t, uid: '
', 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, "
"); n != 3 {
+ t.Fatalf("应渲染 3 行,得到 %d: %s", n, badDelta)
+ }
+
+ // account / uid 含
+