3 Commits
Author SHA1 Message Date
CangShui 61aa053816 feat: add foreground monitor command with live status + recent logs
- serve now periodically writes account-pool state (active/cooldown/
  disabled, cooldown until, token expiry) to workbuddy-status.json,
  refreshed every 3s and immediately on cooldown/disable/refresh events
- new 'monitor' command runs in foreground: refreshes a live panel of
  all accounts (usable/cooldown/expired + reasons) every N seconds,
  optionally showing recent logs via journalctl (-journal) or a log
  file (-logfile)
- statusSnapshotFile excluded from git via .gitignore
- unit tests for tailLines and status snapshot states
2026-09-04 09:36:51 +08:00
CangShui 86b70baf29 feat: print loaded credential file list at end of startup banner
- After the per-account status block, print a summary line listing all
  loaded credential files (e.g. '已加载凭据文件 (2): workbuddy.json,
  workbuddy2.json') so the count is visible even when journalctl/
  systemctl truncates the earlier log lines
2026-09-04 08:48:21 +08:00
CangShui 0484db4b3a feat: show all accounts with full status in serve startup banner
- Startup banner now prints every account's complete status (nickname,
  UID, enterprise, domain, cooldown state, token validity, expiry)
  instead of a single-line summary
- Extracted shared formatAccountStatus() used by both status command
  and serve startup output, keeping them consistent
