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
This commit is contained in:
+4
-7
@@ -1,19 +1,16 @@
|
||||
# 凭据与敏感数据(切勿提交!)
|
||||
workbuddy.json
|
||||
workbuddy*.json
|
||||
workbuddy-console.json
|
||||
dist/workbuddy.json
|
||||
dist/*.json
|
||||
last-*.json
|
||||
*.json.bak
|
||||
*.json.disabled
|
||||
*.disabled
|
||||
.workbuddy-*.json
|
||||
workbuddy-status.json
|
||||
|
||||
# 本地编译产物
|
||||
workbuddy-gateway
|
||||
workbuddy-gateway.exe
|
||||
dist/*.exe
|
||||
dist/workbuddy-gateway-linux-*
|
||||
dist/workbuddy-gateway-darwin-*
|
||||
dist/
|
||||
|
||||
# 日志与临时文件
|
||||
*.log
|
||||
|
||||
@@ -2,14 +2,23 @@
|
||||
<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` 子命令完成全部管理。
|
||||
- **多账号池 + 轮询负载均衡**:支持同时挂载多个 CodeBuddy 账号(`-auth` 逗号分隔或 `-auth-dir` 目录),请求按轮询(round-robin)均匀分配到各账号,保持多账号额度使用一致。
|
||||
- **429 频率限制自动冷却**:任一账号触发上游频率限制(HTTP 429 / code 6004)时,自动解析消息中的重置时间(如 `将在 2026-09-04 07:48:15 UTC+8 重置`),将该账号屏蔽至重置时间;冷却期间自动改用其他账号代偿,冷却到期自动恢复。
|
||||
- **纯 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`。
|
||||
@@ -39,6 +48,28 @@ workbuddy-gateway login -auth /opt/workbuddy-gateway/workbuddy001.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
|
||||
@@ -123,6 +154,7 @@ workbuddy-gateway status
|
||||
# 输出示例:
|
||||
# --- 账号 #1 ---
|
||||
# 凭据文件: workbuddy-2.json
|
||||
# 站点: 国内站 (copilot.tencent.com)
|
||||
# 用户昵称: tester
|
||||
# 用户 UID: uid-xxx
|
||||
# 账号状态: ❌ 授权失效(禁止调度)
|
||||
@@ -140,6 +172,22 @@ workbuddy-gateway status # 该账号恢复为 ✅ 可用
|
||||
- 只有一个账号时,请求始终使用该账号,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 退出):
|
||||
@@ -288,7 +336,7 @@ sudo /opt/workbuddy-gateway/workbuddy-gateway login -auth /opt/workbuddy-gateway
|
||||
|
||||
> 💡 **多账号自动发现**:服务通过 `WorkingDirectory` 固定在 `/opt/workbuddy-gateway`,
|
||||
> 无需修改 ExecStart——把多个凭据文件(`workbuddy.json`、`workbuddy2.json`…)放进该目录,
|
||||
> 重启服务即自动加载全部账号组成轮询池。确认方式:
|
||||
> 即可自动组成轮询池;凭据热加载会让新增/更新/删除的凭据文件免重启生效。确认方式:
|
||||
> ```bash
|
||||
> sudo journalctl -u workbuddy-gateway | grep 账号池
|
||||
> # 输出示例: 已就绪账号池: 2 个账号 (有效 2, 失效 0)
|
||||
@@ -363,7 +411,8 @@ ExecStart=/opt/workbuddy-gateway/workbuddy-gateway serve -addr 0.0.0.0 -port 831
|
||||
|
||||
```
|
||||
命令: serve | login | status | refresh | monitor | version | help
|
||||
选项: -addr <ip> · -port <port> · -auth <path> · -auth-dir <dir> · -api-key <key> · -proxy <url> · -verbose
|
||||
选项: -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>
|
||||
```
|
||||
|
||||
@@ -379,4 +428,4 @@ CGO_ENABLED=0 go build -ldflags="-s -w" -o workbuddy-gateway .
|
||||
|
||||
## 免责声明
|
||||
|
||||
本项目仅用于个人学习与技术研究。腾讯 CodeBuddy 的接口协议与风控策略可能随时变化;请遵守腾讯服务条款,自行承担使用风险。本仓库不包含任何官方未公开的密钥或凭据。
|
||||
本项目仅用于个人学习与技术研究。腾讯 CodeBuddy(含国内站与国际站 workbuddy.ai)的接口协议与风控策略可能随时变化;请遵守腾讯服务条款,自行承担使用风险。本仓库不包含任何官方未公开的密钥或凭据。
|
||||
|
||||
@@ -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
|
||||
@@ -31,23 +31,80 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
version = "1.5.0"
|
||||
upstreamBase = "https://copilot.tencent.com"
|
||||
clientUA = "CLI/2.143.1 CodeBuddy/2.143.1"
|
||||
originReferer = "https://www.codebuddy.cn"
|
||||
|
||||
endpointAuthState = upstreamBase + "/v2/plugin/auth/state?platform=VSCode"
|
||||
endpointLoginAcct = upstreamBase + "/v2/plugin/login/account?state="
|
||||
endpointAuthToken = upstreamBase + "/v2/plugin/auth/token?state="
|
||||
endpointTokenRefresh = upstreamBase + "/v2/plugin/auth/token/refresh"
|
||||
endpointChat = upstreamBase + "/v2/chat/completions"
|
||||
|
||||
loginTTL = 5 * time.Minute
|
||||
version = "1.7.0"
|
||||
|
||||
// 状态快照文件名:serve 后台周期写入,monitor 前台命令实时读取展示
|
||||
statusSnapshotFile = "workbuddy-status.json"
|
||||
)
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 上游站点 Profile(国内站 / 国际站)
|
||||
//
|
||||
// 国际站 www.workbuddy.ai 与国内站 copilot.tencent.com 走同一套 /v2/plugin/* 协议
|
||||
// (实测:auth/state、auth/token、login/account、auth/token/refresh、chat/completions
|
||||
// 的路径与响应包络完全一致),差异仅在:
|
||||
// - 上游域名与 Web Origin(国际站位于腾讯 EdgeOne 国际 CDN)
|
||||
// - 登录 platform 参数(workbuddy-ai 而非 VSCode),登录在浏览器内完成(邮箱/验证码/SSO)
|
||||
// - 等待授权期间 auth/token 轮询返回 code 11217 (login ing...)
|
||||
// 每个账号凭据文件通过 edition 字段记录所属站点,账号池支持国内/国际混挂轮询。
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
type upstreamProfile struct {
|
||||
Key string // 存储于凭据文件 edition 字段的站点标识
|
||||
Label string // 控制台展示名
|
||||
Base string // 上游 API 基础地址
|
||||
Origin string // Origin/Referer 伪装来源(各站 Web 控制台)
|
||||
Platform string // auth/state 的 platform 参数
|
||||
ClientUA string // User-Agent
|
||||
ClientID string // X-Client-ID
|
||||
ClientVer string // X-Client-Version
|
||||
Product string // X-Product
|
||||
LoginTTL time.Duration // login 命令等待授权完成的超时
|
||||
}
|
||||
|
||||
var (
|
||||
profileCN = upstreamProfile{
|
||||
Key: "cn", Label: "国内站",
|
||||
Base: "https://copilot.tencent.com", Origin: "https://www.codebuddy.cn",
|
||||
Platform: "VSCode", ClientUA: "CLI/2.143.1 CodeBuddy/2.143.1",
|
||||
ClientID: "codebuddy-cli", ClientVer: "2.143.1", Product: "SaaS",
|
||||
LoginTTL: 5 * time.Minute,
|
||||
}
|
||||
profileINTL = upstreamProfile{
|
||||
Key: "intl", Label: "国际站",
|
||||
Base: "https://www.workbuddy.ai", Origin: "https://www.workbuddy.ai",
|
||||
Platform: "workbuddy-ai", ClientUA: "CLI/2.143.1 CodeBuddy/2.143.1",
|
||||
ClientID: "codebuddy-cli", ClientVer: "2.143.1", Product: "SaaS",
|
||||
LoginTTL: 15 * time.Minute, // 浏览器内登录(邮箱/验证码/SSO)比扫码慢,放宽超时
|
||||
}
|
||||
)
|
||||
|
||||
// profileForEdition 根据凭据文件中的 edition 标识返回上游站点参数;空值/未知值回退国内站。
|
||||
func profileForEdition(edition string) *upstreamProfile {
|
||||
switch strings.ToLower(strings.TrimSpace(edition)) {
|
||||
case "intl", "international", "global", "workbuddy.ai":
|
||||
return &profileINTL
|
||||
default:
|
||||
return &profileCN
|
||||
}
|
||||
}
|
||||
|
||||
func (p *upstreamProfile) authStateURL() string {
|
||||
return p.Base + "/v2/plugin/auth/state?platform=" + url.QueryEscape(p.Platform)
|
||||
}
|
||||
|
||||
func (p *upstreamProfile) loginAcctURL(state string) string {
|
||||
return p.Base + "/v2/plugin/login/account?state=" + url.QueryEscape(state)
|
||||
}
|
||||
|
||||
func (p *upstreamProfile) authTokenURL(state string) string {
|
||||
return p.Base + "/v2/plugin/auth/token?state=" + url.QueryEscape(state)
|
||||
}
|
||||
|
||||
func (p *upstreamProfile) tokenRefreshURL() string { return p.Base + "/v2/plugin/auth/token/refresh" }
|
||||
|
||||
func (p *upstreamProfile) chatURL() string { return p.Base + "/v2/chat/completions" }
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 数据结构定义
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -55,6 +112,7 @@ const (
|
||||
type StoredAuth struct {
|
||||
Auth StoredTokens `json:"auth"`
|
||||
Account StoredAccount `json:"account"`
|
||||
Edition string `json:"edition,omitempty"` // 站点标识:cn(国内站,默认)| intl(国际站 www.workbuddy.ai)
|
||||
}
|
||||
|
||||
type StoredTokens struct {
|
||||
@@ -105,9 +163,11 @@ type Config struct {
|
||||
AuthFile string
|
||||
AuthDir string
|
||||
AuthExplicit bool // 用户是否显式指定了 -auth(未指定时自动扫描目录下所有 workbuddy*.json)
|
||||
LoginIntl bool // login -intl:登录国际站 (www.workbuddy.ai,浏览器内完成登录)
|
||||
APIKey string
|
||||
ProxyURL string
|
||||
Verbose bool
|
||||
ReloadInterval int // 账号池热加载扫描间隔(秒),0 关闭
|
||||
MonitorInterval int // monitor 状态刷新间隔(秒)
|
||||
LogFile string // monitor 附加展示的日志文件路径
|
||||
JournalService string // monitor 附加展示的 systemd 服务名(journalctl -u)
|
||||
@@ -125,9 +185,19 @@ type Account struct {
|
||||
DisabledReason string // 失效原因,用于控制台展示
|
||||
Nickname string // 失效标记持久化字段:昵称(Auth 为 nil 时使用)
|
||||
UID string // 失效标记持久化字段:UID
|
||||
Edition string // 站点标识(cn/intl):失效标记恢复时 Auth 为 nil 也可见
|
||||
fingerprint string // 凭据文件变更指纹(mtime+size,凭据热加载用)
|
||||
lock sync.Mutex // 单账号串行锁(防止同账号并发触发 11128)
|
||||
}
|
||||
|
||||
// Profile 返回该账号对应的上游站点参数(国内站/国际站)。
|
||||
func (acc *Account) Profile() *upstreamProfile {
|
||||
if acc.Auth != nil {
|
||||
return profileForEdition(acc.Auth.Edition)
|
||||
}
|
||||
return profileForEdition(acc.Edition)
|
||||
}
|
||||
|
||||
// disabledMarker 是授权失效账号的持久化标记(凭据文件删除后用于控制台提示重新登录)。
|
||||
type disabledMarker struct {
|
||||
Path string `json:"path"`
|
||||
@@ -135,6 +205,7 @@ type disabledMarker struct {
|
||||
DisabledAt int64 `json:"disabledAt"`
|
||||
Nickname string `json:"nickname,omitempty"`
|
||||
UID string `json:"uid,omitempty"`
|
||||
Edition string `json:"edition,omitempty"` // 站点标识(cn/intl),用于控制台展示
|
||||
}
|
||||
|
||||
// markerPath 返回与凭据文件同目录的失效标记文件路径。
|
||||
@@ -185,7 +256,9 @@ func main() {
|
||||
fs.StringVar(&cfg.APIKey, "api-key", "", "可选:访问网关所需的 API Key (客户端 Bearer 校验)")
|
||||
fs.StringVar(&cfg.ProxyURL, "proxy", "", "可选:上游请求代理 (如 http://127.0.0.1:7890)")
|
||||
fs.BoolVar(&cfg.Verbose, "verbose", false, "输出详细调试日志")
|
||||
fs.BoolVar(&cfg.LoginIntl, "intl", false, "login 专用:登录国际站 (www.workbuddy.ai,浏览器内完成登录);默认登录国内站")
|
||||
fs.IntVar(&cfg.MonitorInterval, "interval", 3, "monitor 状态刷新间隔(秒)")
|
||||
fs.IntVar(&cfg.ReloadInterval, "reload-interval", 5, "账号池热加载扫描间隔(秒),0 关闭:运行期自动发现新增/更新/删除的凭据文件,免重启")
|
||||
fs.StringVar(&cfg.LogFile, "logfile", "", "monitor 附加跟随的日志文件路径(如 -logfile /var/log/workbuddy-gateway.log)")
|
||||
fs.StringVar(&cfg.JournalService, "journal", "", "monitor 附加跟随的 systemd 服务名(Linux 下用 journalctl -u <服务> -f 跟随)")
|
||||
fs.IntVar(&cfg.LogLines, "lines", 5, "monitor 每次刷新展示的最近日志行数")
|
||||
@@ -234,8 +307,8 @@ func printHelp() {
|
||||
|
||||
命令:
|
||||
serve 启动本地网关 (默认操作)
|
||||
login 微信/企业微信扫码登录,获取/更新凭据
|
||||
status 查看账号池状态(含冷却状态与过期时间)
|
||||
login 登录并获取/更新凭据:国内站微信扫码;-intl 登录国际站 (浏览器内完成)
|
||||
status 查看账号池状态(含站点、冷却状态与过期时间)
|
||||
refresh 手动立即刷新所有账号访问令牌 (Access Token)
|
||||
monitor 前台实时监控:周期刷新展示账号状态 + 最近日志 (Ctrl+C 退出)
|
||||
version 查看版本信息
|
||||
@@ -247,6 +320,10 @@ func printHelp() {
|
||||
-auth <path> 凭据文件路径;支持逗号分隔多个文件实现多账号
|
||||
(默认: 自动发现当前目录下所有 workbuddy*.json)
|
||||
-auth-dir <dir> 凭据目录:自动加载目录下所有 workbuddy*.json 作为账号池
|
||||
-intl login 专用:登录国际站 www.workbuddy.ai(浏览器内完成登录)
|
||||
-reload-interval <sec>
|
||||
账号池热加载扫描间隔(默认 5 秒,0 关闭):运行期自动发现
|
||||
新增/更新/删除的凭据文件,免重启生效
|
||||
-api-key <key> 设置后,调用网关必须携带 Bearer <key> 鉴权
|
||||
-proxy <url> 设置上游转发代理 (例如 http://127.0.0.1:7890 或 socks5://...)
|
||||
-verbose 输出详细调试日志 (请求/响应体)
|
||||
@@ -261,19 +338,27 @@ monitor 选项:
|
||||
# 登录第二个账号(保存到不同文件)
|
||||
workbuddy-gateway login -auth workbuddy2.json
|
||||
|
||||
# 登录国际站账号(www.workbuddy.ai,浏览器内完成登录)
|
||||
workbuddy-gateway login -intl -auth workbuddy-intl.json
|
||||
|
||||
# 自动发现:把多个凭据文件放进工作目录即可自动多账号(无需任何参数)
|
||||
workbuddy-gateway serve # 自动加载 ./workbuddy*.json
|
||||
|
||||
# 启动时指定多个凭据文件(轮询 + 429 自动冷却代偿)
|
||||
# 启动时指定多个凭据文件(轮询 + 429 自动冷却代偿;国内/国际可混挂)
|
||||
workbuddy-gateway serve -auth workbuddy.json,workbuddy2.json
|
||||
|
||||
# 或使用目录模式:目录内所有 workbuddy*.json 自动组成账号池
|
||||
workbuddy-gateway serve -auth-dir ./auths
|
||||
|
||||
# 运行期新增/更新/删除凭据文件会自动热加载(默认每 5 秒),无需重启 serve
|
||||
|
||||
示例:
|
||||
# 首次使用扫码登录
|
||||
# 首次使用扫码登录(国内站)
|
||||
workbuddy-gateway login
|
||||
|
||||
# 登录国际站(www.workbuddy.ai)
|
||||
workbuddy-gateway login -intl
|
||||
|
||||
# 启动本地网关 (监听 127.0.0.1:8317)
|
||||
workbuddy-gateway serve
|
||||
|
||||
@@ -387,6 +472,15 @@ func loadAccountFile(path string) (*StoredAuth, error) {
|
||||
return &sa, nil
|
||||
}
|
||||
|
||||
// isCredentialFile 判断自动发现模式下文件名是否为凭据文件。
|
||||
// 排除运行时产物:workbuddy-status.json(monitor 状态快照)等。
|
||||
func isCredentialFile(name string) bool {
|
||||
if !strings.HasPrefix(name, "workbuddy") || !strings.HasSuffix(name, ".json") {
|
||||
return false
|
||||
}
|
||||
return name != statusSnapshotFile
|
||||
}
|
||||
|
||||
// collectConfiguredAuthPaths 解析应加载的凭据路径列表:
|
||||
// 1) -auth-dir 指定目录 → 目录下所有 workbuddy*.json
|
||||
// 2) 显式 -auth → 逗号分隔的凭据文件列表(保持用户顺序)
|
||||
@@ -399,13 +493,10 @@ func collectConfiguredAuthPaths() []string {
|
||||
entries, err := os.ReadDir(cfg.AuthDir)
|
||||
if err == nil {
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
if e.IsDir() || !isCredentialFile(e.Name()) {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
if strings.HasPrefix(name, "workbuddy") && strings.HasSuffix(name, ".json") {
|
||||
paths = append(paths, filepath.Join(cfg.AuthDir, name))
|
||||
}
|
||||
paths = append(paths, filepath.Join(cfg.AuthDir, e.Name()))
|
||||
}
|
||||
sort.Strings(paths)
|
||||
}
|
||||
@@ -426,13 +517,10 @@ func collectConfiguredAuthPaths() []string {
|
||||
entries, err := os.ReadDir(".")
|
||||
if err == nil {
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
if e.IsDir() || !isCredentialFile(e.Name()) {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
if strings.HasPrefix(name, "workbuddy") && strings.HasSuffix(name, ".json") {
|
||||
paths = append(paths, name)
|
||||
}
|
||||
paths = append(paths, e.Name())
|
||||
}
|
||||
sort.Strings(paths)
|
||||
}
|
||||
@@ -459,7 +547,11 @@ func loadAccounts() error {
|
||||
log.Printf("[Auth] 跳过无效凭据文件 %s: %v", p, err)
|
||||
continue
|
||||
}
|
||||
accounts = append(accounts, &Account{Path: p, Auth: sa})
|
||||
fp := ""
|
||||
if st, statErr := os.Stat(p); statErr == nil {
|
||||
fp = accountFingerprint(st)
|
||||
}
|
||||
accounts = append(accounts, &Account{Path: p, Auth: sa, Edition: profileForEdition(sa.Edition).Key, fingerprint: fp})
|
||||
}
|
||||
|
||||
// 恢复持久化的失效账号标记(凭据文件已删除,仅供控制台提示重新登录)
|
||||
@@ -471,6 +563,124 @@ func loadAccounts() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 凭据热加载:serve 运行期间自动发现凭据文件的新增/更新/删除,免重启收敛账号池
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// accountFingerprint 基于文件 mtime+size 生成凭据文件变更指纹。
|
||||
func accountFingerprint(st os.FileInfo) string {
|
||||
return fmt.Sprintf("%d:%d", st.ModTime().UnixNano(), st.Size())
|
||||
}
|
||||
|
||||
// reloadAccounts 将磁盘上的凭据文件与内存账号池原地收敛(热加载):
|
||||
// - 新增凭据文件 → 自动加入账号池;
|
||||
// - 凭据内容变化(重新登录/手动更新)→ 原地替换凭据、清除冷却/失效状态并恢复调度;
|
||||
// - 凭据文件被删除 → 移出账号池(已写入失效标记的幻影账号保留,用于提示重新登录)。
|
||||
//
|
||||
// 返回本次是否发生变更。调用方需自行处理 accountMu 之外的快照刷新。
|
||||
func reloadAccounts() bool {
|
||||
accountMu.Lock()
|
||||
defer accountMu.Unlock()
|
||||
|
||||
changed := false
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for _, p := range collectConfiguredAuthPaths() {
|
||||
st, err := os.Stat(p)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
seen[p] = true
|
||||
fp := accountFingerprint(st)
|
||||
|
||||
var existing *Account
|
||||
for _, acc := range accounts {
|
||||
if acc.Path == p {
|
||||
existing = acc
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if existing == nil {
|
||||
// 新凭据文件:加载并加入账号池(文件可能正在写入,失败则下轮重试)
|
||||
sa, err := loadAccountFile(p)
|
||||
if err != nil {
|
||||
log.Printf("[Reload] 凭据文件 %s 暂不可解析,下轮重试: %v", p, err)
|
||||
continue
|
||||
}
|
||||
accounts = append(accounts, &Account{
|
||||
Path: p,
|
||||
Auth: sa,
|
||||
Edition: profileForEdition(sa.Edition).Key,
|
||||
fingerprint: fp,
|
||||
})
|
||||
log.Printf("[Reload] 发现新账号凭据 %s(%s),已自动加入账号池", p, profileForEdition(sa.Edition).Label)
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
|
||||
if existing.fingerprint == fp {
|
||||
continue // 未变化
|
||||
}
|
||||
|
||||
// 内容变化:原地替换凭据(重新登录或手动更新)
|
||||
sa, err := loadAccountFile(p)
|
||||
if err != nil {
|
||||
// 不更新指纹,下一轮重试(正常写入窗口极短,几乎必在下轮成功)
|
||||
log.Printf("[Reload] 凭据文件 %s 变更但暂不可解析,保留旧凭据: %v", p, err)
|
||||
continue
|
||||
}
|
||||
recovered := existing.Disabled
|
||||
existing.Auth = sa
|
||||
existing.Edition = profileForEdition(sa.Edition).Key
|
||||
existing.Disabled = false
|
||||
existing.DisabledReason = ""
|
||||
existing.CooldownUntil = time.Time{}
|
||||
existing.CooldownMsg = ""
|
||||
existing.fingerprint = fp
|
||||
clearDisabledMarker(p)
|
||||
if recovered {
|
||||
log.Printf("[Reload] 账号 %s 重新登录成功,已自动恢复调度", p)
|
||||
} else {
|
||||
log.Printf("[Reload] 账号 %s 凭据已更新(热加载,无需重启)", p)
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
|
||||
// 收敛:移除已删除的凭据(失效幻影账号保留)
|
||||
kept := make([]*Account, 0, len(accounts))
|
||||
for _, acc := range accounts {
|
||||
if !seen[acc.Path] && !acc.Disabled {
|
||||
log.Printf("[Reload] 凭据文件 %s 已删除,已移出账号池", acc.Path)
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
kept = append(kept, acc)
|
||||
}
|
||||
accounts = kept
|
||||
if len(accounts) > 0 {
|
||||
rrIndex %= len(accounts)
|
||||
} else {
|
||||
rrIndex = 0
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
// accountReloaderLoop serve 后台凭据热加载协程:周期扫描凭据变化并原地收敛账号池。
|
||||
func accountReloaderLoop() {
|
||||
interval := time.Duration(cfg.ReloadInterval) * time.Second
|
||||
if interval <= 0 {
|
||||
return
|
||||
}
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
if reloadAccounts() {
|
||||
writeStatusSnapshot() // 立即刷新 monitor 状态文件
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// nextAccount 轮询选择下一个未处于冷却状态且未被禁用的账号。
|
||||
// 若全部账号不可用,返回错误并附带说明。
|
||||
func nextAccount() (*Account, error) {
|
||||
@@ -534,11 +744,14 @@ func disableAccount(acc *Account, reason string) {
|
||||
path := acc.Path
|
||||
nickname := ""
|
||||
uid := ""
|
||||
edition := ""
|
||||
if acc.Auth != nil {
|
||||
nickname = acc.Auth.Account.Nickname
|
||||
uid = acc.Auth.Account.UID
|
||||
edition = profileForEdition(acc.Auth.Edition).Key
|
||||
acc.Nickname = nickname
|
||||
acc.UID = uid
|
||||
acc.Edition = edition
|
||||
}
|
||||
accountMu.Unlock()
|
||||
|
||||
@@ -549,6 +762,7 @@ func disableAccount(acc *Account, reason string) {
|
||||
DisabledAt: time.Now().Unix(),
|
||||
Nickname: nickname,
|
||||
UID: uid,
|
||||
Edition: edition,
|
||||
}
|
||||
if data, err := json.MarshalIndent(marker, "", " "); err == nil {
|
||||
if werr := os.WriteFile(markerPath(path), data, 0600); werr != nil {
|
||||
@@ -635,6 +849,7 @@ func loadDisabledMarkers() {
|
||||
DisabledReason: m.Reason,
|
||||
Nickname: m.Nickname,
|
||||
UID: m.UID,
|
||||
Edition: m.Edition,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -666,34 +881,47 @@ func isAuthFailure(statusCode int, body string) bool {
|
||||
|
||||
var resetTimeRe = regexp.MustCompile(`将在\s*(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2})\s*(UTC[+-]\d+(?::\d{2})?)?`)
|
||||
|
||||
// resetTimeGenericRe 兜底匹配任意「日期 + 时间(可选 UTC 偏移)」片段,
|
||||
// 用于国际站等英文限流消息(如 "will reset at 2026-09-05 01:57:00 UTC+8")。
|
||||
var resetTimeGenericRe = regexp.MustCompile(`(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2})(?:\s*(UTC[+-]\d+(?::\d{2})?))?`)
|
||||
|
||||
// parseResetTime 从上游 429 错误消息中解析频率限制重置时间。
|
||||
// 示例: "您的使用量已超出频率限制,将在 2026-09-04 07:48:15 UTC+8 重置"
|
||||
// 国内站示例: "您的使用量已超出频率限制,将在 2026-09-04 07:48:15 UTC+8 重置"
|
||||
// 国际站示例: "Your usage has exceeded the rate limit. It will reset at 2026-09-05 01:57:00 UTC+8."
|
||||
func parseResetTime(s string) (time.Time, bool) {
|
||||
m := resetTimeRe.FindStringSubmatch(s)
|
||||
if m == nil {
|
||||
m = resetTimeGenericRe.FindStringSubmatch(s)
|
||||
}
|
||||
if m == nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return parseDateTimeTZ(m[1], m[2], m[3])
|
||||
}
|
||||
|
||||
// parseDateTimeTZ 按「日期 时间 (可选 UTC 偏移)」解析时间;未提供时区时默认按 UTC+8。
|
||||
func parseDateTimeTZ(date, clock, zone string) (time.Time, bool) {
|
||||
offset := 8 * 3600 // 默认按 UTC+8 解析
|
||||
if m[3] != "" {
|
||||
zone := m[3]
|
||||
zone = strings.TrimPrefix(zone, "UTC")
|
||||
zone = strings.TrimPrefix(zone, "utc")
|
||||
if zone != "" {
|
||||
z := zone
|
||||
z = strings.TrimPrefix(z, "UTC")
|
||||
z = strings.TrimPrefix(z, "utc")
|
||||
var h, mi int
|
||||
if _, err := fmt.Sscanf(zone, "%d:%d", &h, &mi); err == nil {
|
||||
if _, err := fmt.Sscanf(z, "%d:%d", &h, &mi); err == nil {
|
||||
offset = h*3600 + mi*60
|
||||
} else if _, err := fmt.Sscanf(zone, "%d", &h); err == nil {
|
||||
} else if _, err := fmt.Sscanf(z, "%d", &h); err == nil {
|
||||
offset = h * 3600
|
||||
}
|
||||
}
|
||||
loc := time.FixedZone("UTC", offset)
|
||||
t, err := time.ParseInLocation("2006-01-02 15:04:05", m[1]+" "+m[2], loc)
|
||||
t, err := time.ParseInLocation("2006-01-02 15:04:05", date+" "+clock, loc)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return t, true
|
||||
}
|
||||
|
||||
// isRateLimited 判断上游响应是否属于频率限制(429 或 code 6004 / 频率限制提示)。
|
||||
// isRateLimited 判断上游响应是否属于频率限制(429 或 code 6004 / 频率限制提示,含中英文)。
|
||||
func isRateLimited(statusCode int, body string) bool {
|
||||
if statusCode == http.StatusTooManyRequests {
|
||||
return true
|
||||
@@ -701,6 +929,10 @@ func isRateLimited(statusCode int, body string) bool {
|
||||
if strings.Contains(body, `"code":6004`) || strings.Contains(body, "频率限制") || strings.Contains(body, "frequency limit") {
|
||||
return true
|
||||
}
|
||||
low := strings.ToLower(body)
|
||||
if strings.Contains(low, "rate limit") || strings.Contains(low, "ratelimit") || strings.Contains(low, "too many requests") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -766,10 +998,12 @@ func doRefreshTokenFor(acc *Account) error {
|
||||
}
|
||||
|
||||
// refreshTokenPayload 调用上游刷新接口并更新内存中的令牌字段(不落盘)。
|
||||
// 按凭据文件中的 edition 路由到对应站点(国内站/国际站)的刷新接口。
|
||||
// 返回上游 HTTP 状态码(成功或失败时均为实际状态;网络错误为 0)。
|
||||
func refreshTokenPayload(sa *StoredAuth) (int, error) {
|
||||
prof := profileForEdition(sa.Edition)
|
||||
headers := func(r *http.Request) {
|
||||
commonHeaders(r)
|
||||
commonHeaders(r, prof)
|
||||
r.Header.Set("X-Refresh-Token", sa.Auth.RefreshToken)
|
||||
if sa.Account.EnterpriseID != "" {
|
||||
r.Header.Set("X-Enterprise-Id", sa.Account.EnterpriseID)
|
||||
@@ -777,7 +1011,7 @@ func refreshTokenPayload(sa *StoredAuth) (int, error) {
|
||||
r.Header.Set("X-Auth-Refresh-Source", "workbuddy")
|
||||
}
|
||||
|
||||
data, status, err := doJSON(cfg.HttpClient, http.MethodPost, endpointTokenRefresh, headers, nil)
|
||||
data, status, err := doJSON(cfg.HttpClient, http.MethodPost, prof.tokenRefreshURL(), headers, nil)
|
||||
if err != nil {
|
||||
return status, fmt.Errorf("上游刷新拒绝 (HTTP %d): %w", status, err)
|
||||
}
|
||||
@@ -809,6 +1043,8 @@ func formatAccountStatus(acc *Account, idx int, now time.Time) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("\n--- 账号 #%d ---\n", idx))
|
||||
sb.WriteString(fmt.Sprintf("凭据文件: %s\n", acc.Path))
|
||||
prof := acc.Profile()
|
||||
sb.WriteString(fmt.Sprintf("站点: %s (%s)\n", prof.Label, strings.TrimPrefix(prof.Base, "https://")))
|
||||
|
||||
if acc.Disabled {
|
||||
// 失效账号(Auth 可能为 nil:凭据文件已删除,信息来自失效标记)
|
||||
@@ -911,6 +1147,7 @@ func runRefresh() {
|
||||
// accountSnapshot 是写入状态快照文件的单个账号状态。
|
||||
type accountSnapshot struct {
|
||||
Path string `json:"path"`
|
||||
Edition string `json:"edition,omitempty"` // 站点标识(cn/intl)
|
||||
Nickname string `json:"nickname"`
|
||||
UID string `json:"uid"`
|
||||
State string `json:"state"` // active | cooldown | disabled
|
||||
@@ -933,7 +1170,7 @@ func writeStatusSnapshot() {
|
||||
snap := statusSnapshot{UpdatedAt: time.Now().Unix()}
|
||||
now := time.Now()
|
||||
for _, acc := range accounts {
|
||||
as := accountSnapshot{Path: acc.Path}
|
||||
as := accountSnapshot{Path: acc.Path, Edition: acc.Profile().Key}
|
||||
switch {
|
||||
case acc.Disabled:
|
||||
as.State = "disabled"
|
||||
@@ -1114,7 +1351,7 @@ func runMonitor() {
|
||||
fmt.Printf("📊 账号池: 共 %d 个 | ✅ 可用 %d | 🔒 冷却 %d | ❌ 失效 %d\n",
|
||||
len(snap.Accounts), active, cooldown, disabled)
|
||||
for i, a := range snap.Accounts {
|
||||
fmt.Printf(" #%d %s (%s)\n", i+1, a.Path, ifEmpty(a.Nickname, "?"))
|
||||
fmt.Printf(" #%d %s (%s · %s)\n", i+1, a.Path, ifEmpty(a.Nickname, "?"), profileForEdition(a.Edition).Label)
|
||||
switch a.State {
|
||||
case "active":
|
||||
fmt.Printf(" ✅ 可用 | Token 有效期至: %s\n",
|
||||
@@ -1159,7 +1396,16 @@ func runMonitor() {
|
||||
}
|
||||
|
||||
func runLogin() {
|
||||
fmt.Println("================ WorkBuddy 扫码登录 ================")
|
||||
prof := &profileCN
|
||||
if cfg.LoginIntl {
|
||||
prof = &profileINTL
|
||||
}
|
||||
|
||||
fmt.Println("================ WorkBuddy 登录 ================")
|
||||
fmt.Printf("目标站点: %s (%s)\n", prof.Label, strings.TrimPrefix(prof.Base, "https://"))
|
||||
if cfg.LoginIntl {
|
||||
fmt.Println("国际站登录将在浏览器中完成(邮箱 / 验证码 / SSO 等),凭据由网关自动接管。")
|
||||
}
|
||||
fmt.Println("正在生成登录凭据与二维码...")
|
||||
|
||||
loginClient := &http.Client{
|
||||
@@ -1167,7 +1413,13 @@ func runLogin() {
|
||||
Jar: cfg.HttpClient.Jar,
|
||||
}
|
||||
|
||||
data, _, err := doJSON(loginClient, http.MethodPost, endpointAuthState, nil, bytes.NewReader([]byte("{}")))
|
||||
// 轮询 auth/token 时与官方客户端一致,显式声明无 Authorization
|
||||
pollHeaders := func(r *http.Request) {
|
||||
commonHeaders(r, prof)
|
||||
r.Header.Set("X-No-Authorization", "1")
|
||||
}
|
||||
|
||||
data, _, err := doJSON(loginClient, http.MethodPost, prof.authStateURL(), nil, bytes.NewReader([]byte("{}")))
|
||||
if err != nil {
|
||||
fmt.Printf("获取登录状态失败: %v\n", err)
|
||||
return
|
||||
@@ -1182,15 +1434,19 @@ func runLogin() {
|
||||
// 终端字符二维码
|
||||
qr, err := qrcode.New(st.AuthURL, qrcode.Medium)
|
||||
if err == nil {
|
||||
fmt.Println("\n请使用 微信 或 企业微信 扫描下方二维码登录:")
|
||||
if cfg.LoginIntl {
|
||||
fmt.Println("\n请用手机扫描下方二维码,并在浏览器中完成登录(邮箱 / 验证码 / SSO 等):")
|
||||
} else {
|
||||
fmt.Println("\n请使用 微信 或 企业微信 扫描下方二维码登录:")
|
||||
}
|
||||
fmt.Println(qr.ToSmallString(false))
|
||||
}
|
||||
|
||||
fmt.Println("如无法扫码,也可在浏览器中直接打开以下链接:")
|
||||
fmt.Printf("👉 %s\n\n", st.AuthURL)
|
||||
fmt.Println("等待扫码授权中 (按 Ctrl+C 可取消)...")
|
||||
fmt.Println("等待登录授权完成 (按 Ctrl+C 可取消)...")
|
||||
|
||||
deadline := time.Now().Add(loginTTL)
|
||||
deadline := time.Now().Add(prof.LoginTTL)
|
||||
ticker := time.NewTicker(2 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
@@ -1201,7 +1457,7 @@ func runLogin() {
|
||||
fmt.Println("\n登录已超时,请重新执行 login 命令。")
|
||||
return
|
||||
}
|
||||
tokRaw, _, errTok := doJSON(loginClient, http.MethodGet, endpointAuthToken+st.State, nil, nil)
|
||||
tokRaw, _, errTok := doJSON(loginClient, http.MethodGet, prof.authTokenURL(st.State), pollHeaders, nil)
|
||||
if errTok != nil {
|
||||
continue
|
||||
}
|
||||
@@ -1213,14 +1469,15 @@ func runLogin() {
|
||||
// 登录成功,拉取账号信息
|
||||
var acct accountData
|
||||
acctHeaders := func(r *http.Request) {
|
||||
commonHeaders(r)
|
||||
commonHeaders(r, prof)
|
||||
r.Header.Set("Authorization", "Bearer "+tok.AccessToken)
|
||||
}
|
||||
if acctRaw, _, errAcct := doJSON(loginClient, http.MethodGet, endpointLoginAcct+st.State, acctHeaders, nil); errAcct == nil {
|
||||
if acctRaw, _, errAcct := doJSON(loginClient, http.MethodGet, prof.loginAcctURL(st.State), acctHeaders, nil); errAcct == nil {
|
||||
_ = json.Unmarshal(acctRaw, &acct)
|
||||
}
|
||||
|
||||
sa := &StoredAuth{
|
||||
Edition: prof.Key,
|
||||
Auth: StoredTokens{
|
||||
AccessToken: tok.AccessToken,
|
||||
RefreshToken: tok.RefreshToken,
|
||||
@@ -1240,6 +1497,7 @@ func runLogin() {
|
||||
}
|
||||
|
||||
fmt.Println("\n🎉 登录成功!")
|
||||
fmt.Printf("站点: %s (%s)\n", prof.Label, strings.TrimPrefix(prof.Base, "https://"))
|
||||
fmt.Printf("欢迎,%s (UID: %s)\n", sa.Account.Nickname, sa.Account.UID)
|
||||
fmt.Printf("凭据已成功保存至: %s\n", cfg.AuthFile)
|
||||
fmt.Printf("令牌有效期至: %s\n", time.Unix(sa.Auth.ExpiresAt, 0).Format("2006-01-02 15:04:05"))
|
||||
@@ -1274,6 +1532,11 @@ func runServe() {
|
||||
// 启动状态快照协程(monitor 命令实时读取展示)
|
||||
go statusSnapshotLoop()
|
||||
|
||||
// 启动凭据热加载协程(新增/更新/删除凭据文件免重启生效)
|
||||
if cfg.ReloadInterval > 0 {
|
||||
go accountReloaderLoop()
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/v1/chat/completions", handleChatCompletions)
|
||||
mux.HandleFunc("/chat/completions", handleChatCompletions)
|
||||
@@ -1297,6 +1560,11 @@ func runServe() {
|
||||
fmt.Printf(" Chat 接口地址: http://%s/v1/chat/completions\n", listenAddr)
|
||||
fmt.Printf(" Models 接口: http://%s/v1/models\n", listenAddr)
|
||||
fmt.Printf(" 模型转发策略: 【完全透传】客户端请求的任意 model 原样中继至上游\n")
|
||||
if cfg.ReloadInterval > 0 {
|
||||
fmt.Printf(" 凭据热加载: 每 %ds 自动扫描,新增/更新/删除凭据免重启生效\n", cfg.ReloadInterval)
|
||||
} else {
|
||||
fmt.Printf(" 凭据热加载: 已关闭 (-reload-interval 0)\n")
|
||||
}
|
||||
if cfg.APIKey != "" {
|
||||
fmt.Printf(" API 鉴权: 已启用 (Bearer %s)\n", cfg.APIKey)
|
||||
} else {
|
||||
@@ -1501,20 +1769,23 @@ func handleChatCompletions(w http.ResponseWriter, r *http.Request) {
|
||||
continue
|
||||
}
|
||||
|
||||
upstreamReq, err := http.NewRequestWithContext(r.Context(), http.MethodPost, endpointChat, bytes.NewReader(upstreamBytes))
|
||||
// 按账号所属站点(国内站/国际站)路由上游与指纹 Header
|
||||
prof := acc.Profile()
|
||||
|
||||
upstreamReq, err := http.NewRequestWithContext(r.Context(), http.MethodPost, prof.chatURL(), bytes.NewReader(upstreamBytes))
|
||||
if err != nil {
|
||||
writeOpenAIError(w, http.StatusInternalServerError, "req_create_error", err.Error())
|
||||
return
|
||||
}
|
||||
// 注入 CodeBuddy 凭据与指纹 Header
|
||||
backendHeaders(upstreamReq, acc.Auth)
|
||||
backendHeaders(upstreamReq, acc.Auth, prof)
|
||||
|
||||
// 限制同一账号向腾讯上游的请求严格单并发串行排队,防止 DSH 同时发起标题生成+主对话触发腾讯 11128 风控
|
||||
acc.lock.Lock()
|
||||
resp, err := cfg.HttpClient.Do(upstreamReq)
|
||||
acc.lock.Unlock()
|
||||
if err != nil {
|
||||
log.Printf("[#%d] 账号 %s 上游请求失败: %v", reqID, acc.Path, err)
|
||||
log.Printf("[#%d] 账号 %s [%s] 上游请求失败: %v", reqID, acc.Path, prof.Label, err)
|
||||
writeOpenAIError(w, http.StatusBadGateway, "upstream_network_error", fmt.Sprintf("网络转发失败: %v", err))
|
||||
return
|
||||
}
|
||||
@@ -1524,7 +1795,7 @@ func handleChatCompletions(w http.ResponseWriter, r *http.Request) {
|
||||
errBody, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
errStr := string(errBody)
|
||||
log.Printf("[#%d] 账号 %s 上游返回 HTTP %d: %s (耗时 %v)", reqID, acc.Path, resp.StatusCode, errStr, time.Since(startTime))
|
||||
log.Printf("[#%d] 账号 %s [%s] 上游返回 HTTP %d: %s (耗时 %v)", reqID, acc.Path, prof.Label, resp.StatusCode, errStr, time.Since(startTime))
|
||||
|
||||
if isRateLimited(resp.StatusCode, errStr) {
|
||||
// 429 频率限制:解析重置时间并屏蔽该账号,交由其他账号代偿
|
||||
@@ -1584,7 +1855,7 @@ func handleChatCompletions(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
resp.Body.Close()
|
||||
log.Printf("[#%d] 流式输出完成 (账号 %s, 耗时 %v)", reqID, acc.Path, time.Since(startTime))
|
||||
log.Printf("[#%d] 流式输出完成 (账号 %s [%s], 耗时 %v)", reqID, acc.Path, prof.Label, time.Since(startTime))
|
||||
} else {
|
||||
// 客户端需要完整 JSON 响应,聚合 SSE 数据流
|
||||
completionJSON, err := aggregateCompletion(resp.Body, modelName)
|
||||
@@ -1597,7 +1868,7 @@ func handleChatCompletions(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(completionJSON)
|
||||
log.Printf("[#%d] 非流式响应完成 (账号 %s, 耗时 %v)", reqID, acc.Path, time.Since(startTime))
|
||||
log.Printf("[#%d] 非流式响应完成 (账号 %s [%s], 耗时 %v)", reqID, acc.Path, prof.Label, time.Since(startTime))
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -1704,14 +1975,14 @@ func sanitizeBlockedTemplates(s string) string {
|
||||
return s
|
||||
}
|
||||
|
||||
// backendHeaders 设置 CodeBuddy 上游专用指纹与鉴权 Header
|
||||
func backendHeaders(req *http.Request, sa *StoredAuth) {
|
||||
commonHeaders(req)
|
||||
// backendHeaders 设置 CodeBuddy 上游专用指纹与鉴权 Header(按站点 Profile 生成)
|
||||
func backendHeaders(req *http.Request, sa *StoredAuth, prof *upstreamProfile) {
|
||||
commonHeaders(req, prof)
|
||||
reqID := uuid.New().String()
|
||||
req.Header.Set("X-Request-ID", reqID)
|
||||
req.Header.Set("X-Trace-ID", reqID)
|
||||
req.Header.Set("X-Client-ID", "codebuddy-cli")
|
||||
req.Header.Set("X-Client-Version", "2.143.1")
|
||||
req.Header.Set("X-Client-ID", prof.ClientID)
|
||||
req.Header.Set("X-Client-Version", prof.ClientVer)
|
||||
|
||||
if sa != nil && sa.Auth.AccessToken != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+sa.Auth.AccessToken)
|
||||
@@ -1736,16 +2007,17 @@ func backendHeaders(req *http.Request, sa *StoredAuth) {
|
||||
} else {
|
||||
req.Header.Set("X-No-Department-Info", "1")
|
||||
}
|
||||
req.Header.Set("X-Product", "SaaS")
|
||||
req.Header.Set("X-Product", prof.Product)
|
||||
}
|
||||
|
||||
func commonHeaders(req *http.Request) {
|
||||
// commonHeaders 按站点 Profile 设置通用伪装 Header(Origin/Referer/UA 因站点而异)。
|
||||
func commonHeaders(req *http.Request, prof *upstreamProfile) {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json, text/plain, */*")
|
||||
req.Header.Set("X-Requested-With", "XMLHttpRequest")
|
||||
req.Header.Set("Origin", originReferer)
|
||||
req.Header.Set("Referer", originReferer+"/")
|
||||
req.Header.Set("User-Agent", clientUA)
|
||||
req.Header.Set("Origin", prof.Origin)
|
||||
req.Header.Set("Referer", prof.Origin+"/")
|
||||
req.Header.Set("User-Agent", prof.ClientUA)
|
||||
}
|
||||
|
||||
func doJSON(client *http.Client, method, fullURL string, headers func(*http.Request), body io.Reader) (json.RawMessage, int, error) {
|
||||
@@ -1756,7 +2028,7 @@ func doJSON(client *http.Client, method, fullURL string, headers func(*http.Requ
|
||||
if headers != nil {
|
||||
headers(req)
|
||||
} else {
|
||||
commonHeaders(req)
|
||||
commonHeaders(req, &profileCN) // 兜底默认(当前所有调用方均显式传入站点 Profile)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
|
||||
+183
-3
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -28,7 +29,99 @@ func TestParseResetTimeInvalid(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 验证限流识别(429 状态码 / code 6004 / 频率限制关键词)
|
||||
// 验证国际站英文限流消息的重置时间解析(通用兜底正则)
|
||||
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
|
||||
@@ -39,6 +132,10 @@ func TestIsRateLimited(t *testing.T) {
|
||||
{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},
|
||||
}
|
||||
@@ -290,8 +387,8 @@ func TestCollectConfiguredAuthPathsAutoDiscover(t *testing.T) {
|
||||
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"} {
|
||||
// 模拟目录内多个凭据文件 + 非凭据文件 + 失效标记 + 运行时状态快照(应被排除)
|
||||
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)
|
||||
}
|
||||
@@ -355,6 +452,89 @@ func TestCollectConfiguredAuthPathsAutoDiscoverFallback(t *testing.T) {
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user