feat: Implement DeepSeek integration, refactor model adapters for streaming and tool calls, enhance admin and account management, and introduce new UI features for settings, API testing, and Vercel sync.

This commit is contained in:
CJACK
2026-02-22 17:25:48 +08:00
parent 5d3989a9a7
commit 6c48429b90
152 changed files with 13583 additions and 11817 deletions

View File

@@ -0,0 +1,114 @@
package admin
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"ds2api/internal/config"
)
func (h *Handler) listAccounts(w http.ResponseWriter, r *http.Request) {
page := intFromQuery(r, "page", 1)
pageSize := intFromQuery(r, "page_size", 10)
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 1
}
if pageSize > 100 {
pageSize = 100
}
accounts := h.Store.Snapshot().Accounts
total := len(accounts)
reverseAccounts(accounts)
totalPages := 1
if total > 0 {
totalPages = (total + pageSize - 1) / pageSize
}
start := (page - 1) * pageSize
if start > total {
start = total
}
end := start + pageSize
if end > total {
end = total
}
items := make([]map[string]any, 0, end-start)
for _, acc := range accounts[start:end] {
token := strings.TrimSpace(acc.Token)
preview := ""
if token != "" {
if len(token) > 20 {
preview = token[:20] + "..."
} else {
preview = token
}
}
items = append(items, map[string]any{
"identifier": acc.Identifier(),
"email": acc.Email,
"mobile": acc.Mobile,
"has_password": acc.Password != "",
"has_token": token != "",
"token_preview": preview,
})
}
writeJSON(w, http.StatusOK, map[string]any{"items": items, "total": total, "page": page, "page_size": pageSize, "total_pages": totalPages})
}
func (h *Handler) addAccount(w http.ResponseWriter, r *http.Request) {
var req map[string]any
_ = json.NewDecoder(r.Body).Decode(&req)
acc := toAccount(req)
if acc.Identifier() == "" {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "需要 email 或 mobile"})
return
}
err := h.Store.Update(func(c *config.Config) error {
for _, a := range c.Accounts {
if acc.Email != "" && a.Email == acc.Email {
return fmt.Errorf("邮箱已存在")
}
if acc.Mobile != "" && a.Mobile == acc.Mobile {
return fmt.Errorf("手机号已存在")
}
}
c.Accounts = append(c.Accounts, acc)
return nil
})
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
return
}
h.Pool.Reset()
writeJSON(w, http.StatusOK, map[string]any{"success": true, "total_accounts": len(h.Store.Snapshot().Accounts)})
}
func (h *Handler) deleteAccount(w http.ResponseWriter, r *http.Request) {
identifier := chi.URLParam(r, "identifier")
err := h.Store.Update(func(c *config.Config) error {
idx := -1
for i, a := range c.Accounts {
if accountMatchesIdentifier(a, identifier) {
idx = i
break
}
}
if idx < 0 {
return fmt.Errorf("账号不存在")
}
c.Accounts = append(c.Accounts[:idx], c.Accounts[idx+1:]...)
return nil
})
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]any{"detail": err.Error()})
return
}
h.Pool.Reset()
writeJSON(w, http.StatusOK, map[string]any{"success": true, "total_accounts": len(h.Store.Snapshot().Accounts)})
}

View File

@@ -0,0 +1,7 @@
package admin
import "net/http"
func (h *Handler) queueStatus(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, h.Pool.Status())
}

View File

