feat: implement API key management with reconciliation and add update key endpoint

This commit is contained in:
CJACK.
2026-04-22 15:51:43 +00:00
parent 8ff923cd77
commit 8f09e3b381
19 changed files with 629 additions and 107 deletions

View File

@@ -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 |
@@ -643,11 +644,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,
@@ -671,13 +676,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": ""}
],
@@ -737,7 +747,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`.
@@ -748,7 +758,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}`

30
API.md
View File

@@ -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 | 添加代理 |
@@ -644,11 +645,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,
@@ -672,13 +677,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": ""}
],
@@ -738,7 +748,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`。
@@ -749,7 +759,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}`

View File

@@ -1 +1 @@
3.5.2
3.6.0

View File

@@ -25,6 +25,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)

View File

@@ -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++
}

View File

@@ -21,8 +21,7 @@ func (h *Handler) updateConfig(w http.ResponseWriter, r *http.Request) {
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 {
} else if keys, ok := toStringSlice(req["keys"]); ok {
legacy := make([]config.APIKey, 0, len(keys))
for _, key := range keys {
if key == "" {
@@ -110,6 +109,47 @@ 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) 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, item := range c.APIKeys {
if item.Key == key {
idx = i
break
}
}
if idx < 0 {
return fmt.Errorf("key 不存在")
}
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 {

View File

@@ -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)
}
}

View File

@@ -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"]}`)

View File

@@ -117,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

View File

@@ -86,65 +86,14 @@ 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})
}
}
normalizedAPIKeys := normalizeAPIKeys(c.APIKeys)
if len(normalizedAPIKeys) > 0 {
c.APIKeys = normalizedAPIKeys
c.Keys = apiKeysToStrings(c.APIKeys)
} 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.Keys = normalizeKeys(c.Keys)
c.APIKeys = apiKeysFromStrings(c.Keys, nil)
}
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)

View File

@@ -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()

View File

@@ -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)
})
}

View File

@@ -218,10 +218,12 @@ 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()

View File

@@ -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,
<AddKeyModal
show={showAddKey}
t={t}
editingKey={editingKey}
newKey={newKey}
setNewKey={setNewKey}
loading={loading}
onClose={() => setShowAddKey(false)}
onClose={closeKeyModal}
onAdd={addKey}
/>

View File

