4 Commits
Author SHA1 Message Date
CangShui 86b70baf29 feat: print loaded credential file list at end of startup banner
- After the per-account status block, print a summary line listing all
  loaded credential files (e.g. '已加载凭据文件 (2): workbuddy.json,
  workbuddy2.json') so the count is visible even when journalctl/
  systemctl truncates the earlier log lines
2026-09-04 08:48:21 +08:00
CangShui 0484db4b3a feat: show all accounts with full status in serve startup banner
- Startup banner now prints every account's complete status (nickname,
  UID, enterprise, domain, cooldown state, token validity, expiry)
  instead of a single-line summary
- Extracted shared formatAccountStatus() used by both status command
  and serve startup output, keeping them consistent
- Shows disabled accounts with reason and re-login hint at startup
2026-09-04 08:42:03 +08:00
CangShui 9fcd99f152 feat: auto-discover all workbuddy*.json in working directory
- When neither -auth nor -auth-dir is specified, scan the working
  directory for all workbuddy*.json files and build the account pool
  from them (multi-account without any flags)
- Example: /opt/workbuddy-gateway with workbuddy.json + workbuddy2.json
  is automatically loaded as a 2-account pool under systemd
- loadDisabledMarkers also auto-discovers *.json.disabled markers
- Fallback to default workbuddy.json when no credential files found
- Unit tests for auto-discovery and fallback behavior
2026-09-04 08:13:38 +08:00
CangShui 818d0c223e feat: auto-disable accounts on expired/revoked auth
- Detect auth failure (HTTP 401/403, invalid token, login expired)
  during token refresh and upstream requests
- Disable the account from round-robin scheduling immediately,
  delete its credential file, and write a persistent .disabled marker
- status/runServe console shows disabled accounts with reason and
  re-login command; re-login clears the marker and restores scheduling
- Background refresher skips disabled accounts; refresh command reports
  skipped count
- Unit tests for auth-failure detection, disabled skip, marker
  persistence/restore/clear
