From 5ac6ee79757c52da4ee9fc4c277273487773522b Mon Sep 17 00:00:00 2001 From: CangShui Date: Sat, 5 Sep 2026 05:30:37 +0800 Subject: [PATCH] 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 --- .gitignore | 11 +- README.md | 63 +++++++- build.ps1 | 142 ++++++++++++++++++ main.go | 402 ++++++++++++++++++++++++++++++++++++++++++--------- main_test.go | 186 +++++++++++++++++++++++- 5 files changed, 722 insertions(+), 82 deletions(-) create mode 100644 build.ps1 diff --git a/.gitignore b/.gitignore index b80ea6d..4091ed4 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md index 3250a92..6678c77 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,23 @@ image -基于腾讯 **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 · -port · -auth · -auth-dir · -api-key · -proxy · -verbose +选项: -addr · -port · -auth · -auth-dir · -intl · -reload-interval · -api-key · -proxy · -verbose + (-intl 仅 login 生效:登录国际站 www.workbuddy.ai;-reload-interval 默认 5,0 关闭热加载) monitor: -interval · -journal · -logfile · -lines ``` @@ -379,4 +428,4 @@ CGO_ENABLED=0 go build -ldflags="-s -w" -o workbuddy-gateway . ## 免责声明 -本项目仅用于个人学习与技术研究。腾讯 CodeBuddy 的接口协议与风控策略可能随时变化;请遵守腾讯服务条款,自行承担使用风险。本仓库不包含任何官方未公开的密钥或凭据。 +本项目仅用于个人学习与技术研究。腾讯 CodeBuddy(含国内站与国际站 workbuddy.ai)的接口协议与风控策略可能随时变化;请遵守腾讯服务条款,自行承担使用风险。本仓库不包含任何官方未公开的密钥或凭据。 diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..0e35189 --- /dev/null +++ b/build.ps1 @@ -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 diff --git a/main.go b/main.go index 25e40b2..4bd7c1a 100644 --- a/main.go +++ b/main.go @@ -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 凭据文件路径;支持逗号分隔多个文件实现多账号 (默认: 自动发现当前目录下所有 workbuddy*.json) -auth-dir 凭据目录:自动加载目录下所有 workbuddy*.json 作为账号池 + -intl login 专用:登录国际站 www.workbuddy.ai(浏览器内完成登录) + -reload-interval + 账号池热加载扫描间隔(默认 5 秒,0 关闭):运行期自动发现 + 新增/更新/删除的凭据文件,免重启生效 -api-key 设置后,调用网关必须携带 Bearer 鉴权 -proxy 设置上游转发代理 (例如 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 { diff --git a/main_test.go b/main_test.go index b98a61c..d30ac49 100644 --- a/main_test.go +++ b/main_test.go @@ -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()