@@ -11,119 +11,11 @@ import (
"sync"
"time"
"github.com/go-chi/chi/v5"
authn "ds2api/internal/auth"
"ds2api/internal/config"
"ds2api/internal/sse"
)
func (h *Handler) listAccounts(w http.ResponseWriter, r *http.Request) {
page := intFromQuery(r, "page", 1)
pageSize := intFromQuery(r, "page_size", 10)
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 1
}
if pageSize > 100 {
pageSize = 100
}
accounts := h.Store.Snapshot().Accounts
total := len(accounts)
reverseAccounts(accounts)
totalPages := 1
if total > 0 {
totalPages = (total + pageSize - 1) / pageSize
}
start := (page - 1) * pageSize
if start > total {
start = total
}
end := start + pageSize
if end > total {
end = total
}
items := make([]map[string]any, 0, end-start)
for _, acc := range accounts[start:end] {
token := strings.TrimSpace(acc.Token)
preview := ""
if token != "" {
if len(token) > 20 {
preview = token[:20] + "..."
} else {
preview = token
}
}
items = append(items, map[string]any{
"identifier": acc.Identifier(),
"email": acc.Email,
"mobile": acc.Mobile,
"has_password": acc.Password != "",
"has_token": token != "",
"token_preview": preview,
})
}
writeJSON(w, http.StatusOK, map[string]any{"items": items, "total": total, "page": page, "page_size": pageSize, "total_pages": totalPages})
}
func (h *Handler) addAccount(w http.ResponseWriter, r *http.Request) {
var req map[string]any
_ = json.NewDecoder(r.Body).Decode(&req)
acc := toAccount(req)
if acc.Identifier() == "" {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "需要 email 或 mobile"})
return
}
err := h.Store.Update(func(c *config.Config) error {
for _, a := range c.Accounts {
if acc.Email != "" && a.Email == acc.Email {
return fmt.Errorf("邮箱已存在")
}
if acc.Mobile != "" && a.Mobile == acc.Mobile {
return fmt.Errorf("手机号已存在")
}
}
c.Accounts = append(c.Accounts, acc)
return nil
})
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
return
}
h.Pool.Reset()
writeJSON(w, http.StatusOK, map[string]any{"success": true, "total_accounts": len(h.Store.Snapshot().Accounts)})
}
func (h *Handler) deleteAccount(w http.ResponseWriter, r *http.Request) {
identifier := chi.URLParam(r, "identifier")
err := h.Store.Update(func(c *config.Config) error {
idx := -1
for i, a := range c.Accounts {
if accountMatchesIdentifier(a, identifier) {
idx = i
break
}
}
if idx < 0 {
return fmt.Errorf("账号不存在")
}
c.Accounts = append(c.Accounts[:idx], c.Accounts[idx+1:]...)
return nil
})
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]any{"detail": err.Error()})
return
}
h.Pool.Reset()
writeJSON(w, http.StatusOK, map[string]any{"success": true, "total_accounts": len(h.Store.Snapshot().Accounts)})
}
func (h *Handler) queueStatus(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, h.Pool.Status())
}
func (h *Handler) testSingleAccount(w http.ResponseWriter, r *http.Request) {
var req map[string]any
_ = json.NewDecoder(r.Body).Decode(&req)

View File

@@ -1,393 +0,0 @@
package admin
import (
"crypto/md5"
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"ds2api/internal/config"
)
func (h *Handler) getConfig(w http.ResponseWriter, _ *http.Request) {
snap := h.Store.Snapshot()
safe := map[string]any{
"keys": snap.Keys,
"accounts": []map[string]any{},
"claude_mapping": func() map[string]string {
if len(snap.ClaudeMapping) > 0 {
return snap.ClaudeMapping
}
return snap.ClaudeModelMap
}(),
}
accounts := make([]map[string]any, 0, len(snap.Accounts))
for _, acc := range snap.Accounts {
token := strings.TrimSpace(acc.Token)
preview := ""
if token != "" {
if len(token) > 20 {
preview = token[:20] + "..."
} else {
preview = token
}
}
accounts = append(accounts, map[string]any{
"identifier": acc.Identifier(),
"email": acc.Email,
"mobile": acc.Mobile,
"has_password": strings.TrimSpace(acc.Password) != "",
"has_token": token != "",
"token_preview": preview,
})
}
safe["accounts"] = accounts
writeJSON(w, http.StatusOK, safe)
}
func (h *Handler) updateConfig(w http.ResponseWriter, r *http.Request) {
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
}
old := h.Store.Snapshot()
err := h.Store.Update(func(c *config.Config) error {
if keys, ok := toStringSlice(req["keys"]); ok {
c.Keys = keys
}
if accountsRaw, ok := req["accounts"].([]any); ok {
existing := map[string]config.Account{}
for _, a := range old.Accounts {
existing[a.Identifier()] = a
}
accounts := make([]config.Account, 0, len(accountsRaw))
for _, item := range accountsRaw {
m, ok := item.(map[string]any)
if !ok {
continue
}
acc := toAccount(m)
id := acc.Identifier()
if prev, ok := existing[id]; ok {
if strings.TrimSpace(acc.Password) == "" {
acc.Password = prev.Password
}
if strings.TrimSpace(acc.Token) == "" {
acc.Token = prev.Token
}
}
accounts = append(accounts, acc)
}
c.Accounts = accounts
}
if m, ok := req["claude_mapping"].(map[string]any); ok {
newMap := map[string]string{}
for k, v := range m {
newMap[k] = fmt.Sprintf("%v", v)
}
c.ClaudeMapping = newMap
}
return nil
})
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": err.Error()})
return
}
h.Pool.Reset()
writeJSON(w, http.StatusOK, map[string]any{"success": true, "message": "配置已更新"})
}
func (h *Handler) addKey(w http.ResponseWriter, r *http.Request) {
var req map[string]any
_ = json.NewDecoder(r.Body).Decode(&req)
key, _ := req["key"].(string)
key = strings.TrimSpace(key)
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 {
return fmt.Errorf("Key 已存在")
}
}
c.Keys = append(c.Keys, key)
return nil
})
if err != nil {
writeJSON(w, http.StatusBadRequest, 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, k := range c.Keys {
if k == key {
idx = i
break
}
}
if idx < 0 {
return fmt.Errorf("Key 不存在")
}
c.Keys = append(c.Keys[:idx], c.Keys[idx+1:]...)
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) batchImport(w http.ResponseWriter, r *http.Request) {
var req map[string]any
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "无效的 JSON 格式"})
return
}
importedKeys, importedAccounts := 0, 0
err := h.Store.Update(func(c *config.Config) error {
if keys, ok := req["keys"].([]any); ok {
existing := map[string]bool{}
for _, k := range c.Keys {
existing[k] = true
}
for _, k := range keys {
key := strings.TrimSpace(fmt.Sprintf("%v", k))
if key == "" || existing[key] {
continue
}
c.Keys = append(c.Keys, key)
existing[key] = true
importedKeys++
}
}
if accounts, ok := req["accounts"].([]any); ok {
existing := map[string]bool{}
for _, a := range c.Accounts {
existing[a.Identifier()] = true
}
for _, item := range accounts {
m, ok := item.(map[string]any)
if !ok {
continue
}
acc := toAccount(m)
id := acc.Identifier()
if id == "" || existing[id] {
continue
}
c.Accounts = append(c.Accounts, acc)
existing[id] = true
importedAccounts++
}
}
return nil
})
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": err.Error()})
return
}
h.Pool.Reset()
writeJSON(w, http.StatusOK, map[string]any{"success": true, "imported_keys": importedKeys, "imported_accounts": importedAccounts})
}
func (h *Handler) exportConfig(w http.ResponseWriter, _ *http.Request) {
h.configExport(w, nil)
}
func (h *Handler) configExport(w http.ResponseWriter, _ *http.Request) {
snap := h.Store.Snapshot()
jsonStr, b64, err := h.Store.ExportJSONAndBase64()
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{
"success": true,
"config": snap,
"json": jsonStr,
"base64": b64,
})
}
func (h *Handler) configImport(w http.ResponseWriter, r *http.Request) {
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
}
mode := strings.TrimSpace(strings.ToLower(r.URL.Query().Get("mode")))
if mode == "" {
mode = strings.TrimSpace(strings.ToLower(fieldString(req, "mode")))
}
if mode == "" {
mode = "merge"
}
if mode != "merge" && mode != "replace" {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "mode must be merge or replace"})
return
}
payload := req
if raw, ok := req["config"].(map[string]any); ok && len(raw) > 0 {
payload = raw
}
rawJSON, err := json.Marshal(payload)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "invalid config payload"})
return
}
var incoming config.Config
if err := json.Unmarshal(rawJSON, &incoming); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
return
}
importedKeys, importedAccounts := 0, 0
err = h.Store.Update(func(c *config.Config) error {
next := c.Clone()
if mode == "replace" {
next = incoming.Clone()
next.VercelSyncHash = c.VercelSyncHash
next.VercelSyncTime = c.VercelSyncTime
importedKeys = len(next.Keys)
importedAccounts = len(next.Accounts)
} else {
existingKeys := map[string]struct{}{}
for _, k := range next.Keys {
existingKeys[k] = struct{}{}
}
for _, k := range incoming.Keys {
key := strings.TrimSpace(k)
if key == "" {
continue
}
if _, ok := existingKeys[key]; ok {
continue
}
existingKeys[key] = struct{}{}
next.Keys = append(next.Keys, key)
importedKeys++
}
existingAccounts := map[string]struct{}{}
for _, acc := range next.Accounts {
existingAccounts[acc.Identifier()] = struct{}{}
}
for _, acc := range incoming.Accounts {
id := acc.Identifier()
if id == "" {
continue
}
if _, ok := existingAccounts[id]; ok {
continue
}
existingAccounts[id] = struct{}{}
next.Accounts = append(next.Accounts, acc)
importedAccounts++
}
if len(incoming.ClaudeMapping) > 0 {
if next.ClaudeMapping == nil {
next.ClaudeMapping = map[string]string{}
}
for k, v := range incoming.ClaudeMapping {
next.ClaudeMapping[k] = v
}
}
if len(incoming.ClaudeModelMap) > 0 {
if next.ClaudeModelMap == nil {
next.ClaudeModelMap = map[string]string{}
}
for k, v := range incoming.ClaudeModelMap {
next.ClaudeModelMap[k] = v
}
}
if len(incoming.ModelAliases) > 0 {
if next.ModelAliases == nil {
next.ModelAliases = map[string]string{}
}
for k, v := range incoming.ModelAliases {
next.ModelAliases[k] = v
}
}
if strings.TrimSpace(incoming.Toolcall.Mode) != "" {
next.Toolcall.Mode = incoming.Toolcall.Mode
}
if strings.TrimSpace(incoming.Toolcall.EarlyEmitConfidence) != "" {
next.Toolcall.EarlyEmitConfidence = incoming.Toolcall.EarlyEmitConfidence
}
if incoming.Responses.StoreTTLSeconds > 0 {
next.Responses.StoreTTLSeconds = incoming.Responses.StoreTTLSeconds
}
if strings.TrimSpace(incoming.Embeddings.Provider) != "" {
next.Embeddings.Provider = incoming.Embeddings.Provider
}
if strings.TrimSpace(incoming.Admin.PasswordHash) != "" {
next.Admin.PasswordHash = incoming.Admin.PasswordHash
}
if incoming.Admin.JWTExpireHours > 0 {
next.Admin.JWTExpireHours = incoming.Admin.JWTExpireHours
}
if incoming.Admin.JWTValidAfterUnix > 0 {
next.Admin.JWTValidAfterUnix = incoming.Admin.JWTValidAfterUnix
}
if incoming.Runtime.AccountMaxInflight > 0 {
next.Runtime.AccountMaxInflight = incoming.Runtime.AccountMaxInflight
}
if incoming.Runtime.AccountMaxQueue > 0 {
next.Runtime.AccountMaxQueue = incoming.Runtime.AccountMaxQueue
}
if incoming.Runtime.GlobalMaxInflight > 0 {
next.Runtime.GlobalMaxInflight = incoming.Runtime.GlobalMaxInflight
}
}
normalizeSettingsConfig(&next)
if err := validateSettingsConfig(next); err != nil {
return newRequestError(err.Error())
}
*c = next
return nil
})
if err != nil {
if detail, ok := requestErrorDetail(err); ok {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": detail})
return
}
writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": err.Error()})
return
}
h.Pool.Reset()
writeJSON(w, http.StatusOK, map[string]any{
"success": true,
"mode": mode,
"imported_keys": importedKeys,
"imported_accounts": importedAccounts,
"message": "config imported",
})
}
func (h *Handler) computeSyncHash() string {
snap := h.Store.Snapshot().Clone()
snap.VercelSyncHash = ""
snap.VercelSyncTime = 0
b, _ := json.Marshal(snap)
sum := md5.Sum(b)
return fmt.Sprintf("%x", sum)
}

