From 7a65d1eaa24faea89d332ced23a1996cc8ea2416 Mon Sep 17 00:00:00 2001 From: jacob-sheng Date: Sat, 21 Mar 2026 09:55:53 +0800 Subject: [PATCH 1/3] fix: allow Docker builds without BUILD_VERSION --- DEPLOY.en.md | 1 + DEPLOY.md | 1 + Dockerfile | 2 +- README.MD | 2 ++ README.en.md | 2 ++ zeabur.yaml | 1 + 6 files changed, 8 insertions(+), 1 deletion(-) diff --git a/DEPLOY.en.md b/DEPLOY.en.md index 2304cfd..8f5ae27 100644 --- a/DEPLOY.en.md +++ b/DEPLOY.en.md @@ -181,6 +181,7 @@ Notes: - **Port**: DS2API listens on `5001` by default; the template sets `PORT=5001`. - **Persistent config**: the template mounts `/data` and sets `DS2API_CONFIG_PATH=/data/config.json`. After importing config in Admin UI, it will be written and persisted to this path. +- **Build version**: Zeabur / regular `docker build` does not require `BUILD_VERSION` by default. The image prefers that build arg when provided, and automatically falls back to the repo-root `VERSION` file when it is absent. - **First login**: after deployment, open `/admin` and login with `DS2API_ADMIN_KEY` shown in Zeabur env/template instructions (recommended: rotate to a strong secret after first login). --- diff --git a/DEPLOY.md b/DEPLOY.md index 93364ef..1c71b91 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -181,6 +181,7 @@ healthcheck: - **端口**:服务默认监听 `5001`,模板会固定设置 `PORT=5001`。 - **配置持久化**:模板挂载卷 `/data`,并设置 `DS2API_CONFIG_PATH=/data/config.json`;在管理台导入配置后,会写入并持久化到该路径。 +- **构建版本号**:Zeabur / 普通 `docker build` 默认不需要传 `BUILD_VERSION`;镜像会优先使用该构建参数,未提供时自动回退到仓库根目录的 `VERSION` 文件。 - **首次登录**:部署完成后访问 `/admin`,使用 Zeabur 环境变量/模板指引中的 `DS2API_ADMIN_KEY` 登录(建议首次登录后自行更换为强密码)。 --- diff --git a/Dockerfile b/Dockerfile index 19c24ea..6aa1974 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,7 +17,7 @@ COPY . . RUN set -eux; \ GOOS="${TARGETOS:-$(go env GOOS)}"; \ GOARCH="${TARGETARCH:-$(go env GOARCH)}"; \ - BUILD_VERSION_RESOLVED="${BUILD_VERSION}"; \ + BUILD_VERSION_RESOLVED="${BUILD_VERSION:-}"; \ if [ -z "${BUILD_VERSION_RESOLVED}" ] && [ -f VERSION ]; then BUILD_VERSION_RESOLVED="$(cat VERSION | tr -d "[:space:]")"; fi; \ CGO_ENABLED=0 GOOS="${GOOS}" GOARCH="${GOARCH}" go build -ldflags="-s -w -X ds2api/internal/version.BuildVersion=${BUILD_VERSION_RESOLVED}" -o /out/ds2api ./cmd/ds2api diff --git a/README.MD b/README.MD index 9946515..1544ca8 100644 --- a/README.MD +++ b/README.MD @@ -178,6 +178,8 @@ docker-compose logs -f 2. 部署完成后访问 `/admin`,使用 Zeabur 环境变量/模板指引中的 `DS2API_ADMIN_KEY` 登录。 3. 在管理台导入/编辑配置(会写入并持久化到 `/data/config.json`)。 +说明:Zeabur 使用仓库内 `Dockerfile` 直接构建时,不需要额外传入 `BUILD_VERSION`;镜像会优先读取该构建参数,未提供时自动回退到仓库根目录的 `VERSION` 文件。 + ### 方式三:Vercel 部署 1. Fork 仓库到自己的 GitHub diff --git a/README.en.md b/README.en.md index 8ab7b32..8b4ddef 100644 --- a/README.en.md +++ b/README.en.md @@ -178,6 +178,8 @@ Rebuild after updates: `docker-compose up -d --build` 2. After deployment, open `/admin` and login with `DS2API_ADMIN_KEY` shown in Zeabur env/template instructions. 3. Import / edit config in Admin UI (it will be written and persisted to `/data/config.json`). +Note: when Zeabur builds directly from the repo `Dockerfile`, you do not need to pass `BUILD_VERSION`. The image prefers that build arg when provided, and automatically falls back to the repo-root `VERSION` file when it is absent. + ### Option 3: Vercel 1. Fork this repo to your GitHub account diff --git a/zeabur.yaml b/zeabur.yaml index 8d36cb4..c1ba6bf 100644 --- a/zeabur.yaml +++ b/zeabur.yaml @@ -16,6 +16,7 @@ spec: - Admin panel: `/admin` - Health check: `/healthz` - Config is persisted at `/data/config.json` (mounted volume) + - `BUILD_VERSION` is optional; when omitted, Docker build falls back to the repo `VERSION` file automatically ## First-time setup 1. Open your service URL, then visit `/admin` From ca08bb66b91e490f5d67b458026b94a3806fa23f Mon Sep 17 00:00:00 2001 From: "CJACK." Date: Sat, 21 Mar 2026 14:27:12 +0800 Subject: [PATCH 2/3] Add HTTP token-runtime coverage and fix gate tests for tokenless config --- internal/account/pool_test.go | 12 +- .../admin/handler_accounts_identifier_test.go | 61 +--------- internal/admin/handler_accounts_testing.go | 42 +++---- .../admin/handler_accounts_testing_test.go | 6 +- internal/admin/handler_config_import.go | 2 + internal/admin/handler_config_write.go | 3 - internal/admin/helpers.go | 1 - internal/admin/helpers_edge_test.go | 4 +- internal/admin/token_runtime_http_test.go | 109 ++++++++++++++++++ internal/auth/request_test.go | 2 +- internal/config/account.go | 15 +-- internal/config/config.go | 13 ++- internal/config/config_test.go | 43 ++----- internal/config/store.go | 14 ++- webui/src/locales/en.json | 14 +-- webui/src/locales/zh.json | 14 +-- 16 files changed, 192 insertions(+), 163 deletions(-) create mode 100644 internal/admin/token_runtime_http_test.go diff --git a/internal/account/pool_test.go b/internal/account/pool_test.go index 59ea32b..f19f243 100644 --- a/internal/account/pool_test.go +++ b/internal/account/pool_test.go @@ -194,7 +194,7 @@ func TestPoolAccountConcurrencyAliasEnv(t *testing.T) { } } -func TestPoolSupportsTokenOnlyAccount(t *testing.T) { +func TestPoolSkipsTokenOnlyAccount(t *testing.T) { t.Setenv("DS2API_ACCOUNT_MAX_INFLIGHT", "1") t.Setenv("DS2API_CONFIG_JSON", `{ "keys":["k1"], @@ -206,16 +206,12 @@ func TestPoolSupportsTokenOnlyAccount(t *testing.T) { if got, ok := status["total"].(int); !ok || got != 1 { t.Fatalf("unexpected total in pool status: %#v", status["total"]) } - if got, ok := status["available"].(int); !ok || got != 1 { + if got, ok := status["available"].(int); !ok || got != 0 { t.Fatalf("unexpected available in pool status: %#v", status["available"]) } - acc, ok := pool.Acquire("", nil) - if !ok { - t.Fatalf("expected acquire success for token-only account") - } - if acc.Token != "token-only-account" { - t.Fatalf("unexpected token on acquired account: %q", acc.Token) + if _, ok := pool.Acquire("", nil); ok { + t.Fatalf("expected acquire to fail for token-only account") } } diff --git a/internal/admin/handler_accounts_identifier_test.go b/internal/admin/handler_accounts_identifier_test.go index b6f63ca..7cac96b 100644 --- a/internal/admin/handler_accounts_identifier_test.go +++ b/internal/admin/handler_accounts_identifier_test.go @@ -6,7 +6,6 @@ import ( "net/http" "net/http/httptest" "net/url" - "strings" "testing" "github.com/go-chi/chi/v5" @@ -26,9 +25,9 @@ func newAdminTestHandler(t *testing.T, raw string) *Handler { } } -func TestListAccountsIncludesTokenOnlyIdentifier(t *testing.T) { +func TestListAccountsUsesEmailIdentifier(t *testing.T) { h := newAdminTestHandler(t, `{ - "accounts":[{"token":"token-only-account"}] + "accounts":[{"email":"u@example.com","password":"pwd"}] }`) req := httptest.NewRequest(http.MethodGet, "/admin/accounts?page=1&page_size=10", nil) @@ -49,38 +48,8 @@ func TestListAccountsIncludesTokenOnlyIdentifier(t *testing.T) { } first, _ := items[0].(map[string]any) identifier, _ := first["identifier"].(string) - if identifier == "" { - t.Fatalf("expected non-empty identifier: %#v", first) - } - if !strings.HasPrefix(identifier, "token:") { - t.Fatalf("expected token synthetic identifier, got %q", identifier) - } -} - -func TestDeleteAccountSupportsTokenOnlyIdentifier(t *testing.T) { - h := newAdminTestHandler(t, `{ - "accounts":[{"token":"token-only-account"}] - }`) - accounts := h.Store.Accounts() - if len(accounts) != 1 { - t.Fatalf("expected 1 account, got %d", len(accounts)) - } - id := accounts[0].Identifier() - if id == "" { - t.Fatal("expected token-only synthetic identifier") - } - - r := chi.NewRouter() - r.Delete("/admin/accounts/{identifier}", h.deleteAccount) - req := httptest.NewRequest(http.MethodDelete, "/admin/accounts/"+url.PathEscape(id), nil) - rec := httptest.NewRecorder() - r.ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String()) - } - if got := len(h.Store.Accounts()); got != 0 { - t.Fatalf("expected account removed, remaining=%d", got) + if identifier != "u@example.com" { + t.Fatalf("expected email identifier, got %q", identifier) } } @@ -142,11 +111,10 @@ func TestAddAccountRejectsCanonicalMobileDuplicate(t *testing.T) { } } -func TestFindAccountByIdentifierSupportsMobileAndTokenOnly(t *testing.T) { +func TestFindAccountByIdentifierSupportsMobile(t *testing.T) { h := newAdminTestHandler(t, `{ "accounts":[ - {"email":"u@example.com","mobile":"13800138000","password":"pwd"}, - {"token":"token-only-account"} + {"email":"u@example.com","mobile":"13800138000","password":"pwd"} ] }`) @@ -165,21 +133,4 @@ func TestFindAccountByIdentifierSupportsMobileAndTokenOnly(t *testing.T) { t.Fatalf("unexpected account by +86 mobile: %#v", accByMobileWithCountryCode) } - tokenOnlyID := "" - for _, acc := range h.Store.Accounts() { - if strings.TrimSpace(acc.Email) == "" && strings.TrimSpace(acc.Mobile) == "" { - tokenOnlyID = acc.Identifier() - break - } - } - if tokenOnlyID == "" { - t.Fatal("expected token-only account identifier") - } - accByTokenOnly, ok := findAccountByIdentifier(h.Store, tokenOnlyID) - if !ok { - t.Fatalf("expected find by token-only id=%q", tokenOnlyID) - } - if accByTokenOnly.Token != "token-only-account" { - t.Fatalf("unexpected token-only account: %#v", accByTokenOnly) - } } diff --git a/internal/admin/handler_accounts_testing.go b/internal/admin/handler_accounts_testing.go index e528de0..2a8a447 100644 --- a/internal/admin/handler_accounts_testing.go +++ b/internal/admin/handler_accounts_testing.go @@ -105,18 +105,14 @@ func (h *Handler) testAccount(ctx context.Context, acc config.Account, model, me } _ = h.Store.UpdateAccountTestStatus(identifier, status) }() - token := strings.TrimSpace(acc.Token) - if token == "" { - newToken, err := h.DS.Login(ctx, acc) - if err != nil { - result["message"] = "登录失败: " + err.Error() - return result - } - token = newToken - if err := h.Store.UpdateAccountToken(acc.Identifier(), token); err != nil { - result["message"] = "登录成功但写入配置失败: " + err.Error() - return result - } + token, err := h.DS.Login(ctx, acc) + if err != nil { + result["message"] = "登录失败: " + err.Error() + return result + } + if err := h.Store.UpdateAccountToken(acc.Identifier(), token); err != nil { + result["message"] = "登录成功但写入运行时 token 失败: " + err.Error() + return result } authCtx := &authn.RequestAuth{UseConfigToken: false, DeepSeekToken: token} sessionID, err := h.DS.CreateSession(ctx, authCtx, 1) @@ -129,7 +125,7 @@ func (h *Handler) testAccount(ctx context.Context, acc config.Account, model, me token = newToken authCtx.DeepSeekToken = token if err := h.Store.UpdateAccountToken(acc.Identifier(), token); err != nil { - result["message"] = "刷新 token 成功但写入配置失败: " + err.Error() + result["message"] = "刷新 token 成功但写入运行时 token 失败: " + err.Error() return result } sessionID, err = h.DS.CreateSession(ctx, authCtx, 1) @@ -147,7 +143,7 @@ func (h *Handler) testAccount(ctx context.Context, acc config.Account, model, me if strings.TrimSpace(message) == "" { result["success"] = true - result["message"] = "API 测试成功(仅会话创建)" + result["message"] = "Token 刷新成功(登录与会话创建成功)" result["response_time"] = int(time.Since(start).Milliseconds()) return result } @@ -246,20 +242,16 @@ func (h *Handler) deleteAllSessions(w http.ResponseWriter, r *http.Request) { return } - // 获取 token - token := strings.TrimSpace(acc.Token) - if token == "" { - newToken, err := h.DS.Login(r.Context(), acc) - if err != nil { - writeJSON(w, http.StatusOK, map[string]any{"success": false, "message": "登录失败: " + err.Error()}) - return - } - token = newToken - _ = h.Store.UpdateAccountToken(acc.Identifier(), token) + // 每次先登录刷新一次 token,避免使用过期 token。 + token, err := h.DS.Login(r.Context(), acc) + if err != nil { + writeJSON(w, http.StatusOK, map[string]any{"success": false, "message": "登录失败: " + err.Error()}) + return } + _ = h.Store.UpdateAccountToken(acc.Identifier(), token) // 删除所有会话 - err := h.DS.DeleteAllSessionsForToken(r.Context(), token) + err = h.DS.DeleteAllSessionsForToken(r.Context(), token) if err != nil { // token 可能过期,尝试重新登录并重试一次 newToken, loginErr := h.DS.Login(r.Context(), acc) diff --git a/internal/admin/handler_accounts_testing_test.go b/internal/admin/handler_accounts_testing_test.go index e80eefe..b07afaa 100644 --- a/internal/admin/handler_accounts_testing_test.go +++ b/internal/admin/handler_accounts_testing_test.go @@ -77,7 +77,7 @@ func TestTestAccount_BatchModeOnlyCreatesSession(t *testing.T) { t.Fatalf("expected success=true, got %#v", result) } msg, _ := result["message"].(string) - if !strings.Contains(msg, "仅会话创建") { + if !strings.Contains(msg, "Token 刷新成功") { t.Fatalf("expected session-only success message, got %q", msg) } if ds.loginCalls != 1 || ds.createSessionCalls != 1 { @@ -118,8 +118,8 @@ func TestDeleteAllSessions_RetryWithReloginOnDeleteFailure(t *testing.T) { if ok, _ := resp["success"].(bool); !ok { t.Fatalf("expected success response, got %#v", resp) } - if ds.loginCalls != 1 { - t.Fatalf("expected relogin once, got %d", ds.loginCalls) + if ds.loginCalls != 2 { + t.Fatalf("expected initial login plus relogin, got %d", ds.loginCalls) } if ds.deleteAllSessionsCalls != 2 { t.Fatalf("expected delete called twice, got %d", ds.deleteAllSessionsCalls) diff --git a/internal/admin/handler_config_import.go b/internal/admin/handler_config_import.go index 2b88d45..b9dd1f6 100644 --- a/internal/admin/handler_config_import.go +++ b/internal/admin/handler_config_import.go @@ -43,6 +43,7 @@ func (h *Handler) configImport(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()}) return } + incoming.ClearAccountTokens() importedKeys, importedAccounts := 0, 0 err = h.Store.Update(func(c *config.Config) error { @@ -180,6 +181,7 @@ func (h *Handler) configImport(w http.ResponseWriter, r *http.Request) { func (h *Handler) computeSyncHash() string { snap := h.Store.Snapshot().Clone() + snap.ClearAccountTokens() snap.VercelSyncHash = "" snap.VercelSyncTime = 0 b, _ := json.Marshal(snap) diff --git a/internal/admin/handler_config_write.go b/internal/admin/handler_config_write.go index e09edfe..bfc6296 100644 --- a/internal/admin/handler_config_write.go +++ b/internal/admin/handler_config_write.go @@ -50,9 +50,6 @@ func (h *Handler) updateConfig(w http.ResponseWriter, r *http.Request) { if strings.TrimSpace(acc.Password) == "" { acc.Password = prev.Password } - if strings.TrimSpace(acc.Token) == "" { - acc.Token = prev.Token - } } seen[key] = struct{}{} accounts = append(accounts, acc) diff --git a/internal/admin/helpers.go b/internal/admin/helpers.go index af27676..6d5a292 100644 --- a/internal/admin/helpers.go +++ b/internal/admin/helpers.go @@ -65,7 +65,6 @@ func toAccount(m map[string]any) config.Account { Email: email, Mobile: mobile, Password: fieldString(m, "password"), - Token: fieldString(m, "token"), } } diff --git a/internal/admin/helpers_edge_test.go b/internal/admin/helpers_edge_test.go index 0b2a0ab..17bb3d7 100644 --- a/internal/admin/helpers_edge_test.go +++ b/internal/admin/helpers_edge_test.go @@ -188,8 +188,8 @@ func TestToAccountAllFields(t *testing.T) { if acc.Password != "secret" { t.Fatalf("unexpected password: %q", acc.Password) } - if acc.Token != "tok123" { - t.Fatalf("unexpected token: %q", acc.Token) + if acc.Token != "" { + t.Fatalf("expected token to be ignored, got %q", acc.Token) } } diff --git a/internal/admin/token_runtime_http_test.go b/internal/admin/token_runtime_http_test.go new file mode 100644 index 0000000..e23c1aa --- /dev/null +++ b/internal/admin/token_runtime_http_test.go @@ -0,0 +1,109 @@ +package admin + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-chi/chi/v5" + + "ds2api/internal/account" + "ds2api/internal/config" +) + +func newHTTPAdminHarness(t *testing.T, rawConfig string, ds DeepSeekCaller) http.Handler { + t.Helper() + t.Setenv("DS2API_CONFIG_JSON", rawConfig) + t.Setenv("CONFIG_JSON", "") + store := config.LoadStore() + h := &Handler{ + Store: store, + Pool: account.NewPool(store), + DS: ds, + } + r := chi.NewRouter() + RegisterRoutes(r, h) + return r +} + +func adminReq(method, path string, body []byte) *http.Request { + req := httptest.NewRequest(method, path, bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer admin") + req.Header.Set("Content-Type", "application/json") + return req +} + +func TestConfigImportIgnoresTokenFieldInPayload(t *testing.T) { + ds := &testingDSMock{} + router := newHTTPAdminHarness(t, `{"accounts":[]}`, ds) + + payload := []byte(`{ + "mode":"replace", + "config":{ + "accounts":[{"email":"u@example.com","password":"pwd","token":"expired-token"}] + } + }`) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, adminReq(http.MethodPost, "/config/import", payload)) + if rec.Code != http.StatusOK { + t.Fatalf("import status=%d body=%s", rec.Code, rec.Body.String()) + } + + readRec := httptest.NewRecorder() + router.ServeHTTP(readRec, adminReq(http.MethodGet, "/config", nil)) + if readRec.Code != http.StatusOK { + t.Fatalf("get config status=%d body=%s", readRec.Code, readRec.Body.String()) + } + var data map[string]any + if err := json.Unmarshal(readRec.Body.Bytes(), &data); err != nil { + t.Fatalf("decode config response: %v", err) + } + accounts, _ := data["accounts"].([]any) + if len(accounts) != 1 { + t.Fatalf("expected one account, got %d", len(accounts)) + } + accountMap, _ := accounts[0].(map[string]any) + if hasToken, _ := accountMap["has_token"].(bool); hasToken { + t.Fatalf("expected imported token to be ignored, account=%#v", accountMap) + } +} + +func TestAccountTestRefreshesRuntimeTokenButExportOmitsToken(t *testing.T) { + ds := &testingDSMock{} + router := newHTTPAdminHarness(t, `{ + "accounts":[{"email":"batch@example.com","password":"pwd","token":"stale-token"}] + }`, ds) + + rec := httptest.NewRecorder() + router.ServeHTTP(rec, adminReq(http.MethodPost, "/accounts/test", []byte(`{"identifier":"batch@example.com"}`))) + if rec.Code != http.StatusOK { + t.Fatalf("test account status=%d body=%s", rec.Code, rec.Body.String()) + } + var testResp map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &testResp); err != nil { + t.Fatalf("decode test response: %v", err) + } + if ok, _ := testResp["success"].(bool); !ok { + t.Fatalf("expected test success, got %#v", testResp) + } + if ds.loginCalls < 1 { + t.Fatalf("expected login to be called at least once, got %d", ds.loginCalls) + } + + exportRec := httptest.NewRecorder() + router.ServeHTTP(exportRec, adminReq(http.MethodGet, "/config/export", nil)) + if exportRec.Code != http.StatusOK { + t.Fatalf("export status=%d body=%s", exportRec.Code, exportRec.Body.String()) + } + var exportResp map[string]any + if err := json.Unmarshal(exportRec.Body.Bytes(), &exportResp); err != nil { + t.Fatalf("decode export response: %v", err) + } + exportJSON, _ := exportResp["json"].(string) + if strings.Contains(exportJSON, `"token"`) { + t.Fatalf("expected export json to omit tokens, got %s", exportJSON) + } +} diff --git a/internal/auth/request_test.go b/internal/auth/request_test.go index 2f70e3f..f8cb40f 100644 --- a/internal/auth/request_test.go +++ b/internal/auth/request_test.go @@ -58,7 +58,7 @@ func TestDetermineWithXAPIKeyManagedKeyAcquiresAccount(t *testing.T) { if auth.AccountID != "acc@example.com" { t.Fatalf("unexpected account id: %q", auth.AccountID) } - if auth.DeepSeekToken != "account-token" { + if auth.DeepSeekToken != "fresh-token" { t.Fatalf("unexpected account token: %q", auth.DeepSeekToken) } if auth.CallerID == "" { diff --git a/internal/config/account.go b/internal/config/account.go index 3d6fa7d..bebb70e 100644 --- a/internal/config/account.go +++ b/internal/config/account.go @@ -1,10 +1,6 @@ package config -import ( - "crypto/sha256" - "encoding/hex" - "strings" -) +import "strings" func (a Account) Identifier() string { if strings.TrimSpace(a.Email) != "" { @@ -13,12 +9,5 @@ func (a Account) Identifier() string { if mobile := NormalizeMobileForStorage(a.Mobile); mobile != "" { return mobile } - // Backward compatibility: old configs may contain token-only accounts. - // Use a stable non-sensitive synthetic id so they can still join the pool. - token := strings.TrimSpace(a.Token) - if token == "" { - return "" - } - sum := sha256.Sum256([]byte(token)) - return "token:" + hex.EncodeToString(sum[:8]) + return "" } diff --git a/internal/config/config.go b/internal/config/config.go index 0d541ed..7264e9d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -12,8 +12,8 @@ type Config struct { Toolcall ToolcallConfig `json:"toolcall,omitempty"` Responses ResponsesConfig `json:"responses,omitempty"` Embeddings EmbeddingsConfig `json:"embeddings,omitempty"` - AutoDelete AutoDeleteConfig `json:"auto_delete"` - VercelSyncHash string `json:"_vercel_sync_hash,omitempty"` + AutoDelete AutoDeleteConfig `json:"auto_delete"` + VercelSyncHash string `json:"_vercel_sync_hash,omitempty"` VercelSyncTime int64 `json:"_vercel_sync_time,omitempty"` AdditionalFields map[string]any `json:"-"` } @@ -26,6 +26,15 @@ type Account struct { TestStatus string `json:"test_status,omitempty"` } +func (c *Config) ClearAccountTokens() { + if c == nil { + return + } + for i := range c.Accounts { + c.Accounts[i].Token = "" + } +} + type CompatConfig struct { WideInputStrictOutput *bool `json:"wide_input_strict_output,omitempty"` } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index a409fd7..5a0f9b7 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -2,25 +2,21 @@ package config import ( "encoding/base64" - "strings" "testing" ) -func TestAccountIdentifierFallsBackToTokenHash(t *testing.T) { +func TestAccountIdentifierRequiresEmailOrMobile(t *testing.T) { acc := Account{Token: "example-token-value"} id := acc.Identifier() - if !strings.HasPrefix(id, "token:") { - t.Fatalf("expected token-prefixed identifier, got %q", id) - } - if len(id) != len("token:")+16 { - t.Fatalf("unexpected identifier length: %d (%q)", len(id), id) + if id != "" { + t.Fatalf("expected empty identifier when only token is present, got %q", id) } } -func TestStoreFindAccountWithTokenOnlyIdentifier(t *testing.T) { +func TestLoadStoreClearsTokensFromConfigInput(t *testing.T) { t.Setenv("DS2API_CONFIG_JSON", `{ "keys":["k1"], - "accounts":[{"token":"token-only-account"}] + "accounts":[{"email":"u@example.com","password":"p","token":"token-only-account"}] }`) store := LoadStore() @@ -28,22 +24,14 @@ func TestStoreFindAccountWithTokenOnlyIdentifier(t *testing.T) { if len(accounts) != 1 { t.Fatalf("expected 1 account, got %d", len(accounts)) } - id := accounts[0].Identifier() - if id == "" { - t.Fatalf("expected synthetic identifier for token-only account") - } - found, ok := store.FindAccount(id) - if !ok { - t.Fatalf("expected FindAccount to locate token-only account by synthetic id") - } - if found.Token != "token-only-account" { - t.Fatalf("unexpected token value: %q", found.Token) + if accounts[0].Token != "" { + t.Fatalf("expected token to be cleared after loading, got %q", accounts[0].Token) } } -func TestStoreUpdateAccountTokenKeepsOldAndNewIdentifierResolvable(t *testing.T) { +func TestStoreUpdateAccountTokenKeepsIdentifierResolvable(t *testing.T) { t.Setenv("DS2API_CONFIG_JSON", `{ - "accounts":[{"token":"old-token"}] + "accounts":[{"email":"user@example.com","password":"p"}] }`) store := LoadStore() @@ -52,23 +40,12 @@ func TestStoreUpdateAccountTokenKeepsOldAndNewIdentifierResolvable(t *testing.T) t.Fatalf("expected 1 account, got %d", len(before)) } oldID := before[0].Identifier() - if oldID == "" { - t.Fatal("expected old identifier") - } if err := store.UpdateAccountToken(oldID, "new-token"); err != nil { t.Fatalf("update token failed: %v", err) } - after := store.Accounts() - newID := after[0].Identifier() - if newID == "" || newID == oldID { - t.Fatalf("expected changed identifier, old=%q new=%q", oldID, newID) - } - if got, ok := store.FindAccount(newID); !ok || got.Token != "new-token" { - t.Fatalf("expected find by new identifier") - } if got, ok := store.FindAccount(oldID); !ok || got.Token != "new-token" { - t.Fatalf("expected find by old identifier alias") + t.Fatalf("expected find by stable account identifier") } } diff --git a/internal/config/store.go b/internal/config/store.go index 7a09cdc..8b734d0 100644 --- a/internal/config/store.go +++ b/internal/config/store.go @@ -39,6 +39,7 @@ func loadConfig() (Config, bool, error) { } if rawCfg != "" { cfg, err := parseConfigString(rawCfg) + cfg.ClearAccountTokens() return cfg, true, err } @@ -55,6 +56,7 @@ func loadConfig() (Config, bool, error) { if err := json.Unmarshal(content, &cfg); err != nil { return Config{}, false, err } + cfg.ClearAccountTokens() if IsVercel() { // Vercel filesystem is ephemeral/read-only for runtime writes; avoid save errors. return cfg, true, nil @@ -161,7 +163,9 @@ func (s *Store) Save() error { Logger.Info("[save_config] source from env, skip write") return nil } - b, err := json.MarshalIndent(s.cfg, "", " ") + persistCfg := s.cfg.Clone() + persistCfg.ClearAccountTokens() + b, err := json.MarshalIndent(persistCfg, "", " ") if err != nil { return err } @@ -173,7 +177,9 @@ func (s *Store) saveLocked() error { Logger.Info("[save_config] source from env, skip write") return nil } - b, err := json.MarshalIndent(s.cfg, "", " ") + persistCfg := s.cfg.Clone() + persistCfg.ClearAccountTokens() + b, err := json.MarshalIndent(persistCfg, "", " ") if err != nil { return err } @@ -197,7 +203,9 @@ func (s *Store) SetVercelSync(hash string, ts int64) error { func (s *Store) ExportJSONAndBase64() (string, string, error) { s.mu.RLock() defer s.mu.RUnlock() - b, err := json.Marshal(s.cfg) + exportCfg := s.cfg.Clone() + exportCfg.ClearAccountTokens() + b, err := json.Marshal(exportCfg) if err != nil { return "", "", err } diff --git a/webui/src/locales/en.json b/webui/src/locales/en.json index 35eea1f..2f0adf0 100644 --- a/webui/src/locales/en.json +++ b/webui/src/locales/en.json @@ -49,8 +49,8 @@ "delete": "Delete", "copy": "Copy", "generate": "Generate", - "test": "Test", - "testing": "Testing...", + "test": "Refresh token", + "testing": "Refreshing...", "loading": "Loading..." }, "messages": { @@ -93,8 +93,8 @@ "deleteKeyConfirm": "Are you sure you want to delete this API key?", "deleteAccountConfirm": "Are you sure you want to delete this account?", "invalidIdentifier": "Invalid account identifier. Operation aborted.", - "testAllConfirm": "Test API connectivity for all accounts?", - "testAllCompleted": "Completed: {success}/{total} available", + "testAllConfirm": "Refresh all account tokens and verify login?", + "testAllCompleted": "Completed: {success}/{total} refreshed", "testFailed": "Test failed: {error}", "available": "Available", "inUse": "In use", @@ -110,9 +110,9 @@ "noApiKeys": "No API keys found.", "accountsTitle": "DeepSeek Accounts", "accountsDesc": "Manage the DeepSeek account pool", - "testAll": "Test all", + "testAll": "Refresh all tokens", "addAccount": "Add account", - "testingAllAccounts": "Testing all accounts...", + "testingAllAccounts": "Refreshing tokens for all accounts...", "sessionActive": "Session active", "reauthRequired": "Re-auth required", "testStatusFailed": "Last test failed", @@ -150,7 +150,7 @@ "missingApiKey": "Please provide an API key.", "requestFailed": "Request failed.", "networkError": "Network error: {error}", - "testSuccess": "{account}: Test successful ({time}ms)", + "testSuccess": "{account}: Token refresh successful ({time}ms)", "config": "Configuration", "modelLabel": "Model", "streamMode": "Streaming", diff --git a/webui/src/locales/zh.json b/webui/src/locales/zh.json index 29ebc7f..3a3a6e8 100644 --- a/webui/src/locales/zh.json +++ b/webui/src/locales/zh.json @@ -49,8 +49,8 @@ "delete": "删除", "copy": "复制", "generate": "生成", - "test": "测试", - "testing": "正在测试...", + "test": "刷新 Token", + "testing": "正在刷新...", "loading": "加载中..." }, "messages": { @@ -93,8 +93,8 @@ "deleteKeyConfirm": "确定要删除此 API 密钥吗?", "deleteAccountConfirm": "确定要删除此账号吗?", "invalidIdentifier": "账号标识无效,无法执行操作", - "testAllConfirm": "测试所有账号的 API 连通性?", - "testAllCompleted": "完成:{success}/{total} 可用", + "testAllConfirm": "刷新所有账号 Token 并验证登录?", + "testAllCompleted": "完成:{success}/{total} 刷新成功", "testFailed": "测试失败: {error}", "available": "可用", "inUse": "正在使用", @@ -110,9 +110,9 @@ "noApiKeys": "未找到 API 密钥", "accountsTitle": "DeepSeek 账号", "accountsDesc": "管理 DeepSeek 账号池", - "testAll": "测试全部", + "testAll": "刷新全部 Token", "addAccount": "添加账号", - "testingAllAccounts": "正在测试所有账号...", + "testingAllAccounts": "正在刷新所有账号 Token...", "sessionActive": "已建立会话", "reauthRequired": "需重新登录", "testStatusFailed": "上次测试失败", @@ -150,7 +150,7 @@ "missingApiKey": "请提供 API 密钥", "requestFailed": "请求失败", "networkError": "网络错误: {error}", - "testSuccess": "{account}: 测试成功 ({time}ms)", + "testSuccess": "{account}: Token 刷新成功 ({time}ms)", "config": "配置", "modelLabel": "模型", "streamMode": "流式模式", From ee88a74dcf4f8b1e8f3438ae067dcd820513974a Mon Sep 17 00:00:00 2001 From: "CJACK." Date: Sat, 21 Mar 2026 15:01:16 +0800 Subject: [PATCH 3/3] Drop legacy token-only accounts when loading config --- internal/account/pool_test.go | 4 ++-- internal/config/config.go | 17 +++++++++++++++++ internal/config/config_test.go | 21 +++++++++++++++++++++ internal/config/store.go | 2 ++ 4 files changed, 42 insertions(+), 2 deletions(-) diff --git a/internal/account/pool_test.go b/internal/account/pool_test.go index f19f243..37109ff 100644 --- a/internal/account/pool_test.go +++ b/internal/account/pool_test.go @@ -194,7 +194,7 @@ func TestPoolAccountConcurrencyAliasEnv(t *testing.T) { } } -func TestPoolSkipsTokenOnlyAccount(t *testing.T) { +func TestPoolDropsLegacyTokenOnlyAccountOnLoad(t *testing.T) { t.Setenv("DS2API_ACCOUNT_MAX_INFLIGHT", "1") t.Setenv("DS2API_CONFIG_JSON", `{ "keys":["k1"], @@ -203,7 +203,7 @@ func TestPoolSkipsTokenOnlyAccount(t *testing.T) { pool := NewPool(config.LoadStore()) status := pool.Status() - if got, ok := status["total"].(int); !ok || got != 1 { + if got, ok := status["total"].(int); !ok || got != 0 { t.Fatalf("unexpected total in pool status: %#v", status["total"]) } if got, ok := status["available"].(int); !ok || got != 0 { diff --git a/internal/config/config.go b/internal/config/config.go index 7264e9d..8c50f8e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -35,6 +35,23 @@ func (c *Config) ClearAccountTokens() { } } +// DropInvalidAccounts removes accounts that cannot be addressed by admin APIs +// (no email and no normalizable mobile). This prevents legacy token-only +// records from becoming orphaned empty entries after token stripping. +func (c *Config) DropInvalidAccounts() { + if c == nil || len(c.Accounts) == 0 { + return + } + kept := make([]Account, 0, len(c.Accounts)) + for _, acc := range c.Accounts { + if acc.Identifier() == "" { + continue + } + kept = append(kept, acc) + } + c.Accounts = kept +} + type CompatConfig struct { WideInputStrictOutput *bool `json:"wide_input_strict_output,omitempty"` } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 5a0f9b7..b87b118 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -29,6 +29,27 @@ func TestLoadStoreClearsTokensFromConfigInput(t *testing.T) { } } +func TestLoadStoreDropsLegacyTokenOnlyAccounts(t *testing.T) { + t.Setenv("DS2API_CONFIG_JSON", `{ + "accounts":[ + {"token":"legacy-token-only"}, + {"email":"u@example.com","password":"p","token":"runtime-token"} + ] + }`) + + store := LoadStore() + accounts := store.Accounts() + if len(accounts) != 1 { + t.Fatalf("expected token-only account to be dropped, got %d accounts", len(accounts)) + } + if accounts[0].Identifier() != "u@example.com" { + t.Fatalf("unexpected remaining account: %#v", accounts[0]) + } + if accounts[0].Token != "" { + t.Fatalf("expected persisted token to be cleared, got %q", accounts[0].Token) + } +} + func TestStoreUpdateAccountTokenKeepsIdentifierResolvable(t *testing.T) { t.Setenv("DS2API_CONFIG_JSON", `{ "accounts":[{"email":"user@example.com","password":"p"}] diff --git a/internal/config/store.go b/internal/config/store.go index 8b734d0..10e26f4 100644 --- a/internal/config/store.go +++ b/internal/config/store.go @@ -40,6 +40,7 @@ func loadConfig() (Config, bool, error) { if rawCfg != "" { cfg, err := parseConfigString(rawCfg) cfg.ClearAccountTokens() + cfg.DropInvalidAccounts() return cfg, true, err } @@ -57,6 +58,7 @@ func loadConfig() (Config, bool, error) { return Config{}, false, err } cfg.ClearAccountTokens() + cfg.DropInvalidAccounts() if IsVercel() { // Vercel filesystem is ephemeral/read-only for runtime writes; avoid save errors. return cfg, true, nil