diff --git a/.gitignore b/.gitignore index 5dfb126..e6003f2 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ dist/workbuddy.json dist/*.json last-*.json *.json.bak +*.json.disabled +.workbuddy-*.json # 本地编译产物 workbuddy-gateway.exe diff --git a/README.md b/README.md index 2599ddb..f74a86a 100644 --- a/README.md +++ b/README.md @@ -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` 时转发;绝不强制注入,避免触发上游内容安全策略。 @@ -91,6 +92,38 @@ 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`,行为与旧版完全一致; diff --git a/main.go b/main.go index 0e55bd6..fee0ad7 100644 --- a/main.go +++ b/main.go @@ -29,7 +29,7 @@ import ( ) const ( - version = "1.2.0" + version = "1.3.0" upstreamBase = "https://copilot.tencent.com" clientUA = "CLI/2.143.1 CodeBuddy/2.143.1" originReferer = "https://www.codebuddy.cn" @@ -107,11 +107,29 @@ type Config struct { // Account 表示一个 CodeBuddy 账号凭据及其运行时状态。 type Account struct { - Path string // 凭据文件路径 - Auth *StoredAuth // 凭据内容 - CooldownUntil time.Time // 冷却截止时间(429 频率限制后自动屏蔽到该时间) - CooldownMsg string // 触发冷却的原因/上游提示 - lock sync.Mutex // 单账号串行锁(防止同账号并发触发 11128) + Path string // 凭据文件路径 + 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 ( @@ -293,10 +311,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 +329,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 从指定路径读取并解析凭据(不修改全局缓存)。 @@ -369,14 +396,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 +415,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 +457,129 @@ 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 即为标记文件 + // - 单文件模式:<凭据路径>.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 { + for _, p := range strings.Split(cfg.AuthFile, ",") { + p = strings.TrimSpace(p) + if p != "" { + markerPaths = append(markerPaths, markerPath(p)) + } + } + } + + 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 +621,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 +650,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 +661,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 或已失效") } - if err := refreshTokenPayload(acc.Auth); err != nil { + status, err := refreshTokenPayload(acc.Auth) + if err != nil { + if isAuthFailure(status, err.Error()) { + disableAccount(acc, fmt.Sprintf("令牌刷新失败 (HTTP %d): %v", status, err)) + } return err } if err := saveAuthTo(acc.Path, acc.Auth); err != nil { @@ -509,7 +681,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 +694,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,7 +711,7 @@ 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 } // ----------------------------------------------------------------------------- @@ -558,9 +731,29 @@ func runStatus() { fmt.Printf("账号总数: %d\n", len(accounts)) now := time.Now() for i, acc := range accounts { + fmt.Printf("\n--- 账号 #%d ---\n", i+1) + fmt.Printf("凭据文件: %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 + } + fmt.Printf("用户昵称: %s\n", ifEmpty(nickname, "(未知)")) + fmt.Printf("用户 UID: %s\n", ifEmpty(uid, "(未知)")) + fmt.Printf("账号状态: ❌ 授权失效(禁止调度)\n") + fmt.Printf("失效原因: %s\n", truncate(acc.DisabledReason, 200)) + fmt.Printf("处理建议: ⚠️ 凭据文件已删除,请重新执行: workbuddy-gateway login -auth %s\n", acc.Path) + continue + } + if acc.Auth == nil { continue } + expTime := time.Unix(acc.Auth.Auth.ExpiresAt, 0) remaining := time.Until(expTime) statusStr := "有效" @@ -568,8 +761,6 @@ func runStatus() { 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, "(个人账号)")) @@ -601,8 +792,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 +808,7 @@ func runRefresh() { ok++ } } - fmt.Printf("\n刷新完成: 成功 %d 个,失败 %d 个\n", ok, fail) + fmt.Printf("\n刷新完成: 成功 %d 个,失败 %d 个,跳过 %d 个(授权失效)\n", ok, fail, skipped) } func runLogin() { @@ -716,13 +913,24 @@ func runServe() { } else { accountMu.Lock() accCount := len(accounts) + activeCount := 0 + disabledCount := 0 for _, acc := range accounts { + if acc.Disabled || acc.Auth == nil { + disabledCount++ + continue + } + activeCount++ _ = ensureValidTokenFor(acc) } accountMu.Unlock() - fmt.Printf("已就绪账号池: %d 个账号\n", accCount) + fmt.Printf("已就绪账号池: %d 个账号 (有效 %d, 失效 %d)\n", accCount, activeCount, disabledCount) for i, acc := range accounts { - fmt.Printf(" #%d %s (UID: %s, 有效期至: %s)\n", + if acc.Disabled || acc.Auth == nil { + fmt.Printf(" #%d ❌ %s (授权失效,凭据文件已删除,请重新登录)\n", i+1, acc.Path) + continue + } + 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")) } @@ -898,19 +1106,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 +1168,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 } diff --git a/main_test.go b/main_test.go index a2ce826..0e0b908 100644 --- a/main_test.go +++ b/main_test.go @@ -1,6 +1,8 @@ package main import ( + "encoding/json" + "os" "testing" "time" ) @@ -116,3 +118,159 @@ 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 + defer func() { + cfg.AuthFile = oldAuthFile + cfg.AuthDir = oldAuthDir + }() + cfg.AuthFile = authPath + cfg.AuthDir = "" + + 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") +}