@@ -1,40 +1,47 @@
import { X } from 'lucide-react'
export default function AddKeyModal({ show, t, newKey, setNewKey, loading, onClose, onAdd }) {
export default function AddKeyModal({ show, t, editingKey, newKey, setNewKey, loading, onClose, onAdd }) {
if (!show) {
return null
}
const isEditing = Boolean(editingKey?.key)
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4 animate-in fade-in">
<div className="bg-card w-full max-w-md rounded-xl border border-border shadow-2xl overflow-hidden animate-in zoom-in-95">
<div className="p-4 border-b border-border flex justify-between items-center">
<h3 className="font-semibold">{t('accountManager.modalAddKeyTitle')}</h3>
<h3 className="font-semibold">{isEditing ? t('accountManager.modalEditKeyTitle') : t('accountManager.modalAddKeyTitle')}</h3>
<button onClick={onClose} className="text-muted-foreground hover:text-foreground">
<X className="w-5 h-5" />
</button>
</div>
<div className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium mb-1.5">{t('accountManager.newKeyLabel')}</label>
<label className="block text-sm font-medium mb-1.5">{isEditing ? t('accountManager.keyLabel') : t('accountManager.newKeyLabel')}</label>
<div className="flex gap-2">
<input
type="text"
className="input-field bg-[#09090b] flex-1"
placeholder={t('accountManager.newKeyPlaceholder')}
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
autoFocus={!isEditing}
readOnly={isEditing}
/>
<button
type="button"
onClick={() => setNewKey({ ...newKey, key: 'sk-' + crypto.randomUUID().replace(/-/g, '') })}
className="px-3 py-2 bg-secondary text-secondary-foreground rounded-lg hover:bg-secondary/80 transition-colors text-sm font-medium border border-border whitespace-nowrap"
>
{t('accountManager.generate')}
</button>
{!isEditing && (
<button
type="button"
onClick={() => setNewKey({ ...newKey, key: 'sk-' + crypto.randomUUID().replace(/-/g, '') })}
className="px-3 py-2 bg-secondary text-secondary-foreground rounded-lg hover:bg-secondary/80 transition-colors text-sm font-medium border border-border whitespace-nowrap"
>
{t('accountManager.generate')}
</button>
)}
</div>
<p className="text-xs text-muted-foreground mt-1.5">{t('accountManager.generateHint')}</p>
<p className="text-xs text-muted-foreground mt-1.5">
{isEditing ? t('accountManager.keyReadonlyHint') : t('accountManager.generateHint')}
</p>
</div>
<div>
<label className="block text-sm font-medium mb-1.5">{t('accountManager.nameOptional')}</label>
@@ -44,6 +51,7 @@ export default function AddKeyModal({ show, t, newKey, setNewKey, loading, onClo
placeholder={t('accountManager.namePlaceholder')}
value={newKey.name}
onChange={e => setNewKey({ ...newKey, name: e.target.value })}
autoFocus={isEditing}
/>
</div>
<div>
@@ -59,7 +67,9 @@ export default function AddKeyModal({ show, t, newKey, setNewKey, loading, onClo
<div className="flex justify-end gap-2 pt-2">
<button onClick={onClose} className="px-4 py-2 rounded-lg border border-border hover:bg-secondary transition-colors text-sm font-medium">{t('actions.cancel')}</button>
<button onClick={onAdd} disabled={loading} className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors text-sm font-medium disabled:opacity-50">
{loading ? t('accountManager.addKeyLoading') : t('accountManager.addKeyAction')}
{loading
? (isEditing ? t('accountManager.editKeyLoading') : t('accountManager.addKeyLoading'))
: (isEditing ? t('accountManager.editKeyAction') : t('accountManager.addKeyAction'))}
</button>
</div>
</div>

View File

@@ -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,7 +31,8 @@ export default function ApiKeysPanel({
config,
keysExpanded,
setKeysExpanded,
setShowAddKey,
onAddKey,
onEditKey,
copiedKey,
setCopiedKey,
onDeleteKey,
@@ -81,7 +82,7 @@ export default function ApiKeysPanel({
</div>
</div>
<button
onClick={(e) => { e.stopPropagation(); setShowAddKey(true) }}
onClick={(e) => { e.stopPropagation(); onAddKey() }}
className="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors font-medium text-sm shadow-sm"
>
<Plus className="w-4 h-4" />
@@ -112,6 +113,13 @@ export default function ApiKeysPanel({
)}
</div>
<div className="flex items-center gap-1">
<button
onClick={() => onEditKey(item)}
className="p-2 text-muted-foreground hover:text-primary hover:bg-primary/10 rounded-md transition-colors"
title={t('accountManager.editKeyTitle')}
>
<Pencil className="w-4 h-4" />
</button>
<button
onClick={() => handleCopyKey(item.key)}
className="p-2 text-muted-foreground hover:text-primary hover:bg-primary/10 rounded-md transition-colors"

View File

@@ -2,6 +2,7 @@ import { useState } from 'react'
export function useAccountActions({ apiFetch, t, onMessage, onRefresh, config, fetchAccounts, resolveAccountIdentifier }) {
const [showAddKey, setShowAddKey] = useState(false)
const [editingKey, setEditingKey] = useState(null)
const [showAddAccount, setShowAddAccount] = useState(false)
const [newKey, setNewKey] = useState({ key: '', name: '', remark: '' })
const [copiedKey, setCopiedKey] = useState(null)
@@ -14,23 +15,58 @@ export function useAccountActions({ apiFetch, t, onMessage, onRefresh, config, f
const [deletingSessions, setDeletingSessions] = useState({})
const [updatingProxy, setUpdatingProxy] = useState({})
const openAddKey = () => {
setEditingKey(null)
setNewKey({ key: '', name: '', remark: '' })
setShowAddKey(true)
}
const openEditKey = (item) => {
if (!item?.key) return
setEditingKey(item)
setNewKey({
key: item.key || '',
name: item.name || '',
remark: item.remark || '',
})
setShowAddKey(true)
}
const closeKeyModal = () => {
setShowAddKey(false)
setEditingKey(null)
setNewKey({ key: '', name: '', remark: '' })
}
const addKey = async () => {
if (!newKey.key.trim()) return
const isEditing = Boolean(editingKey?.key)
if (!isEditing && !newKey.key.trim()) {
return
}
setLoading(true)
try {
const res = await apiFetch('/admin/keys', {
method: 'POST',
const endpoint = isEditing
? `/admin/keys/${encodeURIComponent(editingKey.key)}`
: '/admin/keys'
const method = isEditing ? 'PUT' : 'POST'
const payload = isEditing
? { name: newKey.name, remark: newKey.remark }
: { key: newKey.key.trim(), name: newKey.name, remark: newKey.remark }
if (!isEditing && !payload.key) {
return
}
const res = await apiFetch(endpoint, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: newKey.key.trim(), name: newKey.name, remark: newKey.remark }),
body: JSON.stringify(payload),
})
if (res.ok) {
onMessage('success', t('accountManager.addKeySuccess'))
setNewKey({ key: '', name: '', remark: '' })
setShowAddKey(false)
onMessage('success', isEditing ? t('accountManager.updateKeySuccess') : t('accountManager.addKeySuccess'))
closeKeyModal()
onRefresh()
} else {
const data = await res.json()
onMessage('error', data.detail || t('messages.failedToAdd'))
onMessage('error', data.detail || (isEditing ? t('messages.requestFailed') : t('messages.failedToAdd')))
}
} catch (e) {
onMessage('error', t('messages.networkError'))
@@ -244,7 +280,10 @@ export function useAccountActions({ apiFetch, t, onMessage, onRefresh, config, f
return {
showAddKey,
setShowAddKey,
openAddKey,
openEditKey,
closeKeyModal,
editingKey,
showAddAccount,
setShowAddAccount,
newKey,

View File

@@ -92,6 +92,7 @@
},
"accountManager": {
"addKeySuccess": "API key added successfully.",
"updateKeySuccess": "API key updated successfully.",
"addAccountSuccess": "Account added successfully.",
"requiredFields": "Password and email/mobile are required.",
"deleteKeyConfirm": "Are you sure you want to delete this API key?",
@@ -108,6 +109,7 @@
"apiKeysTitle": "API Keys",
"apiKeysDesc": "Manage the API access key pool",
"addKey": "Add key",
"editKeyTitle": "Edit key",
"copied": "Copied",
"copyFailed": "Copy failed",
"copyKeyTitle": "Copy key",
@@ -124,12 +126,18 @@
"testStatusFailed": "Last test failed",
"noAccounts": "No accounts found.",
"modalAddKeyTitle": "Add API key",
"modalEditKeyTitle": "Edit API key",
"newKeyLabel": "New key value",
"newKeyPlaceholder": "Enter a custom API key",
"keyLabel": "Key value",
"keyReadonlyPlaceholder": "Key value cannot be changed",
"keyReadonlyHint": "The key value is read-only. Update the name and remark instead.",
"generate": "Generate",
"generateHint": "Click Generate to create a random key.",
"addKeyLoading": "Adding...",
"addKeyAction": "Add key",
"editKeyLoading": "Saving...",
"editKeyAction": "Save changes",
"modalAddAccountTitle": "Add DeepSeek account",
"nameOptional": "Name (optional)",
"namePlaceholder": "e.g. Primary Account A",

View File

@@ -92,6 +92,7 @@
},
"accountManager": {
"addKeySuccess": "API 密钥添加成功",
"updateKeySuccess": "API 密钥更新成功",
"addAccountSuccess": "账号添加成功",
"requiredFields": "需要填写密码以及邮箱或手机号",
"deleteKeyConfirm": "确定要删除此 API 密钥吗?",
@@ -108,6 +109,7 @@
"apiKeysTitle": "API 密钥",
"apiKeysDesc": "管理 API 访问密钥池",
"addKey": "添加密钥",
"editKeyTitle": "编辑密钥",
"copied": "已复制",
"copyFailed": "复制失败",
"copyKeyTitle": "复制密钥",
@@ -124,12 +126,18 @@
"testStatusFailed": "上次测试失败",
"noAccounts": "未找到任何账号",
"modalAddKeyTitle": "添加 API 密钥",
"modalEditKeyTitle": "编辑 API 密钥",
"newKeyLabel": "新密钥值",
"newKeyPlaceholder": "输入自定义 API 密钥",
"keyLabel": "密钥值",
"keyReadonlyPlaceholder": "密钥值不可修改",
"keyReadonlyHint": "密钥值不可编辑,仅可修改名称和备注。",
"generate": "生成",
"generateHint": "点击「生成」自动创建随机密钥",
"addKeyLoading": "添加中...",
"addKeyAction": "添加密钥",
"editKeyLoading": "保存中...",
"editKeyAction": "保存修改",
"modalAddAccountTitle": "添加 DeepSeek 账号",
"nameOptional": "名称(可选)",
"namePlaceholder": "例如:主账号 A",