diff --git a/API.en.md b/API.en.md index c055d33..1629d9b 100644 --- a/API.en.md +++ b/API.en.md @@ -130,7 +130,8 @@ Gemini-compatible clients can also send `x-goog-api-key`, `?key=`, or `?api_key= | POST | `/admin/settings/password` | Admin | Update admin password and invalidate old JWTs | | POST | `/admin/config/import` | Admin | Import config (merge/replace) | | GET | `/admin/config/export` | Admin | Export full config (`config`/`json`/`base64`) | -| POST | `/admin/keys` | Admin | Add API key | +| POST | `/admin/keys` | Admin | Add API key (optional `name`/`remark`) | +| PUT | `/admin/keys/{key}` | Admin | Update API key metadata | | DELETE | `/admin/keys/{key}` | Admin | Delete API key | | GET | `/admin/proxies` | Admin | List proxies | | POST | `/admin/proxies` | Admin | Add proxy | @@ -647,11 +648,15 @@ Returns Vercel preconfiguration status. ### `GET /admin/config` -Returns sanitized config. +Returns sanitized config, including both `keys` and `api_keys`. ```json { "keys": ["k1", "k2"], + "api_keys": [ + {"key": "k1", "name": "Primary", "remark": "Production"}, + {"key": "k2", "name": "Backup", "remark": "Load test"} + ], "env_backed": false, "env_source_present": true, "env_writeback_enabled": true, @@ -675,13 +680,18 @@ Returns sanitized config. ### `POST /admin/config` -Only updates `keys`, `accounts`, and `claude_mapping`. +Only updates `keys`, `api_keys`, `accounts`, and `claude_mapping`. +If both `api_keys` and `keys` are sent, the structured `api_keys` entries win so `name` / `remark` metadata is preserved; `keys` remains a legacy fallback. **Request**: ```json { "keys": ["k1", "k2"], + "api_keys": [ + {"key": "k1", "name": "Primary", "remark": "Production"}, + {"key": "k2", "name": "Backup", "remark": "Load test"} + ], "accounts": [ {"email": "user@example.com", "password": "pwd", "token": ""} ], @@ -741,7 +751,7 @@ Imports full config with: The request can send config directly, or wrapped as `{"config": {...}, "mode":"merge"}`. Query params `?mode=merge` / `?mode=replace` are also supported. -Import accepts `keys`, `accounts`, `claude_mapping` / `claude_model_mapping`, `model_aliases`, `admin`, `runtime`, `responses`, `embeddings`, and `auto_delete`; legacy `toolcall` fields are ignored. +Import accepts `keys`, `api_keys`, `accounts`, `claude_mapping` / `claude_model_mapping`, `model_aliases`, `admin`, `runtime`, `responses`, `embeddings`, and `auto_delete`; legacy `toolcall` fields are ignored. > `compat` fields are managed via `/admin/settings` or the config file; this import endpoint does not update `compat`. @@ -752,7 +762,17 @@ Exports full config in three forms: `config`, `json`, and `base64`. ### `POST /admin/keys` ```json -{"key": "new-api-key"} +{"key": "new-api-key", "name": "Primary", "remark": "Production"} +``` + +**Response**: `{"success": true, "total_keys": 3}` + +### `PUT /admin/keys/{key}` + +Updates the `name` / `remark` of the specified API key. The path `key` is read-only and cannot be changed. + +```json +{"name": "Backup", "remark": "Load test"} ``` **Response**: `{"success": true, "total_keys": 3}` diff --git a/API.md b/API.md index 016b113..8aea84d 100644 --- a/API.md +++ b/API.md @@ -130,7 +130,8 @@ Gemini 兼容客户端还可以使用 `x-goog-api-key`、`?key=` 或 `?api_key=` | POST | `/admin/settings/password` | Admin | 更新 Admin 密码并使旧 JWT 失效 | | POST | `/admin/config/import` | Admin | 导入配置(merge/replace) | | GET | `/admin/config/export` | Admin | 导出完整配置(含 `config`/`json`/`base64`) | -| POST | `/admin/keys` | Admin | 添加 API key | +| POST | `/admin/keys` | Admin | 添加 API key(可附 name/remark) | +| PUT | `/admin/keys/{key}` | Admin | 更新 API key 备注信息 | | DELETE | `/admin/keys/{key}` | Admin | 删除 API key | | GET | `/admin/proxies` | Admin | 代理列表 | | POST | `/admin/proxies` | Admin | 添加代理 | @@ -648,11 +649,15 @@ data: {"type":"message_stop"} ### `GET /admin/config` -返回脱敏后的配置。 +返回脱敏后的配置,包含 `keys` 与 `api_keys`。 ```json { "keys": ["k1", "k2"], + "api_keys": [ + {"key": "k1", "name": "主 Key", "remark": "生产流量"}, + {"key": "k2", "name": "备用 Key", "remark": "压测"} + ], "env_backed": false, "env_source_present": true, "env_writeback_enabled": true, @@ -676,13 +681,18 @@ data: {"type":"message_stop"} ### `POST /admin/config` -只更新 `keys`、`accounts`、`claude_mapping`。 +只更新 `keys`、`api_keys`、`accounts`、`claude_mapping`。 +如果同时发送 `api_keys` 与 `keys`,优先保留 `api_keys` 中的结构化 `name` / `remark`;`keys` 仅作为旧格式兼容回退。 **请求**: ```json { "keys": ["k1", "k2"], + "api_keys": [ + {"key": "k1", "name": "主 Key", "remark": "生产流量"}, + {"key": "k2", "name": "备用 Key", "remark": "压测"} + ], "accounts": [ {"email": "user@example.com", "password": "pwd", "token": ""} ], @@ -742,7 +752,7 @@ data: {"type":"message_stop"} 请求可直接传配置对象,或使用 `{"config": {...}, "mode":"merge"}` 包裹格式。 也支持在查询参数里传 `?mode=merge` / `?mode=replace`。 -导入时会接受 `keys`、`accounts`、`claude_mapping` / `claude_model_mapping`、`model_aliases`、`admin`、`runtime`、`responses`、`embeddings`、`auto_delete` 等字段;`toolcall` 相关字段会被忽略。 +导入时会接受 `keys`、`api_keys`、`accounts`、`claude_mapping` / `claude_model_mapping`、`model_aliases`、`admin`、`runtime`、`responses`、`embeddings`、`auto_delete` 等字段;`toolcall` 相关字段会被忽略。 > `compat` 相关字段请通过 `/admin/settings` 或配置文件管理;该导入接口不会更新 `compat`。 @@ -753,7 +763,17 @@ data: {"type":"message_stop"} ### `POST /admin/keys` ```json -{"key": "new-api-key"} +{"key": "new-api-key", "name": "主 Key", "remark": "生产流量"} +``` + +**响应**:`{"success": true, "total_keys": 3}` + +### `PUT /admin/keys/{key}` + +更新指定 API key 的 `name` / `remark`,路径参数中的 `key` 为只读标识,不可修改。 + +```json +{"name": "备用 Key", "remark": "压测"} ``` **响应**:`{"success": true, "total_keys": 3}` diff --git a/README.MD b/README.MD index 4d99c83..459a5ba 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` | @@ -363,6 +375,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/VERSION b/VERSION index 87ce492..40c341b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.5.2 +3.6.0 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.go b/internal/admin/handler.go index 8d271ea..3f84a18 100644 --- a/internal/admin/handler.go +++ b/internal/admin/handler.go @@ -28,6 +28,7 @@ func RegisterRoutes(r chi.Router, h *Handler) { pr.Post("/config/import", h.configImport) pr.Get("/config/export", h.configExport) pr.Post("/keys", h.addKey) + pr.Put("/keys/{key}", h.updateKey) pr.Delete("/keys/{key}", h.deleteKey) pr.Get("/proxies", h.listProxies) pr.Post("/proxies", h.addProxy) 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_import.go b/internal/admin/handler_config_import.go index a28b4e5..c238428 100644 --- a/internal/admin/handler_config_import.go +++ b/internal/admin/handler_config_import.go @@ -53,15 +53,19 @@ func (h *Handler) configImport(w http.ResponseWriter, r *http.Request) { next.Accounts = normalizeAndDedupeAccounts(next.Accounts) next.VercelSyncHash = c.VercelSyncHash next.VercelSyncTime = c.VercelSyncTime - importedKeys = len(next.Keys) + importedKeys = len(next.APIKeys) importedAccounts = len(next.Accounts) } else { existingKeys := map[string]struct{}{} - for _, k := range next.Keys { - existingKeys[k] = struct{}{} + for _, item := range next.APIKeys { + key := strings.TrimSpace(item.Key) + if key == "" { + continue + } + existingKeys[key] = struct{}{} } - for _, k := range incoming.Keys { - key := strings.TrimSpace(k) + for _, item := range incoming.APIKeys { + key := strings.TrimSpace(item.Key) if key == "" { continue } @@ -69,7 +73,7 @@ func (h *Handler) configImport(w http.ResponseWriter, r *http.Request) { continue } existingKeys[key] = struct{}{} - next.Keys = append(next.Keys, key) + next.APIKeys = append(next.APIKeys, item) importedKeys++ } 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..ae696bc 100644 --- a/internal/admin/handler_config_write.go +++ b/internal/admin/handler_config_write.go @@ -19,8 +19,17 @@ func (h *Handler) updateConfig(w http.ResponseWriter, r *http.Request) { } old := h.Store.Snapshot() err := h.Store.Update(func(c *config.Config) error { - if keys, ok := toStringSlice(req["keys"]); ok { - c.Keys = keys + if apiKeys, ok := toAPIKeys(req["api_keys"]); ok { + c.APIKeys = apiKeys + } else if keys, ok := toStringSlice(req["keys"]); ok { + 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 +87,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 { @@ -98,12 +109,25 @@ func (h *Handler) addKey(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"success": true, "total_keys": len(h.Store.Snapshot().Keys)}) } -func (h *Handler) deleteKey(w http.ResponseWriter, r *http.Request) { - key := chi.URLParam(r, "key") +func (h *Handler) updateKey(w http.ResponseWriter, r *http.Request) { + key := strings.TrimSpace(chi.URLParam(r, "key")) + if key == "" { + writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "key 不能为空"}) + return + } + + var req map[string]any + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "invalid json"}) + return + } + name, nameOK := fieldStringOptional(req, "name") + remark, remarkOK := fieldStringOptional(req, "remark") + 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 +135,35 @@ 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:]...) + if nameOK { + c.APIKeys[idx].Name = name + } + if remarkOK { + c.APIKeys[idx].Remark = remark + } + return nil + }) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]any{"detail": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"success": true, "total_keys": len(h.Store.Snapshot().Keys)}) +} + +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, item := range c.APIKeys { + if item.Key == key { + idx = i + break + } + } + if idx < 0 { + return fmt.Errorf("key 不存在") + } + c.APIKeys = append(c.APIKeys[:idx], c.APIKeys[idx+1:]...) return nil }) if err != nil { @@ -129,17 +181,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/handler_keys_test.go b/internal/admin/handler_keys_test.go new file mode 100644 index 0000000..82ff5e2 --- /dev/null +++ b/internal/admin/handler_keys_test.go @@ -0,0 +1,76 @@ +package admin + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" +) + +func TestKeyEndpointsPreserveStructuredMetadata(t *testing.T) { + h := newAdminTestHandler(t, `{ + "api_keys":[{"key":"k1","name":"primary","remark":"prod"}] + }`) + + r := chi.NewRouter() + r.Post("/admin/keys", h.addKey) + r.Put("/admin/keys/{key}", h.updateKey) + r.Delete("/admin/keys/{key}", h.deleteKey) + + addBody := []byte(`{"key":"k2","name":"secondary","remark":"staging"}`) + addReq := httptest.NewRequest(http.MethodPost, "/admin/keys", bytes.NewReader(addBody)) + addRec := httptest.NewRecorder() + r.ServeHTTP(addRec, addReq) + if addRec.Code != http.StatusOK { + t.Fatalf("add status=%d body=%s", addRec.Code, addRec.Body.String()) + } + + snap := h.Store.Snapshot() + if len(snap.APIKeys) != 2 { + t.Fatalf("unexpected api keys after add: %#v", snap.APIKeys) + } + if snap.APIKeys[0].Name != "primary" || snap.APIKeys[0].Remark != "prod" { + t.Fatalf("existing metadata was lost after add: %#v", snap.APIKeys[0]) + } + if snap.APIKeys[1].Name != "secondary" || snap.APIKeys[1].Remark != "staging" { + t.Fatalf("new metadata was lost after add: %#v", snap.APIKeys[1]) + } + + updateBody := map[string]any{ + "name": "primary-updated", + "remark": "prod-updated", + } + updateBytes, _ := json.Marshal(updateBody) + updateReq := httptest.NewRequest(http.MethodPut, "/admin/keys/k1", bytes.NewReader(updateBytes)) + updateRec := httptest.NewRecorder() + r.ServeHTTP(updateRec, updateReq) + if updateRec.Code != http.StatusOK { + t.Fatalf("update status=%d body=%s", updateRec.Code, updateRec.Body.String()) + } + + snap = h.Store.Snapshot() + if len(snap.APIKeys) != 2 { + t.Fatalf("unexpected api keys after update: %#v", snap.APIKeys) + } + if snap.APIKeys[0].Key != "k1" || snap.APIKeys[0].Name != "primary-updated" || snap.APIKeys[0].Remark != "prod-updated" { + t.Fatalf("metadata update did not persist: %#v", snap.APIKeys[0]) + } + + deleteReq := httptest.NewRequest(http.MethodDelete, "/admin/keys/k1", nil) + deleteRec := httptest.NewRecorder() + r.ServeHTTP(deleteRec, deleteReq) + if deleteRec.Code != http.StatusOK { + t.Fatalf("delete status=%d body=%s", deleteRec.Code, deleteRec.Body.String()) + } + + snap = h.Store.Snapshot() + if len(snap.APIKeys) != 1 || snap.APIKeys[0].Key != "k2" { + t.Fatalf("unexpected api keys after delete: %#v", snap.APIKeys) + } + if len(snap.Keys) != 1 || snap.Keys[0] != "k2" { + t.Fatalf("unexpected legacy keys after delete: %#v", snap.Keys) + } +} diff --git a/internal/admin/handler_settings_test.go b/internal/admin/handler_settings_test.go index d698b67..9aeba35 100644 --- a/internal/admin/handler_settings_test.go +++ b/internal/admin/handler_settings_test.go @@ -234,6 +234,43 @@ func TestUpdateSettingsHotReloadTokenRefreshInterval(t *testing.T) { } } +func TestUpdateConfigPreservesStructuredAPIKeysWhenBothFieldsPresent(t *testing.T) { + h := newAdminTestHandler(t, `{ + "keys":["legacy"], + "api_keys":[{"key":"legacy","name":"primary","remark":"prod"}], + "accounts":[] + }`) + + payload := map[string]any{ + "keys": []any{"legacy", "new-key"}, + "api_keys": []any{ + map[string]any{"key": "legacy", "name": "primary-updated", "remark": "prod-updated"}, + map[string]any{"key": "new-key", "name": "secondary", "remark": "staging"}, + }, + } + b, _ := json.Marshal(payload) + req := httptest.NewRequest(http.MethodPost, "/admin/config", bytes.NewReader(b)) + rec := httptest.NewRecorder() + h.updateConfig(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + + snap := h.Store.Snapshot() + if len(snap.Keys) != 2 || snap.Keys[0] != "legacy" || snap.Keys[1] != "new-key" { + t.Fatalf("unexpected keys after config update: %#v", snap.Keys) + } + if len(snap.APIKeys) != 2 { + t.Fatalf("unexpected api keys after config update: %#v", snap.APIKeys) + } + if snap.APIKeys[0].Name != "primary-updated" || snap.APIKeys[0].Remark != "prod-updated" { + t.Fatalf("structured metadata for existing key was not preserved: %#v", snap.APIKeys[0]) + } + if snap.APIKeys[1].Name != "secondary" || snap.APIKeys[1].Remark != "staging" { + t.Fatalf("structured metadata for new key was not preserved: %#v", snap.APIKeys[1]) + } +} + func TestUpdateSettingsPasswordInvalidatesOldJWT(t *testing.T) { hash := authn.HashAdminPassword("old-password") h := newAdminTestHandler(t, `{"admin":{"password_hash":"`+hash+`"}}`) @@ -315,6 +352,40 @@ func TestConfigImportMergeAndReplace(t *testing.T) { } } +func TestConfigImportMergePreservesStructuredAPIKeys(t *testing.T) { + h := newAdminTestHandler(t, `{ + "api_keys":[{"key":"k1","name":"primary","remark":"prod"}] + }`) + + merge := map[string]any{ + "mode": "merge", + "config": map[string]any{ + "api_keys": []any{ + map[string]any{"key": "k1", "name": "should-not-overwrite", "remark": "ignored"}, + map[string]any{"key": "k2", "name": "secondary", "remark": "staging"}, + }, + }, + } + mergeBytes, _ := json.Marshal(merge) + mergeReq := httptest.NewRequest(http.MethodPost, "/admin/config/import?mode=merge", bytes.NewReader(mergeBytes)) + mergeRec := httptest.NewRecorder() + h.configImport(mergeRec, mergeReq) + if mergeRec.Code != http.StatusOK { + t.Fatalf("merge status=%d body=%s", mergeRec.Code, mergeRec.Body.String()) + } + + snap := h.Store.Snapshot() + if len(snap.APIKeys) != 2 { + t.Fatalf("unexpected api keys after structured merge: %#v", snap.APIKeys) + } + if snap.APIKeys[0].Name != "primary" || snap.APIKeys[0].Remark != "prod" { + t.Fatalf("existing structured metadata was overwritten: %#v", snap.APIKeys[0]) + } + if snap.APIKeys[1].Name != "secondary" || snap.APIKeys[1].Remark != "staging" { + t.Fatalf("new structured metadata was lost: %#v", snap.APIKeys[1]) + } +} + func TestConfigImportAppliesTokenRefreshInterval(t *testing.T) { h := newAdminTestHandler(t, `{"keys":["k1"]}`) diff --git a/internal/admin/helpers.go b/internal/admin/helpers.go index 31abbc0..8411b3b 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 { @@ -77,6 +117,14 @@ func fieldString(m map[string]any, key string) string { return strings.TrimSpace(fmt.Sprintf("%v", v)) } +func fieldStringOptional(m map[string]any, key string) (string, bool) { + v, ok := m[key] + if !ok || v == nil { + return "", false + } + return strings.TrimSpace(fmt.Sprintf("%v", v)), true +} + func statusOr(v int, d int) int { if v == 0 { return d @@ -99,6 +147,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..05879c2 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,25 @@ func (c *Config) ClearAccountTokens() { } } +func (c *Config) NormalizeCredentials() { + if c == nil { + return + } + normalizedAPIKeys := normalizeAPIKeys(c.APIKeys) + if len(normalizedAPIKeys) > 0 { + c.APIKeys = normalizedAPIKeys + c.Keys = apiKeysToStrings(c.APIKeys) + } else { + c.Keys = normalizeKeys(c.Keys) + c.APIKeys = apiKeysFromStrings(c.Keys, nil) + } + + 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/config_edge_test.go b/internal/config/config_edge_test.go index 0de0815..b70cf11 100644 --- a/internal/config/config_edge_test.go +++ b/internal/config/config_edge_test.go @@ -529,6 +529,101 @@ func TestStoreUpdate(t *testing.T) { } } +func TestStoreUpdateReconcilesAPIKeyMutations(t *testing.T) { + t.Setenv("DS2API_CONFIG_JSON", `{ + "keys":["k1"], + "api_keys":[{"key":"k1","name":"primary","remark":"prod"}], + "accounts":[] + }`) + store := LoadStore() + + if err := store.Update(func(cfg *Config) error { + cfg.APIKeys = append(cfg.APIKeys, APIKey{Key: "k2", Name: "secondary", Remark: "staging"}) + return nil + }); err != nil { + t.Fatalf("add api key failed: %v", err) + } + + snap := store.Snapshot() + if len(snap.Keys) != 2 || snap.Keys[0] != "k1" || snap.Keys[1] != "k2" { + t.Fatalf("unexpected keys after api key add: %#v", snap.Keys) + } + if len(snap.APIKeys) != 2 { + t.Fatalf("unexpected api keys length after add: %#v", snap.APIKeys) + } + if snap.APIKeys[0].Name != "primary" || snap.APIKeys[0].Remark != "prod" { + t.Fatalf("metadata for existing key was lost: %#v", snap.APIKeys[0]) + } + if snap.APIKeys[1].Name != "secondary" || snap.APIKeys[1].Remark != "staging" { + t.Fatalf("metadata for new key was lost: %#v", snap.APIKeys[1]) + } + + if err := store.Update(func(cfg *Config) error { + cfg.APIKeys = append([]APIKey(nil), cfg.APIKeys[1:]...) + return nil + }); err != nil { + t.Fatalf("delete api key failed: %v", err) + } + + snap = store.Snapshot() + if len(snap.Keys) != 1 || snap.Keys[0] != "k2" { + t.Fatalf("unexpected keys after api key delete: %#v", snap.Keys) + } + if len(snap.APIKeys) != 1 || snap.APIKeys[0].Key != "k2" { + t.Fatalf("unexpected api keys after delete: %#v", snap.APIKeys) + } +} + +func TestStoreUpdateReconcilesLegacyKeyMutations(t *testing.T) { + t.Setenv("DS2API_CONFIG_JSON", `{ + "keys":["k1"], + "api_keys":[{"key":"k1","name":"primary","remark":"prod"}], + "accounts":[] + }`) + store := LoadStore() + + if err := store.Update(func(cfg *Config) error { + cfg.Keys = append(cfg.Keys, "k2") + return nil + }); err != nil { + t.Fatalf("legacy key update failed: %v", err) + } + + snap := store.Snapshot() + if len(snap.Keys) != 2 || snap.Keys[0] != "k1" || snap.Keys[1] != "k2" { + t.Fatalf("unexpected keys after legacy update: %#v", snap.Keys) + } + if len(snap.APIKeys) != 2 { + t.Fatalf("unexpected api keys after legacy update: %#v", snap.APIKeys) + } + if snap.APIKeys[0].Name != "primary" || snap.APIKeys[0].Remark != "prod" { + t.Fatalf("metadata for preserved key was lost: %#v", snap.APIKeys[0]) + } + if snap.APIKeys[1].Key != "k2" || snap.APIKeys[1].Name != "" || snap.APIKeys[1].Remark != "" { + t.Fatalf("new legacy key should stay metadata-free: %#v", snap.APIKeys[1]) + } +} + +func TestNormalizeCredentialsPrefersStructuredAPIKeys(t *testing.T) { + cfg := Config{ + Keys: []string{"legacy-key"}, + APIKeys: []APIKey{ + {Key: "structured-key", Name: "primary", Remark: "prod"}, + }, + } + cfg.NormalizeCredentials() + + if len(cfg.Keys) != 1 || cfg.Keys[0] != "structured-key" { + t.Fatalf("unexpected normalized keys: %#v", cfg.Keys) + } + if len(cfg.APIKeys) != 1 { + t.Fatalf("unexpected normalized api keys: %#v", cfg.APIKeys) + } + if cfg.APIKeys[0].Key != "structured-key" || cfg.APIKeys[0].Name != "primary" || cfg.APIKeys[0].Remark != "prod" { + t.Fatalf("unexpected structured api key metadata: %#v", cfg.APIKeys[0]) + } +} + func TestStoreClaudeMapping(t *testing.T) { t.Setenv("DS2API_CONFIG_JSON", `{"keys":[],"accounts":[],"claude_mapping":{"fast":"deepseek-chat","slow":"deepseek-reasoner"}}`) store := LoadStore() diff --git a/internal/config/credentials.go b/internal/config/credentials.go new file mode 100644 index 0000000..a29f314 --- /dev/null +++ b/internal/config/credentials.go @@ -0,0 +1,158 @@ +package config + +import ( + "slices" + "strings" +) + +func (c *Config) ReconcileCredentials(base Config) { + if c == nil { + return + } + currKeys := normalizeKeys(c.Keys) + currAPIKeys := normalizeAPIKeys(c.APIKeys) + baseKeys := normalizeKeys(base.Keys) + baseAPIKeys := normalizeAPIKeys(base.APIKeys) + + keysChanged := !slices.Equal(currKeys, baseKeys) + apiKeysChanged := !equalAPIKeys(currAPIKeys, baseAPIKeys) + + if keysChanged && !apiKeysChanged { + c.APIKeys = apiKeysFromStrings(currKeys, apiKeyMap(baseAPIKeys)) + } else { + c.APIKeys = currAPIKeys + } + c.Keys = apiKeysToStrings(c.APIKeys) +} + +func normalizeKeys(keys []string) []string { + if len(keys) == 0 { + return nil + } + out := make([]string, 0, len(keys)) + seen := make(map[string]struct{}, len(keys)) + for _, key := range keys { + key = strings.TrimSpace(key) + if key == "" { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, key) + } + if len(out) == 0 { + return nil + } + return out +} + +func normalizeAPIKeys(items []APIKey) []APIKey { + if len(items) == 0 { + return nil + } + out := make([]APIKey, 0, len(items)) + seen := make(map[string]struct{}, len(items)) + for _, item := range items { + key := strings.TrimSpace(item.Key) + if key == "" { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, APIKey{ + Key: key, + Name: strings.TrimSpace(item.Name), + Remark: strings.TrimSpace(item.Remark), + }) + } + if len(out) == 0 { + return nil + } + return out +} + +func apiKeysFromStrings(keys []string, meta map[string]APIKey) []APIKey { + if len(keys) == 0 { + return nil + } + out := make([]APIKey, 0, len(keys)) + seen := make(map[string]struct{}, len(keys)) + for _, key := range keys { + key = strings.TrimSpace(key) + if key == "" { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + if item, ok := meta[key]; ok { + out = append(out, APIKey{ + Key: key, + Name: strings.TrimSpace(item.Name), + Remark: strings.TrimSpace(item.Remark), + }) + continue + } + out = append(out, APIKey{Key: key}) + } + if len(out) == 0 { + return nil + } + return out +} + +func apiKeysToStrings(items []APIKey) []string { + if len(items) == 0 { + return nil + } + keys := make([]string, 0, len(items)) + for _, item := range items { + key := strings.TrimSpace(item.Key) + if key == "" { + continue + } + keys = append(keys, key) + } + if len(keys) == 0 { + return nil + } + return keys +} + +func apiKeyMap(items []APIKey) map[string]APIKey { + if len(items) == 0 { + return nil + } + out := make(map[string]APIKey, len(items)) + for _, item := range items { + key := strings.TrimSpace(item.Key) + if key == "" { + continue + } + if _, ok := out[key]; ok { + continue + } + out[key] = APIKey{ + Key: key, + Name: strings.TrimSpace(item.Name), + Remark: strings.TrimSpace(item.Remark), + } + } + return out +} + +func equalAPIKeys(a, b []APIKey) bool { + if len(a) != len(b) { + return false + } + return slices.EqualFunc(a, b, func(x, y APIKey) bool { + return strings.TrimSpace(x.Key) == strings.TrimSpace(y.Key) && + strings.TrimSpace(x.Name) == strings.TrimSpace(y.Name) && + strings.TrimSpace(x.Remark) == strings.TrimSpace(y.Remark) + }) +} diff --git a/internal/config/store.go b/internal/config/store.go index ebee6b0..a54e012 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() @@ -215,10 +218,13 @@ func (s *Store) Replace(cfg Config) error { func (s *Store) Update(mutator func(*Config) error) error { s.mu.Lock() defer s.mu.Unlock() - cfg := s.cfg.Clone() + base := s.cfg.Clone() + cfg := base.Clone() if err := mutator(&cfg); err != nil { return err } + cfg.ReconcileCredentials(base) + cfg.NormalizeCredentials() s.cfg = cfg s.rebuildIndexes() return s.saveLocked() diff --git a/webui/src/features/account/AccountManagerContainer.jsx b/webui/src/features/account/AccountManagerContainer.jsx index 3e00241..9b88ca1 100644 --- a/webui/src/features/account/AccountManagerContainer.jsx +++ b/webui/src/features/account/AccountManagerContainer.jsx @@ -30,7 +30,10 @@ export default function AccountManagerContainer({ config, onRefresh, onMessage, const { showAddKey, - setShowAddKey, + openAddKey, + openEditKey, + closeKeyModal, + editingKey, showAddAccount, setShowAddAccount, newKey, @@ -94,7 +97,8 @@ export default function AccountManagerContainer({ config, onRefresh, onMessage, config={config} keysExpanded={keysExpanded} setKeysExpanded={setKeysExpanded} - setShowAddKey={setShowAddKey} + onAddKey={openAddKey} + onEditKey={openEditKey} copiedKey={copiedKey} setCopiedKey={setCopiedKey} onDeleteKey={deleteKey} @@ -133,10 +137,11 @@ export default function AccountManagerContainer({ config, onRefresh, onMessage, setShowAddKey(false)} + onClose={closeKeyModal} onAdd={addKey} /> 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 })} + /> +
-

{t('accountManager.modalAddKeyTitle')}

+

{isEditing ? t('accountManager.modalEditKeyTitle') : t('accountManager.modalAddKeyTitle')}

- +
setNewKey(e.target.value)} - autoFocus + className={isEditing ? "input-field bg-muted/30 flex-1 cursor-not-allowed" : "input-field bg-[#09090b] flex-1"} + placeholder={isEditing ? t('accountManager.keyReadonlyPlaceholder') : t('accountManager.newKeyPlaceholder')} + value={newKey.key} + onChange={e => setNewKey({ ...newKey, key: e.target.value })} + autoFocus={!isEditing} + readOnly={isEditing} /> - + {!isEditing && ( + + )}
-

