11 Commits
Author SHA1 Message Date
CangShui 5ac6ee7975 feat: intl site (workbuddy.ai) support + credential hot-reload + build script
- login -intl: sign in via www.workbuddy.ai (browser-based, OneID email/SSO flow)
- per-credential edition routing for refresh/chat upstreams (cn | intl)
- mixed cn/intl account pool with round-robin and 429 cooldown fallback
- English rate-limit message parsing (reset time extraction)
- credential hot-reload: auto discover/add/update/remove credential files while serving (-reload-interval, default 5s, 0=off)
- exclude workbuddy-status.json from credential auto-discovery
- status/monitor/snapshot show per-account site label
- add build.ps1 cross-platform build script; harden .gitignore
2026-09-05 05:32:09 +08:00
CangShui 20f7fdded3 Update README.md 2026-09-04 16:37:40 +08:00
CangShui 4d5d315125 Update README.md 2026-09-04 12:22:15 +08:00
CangShui 61aa053816 feat: add foreground monitor command with live status + recent logs
- serve now periodically writes account-pool state (active/cooldown/
  disabled, cooldown until, token expiry) to workbuddy-status.json,
  refreshed every 3s and immediately on cooldown/disable/refresh events
- new 'monitor' command runs in foreground: refreshes a live panel of
  all accounts (usable/cooldown/expired + reasons) every N seconds,
  optionally showing recent logs via journalctl (-journal) or a log
  file (-logfile)
- statusSnapshotFile excluded from git via .gitignore
- unit tests for tailLines and status snapshot states
2026-09-04 09:36:51 +08:00
CangShui 86b70baf29 feat: print loaded credential file list at end of startup banner
- After the per-account status block, print a summary line listing all
  loaded credential files (e.g. '已加载凭据文件 (2): workbuddy.json,
  workbuddy2.json') so the count is visible even when journalctl/
  systemctl truncates the earlier log lines
2026-09-04 08:48:21 +08:00
CangShui 0484db4b3a feat: show all accounts with full status in serve startup banner
- Startup banner now prints every account's complete status (nickname,
  UID, enterprise, domain, cooldown state, token validity, expiry)
  instead of a single-line summary
- Extracted shared formatAccountStatus() used by both status command
  and serve startup output, keeping them consistent
- Shows disabled accounts with reason and re-login hint at startup
2026-09-04 08:42:03 +08:00
CangShui 9fcd99f152 feat: auto-discover all workbuddy*.json in working directory
- When neither -auth nor -auth-dir is specified, scan the working
  directory for all workbuddy*.json files and build the account pool
  from them (multi-account without any flags)
- Example: /opt/workbuddy-gateway with workbuddy.json + workbuddy2.json
  is automatically loaded as a 2-account pool under systemd
- loadDisabledMarkers also auto-discovers *.json.disabled markers
- Fallback to default workbuddy.json when no credential files found
- Unit tests for auto-discovery and fallback behavior
2026-09-04 08:13:38 +08:00
CangShui 818d0c223e feat: auto-disable accounts on expired/revoked auth
- Detect auth failure (HTTP 401/403, invalid token, login expired)
  during token refresh and upstream requests
- Disable the account from round-robin scheduling immediately,
  delete its credential file, and write a persistent .disabled marker
- status/runServe console shows disabled accounts with reason and
  re-login command; re-login clears the marker and restores scheduling
- Background refresher skips disabled accounts; refresh command reports
  skipped count
- Unit tests for auth-failure detection, disabled skip, marker
  persistence/restore/clear
2026-09-04 04:12:11 +08:00
CangShui f96a874d2a feat: multi-account pool with round-robin and 429 auto-cooldown
- Support multiple CodeBuddy accounts: -auth comma-separated or -auth-dir
- Round-robin request distribution to balance quota usage across accounts
- Auto-parse 429 rate-limit reset time (e.g. '将在 ... UTC+8 重置'),
  cooldown-block the offending account until reset, failover to others