2026-09-04 04:12:11 +08:00
4 changed files with 706 additions and 102 deletions
+2
View File
@@ -5,6 +5,8 @@ dist/workbuddy.json
dist/*.json
last-*.json
*.json.bak
*.json.disabled
.workbuddy-*.json
# 本地编译产物
workbuddy-gateway.exe
+53 -4
View File
@@ -9,6 +9,7 @@
- **纯 CLI 控制**:终端内嵌 ASCII 二维码,微信 / 企业微信扫码登录;`status` / `refresh` / `serve` 子命令完成全部管理。
- **多账号池 + 轮询负载均衡**:支持同时挂载多个 CodeBuddy 账号(`-auth` 逗号分隔或 `-auth-dir` 目录),请求按轮询(round-robin)均匀分配到各账号,保持多账号额度使用一致。
- **429 频率限制自动冷却**:任一账号触发上游频率限制(HTTP 429 / code 6004)时,自动解析消息中的重置时间(如 `将在 2026-09-04 07:48:15 UTC+8 重置`),将该账号屏蔽至重置时间;冷却期间自动改用其他账号代偿,冷却到期自动恢复。
- **授权失效自动禁用**:账号授权过期、被撤销或令牌刷新失败(HTTP 401/403 / invalid token / 登录已过期)时,自动将该账号**禁止调度并删除凭据文件**,同时写入持久化失效标记;`status` / 启动日志会明确提示该账号失效原因与重新登录命令,重新 `login` 后自动恢复调度。
- **后台自动续期**:运行期间每 5 分钟检查所有账号 Token,距过期不足 15 分钟自动刷新并持久化回各自凭据文件。
- **OpenAI 兼容协议**:`POST /v1/chat/completions`(SSE 流式 + 非流式聚合)、`GET /v1/models`、`GET /health`。
- **深度思考透传规则**:仅当客户端显式请求 `reasoning_effort` 时转发;绝不强制注入,避免触发上游内容安全策略。
@@ -60,13 +61,19 @@ workbuddy-gateway serve -api-key sk-localsecret
### 配置多个账号
网关支持同时挂载多个 CodeBuddy 账号,请求按**轮询(round-robin)**方式均匀分发,保持各账号额度消耗一致。两种配置方式:
网关支持同时挂载多个 CodeBuddy 账号,请求按**轮询(round-robin)**方式均匀分发,保持各账号额度消耗一致。三种配置方式:
```bash
# 方式一:-auth 逗号分隔多个凭据文件
# 方式一(推荐):自动发现 — 把多个凭据文件放进工作目录即可,无需任何参数
# 网关启动时自动加载当前目录下所有 workbuddy*.json(如 workbuddy.json、workbuddy2.json…)
cd /opt/workbuddy-gateway
workbuddy-gateway serve -addr 0.0.0.0 -port 8317
# 启动日志会显示: 已就绪账号池: 2 个账号 (有效 2, 失效 0)
# 方式二:-auth 逗号分隔多个凭据文件
workbuddy-gateway serve -auth workbuddy.json,workbuddy-2.json,workbuddy-3.json
# 方式二:-auth-dir 指定凭据目录(自动加载目录下所有 workbuddy*.json)
# 方式三:-auth-dir 指定凭据目录(自动加载目录下所有 workbuddy*.json)
mkdir -p auths
workbuddy-gateway login -auth auths/workbuddy-1.json # 依次为每个账号扫码登录
workbuddy-gateway login -auth auths/workbuddy-2.json
@@ -91,9 +98,41 @@ workbuddy-gateway status
workbuddy-gateway refresh
```
### 授权失效自动禁用
当账号出现以下任一情况时,网关会**自动禁用该账号调度并删除其凭据文件**:
- 令牌刷新失败且返回授权类错误(HTTP 401/403、`invalid token`、`unauthorized`、`登录已过期` 等);
- 上游请求返回 401/403(token 被撤销或已过期);
- 凭据缺少 RefreshToken 且已无法刷新。
处理流程:
1. 将该账号标记为 **❌ 授权失效**,立即移出轮询调度;
2. **删除对应的凭据文件**(如 `workbuddy.json`),并写入持久化失效标记(`workbuddy.json.disabled`);
3. 控制台(`status` / 启动日志 / 运行日志)明确显示失效原因,并给出重新登录命令;
4. 失效期间其余账号正常代偿;**重新执行 login 后自动清除失效标记并恢复调度**。
```bash
# 查看失效账号与原因
workbuddy-gateway status
# 输出示例:
# --- 账号 #1 ---
# 凭据文件: workbuddy-2.json
# 用户昵称: tester
# 用户 UID: uid-xxx
# 账号状态: ❌ 授权失效(禁止调度)
# 失效原因: 令牌刷新失败 (HTTP 401): invalid token
# 处理建议: ⚠️ 凭据文件已删除,请重新执行: workbuddy-gateway login -auth workbuddy-2.json
# 失效账号重新登录后自动恢复
workbuddy-gateway login -auth workbuddy-2.json
workbuddy-gateway status # 该账号恢复为 ✅ 可用
```
### 与单账号模式的兼容性
- 不传 `-auth` 时默认使用 `./workbuddy.json`,行为与旧版完全一致;
- 不传 `-auth` / `-auth-dir` 时,自动发现当前工作目录下所有 `workbuddy*.json`;目录中只有一个凭据文件时行为与旧版完全一致;
- 只有一个账号时,请求始终使用该账号,429 冷却逻辑同样生效(冷却期间请求将返回 429 提示);
- 账号之间使用独立的串行锁:同一账号请求严格排队,不同账号可并行,兼顾风控与吞吐。
@@ -197,8 +236,18 @@ sudo mkdir -p /opt/workbuddy-gateway
sudo cp workbuddy-gateway /opt/workbuddy-gateway/ # 对应架构的二进制
# 首次登录(会生成 workbuddy.json)
sudo /opt/workbuddy-gateway/workbuddy-gateway login
# 登录第二个账号(可选):生成 workbuddy2.json
sudo /opt/workbuddy-gateway/workbuddy-gateway login -auth /opt/workbuddy-gateway/workbuddy2.json
```
> 💡 **多账号自动发现**:服务通过 `WorkingDirectory` 固定在 `/opt/workbuddy-gateway`,
> 无需修改 ExecStart——把多个凭据文件(`workbuddy.json`、`workbuddy2.json`…)放进该目录,
> 重启服务即自动加载全部账号组成轮询池。确认方式:
> ```bash
> sudo journalctl -u workbuddy-gateway | grep 账号池
> # 输出示例: 已就绪账号池: 2 个账号 (有效 2, 失效 0)
> ```
启用并启动服务:
```bash
+387 -72
View File
@@ -29,7 +29,7 @@ import (
)
const (
version = "1.2.0"
version = "1.4.2"
upstreamBase = "https://copilot.tencent.com"
clientUA = "CLI/2.143.1 CodeBuddy/2.143.1"
originReferer = "https://www.codebuddy.cn"
@@ -99,6 +99,7 @@ type Config struct {
Port int
AuthFile string
AuthDir string
AuthExplicit bool // 用户是否显式指定了 -auth(未指定时自动扫描目录下所有 workbuddy*.json)
APIKey string
ProxyURL string
Verbose bool
@@ -108,12 +109,30 @@ type Config struct {
// Account 表示一个 CodeBuddy 账号凭据及其运行时状态。
type Account struct {
Path string // 凭据文件路径
Auth *StoredAuth // 凭据内容
Auth *StoredAuth // 凭据内容(失效标记恢复时可能为 nil)
CooldownUntil time.Time // 冷却截止时间(429 频率限制后自动屏蔽到该时间)
CooldownMsg string // 触发冷却的原因/上游提示
Disabled bool // 授权失效(token 过期且无法刷新/已撤销):禁止调度
DisabledReason string // 失效原因,用于控制台展示
Nickname string // 失效标记持久化字段:昵称(Auth 为 nil 时使用)
UID string // 失效标记持久化字段:UID
lock sync.Mutex // 单账号串行锁(防止同账号并发触发 11128)
}
// disabledMarker 是授权失效账号的持久化标记(凭据文件删除后用于控制台提示重新登录)。
type disabledMarker struct {
Path string `json:"path"`
Reason string `json:"reason"`
DisabledAt int64 `json:"disabledAt"`
Nickname string `json:"nickname,omitempty"`
UID string `json:"uid,omitempty"`
}
// markerPath 返回与凭据文件同目录的失效标记文件路径。
func markerPath(authPath string) string {
return authPath + ".disabled"
}
var (
cfg Config
authLock sync.RWMutex
@@ -159,6 +178,15 @@ func main() {
fs.BoolVar(&cfg.Verbose, "verbose", false, "输出详细调试日志")
_ = fs.Parse(args)
// 检测 -auth 是否被显式指定:
// 若未指定 -auth 且未指定 -auth-dir,则自动扫描当前目录下所有 workbuddy*.json 组成账号池,
// 这样把多个凭据文件放进工作目录即可自动多账号,无需手写参数。
fs.Visit(func(f *flag.Flag) {
if f.Name == "auth" {
cfg.AuthExplicit = true
}
})
// 初始化 HTTP 客户端
initHTTPClient()
@@ -201,7 +229,7 @@ func printHelp() {
-addr <ip> 网关监听地址 (默认: 127.0.0.1)
-port <port> 网关监听端口 (默认: 8317)
-auth <path> 凭据文件路径;支持逗号分隔多个文件实现多账号
(默认: ./workbuddy.json)
(默认: 自动发现当前目录下所有 workbuddy*.json)
-auth-dir <dir> 凭据目录:自动加载目录下所有 workbuddy*.json 作为账号池
-api-key <key> 设置后,调用网关必须携带 Bearer <key> 鉴权
-proxy <url> 设置上游转发代理 (例如 http://127.0.0.1:7890 或 socks5://...)
@@ -209,10 +237,13 @@ func printHelp() {
多账号说明:
# 登录第二个账号(保存到不同文件)
workbuddy-gateway login -auth workbuddy-2.json
workbuddy-gateway login -auth workbuddy2.json
# 自动发现:把多个凭据文件放进工作目录即可自动多账号(无需任何参数)
workbuddy-gateway serve # 自动加载 ./workbuddy*.json
# 启动时指定多个凭据文件(轮询 + 429 自动冷却代偿)
workbuddy-gateway serve -auth workbuddy.json,workbuddy-2.json
workbuddy-gateway serve -auth workbuddy.json,workbuddy2.json
# 或使用目录模式:目录内所有 workbuddy*.json 自动组成账号池
workbuddy-gateway serve -auth-dir ./auths
@@ -293,10 +324,15 @@ func saveAuth(sa *StoredAuth) error {
if err != nil {
return err
}
return os.WriteFile(cfg.AuthFile, data, 0600)
if err := os.WriteFile(cfg.AuthFile, data, 0600); err != nil {
return err
}
clearDisabledMarker(cfg.AuthFile)
return nil
}
// saveAuthTo 将凭据写入指定路径(多账号模式使用)。
// 写入成功后清除该路径的失效标记(表示账号已重新登录)。
func saveAuthTo(path string, sa *StoredAuth) error {
dir := filepath.Dir(path)
if dir != "" && dir != "." {
@@ -306,7 +342,11 @@ func saveAuthTo(path string, sa *StoredAuth) error {
if err != nil {
return err
}
return os.WriteFile(path, data, 0600)
if err := os.WriteFile(path, data, 0600); err != nil {
return err
}
clearDisabledMarker(path)
return nil
}
// loadAccountFile 从指定路径读取并解析凭据(不修改全局缓存)。
@@ -325,22 +365,17 @@ func loadAccountFile(path string) (*StoredAuth, error) {
return &sa, nil
}
// loadAccounts 根据 -auth / -auth-dir 配置构建账号池。
// -auth 支持逗号分隔多个凭据文件;-auth-dir 自动加载目录下所有 workbuddy*.json。
// 单账号时池长度为 1,行为与旧版完全一致。
func loadAccounts() error {
accountMu.Lock()
defer accountMu.Unlock()
accounts = nil
rrIndex = 0
// collectConfiguredAuthPaths 解析应加载的凭据路径列表:
// 1) -auth-dir 指定目录 → 目录下所有 workbuddy*.json
// 2) 显式 -auth → 逗号分隔的凭据文件列表(保持用户顺序)
// 3) 均未指定(自动发现模式)→ 扫描当前目录下所有 workbuddy*.json,
// 这样把多个凭据文件放进工作目录即可自动组成多账号池;若一个都没有则回退默认 cfg.AuthFile
func collectConfiguredAuthPaths() []string {
var paths []string
if cfg.AuthDir != "" {
entries, err := os.ReadDir(cfg.AuthDir)
if err != nil {
return fmt.Errorf("读取凭据目录失败 (%s): %w", cfg.AuthDir, err)
}
if err == nil {
for _, e := range entries {
if e.IsDir() {
continue
@@ -351,16 +386,52 @@ func loadAccounts() error {
}
}
sort.Strings(paths)
} else {
}
return paths
}
if cfg.AuthExplicit {
for _, p := range strings.Split(cfg.AuthFile, ",") {
p = strings.TrimSpace(p)
if p != "" {
paths = append(paths, p)
}
}
return paths
}
for _, p := range paths {
// 自动发现模式:扫描当前工作目录(systemd WorkingDirectory 即凭据目录)
entries, err := os.ReadDir(".")
if err == nil {
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if strings.HasPrefix(name, "workbuddy") && strings.HasSuffix(name, ".json") {
paths = append(paths, name)
}
}
sort.Strings(paths)
}
if len(paths) == 0 {
// 无任何凭据文件时回退默认路径,便于给出"请先 login"的友好提示
paths = append(paths, cfg.AuthFile)
}
return paths
}
// loadAccounts 构建账号池。
// -auth 支持逗号分隔多个凭据文件;-auth-dir 自动加载目录下所有 workbuddy*.json;
// 两者都未指定时自动发现当前目录下所有 workbuddy*.json(多账号免参数)。
func loadAccounts() error {
accountMu.Lock()
defer accountMu.Unlock()
accounts = nil
rrIndex = 0
for _, p := range collectConfiguredAuthPaths() {
sa, err := loadAccountFile(p)
if err != nil {
log.Printf("[Auth] 跳过无效凭据文件 %s: %v", p, err)
@@ -369,14 +440,17 @@ func loadAccounts() error {
accounts = append(accounts, &Account{Path: p, Auth: sa})
}
// 恢复持久化的失效账号标记(凭据文件已删除,仅供控制台提示重新登录)
loadDisabledMarkers()
if len(accounts) == 0 {
return fmt.Errorf("未找到有效凭据,请先执行 login 命令扫码登录")
}
return nil
}
// nextAccount 轮询选择下一个未处于冷却状态的账号。
// 若全部账号冷却,返回错误并附带最早解封时间。
// nextAccount 轮询选择下一个未处于冷却状态且未被禁用的账号。
// 若全部账号不可用,返回错误并附带说明。
func nextAccount() (*Account, error) {
accountMu.Lock()
defer accountMu.Unlock()
@@ -385,15 +459,28 @@ func nextAccount() (*Account, error) {
return nil, fmt.Errorf("账号池为空")
}
now := time.Now()
disabledCount := 0
cooldownCount := 0
for i := 0; i < len(accounts); i++ {
idx := (rrIndex + i) % len(accounts)
acc := accounts[idx]
if acc.Disabled {
disabledCount++
continue
}
if acc.CooldownUntil.After(now) {
cooldownCount++
continue
}
rrIndex = (idx + 1) % len(accounts)
return acc, nil
}
// 全部不可用:优先报告失效账号
for _, a := range accounts {
if a.Disabled {
return nil, fmt.Errorf("账号 %s 已失效(授权过期或已撤销,凭据文件已删除),请重新执行 login 登录", a.Path)
}
}
// 全部冷却:返回最早解封时间
earliest := accounts[0].CooldownUntil
for _, a := range accounts {
@@ -414,6 +501,145 @@ func markCooldown(acc *Account, until time.Time, msg string) {
acc.Path, until.Format("2006-01-02 15:04:05"), msg)
}
// disableAccount 将账号标记为失效(授权过期/撤销),禁止调度并删除凭据文件,
// 同时写入持久化失效标记,便于控制台提示用户重新登录。
func disableAccount(acc *Account, reason string) {
accountMu.Lock()
acc.Disabled = true
acc.DisabledReason = reason
acc.CooldownUntil = time.Time{}
path := acc.Path
nickname := ""
uid := ""
if acc.Auth != nil {
nickname = acc.Auth.Account.Nickname
uid = acc.Auth.Account.UID
acc.Nickname = nickname
acc.UID = uid
}
accountMu.Unlock()
// 写持久化失效标记(凭据删除后仍能在 status 中提示重新登录)
marker := disabledMarker{
Path: path,
Reason: reason,
DisabledAt: time.Now().Unix(),
Nickname: nickname,
UID: uid,
}
if data, err := json.MarshalIndent(marker, "", " "); err == nil {
if werr := os.WriteFile(markerPath(path), data, 0600); werr != nil {
log.Printf("[Auth] 账号 %s 失效标记写入失败: %v", path, werr)
}
}
// 删除失效的凭据文件,方便用户下次重新登录
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
log.Printf("[Auth] 账号 %s 凭据文件删除失败: %v", path, err)
}
log.Printf("[Auth] ⚠️ 账号 %s 授权失效,已禁止调度并删除凭据文件: %s", path, reason)
}
// loadDisabledMarkers 扫描失效标记文件,将其恢复为账号池中的失效账号(Auth 为 nil)。
func loadDisabledMarkers() {
// 收集标记文件路径:
// - AuthDir 模式:目录下 *.json.disabled 即为标记文件
// - 显式 -auth 模式:<凭据路径>.disabled 为标记文件
// - 自动发现模式:扫描当前目录下所有 *.json.disabled 标记文件
var markerPaths []string
if cfg.AuthDir != "" {
entries, err := os.ReadDir(cfg.AuthDir)
if err != nil {
return
}
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if strings.HasSuffix(name, ".json.disabled") {
markerPaths = append(markerPaths, filepath.Join(cfg.AuthDir, name))
}
}
} else if cfg.AuthExplicit {
for _, p := range strings.Split(cfg.AuthFile, ",") {
p = strings.TrimSpace(p)
if p != "" {
markerPaths = append(markerPaths, markerPath(p))
}
}
} else {
// 自动发现模式:扫描当前工作目录
entries, err := os.ReadDir(".")
if err == nil {
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if strings.HasPrefix(name, "workbuddy") && strings.HasSuffix(name, ".json.disabled") {
markerPaths = append(markerPaths, name)
}
}
sort.Strings(markerPaths)
}
}
for _, mp := range markerPaths {
data, err := os.ReadFile(mp)
if err != nil {
continue
}
var m disabledMarker
if err := json.Unmarshal(data, &m); err != nil {
continue
}
// 避免与已加载的有效账号重复
dup := false
for _, a := range accounts {
if a.Path == m.Path {
dup = true
break
}
}
if dup {
continue
}
accounts = append(accounts, &Account{
Path: m.Path,
Disabled: true,
DisabledReason: m.Reason,
Nickname: m.Nickname,
UID: m.UID,
})
}
}
// clearDisabledMarker 在重新登录成功后清除失效标记。
func clearDisabledMarker(path string) {
mp := markerPath(path)
if err := os.Remove(mp); err != nil && !os.IsNotExist(err) {
log.Printf("[Auth] 失效标记清除失败 (%s): %v", mp, err)
}
}
// isAuthFailure 判断上游响应是否为授权失效(401/403 / invalid token / 登录过期等)。
func isAuthFailure(statusCode int, body string) bool {
if statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden {
return true
}
low := strings.ToLower(body)
if strings.Contains(low, "invalid token") ||
strings.Contains(low, "unauthorized") ||
strings.Contains(low, "登录已过期") ||
strings.Contains(low, "登录失效") ||
strings.Contains(low, "token 已失效") ||
strings.Contains(low, "authentication required") {
return true
}
return false
}
var resetTimeRe = regexp.MustCompile(`将在\s*(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2})\s*(UTC[+-]\d+(?::\d{2})?)?`)
// parseResetTime 从上游 429 错误消息中解析频率限制重置时间。
@@ -455,9 +681,10 @@ func isRateLimited(statusCode int, body string) bool {
}
// ensureValidTokenFor 对指定账号检查并刷新令牌(多账号版)。
// 失效账号(Disabled 或 Auth 为 nil)直接跳过,不参与调度。
func ensureValidTokenFor(acc *Account) error {
if acc == nil || acc.Auth == nil {
return fmt.Errorf("账号为空")
if acc == nil || acc.Disabled || acc.Auth == nil {
return nil
}
if time.Now().Unix() > acc.Auth.Auth.ExpiresAt-900 {
log.Printf("[Auth] 账号 %s 访问令牌即将/已经过期,正在自动刷新...", acc.Path)
@@ -483,7 +710,7 @@ func doRefreshToken(sa *StoredAuth) error {
if sa == nil || sa.Auth.RefreshToken == "" {
return fmt.Errorf("无法刷新:缺少 RefreshToken")
}
if err := refreshTokenPayload(sa); err != nil {
if _, err := refreshTokenPayload(sa); err != nil {
return err
}
if err := saveAuth(sa); err != nil {
@@ -494,11 +721,16 @@ func doRefreshToken(sa *StoredAuth) error {
}
// doRefreshTokenFor 刷新指定账号的令牌并保存回其凭据文件(多账号版)。
// 若刷新因授权失效失败(401/403/refresh token 无效),自动禁用该账号并删除凭据文件。
func doRefreshTokenFor(acc *Account) error {
if acc == nil || acc.Auth == nil || acc.Auth.Auth.RefreshToken == "" {
return fmt.Errorf("无法刷新:账号缺少 RefreshToken")
if acc == nil || acc.Disabled || acc.Auth == nil || acc.Auth.Auth.RefreshToken == "" {
return fmt.Errorf("无法刷新:账号缺少 RefreshToken 或已失效")
}
status, err := refreshTokenPayload(acc.Auth)
if err != nil {
if isAuthFailure(status, err.Error()) {
disableAccount(acc, fmt.Sprintf("令牌刷新失败 (HTTP %d): %v", status, err))
}
if err := refreshTokenPayload(acc.Auth); err != nil {
return err
}
if err := saveAuthTo(acc.Path, acc.Auth); err != nil {
@@ -509,7 +741,8 @@ func doRefreshTokenFor(acc *Account) error {
}
// refreshTokenPayload 调用上游刷新接口并更新内存中的令牌字段(不落盘)。
func refreshTokenPayload(sa *StoredAuth) error {
// 返回上游 HTTP 状态码(成功或失败时均为实际状态;网络错误为 0)。
func refreshTokenPayload(sa *StoredAuth) (int, error) {
headers := func(r *http.Request) {
commonHeaders(r)
r.Header.Set("X-Refresh-Token", sa.Auth.RefreshToken)
@@ -521,11 +754,11 @@ func refreshTokenPayload(sa *StoredAuth) error {
data, status, err := doJSON(cfg.HttpClient, http.MethodPost, endpointTokenRefresh, headers, nil)
if err != nil {
return fmt.Errorf("上游刷新拒绝 (HTTP %d): %w", status, err)
return status, fmt.Errorf("上游刷新拒绝 (HTTP %d): %w", status, err)
}
var tok tokenData
if err := json.Unmarshal(data, &tok); err != nil || tok.AccessToken == "" {
return fmt.Errorf("解析新 Token 失败: %w", err)
return status, fmt.Errorf("解析新 Token 失败: %w", err)
}
sa.Auth.AccessToken = tok.AccessToken
@@ -538,13 +771,64 @@ func refreshTokenPayload(sa *StoredAuth) error {
if tok.ExpiresIn > 0 {
sa.Auth.ExpiresAt = time.Now().Add(time.Duration(tok.ExpiresIn) * time.Second).Unix()
}
return nil
return status, nil
}
// -----------------------------------------------------------------------------
// CLI 子命令实现: login, status, refresh
// -----------------------------------------------------------------------------
// formatAccountStatus 生成单个账号的完整状态文本(status 命令与 serve 启动横幅共用)。
// idx 从 1 开始的账号序号;now 为当前时间(用于冷却/过期判定)。
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))
if acc.Disabled {
// 失效账号(Auth 可能为 nil:凭据文件已删除,信息来自失效标记)
nickname := acc.Nickname
uid := acc.UID
if acc.Auth != nil {
nickname = acc.Auth.Account.Nickname
uid = acc.Auth.Account.UID
}
sb.WriteString(fmt.Sprintf("用户昵称: %s\n", ifEmpty(nickname, "(未知)")))
sb.WriteString(fmt.Sprintf("用户 UID: %s\n", ifEmpty(uid, "(未知)")))
sb.WriteString("账号状态: ❌ 授权失效(禁止调度)\n")
sb.WriteString(fmt.Sprintf("失效原因: %s\n", truncate(acc.DisabledReason, 200)))
sb.WriteString(fmt.Sprintf("处理建议: ⚠️ 凭据文件已删除,请重新执行: workbuddy-gateway login -auth %s\n", acc.Path))
return sb.String()
}
if acc.Auth == nil {
return sb.String()
}
expTime := time.Unix(acc.Auth.Auth.ExpiresAt, 0)
remaining := time.Until(expTime)
statusStr := "有效"
if remaining <= 0 {
statusStr = "已过期"
}
sb.WriteString(fmt.Sprintf("用户昵称: %s\n", acc.Auth.Account.Nickname))
sb.WriteString(fmt.Sprintf("用户 UID: %s\n", acc.Auth.Account.UID))
sb.WriteString(fmt.Sprintf("企业 ID: %s\n", ifEmpty(acc.Auth.Account.EnterpriseID, "(个人账号)")))
sb.WriteString(fmt.Sprintf("认证域名: %s\n", ifEmpty(acc.Auth.Auth.Domain, "www.codebuddy.cn")))
if acc.CooldownUntil.After(now) {
sb.WriteString(fmt.Sprintf("冷却状态: 🔒 冷却中 (解封: %s, 剩余 %v)\n",
acc.CooldownUntil.Format("2006-01-02 15:04:05"),
time.Until(acc.CooldownUntil).Round(time.Minute)))
sb.WriteString(fmt.Sprintf("冷却原因: %s\n", truncate(acc.CooldownMsg, 120)))
} else {
sb.WriteString("冷却状态: ✅ 可用\n")
}
sb.WriteString(fmt.Sprintf("Token 状态: %s\n", statusStr))
sb.WriteString(fmt.Sprintf("过期时间: %s (剩余 %v)\n", expTime.Format("2006-01-02 15:04:05"), remaining.Round(time.Minute)))
return sb.String()
}
func runStatus() {
if err := loadAccounts(); err != nil {
fmt.Printf("未找到有效凭据: %v\n请先执行: workbuddy-gateway login 扫码登录。\n", err)
@@ -558,32 +842,7 @@ func runStatus() {
fmt.Printf("账号总数: %d\n", len(accounts))
now := time.Now()
for i, acc := range accounts {
if acc.Auth == nil {
continue
}
expTime := time.Unix(acc.Auth.Auth.ExpiresAt, 0)
remaining := time.Until(expTime)
statusStr := "有效"
if remaining <= 0 {
statusStr = "已过期"
}
fmt.Printf("\n--- 账号 #%d ---\n", i+1)
fmt.Printf("凭据文件: %s\n", acc.Path)
fmt.Printf("用户昵称: %s\n", acc.Auth.Account.Nickname)
fmt.Printf("用户 UID: %s\n", acc.Auth.Account.UID)
fmt.Printf("企业 ID: %s\n", ifEmpty(acc.Auth.Account.EnterpriseID, "(个人账号)"))
fmt.Printf("认证域名: %s\n", ifEmpty(acc.Auth.Auth.Domain, "www.codebuddy.cn"))
if acc.CooldownUntil.After(now) {
fmt.Printf("冷却状态: 🔒 冷却中 (解封: %s, 剩余 %v)\n",
acc.CooldownUntil.Format("2006-01-02 15:04:05"),
time.Until(acc.CooldownUntil).Round(time.Minute))
fmt.Printf("冷却原因: %s\n", truncate(acc.CooldownMsg, 120))
} else {
fmt.Printf("冷却状态: ✅ 可用\n")
}
fmt.Printf("Token 状态: %s\n", statusStr)
fmt.Printf("过期时间: %s (剩余 %v)\n", expTime.Format("2006-01-02 15:04:05"), remaining.Round(time.Minute))
fmt.Print(formatAccountStatus(acc, i+1, now))
}
fmt.Println("\n=======================================================")
}
@@ -601,8 +860,14 @@ func runRefresh() {
}
ok := 0
fail := 0
skipped := 0
for i, acc := range accounts {
fmt.Printf("正在刷新账号 #%d (%s)... ", i+1, acc.Path)
if acc.Disabled || acc.Auth == nil {
fmt.Printf("⏭️ 跳过(授权失效,请重新登录)\n")
skipped++
continue
}
if err := doRefreshTokenFor(acc); err != nil {
fmt.Printf("❌ %v\n", err)
fail++
@@ -611,7 +876,7 @@ func runRefresh() {
ok++
}
}
fmt.Printf("\n刷新完成: 成功 %d 个,失败 %d 个\n", ok, fail)
fmt.Printf("\n刷新完成: 成功 %d 个,失败 %d 个,跳过 %d 个(授权失效)\n", ok, fail, skipped)
}
func runLogin() {
@@ -715,17 +980,13 @@ func runServe() {
fmt.Printf("警告: 未检测到有效凭据 (%v)。\n请先执行: workbuddy-gateway login 扫码登录,或确保凭据文件存在。\n\n", err)
} else {
accountMu.Lock()
accCount := len(accounts)
for _, acc := range accounts {
if acc.Disabled || acc.Auth == nil {
continue
}
_ = ensureValidTokenFor(acc)
}
accountMu.Unlock()
fmt.Printf("已就绪账号池: %d 个账号\n", accCount)
for i, acc := range accounts {
fmt.Printf(" #%d %s (UID: %s, 有效期至: %s)\n",
i+1, acc.Auth.Account.Nickname, acc.Auth.Account.UID,
time.Unix(acc.Auth.Auth.ExpiresAt, 0).Format("2006-01-02 15:04:05"))
}
}
// 启动后台自动刷新协程
@@ -762,6 +1023,40 @@ func runServe() {
if cfg.ProxyURL != "" {
fmt.Printf(" 上游出口代理: %s\n", cfg.ProxyURL)
}
// 启动时展示所有账号状态(与 status 命令一致)
accountMu.Lock()
accCount := len(accounts)
activeCount := 0
disabledCount := 0
now := time.Now()
for _, acc := range accounts {
if acc.Disabled || acc.Auth == nil {
disabledCount++
} else {
activeCount++
}
}
fmt.Printf(" 账号池: %d 个账号 (有效 %d, 失效 %d)\n", accCount, activeCount, disabledCount)
if accCount > 0 {
fmt.Println(" ----------------------------------------------------------")
for i, acc := range accounts {
fmt.Print(formatAccountStatus(acc, i+1, now))
}
fmt.Println(" ----------------------------------------------------------")
}
// 末尾汇总:加载到的凭据文件清单(即使上方日志被截断也能确认)
fileList := make([]string, 0, len(accounts))
for _, acc := range accounts {
fileList = append(fileList, acc.Path)
}
if len(fileList) > 0 {
fmt.Printf(" ✅ 已加载凭据文件 (%d): %s\n", len(fileList), strings.Join(fileList, ", "))
} else {
fmt.Println(" ⚠️ 未加载到任何凭据文件,请先执行 login 命令扫码登录")
}
accountMu.Unlock()
fmt.Println("================================================================")
fmt.Println("等待客户端请求中 (按 Ctrl+C 安全停止)...")
@@ -898,19 +1193,31 @@ func handleChatCompletions(w http.ResponseWriter, r *http.Request) {
}
var lastRateErr string
var lastAuthErr string
for attempt := 0; attempt < poolSize; attempt++ {
acc, err := nextAccount()
if err != nil {
// 所有账号均处于冷却状态
msg := fmt.Sprintf("所有账号均处于冷却状态: %v", err)
// 所有账号均不可用(冷却或失效)
msg := fmt.Sprintf("无可用账号: %v", err)
if lastAuthErr != "" {
msg += " | 最近一次授权失效: " + truncate(lastAuthErr, 200)
}
if lastRateErr != "" {
msg += " | 最近一次限制: " + truncate(lastRateErr, 200)
msg += " | 最近一次频率限制: " + truncate(lastRateErr, 200)
}
log.Printf("[#%d] %s", reqID, msg)
writeOpenAIError(w, http.StatusTooManyRequests, "all_accounts_cooldown", msg)
writeOpenAIError(w, http.StatusServiceUnavailable, "no_available_account", msg)
return
}
_ = ensureValidTokenFor(acc)
// 令牌刷新时若发现授权失效会禁用账号;若被禁用则跳过换下一个
accountMu.Lock()
disabled := acc.Disabled
accountMu.Unlock()
if disabled {
lastAuthErr = acc.DisabledReason
continue
}
upstreamReq, err := http.NewRequestWithContext(r.Context(), http.MethodPost, endpointChat, bytes.NewReader(upstreamBytes))
if err != nil {
@@ -948,6 +1255,14 @@ func handleChatCompletions(w http.ResponseWriter, r *http.Request) {
continue // 尝试下一个账号
}
if isAuthFailure(resp.StatusCode, errStr) {
// 授权失效(401/403 / token 无效 / 登录过期):禁用该账号并删除凭据文件,
// 自动改用下一个可用账号,控制台提示用户重新登录
disableAccount(acc, fmt.Sprintf("上游鉴权失败 (HTTP %d): %s", resp.StatusCode, truncate(errStr, 200)))
lastAuthErr = errStr
continue // 尝试下一个账号
}
writeOpenAIError(w, resp.StatusCode, "upstream_error", fmt.Sprintf("upstream %d: %s", resp.StatusCode, errStr))
return
}
+238
View File
@@ -1,6 +1,8 @@
package main
import (
"encoding/json"
"os"
"testing"
"time"
)
@@ -116,3 +118,239 @@ func TestNextAccountCooldownExpiry(t *testing.T) {
}
t.Logf("expired cooldown recovers, selected %s", a.Path)
}
// 验证授权失效识别(401/403 / invalid token / 登录过期等)
func TestIsAuthFailure(t *testing.T) {
cases := []struct {
status int
body string
want bool
}{
{401, "{}", true},
{403, "{}", true},
{400, `{"msg":"invalid token"}`, true},
{400, "unauthorized", true},
{400, "登录已过期", true},
{400, "登录失效,请重新登录", true},
{400, "some other error", false},
{429, "频率限制", false},
{500, "{}", false},
}
for _, c := range cases {
if got := isAuthFailure(c.status, c.body); got != c.want {
t.Errorf("isAuthFailure(%d, %q) = %v, want %v", c.status, c.body, got, c.want)
}
}
}
// 验证轮询跳过已失效(Disabled)账号
func TestNextAccountSkipsDisabled(t *testing.T) {
accountMu.Lock()
accounts = []*Account{
{Path: "a.json", Auth: &StoredAuth{}, Disabled: true, DisabledReason: "revoked"},
{Path: "b.json", Auth: &StoredAuth{}},
}
rrIndex = 0
accountMu.Unlock()
a, _ := nextAccount()
if a.Path != "b.json" {
t.Fatalf("expected b.json (only non-disabled), got %s", a.Path)
}
t.Logf("disabled skip works, selected %s", a.Path)
}
// 验证全部失效时返回错误并提示重新登录
func TestNextAccountAllDisabled(t *testing.T) {
accountMu.Lock()
accounts = []*Account{
{Path: "a.json", Auth: &StoredAuth{}, Disabled: true, DisabledReason: "expired"},
{Path: "b.json", Auth: &StoredAuth{}, Disabled: true, DisabledReason: "revoked"},
}
rrIndex = 0
accountMu.Unlock()
_, err := nextAccount()
if err == nil {
t.Fatal("expected error when all accounts disabled")
}
t.Logf("all-disabled error: %v", err)
}
// 验证 disableAccount:标记失效、删除凭据文件、写入失效标记文件
func TestDisableAccount(t *testing.T) {
dir := t.TempDir()
authPath := dir + "/workbuddy-test.json"
// 写一个假的凭据文件
if err := os.WriteFile(authPath, []byte(`{"auth":{"accessToken":"x"}}`), 0600); err != nil {
t.Fatal(err)
}
acc := &Account{Path: authPath, Auth: &StoredAuth{
Auth: StoredTokens{AccessToken: "x"},
Account: StoredAccount{Nickname: "tester", UID: "uid-1"},
}}
disableAccount(acc, "令牌刷新失败 (HTTP 401): invalid token")
accountMu.Lock()
disabled := acc.Disabled
reason := acc.DisabledReason
accountMu.Unlock()
if !disabled {
t.Fatal("account should be marked disabled")
}
if reason == "" {
t.Fatal("disabled reason should be recorded")
}
// 凭据文件应被删除
if _, err := os.Stat(authPath); !os.IsNotExist(err) {
t.Fatalf("credential file should be deleted, stat err=%v", err)
}
// 失效标记文件应存在
if _, err := os.Stat(markerPath(authPath)); err != nil {
t.Fatalf("marker file should exist: %v", err)
}
t.Logf("disableAccount works: disabled=%v reason=%q", disabled, reason)
}
// 验证失效标记文件可被恢复为失效账号(Auth 为 nil),并提示重新登录
func TestLoadDisabledMarkers(t *testing.T) {
dir := t.TempDir()
authPath := dir + "/workbuddy-test.json"
// 构造失效标记文件
marker := disabledMarker{
Path: authPath,
Reason: "授权失效(令牌刷新失败 HTTP 401)",
DisabledAt: time.Now().Unix(),
Nickname: "tester",
UID: "uid-1",
}
data, _ := json.Marshal(marker)
if err := os.WriteFile(markerPath(authPath), data, 0600); err != nil {
t.Fatal(err)
}
// 模拟 -auth 显式指定该路径
oldAuthFile := cfg.AuthFile
oldAuthDir := cfg.AuthDir
oldAuthExplicit := cfg.AuthExplicit
defer func() {
cfg.AuthFile = oldAuthFile
cfg.AuthDir = oldAuthDir
cfg.AuthExplicit = oldAuthExplicit
}()
cfg.AuthFile = authPath
cfg.AuthDir = ""
cfg.AuthExplicit = true
accounts = nil
loadDisabledMarkers()
if len(accounts) != 1 {
t.Fatalf("expected 1 disabled account, got %d", len(accounts))
}
acc := accounts[0]
if !acc.Disabled || acc.Auth != nil {
t.Fatalf("expected disabled account with nil Auth, got disabled=%v authNil=%v", acc.Disabled, acc.Auth == nil)
}
if acc.Nickname != "tester" || acc.UID != "uid-1" {
t.Fatalf("marker nickname/uid not restored: %+v", acc)
}
t.Logf("loadDisabledMarkers restores: %s (nickname=%s)", acc.Path, acc.Nickname)
}
// 验证登录成功后清除失效标记
func TestClearDisabledMarker(t *testing.T) {
dir := t.TempDir()
authPath := dir + "/workbuddy-test.json"
marker := disabledMarker{Path: authPath, Reason: "revoked"}
data, _ := json.Marshal(marker)
if err := os.WriteFile(markerPath(authPath), data, 0600); err != nil {
t.Fatal(err)
}
clearDisabledMarker(authPath)
if _, err := os.Stat(markerPath(authPath)); !os.IsNotExist(err) {
t.Fatalf("marker file should be removed, stat err=%v", err)
}
t.Log("clearDisabledMarker works")
}
// 验证自动发现模式:未指定 -auth/-auth-dir 时,扫描当前目录下所有 workbuddy*.json
func TestCollectConfiguredAuthPathsAutoDiscover(t *testing.T) {
oldDir, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
defer os.Chdir(oldDir)
dir := t.TempDir()
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
// 模拟目录内多个凭据文件 + 非凭据文件 + 失效标记
for _, name := range []string{"workbuddy.json", "workbuddy2.json", "workbuddy-3.json", "other.json", "workbuddy.json.disabled"} {
if err := os.WriteFile(name, []byte(`{"auth":{"accessToken":"x"}}`), 0600); err != nil {
t.Fatal(err)
}
}
oldAuthFile := cfg.AuthFile
oldAuthDir := cfg.AuthDir
oldAuthExplicit := cfg.AuthExplicit
defer func() {
cfg.AuthFile = oldAuthFile
cfg.AuthDir = oldAuthDir
cfg.AuthExplicit = oldAuthExplicit
}()
cfg.AuthFile = "workbuddy.json"
cfg.AuthDir = ""
cfg.AuthExplicit = false
paths := collectConfiguredAuthPaths()
if len(paths) != 3 {
t.Fatalf("expected 3 workbuddy json files, got %d: %v", len(paths), paths)
}
want := []string{"workbuddy-3.json", "workbuddy.json", "workbuddy2.json"} // sort.Strings 排序结果
for i, p := range want {
if paths[i] != p {
t.Fatalf("paths[%d] = %s, want %s (all: %v)", i, paths[i], p, paths)
}
}
t.Logf("auto-discover paths: %v", paths)
}
// 验证自动发现模式:目录内没有任何凭据文件时回退到默认路径
func TestCollectConfiguredAuthPathsAutoDiscoverFallback(t *testing.T) {
oldDir, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
defer os.Chdir(oldDir)
dir := t.TempDir()
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
// 目录为空
oldAuthFile := cfg.AuthFile
oldAuthDir := cfg.AuthDir
oldAuthExplicit := cfg.AuthExplicit
defer func() {
cfg.AuthFile = oldAuthFile
cfg.AuthDir = oldAuthDir
cfg.AuthExplicit = oldAuthExplicit
}()
cfg.AuthFile = "workbuddy.json"
cfg.AuthDir = ""
cfg.AuthExplicit = false
paths := collectConfiguredAuthPaths()
if len(paths) != 1 || paths[0] != "workbuddy.json" {
t.Fatalf("expected fallback to default workbuddy.json, got %v", paths)
}
t.Logf("fallback path: %v", paths)
}