{t('accountManager.generateHint')}

+

+ {isEditing ? t('accountManager.keyReadonlyHint') : t('accountManager.generateHint')} +

+
+
+ + setNewKey({ ...newKey, name: e.target.value })} + autoFocus={isEditing} + /> +
+
+ + setNewKey({ ...newKey, remark: e.target.value })} + />
diff --git a/webui/src/features/account/ApiKeysPanel.jsx b/webui/src/features/account/ApiKeysPanel.jsx index 956c0fa..7030d8b 100644 --- a/webui/src/features/account/ApiKeysPanel.jsx +++ b/webui/src/features/account/ApiKeysPanel.jsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import { Check, ChevronDown, Copy, Plus, Trash2 } from 'lucide-react' +import { Check, ChevronDown, Copy, Pencil, Plus, Trash2 } from 'lucide-react' import clsx from 'clsx' function fallbackCopyText(text) { @@ -31,12 +31,16 @@ export default function ApiKeysPanel({ config, keysExpanded, setKeysExpanded, - setShowAddKey, + onAddKey, + onEditKey, copiedKey, setCopiedKey, onDeleteKey, }) { const [failedKey, setFailedKey] = useState(null) + const apiKeys = Array.isArray(config?.api_keys) && config.api_keys.length > 0 + ? config.api_keys + : (config?.keys || []).map(key => ({ key, name: '', remark: '' })) const handleCopyKey = async (key) => { try { @@ -74,11 +78,11 @@ export default function ApiKeysPanel({ )} />

{t('accountManager.apiKeysTitle')}

-

{t('accountManager.apiKeysDesc')} ({config.keys?.length || 0})

+

{t('accountManager.apiKeysDesc')} ({apiKeys.length || 0})

- {copiedKey === key && ( +
{item.remark || '-'}
+ {copiedKey === item.key && ( {t('accountManager.copied')} )} - {failedKey === key && ( + {failedKey === item.key && ( {t('accountManager.copyFailed')} )}
+