- Per-account serialization lock (same account queues, different accounts parallel)
- status/refresh commands now cover the whole account pool
- Unit tests for reset-time parsing, rate-limit detection and round-robin
2026-09-04 03:54:32 +08:00
CangShui e82b5f4212 docs: point download links to GitHub Releases 2026-09-04 00:12:02 +08:00
CangShui 946f540cb1 Update README.md 2026-09-04 00:06:33 +08:00
5 changed files with 2292 additions and 183 deletions
+6 -6
View File
@@ -1,16 +1,16 @@
# 凭据与敏感数据(切勿提交!)
workbuddy.json
workbuddy*.json
workbuddy-console.json
dist/workbuddy.json
dist/*.json
last-*.json
*.json.bak
*.json.disabled
*.disabled
.workbuddy-*.json
# 本地编译产物
workbuddy-gateway
workbuddy-gateway.exe
dist/*.exe
dist/workbuddy-gateway-linux-*
dist/workbuddy-gateway-darwin-*
dist/
# 日志与临时文件
*.log
+201 -10
View File
@@ -1,16 +1,30 @@
# WorkBuddy Local Gateway
<img width="797" height="231" alt="image" src="https://github.com/user-attachments/assets/b179efab-1caa-4bf6-a679-d6b6f299cbd6" />
基于腾讯 **CodeBuddy / 混元(Hunyuan)** 协议开发的**纯 Go、零 CGO 依赖、跨平台单二进制**本地 AI 代理网关。无 Web UI,仅通过命令行(CLI)完成扫码登录、凭据续期与服务控制。
基于腾讯 **CodeBuddy / 混元(Hunyuan)** 协议开发的**纯 Go、零 CGO 依赖、跨平台单二进制**本地 AI 代理网关。无 Web UI,仅通过命令行(CLI)完成登录、凭据续期与服务控制。
**同时支持两个上游站点**(同一套协议,凭据按站点隔离,账号池可混挂轮询):
| 站点 | 上游 | 登录方式 | 登录命令 |
|---|---|---|---|
| 国内站 | `copilot.tencent.com` | 微信 / 企业微信扫码 | `login` |
| 国际站 | `www.workbuddy.ai` | 浏览器内登录(邮箱 / 验证码 / SSO 等) | `login -intl` |
## 核心特性
- **国内 / 国际双站反代**:国际站 `www.workbuddy.ai` 与国内站走同一套 `/v2/plugin/*` 协议,凭据文件通过 `edition` 字段区分站点,刷新 / 对话自动路由到各自上游。
- **模型完全透传**:客户端(OpenAI SDK、Cursor、Claude Code、DSH 等)传什么 `model`(如 `hy4-preview`、`hy3-preview-agent`、`hy3`、`deepseek-v4-pro`、`glm-5.2`…),网关原样透传至腾讯上游,无白名单限制。
- **纯 CLI 控制**:终端内嵌 ASCII 二维码,微信 / 企业微信扫码登录;`status` / `refresh` / `serve` 子命令完成全部管理。
- **后台自动续期**:运行期间每 5 分钟检查 Token,距过期不足 15 分钟自动刷新并持久化到 `workbuddy.json`。
- **纯 CLI 控制**:终端内嵌 ASCII 二维码,国内站微信 / 企业微信扫码登录;`status` / `refresh` / `serve` 子命令完成全部管理。
- **多账号池 + 轮询负载均衡**:支持同时挂载多个 CodeBuddy 账号(`-auth` 逗号分隔或 `-auth-dir` 目录),请求按轮询(round-robin)均匀分配到各账号,保持多账号额度使用一致;**国内站与国际站账号可混挂在同一池中**。
- **凭据热加载(免重启)**:`serve` 运行期间自动扫描凭据来源(默认每 5 秒,`-reload-interval` 可调):新增凭据文件自动入池、重新登录/手动更新凭据原地生效、删除凭据自动移出,全程无需重启服务。
- **429 频率限制自动冷却**:任一账号触发上游频率限制(HTTP 429 / code 6004,中英文消息均可识别)时,自动解析消息中的重置时间(中文如 `将在 2026-09-04 07:48:15 UTC+8 重置`,英文如 `will reset at 2026-09-05 01:57:00 UTC+8`),将该账号屏蔽至重置时间;冷却期间自动改用其他账号代偿,冷却到期自动恢复。
- **授权失效自动禁用**:账号授权过期、被撤销或令牌刷新失败(HTTP 401/403 / invalid token / 登录已过期)时,自动将该账号**禁止调度并删除凭据文件**,同时写入持久化失效标记;`status` / 启动日志会明确提示该账号失效原因与重新登录命令,重新 `login` 后自动恢复调度。
- **后台自动续期**:运行期间每 5 分钟检查所有账号 Token,距过期不足 15 分钟自动刷新并持久化回各自凭据文件。
- **OpenAI 兼容协议**:`POST /v1/chat/completions`(SSE 流式 + 非流式聚合)、`GET /v1/models`、`GET /health`。
- **深度思考透传规则**:仅当客户端显式请求 `reasoning_effort` 时转发;绝不强制注入,避免触发上游内容安全策略。
- **反审查净化**:自动改写 Claude Code 等框架被上游逐字拉黑的固定 Prompt 语句。
- **单并发串行化**:同一账号请求自动排队,避免并发双发触发上游风控。
- **单账号串行化**:同一账号请求自动排队,避免并发双发触发上游风控;不同账号之间可并行。
## 快速上手
@@ -25,12 +39,37 @@
# Linux / macOS
./workbuddy-gateway-linux-amd64 login
./workbuddy-gateway-darwin-arm64 login
#指定保存凭据到某个目录(多账号模式下建议使用)
workbuddy-gateway login -auth /opt/workbuddy-gateway/workbuddy001.json
```
终端将打印 ASCII 二维码与浏览器直达链接,扫码后凭据自动保存到当前目录 `workbuddy.json`(请勿提交到代码仓库)。
> 提示:建议在 **CodeBuddy 控制台(网页端)** 扫码登录获取更高权限评级的 Token;若使用 `login` 命令扫码,账号渠道可能受限(`azp=invite`)。
### 1b. 登录国际站(workbuddy.ai)
```bash
# 登录国际站账号(保存到指定文件以便与国内站账号区分)
workbuddy-gateway login -intl
workbuddy-gateway login -intl -auth workbuddy-intl.json
```
国际站登录流程与国内站不同:网关生成登录链接后,**需要你在浏览器里完成登录**(支持邮箱、验证码、SSO 等方式),流程如下:
1. 终端打印二维码与直达链接(手机扫码或电脑打开均可);
2. 在浏览器中完成登录,页面显示 **Login Successful** 即可(无需理会跳转 App 的提示);
3. 网关自动轮询拿到 Token 并保存到凭据文件(`edition: "intl"`),等待窗口 15 分钟。
启动服务无需任何额外参数——凭据文件内已记录站点标识,`serve` / `refresh` 会自动路由到对应上游;**登录成功后新账号由凭据热加载自动加入运行中的账号池,无需重启服务**:
```bash
workbuddy-gateway serve # 自动发现目录下所有 workbuddy*.json(含国际站账号,混挂轮询)
```
> 说明:国际站 `www.workbuddy.ai` 与国内站 `copilot.tencent.com` 是同一协议的两套部署(登录 platform 为 `workbuddy-ai`,等待授权时轮询返回 code 11217)。国际站账号是否可用各模型、额度与风控策略由腾讯国际站侧决定。
### 2. 查看凭据状态
```bash
@@ -53,6 +92,144 @@ workbuddy-gateway serve -proxy http://127.0.0.1:7890
workbuddy-gateway serve -api-key sk-localsecret
```
## 多账号池与 429 自动冷却
### 配置多个账号
网关支持同时挂载多个 CodeBuddy 账号,请求按**轮询(round-robin)**方式均匀分发,保持各账号额度消耗一致。三种配置方式:
```bash
# 方式一(推荐):自动发现 — 把多个凭据文件放进工作目录即可,无需任何参数
# 网关启动时自动加载当前目录下所有 workbuddy*.json(如 workbuddy.json、workbuddy2.json…)
cd /opt/workbuddy-gateway
workbuddy-gateway serve -addr 0.0.0.0 -port 8317
# 启动日志会显示: 已就绪账号池: 2 个账号 (有效 2, 失效 0)
# 方式二:-auth 逗号分隔多个凭据文件
workbuddy-gateway serve -auth workbuddy.json,workbuddy-2.json,workbuddy-3.json
# 方式三:-auth-dir 指定凭据目录(自动加载目录下所有 workbuddy*.json)
mkdir -p auths
workbuddy-gateway login -auth auths/workbuddy-1.json # 依次为每个账号扫码登录
workbuddy-gateway login -auth auths/workbuddy-2.json
workbuddy-gateway serve -auth-dir ./auths
```
### 429 频率限制自动冷却
当某个账号触发上游频率限制(HTTP 429,消息形如 `您的使用量已超出频率限制,将在 2026-09-04 07:48:15 UTC+8 重置`)时,网关会:
1. **自动解析消息中的重置时间**,立即将该账号屏蔽(冷却)至该时间点;
2. **自动改用下一个可用账号重试**当前请求(代偿),无需客户端干预;
3. 冷却期间该账号不参与轮询,**冷却到期后自动恢复**;
4. 若消息中无法解析重置时间,默认冷却 60 秒后重试;
5. 当所有账号均处于冷却状态时,返回 HTTP 429 并附上最早解封时间。
```bash
# 查看各账号状态(含冷却状态与解封时间)
workbuddy-gateway status
# 手动刷新所有账号令牌
workbuddy-gateway refresh
```
### 授权失效自动禁用
当账号出现以下任一情况时,网关会**自动禁用该账号调度并删除其凭据文件**:
- 令牌刷新失败且返回授权类错误(HTTP 401/403、`invalid token`、`unauthorized`、`登录已过期` 等);
- 上游请求返回 401/403(token 被撤销或已过期);
- 凭据缺少 RefreshToken 且已无法刷新。
处理流程:
1. 将该账号标记为 **❌ 授权失效**,立即移出轮询调度;
2. **删除对应的凭据文件**(如 `workbuddy.json`),并写入持久化失效标记(`workbuddy.json.disabled`);
3. 控制台(`status` / 启动日志 / 运行日志)明确显示失效原因,并给出重新登录命令;
4. 失效期间其余账号正常代偿;**重新执行 login 后自动清除失效标记并恢复调度**。
```bash
# 查看失效账号与原因
workbuddy-gateway status
# 输出示例:
# --- 账号 #1 ---
# 凭据文件: workbuddy-2.json
# 站点: 国内站 (copilot.tencent.com)
# 用户昵称: tester
# 用户 UID: uid-xxx
# 账号状态: ❌ 授权失效(禁止调度)
# 失效原因: 令牌刷新失败 (HTTP 401): invalid token
# 处理建议: ⚠️ 凭据文件已删除,请重新执行: workbuddy-gateway login -auth workbuddy-2.json
# 失效账号重新登录后自动恢复
workbuddy-gateway login -auth workbuddy-2.json
workbuddy-gateway status # 该账号恢复为 ✅ 可用
```
### 与单账号模式的兼容性
- 不传 `-auth` / `-auth-dir` 时,自动发现当前工作目录下所有 `workbuddy*.json`;目录中只有一个凭据文件时行为与旧版完全一致;
- 只有一个账号时,请求始终使用该账号,429 冷却逻辑同样生效(冷却期间请求将返回 429 提示);
- 账号之间使用独立的串行锁:同一账号请求严格排队,不同账号可并行,兼顾风控与吞吐。
### 凭据热加载(免重启增减账号)
`serve` 运行期间默认每 5 秒扫描一次凭据来源(`-reload-interval` 可调,设为 `0` 关闭),自动完成:
- **新增账号**:把新的 `workbuddy*.json` 放进凭据目录(或对运行中的网关执行 `login -auth 新文件.json`),几秒内自动加入轮询池;
- **重新登录 / 更新凭据**:对已有凭据文件重新 `login` 后,网关原地替换凭据并自动恢复调度(含曾因授权失效被禁用的账号);
- **删除账号**:删除凭据文件即自动移出轮询池(已写入失效标记的账号保留提示,重新登录后自动恢复)。
日志示例:
```text
[Reload] 发现新账号凭据 workbuddy4.json(国际站),已自动加入账号池
[Reload] 账号 workbuddy2.json 重新登录成功,已自动恢复调度
[Reload] 凭据文件 workbuddy3.json 已删除,已移出账号池
```
## 前台实时监控 (monitor)
网关以 systemd / 后台方式运行时,可用 **`monitor` 命令在前台实时查看所有账号的最新状态与最近日志**(Ctrl+C 退出):
```bash
# 基本用法:每 3 秒刷新展示账号池状态(可用/冷却/失效 + Token 有效期)
cd /opt/workbuddy-gateway # 必须与 serve 同一工作目录(读取 workbuddy-status.json)
workbuddy-gateway monitor
# 自定义刷新间隔(秒)
workbuddy-gateway monitor -interval 2
# 同时展示 systemd 服务最近日志(Linux)
workbuddy-gateway monitor -journal workbuddy-gateway
# 或展示指定日志文件最近内容
workbuddy-gateway monitor -logfile /var/log/workbuddy-gateway.log
# 控制每次展示的日志行数
workbuddy-gateway monitor -journal workbuddy-gateway -lines 8
```
展示内容(实时刷新):
```text
================ WorkBuddy 实时监控 ================
按 Ctrl+C 退出 | 状态文件: workbuddy-status.json
---------------------------------------------------------------
🕐 更新时间: 2026-09-04 09:35:12
📊 账号池: 共 2 个 | ✅ 可用 1 | 🔒 冷却 1 | ❌ 失效 0
#1 workbuddy.json (Abandon)
✅ 可用 | Token 有效期至: 09-10 20:43
#2 workbuddy2.json (啊水)
🔒 冷却中 至 09-05 01:57 (剩余 16h)
原因: 您的使用量已超出频率限制...
📜 最近日志 (journalctl -u workbuddy-gateway):
9月 04 09:34:31 ... [Cooldown] 账号 workbuddy2.json 触发频率限制...
---------------------------------------------------------------
```
> 原理:`serve` 后台每 3 秒(及状态变化时)将账号池实时状态原子写入同目录 `workbuddy-status.json`,`monitor` 前台读取该文件并周期刷新展示;日志通过 `journalctl` 或日志文件补充展示。
## 客户端接入
网关启动后服务地址为 `http://127.0.0.1:8317/v1`。
@@ -95,7 +272,7 @@ llm-pi-ai:
### Windows
1. 下载 `dist/workbuddy-gateway-windows-amd64.exe`。
1. 从 [Releases](https://github.com/CangShui/workbuddy-gateway/releases) 下载 `workbuddy-gateway-windows-amd64.exe`。
2. 在 PowerShell / CMD 中进入文件所在目录:
```powershell
.\workbuddy-gateway-windows-amd64.exe login
@@ -105,11 +282,13 @@ llm-pi-ai:
### Linux (amd64 / arm64)
1. 下载对应架构二进制,赋执行权限并放入 PATH:
1. 从 [Releases](https://github.com/CangShui/workbuddy-gateway/releases) 下载对应架构二进制,赋执行权限并放入 PATH:
```bash
# x86_64
wget https://github.com/CangShui/workbuddy-gateway/releases/latest/download/workbuddy-gateway-linux-amd64
sudo install -m 755 workbuddy-gateway-linux-amd64 /usr/local/bin/workbuddy-gateway
# ARM64 (树莓派 / 飞腾 / Apple silicon 云主机等)
wget https://github.com/CangShui/workbuddy-gateway/releases/latest/download/workbuddy-gateway-linux-arm64
sudo install -m 755 workbuddy-gateway-linux-arm64 /usr/local/bin/workbuddy-gateway
```
2. 登录并启动:
@@ -151,8 +330,18 @@ sudo mkdir -p /opt/workbuddy-gateway
sudo cp workbuddy-gateway /opt/workbuddy-gateway/ # 对应架构的二进制
# 首次登录(会生成 workbuddy.json)
sudo /opt/workbuddy-gateway/workbuddy-gateway login
# 登录第二个账号(可选):生成 workbuddy2.json
sudo /opt/workbuddy-gateway/workbuddy-gateway login -auth /opt/workbuddy-gateway/workbuddy2.json
```
> 💡 **多账号自动发现**:服务通过 `WorkingDirectory` 固定在 `/opt/workbuddy-gateway`,
> 无需修改 ExecStart——把多个凭据文件(`workbuddy.json`、`workbuddy2.json`…)放进该目录,
> 即可自动组成轮询池;凭据热加载会让新增/更新/删除的凭据文件免重启生效。确认方式:
> ```bash
> sudo journalctl -u workbuddy-gateway | grep 账号池
> # 输出示例: 已就绪账号池: 2 个账号 (有效 2, 失效 0)
> ```
启用并启动服务:
```bash
@@ -178,7 +367,7 @@ ExecStart=/opt/workbuddy-gateway/workbuddy-gateway serve -addr 0.0.0.0 -port 831
### macOS (Apple Silicon / Intel)
1. 下载 `workbuddy-gateway-darwin-arm64`(M 系列)或 `workbuddy-gateway-darwin-amd64`(Intel)。
1. 从 [Releases](https://github.com/CangShui/workbuddy-gateway/releases) 下载 `workbuddy-gateway-darwin-arm64`(M 系列)或 `workbuddy-gateway-darwin-amd64`(Intel)。
2. 首次运行需移除隔离属性:
```bash
chmod +x workbuddy-gateway-darwin-arm64
@@ -221,8 +410,10 @@ ExecStart=/opt/workbuddy-gateway/workbuddy-gateway serve -addr 0.0.0.0 -port 831
## 命令行速查
```
命令: serve | login | status | refresh | version | help
选项: -addr <ip> · -port <port> · -auth <path> · -api-key <key> · -proxy <url> · -verbose
命令: serve | login | status | refresh | monitor | version | help
选项: -addr <ip> · -port <port> · -auth <path> · -auth-dir <dir> · -intl · -reload-interval <sec> · -api-key <key> · -proxy <url> · -verbose
(-intl 仅 login 生效:登录国际站 www.workbuddy.ai;-reload-interval 默认 5,0 关闭热加载)
monitor: -interval <sec> · -journal <svc> · -logfile <path> · -lines <n>
```
## 从源码构建
@@ -237,4 +428,4 @@ CGO_ENABLED=0 go build -ldflags="-s -w" -o workbuddy-gateway .
## 免责声明
本项目仅用于个人学习与技术研究。腾讯 CodeBuddy 的接口协议与风控策略可能随时变化;请遵守腾讯服务条款,自行承担使用风险。本仓库不包含任何官方未公开的密钥或凭据。
本项目仅用于个人学习与技术研究。腾讯 CodeBuddy(含国内站与国际站 workbuddy.ai)的接口协议与风控策略可能随时变化;请遵守腾讯服务条款,自行承担使用风险。本仓库不包含任何官方未公开的密钥或凭据。
+142
View File
@@ -0,0 +1,142 @@
<#
.SYNOPSIS
WorkBuddy Local Gateway 一键交叉编译脚本(纯 Go、零 CGO 依赖,产物为静态单二进制)
.DESCRIPTION
默认构建全部 5 个平台产物到 dist\ 目录:
workbuddy-gateway-windows-amd64.exe
workbuddy-gateway-linux-amd64
workbuddy-gateway-linux-arm64
workbuddy-gateway-darwin-amd64
workbuddy-gateway-darwin-arm64
构建前默认执行 go vet + go test,任何一步失败即中止。
.PARAMETER Only
仅构建指定平台:windows | linux | darwin
.PARAMETER OutputDir
输出目录(默认 dist)
.PARAMETER Clean
构建前清空输出目录
.PARAMETER SkipTests
跳过构建前的 go vet / go test
.EXAMPLE
.\build.ps1 # 全平台构建
.\build.ps1 -Only windows # 只构建 windows
.\build.ps1 -Clean -SkipTests
#>
param(
[ValidateSet('', 'windows', 'linux', 'darwin')]
[string]$Only = '',
[string]$OutputDir = 'dist',
[switch]$Clean,
[switch]$SkipTests
)
$ErrorActionPreference = 'Stop'
Set-Location -LiteralPath $PSScriptRoot
# ---------------------------------------------------------------------------
# 0. 环境检查
# ---------------------------------------------------------------------------
$go = Get-Command go -ErrorAction SilentlyContinue
if (-not $go) {
Write-Host '[错误] 未检测到 Go 工具链,请先安装 Go 1.20+:https://go.dev/dl/' -ForegroundColor Red
exit 1
}
# 从 main.go 读取版本号(仅用于展示;版本定义在 main.go 的 version 常量中)
$version = 'dev'
if (Test-Path 'main.go') {
$m = Select-String -Path 'main.go' -Pattern 'version\s*=\s*"([^"]+)"' | Select-Object -First 1
if ($m) { $version = $m.Matches[0].Groups[1].Value }
}
Write-Host '================ WorkBuddy Local Gateway 构建 ================' -ForegroundColor Cyan
Write-Host ("工具链: {0}" -f (go version))
Write-Host ("目标版本: v{0}" -f $version)
# ---------------------------------------------------------------------------
# 1. 代码检查与单元测试
# ---------------------------------------------------------------------------
if (-not $SkipTests) {
Write-Host "`n[1/3] go vet + go test ..." -ForegroundColor Cyan
go vet .
if ($LASTEXITCODE -ne 0) { Write-Host '[错误] go vet 未通过' -ForegroundColor Red; exit 1 }
go test ./...
if ($LASTEXITCODE -ne 0) { Write-Host '[错误] go test 未通过' -ForegroundColor Red; exit 1 }
}
else {
Write-Host "`n[1/3] 已跳过 go vet / go test (-SkipTests)" -ForegroundColor DarkGray
}
# ---------------------------------------------------------------------------
# 2. 交叉编译
# ---------------------------------------------------------------------------
$targets = @(
@{ GOOS = 'windows'; GOARCH = 'amd64'; Ext = '.exe' }
@{ GOOS = 'linux'; GOARCH = 'amd64'; Ext = '' }
@{ GOOS = 'linux'; GOARCH = 'arm64'; Ext = '' }
@{ GOOS = 'darwin'; GOARCH = 'amd64'; Ext = '' }
@{ GOOS = 'darwin'; GOARCH = 'arm64'; Ext = '' }
)
if ($Only) { $targets = @($targets | Where-Object { $_.GOOS -eq $Only }) }
if (-not (Test-Path -LiteralPath $OutputDir)) {
New-Item -ItemType Directory -Path $OutputDir | Out-Null
}
elseif ($Clean) {
Remove-Item (Join-Path $OutputDir '*') -Recurse -Force
Write-Host ("已清空输出目录: {0}" -f $OutputDir) -ForegroundColor DarkGray
}
# 保存并临时覆盖构建环境变量,结束后恢复
$oldEnv = @{ GOOS = $env:GOOS; GOARCH = $env:GOARCH; CGO_ENABLED = $env:CGO_ENABLED }
$env:CGO_ENABLED = '0' # 强制零 CGO,保证产物可任意交叉分发
$failed = @()
$total = $targets.Count
$i = 0
foreach ($t in $targets) {
$i++
$name = "workbuddy-gateway-$($t.GOOS)-$($t.GOARCH)$($t.Ext)"
Write-Host ("`n[2/3] ({0}/{1}) building {2} ..." -f $i, $total, $name) -ForegroundColor Cyan
$env:GOOS = $t.GOOS
$env:GOARCH = $t.GOARCH
go build -trimpath -ldflags '-s -w' -o (Join-Path $OutputDir $name) .
if ($LASTEXITCODE -ne 0) {
$failed += $name
Write-Host " [FAIL] $name" -ForegroundColor Red
}
else {
Write-Host " [OK] $name" -ForegroundColor Green
}
}
# 恢复原环境变量
foreach ($k in @('GOOS', 'GOARCH', 'CGO_ENABLED')) {
if ($oldEnv[$k]) { Set-Item -Path "env:$k" -Value $oldEnv[$k] }
else { Remove-Item -Path "env:$k" -ErrorAction SilentlyContinue }
}
# ---------------------------------------------------------------------------
# 3. 结果汇总
# ---------------------------------------------------------------------------
Write-Host "`n================ 构建结果 ================" -ForegroundColor Cyan
if ($failed.Count -gt 0) {
Write-Host ("失败 {0} 个: {1}" -f $failed.Count, ($failed -join ', ')) -ForegroundColor Red
exit 1
}
Get-ChildItem -LiteralPath $OutputDir -File |
Sort-Object Name |
Format-Table @(
@{ Label = 'Artifact'; Expression = { ' ' + $_.Name } },
@{ Label = 'Size'; Expression = { '{0:N1} MB' -f ($_.Length / 1MB) } }
) -AutoSize | Out-Host
Write-Host ("全部构建成功 共 {0} 个产物,输出目录: {1}" -f $total, (Join-Path $PSScriptRoot $OutputDir)) -ForegroundColor Green
+1340 -167
View File
File diff suppressed because it is too large Load Diff
+603
View File
@@ -0,0 +1,603 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
)
// 验证 429 消息中的重置时间解析
func TestParseResetTime(t *testing.T) {
msg := `{"code":429,"message":"upstream 429: {\"code\":6004,\"msg\":\"您的使用量已超出频率限制,将在 2026-09-04 07:48:15 UTC+8 重置,您也可以切换其他模型继续使用。\",\"requestId\":\"149e3299-ddf3-4ad1-aaa8-cc752c37630a\"}","type":"upstream_error"}`
got, ok := parseResetTime(msg)
if !ok {
t.Fatal("parseResetTime should succeed")
}
want := time.Date(2026, 9, 4, 7, 48, 15, 0, time.FixedZone("UTC+8", 8*3600))
if !got.Equal(want) {
t.Fatalf("got %v want %v", got, want)
}
t.Logf("parsed reset time: %v", got)
}
// 验证无法解析时返回 false
func TestParseResetTimeInvalid(t *testing.T) {
if _, ok := parseResetTime("some random error"); ok {
t.Fatal("should not parse random string")
}
}
// 验证国际站英文限流消息的重置时间解析(通用兜底正则)
func TestParseResetTimeEnglish(t *testing.T) {
cases := []string{
`{"code":6004,"msg":"Your usage has exceeded the rate limit. It will reset at 2026-09-05 01:57:00 UTC+8."}`,
`upstream 429: rate limit exceeded, reset at 2026-09-05 01:57:00`,
`{"msg":"quota exceeded, will reset on 2026-09-05T01:57:00 UTC+8"}`,
}
want := time.Date(2026, 9, 5, 1, 57, 0, 0, time.FixedZone("UTC+8", 8*3600))
for _, msg := range cases {
got, ok := parseResetTime(msg)
if !ok {
t.Fatalf("parseResetTime(%q) should succeed", msg)
}
if !got.Equal(want) {
t.Fatalf("parseResetTime(%q) = %v, want %v", msg, got, want)
}
}
}
// 验证站点 Profile 选择:空值/未知回退国内站,intl 系列值命中国际站
func TestProfileForEdition(t *testing.T) {
cases := []struct {
edition string
wantKey string
}{
{"", "cn"},
{"cn", "cn"},
{"unknown", "cn"},
{"intl", "intl"},
{"INTL", "intl"},
{" workbuddy.ai ", "intl"},
{"international", "intl"},
}
for _, c := range cases {
if got := profileForEdition(c.edition).Key; got != c.wantKey {
t.Errorf("profileForEdition(%q).Key = %s, want %s", c.edition, got, c.wantKey)
}
}
if p := profileForEdition("intl"); p.Base != "https://www.workbuddy.ai" || p.Origin != "https://www.workbuddy.ai" {
t.Errorf("intl profile base/origin unexpected: %+v", p)
}
if p := profileForEdition("cn"); p.Base != "https://copilot.tencent.com" || p.Platform != "VSCode" {
t.Errorf("cn profile base/platform unexpected: %+v", p)
}
}
// 验证各站点上游 URL 构建与旧版常量完全一致(国内站回归)+ 国际站正确
func TestUpstreamProfileURLs(t *testing.T) {
cn := profileForEdition("cn")
if got := cn.authStateURL(); got != "https://copilot.tencent.com/v2/plugin/auth/state?platform=VSCode" {
t.Errorf("cn authStateURL = %s", got)
}
if got := cn.chatURL(); got != "https://copilot.tencent.com/v2/chat/completions" {
t.Errorf("cn chatURL = %s", got)
}
if got := cn.tokenRefreshURL(); got != "https://copilot.tencent.com/v2/plugin/auth/token/refresh" {
t.Errorf("cn tokenRefreshURL = %s", got)
}
itl := profileForEdition("intl")
if got := itl.authStateURL(); got != "https://www.workbuddy.ai/v2/plugin/auth/state?platform=workbuddy-ai" {
t.Errorf("intl authStateURL = %s", got)
}
if got := itl.authTokenURL("abc-123"); got != "https://www.workbuddy.ai/v2/plugin/auth/token?state=abc-123" {
t.Errorf("intl authTokenURL = %s", got)
}
if got := itl.loginAcctURL("abc-123"); got != "https://www.workbuddy.ai/v2/plugin/login/account?state=abc-123" {
t.Errorf("intl loginAcctURL = %s", got)
}
if got := itl.chatURL(); got != "https://www.workbuddy.ai/v2/chat/completions" {
t.Errorf("intl chatURL = %s", got)
}
}
// 验证账号站点路由:优先取凭据内 edition;失效标记恢复(Auth 为 nil)时取账号上的 edition
func TestAccountProfile(t *testing.T) {
acc := &Account{Auth: &StoredAuth{Edition: "intl"}}
if acc.Profile().Key != "intl" {
t.Fatalf("expected intl via Auth.Edition, got %s", acc.Profile().Key)
}
// 失效标记恢复场景:凭据文件已删除
acc2 := &Account{Edition: "intl"}
if acc2.Profile().Key != "intl" {
t.Fatalf("expected intl via Account.Edition, got %s", acc2.Profile().Key)
}
// 旧版凭据(无 edition 字段)回退国内站
acc3 := &Account{Auth: &StoredAuth{}}
if acc3.Profile().Key != "cn" {
t.Fatalf("expected cn fallback, got %s", acc3.Profile().Key)
}
}
// 验证限流识别(429 状态码 / code 6004 / 频率限制关键词,含中英文)
func TestIsRateLimited(t *testing.T) {
cases := []struct {
status int
body string
want bool
}{
{429, "{}", true},
{400, `{"code":6004,"msg":"x"}`, true},
{400, "频率限制", true},
{400, "frequency limit", true},
{400, "rate limit exceeded", true},
{400, "Rate Limit Exceeded", true},
{400, "ratelimit", true},
{400, "Too Many Requests", true},
{400, "some other error", false},
{500, "{}", false},
}
for _, c := range cases {
if got := isRateLimited(c.status, c.body); got != c.want {
t.Errorf("isRateLimited(%d, %q) = %v, want %v", c.status, c.body, got, c.want)
}
}
}
// 验证轮询选择:两个账号交替返回
func TestNextAccountRoundRobin(t *testing.T) {
accountMu.Lock()
accounts = []*Account{
{Path: "a.json", Auth: &StoredAuth{}},
{Path: "b.json", Auth: &StoredAuth{}},
}
rrIndex = 0
accountMu.Unlock()
a1, _ := nextAccount()
a2, _ := nextAccount()
a3, _ := nextAccount()
if a1.Path != "a.json" || a2.Path != "b.json" || a3.Path != "a.json" {
t.Fatalf("round robin failed: %s %s %s", a1.Path, a2.Path, a3.Path)
}
t.Logf("round-robin order: %s %s %s", a1.Path, a2.Path, a3.Path)
}
// 验证冷却屏蔽:冷却中的账号被跳过,由另一账号代偿
func TestNextAccountSkipsCooldown(t *testing.T) {
accountMu.Lock()
accounts = []*Account{
{Path: "a.json", Auth: &StoredAuth{}, CooldownUntil: time.Now().Add(time.Hour)},
{Path: "b.json", Auth: &StoredAuth{}},
}
rrIndex = 0
accountMu.Unlock()
a, _ := nextAccount()
if a.Path != "b.json" {
t.Fatalf("expected b.json (only non-cooldown), got %s", a.Path)
}
t.Logf("cooldown skip works, selected %s", a.Path)
}
// 验证全部冷却时返回错误
func TestNextAccountAllCooldown(t *testing.T) {
accountMu.Lock()
accounts = []*Account{
{Path: "a.json", Auth: &StoredAuth{}, CooldownUntil: time.Now().Add(time.Hour)},
{Path: "b.json", Auth: &StoredAuth{}, CooldownUntil: time.Now().Add(2 * time.Hour)},
}
rrIndex = 0
accountMu.Unlock()
_, err := nextAccount()
if err == nil {
t.Fatal("expected error when all accounts in cooldown")
}
t.Logf("all-cooldown error: %v", err)
}
// 验证冷却到期后自动恢复
func TestNextAccountCooldownExpiry(t *testing.T) {
accountMu.Lock()
accounts = []*Account{
{Path: "a.json", Auth: &StoredAuth{}, CooldownUntil: time.Now().Add(-time.Minute)},
{Path: "b.json", Auth: &StoredAuth{}, CooldownUntil: time.Now().Add(time.Hour)},
}
rrIndex = 0
accountMu.Unlock()
a, _ := nextAccount()
if a.Path != "a.json" {
t.Fatalf("expected a.json (cooldown expired), got %s", a.Path)
}
t.Logf("expired cooldown recovers, selected %s", a.Path)
}
// 验证授权失效识别(401/403 / invalid token / 登录过期等)
func TestIsAuthFailure(t *testing.T) {
cases := []struct {
status int
body string
want bool
}{
{401, "{}", true},
{403, "{}", true},
{400, `{"msg":"invalid token"}`, true},
{400, "unauthorized", true},
{400, "登录已过期", true},
{400, "登录失效,请重新登录", true},
{400, "some other error", false},
{429, "频率限制", false},
{500, "{}", false},
}
for _, c := range cases {
if got := isAuthFailure(c.status, c.body); got != c.want {
t.Errorf("isAuthFailure(%d, %q) = %v, want %v", c.status, c.body, got, c.want)
}
}
}
// 验证轮询跳过已失效(Disabled)账号
func TestNextAccountSkipsDisabled(t *testing.T) {
accountMu.Lock()
accounts = []*Account{
{Path: "a.json", Auth: &StoredAuth{}, Disabled: true, DisabledReason: "revoked"},
{Path: "b.json", Auth: &StoredAuth{}},
}
rrIndex = 0
accountMu.Unlock()
a, _ := nextAccount()
if a.Path != "b.json" {
t.Fatalf("expected b.json (only non-disabled), got %s", a.Path)
}
t.Logf("disabled skip works, selected %s", a.Path)
}
// 验证全部失效时返回错误并提示重新登录
func TestNextAccountAllDisabled(t *testing.T) {
accountMu.Lock()
accounts = []*Account{
{Path: "a.json", Auth: &StoredAuth{}, Disabled: true, DisabledReason: "expired"},
{Path: "b.json", Auth: &StoredAuth{}, Disabled: true, DisabledReason: "revoked"},
}
rrIndex = 0
accountMu.Unlock()
_, err := nextAccount()
if err == nil {
t.Fatal("expected error when all accounts disabled")
}
t.Logf("all-disabled error: %v", err)
}
// 验证 disableAccount:标记失效、删除凭据文件、写入失效标记文件
func TestDisableAccount(t *testing.T) {
dir := t.TempDir()
authPath := dir + "/workbuddy-test.json"
// 写一个假的凭据文件
if err := os.WriteFile(authPath, []byte(`{"auth":{"accessToken":"x"}}`), 0600); err != nil {
t.Fatal(err)
}
acc := &Account{Path: authPath, Auth: &StoredAuth{
Auth: StoredTokens{AccessToken: "x"},
Account: StoredAccount{Nickname: "tester", UID: "uid-1"},
}}
disableAccount(acc, "令牌刷新失败 (HTTP 401): invalid token")
accountMu.Lock()
disabled := acc.Disabled
reason := acc.DisabledReason
accountMu.Unlock()
if !disabled {
t.Fatal("account should be marked disabled")
}
if reason == "" {
t.Fatal("disabled reason should be recorded")
}
// 凭据文件应被删除
if _, err := os.Stat(authPath); !os.IsNotExist(err) {
t.Fatalf("credential file should be deleted, stat err=%v", err)
}
// 失效标记文件应存在
if _, err := os.Stat(markerPath(authPath)); err != nil {
t.Fatalf("marker file should exist: %v", err)
}
t.Logf("disableAccount works: disabled=%v reason=%q", disabled, reason)
}
// 验证失效标记文件可被恢复为失效账号(Auth 为 nil),并提示重新登录
func TestLoadDisabledMarkers(t *testing.T) {
dir := t.TempDir()
authPath := dir + "/workbuddy-test.json"
// 构造失效标记文件
marker := disabledMarker{
Path: authPath,
Reason: "授权失效(令牌刷新失败 HTTP 401)",
DisabledAt: time.Now().Unix(),
Nickname: "tester",
UID: "uid-1",
}
data, _ := json.Marshal(marker)
if err := os.WriteFile(markerPath(authPath), data, 0600); err != nil {
t.Fatal(err)
}
// 模拟 -auth 显式指定该路径
oldAuthFile := cfg.AuthFile
oldAuthDir := cfg.AuthDir
oldAuthExplicit := cfg.AuthExplicit
defer func() {
cfg.AuthFile = oldAuthFile
cfg.AuthDir = oldAuthDir
cfg.AuthExplicit = oldAuthExplicit
}()
cfg.AuthFile = authPath
cfg.AuthDir = ""
cfg.AuthExplicit = true
accounts = nil
loadDisabledMarkers()
if len(accounts) != 1 {
t.Fatalf("expected 1 disabled account, got %d", len(accounts))
}
acc := accounts[0]
if !acc.Disabled || acc.Auth != nil {
t.Fatalf("expected disabled account with nil Auth, got disabled=%v authNil=%v", acc.Disabled, acc.Auth == nil)
}
if acc.Nickname != "tester" || acc.UID != "uid-1" {
t.Fatalf("marker nickname/uid not restored: %+v", acc)
}
t.Logf("loadDisabledMarkers restores: %s (nickname=%s)", acc.Path, acc.Nickname)
}
// 验证登录成功后清除失效标记
func TestClearDisabledMarker(t *testing.T) {
dir := t.TempDir()
authPath := dir + "/workbuddy-test.json"
marker := disabledMarker{Path: authPath, Reason: "revoked"}
data, _ := json.Marshal(marker)
if err := os.WriteFile(markerPath(authPath), data, 0600); err != nil {
t.Fatal(err)
}
clearDisabledMarker(authPath)
if _, err := os.Stat(markerPath(authPath)); !os.IsNotExist(err) {
t.Fatalf("marker file should be removed, stat err=%v", err)
}
t.Log("clearDisabledMarker works")
}
// 验证自动发现模式:未指定 -auth/-auth-dir 时,扫描当前目录下所有 workbuddy*.json
func TestCollectConfiguredAuthPathsAutoDiscover(t *testing.T) {
oldDir, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
defer os.Chdir(oldDir)
dir := t.TempDir()
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
// 模拟目录内多个凭据文件 + 非凭据文件 + 失效标记 + 运行时状态快照(应被排除)
for _, name := range []string{"workbuddy.json", "workbuddy2.json", "workbuddy-3.json", "other.json", "workbuddy.json.disabled", "workbuddy-status.json"} {
if err := os.WriteFile(name, []byte(`{"auth":{"accessToken":"x"}}`), 0600); err != nil {
t.Fatal(err)
}
}
oldAuthFile := cfg.AuthFile
oldAuthDir := cfg.AuthDir
oldAuthExplicit := cfg.AuthExplicit
defer func() {
cfg.AuthFile = oldAuthFile
cfg.AuthDir = oldAuthDir
cfg.AuthExplicit = oldAuthExplicit
}()
cfg.AuthFile = "workbuddy.json"
cfg.AuthDir = ""
cfg.AuthExplicit = false
paths := collectConfiguredAuthPaths()
if len(paths) != 3 {
t.Fatalf("expected 3 workbuddy json files, got %d: %v", len(paths), paths)
}
want := []string{"workbuddy-3.json", "workbuddy.json", "workbuddy2.json"} // sort.Strings 排序结果
for i, p := range want {
if paths[i] != p {
t.Fatalf("paths[%d] = %s, want %s (all: %v)", i, paths[i], p, paths)
}
}
t.Logf("auto-discover paths: %v", paths)
}
// 验证自动发现模式:目录内没有任何凭据文件时回退到默认路径
func TestCollectConfiguredAuthPathsAutoDiscoverFallback(t *testing.T) {
oldDir, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
defer os.Chdir(oldDir)
dir := t.TempDir()
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
// 目录为空
oldAuthFile := cfg.AuthFile
oldAuthDir := cfg.AuthDir
oldAuthExplicit := cfg.AuthExplicit
defer func() {
cfg.AuthFile = oldAuthFile
cfg.AuthDir = oldAuthDir
cfg.AuthExplicit = oldAuthExplicit
}()
cfg.AuthFile = "workbuddy.json"
cfg.AuthDir = ""
cfg.AuthExplicit = false
paths := collectConfiguredAuthPaths()
if len(paths) != 1 || paths[0] != "workbuddy.json" {
t.Fatalf("expected fallback to default workbuddy.json, got %v", paths)
}
t.Logf("fallback path: %v", paths)
}
// 验证凭据热加载:新增 / 更新 / 删除凭据文件均原地收敛账号池,无需重启
func TestReloadAccounts(t *testing.T) {
dir := t.TempDir()
oldAuthFile := cfg.AuthFile
oldAuthDir := cfg.AuthDir
oldAuthExplicit := cfg.AuthExplicit
defer func() {
cfg.AuthFile = oldAuthFile
cfg.AuthDir = oldAuthDir
cfg.AuthExplicit = oldAuthExplicit
accountMu.Lock()
accounts = nil
rrIndex = 0
accountMu.Unlock()
}()
cfg.AuthFile = "workbuddy.json"
cfg.AuthDir = dir
cfg.AuthExplicit = false
accountMu.Lock()
accounts = nil
rrIndex = 0
accountMu.Unlock()
pa := filepath.Join(dir, "workbuddy-a.json")
// 1) 新增凭据文件 → 自动入池
if err := os.WriteFile(pa, []byte(`{"auth":{"accessToken":"a1"},"account":{"nickname":"A"}}`), 0600); err != nil {
t.Fatal(err)
}
if !reloadAccounts() {
t.Fatal("expected changed=true after adding new credential file")
}
if len(accounts) != 1 || accounts[0].Auth.Auth.AccessToken != "a1" {
t.Fatalf("expected 1 account with a1, got %+v", accounts)
}
t.Logf("hot-add works: %s joined the pool", accounts[0].Path)
// 2) 内容变更(含站点切换 cn -> intl)→ 原地替换凭据
if err := os.WriteFile(pa, []byte(`{"auth":{"accessToken":"a2-longer"},"account":{"nickname":"A2"},"edition":"intl"}`), 0600); err != nil {
t.Fatal(err)
}
if !reloadAccounts() {
t.Fatal("expected changed=true after credential content change")
}
if len(accounts) != 1 || accounts[0].Auth.Auth.AccessToken != "a2-longer" {
t.Fatalf("expected hot-reloaded a2 credential, got %+v", accounts)
}
if accounts[0].Profile().Key != "intl" {
t.Fatalf("expected intl profile after reload, got %s", accounts[0].Profile().Key)
}
t.Logf("hot-update works: edition now %s", accounts[0].Profile().Key)
// 3) 失效幻影账号:文件被删但账号 Disabled → 保留(用于提示重新登录)
accountMu.Lock()
accounts[0].Disabled = true
accountMu.Unlock()
if err := os.Remove(pa); err != nil {
t.Fatal(err)
}
if reloadAccounts() {
t.Fatal("disabled phantom account should be kept, expected no change")
}
if len(accounts) != 1 || !accounts[0].Disabled {
t.Fatalf("disabled phantom should be kept, got %d accounts", len(accounts))
}
t.Log("disabled phantom kept after file deletion")
// 4) 非失效账号文件被删 → 移出账号池
accountMu.Lock()
accounts[0].Disabled = false
accounts[0].Auth = &StoredAuth{Auth: StoredTokens{AccessToken: "x"}}
accountMu.Unlock()
if !reloadAccounts() {
t.Fatal("expected changed=true after credential file deletion")
}
if len(accounts) != 0 {
t.Fatalf("expected empty pool after deletion, got %d", len(accounts))
}
t.Log("hot-remove works: pool emptied after file deletion")
}
// 验证 tailLines 读取文件末尾 N 行
func TestTailLines(t *testing.T) {
dir := t.TempDir()
f := dir + "/test.log"
content := "line1\nline2\nline3\nline4\nline5\n"
if err := os.WriteFile(f, []byte(content), 0600); err != nil {
t.Fatal(err)
}
lines, err := tailLines(f, 2)
if err != nil {
t.Fatal(err)
}
if len(lines) != 2 || lines[0] != "line4" || lines[1] != "line5" {
t.Fatalf("tailLines got %v", lines)
}
t.Logf("tailLines(2) = %v", lines)
}
// 验证状态快照写入:含 active / cooldown / disabled 三种状态
func TestWriteStatusSnapshot(t *testing.T) {
oldDir, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
defer os.Chdir(oldDir)
dir := t.TempDir()
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
accountMu.Lock()
accounts = []*Account{
{Path: "a.json", Auth: &StoredAuth{
Auth: StoredTokens{AccessToken: "x", ExpiresAt: time.Now().Add(time.Hour).Unix()},
Account: StoredAccount{Nickname: "alice", UID: "u1"},
}},
{Path: "b.json", Auth: &StoredAuth{
Auth: StoredTokens{AccessToken: "x", ExpiresAt: time.Now().Add(time.Hour).Unix()},
Account: StoredAccount{Nickname: "bob", UID: "u2"},
}, CooldownUntil: time.Now().Add(30 * time.Minute), CooldownMsg: "频率限制"},
{Path: "c.json", Disabled: true, DisabledReason: "revoked", Nickname: "carol", UID: "u3"},
}
accountMu.Unlock()
writeStatusSnapshot()
data, err := os.ReadFile(statusSnapshotFile)
if err != nil {
t.Fatal(err)
}
var snap statusSnapshot
if err := json.Unmarshal(data, &snap); err != nil {
t.Fatal(err)
}
if len(snap.Accounts) != 3 {
t.Fatalf("expected 3 accounts in snapshot, got %d", len(snap.Accounts))
}
states := map[string]bool{}
for _, a := range snap.Accounts {
states[a.State] = true
}
if !states["active"] || !states["cooldown"] || !states["disabled"] {
t.Fatalf("expected all three states in snapshot, got %v", states)
}
t.Logf("snapshot states: %v", states)
}