From 0348fa8a22084b2aaa68a1ee16b32904ab9460ee Mon Sep 17 00:00:00 2001 From: CJACK Date: Wed, 18 Feb 2026 20:39:38 +0800 Subject: [PATCH] feat: Enhance account identification to support email, mobile, and token-only synthetic IDs across API, UI, and documentation. --- API.en.md | 6 +- API.md | 6 +- internal/admin/handler_accounts.go | 15 +- .../admin/handler_accounts_identifier_test.go | 138 ++++++++++++++++++ internal/admin/handler_config.go | 1 + internal/admin/helpers.go | 31 ++++ webui/src/components/AccountManager.jsx | 37 +++-- webui/src/components/ApiTester.jsx | 18 ++- webui/src/locales/en.json | 1 + webui/src/locales/zh.json | 1 + 10 files changed, 232 insertions(+), 22 deletions(-) create mode 100644 internal/admin/handler_accounts_identifier_test.go diff --git a/API.en.md b/API.en.md index 1203e12..09149b2 100644 --- a/API.en.md +++ b/API.en.md @@ -439,6 +439,7 @@ Returns sanitized config. "keys": ["k1", "k2"], "accounts": [ { + "identifier": "user@example.com", "email": "user@example.com", "mobile": "", "has_password": true, @@ -499,6 +500,7 @@ Updatable fields: `keys`, `accounts`, `claude_mapping`. { "items": [ { + "identifier": "user@example.com", "email": "user@example.com", "mobile": "", "has_password": true, @@ -523,7 +525,7 @@ Updatable fields: `keys`, `accounts`, `claude_mapping`. ### `DELETE /admin/accounts/{identifier}` -`identifier` is email or mobile. +`identifier` can be email, mobile, or the synthetic id for token-only accounts (`token:`). **Response**: `{"success": true, "total_accounts": 5}` @@ -553,7 +555,7 @@ Updatable fields: `keys`, `accounts`, `claude_mapping`. | Field | Required | Notes | | --- | --- | --- | -| `identifier` | ✅ | email or mobile | +| `identifier` | ✅ | email / mobile / token-only synthetic id | | `model` | ❌ | default `deepseek-chat` | | `message` | ❌ | if empty, only session creation is tested | diff --git a/API.md b/API.md index f57f0a8..02cbf9b 100644 --- a/API.md +++ b/API.md @@ -439,6 +439,7 @@ data: {"type":"message_stop"} "keys": ["k1", "k2"], "accounts": [ { + "identifier": "user@example.com", "email": "user@example.com", "mobile": "", "has_password": true, @@ -499,6 +500,7 @@ data: {"type":"message_stop"} { "items": [ { + "identifier": "user@example.com", "email": "user@example.com", "mobile": "", "has_password": true, @@ -523,7 +525,7 @@ data: {"type":"message_stop"} ### `DELETE /admin/accounts/{identifier}` -`identifier` 为 email 或 mobile。 +`identifier` 可为 email、mobile,或 token-only 账号的合成标识(`token:`)。 **响应**:`{"success": true, "total_accounts": 5}` @@ -553,7 +555,7 @@ data: {"type":"message_stop"} | 字段 | 必填 | 说明 | | --- | --- | --- | -| `identifier` | ✅ | email 或 mobile | +| `identifier` | ✅ | email / mobile / token-only 合成标识 | | `model` | ❌ | 默认 `deepseek-chat` | | `message` | ❌ | 空字符串时仅测试会话创建 | diff --git a/internal/admin/handler_accounts.go b/internal/admin/handler_accounts.go index b95077d..5cb88cc 100644 --- a/internal/admin/handler_accounts.go +++ b/internal/admin/handler_accounts.go @@ -56,7 +56,14 @@ func (h *Handler) listAccounts(w http.ResponseWriter, r *http.Request) { preview = token } } - items = append(items, map[string]any{"email": acc.Email, "mobile": acc.Mobile, "has_password": acc.Password != "", "has_token": token != "", "token_preview": preview}) + items = append(items, map[string]any{ + "identifier": acc.Identifier(), + "email": acc.Email, + "mobile": acc.Mobile, + "has_password": acc.Password != "", + "has_token": token != "", + "token_preview": preview, + }) } writeJSON(w, http.StatusOK, map[string]any{"items": items, "total": total, "page": page, "page_size": pageSize, "total_pages": totalPages}) } @@ -94,7 +101,7 @@ func (h *Handler) deleteAccount(w http.ResponseWriter, r *http.Request) { err := h.Store.Update(func(c *config.Config) error { idx := -1 for i, a := range c.Accounts { - if a.Email == identifier || a.Mobile == identifier { + if accountMatchesIdentifier(a, identifier) { idx = i break } @@ -122,10 +129,10 @@ func (h *Handler) testSingleAccount(w http.ResponseWriter, r *http.Request) { _ = json.NewDecoder(r.Body).Decode(&req) identifier, _ := req["identifier"].(string) if strings.TrimSpace(identifier) == "" { - writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "需要账号标识(email 或 mobile)"}) + writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "需要账号标识(identifier / email / mobile)"}) return } - acc, ok := h.Store.FindAccount(identifier) + acc, ok := findAccountByIdentifier(h.Store, identifier) if !ok { writeJSON(w, http.StatusNotFound, map[string]any{"detail": "账号不存在"}) return diff --git a/internal/admin/handler_accounts_identifier_test.go b/internal/admin/handler_accounts_identifier_test.go new file mode 100644 index 0000000..591d43a --- /dev/null +++ b/internal/admin/handler_accounts_identifier_test.go @@ -0,0 +1,138 @@ +package admin + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/go-chi/chi/v5" + + "ds2api/internal/account" + "ds2api/internal/config" +) + +func newAdminTestHandler(t *testing.T, raw string) *Handler { + t.Helper() + t.Setenv("DS2API_CONFIG_JSON", raw) + t.Setenv("CONFIG_JSON", "") + store := config.LoadStore() + return &Handler{ + Store: store, + Pool: account.NewPool(store), + } +} + +func TestListAccountsIncludesTokenOnlyIdentifier(t *testing.T) { + h := newAdminTestHandler(t, `{ + "accounts":[{"token":"token-only-account"}] + }`) + + req := httptest.NewRequest(http.MethodGet, "/admin/accounts?page=1&page_size=10", nil) + rec := httptest.NewRecorder() + h.listAccounts(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String()) + } + + var payload map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode response failed: %v", err) + } + items, _ := payload["items"].([]any) + if len(items) != 1 { + t.Fatalf("expected 1 item, got %d", len(items)) + } + 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) + } +} + +func TestDeleteAccountSupportsMobileAlias(t *testing.T) { + h := newAdminTestHandler(t, `{ + "accounts":[{"email":"u@example.com","mobile":"13800138000","password":"pwd"}] + }`) + + r := chi.NewRouter() + r.Delete("/admin/accounts/{identifier}", h.deleteAccount) + req := httptest.NewRequest(http.MethodDelete, "/admin/accounts/13800138000", 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) + } +} + +func TestFindAccountByIdentifierSupportsMobileAndTokenOnly(t *testing.T) { + h := newAdminTestHandler(t, `{ + "accounts":[ + {"email":"u@example.com","mobile":"13800138000","password":"pwd"}, + {"token":"token-only-account"} + ] + }`) + + accByMobile, ok := findAccountByIdentifier(h.Store, "13800138000") + if !ok { + t.Fatal("expected find by mobile") + } + if accByMobile.Email != "u@example.com" { + t.Fatalf("unexpected account by mobile: %#v", accByMobile) + } + + 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_config.go b/internal/admin/handler_config.go index 7627602..2b672c3 100644 --- a/internal/admin/handler_config.go +++ b/internal/admin/handler_config.go @@ -37,6 +37,7 @@ func (h *Handler) getConfig(w http.ResponseWriter, _ *http.Request) { } } accounts = append(accounts, map[string]any{ + "identifier": acc.Identifier(), "email": acc.Email, "mobile": acc.Mobile, "has_password": strings.TrimSpace(acc.Password) != "", diff --git a/internal/admin/helpers.go b/internal/admin/helpers.go index fa75b59..d7d1198 100644 --- a/internal/admin/helpers.go +++ b/internal/admin/helpers.go @@ -81,3 +81,34 @@ func statusOr(v int, d int) int { } return v } + +func accountMatchesIdentifier(acc config.Account, identifier string) bool { + id := strings.TrimSpace(identifier) + if id == "" { + return false + } + if strings.TrimSpace(acc.Email) == id { + return true + } + if strings.TrimSpace(acc.Mobile) == id { + return true + } + return acc.Identifier() == id +} + +func findAccountByIdentifier(store *config.Store, identifier string) (config.Account, bool) { + id := strings.TrimSpace(identifier) + if id == "" { + return config.Account{}, false + } + if acc, ok := store.FindAccount(id); ok { + return acc, true + } + accounts := store.Snapshot().Accounts + for _, acc := range accounts { + if accountMatchesIdentifier(acc, id) { + return acc, true + } + } + return config.Account{}, false +} diff --git a/webui/src/components/AccountManager.jsx b/webui/src/components/AccountManager.jsx index 3a8fa76..7ee3b97 100644 --- a/webui/src/components/AccountManager.jsx +++ b/webui/src/components/AccountManager.jsx @@ -39,6 +39,10 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch const [loadingAccounts, setLoadingAccounts] = useState(false) const apiFetch = authFetch || fetch + const resolveAccountIdentifier = (acc) => { + if (!acc || typeof acc !== 'object') return '' + return String(acc.identifier || acc.email || acc.mobile || '').trim() + } const fetchAccounts = async (targetPage = page) => { setLoadingAccounts(true) @@ -147,9 +151,14 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch } const deleteAccount = async (id) => { + const identifier = String(id || '').trim() + if (!identifier) { + onMessage('error', t('accountManager.invalidIdentifier')) + return + } if (!confirm(t('accountManager.deleteAccountConfirm'))) return try { - const res = await apiFetch(`/admin/accounts/${encodeURIComponent(id)}`, { method: 'DELETE' }) + const res = await apiFetch(`/admin/accounts/${encodeURIComponent(identifier)}`, { method: 'DELETE' }) if (res.ok) { onMessage('success', t('messages.deleted')) fetchAccounts() // 刷新当前页 @@ -163,24 +172,29 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch } const testAccount = async (identifier) => { - setTesting(prev => ({ ...prev, [identifier]: true })) + const accountID = String(identifier || '').trim() + if (!accountID) { + onMessage('error', t('accountManager.invalidIdentifier')) + return + } + setTesting(prev => ({ ...prev, [accountID]: true })) try { const res = await apiFetch('/admin/accounts/test', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ identifier }), + body: JSON.stringify({ identifier: accountID }), }) const data = await res.json() const statusMessage = data.success - ? t('apiTester.testSuccess', { account: identifier, time: data.response_time }) - : `${identifier}: ${data.message}` + ? t('apiTester.testSuccess', { account: accountID, time: data.response_time }) + : `${accountID}: ${data.message}` onMessage(data.success ? 'success' : 'error', statusMessage) fetchAccounts() // 刷新当前页 onRefresh() } catch (e) { onMessage('error', t('accountManager.testFailed', { error: e.message })) } finally { - setTesting(prev => ({ ...prev, [identifier]: false })) + setTesting(prev => ({ ...prev, [accountID]: false })) } } @@ -197,7 +211,12 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch for (let i = 0; i < allAccounts.length; i++) { const acc = allAccounts[i] - const id = acc.email || acc.mobile + const id = resolveAccountIdentifier(acc) + if (!id) { + results.push({ id: '-', success: false, message: t('accountManager.invalidIdentifier') }) + setBatchProgress({ current: i + 1, total: allAccounts.length, results: [...results] }) + continue + } try { const res = await apiFetch('/admin/accounts/test', { @@ -387,7 +406,7 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch
{t('actions.loading')}
) : accounts.length > 0 ? ( accounts.map((acc, i) => { - const id = acc.email || acc.mobile + const id = resolveAccountIdentifier(acc) return (
@@ -396,7 +415,7 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch acc.has_token ? "bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]" : "bg-amber-500" )} />
-
{id}
+
{id || '-'}
{acc.has_token ? t('accountManager.sessionActive') : t('accountManager.reauthRequired')} {acc.token_preview && ( diff --git a/webui/src/components/ApiTester.jsx b/webui/src/components/ApiTester.jsx index 7d49982..75af1c0 100644 --- a/webui/src/components/ApiTester.jsx +++ b/webui/src/components/ApiTester.jsx @@ -42,6 +42,10 @@ export default function ApiTester({ config, onMessage, authFetch }) { const apiFetch = authFetch || fetch const accounts = config.accounts || [] + const resolveAccountIdentifier = (acc) => { + if (!acc || typeof acc !== 'object') return '' + return String(acc.identifier || acc.email || acc.mobile || '').trim() + } const configuredKeys = config.keys || [] const trimmedApiKey = apiKey.trim() const defaultKey = configuredKeys[0] || '' @@ -297,11 +301,15 @@ return ( onChange={e => setSelectedAccount(e.target.value)} > - {accounts.map((acc, i) => ( - - ))} + {accounts.map((acc, i) => { + const id = resolveAccountIdentifier(acc) + if (!id) return null + return ( + + ) + })}
diff --git a/webui/src/locales/en.json b/webui/src/locales/en.json index 0daf15f..07610f5 100644 --- a/webui/src/locales/en.json +++ b/webui/src/locales/en.json @@ -86,6 +86,7 @@ "requiredFields": "Password and email/mobile are required.", "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", "testFailed": "Test failed: {error}", diff --git a/webui/src/locales/zh.json b/webui/src/locales/zh.json index b405ee4..d0780dd 100644 --- a/webui/src/locales/zh.json +++ b/webui/src/locales/zh.json @@ -86,6 +86,7 @@ "requiredFields": "需要填写密码以及邮箱或手机号", "deleteKeyConfirm": "确定要删除此 API 密钥吗?", "deleteAccountConfirm": "确定要删除此账号吗?", + "invalidIdentifier": "账号标识无效,无法执行操作", "testAllConfirm": "测试所有账号的 API 连通性?", "testAllCompleted": "完成:{success}/{total} 可用", "testFailed": "测试失败: {error}",