From 8ff923cd7700c4446875ded11bb7f9ea030667e0 Mon Sep 17 00:00:00 2001 From: "CJACK." <155826701+CJackHwang@users.noreply.github.com> Date: Wed, 22 Apr 2026 01:43:20 +0800 Subject: [PATCH] feat(account): add key/account name and remark metadata --- README.MD | 23 +++++- config.example.json | 15 ++++ internal/admin/handler_accounts_crud.go | 4 + internal/admin/handler_config_read.go | 3 + internal/admin/handler_config_write.go | 46 ++++++++--- internal/admin/helpers.go | 42 ++++++++++ internal/config/codec.go | 9 +++ internal/config/config.go | 79 +++++++++++++++++++ internal/config/store.go | 4 + webui/src/features/account/AccountsTable.jsx | 4 + .../src/features/account/AddAccountModal.jsx | 20 +++++ webui/src/features/account/AddKeyModal.jsx | 26 +++++- webui/src/features/account/ApiKeysPanel.jsx | 27 ++++--- .../src/features/account/useAccountActions.js | 12 +-- webui/src/locales/en.json | 4 + webui/src/locales/zh.json | 4 + 16 files changed, 291 insertions(+), 31 deletions(-) diff --git a/README.MD b/README.MD index b72c364..d027477 100644 --- a/README.MD +++ b/README.MD @@ -271,8 +271,17 @@ go run ./cmd/ds2api ```json { "keys": ["your-api-key-1", "your-api-key-2"], + "api_keys": [ + { + "key": "your-api-key-1", + "name": "主 Key", + "remark": "生产流量" + } + ], "accounts": [ { + "name": "账号 A", + "remark": "主账号", "email": "user@example.com", "password": "your-password" }, @@ -320,7 +329,8 @@ go run ./cmd/ds2api ``` - `keys`:API 访问密钥列表,客户端通过 `Authorization: Bearer ` 鉴权 -- `accounts`:DeepSeek 账号列表,支持 `email` 或 `mobile` 登录 +- `api_keys`:推荐使用的新结构化密钥列表,支持 `key` + `name` + `remark`(`keys` 仍兼容) +- `accounts`:DeepSeek 账号列表,支持 `email` 或 `mobile` 登录;可额外填写 `name` / `remark` 便于管理 - `token`:配置文件中即使填写也会在加载时被清空(不会从 `config.json` 读取 token);实际 token 仅在运行时内存中维护并自动刷新 - `model_aliases`:常见模型名(如 GPT/Codex/Claude)到 DeepSeek 模型的映射 - `compat.wide_input_strict_output`:建议保持 `true`(当前实现默认宽进严出) @@ -335,6 +345,8 @@ go run ./cmd/ds2api ### 环境变量 +> 建议:长期维护请优先以 `config.json`(或其 Base64)为单一配置源。环境变量仅保留部署必需项;`DS2API_CONFIG_JSON` 主要用于 Vercel/无持久盘场景,后续可能进一步收敛。 + | 变量 | 用途 | 默认值 | | --- | --- | --- | | `PORT` | 服务端口 | `5001` | @@ -362,6 +374,15 @@ go run ./cmd/ds2api > 提示:当检测到 `DS2API_CONFIG_JSON` 时,管理台会显示当前模式风险与自动持久化状态(含 `DS2API_CONFIG_PATH` 路径与模式切换说明)。 +#### 必填 / 可选(按部署方式) + +- **所有部署都必填**:`DS2API_ADMIN_KEY` +- **配置来源二选一(推荐前者)**: + - `config.json` 文件(推荐,持久化更直观) + - `DS2API_CONFIG_JSON`(可选,适合 Vercel;支持 JSON 或 Base64) +- **仅在环境变量配置模式建议开启**:`DS2API_ENV_WRITEBACK=1`(避免管理台改动重启后丢失) +- 其余环境变量均为可选调优项。 + ## 鉴权模式 调用业务接口(`/v1/*`、`/anthropic/*`、Gemini 路由)时支持两种模式: diff --git a/config.example.json b/config.example.json index f914050..0f40a45 100644 --- a/config.example.json +++ b/config.example.json @@ -5,14 +5,29 @@ "your-api-key-1", "your-api-key-2" ], + "api_keys": [ + { + "key": "your-api-key-1", + "name": "主 API Key", + "remark": "给 OpenAI 客户端使用" + }, + { + "key": "your-api-key-2", + "name": "备用 API Key", + "remark": "压测或临时调试" + } + ], "accounts": [ { "_comment": "邮箱登录方式", + "name": "主账号", + "remark": "优先用于生产流量", "email": "example1@example.com", "password": "your-password-1" }, { "_comment": "邮箱登录方式 - 账号2", + "name": "备用账号", "email": "example2@example.com", "password": "your-password-2" }, diff --git a/internal/admin/handler_accounts_crud.go b/internal/admin/handler_accounts_crud.go index 403e4cb..9b888e7 100644 --- a/internal/admin/handler_accounts_crud.go +++ b/internal/admin/handler_accounts_crud.go @@ -32,6 +32,8 @@ func (h *Handler) listAccounts(w http.ResponseWriter, r *http.Request) { for _, acc := range accounts { id := strings.ToLower(acc.Identifier()) if strings.Contains(id, q) || + strings.Contains(strings.ToLower(acc.Name), q) || + strings.Contains(strings.ToLower(acc.Remark), q) || strings.Contains(strings.ToLower(acc.Email), q) || strings.Contains(strings.ToLower(acc.Mobile), q) { filtered = append(filtered, acc) @@ -66,6 +68,8 @@ func (h *Handler) listAccounts(w http.ResponseWriter, r *http.Request) { } items = append(items, map[string]any{ "identifier": acc.Identifier(), + "name": acc.Name, + "remark": acc.Remark, "email": acc.Email, "mobile": acc.Mobile, "proxy_id": acc.ProxyID, diff --git a/internal/admin/handler_config_read.go b/internal/admin/handler_config_read.go index 8ee1fcc..ceeb523 100644 --- a/internal/admin/handler_config_read.go +++ b/internal/admin/handler_config_read.go @@ -11,6 +11,7 @@ func (h *Handler) getConfig(w http.ResponseWriter, _ *http.Request) { snap := h.Store.Snapshot() safe := map[string]any{ "keys": snap.Keys, + "api_keys": snap.APIKeys, "accounts": []map[string]any{}, "proxies": []map[string]any{}, "env_backed": h.Store.IsEnvBacked(), @@ -37,6 +38,8 @@ func (h *Handler) getConfig(w http.ResponseWriter, _ *http.Request) { } accounts = append(accounts, map[string]any{ "identifier": acc.Identifier(), + "name": acc.Name, + "remark": acc.Remark, "email": acc.Email, "mobile": acc.Mobile, "proxy_id": acc.ProxyID, diff --git a/internal/admin/handler_config_write.go b/internal/admin/handler_config_write.go index c371e13..adddfad 100644 --- a/internal/admin/handler_config_write.go +++ b/internal/admin/handler_config_write.go @@ -19,8 +19,18 @@ func (h *Handler) updateConfig(w http.ResponseWriter, r *http.Request) { } old := h.Store.Snapshot() err := h.Store.Update(func(c *config.Config) error { + if apiKeys, ok := toAPIKeys(req["api_keys"]); ok { + c.APIKeys = apiKeys + } if keys, ok := toStringSlice(req["keys"]); ok { - c.Keys = keys + legacy := make([]config.APIKey, 0, len(keys)) + for _, key := range keys { + if key == "" { + continue + } + legacy = append(legacy, config.APIKey{Key: key}) + } + c.APIKeys = legacy } if accountsRaw, ok := req["accounts"].([]any); ok { existing := map[string]config.Account{} @@ -78,17 +88,19 @@ func (h *Handler) addKey(w http.ResponseWriter, r *http.Request) { _ = json.NewDecoder(r.Body).Decode(&req) key, _ := req["key"].(string) key = strings.TrimSpace(key) + name := fieldString(req, "name") + remark := fieldString(req, "remark") if key == "" { writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "Key 不能为空"}) return } err := h.Store.Update(func(c *config.Config) error { - for _, k := range c.Keys { - if k == key { + for _, item := range c.APIKeys { + if item.Key == key { return fmt.Errorf("key 已存在") } } - c.Keys = append(c.Keys, key) + c.APIKeys = append(c.APIKeys, config.APIKey{Key: key, Name: name, Remark: remark}) return nil }) if err != nil { @@ -102,8 +114,8 @@ func (h *Handler) deleteKey(w http.ResponseWriter, r *http.Request) { key := chi.URLParam(r, "key") err := h.Store.Update(func(c *config.Config) error { idx := -1 - for i, k := range c.Keys { - if k == key { + for i, item := range c.APIKeys { + if item.Key == key { idx = i break } @@ -111,7 +123,7 @@ func (h *Handler) deleteKey(w http.ResponseWriter, r *http.Request) { if idx < 0 { return fmt.Errorf("key 不存在") } - c.Keys = append(c.Keys[:idx], c.Keys[idx+1:]...) + c.APIKeys = append(c.APIKeys[:idx], c.APIKeys[idx+1:]...) return nil }) if err != nil { @@ -129,17 +141,31 @@ func (h *Handler) batchImport(w http.ResponseWriter, r *http.Request) { } importedKeys, importedAccounts := 0, 0 err := h.Store.Update(func(c *config.Config) error { + if apiKeys, ok := toAPIKeys(req["api_keys"]); ok { + existing := map[string]bool{} + for _, item := range c.APIKeys { + existing[item.Key] = true + } + for _, item := range apiKeys { + if item.Key == "" || existing[item.Key] { + continue + } + c.APIKeys = append(c.APIKeys, item) + existing[item.Key] = true + importedKeys++ + } + } if keys, ok := req["keys"].([]any); ok { existing := map[string]bool{} - for _, k := range c.Keys { - existing[k] = true + for _, item := range c.APIKeys { + existing[item.Key] = true } for _, k := range keys { key := strings.TrimSpace(fmt.Sprintf("%v", k)) if key == "" || existing[key] { continue } - c.Keys = append(c.Keys, key) + c.APIKeys = append(c.APIKeys, config.APIKey{Key: key}) existing[key] = true importedKeys++ } diff --git a/internal/admin/helpers.go b/internal/admin/helpers.go index 31abbc0..5af8388 100644 --- a/internal/admin/helpers.go +++ b/internal/admin/helpers.go @@ -62,6 +62,8 @@ func toAccount(m map[string]any) config.Account { email := fieldString(m, "email") mobile := config.NormalizeMobileForStorage(fieldString(m, "mobile")) return config.Account{ + Name: fieldString(m, "name"), + Remark: fieldString(m, "remark"), Email: email, Mobile: mobile, Password: fieldString(m, "password"), @@ -69,6 +71,44 @@ func toAccount(m map[string]any) config.Account { } } +func toAPIKeys(v any) ([]config.APIKey, bool) { + arr, ok := v.([]any) + if !ok { + return nil, false + } + out := make([]config.APIKey, 0, len(arr)) + seen := map[string]struct{}{} + for _, item := range arr { + switch x := item.(type) { + case map[string]any: + key := fieldString(x, "key") + if key == "" { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, config.APIKey{ + Key: key, + Name: fieldString(x, "name"), + Remark: fieldString(x, "remark"), + }) + default: + key := strings.TrimSpace(fmt.Sprintf("%v", item)) + if key == "" { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, config.APIKey{Key: key}) + } + } + return out, true +} + func fieldString(m map[string]any, key string) string { v, ok := m[key] if !ok || v == nil { @@ -99,6 +139,8 @@ func accountMatchesIdentifier(acc config.Account, identifier string) bool { } func normalizeAccountForStorage(acc config.Account) config.Account { + acc.Name = strings.TrimSpace(acc.Name) + acc.Remark = strings.TrimSpace(acc.Remark) acc.Email = strings.TrimSpace(acc.Email) acc.Mobile = config.NormalizeMobileForStorage(acc.Mobile) acc.ProxyID = strings.TrimSpace(acc.ProxyID) diff --git a/internal/config/codec.go b/internal/config/codec.go index bca8747..744b9b7 100644 --- a/internal/config/codec.go +++ b/internal/config/codec.go @@ -17,6 +17,9 @@ func (c Config) MarshalJSON() ([]byte, error) { if len(c.Keys) > 0 { m["keys"] = c.Keys } + if len(c.APIKeys) > 0 { + m["api_keys"] = c.APIKeys + } if len(c.Accounts) > 0 { m["accounts"] = c.Accounts } @@ -69,6 +72,10 @@ func (c *Config) UnmarshalJSON(b []byte) error { if err := json.Unmarshal(v, &c.Keys); err != nil { return fmt.Errorf("invalid field %q: %w", k, err) } + case "api_keys": + if err := json.Unmarshal(v, &c.APIKeys); err != nil { + return fmt.Errorf("invalid field %q: %w", k, err) + } case "accounts": if err := json.Unmarshal(v, &c.Accounts); err != nil { return fmt.Errorf("invalid field %q: %w", k, err) @@ -130,12 +137,14 @@ func (c *Config) UnmarshalJSON(b []byte) error { } } } + c.NormalizeCredentials() return nil } func (c Config) Clone() Config { clone := Config{ Keys: slices.Clone(c.Keys), + APIKeys: slices.Clone(c.APIKeys), Accounts: slices.Clone(c.Accounts), Proxies: slices.Clone(c.Proxies), ClaudeMapping: cloneStringMap(c.ClaudeMapping), diff --git a/internal/config/config.go b/internal/config/config.go index f8c7529..3566f2e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -9,6 +9,7 @@ import ( type Config struct { Keys []string `json:"keys,omitempty"` + APIKeys []APIKey `json:"api_keys,omitempty"` Accounts []Account `json:"accounts,omitempty"` Proxies []Proxy `json:"proxies,omitempty"` ClaudeMapping map[string]string `json:"claude_mapping,omitempty"` @@ -26,6 +27,8 @@ type Config struct { } type Account struct { + Name string `json:"name,omitempty"` + Remark string `json:"remark,omitempty"` Email string `json:"email,omitempty"` Mobile string `json:"mobile,omitempty"` Password string `json:"password,omitempty"` @@ -33,6 +36,12 @@ type Account struct { ProxyID string `json:"proxy_id,omitempty"` } +type APIKey struct { + Key string `json:"key"` + Name string `json:"name,omitempty"` + Remark string `json:"remark,omitempty"` +} + type Proxy struct { ID string `json:"id,omitempty"` Name string `json:"name,omitempty"` @@ -73,6 +82,76 @@ func (c *Config) ClearAccountTokens() { } } +func (c *Config) NormalizeCredentials() { + if c == nil { + return + } + normalizedAPIKeys := make([]APIKey, 0, len(c.APIKeys)) + metaByKey := make(map[string]APIKey, len(c.APIKeys)) + for _, item := range c.APIKeys { + key := strings.TrimSpace(item.Key) + if key == "" { + continue + } + if _, ok := metaByKey[key]; ok { + continue + } + metaByKey[key] = APIKey{ + Key: key, + Name: strings.TrimSpace(item.Name), + Remark: strings.TrimSpace(item.Remark), + } + } + if len(c.Keys) > 0 { + seen := make(map[string]struct{}, len(c.Keys)) + for _, key := range c.Keys { + key = strings.TrimSpace(key) + if key == "" { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + if item, ok := metaByKey[key]; ok { + normalizedAPIKeys = append(normalizedAPIKeys, item) + } else { + normalizedAPIKeys = append(normalizedAPIKeys, APIKey{Key: key}) + } + } + } else { + normalizedAPIKeys = make([]APIKey, 0, len(c.APIKeys)) + seen := make(map[string]struct{}, len(c.APIKeys)) + for _, item := range c.APIKeys { + key := strings.TrimSpace(item.Key) + if key == "" { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + normalizedAPIKeys = append(normalizedAPIKeys, APIKey{ + Key: key, + Name: strings.TrimSpace(item.Name), + Remark: strings.TrimSpace(item.Remark), + }) + } + } + c.APIKeys = normalizedAPIKeys + + keys := make([]string, 0, len(c.APIKeys)) + for _, item := range c.APIKeys { + keys = append(keys, item.Key) + } + c.Keys = keys + + for i := range c.Accounts { + c.Accounts[i].Name = strings.TrimSpace(c.Accounts[i].Name) + c.Accounts[i].Remark = strings.TrimSpace(c.Accounts[i].Remark) + } +} + // 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. diff --git a/internal/config/store.go b/internal/config/store.go index ebee6b0..3e397ef 100644 --- a/internal/config/store.go +++ b/internal/config/store.go @@ -43,6 +43,7 @@ func LoadStoreWithError() (*Store, error) { func loadStore() (*Store, error) { cfg, fromEnv, err := loadConfig() + cfg.NormalizeCredentials() if validateErr := ValidateConfig(cfg); validateErr != nil { err = errors.Join(err, validateErr) } @@ -112,6 +113,7 @@ func loadConfigFromFile(path string) (Config, error) { if err := json.Unmarshal(content, &cfg); err != nil { return Config{}, err } + cfg.NormalizeCredentials() cfg.DropInvalidAccounts() if strings.Contains(string(content), `"test_status"`) && !IsVercel() { if b, err := json.MarshalIndent(cfg, "", " "); err == nil { @@ -207,6 +209,7 @@ func (s *Store) UpdateAccountToken(identifier, token string) error { func (s *Store) Replace(cfg Config) error { s.mu.Lock() defer s.mu.Unlock() + cfg.NormalizeCredentials() s.cfg = cfg.Clone() s.rebuildIndexes() return s.saveLocked() @@ -219,6 +222,7 @@ func (s *Store) Update(mutator func(*Config) error) error { if err := mutator(&cfg); err != nil { return err } + cfg.NormalizeCredentials() s.cfg = cfg s.rebuildIndexes() return s.saveLocked() diff --git a/webui/src/features/account/AccountsTable.jsx b/webui/src/features/account/AccountsTable.jsx index b8a0829..f01e47f 100644 --- a/webui/src/features/account/AccountsTable.jsx +++ b/webui/src/features/account/AccountsTable.jsx @@ -118,6 +118,7 @@ export default function AccountsTable({ runtimeUnknown ? "bg-blue-500 shadow-[0_0_8px_rgba(59,130,246,0.5)]" : "bg-amber-500" )} />
+
{acc.name || '-'}
copyId(id)} @@ -128,6 +129,9 @@ export default function AccountsTable({ : }
+ {acc.remark && ( +
{acc.remark}
+ )}
{acc.test_status === 'failed' ? t('accountManager.testStatusFailed') : isActive ? t('accountManager.sessionActive') : runtimeUnknown ? t('accountManager.runtimeStatusUnknown') : t('accountManager.reauthRequired')} {acc.token_preview && ( diff --git a/webui/src/features/account/AddAccountModal.jsx b/webui/src/features/account/AddAccountModal.jsx index 69f3d88..ed97257 100644 --- a/webui/src/features/account/AddAccountModal.jsx +++ b/webui/src/features/account/AddAccountModal.jsx @@ -23,6 +23,26 @@ export default function AddAccountModal({
+
+ + setNewAccount({ ...newAccount, name: e.target.value })} + /> +
+
+ + setNewAccount({ ...newAccount, remark: e.target.value })} + /> +
setNewKey(e.target.value)} + value={newKey.key} + onChange={e => setNewKey({ ...newKey, key: e.target.value })} autoFocus />

{t('accountManager.generateHint')}

+
+ + setNewKey({ ...newKey, name: e.target.value })} + /> +
+
+ + setNewKey({ ...newKey, remark: e.target.value })} + /> +
- {copiedKey === key && ( +
{item.remark || '-'}
+ {copiedKey === item.key && ( {t('accountManager.copied')} )} - {failedKey === key && ( + {failedKey === item.key && ( {t('accountManager.copyFailed')} )}