View File

@@ -0,0 +1,182 @@
package admin
import (
"crypto/md5"
"encoding/json"
"fmt"
"net/http"
"strings"
"ds2api/internal/config"
)
func (h *Handler) configImport(w http.ResponseWriter, r *http.Request) {
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
}
mode := strings.TrimSpace(strings.ToLower(r.URL.Query().Get("mode")))
if mode == "" {
mode = strings.TrimSpace(strings.ToLower(fieldString(req, "mode")))
}
if mode == "" {
mode = "merge"
}
if mode != "merge" && mode != "replace" {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "mode must be merge or replace"})
return
}
payload := req
if raw, ok := req["config"].(map[string]any); ok && len(raw) > 0 {
payload = raw
}
rawJSON, err := json.Marshal(payload)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "invalid config payload"})
return
}
var incoming config.Config
if err := json.Unmarshal(rawJSON, &incoming); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
return
}
importedKeys, importedAccounts := 0, 0
err = h.Store.Update(func(c *config.Config) error {
next := c.Clone()
if mode == "replace" {
next = incoming.Clone()
next.VercelSyncHash = c.VercelSyncHash
next.VercelSyncTime = c.VercelSyncTime
importedKeys = len(next.Keys)
importedAccounts = len(next.Accounts)
} else {
existingKeys := map[string]struct{}{}
for _, k := range next.Keys {
existingKeys[k] = struct{}{}
}
for _, k := range incoming.Keys {
key := strings.TrimSpace(k)
if key == "" {
continue
}
if _, ok := existingKeys[key]; ok {
continue
}
existingKeys[key] = struct{}{}
next.Keys = append(next.Keys, key)
importedKeys++
}
existingAccounts := map[string]struct{}{}
for _, acc := range next.Accounts {
existingAccounts[acc.Identifier()] = struct{}{}
}
for _, acc := range incoming.Accounts {
id := acc.Identifier()
if id == "" {
continue
}
if _, ok := existingAccounts[id]; ok {
continue
}
existingAccounts[id] = struct{}{}
next.Accounts = append(next.Accounts, acc)
importedAccounts++
}
if len(incoming.ClaudeMapping) > 0 {
if next.ClaudeMapping == nil {
next.ClaudeMapping = map[string]string{}
}
for k, v := range incoming.ClaudeMapping {
next.ClaudeMapping[k] = v
}
}
if len(incoming.ClaudeModelMap) > 0 {
if next.ClaudeModelMap == nil {
next.ClaudeModelMap = map[string]string{}
}
for k, v := range incoming.ClaudeModelMap {
next.ClaudeModelMap[k] = v
}
}
if len(incoming.ModelAliases) > 0 {
if next.ModelAliases == nil {
next.ModelAliases = map[string]string{}
}
for k, v := range incoming.ModelAliases {
next.ModelAliases[k] = v
}
}
if strings.TrimSpace(incoming.Toolcall.Mode) != "" {
next.Toolcall.Mode = incoming.Toolcall.Mode
}
if strings.TrimSpace(incoming.Toolcall.EarlyEmitConfidence) != "" {
next.Toolcall.EarlyEmitConfidence = incoming.Toolcall.EarlyEmitConfidence
}
if incoming.Responses.StoreTTLSeconds > 0 {
next.Responses.StoreTTLSeconds = incoming.Responses.StoreTTLSeconds
}
if strings.TrimSpace(incoming.Embeddings.Provider) != "" {
next.Embeddings.Provider = incoming.Embeddings.Provider
}
if strings.TrimSpace(incoming.Admin.PasswordHash) != "" {
next.Admin.PasswordHash = incoming.Admin.PasswordHash
}
if incoming.Admin.JWTExpireHours > 0 {
next.Admin.JWTExpireHours = incoming.Admin.JWTExpireHours
}
if incoming.Admin.JWTValidAfterUnix > 0 {
next.Admin.JWTValidAfterUnix = incoming.Admin.JWTValidAfterUnix
}
if incoming.Runtime.AccountMaxInflight > 0 {
next.Runtime.AccountMaxInflight = incoming.Runtime.AccountMaxInflight
}
if incoming.Runtime.AccountMaxQueue > 0 {
next.Runtime.AccountMaxQueue = incoming.Runtime.AccountMaxQueue
}
if incoming.Runtime.GlobalMaxInflight > 0 {
next.Runtime.GlobalMaxInflight = incoming.Runtime.GlobalMaxInflight
}
}
normalizeSettingsConfig(&next)
if err := validateSettingsConfig(next); err != nil {
return newRequestError(err.Error())
}
*c = next
return nil
})
if err != nil {
if detail, ok := requestErrorDetail(err); ok {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": detail})
return
}
writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": err.Error()})
return
}
h.Pool.Reset()
writeJSON(w, http.StatusOK, map[string]any{
"success": true,
"mode": mode,
"imported_keys": importedKeys,
"imported_accounts": importedAccounts,
"message": "config imported",
})
}
func (h *Handler) computeSyncHash() string {
snap := h.Store.Snapshot().Clone()
snap.VercelSyncHash = ""
snap.VercelSyncTime = 0
b, _ := json.Marshal(snap)
sum := md5.Sum(b)
return fmt.Sprintf("%x", sum)
}