- Shows disabled accounts with reason and re-login hint at startup
2026-09-04 08:42:03 +08:00
4 changed files with 491 additions and 71 deletions
+1
View File
@@ -7,6 +7,7 @@ last-*.json
*.json.bak
*.json.disabled
.workbuddy-*.json
workbuddy-status.json
# 本地编译产物
workbuddy-gateway.exe
+45 -2
View File
@@ -136,6 +136,48 @@ workbuddy-gateway status # 该账号恢复为 ✅ 可用
- 只有一个账号时,请求始终使用该账号,429 冷却逻辑同样生效(冷却期间请求将返回 429 提示);
- 账号之间使用独立的串行锁:同一账号请求严格排队,不同账号可并行,兼顾风控与吞吐。
## 前台实时监控 (monitor)
网关以 systemd / 后台方式运行时,可用 **`monitor` 命令在前台实时查看所有账号的最新状态与最近日志**(Ctrl+C 退出):
```bash
# 基本用法:每 3 秒刷新展示账号池状态(可用/冷却/失效 + Token 有效期)
cd /opt/workbuddy-gateway # 必须与 serve 同一工作目录(读取 workbuddy-status.json)
workbuddy-gateway monitor
# 自定义刷新间隔(秒)
workbuddy-gateway monitor -interval 2
# 同时展示 systemd 服务最近日志(Linux)
workbuddy-gateway monitor -journal workbuddy-gateway
# 或展示指定日志文件最近内容
workbuddy-gateway monitor -logfile /var/log/workbuddy-gateway.log
# 控制每次展示的日志行数
workbuddy-gateway monitor -journal workbuddy-gateway -lines 8
```
展示内容(实时刷新):
```text
================ WorkBuddy 实时监控 ================
按 Ctrl+C 退出 | 状态文件: workbuddy-status.json
---------------------------------------------------------------
🕐 更新时间: 2026-09-04 09:35:12
📊 账号池: 共 2 个 | ✅ 可用 1 | 🔒 冷却 1 | ❌ 失效 0
#1 workbuddy.json (Abandon)
✅ 可用 | Token 有效期至: 09-10 20:43
#2 workbuddy2.json (啊水)
🔒 冷却中 至 09-05 01:57 (剩余 16h)
原因: 您的使用量已超出频率限制...
📜 最近日志 (journalctl -u workbuddy-gateway):
9月 04 09:34:31 ... [Cooldown] 账号 workbuddy2.json 触发频率限制...
---------------------------------------------------------------
```
> 原理:`serve` 后台每 3 秒(及状态变化时)将账号池实时状态原子写入同目录 `workbuddy-status.json`,`monitor` 前台读取该文件并周期刷新展示;日志通过 `journalctl` 或日志文件补充展示。
## 客户端接入
网关启动后服务地址为 `http://127.0.0.1:8317/v1`。
@@ -316,8 +358,9 @@ ExecStart=/opt/workbuddy-gateway/workbuddy-gateway serve -addr 0.0.0.0 -port 831
## 命令行速查
```
命令: serve | login | status | refresh | version | help
选项: -addr <ip> · -port <port> · -auth <path> · -api-key <key> · -proxy <url> · -verbose
命令: serve | login | status | refresh | monitor | version | help
选项: -addr <ip> · -port <port> · -auth <path> · -auth-dir <dir> · -api-key <key> · -proxy <url> · -verbose
monitor: -interval <sec> · -journal <svc> · -logfile <path> · -lines <n>
```
## 从源码构建
+378 -69
View File
@@ -14,10 +14,12 @@ import (
"net/http/cookiejar"
"net/url"
"os"
"os/exec"
"os/signal"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
@@ -29,7 +31,7 @@ import (
)
const (
version = "1.4.0"
version = "1.5.0"
upstreamBase = "https://copilot.tencent.com"
clientUA = "CLI/2.143.1 CodeBuddy/2.143.1"
originReferer = "https://www.codebuddy.cn"
@@ -41,6 +43,9 @@ const (
endpointChat = upstreamBase + "/v2/chat/completions"
loginTTL = 5 * time.Minute
// 状态快照文件名:serve 后台周期写入,monitor 前台命令实时读取展示
statusSnapshotFile = "workbuddy-status.json"
)
// -----------------------------------------------------------------------------
@@ -95,15 +100,19 @@ type authStateData struct {
// -----------------------------------------------------------------------------
type Config struct {
Addr string
Port int
AuthFile string
AuthDir string
AuthExplicit bool // 用户是否显式指定了 -auth(未指定时自动扫描目录下所有 workbuddy*.json)
APIKey string
ProxyURL string
Verbose bool
HttpClient *http.Client
Addr string
Port int
AuthFile string
AuthDir string
AuthExplicit bool // 用户是否显式指定了 -auth(未指定时自动扫描目录下所有 workbuddy*.json)
APIKey string
ProxyURL string
Verbose bool
MonitorInterval int // monitor 状态刷新间隔(秒)
LogFile string // monitor 附加展示的日志文件路径
JournalService string // monitor 附加展示的 systemd 服务名(journalctl -u)
LogLines int // monitor 展示的最近日志行数
HttpClient *http.Client
}
// Account 表示一个 CodeBuddy 账号凭据及其运行时状态。
@@ -176,6 +185,10 @@ 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.IntVar(&cfg.MonitorInterval, "interval", 3, "monitor 状态刷新间隔(秒)")
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 每次刷新展示的最近日志行数")
_ = fs.Parse(args)
// 检测 -auth 是否被显式指定:
@@ -199,6 +212,8 @@ func main() {
runStatus()
case "refresh":
runRefresh()
case "monitor":
runMonitor()
case "version", "-v", "--version":
fmt.Printf("WorkBuddy Local Gateway v%s\n", version)
default:
@@ -222,6 +237,7 @@ func printHelp() {
login 微信/企业微信扫码登录,获取/更新凭据
status 查看账号池状态(含冷却状态与过期时间)
refresh 手动立即刷新所有账号访问令牌 (Access Token)
monitor 前台实时监控:周期刷新展示账号状态 + 最近日志 (Ctrl+C 退出)
version 查看版本信息
help 查看帮助说明
@@ -235,6 +251,12 @@ func printHelp() {
-proxy <url> 设置上游转发代理 (例如 http://127.0.0.1:7890 或 socks5://...)
-verbose 输出详细调试日志 (请求/响应体)
monitor 选项:
-interval <sec> 状态刷新间隔秒数 (默认: 3)
-journal <svc> 同时展示 systemd 服务最近日志 (Linux, 如 -journal workbuddy-gateway)
-logfile <path> 同时展示指定日志文件最近内容 (如 -logfile /var/log/wb.log)
-lines <n> 每次刷新展示的最近日志行数 (默认: 5)
多账号说明:
# 登录第二个账号(保存到不同文件)
workbuddy-gateway login -auth workbuddy2.json
@@ -499,6 +521,7 @@ func markCooldown(acc *Account, until time.Time, msg string) {
accountMu.Unlock()
log.Printf("[Cooldown] 账号 %s 触发频率限制,自动屏蔽至 %s (提示: %s)",
acc.Path, until.Format("2006-01-02 15:04:05"), msg)
writeStatusSnapshot()
}
// disableAccount 将账号标记为失效(授权过期/撤销),禁止调度并删除凭据文件,
@@ -538,6 +561,7 @@ func disableAccount(acc *Account, reason string) {
log.Printf("[Auth] 账号 %s 凭据文件删除失败: %v", path, err)
}
log.Printf("[Auth] ⚠️ 账号 %s 授权失效,已禁止调度并删除凭据文件: %s", path, reason)
writeStatusSnapshot()
}
// loadDisabledMarkers 扫描失效标记文件,将其恢复为账号池中的失效账号(Auth 为 nil)。
@@ -737,6 +761,7 @@ func doRefreshTokenFor(acc *Account) error {
return fmt.Errorf("写回凭据失败: %w", err)
}
log.Printf("[Auth] 账号 %s Token 刷新成功!新过期时间: %s", acc.Path, time.Unix(acc.Auth.Auth.ExpiresAt, 0).Format("2006-01-02 15:04:05"))
writeStatusSnapshot()
return nil
}
@@ -778,6 +803,57 @@ func refreshTokenPayload(sa *StoredAuth) (int, error) {
// 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)
@@ -791,50 +867,7 @@ 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 := "有效"
if remaining <= 0 {
statusStr = "已过期"
}
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=======================================================")
}
@@ -871,6 +904,260 @@ func runRefresh() {
fmt.Printf("\n刷新完成: 成功 %d 个,失败 %d 个,跳过 %d 个(授权失效)\n", ok, fail, skipped)
}
// -----------------------------------------------------------------------------
// 状态快照与前台实时监控 (monitor)
// -----------------------------------------------------------------------------
// accountSnapshot 是写入状态快照文件的单个账号状态。
type accountSnapshot struct {
Path string `json:"path"`
Nickname string `json:"nickname"`
UID string `json:"uid"`
State string `json:"state"` // active | cooldown | disabled
CooldownUntil int64 `json:"cooldownUntil,omitempty"`
CooldownMsg string `json:"cooldownMsg,omitempty"`
DisabledReason string `json:"disabledReason,omitempty"`
TokenExpiresAt int64 `json:"tokenExpiresAt,omitempty"`
}
// statusSnapshot 是写入 workbuddy-status.json 的完整快照。
type statusSnapshot struct {
UpdatedAt int64 `json:"updatedAt"`
Accounts []accountSnapshot `json:"accounts"`
}
// writeStatusSnapshot 将账号池实时状态(含冷却/失效)原子写入状态快照文件。
// serve 后台周期调用;monitor 命令前台读取展示。
func writeStatusSnapshot() {
accountMu.Lock()
snap := statusSnapshot{UpdatedAt: time.Now().Unix()}
now := time.Now()
for _, acc := range accounts {
as := accountSnapshot{Path: acc.Path}
switch {
case acc.Disabled:
as.State = "disabled"
as.DisabledReason = acc.DisabledReason
if acc.Auth != nil {
as.Nickname = acc.Auth.Account.Nickname
as.UID = acc.Auth.Account.UID
} else {
as.Nickname = acc.Nickname
as.UID = acc.UID
}
case acc.CooldownUntil.After(now):
as.State = "cooldown"
as.CooldownUntil = acc.CooldownUntil.Unix()
as.CooldownMsg = acc.CooldownMsg
if acc.Auth != nil {
as.Nickname = acc.Auth.Account.Nickname
as.UID = acc.Auth.Account.UID
as.TokenExpiresAt = acc.Auth.Auth.ExpiresAt
}
default:
as.State = "active"
if acc.Auth != nil {
as.Nickname = acc.Auth.Account.Nickname
as.UID = acc.Auth.Account.UID
as.TokenExpiresAt = acc.Auth.Auth.ExpiresAt
}
}
snap.Accounts = append(snap.Accounts, as)
}
accountMu.Unlock()
data, err := json.MarshalIndent(snap, "", " ")
if err != nil {
return
}
// 原子写:先写临时文件再改名,避免 monitor 读到半截内容
tmp := statusSnapshotFile + ".tmp"
if err := os.WriteFile(tmp, data, 0600); err != nil {
return
}
_ = os.Rename(tmp, statusSnapshotFile)
}
// statusSnapshotLoop serve 后台每 3 秒刷新一次状态快照。
func statusSnapshotLoop() {
writeStatusSnapshot()
ticker := time.NewTicker(3 * time.Second)
defer ticker.Stop()
for range ticker.C {
writeStatusSnapshot()
}
}
// tailLines 读取文件末尾 n 行(用于 monitor 展示最近日志)。
func tailLines(path string, n int) ([]string, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
// 先定位到文件末尾,从后向前扫描 n 个换行符
const chunk = 4096
fi, err := f.Stat()
if err != nil {
return nil, err
}
size := fi.Size()
var lines []string
buf := make([]byte, chunk)
pos := size
lineBuf := make([]byte, 0, chunk)
newlines := 0
for pos > 0 && newlines <= n {
read := int64(chunk)
if pos < chunk {
read = pos
}
pos -= read
if _, err := f.Seek(pos, io.SeekStart); err != nil {
break
}
rn, err := f.Read(buf[:read])
if err != nil && rn == 0 {
break
}
for i := rn - 1; i >= 0; i-- {
if buf[i] == '\n' {
if len(lineBuf) > 0 {
lines = append([]string{string(lineBuf)}, lines...)
lineBuf = lineBuf[:0]
newlines++
if newlines > n {
break
}
}
} else {
lineBuf = append([]byte{buf[i]}, lineBuf...)
}
}
if newlines > n {
break
}
}
if len(lineBuf) > 0 && newlines <= n {
lines = append([]string{string(lineBuf)}, lines...)
}
if len(lines) > n {
lines = lines[len(lines)-n:]
}
return lines, nil
}
// journalLines 获取 systemd 服务最近 n 行日志(Linux journalctl)。
func journalLines(service string, n int) ([]string, error) {
cmd := exec.Command("journalctl", "-u", service, "-n", strconv.Itoa(n), "--no-pager", "-o", "short")
out, err := cmd.Output()
if err != nil {
return nil, err
}
s := strings.TrimSpace(string(out))
if s == "" {
return nil, nil
}
lines := strings.Split(s, "\n")
if len(lines) > n {
lines = lines[len(lines)-n:]
}
return lines, nil
}
// runMonitor 前台实时监控:周期刷新展示账号池状态 + 最近日志(Ctrl+C 退出)。
func runMonitor() {
interval := time.Duration(cfg.MonitorInterval) * time.Second
if interval <= 0 {
interval = 3 * time.Second
}
logN := cfg.LogLines
if logN <= 0 {
logN = 5
}
// 判断 stdout 是否为终端(是则用 ANSI 清屏重绘,否则滚动输出)
isTTY := false
if fi, err := os.Stdout.Stat(); err == nil {
isTTY = fi.Mode()&os.ModeCharDevice != 0
}
fmt.Println("================ WorkBuddy 实时监控 ================")
fmt.Println("按 Ctrl+C 退出 | 状态文件: " + statusSnapshotFile)
fmt.Println("---------------------------------------------------------------")
for {
if isTTY {
fmt.Print("\033[H\033[2J") // 清屏
} else {
fmt.Println()
}
fmt.Printf("🕐 更新时间: %s\n", time.Now().Format("2006-01-02 15:04:05"))
data, err := os.ReadFile(statusSnapshotFile)
if err != nil {
fmt.Printf("⚠️ 未找到状态文件 %s(服务是否在运行?请确认已在服务工作目录执行 monitor)\n", statusSnapshotFile)
} else {
var snap statusSnapshot
if err := json.Unmarshal(data, &snap); err == nil {
active, cooldown, disabled := 0, 0, 0
for _, a := range snap.Accounts {
switch a.State {
case "active":
active++
case "cooldown":
cooldown++
case "disabled":
disabled++
}
}
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, "?"))
switch a.State {
case "active":
fmt.Printf(" ✅ 可用 | Token 有效期至: %s\n",
time.Unix(a.TokenExpiresAt, 0).Format("01-02 15:04"))
case "cooldown":
fmt.Printf(" 🔒 冷却中 至 %s (剩余 %v)\n",
time.Unix(a.CooldownUntil, 0).Format("01-02 15:04:05"),
time.Until(time.Unix(a.CooldownUntil, 0)).Round(time.Minute))
if a.CooldownMsg != "" {
fmt.Printf(" 原因: %s\n", truncate(a.CooldownMsg, 100))
}
case "disabled":
fmt.Printf(" ❌ 授权失效: %s\n", truncate(a.DisabledReason, 100))
fmt.Printf(" ⚠️ 请重新执行: workbuddy-gateway login -auth %s\n", a.Path)
}
}
} else {
fmt.Println("⚠️ 状态文件解析失败")
}
}
// 最近日志
if cfg.JournalService != "" {
if lines, err := journalLines(cfg.JournalService, logN); err == nil && len(lines) > 0 {
fmt.Printf("\n📜 最近日志 (journalctl -u %s):\n", cfg.JournalService)
for _, l := range lines {
fmt.Println(" " + l)
}
}
} else if cfg.LogFile != "" {
if lines, err := tailLines(cfg.LogFile, logN); err == nil && len(lines) > 0 {
fmt.Printf("\n📜 最近日志 (%s):\n", cfg.LogFile)
for _, l := range lines {
fmt.Println(" " + l)
}
}
}
fmt.Println("---------------------------------------------------------------")
time.Sleep(interval)
}
}
func runLogin() {
fmt.Println("================ WorkBuddy 扫码登录 ================")
fmt.Println("正在生成登录凭据与二维码...")
@@ -972,33 +1259,21 @@ func runServe() {
fmt.Printf("警告: 未检测到有效凭据 (%v)。\n请先执行: workbuddy-gateway login 扫码登录,或确保凭据文件存在。\n\n", err)
} 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 个账号 (有效 %d, 失效 %d)\n", accCount, activeCount, disabledCount)
for i, acc := range accounts {
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"))
}
}
// 启动后台自动刷新协程
go backgroundTokenRefresher()
// 启动状态快照协程(monitor 命令实时读取展示)
go statusSnapshotLoop()
mux := http.NewServeMux()
mux.HandleFunc("/v1/chat/completions", handleChatCompletions)
mux.HandleFunc("/chat/completions", handleChatCompletions)
@@ -1030,6 +1305,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 安全停止)...")
+67
View File
@@ -354,3 +354,70 @@ func TestCollectConfiguredAuthPathsAutoDiscoverFallback(t *testing.T) {
}
t.Logf("fallback path: %v", paths)
}
// 验证 tailLines 读取文件末尾 N 行
func TestTailLines(t *testing.T) {
dir := t.TempDir()
f := dir + "/test.log"
content := "line1\nline2\nline3\nline4\nline5\n"
if err := os.WriteFile(f, []byte(content), 0600); err != nil {
t.Fatal(err)
}
lines, err := tailLines(f, 2)
if err != nil {
t.Fatal(err)
}
if len(lines) != 2 || lines[0] != "line4" || lines[1] != "line5" {
t.Fatalf("tailLines got %v", lines)
}
t.Logf("tailLines(2) = %v", lines)
}
// 验证状态快照写入:含 active / cooldown / disabled 三种状态
func TestWriteStatusSnapshot(t *testing.T) {
oldDir, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
defer os.Chdir(oldDir)
dir := t.TempDir()
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
accountMu.Lock()
accounts = []*Account{
{Path: "a.json", Auth: &StoredAuth{
Auth: StoredTokens{AccessToken: "x", ExpiresAt: time.Now().Add(time.Hour).Unix()},
Account: StoredAccount{Nickname: "alice", UID: "u1"},
}},
{Path: "b.json", Auth: &StoredAuth{
Auth: StoredTokens{AccessToken: "x", ExpiresAt: time.Now().Add(time.Hour).Unix()},
Account: StoredAccount{Nickname: "bob", UID: "u2"},
}, CooldownUntil: time.Now().Add(30 * time.Minute), CooldownMsg: "频率限制"},
{Path: "c.json", Disabled: true, DisabledReason: "revoked", Nickname: "carol", UID: "u3"},
}
accountMu.Unlock()
writeStatusSnapshot()
data, err := os.ReadFile(statusSnapshotFile)
if err != nil {
t.Fatal(err)
}
var snap statusSnapshot
if err := json.Unmarshal(data, &snap); err != nil {
t.Fatal(err)
}
if len(snap.Accounts) != 3 {
t.Fatalf("expected 3 accounts in snapshot, got %d", len(snap.Accounts))
}
states := map[string]bool{}
for _, a := range snap.Accounts {
states[a.State] = true
}
if !states["active"] || !states["cooldown"] || !states["disabled"] {
t.Fatalf("expected all three states in snapshot, got %v", states)
}
t.Logf("snapshot states: %v", states)
}