Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61aa053816 | ||
|
|
86b70baf29 | ||
|
|
0484db4b3a | ||
|
|
9fcd99f152 |
@@ -7,6 +7,7 @@ last-*.json
|
||||
*.json.bak
|
||||
*.json.disabled
|
||||
.workbuddy-*.json
|
||||
workbuddy-status.json
|
||||
|
||||
# 本地编译产物
|
||||
workbuddy-gateway.exe
|
||||
|
||||
@@ -61,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
|
||||
@@ -126,10 +132,52 @@ workbuddy-gateway status # 该账号恢复为 ✅ 可用
|
||||
|
||||
### 与单账号模式的兼容性
|
||||
|
||||
- 不传 `-auth` 时默认使用 `./workbuddy.json`,行为与旧版完全一致;
|
||||
- 不传 `-auth` / `-auth-dir` 时,自动发现当前工作目录下所有 `workbuddy*.json`;目录中只有一个凭据文件时行为与旧版完全一致;
|
||||
- 只有一个账号时,请求始终使用该账号,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`。
|
||||
@@ -230,8 +278,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
|
||||
@@ -300,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>
|
||||
```
|
||||
|
||||
## 从源码构建
|
||||
|
||||
@@ -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.3.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,14 +100,19 @@ type authStateData struct {
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
type Config struct {
|
||||
Addr string
|
||||
Port int
|
||||
AuthFile string
|
||||
AuthDir string
|
||||
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 账号凭据及其运行时状态。
|
||||
@@ -175,8 +185,21 @@ 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 是否被显式指定:
|
||||
// 若未指定 -auth 且未指定 -auth-dir,则自动扫描当前目录下所有 workbuddy*.json 组成账号池,
|
||||
// 这样把多个凭据文件放进工作目录即可自动多账号,无需手写参数。
|
||||
fs.Visit(func(f *flag.Flag) {
|
||||
if f.Name == "auth" {
|
||||
cfg.AuthExplicit = true
|
||||
}
|
||||
})
|
||||
|
||||
// 初始化 HTTP 客户端
|
||||
initHTTPClient()
|
||||
|
||||
@@ -189,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:
|
||||
@@ -212,6 +237,7 @@ func printHelp() {
|
||||
login 微信/企业微信扫码登录,获取/更新凭据
|
||||
status 查看账号池状态(含冷却状态与过期时间)
|
||||
refresh 手动立即刷新所有账号访问令牌 (Access Token)
|
||||
monitor 前台实时监控:周期刷新展示账号状态 + 最近日志 (Ctrl+C 退出)
|
||||
version 查看版本信息
|
||||
help 查看帮助说明
|
||||
|
||||
@@ -219,18 +245,27 @@ 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://...)
|
||||
-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 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
|
||||
@@ -352,9 +387,65 @@ func loadAccountFile(path string) (*StoredAuth, error) {
|
||||
return &sa, nil
|
||||
}
|
||||
|
||||
// loadAccounts 根据 -auth / -auth-dir 配置构建账号池。
|
||||
// -auth 支持逗号分隔多个凭据文件;-auth-dir 自动加载目录下所有 workbuddy*.json。
|
||||
// 单账号时池长度为 1,行为与旧版完全一致。
|
||||
// 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 {
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
if strings.HasPrefix(name, "workbuddy") && strings.HasSuffix(name, ".json") {
|
||||
paths = append(paths, filepath.Join(cfg.AuthDir, name))
|
||||
}
|
||||
}
|
||||
sort.Strings(paths)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
if cfg.AuthExplicit {
|
||||
for _, p := range strings.Split(cfg.AuthFile, ",") {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
paths = append(paths, p)
|
||||
}
|
||||
}
|
||||
return 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()
|
||||
@@ -362,32 +453,7 @@ func loadAccounts() error {
|
||||
accounts = nil
|
||||
rrIndex = 0
|
||||
|
||||
var paths []string
|
||||
if cfg.AuthDir != "" {
|
||||
entries, err := os.ReadDir(cfg.AuthDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取凭据目录失败 (%s): %w", cfg.AuthDir, err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
if strings.HasPrefix(name, "workbuddy") && strings.HasSuffix(name, ".json") {
|
||||
paths = append(paths, filepath.Join(cfg.AuthDir, name))
|
||||
}
|
||||
}
|
||||
sort.Strings(paths)
|
||||
} else {
|
||||
for _, p := range strings.Split(cfg.AuthFile, ",") {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
paths = append(paths, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, p := range paths {
|
||||
for _, p := range collectConfiguredAuthPaths() {
|
||||
sa, err := loadAccountFile(p)
|
||||
if err != nil {
|
||||
log.Printf("[Auth] 跳过无效凭据文件 %s: %v", p, err)
|
||||
@@ -455,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 将账号标记为失效(授权过期/撤销),禁止调度并删除凭据文件,
|
||||
@@ -494,13 +561,15 @@ func disableAccount(acc *Account, reason string) {
|
||||
log.Printf("[Auth] 账号 %s 凭据文件删除失败: %v", path, err)
|
||||
}
|
||||
log.Printf("[Auth] ⚠️ 账号 %s 授权失效,已禁止调度并删除凭据文件: %s", path, reason)
|
||||
writeStatusSnapshot()
|
||||
}
|
||||
|
||||
// loadDisabledMarkers 扫描失效标记文件,将其恢复为账号池中的失效账号(Auth 为 nil)。
|
||||
func loadDisabledMarkers() {
|
||||
// 收集标记文件路径:
|
||||
// - AuthDir 模式:目录下 *.json.disabled 即为标记文件
|
||||
// - 单文件模式:<凭据路径>.disabled 为标记文件
|
||||
// - 显式 -auth 模式:<凭据路径>.disabled 为标记文件
|
||||
// - 自动发现模式:扫描当前目录下所有 *.json.disabled 标记文件
|
||||
var markerPaths []string
|
||||
if cfg.AuthDir != "" {
|
||||
entries, err := os.ReadDir(cfg.AuthDir)
|
||||
@@ -516,13 +585,28 @@ func loadDisabledMarkers() {
|
||||
markerPaths = append(markerPaths, filepath.Join(cfg.AuthDir, name))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
} 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 {
|
||||
@@ -677,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
|
||||
}
|
||||
|
||||
@@ -718,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)
|
||||
@@ -731,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=======================================================")
|
||||
}
|
||||
@@ -811,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("正在生成登录凭据与二维码...")
|
||||
@@ -912,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)
|
||||
@@ -970,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 安全停止)...")
|
||||
|
||||
|
||||
+148
-1
@@ -232,15 +232,18 @@ func TestLoadDisabledMarkers(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 模拟 -auth 指定该路径
|
||||
// 模拟 -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()
|
||||
@@ -274,3 +277,147 @@ func TestClearDisabledMarker(t *testing.T) {
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
// 验证 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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user