View File

@@ -0,0 +1,61 @@
package admin
import (
"net/http"
"strings"
)
func (h *Handler) getConfig(w http.ResponseWriter, _ *http.Request) {
snap := h.Store.Snapshot()
safe := map[string]any{
"keys": snap.Keys,
"accounts": []map[string]any{},
"claude_mapping": func() map[string]string {
if len(snap.ClaudeMapping) > 0 {
return snap.ClaudeMapping
}
return snap.ClaudeModelMap
}(),
}
accounts := make([]map[string]any, 0, len(snap.Accounts))
for _, acc := range snap.Accounts {
token := strings.TrimSpace(acc.Token)
preview := ""
if token != "" {
if len(token) > 20 {
preview = token[:20] + "..."
} else {
preview = token
}
}
accounts = append(accounts, map[string]any{
"identifier": acc.Identifier(),
"email": acc.Email,
"mobile": acc.Mobile,
"has_password": strings.TrimSpace(acc.Password) != "",
"has_token": token != "",
"token_preview": preview,
})
}
safe["accounts"] = accounts
writeJSON(w, http.StatusOK, safe)
}
func (h *Handler) exportConfig(w http.ResponseWriter, _ *http.Request) {
h.configExport(w, nil)
}
func (h *Handler) configExport(w http.ResponseWriter, _ *http.Request) {
snap := h.Store.Snapshot()
jsonStr, b64, err := h.Store.ExportJSONAndBase64()
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{
"success": true,
"config": snap,
"json": jsonStr,
"base64": b64,
})
}

View File

