Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86b70baf29 | ||
|
|
0484db4b3a | ||
|
|
9fcd99f152 | ||
|
|
818d0c223e | ||
|
|
f96a874d2a | ||
|
|
e82b5f4212 | ||
|
|
946f540cb1 |
@@ -5,6 +5,8 @@ dist/workbuddy.json
|
||||
dist/*.json
|
||||
last-*.json
|
||||
*.json.bak
|
||||
*.json.disabled
|
||||
.workbuddy-*.json
|
||||
|
||||
# 本地编译产物
|
||||
workbuddy-gateway.exe
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
# WorkBuddy Local Gateway
|
||||
<img width="1734" height="924" alt="image" src="https://github.com/user-attachments/assets/d25431ed-0240-464a-a65d-70aaf3909477" />
|
||||
|
||||
基于腾讯 **CodeBuddy / 混元(Hunyuan)** 协议开发的**纯 Go、零 CGO 依赖、跨平台单二进制**本地 AI 代理网关。无 Web UI,仅通过命令行(CLI)完成扫码登录、凭据续期与服务控制。
|
||||
|
||||
@@ -6,11 +7,14 @@
|
||||
|
||||
- **模型完全透传**:客户端(OpenAI SDK、Cursor、Claude Code、DSH 等)传什么 `model`(如 `hy4-preview`、`hy3-preview-agent`、`hy3`、`deepseek-v4-pro`、`glm-5.2`…),网关原样透传至腾讯上游,无白名单限制。
|
||||
- **纯 CLI 控制**:终端内嵌 ASCII 二维码,微信 / 企业微信扫码登录;`status` / `refresh` / `serve` 子命令完成全部管理。
|
||||
- **后台自动续期**:运行期间每 5 分钟检查 Token,距过期不足 15 分钟自动刷新并持久化到 `workbuddy.json`。
|
||||
- **多账号池 + 轮询负载均衡**:支持同时挂载多个 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` 时转发;绝不强制注入,避免触发上游内容安全策略。
|
||||
- **反审查净化**:自动改写 Claude Code 等框架被上游逐字拉黑的固定 Prompt 语句。
|
||||
- **单并发串行化**:同一账号请求自动排队,避免并发双发触发上游风控。
|
||||
- **单账号串行化**:同一账号请求自动排队,避免并发双发触发上游风控;不同账号之间可并行。
|
||||
|
||||
## 快速上手
|
||||
|
||||
@@ -53,6 +57,85 @@ workbuddy-gateway serve -proxy http://127.0.0.1:7890
|
||||
workbuddy-gateway serve -api-key sk-localsecret
|
||||
```
|
||||
|
||||
## 多账号池与 429 自动冷却
|
||||
|
||||
### 配置多个账号
|
||||
|
||||
网关支持同时挂载多个 CodeBuddy 账号,请求按**轮询(round-robin)**方式均匀分发,保持各账号额度消耗一致。三种配置方式:
|
||||
|
||||
```bash
|
||||
# 方式一(推荐):自动发现 — 把多个凭据文件放进工作目录即可,无需任何参数
|
||||
# 网关启动时自动加载当前目录下所有 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)
|
||||
mkdir -p auths
|
||||
workbuddy-gateway login -auth auths/workbuddy-1.json # 依次为每个账号扫码登录
|
||||
workbuddy-gateway login -auth auths/workbuddy-2.json
|
||||
workbuddy-gateway serve -auth-dir ./auths
|
||||
```
|
||||
|
||||
### 429 频率限制自动冷却
|
||||
|
||||
当某个账号触发上游频率限制(HTTP 429,消息形如 `您的使用量已超出频率限制,将在 2026-09-04 07:48:15 UTC+8 重置`)时,网关会:
|
||||
|
||||
1. **自动解析消息中的重置时间**,立即将该账号屏蔽(冷却)至该时间点;
|
||||
2. **自动改用下一个可用账号重试**当前请求(代偿),无需客户端干预;
|
||||
3. 冷却期间该账号不参与轮询,**冷却到期后自动恢复**;
|
||||
4. 若消息中无法解析重置时间,默认冷却 60 秒后重试;
|
||||
5. 当所有账号均处于冷却状态时,返回 HTTP 429 并附上最早解封时间。
|
||||
|
||||
```bash
|
||||
# 查看各账号状态(含冷却状态与解封时间)
|
||||
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` / `-auth-dir` 时,自动发现当前工作目录下所有 `workbuddy*.json`;目录中只有一个凭据文件时行为与旧版完全一致;
|
||||
- 只有一个账号时,请求始终使用该账号,429 冷却逻辑同样生效(冷却期间请求将返回 429 提示);
|
||||
- 账号之间使用独立的串行锁:同一账号请求严格排队,不同账号可并行,兼顾风控与吞吐。
|
||||
|
||||
## 客户端接入
|
||||
|
||||
网关启动后服务地址为 `http://127.0.0.1:8317/v1`。
|
||||
@@ -95,7 +178,7 @@ llm-pi-ai:
|
||||
|
||||
### Windows
|
||||
|
||||
1. 下载 `dist/workbuddy-gateway-windows-amd64.exe`。
|
||||
1. 从 [Releases](https://github.com/CangShui/workbuddy-gateway/releases) 下载 `workbuddy-gateway-windows-amd64.exe`。
|
||||
2. 在 PowerShell / CMD 中进入文件所在目录:
|
||||
```powershell
|
||||
.\workbuddy-gateway-windows-amd64.exe login
|
||||
@@ -105,11 +188,13 @@ llm-pi-ai:
|
||||
|
||||
### Linux (amd64 / arm64)
|
||||
|
||||
1. 下载对应架构二进制,赋执行权限并放入 PATH:
|
||||
1. 从 [Releases](https://github.com/CangShui/workbuddy-gateway/releases) 下载对应架构二进制,赋执行权限并放入 PATH:
|
||||
```bash
|
||||
# x86_64
|
||||
wget https://github.com/CangShui/workbuddy-gateway/releases/latest/download/workbuddy-gateway-linux-amd64
|
||||
sudo install -m 755 workbuddy-gateway-linux-amd64 /usr/local/bin/workbuddy-gateway
|
||||
# ARM64 (树莓派 / 飞腾 / Apple silicon 云主机等)
|
||||
wget https://github.com/CangShui/workbuddy-gateway/releases/latest/download/workbuddy-gateway-linux-arm64
|
||||
sudo install -m 755 workbuddy-gateway-linux-arm64 /usr/local/bin/workbuddy-gateway
|
||||
```
|
||||
2. 登录并启动:
|
||||
@@ -151,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
|
||||
@@ -178,7 +273,7 @@ ExecStart=/opt/workbuddy-gateway/workbuddy-gateway serve -addr 0.0.0.0 -port 831
|
||||
|
||||
### macOS (Apple Silicon / Intel)
|
||||
|
||||
1. 下载 `workbuddy-gateway-darwin-arm64`(M 系列)或 `workbuddy-gateway-darwin-amd64`(Intel)。
|
||||
1. 从 [Releases](https://github.com/CangShui/workbuddy-gateway/releases) 下载 `workbuddy-gateway-darwin-arm64`(M 系列)或 `workbuddy-gateway-darwin-amd64`(Intel)。
|
||||
2. 首次运行需移除隔离属性:
|
||||
```bash
|
||||
chmod +x workbuddy-gateway-darwin-arm64
|
||||
|
||||
+356
@@ -0,0 +1,356 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 验证 429 消息中的重置时间解析
|
||||
func TestParseResetTime(t *testing.T) {
|
||||
msg := `{"code":429,"message":"upstream 429: {\"code\":6004,\"msg\":\"您的使用量已超出频率限制,将在 2026-09-04 07:48:15 UTC+8 重置,您也可以切换其他模型继续使用。\",\"requestId\":\"149e3299-ddf3-4ad1-aaa8-cc752c37630a\"}","type":"upstream_error"}`
|
||||
got, ok := parseResetTime(msg)
|
||||
if !ok {
|
||||
t.Fatal("parseResetTime should succeed")
|
||||
}
|
||||
want := time.Date(2026, 9, 4, 7, 48, 15, 0, time.FixedZone("UTC+8", 8*3600))
|
||||
if !got.Equal(want) {
|
||||
t.Fatalf("got %v want %v", got, want)
|
||||
}
|
||||
t.Logf("parsed reset time: %v", got)
|
||||
}
|
||||
|
||||
// 验证无法解析时返回 false
|
||||
func TestParseResetTimeInvalid(t *testing.T) {
|
||||
if _, ok := parseResetTime("some random error"); ok {
|
||||
t.Fatal("should not parse random string")
|
||||
}
|
||||
}
|
||||
|
||||
// 验证限流识别(429 状态码 / code 6004 / 频率限制关键词)
|
||||
func TestIsRateLimited(t *testing.T) {
|
||||
cases := []struct {
|
||||
status int
|
||||
body string
|
||||
want bool
|
||||
}{
|
||||
{429, "{}", true},
|
||||
{400, `{"code":6004,"msg":"x"}`, true},
|
||||
{400, "频率限制", true},
|
||||
{400, "frequency limit", true},
|
||||
{400, "some other error", false},
|
||||
{500, "{}", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := isRateLimited(c.status, c.body); got != c.want {
|
||||
t.Errorf("isRateLimited(%d, %q) = %v, want %v", c.status, c.body, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 验证轮询选择:两个账号交替返回
|
||||
func TestNextAccountRoundRobin(t *testing.T) {
|
||||
accountMu.Lock()
|
||||
accounts = []*Account{
|
||||
{Path: "a.json", Auth: &StoredAuth{}},
|
||||
{Path: "b.json", Auth: &StoredAuth{}},
|
||||
}
|
||||
rrIndex = 0
|
||||
accountMu.Unlock()
|
||||
|
||||
a1, _ := nextAccount()
|
||||
a2, _ := nextAccount()
|
||||
a3, _ := nextAccount()
|
||||
if a1.Path != "a.json" || a2.Path != "b.json" || a3.Path != "a.json" {
|
||||
t.Fatalf("round robin failed: %s %s %s", a1.Path, a2.Path, a3.Path)
|
||||
}
|
||||
t.Logf("round-robin order: %s %s %s", a1.Path, a2.Path, a3.Path)
|
||||
}
|
||||
|
||||
// 验证冷却屏蔽:冷却中的账号被跳过,由另一账号代偿
|
||||
func TestNextAccountSkipsCooldown(t *testing.T) {
|
||||
accountMu.Lock()
|
||||
accounts = []*Account{
|
||||
{Path: "a.json", Auth: &StoredAuth{}, CooldownUntil: time.Now().Add(time.Hour)},
|
||||
{Path: "b.json", Auth: &StoredAuth{}},
|
||||
}
|
||||
rrIndex = 0
|
||||
accountMu.Unlock()
|
||||
|
||||
a, _ := nextAccount()
|
||||
if a.Path != "b.json" {
|
||||
t.Fatalf("expected b.json (only non-cooldown), got %s", a.Path)
|
||||
}
|
||||
t.Logf("cooldown skip works, selected %s", a.Path)
|
||||
}
|
||||
|
||||
// 验证全部冷却时返回错误
|
||||
func TestNextAccountAllCooldown(t *testing.T) {
|
||||
accountMu.Lock()
|
||||
accounts = []*Account{
|
||||
{Path: "a.json", Auth: &StoredAuth{}, CooldownUntil: time.Now().Add(time.Hour)},
|
||||
{Path: "b.json", Auth: &StoredAuth{}, CooldownUntil: time.Now().Add(2 * time.Hour)},
|
||||
}
|
||||
rrIndex = 0
|
||||
accountMu.Unlock()
|
||||
|
||||
_, err := nextAccount()
|
||||
if err == nil {
|
||||
t.Fatal("expected error when all accounts in cooldown")
|
||||
}
|
||||
t.Logf("all-cooldown error: %v", err)
|
||||
}
|
||||
|
||||
// 验证冷却到期后自动恢复
|
||||
func TestNextAccountCooldownExpiry(t *testing.T) {
|
||||
accountMu.Lock()
|
||||
accounts = []*Account{
|
||||
{Path: "a.json", Auth: &StoredAuth{}, CooldownUntil: time.Now().Add(-time.Minute)},
|
||||
{Path: "b.json", Auth: &StoredAuth{}, CooldownUntil: time.Now().Add(time.Hour)},
|
||||
}
|
||||
rrIndex = 0
|
||||
accountMu.Unlock()
|
||||
|
||||
a, _ := nextAccount()
|
||||
if a.Path != "a.json" {
|
||||
t.Fatalf("expected a.json (cooldown expired), got %s", a.Path)
|
||||
}
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user