feat: implement API key metadata preservation and make chat history migration best-effort

This commit is contained in:
CJACK.
2026-04-22 16:59:10 +00:00
parent fe8a6bd3cd
commit f14969eca5
7 changed files with 256 additions and 51 deletions

View File

@@ -56,26 +56,9 @@ func (h *Handler) configImport(w http.ResponseWriter, r *http.Request) {
importedKeys = len(next.APIKeys)
importedAccounts = len(next.Accounts)
} else {
existingKeys := map[string]struct{}{}
for _, item := range next.APIKeys {
key := strings.TrimSpace(item.Key)
if key == "" {
continue
}
existingKeys[key] = struct{}{}
}
for _, item := range incoming.APIKeys {
key := strings.TrimSpace(item.Key)
if key == "" {
continue
}
if _, ok := existingKeys[key]; ok {
continue
}
existingKeys[key] = struct{}{}
next.APIKeys = append(next.APIKeys, item)
importedKeys++
}
var changed int
next.APIKeys, changed = mergeAPIKeysPreferStructured(next.APIKeys, incoming.APIKeys)
importedKeys += changed
existingAccounts := map[string]struct{}{}
for _, acc := range next.Accounts {

View File

@@ -22,14 +22,7 @@ func (h *Handler) updateConfig(w http.ResponseWriter, r *http.Request) {
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
c.Keys = keys
}
if accountsRaw, ok := req["accounts"].([]any); ok {
existing := map[string]config.Account{}
@@ -182,33 +175,22 @@ 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++
}
var changed int
c.APIKeys, changed = mergeAPIKeysPreferStructured(c.APIKeys, apiKeys)
importedKeys += changed
}
if keys, ok := req["keys"].([]any); ok {
existing := map[string]bool{}
for _, item := range c.APIKeys {
existing[item.Key] = true
}
legacy := make([]config.APIKey, 0, len(keys))
for _, k := range keys {
key := strings.TrimSpace(fmt.Sprintf("%v", k))
if key == "" || existing[key] {
if key == "" {
continue
}
c.APIKeys = append(c.APIKeys, config.APIKey{Key: key})
existing[key] = true
importedKeys++
legacy = append(legacy, config.APIKey{Key: key})
}
var changed int
c.APIKeys, changed = mergeAPIKeysPreferStructured(c.APIKeys, legacy)
importedKeys += changed
}
if accounts, ok := req["accounts"].([]any); ok {
existing := map[string]bool{}

View File

@@ -271,6 +271,38 @@ func TestUpdateConfigPreservesStructuredAPIKeysWhenBothFieldsPresent(t *testing.
}
}
func TestUpdateConfigLegacyKeysPreserveStructuredMetadata(t *testing.T) {
h := newAdminTestHandler(t, `{
"api_keys":[{"key":"legacy","name":"primary","remark":"prod"}],
"accounts":[]
}`)
payload := map[string]any{
"keys": []any{"legacy", "new-key"},
}
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 legacy config update: %#v", snap.Keys)
}
if len(snap.APIKeys) != 2 {
t.Fatalf("unexpected api keys after legacy config update: %#v", snap.APIKeys)
}
if snap.APIKeys[0].Name != "primary" || snap.APIKeys[0].Remark != "prod" {
t.Fatalf("existing structured metadata was lost: %#v", snap.APIKeys[0])
}
if snap.APIKeys[1].Key != "new-key" || snap.APIKeys[1].Name != "" || snap.APIKeys[1].Remark != "" {
t.Fatalf("new legacy key should remain metadata-free: %#v", snap.APIKeys[1])
}
}
func TestUpdateSettingsPasswordInvalidatesOldJWT(t *testing.T) {
hash := authn.HashAdminPassword("old-password")
h := newAdminTestHandler(t, `{"admin":{"password_hash":"`+hash+`"}}`)
@@ -386,6 +418,79 @@ func TestConfigImportMergePreservesStructuredAPIKeys(t *testing.T) {
}
}
func TestConfigImportMergeUpgradesLegacyAPIKeys(t *testing.T) {
h := newAdminTestHandler(t, `{
"keys":["legacy"],
"accounts":[]
}`)
merge := map[string]any{
"mode": "merge",
"config": map[string]any{
"api_keys": []any{
map[string]any{"key": "legacy", "name": "primary", "remark": "prod"},
map[string]any{"key": "new-key", "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.Keys) != 2 || snap.Keys[0] != "legacy" || snap.Keys[1] != "new-key" {
t.Fatalf("unexpected keys after legacy import merge: %#v", snap.Keys)
}
if len(snap.APIKeys) != 2 {
t.Fatalf("unexpected api keys after legacy import merge: %#v", snap.APIKeys)
}
if snap.APIKeys[0].Name != "primary" || snap.APIKeys[0].Remark != "prod" {
t.Fatalf("legacy key metadata was not upgraded: %#v", snap.APIKeys[0])
}
if snap.APIKeys[1].Name != "secondary" || snap.APIKeys[1].Remark != "staging" {
t.Fatalf("new structured metadata was not preserved: %#v", snap.APIKeys[1])
}
}
func TestBatchImportUpgradesLegacyAPIKeys(t *testing.T) {
h := newAdminTestHandler(t, `{
"keys":["legacy"],
"accounts":[]
}`)
payload := map[string]any{
"keys": []any{"legacy", "new-key"},
"api_keys": []any{
map[string]any{"key": "legacy", "name": "primary", "remark": "prod"},
},
}
b, _ := json.Marshal(payload)
req := httptest.NewRequest(http.MethodPost, "/admin/import", bytes.NewReader(b))
rec := httptest.NewRecorder()
h.batchImport(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 batch import: %#v", snap.Keys)
}
if len(snap.APIKeys) != 2 {
t.Fatalf("unexpected api keys after batch import: %#v", snap.APIKeys)
}
if snap.APIKeys[0].Name != "primary" || snap.APIKeys[0].Remark != "prod" {
t.Fatalf("legacy key metadata was not upgraded: %#v", snap.APIKeys[0])
}
if snap.APIKeys[1].Name != "" || snap.APIKeys[1].Remark != "" {
t.Fatalf("new batch-imported key should stay metadata-free: %#v", snap.APIKeys[1])
}
}
func TestConfigImportAppliesTokenRefreshInterval(t *testing.T) {
h := newAdminTestHandler(t, `{"keys":["k1"]}`)

View File

@@ -109,6 +109,78 @@ func toAPIKeys(v any) ([]config.APIKey, bool) {
return out, true
}
func normalizeAPIKeyForStorage(item config.APIKey) config.APIKey {
return config.APIKey{
Key: strings.TrimSpace(item.Key),
Name: strings.TrimSpace(item.Name),
Remark: strings.TrimSpace(item.Remark),
}
}
func apiKeyHasMetadata(item config.APIKey) bool {
return strings.TrimSpace(item.Name) != "" || strings.TrimSpace(item.Remark) != ""
}
func mergeAPIKeysPreferStructured(existing, incoming []config.APIKey) ([]config.APIKey, int) {
if len(existing) == 0 && len(incoming) == 0 {
return nil, 0
}
merged := make([]config.APIKey, 0, len(existing)+len(incoming))
index := make(map[string]int, len(existing)+len(incoming))
for _, item := range existing {
item = normalizeAPIKeyForStorage(item)
if item.Key == "" {
continue
}
if _, ok := index[item.Key]; ok {
continue
}
index[item.Key] = len(merged)
merged = append(merged, item)
}
imported := 0
for _, item := range incoming {
item = normalizeAPIKeyForStorage(item)
if item.Key == "" {
continue
}
if idx, ok := index[item.Key]; ok {
keep := merged[idx]
next := mergeAPIKeyRecord(keep, item)
if next != keep {
merged[idx] = next
imported++
}
continue
}
index[item.Key] = len(merged)
merged = append(merged, item)
imported++
}
if len(merged) == 0 {
return nil, imported
}
return merged, imported
}
func mergeAPIKeyRecord(existing, incoming config.APIKey) config.APIKey {
existing = normalizeAPIKeyForStorage(existing)
incoming = normalizeAPIKeyForStorage(incoming)
if existing.Key == "" {
return incoming
}
if apiKeyHasMetadata(existing) {
return existing
}
if apiKeyHasMetadata(incoming) {
return incoming
}
return existing
}
func fieldString(m map[string]any, key string) string {
v, ok := m[key]
if !ok || v == nil {