@@ -0,0 +1,166 @@
package admin
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"ds2api/internal/config"
)
func (h *Handler) updateConfig(w http.ResponseWriter, r *http.Request) {
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
}
old := h.Store.Snapshot()
err := h.Store.Update(func(c *config.Config) error {
if keys, ok := toStringSlice(req["keys"]); ok {
c.Keys = keys
}
if accountsRaw, ok := req["accounts"].([]any); ok {
existing := map[string]config.Account{}
for _, a := range old.Accounts {
existing[a.Identifier()] = a
}
accounts := make([]config.Account, 0, len(accountsRaw))
for _, item := range accountsRaw {
m, ok := item.(map[string]any)
if !ok {
continue
}
acc := toAccount(m)
id := acc.Identifier()
if prev, ok := existing[id]; ok {
if strings.TrimSpace(acc.Password) == "" {
acc.Password = prev.Password
}
if strings.TrimSpace(acc.Token) == "" {
acc.Token = prev.Token
}
}
accounts = append(accounts, acc)
}
c.Accounts = accounts
}
if m, ok := req["claude_mapping"].(map[string]any); ok {
newMap := map[string]string{}
for k, v := range m {
newMap[k] = fmt.Sprintf("%v", v)
}
c.ClaudeMapping = newMap
}
return nil
})
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": err.Error()})
return
}
h.Pool.Reset()
writeJSON(w, http.StatusOK, map[string]any{"success": true, "message": "配置已更新"})
}
func (h *Handler) addKey(w http.ResponseWriter, r *http.Request) {
var req map[string]any
_ = json.NewDecoder(r.Body).Decode(&req)
key, _ := req["key"].(string)
key = strings.TrimSpace(key)
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 {
return fmt.Errorf("Key 已存在")
}
}
c.Keys = append(c.Keys, key)
return nil
})
if err != nil {
writeJSON(w, http.StatusBadRequest, 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, k := range c.Keys {
if k == key {
idx = i
break
}
}
if idx < 0 {
return fmt.Errorf("Key 不存在")
}
c.Keys = append(c.Keys[:idx], c.Keys[idx+1:]...)
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) batchImport(w http.ResponseWriter, r *http.Request) {
var req map[string]any
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "无效的 JSON 格式"})
return
}
importedKeys, importedAccounts := 0, 0
err := h.Store.Update(func(c *config.Config) error {
if keys, ok := req["keys"].([]any); ok {
existing := map[string]bool{}
for _, k := range c.Keys {
existing[k] = true
}
for _, k := range keys {
key := strings.TrimSpace(fmt.Sprintf("%v", k))
if key == "" || existing[key] {
continue
}
c.Keys = append(c.Keys, key)
existing[key] = true
importedKeys++
}
}
if accounts, ok := req["accounts"].([]any); ok {
existing := map[string]bool{}
for _, a := range c.Accounts {
existing[a.Identifier()] = true
}
for _, item := range accounts {
m, ok := item.(map[string]any)
if !ok {
continue
}
acc := toAccount(m)
id := acc.Identifier()
if id == "" || existing[id] {
continue
}
c.Accounts = append(c.Accounts, acc)
existing[id] = true
importedAccounts++
}
}
return nil
})
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": err.Error()})
return
}
h.Pool.Reset()
writeJSON(w, http.StatusOK, map[string]any{"success": true, "imported_keys": importedKeys, "imported_accounts": importedAccounts})
}

View File

@@ -1,321 +0,0 @@
package admin
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
authn "ds2api/internal/auth"
"ds2api/internal/config"
)
func (h *Handler) getSettings(w http.ResponseWriter, _ *http.Request) {
snap := h.Store.Snapshot()
recommended := defaultRuntimeRecommended(len(snap.Accounts), h.Store.RuntimeAccountMaxInflight())
needsSync := config.IsVercel() && snap.VercelSyncHash != "" && snap.VercelSyncHash != h.computeSyncHash()
writeJSON(w, http.StatusOK, map[string]any{
"success": true,
"admin": map[string]any{
"has_password_hash": strings.TrimSpace(snap.Admin.PasswordHash) != "",
"jwt_expire_hours": h.Store.AdminJWTExpireHours(),
"jwt_valid_after_unix": snap.Admin.JWTValidAfterUnix,
"default_password_warning": authn.UsingDefaultAdminKey(h.Store),
},
"runtime": map[string]any{
"account_max_inflight": h.Store.RuntimeAccountMaxInflight(),
"account_max_queue": h.Store.RuntimeAccountMaxQueue(recommended),
"global_max_inflight": h.Store.RuntimeGlobalMaxInflight(recommended),
},
"toolcall": snap.Toolcall,
"responses": snap.Responses,
"embeddings": snap.Embeddings,
"claude_mapping": settingsClaudeMapping(snap),
"model_aliases": snap.ModelAliases,
"env_backed": h.Store.IsEnvBacked(),
"needs_vercel_sync": needsSync,
})
}
func (h *Handler) updateSettings(w http.ResponseWriter, r *http.Request) {
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
}
adminCfg, runtimeCfg, toolcallCfg, responsesCfg, embeddingsCfg, claudeMap, aliasMap, err := parseSettingsUpdateRequest(req)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
return
}
if runtimeCfg != nil {
if err := validateMergedRuntimeSettings(h.Store.Snapshot().Runtime, runtimeCfg); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
return
}
}
if err := h.Store.Update(func(c *config.Config) error {
if adminCfg != nil {
if adminCfg.JWTExpireHours > 0 {
c.Admin.JWTExpireHours = adminCfg.JWTExpireHours
}
}
if runtimeCfg != nil {
if runtimeCfg.AccountMaxInflight > 0 {
c.Runtime.AccountMaxInflight = runtimeCfg.AccountMaxInflight
}
if runtimeCfg.AccountMaxQueue > 0 {
c.Runtime.AccountMaxQueue = runtimeCfg.AccountMaxQueue
}
if runtimeCfg.GlobalMaxInflight > 0 {
c.Runtime.GlobalMaxInflight = runtimeCfg.GlobalMaxInflight
}
}
if toolcallCfg != nil {
if strings.TrimSpace(toolcallCfg.Mode) != "" {
c.Toolcall.Mode = strings.TrimSpace(toolcallCfg.Mode)
}
if strings.TrimSpace(toolcallCfg.EarlyEmitConfidence) != "" {
c.Toolcall.EarlyEmitConfidence = strings.TrimSpace(toolcallCfg.EarlyEmitConfidence)
}
}
if responsesCfg != nil && responsesCfg.StoreTTLSeconds > 0 {
c.Responses.StoreTTLSeconds = responsesCfg.StoreTTLSeconds
}
if embeddingsCfg != nil && strings.TrimSpace(embeddingsCfg.Provider) != "" {
c.Embeddings.Provider = strings.TrimSpace(embeddingsCfg.Provider)
}
if claudeMap != nil {
c.ClaudeMapping = claudeMap
c.ClaudeModelMap = nil
}
if aliasMap != nil {
c.ModelAliases = aliasMap
}
return nil
}); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": err.Error()})
return
}
h.applyRuntimeSettings()
needsSync := config.IsVercel() || h.Store.IsEnvBacked()
writeJSON(w, http.StatusOK, map[string]any{
"success": true,
"message": "settings updated and hot reloaded",
"env_backed": h.Store.IsEnvBacked(),
"needs_vercel_sync": needsSync,
"manual_sync_message": "配置已保存。Vercel 部署请在 Vercel Sync 页面手动同步。",
})
}
func validateMergedRuntimeSettings(current config.RuntimeConfig, incoming *config.RuntimeConfig) error {
merged := current
if incoming != nil {
if incoming.AccountMaxInflight > 0 {
merged.AccountMaxInflight = incoming.AccountMaxInflight
}
if incoming.AccountMaxQueue > 0 {
merged.AccountMaxQueue = incoming.AccountMaxQueue
}
if incoming.GlobalMaxInflight > 0 {
merged.GlobalMaxInflight = incoming.GlobalMaxInflight
}
}
return validateRuntimeSettings(merged)
}
func (h *Handler) updateSettingsPassword(w http.ResponseWriter, r *http.Request) {
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
}
newPassword := strings.TrimSpace(fieldString(req, "new_password"))
if newPassword == "" {
newPassword = strings.TrimSpace(fieldString(req, "password"))
}
if len(newPassword) < 4 {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "new password must be at least 4 characters"})
return
}
now := time.Now().Unix()
hash := authn.HashAdminPassword(newPassword)
if err := h.Store.Update(func(c *config.Config) error {
c.Admin.PasswordHash = hash
c.Admin.JWTValidAfterUnix = now
return nil
}); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{
"success": true,
"message": "password updated",
"force_relogin": true,
"jwt_valid_after_unix": now,
})
}
func (h *Handler) applyRuntimeSettings() {
if h == nil || h.Store == nil || h.Pool == nil {
return
}
accountCount := len(h.Store.Accounts())
maxPer := h.Store.RuntimeAccountMaxInflight()
recommended := defaultRuntimeRecommended(accountCount, maxPer)
maxQueue := h.Store.RuntimeAccountMaxQueue(recommended)
global := h.Store.RuntimeGlobalMaxInflight(recommended)
h.Pool.ApplyRuntimeLimits(maxPer, maxQueue, global)
}
func defaultRuntimeRecommended(accountCount, maxPer int) int {
if maxPer <= 0 {
maxPer = 1
}
if accountCount <= 0 {
return maxPer
}
return accountCount * maxPer
}
func settingsClaudeMapping(c config.Config) map[string]string {
if len(c.ClaudeMapping) > 0 {
return c.ClaudeMapping
}
if len(c.ClaudeModelMap) > 0 {
return c.ClaudeModelMap
}
return map[string]string{"fast": "deepseek-chat", "slow": "deepseek-reasoner"}
}
func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *config.RuntimeConfig, *config.ToolcallConfig, *config.ResponsesConfig, *config.EmbeddingsConfig, map[string]string, map[string]string, error) {
var (
adminCfg *config.AdminConfig
runtimeCfg *config.RuntimeConfig
toolcallCfg *config.ToolcallConfig
respCfg *config.ResponsesConfig
embCfg *config.EmbeddingsConfig
claudeMap map[string]string
aliasMap map[string]string
)
if raw, ok := req["admin"].(map[string]any); ok {
cfg := &config.AdminConfig{}
if v, exists := raw["jwt_expire_hours"]; exists {
n := intFrom(v)
if n < 1 || n > 720 {
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("admin.jwt_expire_hours must be between 1 and 720")
}
cfg.JWTExpireHours = n
}
adminCfg = cfg
}
if raw, ok := req["runtime"].(map[string]any); ok {
cfg := &config.RuntimeConfig{}
if v, exists := raw["account_max_inflight"]; exists {
n := intFrom(v)
if n < 1 || n > 256 {
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.account_max_inflight must be between 1 and 256")
}
cfg.AccountMaxInflight = n
}
if v, exists := raw["account_max_queue"]; exists {
n := intFrom(v)
if n < 1 || n > 200000 {
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.account_max_queue must be between 1 and 200000")
}
cfg.AccountMaxQueue = n
}
if v, exists := raw["global_max_inflight"]; exists {
n := intFrom(v)
if n < 1 || n > 200000 {
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.global_max_inflight must be between 1 and 200000")
}
cfg.GlobalMaxInflight = n
}
if cfg.AccountMaxInflight > 0 && cfg.GlobalMaxInflight > 0 && cfg.GlobalMaxInflight < cfg.AccountMaxInflight {
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.global_max_inflight must be >= runtime.account_max_inflight")
}
runtimeCfg = cfg
}
if raw, ok := req["toolcall"].(map[string]any); ok {
cfg := &config.ToolcallConfig{}
if v, exists := raw["mode"]; exists {
mode := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", v)))
switch mode {
case "feature_match", "off":
cfg.Mode = mode
default:
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("toolcall.mode must be feature_match or off")
}
}
if v, exists := raw["early_emit_confidence"]; exists {
level := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", v)))
switch level {
case "high", "low", "off":
cfg.EarlyEmitConfidence = level
default:
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("toolcall.early_emit_confidence must be high, low or off")
}
}
toolcallCfg = cfg
}
if raw, ok := req["responses"].(map[string]any); ok {
cfg := &config.ResponsesConfig{}
if v, exists := raw["store_ttl_seconds"]; exists {
n := intFrom(v)
if n < 30 || n > 86400 {
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("responses.store_ttl_seconds must be between 30 and 86400")
}
cfg.StoreTTLSeconds = n
}
respCfg = cfg
}
if raw, ok := req["embeddings"].(map[string]any); ok {
cfg := &config.EmbeddingsConfig{}
if v, exists := raw["provider"]; exists {
p := strings.TrimSpace(fmt.Sprintf("%v", v))
if p == "" {
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("embeddings.provider cannot be empty")
}
cfg.Provider = p
}
embCfg = cfg
}
if raw, ok := req["claude_mapping"].(map[string]any); ok {
claudeMap = map[string]string{}
for k, v := range raw {
key := strings.TrimSpace(k)
val := strings.TrimSpace(fmt.Sprintf("%v", v))
if key == "" || val == "" {
continue
}
claudeMap[key] = val
}
}
if raw, ok := req["model_aliases"].(map[string]any); ok {
aliasMap = map[string]string{}
for k, v := range raw {
key := strings.TrimSpace(k)
val := strings.TrimSpace(fmt.Sprintf("%v", v))
if key == "" || val == "" {
continue
}
aliasMap[key] = val
}
}
return adminCfg, runtimeCfg, toolcallCfg, respCfg, embCfg, claudeMap, aliasMap, nil
}

View File

@@ -0,0 +1,134 @@
package admin
import (
"fmt"
"strings"
"ds2api/internal/config"
)
func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *config.RuntimeConfig, *config.ToolcallConfig, *config.ResponsesConfig, *config.EmbeddingsConfig, map[string]string, map[string]string, error) {
var (
adminCfg *config.AdminConfig
runtimeCfg *config.RuntimeConfig
toolcallCfg *config.ToolcallConfig
respCfg *config.ResponsesConfig
embCfg *config.EmbeddingsConfig
claudeMap map[string]string
aliasMap map[string]string
)
if raw, ok := req["admin"].(map[string]any); ok {
cfg := &config.AdminConfig{}
if v, exists := raw["jwt_expire_hours"]; exists {
n := intFrom(v)
if n < 1 || n > 720 {
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("admin.jwt_expire_hours must be between 1 and 720")
}
cfg.JWTExpireHours = n
}
adminCfg = cfg
}
if raw, ok := req["runtime"].(map[string]any); ok {
cfg := &config.RuntimeConfig{}
if v, exists := raw["account_max_inflight"]; exists {
n := intFrom(v)
if n < 1 || n > 256 {
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.account_max_inflight must be between 1 and 256")
}
cfg.AccountMaxInflight = n
}
if v, exists := raw["account_max_queue"]; exists {
n := intFrom(v)
if n < 1 || n > 200000 {
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.account_max_queue must be between 1 and 200000")
}
cfg.AccountMaxQueue = n
}
if v, exists := raw["global_max_inflight"]; exists {
n := intFrom(v)
if n < 1 || n > 200000 {
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.global_max_inflight must be between 1 and 200000")
}
cfg.GlobalMaxInflight = n
}
if cfg.AccountMaxInflight > 0 && cfg.GlobalMaxInflight > 0 && cfg.GlobalMaxInflight < cfg.AccountMaxInflight {
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.global_max_inflight must be >= runtime.account_max_inflight")
}
runtimeCfg = cfg
}
if raw, ok := req["toolcall"].(map[string]any); ok {
cfg := &config.ToolcallConfig{}
if v, exists := raw["mode"]; exists {
mode := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", v)))
switch mode {
case "feature_match", "off":
cfg.Mode = mode
default:
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("toolcall.mode must be feature_match or off")
}
}
if v, exists := raw["early_emit_confidence"]; exists {
level := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", v)))
switch level {
case "high", "low", "off":
cfg.EarlyEmitConfidence = level
default:
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("toolcall.early_emit_confidence must be high, low or off")
}
}
toolcallCfg = cfg
}
if raw, ok := req["responses"].(map[string]any); ok {
cfg := &config.ResponsesConfig{}
if v, exists := raw["store_ttl_seconds"]; exists {
n := intFrom(v)
if n < 30 || n > 86400 {
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("responses.store_ttl_seconds must be between 30 and 86400")
}
cfg.StoreTTLSeconds = n
}
respCfg = cfg
}
if raw, ok := req["embeddings"].(map[string]any); ok {
cfg := &config.EmbeddingsConfig{}
if v, exists := raw["provider"]; exists {
p := strings.TrimSpace(fmt.Sprintf("%v", v))
if p == "" {
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("embeddings.provider cannot be empty")
}
cfg.Provider = p
}
embCfg = cfg
}
if raw, ok := req["claude_mapping"].(map[string]any); ok {
claudeMap = map[string]string{}
for k, v := range raw {
key := strings.TrimSpace(k)
val := strings.TrimSpace(fmt.Sprintf("%v", v))
if key == "" || val == "" {
continue
}
claudeMap[key] = val
}
}
if raw, ok := req["model_aliases"].(map[string]any); ok {
aliasMap = map[string]string{}
for k, v := range raw {
key := strings.TrimSpace(k)
val := strings.TrimSpace(fmt.Sprintf("%v", v))
if key == "" || val == "" {
continue
}
aliasMap[key] = val
}
}
return adminCfg, runtimeCfg, toolcallCfg, respCfg, embCfg, claudeMap, aliasMap, nil
}

View File

@@ -0,0 +1,36 @@
package admin
import (
"net/http"
"strings"
authn "ds2api/internal/auth"
"ds2api/internal/config"
)
func (h *Handler) getSettings(w http.ResponseWriter, _ *http.Request) {
snap := h.Store.Snapshot()
recommended := defaultRuntimeRecommended(len(snap.Accounts), h.Store.RuntimeAccountMaxInflight())
needsSync := config.IsVercel() && snap.VercelSyncHash != "" && snap.VercelSyncHash != h.computeSyncHash()
writeJSON(w, http.StatusOK, map[string]any{
"success": true,
"admin": map[string]any{
"has_password_hash": strings.TrimSpace(snap.Admin.PasswordHash) != "",
"jwt_expire_hours": h.Store.AdminJWTExpireHours(),
"jwt_valid_after_unix": snap.Admin.JWTValidAfterUnix,
"default_password_warning": authn.UsingDefaultAdminKey(h.Store),
},
"runtime": map[string]any{
"account_max_inflight": h.Store.RuntimeAccountMaxInflight(),
"account_max_queue": h.Store.RuntimeAccountMaxQueue(recommended),
"global_max_inflight": h.Store.RuntimeGlobalMaxInflight(recommended),
},
"toolcall": snap.Toolcall,
"responses": snap.Responses,
"embeddings": snap.Embeddings,
"claude_mapping": settingsClaudeMapping(snap),
"model_aliases": snap.ModelAliases,
"env_backed": h.Store.IsEnvBacked(),
"needs_vercel_sync": needsSync,
})
}

View File

@@ -0,0 +1,51 @@
package admin
import "ds2api/internal/config"
func validateMergedRuntimeSettings(current config.RuntimeConfig, incoming *config.RuntimeConfig) error {
merged := current
if incoming != nil {
if incoming.AccountMaxInflight > 0 {
merged.AccountMaxInflight = incoming.AccountMaxInflight
}
if incoming.AccountMaxQueue > 0 {
merged.AccountMaxQueue = incoming.AccountMaxQueue
}
if incoming.GlobalMaxInflight > 0 {
merged.GlobalMaxInflight = incoming.GlobalMaxInflight
}
}
return validateRuntimeSettings(merged)
}
func (h *Handler) applyRuntimeSettings() {
if h == nil || h.Store == nil || h.Pool == nil {
return
}
accountCount := len(h.Store.Accounts())
maxPer := h.Store.RuntimeAccountMaxInflight()
recommended := defaultRuntimeRecommended(accountCount, maxPer)
maxQueue := h.Store.RuntimeAccountMaxQueue(recommended)
global := h.Store.RuntimeGlobalMaxInflight(recommended)
h.Pool.ApplyRuntimeLimits(maxPer, maxQueue, global)
}
func defaultRuntimeRecommended(accountCount, maxPer int) int {
if maxPer <= 0 {
maxPer = 1
}
if accountCount <= 0 {
return maxPer
}
return accountCount * maxPer
}
func settingsClaudeMapping(c config.Config) map[string]string {
if len(c.ClaudeMapping) > 0 {
return c.ClaudeMapping
}
if len(c.ClaudeModelMap) > 0 {
return c.ClaudeModelMap
}
return map[string]string{"fast": "deepseek-chat", "slow": "deepseek-reasoner"}
}

View File

@@ -0,0 +1,119 @@
package admin
import (
"encoding/json"
"net/http"
"strings"
"time"
authn "ds2api/internal/auth"
"ds2api/internal/config"
)
func (h *Handler) updateSettings(w http.ResponseWriter, r *http.Request) {
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
}
adminCfg, runtimeCfg, toolcallCfg, responsesCfg, embeddingsCfg, claudeMap, aliasMap, err := parseSettingsUpdateRequest(req)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
return
}
if runtimeCfg != nil {
if err := validateMergedRuntimeSettings(h.Store.Snapshot().Runtime, runtimeCfg); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
return
}
}
if err := h.Store.Update(func(c *config.Config) error {
if adminCfg != nil {
if adminCfg.JWTExpireHours > 0 {
c.Admin.JWTExpireHours = adminCfg.JWTExpireHours
}
}
if runtimeCfg != nil {
if runtimeCfg.AccountMaxInflight > 0 {
c.Runtime.AccountMaxInflight = runtimeCfg.AccountMaxInflight
}
if runtimeCfg.AccountMaxQueue > 0 {
c.Runtime.AccountMaxQueue = runtimeCfg.AccountMaxQueue
}
if runtimeCfg.GlobalMaxInflight > 0 {
c.Runtime.GlobalMaxInflight = runtimeCfg.GlobalMaxInflight
}
}
if toolcallCfg != nil {
if strings.TrimSpace(toolcallCfg.Mode) != "" {
c.Toolcall.Mode = strings.TrimSpace(toolcallCfg.Mode)
}
if strings.TrimSpace(toolcallCfg.EarlyEmitConfidence) != "" {
c.Toolcall.EarlyEmitConfidence = strings.TrimSpace(toolcallCfg.EarlyEmitConfidence)
}
}
if responsesCfg != nil && responsesCfg.StoreTTLSeconds > 0 {
c.Responses.StoreTTLSeconds = responsesCfg.StoreTTLSeconds
}
if embeddingsCfg != nil && strings.TrimSpace(embeddingsCfg.Provider) != "" {
c.Embeddings.Provider = strings.TrimSpace(embeddingsCfg.Provider)
}
if claudeMap != nil {
c.ClaudeMapping = claudeMap
c.ClaudeModelMap = nil
}
if aliasMap != nil {
c.ModelAliases = aliasMap
}
return nil
}); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": err.Error()})
return
}
h.applyRuntimeSettings()
needsSync := config.IsVercel() || h.Store.IsEnvBacked()
writeJSON(w, http.StatusOK, map[string]any{
"success": true,
"message": "settings updated and hot reloaded",
"env_backed": h.Store.IsEnvBacked(),
"needs_vercel_sync": needsSync,
"manual_sync_message": "配置已保存。Vercel 部署请在 Vercel Sync 页面手动同步。",
})
}
func (h *Handler) updateSettingsPassword(w http.ResponseWriter, r *http.Request) {
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
}
newPassword := strings.TrimSpace(fieldString(req, "new_password"))
if newPassword == "" {
newPassword = strings.TrimSpace(fieldString(req, "password"))
}
if len(newPassword) < 4 {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "new password must be at least 4 characters"})
return
}
now := time.Now().Unix()
hash := authn.HashAdminPassword(newPassword)
if err := h.Store.Update(func(c *config.Config) error {
c.Admin.PasswordHash = hash
c.Admin.JWTValidAfterUnix = now
return nil
}); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{
"success": true,
"message": "password updated",
"force_relogin": true,
"jwt_valid_after_unix": now,
})
}