refactor backend API structure

This commit is contained in:
CJACK
2026-04-26 06:58:20 +08:00
parent 8a91fef6ab
commit abc96a37d8
207 changed files with 2675 additions and 1344 deletions

View File

@@ -0,0 +1,46 @@
package accounts
import (
"net/http"
"ds2api/internal/chathistory"
"ds2api/internal/config"
adminshared "ds2api/internal/httpapi/admin/shared"
)
type Handler struct {
Store adminshared.ConfigStore
Pool adminshared.PoolController
DS adminshared.DeepSeekCaller
OpenAI adminshared.OpenAIChatCaller
ChatHistory *chathistory.Store
}
var writeJSON = adminshared.WriteJSON
func reverseAccounts(a []config.Account) { adminshared.ReverseAccounts(a) }
func intFromQuery(r *http.Request, key string, d int) int {
return adminshared.IntFromQuery(r, key, d)
}
func maskSecretPreview(secret string) string {
return adminshared.MaskSecretPreview(secret)
}
func toAccount(m map[string]any) config.Account {
return adminshared.ToAccount(m)
}
func fieldStringOptional(m map[string]any, key string) (string, bool) {
return adminshared.FieldStringOptional(m, key)
}
func accountMatchesIdentifier(acc config.Account, identifier string) bool {
return adminshared.AccountMatchesIdentifier(acc, identifier)
}
func findProxyByID(c config.Config, proxyID string) (config.Proxy, bool) {
return adminshared.FindProxyByID(c, proxyID)
}
func findAccountByIdentifier(store adminshared.ConfigStore, identifier string) (config.Account, bool) {
return adminshared.FindAccountByIdentifier(store, identifier)
}
func newRequestError(detail string) error { return adminshared.NewRequestError(detail) }
func requestErrorDetail(err error) (string, bool) {
return adminshared.RequestErrorDetail(err)
}

View File

@@ -0,0 +1,176 @@
package accounts
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"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 > 5000 {
pageSize = 5000
}
accounts := h.Store.Snapshot().Accounts
reverseAccounts(accounts)
q := strings.TrimSpace(strings.ToLower(r.URL.Query().Get("q")))
if q != "" {
filtered := make([]config.Account, 0, len(accounts))
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)
}
}
accounts = filtered
}
total := len(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] {
testStatus, _ := h.Store.AccountTestStatus(acc.Identifier())
token := strings.TrimSpace(acc.Token)
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,
"has_password": acc.Password != "",
"has_token": token != "",
"token_preview": maskSecretPreview(token),
"test_status": testStatus,
})
}
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 {
if acc.ProxyID != "" {
if _, ok := findProxyByID(*c, acc.ProxyID); !ok {
return fmt.Errorf("代理不存在")
}
}
mobileKey := config.CanonicalMobileKey(acc.Mobile)
for _, a := range c.Accounts {
if acc.Email != "" && a.Email == acc.Email {
return fmt.Errorf("邮箱已存在")
}
if mobileKey != "" && config.CanonicalMobileKey(a.Mobile) == mobileKey {
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) updateAccount(w http.ResponseWriter, r *http.Request) {
identifier := chi.URLParam(r, "identifier")
if decoded, err := url.PathUnescape(identifier); err == nil {
identifier = decoded
}
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 {
for i, acc := range c.Accounts {
if !accountMatchesIdentifier(acc, identifier) {
continue
}
if nameOK {
c.Accounts[i].Name = name
}
if remarkOK {
c.Accounts[i].Remark = remark
}
return nil
}
return newRequestError("账号不存在")
})
if err != nil {
if detail, ok := requestErrorDetail(err); ok {
writeJSON(w, http.StatusNotFound, map[string]any{"detail": detail})
return
}
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
return
}
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")
if decoded, err := url.PathUnescape(identifier); err == nil {
identifier = decoded
}
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,118 @@
package accounts
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi/v5"
)
func TestListAccountsPageSizeCapIs5000(t *testing.T) {
accounts := make([]string, 0, 150)
for i := range 150 {
accounts = append(accounts, fmt.Sprintf(`{"email":"u%d@example.com","password":"pwd"}`, i))
}
raw := fmt.Sprintf(`{"accounts":[%s]}`, strings.Join(accounts, ","))
router := newHTTPAdminHarness(t, raw, &testingDSMock{})
rec := httptest.NewRecorder()
router.ServeHTTP(rec, adminReq(http.MethodGet, "/accounts?page=1&page_size=200", nil))
if rec.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String())
}
var payload map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
items, _ := payload["items"].([]any)
if len(items) != 150 {
t.Fatalf("expected all 150 accounts with page_size=200, got %d", len(items))
}
if ps, _ := payload["page_size"].(float64); ps != 200 {
t.Fatalf("expected page_size=200 in response, got %v", payload["page_size"])
}
}
func TestListAccountsPageSizeAbove5000ClampedTo5000(t *testing.T) {
router := newHTTPAdminHarness(t, `{"accounts":[{"email":"u@example.com","password":"pwd"}]}`, &testingDSMock{})
rec := httptest.NewRecorder()
router.ServeHTTP(rec, adminReq(http.MethodGet, "/accounts?page=1&page_size=9999", nil))
if rec.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String())
}
var payload map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if ps, _ := payload["page_size"].(float64); ps != 5000 {
t.Fatalf("expected page_size clamped to 5000, got %v", payload["page_size"])
}
}
func TestUpdateAccountMetadataPreservesCredentials(t *testing.T) {
h := newAdminTestHandler(t, `{
"accounts":[{"email":"u@example.com","name":"old name","remark":"old remark","password":"secret"}]
}`)
r := chi.NewRouter()
r.Put("/admin/accounts/{identifier}", h.updateAccount)
body := []byte(`{"name":"new name","remark":"new remark"}`)
req := httptest.NewRequest(http.MethodPut, "/admin/accounts/u@example.com", strings.NewReader(string(body)))
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String())
}
snap := h.Store.Snapshot()
if len(snap.Accounts) != 1 {
t.Fatalf("unexpected accounts after update: %#v", snap.Accounts)
}
acc := snap.Accounts[0]
if acc.Email != "u@example.com" {
t.Fatalf("identifier changed unexpectedly: %#v", acc)
}
if acc.Name != "new name" || acc.Remark != "new remark" {
t.Fatalf("metadata update did not persist: %#v", acc)
}
if acc.Password != "secret" {
t.Fatalf("password should be preserved, got %#v", acc)
}
}
func TestListAccountsMasksTokenPreview(t *testing.T) {
h := newAdminTestHandler(t, `{
"accounts":[{"email":"u@example.com","password":"pwd"}]
}`)
if err := h.Store.UpdateAccountToken("u@example.com", "abcdefgh"); err != nil {
t.Fatalf("seed runtime token: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/admin/accounts?page=1&page_size=10", nil)
rec := httptest.NewRecorder()
h.listAccounts(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String())
}
var payload map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response failed: %v", err)
}
items, _ := payload["items"].([]any)
if len(items) != 1 {
t.Fatalf("expected 1 item, got %d", len(items))
}
first, _ := items[0].(map[string]any)
if got, _ := first["token_preview"].(string); got != "ab****gh" {
t.Fatalf("expected masked token preview, got %q", got)
}
}

View File

@@ -0,0 +1,135 @@
package accounts
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/go-chi/chi/v5"
"ds2api/internal/account"
"ds2api/internal/config"
)
func newAdminTestHandler(t *testing.T, raw string) *Handler {
t.Helper()
t.Setenv("DS2API_CONFIG_JSON", raw)
store := config.LoadStore()
return &Handler{
Store: store,
Pool: account.NewPool(store),
}
}
func TestListAccountsUsesEmailIdentifier(t *testing.T) {
h := newAdminTestHandler(t, `{
"accounts":[{"email":"u@example.com","password":"pwd"}]
}`)
req := httptest.NewRequest(http.MethodGet, "/admin/accounts?page=1&page_size=10", nil)
rec := httptest.NewRecorder()
h.listAccounts(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String())
}
var payload map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response failed: %v", err)
}
items, _ := payload["items"].([]any)
if len(items) != 1 {
t.Fatalf("expected 1 item, got %d", len(items))
}
first, _ := items[0].(map[string]any)
identifier, _ := first["identifier"].(string)
if identifier != "u@example.com" {
t.Fatalf("expected email identifier, got %q", identifier)
}
}
func TestDeleteAccountSupportsMobileAlias(t *testing.T) {
h := newAdminTestHandler(t, `{
"accounts":[{"email":"u@example.com","mobile":"13800138000","password":"pwd"}]
}`)
r := chi.NewRouter()
r.Delete("/admin/accounts/{identifier}", h.deleteAccount)
req := httptest.NewRequest(http.MethodDelete, "/admin/accounts/13800138000", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String())
}
if got := len(h.Store.Accounts()); got != 0 {
t.Fatalf("expected account removed, remaining=%d", got)
}
}
func TestDeleteAccountSupportsEncodedPlusMobile(t *testing.T) {
h := newAdminTestHandler(t, `{
"accounts":[{"mobile":"+8613800138000","password":"pwd"}]
}`)
r := chi.NewRouter()
r.Delete("/admin/accounts/{identifier}", h.deleteAccount)
req := httptest.NewRequest(http.MethodDelete, "/admin/accounts/"+url.PathEscape("+8613800138000"), nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String())
}
if got := len(h.Store.Accounts()); got != 0 {
t.Fatalf("expected account removed, remaining=%d", got)
}
}
func TestAddAccountRejectsCanonicalMobileDuplicate(t *testing.T) {
h := newAdminTestHandler(t, `{
"accounts":[{"mobile":"+8613800138000","password":"pwd"}]
}`)
r := chi.NewRouter()
r.Post("/admin/accounts", h.addAccount)
body := []byte(`{"mobile":"13800138000","password":"pwd2"}`)
req := httptest.NewRequest(http.MethodPost, "/admin/accounts", bytes.NewReader(body))
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String())
}
if got := len(h.Store.Accounts()); got != 1 {
t.Fatalf("expected no duplicate insert, got=%d", got)
}
}
func TestFindAccountByIdentifierSupportsMobile(t *testing.T) {
h := newAdminTestHandler(t, `{
"accounts":[
{"email":"u@example.com","mobile":"13800138000","password":"pwd"}
]
}`)
accByMobile, ok := findAccountByIdentifier(h.Store, "13800138000")
if !ok {
t.Fatal("expected find by mobile")
}
if accByMobile.Email != "u@example.com" {
t.Fatalf("unexpected account by mobile: %#v", accByMobile)
}
accByMobileWithCountryCode, ok := findAccountByIdentifier(h.Store, "+8613800138000")
if !ok {
t.Fatal("expected find by +86 mobile")
}
if accByMobileWithCountryCode.Email != "u@example.com" {
t.Fatalf("unexpected account by +86 mobile: %#v", accByMobileWithCountryCode)
}
}

View File

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

View File

@@ -0,0 +1,297 @@
package accounts
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
authn "ds2api/internal/auth"
"ds2api/internal/config"
"ds2api/internal/prompt"
"ds2api/internal/promptcompat"
"ds2api/internal/sse"
)
type modelAliasSnapshotReader struct {
aliases map[string]string
}
func (m modelAliasSnapshotReader) ModelAliases() map[string]string {
return m.aliases
}
func (h *Handler) testSingleAccount(w http.ResponseWriter, r *http.Request) {
var req map[string]any
_ = json.NewDecoder(r.Body).Decode(&req)
identifier, _ := req["identifier"].(string)
if strings.TrimSpace(identifier) == "" {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "需要账号标识identifier / email / mobile"})
return
}
acc, ok := findAccountByIdentifier(h.Store, identifier)
if !ok {
writeJSON(w, http.StatusNotFound, map[string]any{"detail": "账号不存在"})
return
}
model, _ := req["model"].(string)
if model == "" {
model = "deepseek-v4-flash"
}
message, _ := req["message"].(string)
result := h.testAccount(r.Context(), acc, model, message)
writeJSON(w, http.StatusOK, result)
}
func (h *Handler) testAllAccounts(w http.ResponseWriter, r *http.Request) {
var req map[string]any
_ = json.NewDecoder(r.Body).Decode(&req)
model, _ := req["model"].(string)
if model == "" {
model = "deepseek-v4-flash"
}
accounts := h.Store.Snapshot().Accounts
if len(accounts) == 0 {
writeJSON(w, http.StatusOK, map[string]any{"total": 0, "success": 0, "failed": 0, "results": []any{}})
return
}
// Concurrent testing with a semaphore to limit parallelism.
const maxConcurrency = 5
results := runAccountTestsConcurrently(accounts, maxConcurrency, func(_ int, account config.Account) map[string]any {
return h.testAccount(r.Context(), account, model, "")
})
success := 0
for _, res := range results {
if ok, _ := res["success"].(bool); ok {
success++
}
}
writeJSON(w, http.StatusOK, map[string]any{"total": len(accounts), "success": success, "failed": len(accounts) - success, "results": results})
}
func runAccountTestsConcurrently(accounts []config.Account, maxConcurrency int, testFn func(int, config.Account) map[string]any) []map[string]any {
if maxConcurrency <= 0 {
maxConcurrency = 1
}
sem := make(chan struct{}, maxConcurrency)
results := make([]map[string]any, len(accounts))
var wg sync.WaitGroup
for i, acc := range accounts {
wg.Add(1)
go func(idx int, account config.Account) {
defer wg.Done()
sem <- struct{}{} // acquire
defer func() { <-sem }() // release
results[idx] = testFn(idx, account)
}(i, acc)
}
wg.Wait()
return results
}
func (h *Handler) testAccount(ctx context.Context, acc config.Account, model, message string) map[string]any {
start := time.Now()
identifier := acc.Identifier()
result := map[string]any{
"account": identifier,
"success": false,
"response_time": 0,
"message": "",
"model": model,
"session_count": 0,
"config_writable": !h.Store.IsEnvBacked(),
}
defer func() {
status := "failed"
if ok, _ := result["success"].(bool); ok {
status = "ok"
}
_ = h.Store.UpdateAccountTestStatus(identifier, status)
}()
token, err := h.DS.Login(ctx, acc)
if err != nil {
result["message"] = "登录失败: " + err.Error()
return result
}
if err := h.Store.UpdateAccountToken(acc.Identifier(), token); err != nil {
result["message"] = "登录成功但写入运行时 token 失败: " + err.Error()
return result
}
authCtx := &authn.RequestAuth{UseConfigToken: false, DeepSeekToken: token, AccountID: identifier, Account: acc}
proxyCtx := authn.WithAuth(ctx, authCtx)
sessionID, err := h.DS.CreateSession(proxyCtx, authCtx, 1)
if err != nil {
newToken, loginErr := h.DS.Login(proxyCtx, acc)
if loginErr != nil {
result["message"] = "创建会话失败: " + err.Error()
return result
}
token = newToken
authCtx.DeepSeekToken = token
if err := h.Store.UpdateAccountToken(acc.Identifier(), token); err != nil {
result["message"] = "刷新 token 成功但写入运行时 token 失败: " + err.Error()
return result
}
sessionID, err = h.DS.CreateSession(proxyCtx, authCtx, 1)
if err != nil {
result["message"] = "创建会话失败: " + err.Error()
return result
}
}
// 获取会话数量
sessionStats, sessionErr := h.DS.GetSessionCountForToken(proxyCtx, token)
if sessionErr == nil && sessionStats != nil {
result["session_count"] = sessionStats.FirstPageCount
}
if strings.TrimSpace(message) == "" {
result["success"] = true
result["message"] = "Token 刷新成功(登录与会话创建成功)"
result["response_time"] = int(time.Since(start).Milliseconds())
return result
}
thinking, search, ok := config.GetModelConfig(model)
resolvedModel, resolved := config.ResolveModel(modelAliasSnapshotReader{
aliases: h.Store.Snapshot().ModelAliases,
}, model)
if resolved {
model = resolvedModel
thinking, search, ok = config.GetModelConfig(model)
}
if !ok {
thinking, search = false, false
}
pow, err := h.DS.GetPow(proxyCtx, authCtx, 1)
if err != nil {
result["message"] = "获取 PoW 失败: " + err.Error()
return result
}
payload := promptcompat.StandardRequest{
ResolvedModel: model,
FinalPrompt: prompt.MessagesPrepare([]map[string]any{{"role": "user", "content": message}}),
Thinking: thinking,
Search: search,
}.CompletionPayload(sessionID)
resp, err := h.DS.CallCompletion(proxyCtx, authCtx, payload, pow, 1)
if err != nil {
result["message"] = "请求失败: " + err.Error()
return result
}
if resp.StatusCode != http.StatusOK {
defer func() { _ = resp.Body.Close() }()
result["message"] = fmt.Sprintf("请求失败: HTTP %d", resp.StatusCode)
return result
}
collected := sse.CollectStream(resp, thinking, true)
result["success"] = true
result["response_time"] = int(time.Since(start).Milliseconds())
if collected.Text != "" {
result["message"] = collected.Text
} else {
result["message"] = "(无回复内容)"
}
if collected.Thinking != "" {
result["thinking"] = collected.Thinking
}
return result
}
func (h *Handler) testAPI(w http.ResponseWriter, r *http.Request) {
var req map[string]any
_ = json.NewDecoder(r.Body).Decode(&req)
model, _ := req["model"].(string)
message, _ := req["message"].(string)
apiKey, _ := req["api_key"].(string)
if model == "" {
model = "deepseek-v4-flash"
}
if message == "" {
message = "你好"
}
if apiKey == "" {
keys := h.Store.Snapshot().Keys
if len(keys) == 0 {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "没有可用的 API Key"})
return
}
apiKey = keys[0]
}
host := r.Host
scheme := "http"
if strings.Contains(strings.ToLower(host), "vercel") || strings.Contains(strings.ToLower(r.Header.Get("X-Forwarded-Proto")), "https") {
scheme = "https"
}
payload := map[string]any{"model": model, "messages": []map[string]any{{"role": "user", "content": message}}, "stream": false}
b, _ := json.Marshal(payload)
request, _ := http.NewRequestWithContext(r.Context(), http.MethodPost, fmt.Sprintf("%s://%s/v1/chat/completions", scheme, host), bytes.NewReader(b))
request.Header.Set("Authorization", "Bearer "+apiKey)
request.Header.Set("Content-Type", "application/json")
resp, err := (&http.Client{Timeout: 60 * time.Second}).Do(request)
if err != nil {
writeJSON(w, http.StatusOK, map[string]any{"success": false, "error": err.Error()})
return
}
defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode == http.StatusOK {
var parsed any
_ = json.Unmarshal(body, &parsed)
writeJSON(w, http.StatusOK, map[string]any{"success": true, "status_code": resp.StatusCode, "response": parsed})
return
}
writeJSON(w, http.StatusOK, map[string]any{"success": false, "status_code": resp.StatusCode, "response": string(body)})
}
func (h *Handler) deleteAllSessions(w http.ResponseWriter, r *http.Request) {
var req map[string]any
_ = json.NewDecoder(r.Body).Decode(&req)
identifier, _ := req["identifier"].(string)
if strings.TrimSpace(identifier) == "" {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "需要账号标识identifier / email / mobile"})
return
}
acc, ok := findAccountByIdentifier(h.Store, identifier)
if !ok {
writeJSON(w, http.StatusNotFound, map[string]any{"detail": "账号不存在"})
return
}
// 每次先登录刷新一次 token避免使用过期 token。
authCtx := &authn.RequestAuth{UseConfigToken: false, AccountID: acc.Identifier(), Account: acc}
proxyCtx := authn.WithAuth(r.Context(), authCtx)
token, err := h.DS.Login(proxyCtx, acc)
if err != nil {
writeJSON(w, http.StatusOK, map[string]any{"success": false, "message": "登录失败: " + err.Error()})
return
}
_ = h.Store.UpdateAccountToken(acc.Identifier(), token)
authCtx.DeepSeekToken = token
// 删除所有会话
err = h.DS.DeleteAllSessionsForToken(proxyCtx, token)
if err != nil {
// token 可能过期,尝试重新登录并重试一次
newToken, loginErr := h.DS.Login(proxyCtx, acc)
if loginErr != nil {
writeJSON(w, http.StatusOK, map[string]any{"success": false, "message": "删除失败: " + err.Error()})
return
}
token = newToken
_ = h.Store.UpdateAccountToken(acc.Identifier(), token)
authCtx.DeepSeekToken = token
if retryErr := h.DS.DeleteAllSessionsForToken(proxyCtx, token); retryErr != nil {
writeJSON(w, http.StatusOK, map[string]any{"success": false, "message": "删除失败: " + retryErr.Error()})
return
}
}
writeJSON(w, http.StatusOK, map[string]any{"success": true, "message": "删除成功"})
}

View File

@@ -0,0 +1,211 @@
package accounts
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"ds2api/internal/auth"
"ds2api/internal/config"
dsclient "ds2api/internal/deepseek/client"
)
type testingDSMock struct {
loginCalls int
createSessionCalls int
getPowCalls int
callCompletionCalls int
deleteAllSessionsCalls int
deleteAllSessionsError error
deleteAllSessionsErrorOnce bool
}
func (m *testingDSMock) Login(_ context.Context, _ config.Account) (string, error) {
m.loginCalls++
return "new-token", nil
}
func (m *testingDSMock) CreateSession(_ context.Context, _ *auth.RequestAuth, _ int) (string, error) {
m.createSessionCalls++
return "session-id", nil
}
func (m *testingDSMock) GetPow(_ context.Context, _ *auth.RequestAuth, _ int) (string, error) {
m.getPowCalls++
return "", errors.New("should not call GetPow in this test")
}
func (m *testingDSMock) CallCompletion(_ context.Context, _ *auth.RequestAuth, _ map[string]any, _ string, _ int) (*http.Response, error) {
m.callCompletionCalls++
return nil, errors.New("should not call CallCompletion in this test")
}
func (m *testingDSMock) DeleteAllSessionsForToken(_ context.Context, _ string) error {
m.deleteAllSessionsCalls++
if m.deleteAllSessionsError != nil {
err := m.deleteAllSessionsError
if m.deleteAllSessionsErrorOnce {
m.deleteAllSessionsError = nil
}
return err
}
return nil
}
func (m *testingDSMock) GetSessionCountForToken(_ context.Context, _ string) (*dsclient.SessionStats, error) {
return &dsclient.SessionStats{Success: true}, nil
}
func TestTestAccount_BatchModeOnlyCreatesSession(t *testing.T) {
t.Setenv("DS2API_CONFIG_JSON", `{"accounts":[{"email":"batch@example.com","password":"pwd","token":""}]}`)
store := config.LoadStore()
ds := &testingDSMock{}
h := &Handler{Store: store, DS: ds}
acc, ok := store.FindAccount("batch@example.com")
if !ok {
t.Fatal("expected test account")
}
result := h.testAccount(context.Background(), acc, "deepseek-v4-flash", "")
if ok, _ := result["success"].(bool); !ok {
t.Fatalf("expected success=true, got %#v", result)
}
msg, _ := result["message"].(string)
if !strings.Contains(msg, "Token 刷新成功") {
t.Fatalf("expected session-only success message, got %q", msg)
}
if ds.loginCalls != 1 || ds.createSessionCalls != 1 {
t.Fatalf("unexpected Login/CreateSession calls: login=%d createSession=%d", ds.loginCalls, ds.createSessionCalls)
}
if ds.getPowCalls != 0 || ds.callCompletionCalls != 0 {
t.Fatalf("expected no completion flow calls, got getPow=%d callCompletion=%d", ds.getPowCalls, ds.callCompletionCalls)
}
updated, ok := store.FindAccount("batch@example.com")
if !ok {
t.Fatal("expected updated account")
}
if updated.Token != "new-token" {
t.Fatalf("expected refreshed token to be persisted, got %q", updated.Token)
}
testStatus, ok := store.AccountTestStatus("batch@example.com")
if !ok || testStatus != "ok" {
t.Fatalf("expected runtime test status ok, got %q (ok=%v)", testStatus, ok)
}
}
func TestDeleteAllSessions_RetryWithReloginOnDeleteFailure(t *testing.T) {
t.Setenv("DS2API_CONFIG_JSON", `{"accounts":[{"email":"batch@example.com","password":"pwd","token":"expired-token"}]}`)
store := config.LoadStore()
ds := &testingDSMock{deleteAllSessionsError: errors.New("token expired"), deleteAllSessionsErrorOnce: true}
h := &Handler{Store: store, DS: ds}
req := httptest.NewRequest(http.MethodPost, "/delete-all", bytes.NewBufferString(`{"identifier":"batch@example.com"}`))
rec := httptest.NewRecorder()
h.deleteAllSessions(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", rec.Code)
}
var resp map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if ok, _ := resp["success"].(bool); !ok {
t.Fatalf("expected success response, got %#v", resp)
}
if ds.loginCalls != 2 {
t.Fatalf("expected initial login plus relogin, got %d", ds.loginCalls)
}
if ds.deleteAllSessionsCalls != 2 {
t.Fatalf("expected delete called twice, got %d", ds.deleteAllSessionsCalls)
}
updated, ok := store.FindAccount("batch@example.com")
if !ok {
t.Fatal("expected account")
}
if updated.Token != "new-token" {
t.Fatalf("expected refreshed token persisted, got %q", updated.Token)
}
}
type completionPayloadDSMock struct {
payload map[string]any
}
func (m *completionPayloadDSMock) Login(_ context.Context, _ config.Account) (string, error) {
return "new-token", nil
}
func (m *completionPayloadDSMock) CreateSession(_ context.Context, _ *auth.RequestAuth, _ int) (string, error) {
return "session-id", nil
}
func (m *completionPayloadDSMock) GetPow(_ context.Context, _ *auth.RequestAuth, _ int) (string, error) {
return "pow-ok", nil
}
func (m *completionPayloadDSMock) CallCompletion(_ context.Context, _ *auth.RequestAuth, payload map[string]any, _ string, _ int) (*http.Response, error) {
m.payload = payload
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader("data: {\"v\":\"ok\"}\n\ndata: [DONE]\n\n")),
}, nil
}
func (m *completionPayloadDSMock) DeleteAllSessionsForToken(_ context.Context, _ string) error {
return nil
}
func (m *completionPayloadDSMock) GetSessionCountForToken(_ context.Context, _ string) (*dsclient.SessionStats, error) {
return &dsclient.SessionStats{Success: true}, nil
}
func TestTestAccount_MessageModeUsesExpertModelTypeForExpertModel(t *testing.T) {
t.Setenv("DS2API_CONFIG_JSON", `{"accounts":[{"email":"batch@example.com","password":"pwd","token":"seed-token"}]}`)
store := config.LoadStore()
ds := &completionPayloadDSMock{}
h := &Handler{Store: store, DS: ds}
acc, ok := store.FindAccount("batch@example.com")
if !ok {
t.Fatal("expected test account")
}
result := h.testAccount(context.Background(), acc, "deepseek-v4-pro", "hello")
if ok, _ := result["success"].(bool); !ok {
t.Fatalf("expected success=true, got %#v", result)
}
if got := ds.payload["model_type"]; got != "expert" {
t.Fatalf("expected model_type expert, got %#v", got)
}
if got := ds.payload["chat_session_id"]; got != "session-id" {
t.Fatalf("unexpected chat_session_id: %#v", got)
}
}
func TestTestAccount_MessageModeUsesVisionModelTypeForVisionModel(t *testing.T) {
t.Setenv("DS2API_CONFIG_JSON", `{"accounts":[{"email":"batch@example.com","password":"pwd","token":"seed-token"}]}`)
store := config.LoadStore()
ds := &completionPayloadDSMock{}
h := &Handler{Store: store, DS: ds}
acc, ok := store.FindAccount("batch@example.com")
if !ok {
t.Fatal("expected test account")
}
result := h.testAccount(context.Background(), acc, "deepseek-v4-vision", "hello")
if ok, _ := result["success"].(bool); !ok {
t.Fatalf("expected success=true, got %#v", result)
}
if got := ds.payload["model_type"]; got != "vision" {
t.Fatalf("expected model_type vision, got %#v", got)
}
}

View File

@@ -0,0 +1,38 @@
package accounts
import (
"context"
"net/http"
"github.com/go-chi/chi/v5"
"ds2api/internal/config"
)
func RegisterRoutes(r chi.Router, h *Handler) {
r.Get("/accounts", h.listAccounts)
r.Post("/accounts", h.addAccount)
r.Put("/accounts/{identifier}", h.updateAccount)
r.Delete("/accounts/{identifier}", h.deleteAccount)
r.Get("/queue/status", h.queueStatus)
r.Post("/accounts/test", h.testSingleAccount)
r.Post("/accounts/test-all", h.testAllAccounts)
r.Post("/accounts/sessions/delete-all", h.deleteAllSessions)
r.Post("/test", h.testAPI)
}
func RunAccountTestsConcurrently(accounts []config.Account, maxConcurrency int, testFn func(int, config.Account) map[string]any) []map[string]any {
return runAccountTestsConcurrently(accounts, maxConcurrency, testFn)
}
func (h *Handler) TestAccount(ctx context.Context, acc config.Account, model, message string) map[string]any {
return h.testAccount(ctx, acc, model, message)
}
func (h *Handler) ListAccounts(w http.ResponseWriter, r *http.Request) { h.listAccounts(w, r) }
func (h *Handler) AddAccount(w http.ResponseWriter, r *http.Request) { h.addAccount(w, r) }
func (h *Handler) UpdateAccount(w http.ResponseWriter, r *http.Request) { h.updateAccount(w, r) }
func (h *Handler) DeleteAccount(w http.ResponseWriter, r *http.Request) { h.deleteAccount(w, r) }
func (h *Handler) DeleteAllSessions(w http.ResponseWriter, r *http.Request) {
h.deleteAllSessions(w, r)
}

View File

@@ -0,0 +1,35 @@
package accounts
import (
"bytes"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"ds2api/internal/account"
"ds2api/internal/config"
adminshared "ds2api/internal/httpapi/admin/shared"
)
func newHTTPAdminHarness(t *testing.T, rawConfig string, ds adminshared.DeepSeekCaller) http.Handler {
t.Helper()
t.Setenv("DS2API_CONFIG_JSON", rawConfig)
store := config.LoadStore()
h := &Handler{
Store: store,
Pool: account.NewPool(store),
DS: ds,
}
r := chi.NewRouter()
RegisterRoutes(r, h)
return r
}
func adminReq(method, path string, body []byte) *http.Request {
req := httptest.NewRequest(method, path, bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer admin")
req.Header.Set("Content-Type", "application/json")
return req
}

View File

@@ -0,0 +1,19 @@
package auth
import (
"ds2api/internal/chathistory"
adminshared "ds2api/internal/httpapi/admin/shared"
)
type Handler struct {
Store adminshared.ConfigStore
Pool adminshared.PoolController
DS adminshared.DeepSeekCaller
OpenAI adminshared.OpenAIChatCaller
ChatHistory *chathistory.Store
}
var writeJSON = adminshared.WriteJSON
var intFrom = adminshared.IntFrom
func nilIfEmpty(s string) any { return adminshared.NilIfEmpty(s) }

View File

@@ -0,0 +1,69 @@
package auth
import (
"encoding/json"
"net/http"
"os"
"strings"
"time"
authn "ds2api/internal/auth"
)
func (h *Handler) requireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := authn.VerifyAdminRequestWithStore(r, h.Store); err != nil {
writeJSON(w, http.StatusUnauthorized, map[string]any{"detail": err.Error()})
return
}
next.ServeHTTP(w, r)
})
}
func (h *Handler) login(w http.ResponseWriter, r *http.Request) {
var req map[string]any
_ = json.NewDecoder(r.Body).Decode(&req)
adminKey, _ := req["admin_key"].(string)
expireHours := intFrom(req["expire_hours"])
if !authn.VerifyAdminCredential(adminKey, h.Store) {
writeJSON(w, http.StatusUnauthorized, map[string]any{"detail": "Invalid admin key"})
return
}
token, err := authn.CreateJWTWithStore(expireHours, h.Store)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": err.Error()})
return
}
if expireHours <= 0 {
expireHours = h.Store.AdminJWTExpireHours()
}
writeJSON(w, http.StatusOK, map[string]any{"success": true, "token": token, "expires_in": expireHours * 3600})
}
func (h *Handler) verify(w http.ResponseWriter, r *http.Request) {
header := strings.TrimSpace(r.Header.Get("Authorization"))
if !strings.HasPrefix(strings.ToLower(header), "bearer ") {
writeJSON(w, http.StatusUnauthorized, map[string]any{"detail": "No credentials provided"})
return
}
token := strings.TrimSpace(header[7:])
payload, err := authn.VerifyJWTWithStore(token, h.Store)
if err != nil {
writeJSON(w, http.StatusUnauthorized, map[string]any{"detail": err.Error()})
return
}
exp, _ := payload["exp"].(float64)
remaining := int64(exp) - time.Now().Unix()
if remaining < 0 {
remaining = 0
}
writeJSON(w, http.StatusOK, map[string]any{"valid": true, "expires_at": int64(exp), "remaining_seconds": remaining})
}
func (h *Handler) getVercelConfig(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"has_token": strings.TrimSpace(os.Getenv("VERCEL_TOKEN")) != "",
"project_id": strings.TrimSpace(os.Getenv("VERCEL_PROJECT_ID")),
"team_id": nilIfEmpty(strings.TrimSpace(os.Getenv("VERCEL_TEAM_ID"))),
})
}

View File

@@ -0,0 +1,20 @@
package auth
import (
"net/http"
"github.com/go-chi/chi/v5"
)
func (h *Handler) RequireAdmin(next http.Handler) http.Handler {
return h.requireAdmin(next)
}
func RegisterPublicRoutes(r chi.Router, h *Handler) {
r.Post("/login", h.login)
r.Get("/verify", h.verify)
}
func RegisterProtectedRoutes(r chi.Router, h *Handler) {
r.Get("/vercel/config", h.getVercelConfig)
}

View File

@@ -0,0 +1,50 @@
package configmgmt
import (
"ds2api/internal/chathistory"
"ds2api/internal/config"
adminshared "ds2api/internal/httpapi/admin/shared"
)
type Handler struct {
Store adminshared.ConfigStore
Pool adminshared.PoolController
DS adminshared.DeepSeekCaller
OpenAI adminshared.OpenAIChatCaller
ChatHistory *chathistory.Store
}
var writeJSON = adminshared.WriteJSON
func maskSecretPreview(secret string) string {
return adminshared.MaskSecretPreview(secret)
}
func toStringSlice(v any) ([]string, bool) { return adminshared.ToStringSlice(v) }
func toAccount(m map[string]any) config.Account {
return adminshared.ToAccount(m)
}
func toAPIKeys(v any) ([]config.APIKey, bool) { return adminshared.ToAPIKeys(v) }
func mergeAPIKeysPreferStructured(existing, incoming []config.APIKey) ([]config.APIKey, int) {
return adminshared.MergeAPIKeysPreferStructured(existing, incoming)
}
func fieldString(m map[string]any, key string) string {
return adminshared.FieldString(m, key)
}
func fieldStringOptional(m map[string]any, key string) (string, bool) {
return adminshared.FieldStringOptional(m, key)
}
func normalizeAccountForStorage(acc config.Account) config.Account {
return adminshared.NormalizeAccountForStorage(acc)
}
func accountDedupeKey(acc config.Account) string { return adminshared.AccountDedupeKey(acc) }
func normalizeAndDedupeAccounts(accounts []config.Account) []config.Account {
return adminshared.NormalizeAndDedupeAccounts(accounts)
}
func newRequestError(detail string) error { return adminshared.NewRequestError(detail) }
func requestErrorDetail(err error) (string, bool) {
return adminshared.RequestErrorDetail(err)
}
func normalizeSettingsConfig(c *config.Config) { adminshared.NormalizeSettingsConfig(c) }
func validateSettingsConfig(c config.Config) error {
return adminshared.ValidateSettingsConfig(c)
}

View File

@@ -0,0 +1,145 @@
package configmgmt
import (
"encoding/json"
"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
}
incoming.ClearAccountTokens()
importedKeys, importedAccounts := 0, 0
err = h.Store.Update(func(c *config.Config) error {
next := c.Clone()
if mode == "replace" {
next = incoming.Clone()
next.Accounts = normalizeAndDedupeAccounts(next.Accounts)
next.VercelSyncHash = c.VercelSyncHash
next.VercelSyncTime = c.VercelSyncTime
importedKeys = len(next.APIKeys)
importedAccounts = len(next.Accounts)
} else {
var changed int
next.APIKeys, changed = mergeAPIKeysPreferStructured(next.APIKeys, incoming.APIKeys)
importedKeys += changed
existingAccounts := map[string]struct{}{}
for _, acc := range next.Accounts {
acc = normalizeAccountForStorage(acc)
key := accountDedupeKey(acc)
if key != "" {
existingAccounts[key] = struct{}{}
}
}
for _, acc := range incoming.Accounts {
acc = normalizeAccountForStorage(acc)
key := accountDedupeKey(acc)
if key == "" {
continue
}
if _, ok := existingAccounts[key]; ok {
continue
}
existingAccounts[key] = struct{}{}
next.Accounts = append(next.Accounts, acc)
importedAccounts++
}
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 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
}
if incoming.Runtime.TokenRefreshIntervalHours > 0 {
next.Runtime.TokenRefreshIntervalHours = incoming.Runtime.TokenRefreshIntervalHours
}
}
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",
})
}

View File

@@ -0,0 +1,73 @@
package configmgmt
import (
"net/http"
"strings"
"ds2api/internal/config"
)
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(),
"env_source_present": h.Store.HasEnvConfigSource(),
"env_writeback_enabled": h.Store.IsEnvWritebackEnabled(),
"config_path": h.Store.ConfigPath(),
"model_aliases": snap.ModelAliases,
}
accounts := make([]map[string]any, 0, len(snap.Accounts))
for _, acc := range snap.Accounts {
token := strings.TrimSpace(acc.Token)
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,
"has_password": strings.TrimSpace(acc.Password) != "",
"has_token": token != "",
"token_preview": maskSecretPreview(token),
})
}
safe["accounts"] = accounts
proxies := make([]map[string]any, 0, len(snap.Proxies))
for _, proxy := range snap.Proxies {
proxy = config.NormalizeProxy(proxy)
proxies = append(proxies, map[string]any{
"id": proxy.ID,
"name": proxy.Name,
"type": proxy.Type,
"host": proxy.Host,
"port": proxy.Port,
"username": proxy.Username,
"has_password": strings.TrimSpace(proxy.Password) != "",
})
}
safe["proxies"] = proxies
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,227 @@
package configmgmt
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 apiKeys, ok := toAPIKeys(req["api_keys"]); ok {
c.APIKeys = apiKeys
} else 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 {
a = normalizeAccountForStorage(a)
key := accountDedupeKey(a)
if key != "" {
existing[key] = a
}
}
seen := map[string]struct{}{}
accounts := make([]config.Account, 0, len(accountsRaw))
for _, item := range accountsRaw {
m, ok := item.(map[string]any)
if !ok {
continue
}
acc := normalizeAccountForStorage(toAccount(m))
key := accountDedupeKey(acc)
if key == "" {
continue
}
if _, ok := seen[key]; ok {
continue
}
if prev, ok := existing[key]; ok {
if strings.TrimSpace(acc.Password) == "" {
acc.Password = prev.Password
}
}
seen[key] = struct{}{}
accounts = append(accounts, acc)
}
c.Accounts = accounts
}
if m, ok := req["model_aliases"].(map[string]any); ok {
aliases := make(map[string]string, len(m))
for k, v := range m {
aliases[k] = fmt.Sprintf("%v", v)
}
c.ModelAliases = aliases
}
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)
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 _, item := range c.APIKeys {
if item.Key == key {
return fmt.Errorf("key 已存在")
}
}
c.APIKeys = append(c.APIKeys, config.APIKey{Key: key, Name: name, Remark: remark})
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) 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 {
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 {
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 apiKeys, ok := toAPIKeys(req["api_keys"]); ok {
var changed int
c.APIKeys, changed = mergeAPIKeysPreferStructured(c.APIKeys, apiKeys)
importedKeys += changed
}
if keys, ok := req["keys"].([]any); ok {
legacy := make([]config.APIKey, 0, len(keys))
for _, k := range keys {
key := strings.TrimSpace(fmt.Sprintf("%v", k))
if key == "" {
continue
}
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{}
for _, a := range c.Accounts {
a = normalizeAccountForStorage(a)
key := accountDedupeKey(a)
if key != "" {
existing[key] = true
}
}
for _, item := range accounts {
m, ok := item.(map[string]any)
if !ok {
continue
}
acc := normalizeAccountForStorage(toAccount(m))
key := accountDedupeKey(acc)
if key == "" || existing[key] {
continue
}
c.Accounts = append(c.Accounts, acc)
existing[key] = 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

@@ -0,0 +1,76 @@
package configmgmt
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

@@ -0,0 +1,27 @@
package configmgmt
import (
"net/http"
"github.com/go-chi/chi/v5"
)
func RegisterRoutes(r chi.Router, h *Handler) {
r.Get("/config", h.getConfig)
r.Post("/config", h.updateConfig)
r.Post("/config/import", h.configImport)
r.Get("/config/export", h.configExport)
r.Get("/export", h.exportConfig)
r.Post("/keys", h.addKey)
r.Put("/keys/{key}", h.updateKey)
r.Delete("/keys/{key}", h.deleteKey)
r.Post("/import", h.batchImport)
}
func (h *Handler) GetConfig(w http.ResponseWriter, r *http.Request) { h.getConfig(w, r) }
func (h *Handler) UpdateConfig(w http.ResponseWriter, r *http.Request) { h.updateConfig(w, r) }
func (h *Handler) ConfigImport(w http.ResponseWriter, r *http.Request) { h.configImport(w, r) }
func (h *Handler) BatchImport(w http.ResponseWriter, r *http.Request) { h.batchImport(w, r) }
func (h *Handler) AddKey(w http.ResponseWriter, r *http.Request) { h.addKey(w, r) }
func (h *Handler) UpdateKey(w http.ResponseWriter, r *http.Request) { h.updateKey(w, r) }
func (h *Handler) DeleteKey(w http.ResponseWriter, r *http.Request) { h.deleteKey(w, r) }

View File

@@ -0,0 +1,18 @@
package configmgmt
import (
"testing"
"ds2api/internal/account"
"ds2api/internal/config"
)
func newAdminTestHandler(t *testing.T, raw string) *Handler {
t.Helper()
t.Setenv("DS2API_CONFIG_JSON", raw)
store := config.LoadStore()
return &Handler{
Store: store,
Pool: account.NewPool(store),
}
}

View File

@@ -0,0 +1,16 @@
package devcapture
import (
"ds2api/internal/chathistory"
adminshared "ds2api/internal/httpapi/admin/shared"
)
type Handler struct {
Store adminshared.ConfigStore
Pool adminshared.PoolController
DS adminshared.DeepSeekCaller
OpenAI adminshared.OpenAIChatCaller
ChatHistory *chathistory.Store
}
var writeJSON = adminshared.WriteJSON

View File

@@ -0,0 +1,26 @@
package devcapture
import (
"net/http"
"ds2api/internal/devcapture"
)
func (h *Handler) getDevCaptures(w http.ResponseWriter, _ *http.Request) {
store := devcapture.Global()
writeJSON(w, http.StatusOK, map[string]any{
"enabled": store.Enabled(),
"limit": store.Limit(),
"max_body_bytes": store.MaxBodyBytes(),
"items": store.Snapshot(),
})
}
func (h *Handler) clearDevCaptures(w http.ResponseWriter, _ *http.Request) {
store := devcapture.Global()
store.Clear()
writeJSON(w, http.StatusOK, map[string]any{
"success": true,
"detail": "capture logs cleared",
})
}

View File

@@ -0,0 +1,45 @@
package devcapture
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestGetDevCapturesShape(t *testing.T) {
h := &Handler{}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/admin/dev/captures", nil)
h.getDevCaptures(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
}
var out map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("decode failed: %v", err)
}
if _, ok := out["enabled"]; !ok {
t.Fatalf("expected enabled field, got %#v", out)
}
if _, ok := out["items"]; !ok {
t.Fatalf("expected items field, got %#v", out)
}
}
func TestClearDevCapturesShape(t *testing.T) {
h := &Handler{}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodDelete, "/admin/dev/captures", nil)
h.clearDevCaptures(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
}
var out map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("decode failed: %v", err)
}
if out["success"] != true {
t.Fatalf("expected success=true, got %#v", out)
}
}

View File

@@ -0,0 +1,8 @@
package devcapture
import "github.com/go-chi/chi/v5"
func RegisterRoutes(r chi.Router, h *Handler) {
r.Get("/dev/captures", h.getDevCaptures)
r.Delete("/dev/captures", h.clearDevCaptures)
}

View File

@@ -0,0 +1,70 @@
package admin
import (
"github.com/go-chi/chi/v5"
"ds2api/internal/chathistory"
adminaccounts "ds2api/internal/httpapi/admin/accounts"
adminauth "ds2api/internal/httpapi/admin/auth"
adminconfig "ds2api/internal/httpapi/admin/configmgmt"
admindevcapture "ds2api/internal/httpapi/admin/devcapture"
adminhistory "ds2api/internal/httpapi/admin/history"
adminproxies "ds2api/internal/httpapi/admin/proxies"
adminrawsamples "ds2api/internal/httpapi/admin/rawsamples"
adminsettings "ds2api/internal/httpapi/admin/settings"
adminshared "ds2api/internal/httpapi/admin/shared"
adminvercel "ds2api/internal/httpapi/admin/vercel"
adminversion "ds2api/internal/httpapi/admin/version"
)
type Handler struct {
Store adminshared.ConfigStore
Pool adminshared.PoolController
DS adminshared.DeepSeekCaller
OpenAI adminshared.OpenAIChatCaller
ChatHistory *chathistory.Store
}
func RegisterRoutes(r chi.Router, h *Handler) {
deps := adminsharedDeps(h)
authHandler := &adminauth.Handler{Store: deps.Store, Pool: deps.Pool, DS: deps.DS, OpenAI: deps.OpenAI, ChatHistory: deps.ChatHistory}
accountsHandler := &adminaccounts.Handler{Store: deps.Store, Pool: deps.Pool, DS: deps.DS, OpenAI: deps.OpenAI, ChatHistory: deps.ChatHistory}
configHandler := &adminconfig.Handler{Store: deps.Store, Pool: deps.Pool, DS: deps.DS, OpenAI: deps.OpenAI, ChatHistory: deps.ChatHistory}
settingsHandler := &adminsettings.Handler{Store: deps.Store, Pool: deps.Pool, DS: deps.DS, OpenAI: deps.OpenAI, ChatHistory: deps.ChatHistory}
proxiesHandler := &adminproxies.Handler{Store: deps.Store, Pool: deps.Pool, DS: deps.DS, OpenAI: deps.OpenAI, ChatHistory: deps.ChatHistory}
rawSamplesHandler := &adminrawsamples.Handler{Store: deps.Store, Pool: deps.Pool, DS: deps.DS, OpenAI: deps.OpenAI, ChatHistory: deps.ChatHistory}
vercelHandler := &adminvercel.Handler{Store: deps.Store, Pool: deps.Pool, DS: deps.DS, OpenAI: deps.OpenAI, ChatHistory: deps.ChatHistory}
historyHandler := &adminhistory.Handler{Store: deps.Store, Pool: deps.Pool, DS: deps.DS, OpenAI: deps.OpenAI, ChatHistory: deps.ChatHistory}
devCaptureHandler := &admindevcapture.Handler{Store: deps.Store, Pool: deps.Pool, DS: deps.DS, OpenAI: deps.OpenAI, ChatHistory: deps.ChatHistory}
versionHandler := &adminversion.Handler{Store: deps.Store, Pool: deps.Pool, DS: deps.DS, OpenAI: deps.OpenAI, ChatHistory: deps.ChatHistory}
adminauth.RegisterPublicRoutes(r, authHandler)
r.Group(func(pr chi.Router) {
pr.Use(authHandler.RequireAdmin)
adminauth.RegisterProtectedRoutes(pr, authHandler)
adminconfig.RegisterRoutes(pr, configHandler)
adminsettings.RegisterRoutes(pr, settingsHandler)
adminproxies.RegisterRoutes(pr, proxiesHandler)
adminaccounts.RegisterRoutes(pr, accountsHandler)
adminrawsamples.RegisterRoutes(pr, rawSamplesHandler)
adminvercel.RegisterRoutes(pr, vercelHandler)
admindevcapture.RegisterRoutes(pr, devCaptureHandler)
adminhistory.RegisterRoutes(pr, historyHandler)
adminversion.RegisterRoutes(pr, versionHandler)
})
}
func adminsharedDeps(h *Handler) adminsharedDepsValue {
if h == nil {
return adminsharedDepsValue{}
}
return adminsharedDepsValue{Store: h.Store, Pool: h.Pool, DS: h.DS, OpenAI: h.OpenAI, ChatHistory: h.ChatHistory}
}
type adminsharedDepsValue struct {
Store adminshared.ConfigStore
Pool adminshared.PoolController
DS adminshared.DeepSeekCaller
OpenAI adminshared.OpenAIChatCaller
ChatHistory *chathistory.Store
}

View File

@@ -0,0 +1,700 @@
package admin
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
authn "ds2api/internal/auth"
)
func TestGetSettingsDefaultPasswordWarning(t *testing.T) {
t.Setenv("DS2API_ADMIN_KEY", "")
h := newAdminTestHandler(t, `{"keys":["k1"]}`)
req := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
rec := httptest.NewRecorder()
h.getSettings(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
var body map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &body)
admin, _ := body["admin"].(map[string]any)
warn, _ := admin["default_password_warning"].(bool)
if !warn {
t.Fatalf("expected default password warning true, body=%v", body)
}
}
func TestGetSettingsIncludesTokenRefreshInterval(t *testing.T) {
h := newAdminTestHandler(t, `{
"keys":["k1"],
"runtime":{"token_refresh_interval_hours":9}
}`)
req := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
rec := httptest.NewRecorder()
h.getSettings(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
var body map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &body)
runtime, _ := body["runtime"].(map[string]any)
if got := intFrom(runtime["token_refresh_interval_hours"]); got != 9 {
t.Fatalf("expected token_refresh_interval_hours=9, got %d body=%v", got, body)
}
}
func TestGetSettingsIncludesHistorySplitDefaults(t *testing.T) {
h := newAdminTestHandler(t, `{"keys":["k1"]}`)
req := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
rec := httptest.NewRecorder()
h.getSettings(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
var body map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &body)
historySplit, _ := body["history_split"].(map[string]any)
if got := boolFrom(historySplit["enabled"]); !got {
t.Fatalf("expected history_split.enabled=true, body=%v", body)
}
if got := intFrom(historySplit["trigger_after_turns"]); got != 1 {
t.Fatalf("expected history_split.trigger_after_turns=1, got %d body=%v", got, body)
}
}
func TestUpdateSettingsValidation(t *testing.T) {
h := newAdminTestHandler(t, `{"keys":["k1"]}`)
payload := map[string]any{
"runtime": map[string]any{
"account_max_inflight": 0,
},
}
b, _ := json.Marshal(payload)
req := httptest.NewRequest(http.MethodPut, "/admin/settings", bytes.NewReader(b))
rec := httptest.NewRecorder()
h.updateSettings(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d body=%s", rec.Code, rec.Body.String())
}
}
func TestUpdateSettingsValidationRejectsTokenRefreshInterval(t *testing.T) {
h := newAdminTestHandler(t, `{"keys":["k1"]}`)
payload := map[string]any{
"runtime": map[string]any{
"token_refresh_interval_hours": 0,
},
}
b, _ := json.Marshal(payload)
req := httptest.NewRequest(http.MethodPut, "/admin/settings", bytes.NewReader(b))
rec := httptest.NewRecorder()
h.updateSettings(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d body=%s", rec.Code, rec.Body.String())
}
if !bytes.Contains(rec.Body.Bytes(), []byte("runtime.token_refresh_interval_hours")) {
t.Fatalf("expected token refresh validation detail, got %s", rec.Body.String())
}
}
func TestUpdateSettingsAllowsEmptyEmbeddingsProvider(t *testing.T) {
h := newAdminTestHandler(t, `{"keys":["k1"]}`)
payload := map[string]any{
"responses": map[string]any{
"store_ttl_seconds": 600,
},
"embeddings": map[string]any{
"provider": "",
},
}
b, _ := json.Marshal(payload)
req := httptest.NewRequest(http.MethodPut, "/admin/settings", bytes.NewReader(b))
rec := httptest.NewRecorder()
h.updateSettings(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
}
if got := h.Store.Snapshot().Responses.StoreTTLSeconds; got != 600 {
t.Fatalf("store_ttl_seconds=%d want=600", got)
}
}
func TestUpdateSettingsValidationWithMergedRuntimeSnapshot(t *testing.T) {
h := newAdminTestHandler(t, `{
"keys":["k1"],
"runtime":{
"account_max_inflight":8,
"global_max_inflight":8
}
}`)
payload := map[string]any{
"runtime": map[string]any{
"account_max_inflight": 16,
},
}
b, _ := json.Marshal(payload)
req := httptest.NewRequest(http.MethodPut, "/admin/settings", bytes.NewReader(b))
rec := httptest.NewRecorder()
h.updateSettings(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d body=%s", rec.Code, rec.Body.String())
}
if !bytes.Contains(rec.Body.Bytes(), []byte("runtime.global_max_inflight")) {
t.Fatalf("expected merged runtime validation detail, got %s", rec.Body.String())
}
}
func TestUpdateSettingsWithoutRuntimeSkipsMergedRuntimeValidation(t *testing.T) {
h := newAdminTestHandler(t, `{
"keys":["k1"],
"runtime":{
"account_max_inflight":8,
"global_max_inflight":4
}
}`)
payload := map[string]any{
"responses": map[string]any{
"store_ttl_seconds": 600,
},
}
b, _ := json.Marshal(payload)
req := httptest.NewRequest(http.MethodPut, "/admin/settings", bytes.NewReader(b))
rec := httptest.NewRecorder()
h.updateSettings(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
}
if got := h.Store.Snapshot().Responses.StoreTTLSeconds; got != 600 {
t.Fatalf("store_ttl_seconds=%d want=600", got)
}
}
func TestUpdateSettingsHistorySplit(t *testing.T) {
h := newAdminTestHandler(t, `{"keys":["k1"]}`)
payload := map[string]any{
"history_split": map[string]any{
"enabled": false,
"trigger_after_turns": 3,
},
}
b, _ := json.Marshal(payload)
req := httptest.NewRequest(http.MethodPut, "/admin/settings", bytes.NewReader(b))
rec := httptest.NewRecorder()
h.updateSettings(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
}
snap := h.Store.Snapshot()
if snap.HistorySplit.Enabled == nil || *snap.HistorySplit.Enabled {
t.Fatalf("expected history_split.enabled=false, got %#v", snap.HistorySplit.Enabled)
}
if snap.HistorySplit.TriggerAfterTurns == nil || *snap.HistorySplit.TriggerAfterTurns != 3 {
t.Fatalf("expected history_split.trigger_after_turns=3, got %#v", snap.HistorySplit.TriggerAfterTurns)
}
}
func TestUpdateSettingsAutoDeleteMode(t *testing.T) {
h := newAdminTestHandler(t, `{"keys":["k1"],"auto_delete":{"sessions":true}}`)
payload := map[string]any{
"auto_delete": map[string]any{
"mode": "single",
},
}
b, _ := json.Marshal(payload)
req := httptest.NewRequest(http.MethodPut, "/admin/settings", bytes.NewReader(b))
rec := httptest.NewRecorder()
h.updateSettings(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
snap := h.Store.Snapshot()
if got := snap.AutoDelete.Mode; got != "single" {
t.Fatalf("auto_delete.mode=%q want=single", got)
}
if got := h.Store.AutoDeleteMode(); got != "single" {
t.Fatalf("AutoDeleteMode()=%q want=single", got)
}
}
func TestUpdateSettingsHotReloadRuntime(t *testing.T) {
h := newAdminTestHandler(t, `{
"keys":["k1"],
"accounts":[{"email":"a@test.com","token":"t1"},{"email":"b@test.com","token":"t2"}]
}`)
payload := map[string]any{
"runtime": map[string]any{
"account_max_inflight": 3,
"account_max_queue": 20,
"global_max_inflight": 5,
},
}
b, _ := json.Marshal(payload)
req := httptest.NewRequest(http.MethodPut, "/admin/settings", bytes.NewReader(b))
rec := httptest.NewRecorder()
h.updateSettings(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
status := h.Pool.Status()
if got := intFrom(status["max_inflight_per_account"]); got != 3 {
t.Fatalf("max_inflight_per_account=%d want=3", got)
}
if got := intFrom(status["max_queue_size"]); got != 20 {
t.Fatalf("max_queue_size=%d want=20", got)
}
if got := intFrom(status["global_max_inflight"]); got != 5 {
t.Fatalf("global_max_inflight=%d want=5", got)
}
}
func TestUpdateSettingsHotReloadTokenRefreshInterval(t *testing.T) {
h := newAdminTestHandler(t, `{
"keys":["k1"],
"runtime":{"token_refresh_interval_hours":6}
}`)
payload := map[string]any{
"runtime": map[string]any{
"token_refresh_interval_hours": 12,
},
}
b, _ := json.Marshal(payload)
req := httptest.NewRequest(http.MethodPut, "/admin/settings", bytes.NewReader(b))
rec := httptest.NewRecorder()
h.updateSettings(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if got := h.Store.RuntimeTokenRefreshIntervalHours(); got != 12 {
t.Fatalf("token_refresh_interval_hours=%d want=12", got)
}
}
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 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 TestUpdateConfigReplacesModelAliases(t *testing.T) {
h := newAdminTestHandler(t, `{
"keys":["k1"],
"model_aliases":{"claude-sonnet-4-6":"deepseek-v4-flash"}
}`)
payload := map[string]any{
"model_aliases": map[string]any{
"gpt-5.5": "deepseek-v4-pro",
},
}
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.ModelAliases) != 1 {
t.Fatalf("expected aliases to be replaced, got %#v", snap.ModelAliases)
}
if snap.ModelAliases["gpt-5.5"] != "deepseek-v4-pro" {
t.Fatalf("expected updated alias, got %#v", snap.ModelAliases)
}
}
func TestUpdateSettingsPasswordInvalidatesOldJWT(t *testing.T) {
hash := authn.HashAdminPassword("old-password")
h := newAdminTestHandler(t, `{"admin":{"password_hash":"`+hash+`"}}`)
token, err := authn.CreateJWTWithStore(1, h.Store)
if err != nil {
t.Fatalf("create jwt failed: %v", err)
}
if _, err := authn.VerifyJWTWithStore(token, h.Store); err != nil {
t.Fatalf("verify before update failed: %v", err)
}
body := map[string]any{"new_password": "new-password"}
b, _ := json.Marshal(body)
req := httptest.NewRequest(http.MethodPost, "/admin/settings/password", bytes.NewReader(b))
rec := httptest.NewRecorder()
h.updateSettingsPassword(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if _, err := authn.VerifyJWTWithStore(token, h.Store); err == nil {
t.Fatal("expected old token to be invalid after password update")
}
if !authn.VerifyAdminCredential("new-password", h.Store) {
t.Fatal("expected new password credential to be accepted")
}
}
func TestConfigImportMergeAndReplace(t *testing.T) {
h := newAdminTestHandler(t, `{
"keys":["k1"],
"accounts":[{"email":"a@test.com","password":"p1"}]
}`)
merge := map[string]any{
"mode": "merge",
"config": map[string]any{
"keys": []any{"k1", "k2"},
"accounts": []any{
map[string]any{"email": "a@test.com", "password": "p1"},
map[string]any{"email": "b@test.com", "password": "p2"},
},
},
}
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())
}
if got := len(h.Store.Keys()); got != 2 {
t.Fatalf("keys after merge=%d want=2", got)
}
if got := len(h.Store.Accounts()); got != 2 {
t.Fatalf("accounts after merge=%d want=2", got)
}
replace := map[string]any{
"mode": "replace",
"config": map[string]any{
"keys": []any{"k9"},
},
}
replaceBytes, _ := json.Marshal(replace)
replaceReq := httptest.NewRequest(http.MethodPost, "/admin/config/import?mode=replace", bytes.NewReader(replaceBytes))
replaceRec := httptest.NewRecorder()
h.configImport(replaceRec, replaceReq)
if replaceRec.Code != http.StatusOK {
t.Fatalf("replace status=%d body=%s", replaceRec.Code, replaceRec.Body.String())
}
keys := h.Store.Keys()
if len(keys) != 1 || keys[0] != "k9" {
t.Fatalf("unexpected keys after replace: %#v", keys)
}
if got := len(h.Store.Accounts()); got != 0 {
t.Fatalf("accounts after replace=%d want=0", got)
}
}
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 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"]}`)
replace := map[string]any{
"mode": "replace",
"config": map[string]any{
"keys": []any{"k9"},
"runtime": map[string]any{
"token_refresh_interval_hours": 11,
},
},
}
replaceBytes, _ := json.Marshal(replace)
replaceReq := httptest.NewRequest(http.MethodPost, "/admin/config/import?mode=replace", bytes.NewReader(replaceBytes))
replaceRec := httptest.NewRecorder()
h.configImport(replaceRec, replaceReq)
if replaceRec.Code != http.StatusOK {
t.Fatalf("replace status=%d body=%s", replaceRec.Code, replaceRec.Body.String())
}
if got := h.Store.RuntimeTokenRefreshIntervalHours(); got != 11 {
t.Fatalf("token_refresh_interval_hours=%d want=11", got)
}
}
func TestConfigImportRejectsInvalidRuntimeBounds(t *testing.T) {
h := newAdminTestHandler(t, `{"keys":["k1"]}`)
payload := map[string]any{
"mode": "replace",
"config": map[string]any{
"keys": []any{"k2"},
"runtime": map[string]any{
"account_max_inflight": 300,
},
},
}
b, _ := json.Marshal(payload)
req := httptest.NewRequest(http.MethodPost, "/admin/config/import?mode=replace", bytes.NewReader(b))
rec := httptest.NewRecorder()
h.configImport(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d body=%s", rec.Code, rec.Body.String())
}
if !bytes.Contains(rec.Body.Bytes(), []byte("runtime.account_max_inflight")) {
t.Fatalf("expected runtime bound detail, got %s", rec.Body.String())
}
keys := h.Store.Keys()
if len(keys) != 1 || keys[0] != "k1" {
t.Fatalf("store should remain unchanged, keys=%v", keys)
}
}
func TestConfigImportRejectsMergedRuntimeConflict(t *testing.T) {
h := newAdminTestHandler(t, `{
"keys":["k1"],
"runtime":{
"account_max_inflight":8,
"global_max_inflight":8
}
}`)
payload := map[string]any{
"mode": "merge",
"config": map[string]any{
"runtime": map[string]any{
"account_max_inflight": 16,
},
},
}
b, _ := json.Marshal(payload)
req := httptest.NewRequest(http.MethodPost, "/admin/config/import?mode=merge", bytes.NewReader(b))
rec := httptest.NewRecorder()
h.configImport(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d body=%s", rec.Code, rec.Body.String())
}
if !bytes.Contains(rec.Body.Bytes(), []byte("runtime.global_max_inflight")) {
t.Fatalf("expected merged runtime validation detail, got %s", rec.Body.String())
}
snap := h.Store.Snapshot()
if snap.Runtime.AccountMaxInflight != 8 || snap.Runtime.GlobalMaxInflight != 8 {
t.Fatalf("runtime should remain unchanged, runtime=%+v", snap.Runtime)
}
}
func TestConfigImportMergeDedupesMobileAliases(t *testing.T) {
h := newAdminTestHandler(t, `{
"keys":["k1"],
"accounts":[{"mobile":"+8613800138000","password":"p1"}]
}`)
merge := map[string]any{
"mode": "merge",
"config": map[string]any{
"accounts": []any{
map[string]any{"mobile": "13800138000", "password": "p2"},
},
},
}
b, _ := json.Marshal(merge)
req := httptest.NewRequest(http.MethodPost, "/admin/config/import?mode=merge", bytes.NewReader(b))
rec := httptest.NewRecorder()
h.configImport(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if got := len(h.Store.Accounts()); got != 1 {
t.Fatalf("expected merge dedupe by canonical mobile, got=%d", got)
}
}
func TestUpdateConfigDedupesMobileAliases(t *testing.T) {
h := newAdminTestHandler(t, `{
"keys":["k1"],
"accounts":[{"mobile":"+8613800138000","password":"old"}]
}`)
reqBody := map[string]any{
"accounts": []any{
map[string]any{"mobile": "+8613800138000"},
map[string]any{"mobile": "13800138000"},
},
}
b, _ := json.Marshal(reqBody)
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())
}
accounts := h.Store.Accounts()
if len(accounts) != 1 {
t.Fatalf("expected update dedupe by canonical mobile, got=%d", len(accounts))
}
if accounts[0].Identifier() != "+8613800138000" {
t.Fatalf("unexpected identifier: %q", accounts[0].Identifier())
}
}

View File

@@ -0,0 +1,143 @@
package admin
import (
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"ds2api/internal/config"
)
func TestToAccountMissingFieldsRemainEmpty(t *testing.T) {
acc := toAccount(map[string]any{
"email": "user@example.com",
"password": "secret",
})
if acc.Email != "user@example.com" {
t.Fatalf("unexpected email: %q", acc.Email)
}
if acc.Mobile != "" {
t.Fatalf("expected empty mobile, got %q", acc.Mobile)
}
if acc.Token != "" {
t.Fatalf("expected empty token, got %q", acc.Token)
}
}
func TestFieldStringNilToEmpty(t *testing.T) {
if got := fieldString(map[string]any{"token": nil}, "token"); got != "" {
t.Fatalf("expected empty string for nil field, got %q", got)
}
if got := fieldString(map[string]any{}, "token"); got != "" {
t.Fatalf("expected empty string for missing field, got %q", got)
}
}
func TestMaskSecretPreviewKeepsOnlyFirstAndLastTwoChars(t *testing.T) {
cases := map[string]string{
"": "",
"a": "*",
"ab": "**",
"abcd": "****",
"abcdef": "ab****ef",
"abc12345": "ab****45",
}
for input, want := range cases {
if got := maskSecretPreview(input); got != want {
t.Fatalf("maskSecretPreview(%q)=%q want %q", input, got, want)
}
}
}
func TestGetConfigMasksAccountTokenPreview(t *testing.T) {
h := newAdminTestHandler(t, `{
"accounts":[{"email":"u@example.com","password":"pwd"}]
}`)
if err := h.Store.UpdateAccountToken("u@example.com", "abcdefgh"); err != nil {
t.Fatalf("seed runtime token: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/admin/config", nil)
rec := httptest.NewRecorder()
h.getConfig(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String())
}
var payload map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response failed: %v", err)
}
accounts, _ := payload["accounts"].([]any)
if len(accounts) != 1 {
t.Fatalf("expected 1 account, got %d", len(accounts))
}
first, _ := accounts[0].(map[string]any)
if got, _ := first["token_preview"].(string); got != "ab****gh" {
t.Fatalf("expected masked token preview, got %q", got)
}
}
func TestRunAccountTestsConcurrentlyKeepsInputOrder(t *testing.T) {
accounts := []config.Account{
{Email: "a@example.com"},
{Email: "b@example.com"},
{Email: "c@example.com"},
}
results := runAccountTestsConcurrently(accounts, 2, func(idx int, acc config.Account) map[string]any {
return map[string]any{
"idx": idx,
"account": acc.Identifier(),
}
})
if len(results) != len(accounts) {
t.Fatalf("unexpected result length: got %d want %d", len(results), len(accounts))
}
for i := range accounts {
gotIdx, _ := results[i]["idx"].(int)
if gotIdx != i {
t.Fatalf("result index mismatch at %d: got %d", i, gotIdx)
}
gotID, _ := results[i]["account"].(string)
if gotID != accounts[i].Identifier() {
t.Fatalf("result order mismatch at %d: got %q want %q", i, gotID, accounts[i].Identifier())
}
}
}
func TestRunAccountTestsConcurrentlyRespectsLimit(t *testing.T) {
const limit = 3
accounts := []config.Account{
{Email: "1@example.com"},
{Email: "2@example.com"},
{Email: "3@example.com"},
{Email: "4@example.com"},
{Email: "5@example.com"},
{Email: "6@example.com"},
}
var current int32
var maxSeen int32
_ = runAccountTestsConcurrently(accounts, limit, func(_ int, _ config.Account) map[string]any {
c := atomic.AddInt32(&current, 1)
for {
m := atomic.LoadInt32(&maxSeen)
if c <= m || atomic.CompareAndSwapInt32(&maxSeen, m, c) {
break
}
}
time.Sleep(20 * time.Millisecond)
atomic.AddInt32(&current, -1)
return map[string]any{"success": true}
})
if maxSeen > limit {
t.Fatalf("concurrency exceeded limit: got %d > %d", maxSeen, limit)
}
if maxSeen < 2 {
t.Fatalf("expected concurrent execution, max seen %d", maxSeen)
}
}

View File

@@ -0,0 +1,16 @@
package history
import (
"ds2api/internal/chathistory"
adminshared "ds2api/internal/httpapi/admin/shared"
)
type Handler struct {
Store adminshared.ConfigStore
Pool adminshared.PoolController
DS adminshared.DeepSeekCaller
OpenAI adminshared.OpenAIChatCaller
ChatHistory *chathistory.Store
}
var writeJSON = adminshared.WriteJSON

View File

@@ -0,0 +1,134 @@
package history
import (
"encoding/json"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"ds2api/internal/chathistory"
)
func (h *Handler) getChatHistory(w http.ResponseWriter, r *http.Request) {
store := h.ChatHistory
if store == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"detail": "chat history store is not configured"})
return
}
snapshot, err := store.Snapshot()
if err != nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]any{
"detail": err.Error(),
"path": store.Path(),
})
return
}
etag := chathistory.ListETag(snapshot.Revision)
w.Header().Set("ETag", etag)
w.Header().Set("Cache-Control", "no-cache")
if strings.TrimSpace(r.Header.Get("If-None-Match")) == etag {
w.WriteHeader(http.StatusNotModified)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"version": snapshot.Version,
"limit": snapshot.Limit,
"revision": snapshot.Revision,
"items": snapshot.Items,
"path": store.Path(),
})
}
func (h *Handler) getChatHistoryItem(w http.ResponseWriter, r *http.Request) {
store := h.ChatHistory
if store == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"detail": "chat history store is not configured"})
return
}
id := strings.TrimSpace(chi.URLParam(r, "id"))
if id == "" {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "history id is required"})
return
}
item, err := store.Get(id)
if err != nil {
status := http.StatusInternalServerError
if strings.Contains(strings.ToLower(err.Error()), "not found") {
status = http.StatusNotFound
}
writeJSON(w, status, map[string]any{"detail": err.Error()})
return
}
etag := chathistory.DetailETag(item.ID, item.Revision)
w.Header().Set("ETag", etag)
w.Header().Set("Cache-Control", "no-cache")
if strings.TrimSpace(r.Header.Get("If-None-Match")) == etag {
w.WriteHeader(http.StatusNotModified)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"item": item,
})
}
func (h *Handler) clearChatHistory(w http.ResponseWriter, _ *http.Request) {
store := h.ChatHistory
if store == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"detail": "chat history store is not configured"})
return
}
if err := store.Clear(); err != nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"detail": err.Error(), "path": store.Path()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"success": true})
}
func (h *Handler) deleteChatHistoryItem(w http.ResponseWriter, r *http.Request) {
store := h.ChatHistory
if store == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"detail": "chat history store is not configured"})
return
}
id := strings.TrimSpace(chi.URLParam(r, "id"))
if id == "" {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "history id is required"})
return
}
if err := store.Delete(id); err != nil {
status := http.StatusInternalServerError
if strings.Contains(strings.ToLower(err.Error()), "not found") {
status = http.StatusNotFound
}
writeJSON(w, status, map[string]any{"detail": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"success": true})
}
func (h *Handler) updateChatHistorySettings(w http.ResponseWriter, r *http.Request) {
store := h.ChatHistory
if store == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"detail": "chat history store is not configured"})
return
}
var body struct {
Limit int `json:"limit"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "invalid json"})
return
}
snapshot, err := store.SetLimit(body.Limit)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{
"success": true,
"limit": snapshot.Limit,
"revision": snapshot.Revision,
"items": snapshot.Items,
})
}

View File

@@ -0,0 +1,176 @@
package history
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/go-chi/chi/v5"
"ds2api/internal/chathistory"
"ds2api/internal/config"
)
func newChatHistoryAdminHarness(t *testing.T) (*Handler, *chathistory.Store) {
t.Helper()
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
if err := os.WriteFile(configPath, []byte(`{}`), 0o644); err != nil {
t.Fatalf("write config failed: %v", err)
}
t.Setenv("DS2API_CONFIG_PATH", configPath)
t.Setenv("DS2API_ADMIN_KEY", "admin")
t.Setenv("DS2API_CONFIG_JSON", "")
store, err := config.LoadStoreWithError()
if err != nil {
t.Fatalf("load config store failed: %v", err)
}
historyStore := chathistory.New(filepath.Join(dir, "chat_history.json"))
return &Handler{Store: store, ChatHistory: historyStore}, historyStore
}
func TestGetChatHistoryAndUpdateSettings(t *testing.T) {
h, historyStore := newChatHistoryAdminHarness(t)
entry, err := historyStore.Start(chathistory.StartParams{
CallerID: "caller:test",
AccountID: "user@example.com",
Model: "deepseek-v4-flash",
UserInput: "hello",
})
if err != nil {
t.Fatalf("start history failed: %v", err)
}
if _, err := historyStore.Update(entry.ID, chathistory.UpdateParams{
Status: "success",
Content: "world",
Completed: true,
}); err != nil {
t.Fatalf("update history failed: %v", err)
}
r := chi.NewRouter()
RegisterRoutes(r, h)
req := httptest.NewRequest(http.MethodGet, "/chat-history", nil)
req.Header.Set("Authorization", "Bearer admin")
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
}
var payload map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode payload failed: %v", err)
}
items, _ := payload["items"].([]any)
if len(items) != 1 {
t.Fatalf("expected one history item, got %#v", payload)
}
if rec.Header().Get("ETag") == "" {
t.Fatalf("expected list etag header")
}
notModifiedReq := httptest.NewRequest(http.MethodGet, "/chat-history", nil)
notModifiedReq.Header.Set("Authorization", "Bearer admin")
notModifiedReq.Header.Set("If-None-Match", rec.Header().Get("ETag"))
notModifiedRec := httptest.NewRecorder()
r.ServeHTTP(notModifiedRec, notModifiedReq)
if notModifiedRec.Code != http.StatusNotModified {
t.Fatalf("expected 304, got %d body=%s", notModifiedRec.Code, notModifiedRec.Body.String())
}
itemReq := httptest.NewRequest(http.MethodGet, "/chat-history/"+entry.ID, nil)
itemReq.Header.Set("Authorization", "Bearer admin")
itemRec := httptest.NewRecorder()
r.ServeHTTP(itemRec, itemReq)
if itemRec.Code != http.StatusOK {
t.Fatalf("expected item 200, got %d body=%s", itemRec.Code, itemRec.Body.String())
}
if itemRec.Header().Get("ETag") == "" {
t.Fatalf("expected detail etag header")
}
updateReq := httptest.NewRequest(http.MethodPut, "/chat-history/settings", bytes.NewReader([]byte(`{"limit":10}`)))
updateReq.Header.Set("Authorization", "Bearer admin")
updateRec := httptest.NewRecorder()
r.ServeHTTP(updateRec, updateReq)
if updateRec.Code != http.StatusOK {
t.Fatalf("expected 200 from settings update, got %d body=%s", updateRec.Code, updateRec.Body.String())
}
snapshot, err := historyStore.Snapshot()
if err != nil {
t.Fatalf("snapshot failed: %v", err)
}
if snapshot.Limit != 10 {
t.Fatalf("expected limit=10, got %d", snapshot.Limit)
}
disableReq := httptest.NewRequest(http.MethodPut, "/chat-history/settings", bytes.NewReader([]byte(`{"limit":0}`)))
disableReq.Header.Set("Authorization", "Bearer admin")
disableRec := httptest.NewRecorder()
r.ServeHTTP(disableRec, disableReq)
if disableRec.Code != http.StatusOK {
t.Fatalf("expected 200 from disable update, got %d body=%s", disableRec.Code, disableRec.Body.String())
}
snapshot, err = historyStore.Snapshot()
if err != nil {
t.Fatalf("snapshot after disable failed: %v", err)
}
if snapshot.Limit != chathistory.DisabledLimit {
t.Fatalf("expected limit=0, got %d", snapshot.Limit)
}
if len(snapshot.Items) != 1 {
t.Fatalf("expected history preserved when disabled, got %d", len(snapshot.Items))
}
}
func TestDeleteAndClearChatHistory(t *testing.T) {
h, historyStore := newChatHistoryAdminHarness(t)
entryA, err := historyStore.Start(chathistory.StartParams{UserInput: "a"})
if err != nil {
t.Fatalf("start A failed: %v", err)
}
if _, err := historyStore.Start(chathistory.StartParams{UserInput: "b"}); err != nil {
t.Fatalf("start B failed: %v", err)
}
r := chi.NewRouter()
RegisterRoutes(r, h)
deleteReq := httptest.NewRequest(http.MethodDelete, "/chat-history/"+entryA.ID, nil)
deleteReq.Header.Set("Authorization", "Bearer admin")
deleteRec := httptest.NewRecorder()
r.ServeHTTP(deleteRec, deleteReq)
if deleteRec.Code != http.StatusOK {
t.Fatalf("expected delete 200, got %d body=%s", deleteRec.Code, deleteRec.Body.String())
}
snapshot, err := historyStore.Snapshot()
if err != nil {
t.Fatalf("snapshot failed: %v", err)
}
if len(snapshot.Items) != 1 {
t.Fatalf("expected one item after delete, got %d", len(snapshot.Items))
}
clearReq := httptest.NewRequest(http.MethodDelete, "/chat-history", nil)
clearReq.Header.Set("Authorization", "Bearer admin")
clearRec := httptest.NewRecorder()
r.ServeHTTP(clearRec, clearReq)
if clearRec.Code != http.StatusOK {
t.Fatalf("expected clear 200, got %d body=%s", clearRec.Code, clearRec.Body.String())
}
snapshot, err = historyStore.Snapshot()
if err != nil {
t.Fatalf("snapshot failed: %v", err)
}
if len(snapshot.Items) != 0 {
t.Fatalf("expected empty items after clear, got %d", len(snapshot.Items))
}
}

View File

@@ -0,0 +1,11 @@
package history
import "github.com/go-chi/chi/v5"
func RegisterRoutes(r chi.Router, h *Handler) {
r.Get("/chat-history", h.getChatHistory)
r.Get("/chat-history/{id}", h.getChatHistoryItem)
r.Delete("/chat-history", h.clearChatHistory)
r.Delete("/chat-history/{id}", h.deleteChatHistoryItem)
r.Put("/chat-history/settings", h.updateChatHistorySettings)
}

View File

@@ -0,0 +1,32 @@
package proxies
import (
"ds2api/internal/chathistory"
"ds2api/internal/config"
adminshared "ds2api/internal/httpapi/admin/shared"
)
type Handler struct {
Store adminshared.ConfigStore
Pool adminshared.PoolController
DS adminshared.DeepSeekCaller
OpenAI adminshared.OpenAIChatCaller
ChatHistory *chathistory.Store
}
var writeJSON = adminshared.WriteJSON
func fieldString(m map[string]any, key string) string {
return adminshared.FieldString(m, key)
}
func accountMatchesIdentifier(acc config.Account, identifier string) bool {
return adminshared.AccountMatchesIdentifier(acc, identifier)
}
func toProxy(m map[string]any) config.Proxy { return adminshared.ToProxy(m) }
func findProxyByID(c config.Config, proxyID string) (config.Proxy, bool) {
return adminshared.FindProxyByID(c, proxyID)
}
func newRequestError(detail string) error { return adminshared.NewRequestError(detail) }
func requestErrorDetail(err error) (string, bool) {
return adminshared.RequestErrorDetail(err)
}

View File

@@ -0,0 +1,202 @@
package proxies
import (
"context"
"encoding/json"
"net/http"
"net/url"
"strings"
"github.com/go-chi/chi/v5"
"ds2api/internal/config"
dsclient "ds2api/internal/deepseek/client"
)
var proxyConnectivityTester = func(ctx context.Context, proxy config.Proxy) map[string]any {
return dsclient.TestProxyConnectivity(ctx, proxy)
}
func validateProxyMutation(cfg *config.Config) error {
if cfg == nil {
return nil
}
if err := config.ValidateProxyConfig(cfg.Proxies); err != nil {
return err
}
return config.ValidateAccountProxyReferences(cfg.Accounts, cfg.Proxies)
}
func proxyResponse(proxy config.Proxy) map[string]any {
proxy = config.NormalizeProxy(proxy)
return map[string]any{
"id": proxy.ID,
"name": proxy.Name,
"type": proxy.Type,
"host": proxy.Host,
"port": proxy.Port,
"username": proxy.Username,
"has_password": strings.TrimSpace(proxy.Password) != "",
}
}
func (h *Handler) listProxies(w http.ResponseWriter, _ *http.Request) {
proxies := h.Store.Snapshot().Proxies
items := make([]map[string]any, 0, len(proxies))
for _, proxy := range proxies {
proxy = config.NormalizeProxy(proxy)
items = append(items, map[string]any{
"id": proxy.ID,
"name": proxy.Name,
"type": proxy.Type,
"host": proxy.Host,
"port": proxy.Port,
"username": proxy.Username,
"has_password": strings.TrimSpace(proxy.Password) != "",
})
}
writeJSON(w, http.StatusOK, map[string]any{"items": items, "total": len(items)})
}
func (h *Handler) addProxy(w http.ResponseWriter, r *http.Request) {
var req map[string]any
_ = json.NewDecoder(r.Body).Decode(&req)
proxy := toProxy(req)
err := h.Store.Update(func(c *config.Config) error {
c.Proxies = append(c.Proxies, proxy)
return validateProxyMutation(c)
})
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"success": true, "proxy": proxyResponse(proxy)})
}
func (h *Handler) updateProxy(w http.ResponseWriter, r *http.Request) {
proxyID := chi.URLParam(r, "proxyID")
if decoded, err := url.PathUnescape(proxyID); err == nil {
proxyID = decoded
}
var req map[string]any
_ = json.NewDecoder(r.Body).Decode(&req)
proxy := toProxy(req)
proxy.ID = strings.TrimSpace(proxyID)
err := h.Store.Update(func(c *config.Config) error {
for i, existing := range c.Proxies {
existing = config.NormalizeProxy(existing)
if existing.ID != proxy.ID {
continue
}
if proxy.Password == "" {
proxy.Password = existing.Password
}
c.Proxies[i] = proxy
return validateProxyMutation(c)
}
return newRequestError("代理不存在")
})
if err != nil {
if detail, ok := requestErrorDetail(err); ok {
writeJSON(w, http.StatusNotFound, map[string]any{"detail": detail})
return
}
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"success": true, "proxy": proxyResponse(proxy)})
}
func (h *Handler) deleteProxy(w http.ResponseWriter, r *http.Request) {
proxyID := chi.URLParam(r, "proxyID")
if decoded, err := url.PathUnescape(proxyID); err == nil {
proxyID = decoded
}
err := h.Store.Update(func(c *config.Config) error {
idx := -1
for i, existing := range c.Proxies {
existing = config.NormalizeProxy(existing)
if existing.ID == strings.TrimSpace(proxyID) {
idx = i
break
}
}
if idx < 0 {
return newRequestError("代理不存在")
}
c.Proxies = append(c.Proxies[:idx], c.Proxies[idx+1:]...)
for i := range c.Accounts {
if strings.TrimSpace(c.Accounts[i].ProxyID) == strings.TrimSpace(proxyID) {
c.Accounts[i].ProxyID = ""
}
}
return validateProxyMutation(c)
})
if err != nil {
if detail, ok := requestErrorDetail(err); ok {
writeJSON(w, http.StatusNotFound, map[string]any{"detail": detail})
return
}
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"success": true})
}
func (h *Handler) testProxy(w http.ResponseWriter, r *http.Request) {
var req map[string]any
_ = json.NewDecoder(r.Body).Decode(&req)
proxyID := fieldString(req, "proxy_id")
var proxy config.Proxy
if proxyID != "" {
var ok bool
proxy, ok = findProxyByID(h.Store.Snapshot(), proxyID)
if !ok {
writeJSON(w, http.StatusNotFound, map[string]any{"detail": "代理不存在"})
return
}
} else {
proxy = toProxy(req)
}
result := proxyConnectivityTester(r.Context(), proxy)
writeJSON(w, http.StatusOK, result)
}
func (h *Handler) updateAccountProxy(w http.ResponseWriter, r *http.Request) {
identifier := chi.URLParam(r, "identifier")
if decoded, err := url.PathUnescape(identifier); err == nil {
identifier = decoded
}
var req map[string]any
_ = json.NewDecoder(r.Body).Decode(&req)
proxyID := fieldString(req, "proxy_id")
err := h.Store.Update(func(c *config.Config) error {
if proxyID != "" {
if _, ok := findProxyByID(*c, proxyID); !ok {
return newRequestError("代理不存在")
}
}
for i, acc := range c.Accounts {
if !accountMatchesIdentifier(acc, identifier) {
continue
}
c.Accounts[i].ProxyID = proxyID
return validateProxyMutation(c)
}
return newRequestError("账号不存在")
})
if err != nil {
if detail, ok := requestErrorDetail(err); ok {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": detail})
return
}
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
return
}
h.Pool.Reset()
writeJSON(w, http.StatusOK, map[string]any{"success": true, "proxy_id": proxyID})
}

View File

@@ -0,0 +1,227 @@
package proxies
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"ds2api/internal/account"
"ds2api/internal/config"
)
func newAdminProxyTestHandler(t *testing.T, raw string) *Handler {
t.Helper()
t.Setenv("DS2API_CONFIG_JSON", raw)
store := config.LoadStore()
return &Handler{
Store: store,
Pool: account.NewPool(store),
}
}
func TestAddProxyPersistsNormalizedProxy(t *testing.T) {
h := newAdminProxyTestHandler(t, `{"accounts":[]}`)
r := chi.NewRouter()
r.Post("/admin/proxies", h.addProxy)
req := httptest.NewRequest(http.MethodPost, "/admin/proxies", bytes.NewBufferString(`{
"name":" HK Exit ",
"type":" SOCKS5H ",
"host":" 127.0.0.1 ",
"port":1081,
"username":" user ",
"password":" pass "
}`))
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String())
}
proxies := h.Store.Snapshot().Proxies
if len(proxies) != 1 {
t.Fatalf("expected 1 proxy, got %d", len(proxies))
}
if proxies[0].Name != "HK Exit" {
t.Fatalf("unexpected proxy name: %#v", proxies[0])
}
if proxies[0].Type != "socks5h" {
t.Fatalf("unexpected proxy type: %#v", proxies[0])
}
if proxies[0].Username != "user" || proxies[0].Password != "pass" {
t.Fatalf("expected trimmed credentials, got %#v", proxies[0])
}
if proxies[0].ID == "" {
t.Fatalf("expected generated proxy id, got %#v", proxies[0])
}
}
func TestAddProxyDoesNotFailOnUnrelatedInvalidRuntimeConfig(t *testing.T) {
router := newHTTPAdminHarness(t, `{
"keys":["k1"],
"runtime":{
"account_max_inflight":8,
"global_max_inflight":4
}
}`, &testingDSMock{})
rec := httptest.NewRecorder()
router.ServeHTTP(rec, adminReq(http.MethodPost, "/proxies", []byte(`{
"name":"HK Exit",
"type":"socks5h",
"host":"127.0.0.1",
"port":1080
}`)))
if rec.Code != http.StatusOK {
t.Fatalf("expected add proxy success despite unrelated runtime issue, got %d body=%s", rec.Code, rec.Body.String())
}
readRec := httptest.NewRecorder()
router.ServeHTTP(readRec, adminReq(http.MethodGet, "/config", nil))
if readRec.Code != http.StatusOK {
t.Fatalf("config read status=%d body=%s", readRec.Code, readRec.Body.String())
}
var payload map[string]any
if err := json.Unmarshal(readRec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode config response: %v", err)
}
proxies, _ := payload["proxies"].([]any)
if len(proxies) != 1 {
t.Fatalf("expected proxy to be persisted, got %#v", payload["proxies"])
}
}
func TestDeleteProxyClearsAssignedAccountProxyID(t *testing.T) {
h := newAdminProxyTestHandler(t, `{
"proxies":[{"id":"proxy-1","name":"Node 1","type":"socks5","host":"127.0.0.1","port":1080}],
"accounts":[{"email":"u@example.com","password":"pwd","proxy_id":"proxy-1"}]
}`)
r := chi.NewRouter()
r.Delete("/admin/proxies/{proxyID}", h.deleteProxy)
req := httptest.NewRequest(http.MethodDelete, "/admin/proxies/proxy-1", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String())
}
snap := h.Store.Snapshot()
if len(snap.Proxies) != 0 {
t.Fatalf("expected proxy removed, got %#v", snap.Proxies)
}
if len(snap.Accounts) != 1 {
t.Fatalf("expected account kept, got %#v", snap.Accounts)
}
if snap.Accounts[0].ProxyID != "" {
t.Fatalf("expected proxy assignment cleared, got %#v", snap.Accounts[0])
}
}
func TestUpdateProxyResponseDoesNotExposeStoredPassword(t *testing.T) {
h := newAdminProxyTestHandler(t, `{
"proxies":[{"id":"proxy-1","name":"Node 1","type":"socks5h","host":"127.0.0.1","port":1080,"username":"u","password":"secret"}]
}`)
r := chi.NewRouter()
r.Put("/admin/proxies/{proxyID}", h.updateProxy)
req := httptest.NewRequest(http.MethodPut, "/admin/proxies/proxy-1", bytes.NewBufferString(`{
"name":"Node 1",
"type":"socks5h",
"host":"127.0.0.2",
"port":1081,
"username":"u2"
}`))
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String())
}
var payload map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
proxy, _ := payload["proxy"].(map[string]any)
if _, exists := proxy["password"]; exists {
t.Fatalf("response should not expose password, got %#v", proxy)
}
if hasPassword, _ := proxy["has_password"].(bool); !hasPassword {
t.Fatalf("expected has_password=true, got %#v", proxy)
}
}
func TestUpdateAccountProxyAssignsProxyID(t *testing.T) {
h := newAdminProxyTestHandler(t, `{
"proxies":[{"id":"proxy-1","name":"Node 1","type":"socks5h","host":"127.0.0.1","port":1080}],
"accounts":[{"email":"u@example.com","password":"pwd"}]
}`)
r := chi.NewRouter()
r.Put("/admin/accounts/{identifier}/proxy", h.updateAccountProxy)
req := httptest.NewRequest(http.MethodPut, "/admin/accounts/u@example.com/proxy", bytes.NewBufferString(`{"proxy_id":"proxy-1"}`))
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String())
}
acc, ok := h.Store.FindAccount("u@example.com")
if !ok {
t.Fatal("expected account")
}
if acc.ProxyID != "proxy-1" {
t.Fatalf("expected proxy assigned, got %#v", acc)
}
}
func TestTestProxyUsesStoredProxy(t *testing.T) {
h := newAdminProxyTestHandler(t, `{
"proxies":[{"id":"proxy-1","name":"Node 1","type":"socks5h","host":"127.0.0.1","port":1080}]
}`)
original := proxyConnectivityTester
defer func() { proxyConnectivityTester = original }()
var got config.Proxy
proxyConnectivityTester = func(_ context.Context, proxy config.Proxy) map[string]any {
got = proxy
return map[string]any{
"success": true,
"proxy_id": proxy.ID,
"proxy_type": proxy.Type,
"response_time": 12,
}
}
r := chi.NewRouter()
r.Post("/admin/proxies/test", h.testProxy)
req := httptest.NewRequest(http.MethodPost, "/admin/proxies/test", bytes.NewBufferString(`{"proxy_id":"proxy-1"}`))
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String())
}
if got.ID != "proxy-1" || got.Type != "socks5h" {
t.Fatalf("expected stored proxy passed to tester, got %#v", got)
}
var payload map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if ok, _ := payload["success"].(bool); !ok {
t.Fatalf("expected success payload, got %#v", payload)
}
}

View File

@@ -0,0 +1,24 @@
package proxies
import (
"net/http"
"github.com/go-chi/chi/v5"
)
func RegisterRoutes(r chi.Router, h *Handler) {
r.Get("/proxies", h.listProxies)
r.Post("/proxies", h.addProxy)
r.Put("/proxies/{proxyID}", h.updateProxy)
r.Delete("/proxies/{proxyID}", h.deleteProxy)
r.Post("/proxies/test", h.testProxy)
r.Put("/accounts/{identifier}/proxy", h.updateAccountProxy)
}
func (h *Handler) AddProxy(w http.ResponseWriter, r *http.Request) { h.addProxy(w, r) }
func (h *Handler) UpdateProxy(w http.ResponseWriter, r *http.Request) { h.updateProxy(w, r) }
func (h *Handler) DeleteProxy(w http.ResponseWriter, r *http.Request) { h.deleteProxy(w, r) }
func (h *Handler) TestProxy(w http.ResponseWriter, r *http.Request) { h.testProxy(w, r) }
func (h *Handler) UpdateAccountProxy(w http.ResponseWriter, r *http.Request) {
h.updateAccountProxy(w, r)
}

View File

@@ -0,0 +1,57 @@
package proxies
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"ds2api/internal/account"
"ds2api/internal/auth"
"ds2api/internal/config"
dsclient "ds2api/internal/deepseek/client"
adminconfig "ds2api/internal/httpapi/admin/configmgmt"
adminshared "ds2api/internal/httpapi/admin/shared"
)
type testingDSMock struct{}
func (m *testingDSMock) Login(_ context.Context, _ config.Account) (string, error) {
return "token", nil
}
func (m *testingDSMock) CreateSession(_ context.Context, _ *auth.RequestAuth, _ int) (string, error) {
return "session-id", nil
}
func (m *testingDSMock) GetPow(_ context.Context, _ *auth.RequestAuth, _ int) (string, error) {
return "pow", nil
}
func (m *testingDSMock) CallCompletion(_ context.Context, _ *auth.RequestAuth, _ map[string]any, _ string, _ int) (*http.Response, error) {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
}
func (m *testingDSMock) DeleteAllSessionsForToken(_ context.Context, _ string) error { return nil }
func (m *testingDSMock) GetSessionCountForToken(_ context.Context, _ string) (*dsclient.SessionStats, error) {
return &dsclient.SessionStats{}, nil
}
func newHTTPAdminHarness(t *testing.T, rawConfig string, ds adminshared.DeepSeekCaller) http.Handler {
t.Helper()
t.Setenv("DS2API_CONFIG_JSON", rawConfig)
store := config.LoadStore()
pool := account.NewPool(store)
h := &Handler{Store: store, Pool: pool, DS: ds}
configHandler := &adminconfig.Handler{Store: store, Pool: pool, DS: ds}
r := chi.NewRouter()
RegisterRoutes(r, h)
r.Get("/config", configHandler.GetConfig)
return r
}
func adminReq(method, path string, body []byte) *http.Request {
req := httptest.NewRequest(method, path, bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer admin")
req.Header.Set("Content-Type", "application/json")
return req
}

View File

@@ -0,0 +1,27 @@
package rawsamples
import (
"net/http"
"ds2api/internal/chathistory"
adminshared "ds2api/internal/httpapi/admin/shared"
)
type Handler struct {
Store adminshared.ConfigStore
Pool adminshared.PoolController
DS adminshared.DeepSeekCaller
OpenAI adminshared.OpenAIChatCaller
ChatHistory *chathistory.Store
}
var writeJSON = adminshared.WriteJSON
func intFromQuery(r *http.Request, key string, d int) int {
return adminshared.IntFromQuery(r, key, d)
}
func nilIfEmpty(s string) any { return adminshared.NilIfEmpty(s) }
func toStringSlice(v any) ([]string, bool) { return adminshared.ToStringSlice(v) }
func fieldString(m map[string]any, key string) string {
return adminshared.FieldString(m, key)
}

View File

@@ -0,0 +1,549 @@
package rawsamples
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"sort"
"strings"
"ds2api/internal/config"
"ds2api/internal/devcapture"
adminshared "ds2api/internal/httpapi/admin/shared"
"ds2api/internal/rawsample"
)
type captureChain struct {
Key string
Entries []devcapture.Entry
}
func (h *Handler) captureRawSample(w http.ResponseWriter, r *http.Request) {
if h.OpenAI == nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": "OpenAI handler is not configured"})
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
}
payload, sampleID, apiKey, err := prepareRawSampleCaptureRequest(h.Store, req)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
return
}
body, err := json.Marshal(payload)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": "failed to encode capture request"})
return
}
traceID := rawsample.NormalizeSampleID(sampleID)
if traceID == "" {
traceID = rawsample.DefaultSampleID("capture")
}
before := devcapture.Global().Snapshot()
rec := httptest.NewRecorder()
captureReq := httptest.NewRequest(http.MethodPost, "/v1/chat/completions?__trace_id="+url.QueryEscape(traceID), bytes.NewReader(body))
captureReq.Header.Set("Authorization", "Bearer "+apiKey)
captureReq.Header.Set("Content-Type", "application/json")
h.OpenAI.ChatCompletions(rec, captureReq)
after := devcapture.Global().Snapshot()
if rec.Code >= http.StatusBadRequest {
copyHeader(w.Header(), rec.Header())
w.WriteHeader(rec.Code)
_, _ = io.Copy(w, bytes.NewReader(rec.Body.Bytes()))
return
}
captureEntries, err := collectNewCaptureEntries(before, after)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": err.Error()})
return
}
saved, err := rawsample.Persist(rawsample.PersistOptions{
RootDir: config.RawStreamSampleRoot(),
SampleID: sampleID,
Source: "admin/dev/raw-samples/capture",
Request: payload,
Capture: captureSummaryFromEntries(captureEntries),
UpstreamBody: combineCaptureBodies(captureEntries),
})
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": err.Error()})
return
}
copyHeader(w.Header(), rec.Header())
w.Header().Set("X-Ds2-Sample-Id", saved.SampleID)
w.Header().Set("X-Ds2-Sample-Dir", saved.Dir)
w.Header().Set("X-Ds2-Sample-Meta", saved.MetaPath)
w.Header().Set("X-Ds2-Sample-Upstream", saved.UpstreamPath)
w.WriteHeader(rec.Code)
_, _ = io.Copy(w, bytes.NewReader(rec.Body.Bytes()))
}
func prepareRawSampleCaptureRequest(store adminshared.ConfigStore, req map[string]any) (map[string]any, string, string, error) {
payload := cloneMap(req)
sampleID := strings.TrimSpace(fieldString(payload, "sample_id"))
apiKey := strings.TrimSpace(fieldString(payload, "api_key"))
for _, k := range []string{"sample_id", "api_key", "promote_default", "persist", "source"} {
delete(payload, k)
}
if apiKey == "" {
if store == nil {
return nil, "", "", fmt.Errorf("no api key provided")
}
keys := store.Keys()
if len(keys) == 0 {
return nil, "", "", fmt.Errorf("no api key available")
}
apiKey = strings.TrimSpace(keys[0])
}
if model := strings.TrimSpace(fieldString(payload, "model")); model == "" {
payload["model"] = "deepseek-v4-flash"
}
if _, ok := payload["stream"]; !ok {
payload["stream"] = true
}
if messagesRaw, ok := payload["messages"].([]any); !ok || len(messagesRaw) == 0 {
message := strings.TrimSpace(fieldString(payload, "message"))
if message == "" {
message = "你好"
}
payload["messages"] = []map[string]any{{"role": "user", "content": message}}
}
delete(payload, "message")
if sampleID == "" {
model := strings.TrimSpace(fieldString(payload, "model"))
if model == "" {
model = "capture"
}
sampleID = rawsample.DefaultSampleID(model)
}
return payload, sampleID, apiKey, nil
}
func collectNewCaptureEntries(before, after []devcapture.Entry) ([]devcapture.Entry, error) {
beforeIDs := make(map[string]struct{}, len(before))
for _, entry := range before {
beforeIDs[entry.ID] = struct{}{}
}
entries := make([]devcapture.Entry, 0, len(after))
for _, entry := range after {
if _, ok := beforeIDs[entry.ID]; ok {
continue
}
if strings.TrimSpace(entry.ResponseBody) == "" {
continue
}
entries = append(entries, entry)
}
if len(entries) == 0 {
return nil, fmt.Errorf("no upstream capture was recorded")
}
// Snapshot order is newest-first; reverse to preserve the actual request order.
for i, j := 0, len(entries)-1; i < j; i, j = i+1, j-1 {
entries[i], entries[j] = entries[j], entries[i]
}
return entries, nil
}
func captureSummaryFromEntries(entries []devcapture.Entry) rawsample.CaptureSummary {
if len(entries) == 0 {
return rawsample.CaptureSummary{}
}
// Primary metadata comes from the first (initial) capture.
summary := rawsample.CaptureSummary{
Label: strings.TrimSpace(entries[0].Label),
URL: strings.TrimSpace(entries[0].URL),
StatusCode: entries[0].StatusCode,
}
// Record every round (initial + continuations) so replay/debug
// can reconstruct the full multi-round interaction.
totalBytes := 0
rounds := make([]rawsample.CaptureRound, 0, len(entries))
for _, entry := range entries {
n := len(entry.ResponseBody)
totalBytes += n
rounds = append(rounds, rawsample.CaptureRound{
Label: strings.TrimSpace(entry.Label),
URL: strings.TrimSpace(entry.URL),
StatusCode: entry.StatusCode,
ResponseBytes: n,
})
}
summary.ResponseBytes = totalBytes
if len(rounds) > 1 {
summary.Rounds = rounds
}
return summary
}
func combineCaptureBodies(entries []devcapture.Entry) []byte {
if len(entries) == 0 {
return nil
}
var buf bytes.Buffer
for _, entry := range entries {
if buf.Len() > 0 {
last := buf.Bytes()[buf.Len()-1]
if last != '\n' {
buf.WriteByte('\n')
}
}
buf.WriteString(entry.ResponseBody)
}
return buf.Bytes()
}
func copyHeader(dst, src http.Header) {
for k, vv := range src {
dst.Del(k)
for _, v := range vv {
dst.Add(k, v)
}
}
}
func cloneMap(in map[string]any) map[string]any {
if len(in) == 0 {
return map[string]any{}
}
out := make(map[string]any, len(in))
for k, v := range in {
out[k] = v
}
return out
}
func (h *Handler) queryRawSampleCaptures(w http.ResponseWriter, r *http.Request) {
query := strings.TrimSpace(r.URL.Query().Get("q"))
limit := intFromQuery(r, "limit", 20)
if limit <= 0 {
limit = 20
}
if limit > 50 {
limit = 50
}
chains := buildCaptureChains(devcapture.Global().Snapshot())
items := make([]map[string]any, 0, len(chains))
for _, chain := range chains {
if query != "" && !captureChainMatchesQuery(chain, query) {
continue
}
items = append(items, buildCaptureChainQueryItem(chain, query))
if len(items) >= limit {
break
}
}
writeJSON(w, http.StatusOK, map[string]any{
"query": query,
"limit": limit,
"count": len(items),
"items": items,
})
}
func (h *Handler) saveRawSampleFromCaptures(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
}
snapshot := devcapture.Global().Snapshot()
if len(snapshot) == 0 {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "no capture logs available"})
return
}
chain, err := resolveCaptureChainSelection(snapshot, req)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
return
}
sampleID := strings.TrimSpace(fieldString(req, "sample_id"))
source := strings.TrimSpace(fieldString(req, "source"))
if source == "" {
source = "admin/dev/raw-samples/save"
}
requestPayload := captureChainRequestPayload(chain)
saved, err := rawsample.Persist(rawsample.PersistOptions{
RootDir: config.RawStreamSampleRoot(),
SampleID: sampleID,
Source: source,
Request: requestPayload,
Capture: captureSummaryFromEntries(chain.Entries),
UpstreamBody: combineCaptureBodies(chain.Entries),
})
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{
"success": true,
"sample_id": saved.SampleID,
"sample_dir": saved.Dir,
"meta_path": saved.MetaPath,
"upstream_path": saved.UpstreamPath,
"chain_key": chain.Key,
"capture_ids": captureChainIDs(chain),
"round_count": len(chain.Entries),
})
}
func buildCaptureChains(snapshot []devcapture.Entry) []captureChain {
if len(snapshot) == 0 {
return nil
}
ordered := make([]devcapture.Entry, len(snapshot))
// devcapture snapshots are newest-first because the store prepends entries.
// Reverse once so equal-second timestamps can preserve the actual capture
// order (completion before continue) under the stable CreatedAt sort below.
for i := range snapshot {
ordered[len(snapshot)-1-i] = snapshot[i]
}
sort.SliceStable(ordered, func(i, j int) bool {
return ordered[i].CreatedAt < ordered[j].CreatedAt
})
byKey := make(map[string]*captureChain, len(ordered))
keys := make([]string, 0, len(ordered))
for _, entry := range ordered {
key := captureChainKey(entry)
if key == "" {
key = "capture:" + entry.ID
}
if _, ok := byKey[key]; !ok {
byKey[key] = &captureChain{Key: key}
keys = append(keys, key)
}
byKey[key].Entries = append(byKey[key].Entries, entry)
}
chains := make([]captureChain, 0, len(keys))
for _, key := range keys {
chains = append(chains, *byKey[key])
}
sort.SliceStable(chains, func(i, j int) bool {
return latestCreatedAt(chains[i]) > latestCreatedAt(chains[j])
})
return chains
}
func captureChainKey(entry devcapture.Entry) string {
req := parseCaptureRequestBody(entry.RequestBody)
if sessionID := strings.TrimSpace(fieldString(req, "chat_session_id")); sessionID != "" {
return "session:" + sessionID
}
return "capture:" + entry.ID
}
func parseCaptureRequestBody(raw string) map[string]any {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
var out map[string]any
if err := json.Unmarshal([]byte(raw), &out); err != nil {
return nil
}
return out
}
func latestCreatedAt(chain captureChain) int64 {
var latest int64
for _, entry := range chain.Entries {
if entry.CreatedAt > latest {
latest = entry.CreatedAt
}
}
return latest
}
func captureChainMatchesQuery(chain captureChain, query string) bool {
query = strings.ToLower(strings.TrimSpace(query))
if query == "" {
return true
}
for _, entry := range chain.Entries {
hay := strings.ToLower(strings.Join([]string{
entry.Label,
entry.URL,
entry.AccountID,
entry.RequestBody,
entry.ResponseBody,
}, "\n"))
if strings.Contains(hay, query) {
return true
}
}
return false
}
func buildCaptureChainQueryItem(chain captureChain, query string) map[string]any {
first := chain.Entries[0]
last := chain.Entries[len(chain.Entries)-1]
requestPreview := previewCaptureChainRequest(chain)
responsePreview := previewCaptureChainResponse(chain)
return map[string]any{
"chain_key": chain.Key,
"capture_ids": captureChainIDs(chain),
"created_at": latestCreatedAt(chain),
"round_count": len(chain.Entries),
"account_id": nilIfEmpty(strings.TrimSpace(first.AccountID)),
"initial_label": first.Label,
"initial_url": first.URL,
"latest_label": last.Label,
"latest_url": last.URL,
"request_preview": requestPreview,
"response_preview": responsePreview,
"query": query,
"response_truncated": captureChainHasTruncatedResponse(chain),
}
}
func captureChainIDs(chain captureChain) []string {
out := make([]string, 0, len(chain.Entries))
for _, entry := range chain.Entries {
out = append(out, entry.ID)
}
return out
}
func previewCaptureChainRequest(chain captureChain) string {
for _, entry := range chain.Entries {
req := parseCaptureRequestBody(entry.RequestBody)
if prompt := strings.TrimSpace(fieldString(req, "prompt")); prompt != "" {
return previewText(prompt, 280)
}
if messages, ok := req["messages"].([]any); ok {
var parts []string
for _, item := range messages {
m, _ := item.(map[string]any)
content := strings.TrimSpace(fieldString(m, "content"))
if content != "" {
parts = append(parts, content)
}
}
if len(parts) > 0 {
return previewText(strings.Join(parts, "\n"), 280)
}
}
}
return previewText(strings.TrimSpace(chain.Entries[0].RequestBody), 280)
}
func previewCaptureChainResponse(chain captureChain) string {
var b strings.Builder
for _, entry := range chain.Entries {
if b.Len() > 0 {
b.WriteByte('\n')
}
b.WriteString(strings.TrimSpace(entry.ResponseBody))
if b.Len() >= 280 {
break
}
}
return previewText(b.String(), 280)
}
func previewText(text string, limit int) string {
text = strings.TrimSpace(text)
if limit <= 0 || len(text) <= limit {
return text
}
return text[:limit] + "..."
}
func captureChainHasTruncatedResponse(chain captureChain) bool {
for _, entry := range chain.Entries {
if entry.ResponseTruncated {
return true
}
}
return false
}
func resolveCaptureChainSelection(snapshot []devcapture.Entry, req map[string]any) (captureChain, error) {
chains := buildCaptureChains(snapshot)
if len(chains) == 0 {
return captureChain{}, fmt.Errorf("no capture logs available")
}
if chainKey := strings.TrimSpace(fieldString(req, "chain_key")); chainKey != "" {
for _, chain := range chains {
if chain.Key == chainKey {
return chain, nil
}
}
return captureChain{}, fmt.Errorf("capture chain not found")
}
captureID := strings.TrimSpace(fieldString(req, "capture_id"))
if captureID == "" {
if ids, ok := toStringSlice(req["capture_ids"]); ok && len(ids) > 0 {
captureID = strings.TrimSpace(ids[0])
}
}
if captureID != "" {
for _, chain := range chains {
for _, entry := range chain.Entries {
if entry.ID == captureID {
return chain, nil
}
}
}
return captureChain{}, fmt.Errorf("capture id not found")
}
query := strings.TrimSpace(fieldString(req, "query"))
if query != "" {
for _, chain := range chains {
if captureChainMatchesQuery(chain, query) {
return chain, nil
}
}
return captureChain{}, fmt.Errorf("no capture chain matched query")
}
return captureChain{}, fmt.Errorf("capture_id, chain_key, or query is required")
}
func captureChainRequestPayload(chain captureChain) any {
for _, entry := range chain.Entries {
if req := parseCaptureRequestBody(entry.RequestBody); req != nil {
return req
}
}
return strings.TrimSpace(chain.Entries[0].RequestBody)
}

View File

@@ -0,0 +1,389 @@
package rawsamples
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"ds2api/internal/devcapture"
)
type stubOpenAIChatCaller struct{}
func (stubOpenAIChatCaller) ChatCompletions(w http.ResponseWriter, _ *http.Request) {
store := devcapture.Global()
session := store.Start("deepseek_completion", "https://chat.deepseek.com/api/v0/chat/completion", "acct-test", map[string]any{"model": "deepseek-v4-flash"})
raw := io.NopCloser(strings.NewReader(
"data: {\"v\":\"hello [reference:1]\"}\n\n" +
"data: {\"v\":\"FINISHED\",\"p\":\"response/status\"}\n\n",
))
if session != nil {
raw = session.WrapBody(raw, http.StatusOK)
}
_, _ = io.ReadAll(raw)
_ = raw.Close()
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"hello\"},\"index\":0}],\"created\":1,\"id\":\"id\",\"model\":\"m\",\"object\":\"chat.completion.chunk\"}\n\n")
}
type stubOpenAIChatCallerWithContinuations struct{}
func (stubOpenAIChatCallerWithContinuations) ChatCompletions(w http.ResponseWriter, _ *http.Request) {
recordCapturedResponse("deepseek_completion", "https://chat.deepseek.com/api/v0/chat/completion", http.StatusOK, map[string]any{"model": "deepseek-v4-flash"}, "data: {\"v\":\"hello [reference:1]\"}\n\n"+"data: [DONE]\n\n")
recordCapturedResponse("deepseek_continue", "https://chat.deepseek.com/api/v0/chat/continue", http.StatusOK, map[string]any{"chat_session_id": "session-1", "message_id": 2}, "data: {\"v\":\"continued\"}\n\n"+"data: [DONE]\n\n")
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"hello continued\"},\"index\":0}],\"created\":1,\"id\":\"id\",\"model\":\"m\",\"object\":\"chat.completion.chunk\"}\n\n")
}
type stubOpenAIChatCallerWithoutCapture struct{}
func (stubOpenAIChatCallerWithoutCapture) ChatCompletions(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"hello\"},\"index\":0}],\"created\":1,\"id\":\"id\",\"model\":\"m\",\"object\":\"chat.completion.chunk\"}\n\n")
}
func recordCapturedResponse(label, rawURL string, statusCode int, request any, body string) {
store := devcapture.Global()
session := store.Start(label, rawURL, "acct-test", request)
raw := io.NopCloser(strings.NewReader(body))
if session != nil {
raw = session.WrapBody(raw, statusCode)
}
_, _ = io.ReadAll(raw)
_ = raw.Close()
}
func TestCaptureRawSampleWritesPersistentSample(t *testing.T) {
t.Setenv("DS2API_RAW_STREAM_SAMPLE_ROOT", t.TempDir())
devcapture.Global().Clear()
defer devcapture.Global().Clear()
h := &Handler{OpenAI: stubOpenAIChatCaller{}}
reqBody := `{
"sample_id":"My Sample 01",
"api_key":"local-key",
"model":"deepseek-v4-flash",
"message":"广州天气",
"stream":true
}`
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/admin/dev/raw-samples/capture", strings.NewReader(reqBody))
h.captureRawSample(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
}
if got := rec.Header().Get("X-Ds2-Sample-Id"); got != "my-sample-01" {
t.Fatalf("expected sample id header my-sample-01, got %q", got)
}
if got := rec.Header().Get("X-Ds2-Sample-Upstream"); got != filepath.Join(os.Getenv("DS2API_RAW_STREAM_SAMPLE_ROOT"), "my-sample-01", "upstream.stream.sse") {
t.Fatalf("unexpected sample upstream header: %q", got)
}
if !strings.Contains(rec.Body.String(), `"content":"hello"`) {
t.Fatalf("expected proxied openai output, got %s", rec.Body.String())
}
sampleDir := filepath.Join(os.Getenv("DS2API_RAW_STREAM_SAMPLE_ROOT"), "my-sample-01")
if _, err := os.Stat(sampleDir); err != nil {
t.Fatalf("sample dir missing: %v", err)
}
metaBytes, err := os.ReadFile(filepath.Join(sampleDir, "meta.json"))
if err != nil {
t.Fatalf("read meta: %v", err)
}
var meta map[string]any
if err := json.Unmarshal(metaBytes, &meta); err != nil {
t.Fatalf("decode meta: %v", err)
}
if meta["sample_id"] != "my-sample-01" {
t.Fatalf("unexpected meta sample_id: %#v", meta["sample_id"])
}
capture, _ := meta["capture"].(map[string]any)
if capture == nil {
t.Fatalf("missing capture meta: %#v", meta)
}
if got := int(capture["response_bytes"].(float64)); got == 0 {
t.Fatalf("expected capture bytes to be recorded, got %#v", capture)
}
if _, ok := meta["processed"]; ok {
t.Fatalf("unexpected processed meta: %#v", meta["processed"])
}
}
func TestCaptureRawSampleCombinesContinuationCaptures(t *testing.T) {
t.Setenv("DS2API_RAW_STREAM_SAMPLE_ROOT", t.TempDir())
devcapture.Global().Clear()
defer devcapture.Global().Clear()
h := &Handler{OpenAI: stubOpenAIChatCallerWithContinuations{}}
reqBody := `{
"sample_id":"My Sample 02",
"api_key":"local-key",
"model":"deepseek-v4-flash",
"message":"广州天气",
"stream":true
}`
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/admin/dev/raw-samples/capture", strings.NewReader(reqBody))
h.captureRawSample(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
}
sampleDir := filepath.Join(os.Getenv("DS2API_RAW_STREAM_SAMPLE_ROOT"), "my-sample-02")
upstreamBytes, err := os.ReadFile(filepath.Join(sampleDir, "upstream.stream.sse"))
if err != nil {
t.Fatalf("read upstream: %v", err)
}
upstream := string(upstreamBytes)
if !strings.Contains(upstream, "hello [reference:1]") {
t.Fatalf("expected initial capture in combined upstream, got %s", upstream)
}
if !strings.Contains(upstream, "continued") {
t.Fatalf("expected continuation capture in combined upstream, got %s", upstream)
}
if strings.Index(upstream, "hello [reference:1]") > strings.Index(upstream, "continued") {
t.Fatalf("expected initial capture before continuation, got %s", upstream)
}
metaBytes, err := os.ReadFile(filepath.Join(sampleDir, "meta.json"))
if err != nil {
t.Fatalf("read meta: %v", err)
}
var meta map[string]any
if err := json.Unmarshal(metaBytes, &meta); err != nil {
t.Fatalf("decode meta: %v", err)
}
capture, _ := meta["capture"].(map[string]any)
if capture == nil {
t.Fatalf("missing capture meta: %#v", meta)
}
if got := int(capture["response_bytes"].(float64)); got != len(upstreamBytes) {
t.Fatalf("expected combined response_bytes %d, got %#v", len(upstreamBytes), capture["response_bytes"])
}
rounds, _ := capture["rounds"].([]any)
if len(rounds) != 2 {
t.Fatalf("expected 2 capture rounds, got %d: %#v", len(rounds), capture)
}
r0, _ := rounds[0].(map[string]any)
r1, _ := rounds[1].(map[string]any)
if r0["label"] != "deepseek_completion" {
t.Fatalf("expected first round label deepseek_completion, got %v", r0["label"])
}
if r1["label"] != "deepseek_continue" {
t.Fatalf("expected second round label deepseek_continue, got %v", r1["label"])
}
}
func TestCaptureRawSampleReturnsErrorWhenNoNewCaptureRecorded(t *testing.T) {
root := t.TempDir()
t.Setenv("DS2API_RAW_STREAM_SAMPLE_ROOT", root)
devcapture.Global().Clear()
defer devcapture.Global().Clear()
recordCapturedResponse("preexisting", "https://chat.deepseek.com/api/v0/chat/completion", http.StatusOK, map[string]any{"model": "deepseek-v4-flash"}, "data: {\"v\":\"old\"}\n\n")
h := &Handler{OpenAI: stubOpenAIChatCallerWithoutCapture{}}
reqBody := `{
"sample_id":"My Sample 03",
"api_key":"local-key",
"model":"deepseek-v4-flash",
"message":"广州天气",
"stream":true
}`
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/admin/dev/raw-samples/capture", strings.NewReader(reqBody))
h.captureRawSample(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d body=%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "no upstream capture was recorded") {
t.Fatalf("expected no-capture error, got %s", rec.Body.String())
}
if _, err := os.Stat(filepath.Join(root, "my-sample-03")); !os.IsNotExist(err) {
t.Fatalf("expected no sample dir to be created, stat err=%v", err)
}
}
func TestCombineCaptureBodiesPreservesOrderAndSeparators(t *testing.T) {
entries := []devcapture.Entry{
{ResponseBody: "first"},
{ResponseBody: "second"},
}
got := combineCaptureBodies(entries)
if !bytes.Equal(got, []byte("first\nsecond")) {
t.Fatalf("unexpected combined body: %q", string(got))
}
}
func TestQueryRawSampleCapturesGroupsBySessionAndMatchesQuestion(t *testing.T) {
devcapture.Global().Clear()
defer devcapture.Global().Clear()
recordCapturedResponse(
"deepseek_completion",
"https://chat.deepseek.com/api/v0/chat/completion",
http.StatusOK,
map[string]any{
"chat_session_id": "session-query-1",
"prompt": "用户问题:广州天气怎么样?",
},
"data: {\"v\":\"先看天气\"}\n\n",
)
recordCapturedResponse(
"deepseek_continue",
"https://chat.deepseek.com/api/v0/chat/continue",
http.StatusOK,
map[string]any{
"chat_session_id": "session-query-1",
"message_id": 2,
},
"data: {\"v\":\"再补充一点\"}\n\n",
)
h := &Handler{}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/admin/dev/raw-samples/query?q=广州天气", nil)
h.queryRawSampleCaptures(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
}
var out map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("decode failed: %v", err)
}
items, _ := out["items"].([]any)
if len(items) != 1 {
t.Fatalf("expected 1 item, got %d body=%s", len(items), rec.Body.String())
}
item, _ := items[0].(map[string]any)
if item["chain_key"] != "session:session-query-1" {
t.Fatalf("unexpected chain key: %#v", item["chain_key"])
}
if int(item["round_count"].(float64)) != 2 {
t.Fatalf("expected 2 rounds, got %#v", item["round_count"])
}
reqPreview, _ := item["request_preview"].(string)
if !strings.Contains(reqPreview, "广州天气") {
t.Fatalf("expected request preview to contain query, got %q", reqPreview)
}
}
func TestBuildCaptureChainsPreservesCaptureOrderWhenTimestampsCollide(t *testing.T) {
snapshot := []devcapture.Entry{
{
ID: "cap_continue",
CreatedAt: 1712365200,
Label: "deepseek_continue",
RequestBody: `{"chat_session_id":"session-collision","message_id":2}`,
ResponseBody: "data: {\"v\":\"第二段\"}\n\n",
},
{
ID: "cap_completion",
CreatedAt: 1712365200,
Label: "deepseek_completion",
RequestBody: `{"chat_session_id":"session-collision","prompt":"题目"}`,
ResponseBody: "data: {\"v\":\"第一段\"}\n\n",
},
}
chains := buildCaptureChains(snapshot)
if len(chains) != 1 {
t.Fatalf("expected 1 chain, got %d", len(chains))
}
if len(chains[0].Entries) != 2 {
t.Fatalf("expected 2 entries, got %d", len(chains[0].Entries))
}
if chains[0].Entries[0].Label != "deepseek_completion" {
t.Fatalf("expected completion first, got %#v", chains[0].Entries)
}
if chains[0].Entries[1].Label != "deepseek_continue" {
t.Fatalf("expected continue second, got %#v", chains[0].Entries)
}
}
func TestSaveRawSampleFromCapturesPersistsSelectedChain(t *testing.T) {
root := t.TempDir()
t.Setenv("DS2API_RAW_STREAM_SAMPLE_ROOT", root)
devcapture.Global().Clear()
defer devcapture.Global().Clear()
recordCapturedResponse(
"deepseek_completion",
"https://chat.deepseek.com/api/v0/chat/completion",
http.StatusOK,
map[string]any{
"chat_session_id": "session-save-1",
"prompt": "请回答深圳天气",
},
"data: {\"v\":\"第一段\"}\n\n",
)
recordCapturedResponse(
"deepseek_continue",
"https://chat.deepseek.com/api/v0/chat/continue",
http.StatusOK,
map[string]any{
"chat_session_id": "session-save-1",
"message_id": 2,
},
"data: {\"v\":\"第二段\"}\n\n",
)
h := &Handler{}
rec := httptest.NewRecorder()
reqBody := `{"query":"深圳天气","sample_id":"saved-from-memory"}`
req := httptest.NewRequest(http.MethodPost, "/admin/dev/raw-samples/save", strings.NewReader(reqBody))
h.saveRawSampleFromCaptures(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
}
var out map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("decode failed: %v", err)
}
if out["sample_id"] != "saved-from-memory" {
t.Fatalf("unexpected sample id: %#v", out["sample_id"])
}
if int(out["round_count"].(float64)) != 2 {
t.Fatalf("expected round_count=2, got %#v", out["round_count"])
}
sampleDir := filepath.Join(root, "saved-from-memory")
upstreamBytes, err := os.ReadFile(filepath.Join(sampleDir, "upstream.stream.sse"))
if err != nil {
t.Fatalf("read upstream: %v", err)
}
upstream := string(upstreamBytes)
if !strings.Contains(upstream, "第一段") || !strings.Contains(upstream, "第二段") {
t.Fatalf("expected combined upstream, got %q", upstream)
}
metaBytes, err := os.ReadFile(filepath.Join(sampleDir, "meta.json"))
if err != nil {
t.Fatalf("read meta: %v", err)
}
var meta map[string]any
if err := json.Unmarshal(metaBytes, &meta); err != nil {
t.Fatalf("decode meta: %v", err)
}
reqMeta, _ := meta["request"].(map[string]any)
if fieldString(reqMeta, "chat_session_id") != "session-save-1" {
t.Fatalf("expected request to come from selected chain, got %#v", meta["request"])
}
}

View File

@@ -0,0 +1,9 @@
package rawsamples
import "github.com/go-chi/chi/v5"
func RegisterRoutes(r chi.Router, h *Handler) {
r.Post("/dev/raw-samples/capture", h.captureRawSample)
r.Get("/dev/raw-samples/query", h.queryRawSampleCaptures)
r.Post("/dev/raw-samples/save", h.saveRawSampleFromCaptures)
}

View File

@@ -0,0 +1,29 @@
package settings
import (
"ds2api/internal/chathistory"
"ds2api/internal/config"
adminshared "ds2api/internal/httpapi/admin/shared"
)
type Handler struct {
Store adminshared.ConfigStore
Pool adminshared.PoolController
DS adminshared.DeepSeekCaller
OpenAI adminshared.OpenAIChatCaller
ChatHistory *chathistory.Store
}
var writeJSON = adminshared.WriteJSON
var intFrom = adminshared.IntFrom
func fieldString(m map[string]any, key string) string {
return adminshared.FieldString(m, key)
}
func validateRuntimeSettings(runtime config.RuntimeConfig) error {
return adminshared.ValidateRuntimeSettings(runtime)
}
func (h *Handler) computeSyncHash() string {
return adminshared.ComputeSyncHash(h.Store)
}

View File

@@ -0,0 +1,173 @@
package settings
import (
"fmt"
"strings"
"ds2api/internal/config"
)
func boolFrom(v any) bool {
if v == nil {
return false
}
switch x := v.(type) {
case bool:
return x
case string:
return strings.ToLower(strings.TrimSpace(x)) == "true"
default:
return false
}
}
func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *config.RuntimeConfig, *config.CompatConfig, *config.ResponsesConfig, *config.EmbeddingsConfig, *config.AutoDeleteConfig, *config.HistorySplitConfig, map[string]string, error) {
var (
adminCfg *config.AdminConfig
runtimeCfg *config.RuntimeConfig
compatCfg *config.CompatConfig
respCfg *config.ResponsesConfig
embCfg *config.EmbeddingsConfig
autoDeleteCfg *config.AutoDeleteConfig
historySplitCfg *config.HistorySplitConfig
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 err := config.ValidateIntRange("admin.jwt_expire_hours", n, 1, 720, true); err != nil {
return nil, nil, nil, nil, nil, nil, nil, nil, err
}
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 err := config.ValidateIntRange("runtime.account_max_inflight", n, 1, 256, true); err != nil {
return nil, nil, nil, nil, nil, nil, nil, nil, err
}
cfg.AccountMaxInflight = n
}
if v, exists := raw["account_max_queue"]; exists {
n := intFrom(v)
if err := config.ValidateIntRange("runtime.account_max_queue", n, 1, 200000, true); err != nil {
return nil, nil, nil, nil, nil, nil, nil, nil, err
}
cfg.AccountMaxQueue = n
}
if v, exists := raw["global_max_inflight"]; exists {
n := intFrom(v)
if err := config.ValidateIntRange("runtime.global_max_inflight", n, 1, 200000, true); err != nil {
return nil, nil, nil, nil, nil, nil, nil, nil, err
}
cfg.GlobalMaxInflight = n
}
if v, exists := raw["token_refresh_interval_hours"]; exists {
n := intFrom(v)
if err := config.ValidateIntRange("runtime.token_refresh_interval_hours", n, 1, 720, true); err != nil {
return nil, nil, nil, nil, nil, nil, nil, nil, err
}
cfg.TokenRefreshIntervalHours = n
}
if cfg.AccountMaxInflight > 0 && cfg.GlobalMaxInflight > 0 && cfg.GlobalMaxInflight < cfg.AccountMaxInflight {
return nil, 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["compat"].(map[string]any); ok {
cfg := &config.CompatConfig{}
if v, exists := raw["wide_input_strict_output"]; exists {
b := boolFrom(v)
cfg.WideInputStrictOutput = &b
}
if v, exists := raw["strip_reference_markers"]; exists {
b := boolFrom(v)
cfg.StripReferenceMarkers = &b
}
compatCfg = 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 err := config.ValidateIntRange("responses.store_ttl_seconds", n, 30, 86400, true); err != nil {
return nil, nil, nil, nil, nil, nil, nil, nil, err
}
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 err := config.ValidateTrimmedString("embeddings.provider", p, false); err != nil {
return nil, nil, nil, nil, nil, nil, nil, nil, err
}
cfg.Provider = p
}
embCfg = cfg
}
if raw, ok := req["model_aliases"].(map[string]any); ok {
if aliasMap == nil {
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
}
}
if raw, ok := req["auto_delete"].(map[string]any); ok {
cfg := &config.AutoDeleteConfig{}
if v, exists := raw["mode"]; exists {
mode := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", v)))
if err := config.ValidateAutoDeleteMode(mode); err != nil {
return nil, nil, nil, nil, nil, nil, nil, nil, err
}
if mode == "" {
mode = "none"
}
cfg.Mode = mode
}
if v, exists := raw["sessions"]; exists {
cfg.Sessions = boolFrom(v)
}
autoDeleteCfg = cfg
}
if raw, ok := req["history_split"].(map[string]any); ok {
cfg := &config.HistorySplitConfig{}
if v, exists := raw["enabled"]; exists {
b := boolFrom(v)
cfg.Enabled = &b
}
if v, exists := raw["trigger_after_turns"]; exists {
n := intFrom(v)
if err := config.ValidateIntRange("history_split.trigger_after_turns", n, 1, 1000, true); err != nil {
return nil, nil, nil, nil, nil, nil, nil, nil, err
}
cfg.TriggerAfterTurns = &n
}
if err := config.ValidateHistorySplitConfig(*cfg); err != nil {
return nil, nil, nil, nil, nil, nil, nil, nil, err
}
historySplitCfg = cfg
}
return adminCfg, runtimeCfg, compatCfg, respCfg, embCfg, autoDeleteCfg, historySplitCfg, aliasMap, nil
}

View File

@@ -0,0 +1,41 @@
package settings
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),
"token_refresh_interval_hours": h.Store.RuntimeTokenRefreshIntervalHours(),
},
"compat": snap.Compat,
"responses": snap.Responses,
"embeddings": snap.Embeddings,
"auto_delete": snap.AutoDelete,
"history_split": map[string]any{
"enabled": h.Store.HistorySplitEnabled(),
"trigger_after_turns": h.Store.HistorySplitTriggerAfterTurns(),
},
"model_aliases": snap.ModelAliases,
"env_backed": h.Store.IsEnvBacked(),
"needs_vercel_sync": needsSync,
})
}

View File

@@ -0,0 +1,44 @@
package settings
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
}
if incoming.TokenRefreshIntervalHours > 0 {
merged.TokenRefreshIntervalHours = incoming.TokenRefreshIntervalHours
}
}
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
}

View File

@@ -0,0 +1,130 @@
package settings
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, compatCfg, responsesCfg, embeddingsCfg, autoDeleteCfg, historySplitCfg, 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 runtimeCfg.TokenRefreshIntervalHours > 0 {
c.Runtime.TokenRefreshIntervalHours = runtimeCfg.TokenRefreshIntervalHours
}
}
if compatCfg != nil {
if compatCfg.WideInputStrictOutput != nil {
c.Compat.WideInputStrictOutput = compatCfg.WideInputStrictOutput
}
if compatCfg.StripReferenceMarkers != nil {
c.Compat.StripReferenceMarkers = compatCfg.StripReferenceMarkers
}
}
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 autoDeleteCfg != nil {
c.AutoDelete.Mode = autoDeleteCfg.Mode
c.AutoDelete.Sessions = autoDeleteCfg.Sessions
}
if historySplitCfg != nil {
if historySplitCfg.Enabled != nil {
c.HistorySplit.Enabled = historySplitCfg.Enabled
}
if historySplitCfg.TriggerAfterTurns != nil {
c.HistorySplit.TriggerAfterTurns = historySplitCfg.TriggerAfterTurns
}
}
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,
})
}

View File

@@ -0,0 +1,20 @@
package settings
import (
"net/http"
"github.com/go-chi/chi/v5"
)
func RegisterRoutes(r chi.Router, h *Handler) {
r.Get("/settings", h.getSettings)
r.Put("/settings", h.updateSettings)
r.Post("/settings/password", h.updateSettingsPassword)
}
func (h *Handler) GetSettings(w http.ResponseWriter, r *http.Request) { h.getSettings(w, r) }
func (h *Handler) UpdateSettings(w http.ResponseWriter, r *http.Request) { h.updateSettings(w, r) }
func (h *Handler) UpdateSettingsPassword(w http.ResponseWriter, r *http.Request) {
h.updateSettingsPassword(w, r)
}
func BoolFrom(v any) bool { return boolFrom(v) }

View File

@@ -0,0 +1,63 @@
package shared
import (
"context"
"net/http"
"ds2api/internal/account"
"ds2api/internal/auth"
"ds2api/internal/config"
dsclient "ds2api/internal/deepseek/client"
)
type ConfigStore interface {
Snapshot() config.Config
Keys() []string
Accounts() []config.Account
FindAccount(identifier string) (config.Account, bool)
UpdateAccountToken(identifier, token string) error
UpdateAccountTestStatus(identifier, status string) error
AccountTestStatus(identifier string) (string, bool)
Update(mutator func(*config.Config) error) error
ExportJSONAndBase64() (string, string, error)
IsEnvBacked() bool
IsEnvWritebackEnabled() bool
HasEnvConfigSource() bool
ConfigPath() string
SetVercelSync(hash string, ts int64) error
AdminPasswordHash() string
AdminJWTExpireHours() int
AdminJWTValidAfterUnix() int64
RuntimeAccountMaxInflight() int
RuntimeAccountMaxQueue(defaultSize int) int
RuntimeGlobalMaxInflight(defaultSize int) int
RuntimeTokenRefreshIntervalHours() int
AutoDeleteMode() string
HistorySplitEnabled() bool
HistorySplitTriggerAfterTurns() int
CompatStripReferenceMarkers() bool
AutoDeleteSessions() bool
}
type PoolController interface {
Reset()
Status() map[string]any
ApplyRuntimeLimits(maxInflightPerAccount, maxQueueSize, globalMaxInflight int)
}
type OpenAIChatCaller interface {
ChatCompletions(w http.ResponseWriter, r *http.Request)
}
type DeepSeekCaller interface {
Login(ctx context.Context, acc config.Account) (string, error)
CreateSession(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error)
GetPow(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error)
CallCompletion(ctx context.Context, a *auth.RequestAuth, payload map[string]any, powResp string, maxAttempts int) (*http.Response, error)
GetSessionCountForToken(ctx context.Context, token string) (*dsclient.SessionStats, error)
DeleteAllSessionsForToken(ctx context.Context, token string) error
}
var _ ConfigStore = (*config.Store)(nil)
var _ PoolController = (*account.Pool)(nil)
var _ DeepSeekCaller = (*dsclient.Client)(nil)

View File

@@ -0,0 +1,403 @@
package shared
import (
"crypto/md5"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"ds2api/internal/config"
"ds2api/internal/util"
)
var intFrom = util.IntFrom
var WriteJSON = util.WriteJSON
var IntFrom = util.IntFrom
func ReverseAccounts(a []config.Account) { reverseAccounts(a) }
func IntFromQuery(r *http.Request, key string, d int) int {
return intFromQuery(r, key, d)
}
func NilIfEmpty(s string) any { return nilIfEmpty(s) }
func NilIfZero(v int64) any { return nilIfZero(v) }
func MaskSecretPreview(secret string) string {
return maskSecretPreview(secret)
}
func ToStringSlice(v any) ([]string, bool) { return toStringSlice(v) }
func ToAccount(m map[string]any) config.Account {
return toAccount(m)
}
func ToAPIKeys(v any) ([]config.APIKey, bool) {
return toAPIKeys(v)
}
func NormalizeAPIKeyForStorage(item config.APIKey) config.APIKey {
return normalizeAPIKeyForStorage(item)
}
func APIKeyHasMetadata(item config.APIKey) bool {
return apiKeyHasMetadata(item)
}
func MergeAPIKeysPreferStructured(existing, incoming []config.APIKey) ([]config.APIKey, int) {
return mergeAPIKeysPreferStructured(existing, incoming)
}
func MergeAPIKeyRecord(existing, incoming config.APIKey) config.APIKey {
return mergeAPIKeyRecord(existing, incoming)
}
func FieldString(m map[string]any, key string) string {
return fieldString(m, key)
}
func FieldStringOptional(m map[string]any, key string) (string, bool) {
return fieldStringOptional(m, key)
}
func StatusOr(v int, d int) int { return statusOr(v, d) }
func AccountMatchesIdentifier(acc config.Account, identifier string) bool {
return accountMatchesIdentifier(acc, identifier)
}
func NormalizeAccountForStorage(acc config.Account) config.Account {
return normalizeAccountForStorage(acc)
}
func ToProxy(m map[string]any) config.Proxy {
return toProxy(m)
}
func FindProxyByID(c config.Config, proxyID string) (config.Proxy, bool) {
return findProxyByID(c, proxyID)
}
func AccountDedupeKey(acc config.Account) string { return accountDedupeKey(acc) }
func NormalizeAndDedupeAccounts(accounts []config.Account) []config.Account {
return normalizeAndDedupeAccounts(accounts)
}
func FindAccountByIdentifier(store ConfigStore, identifier string) (config.Account, bool) {
return findAccountByIdentifier(store, identifier)
}
func ComputeSyncHash(store ConfigStore) string {
if store == nil {
return ""
}
snap := store.Snapshot().Clone()
snap.ClearAccountTokens()
snap.VercelSyncHash = ""
snap.VercelSyncTime = 0
b, _ := json.Marshal(snap)
sum := md5.Sum(b)
return fmt.Sprintf("%x", sum)
}
func SyncHashForJSON(s string) string {
var cfg config.Config
if err := json.Unmarshal([]byte(s), &cfg); err != nil {
return ""
}
cfg.VercelSyncHash = ""
cfg.VercelSyncTime = 0
cfg.ClearAccountTokens()
b, err := json.Marshal(cfg)
if err != nil {
return ""
}
sum := md5.Sum(b)
return fmt.Sprintf("%x", sum)
}
func reverseAccounts(a []config.Account) {
for i, j := 0, len(a)-1; i < j; i, j = i+1, j-1 {
a[i], a[j] = a[j], a[i]
}
}
func intFromQuery(r *http.Request, key string, d int) int {
v := r.URL.Query().Get(key)
if v == "" {
return d
}
n, err := strconv.Atoi(v)
if err != nil {
return d
}
return n
}
func nilIfEmpty(s string) any {
if s == "" {
return nil
}
return s
}
func nilIfZero(v int64) any {
if v == 0 {
return nil
}
return v
}
func maskSecretPreview(secret string) string {
secret = strings.TrimSpace(secret)
if secret == "" {
return ""
}
if len(secret) <= 4 {
return strings.Repeat("*", len(secret))
}
return secret[:2] + "****" + secret[len(secret)-2:]
}
func toStringSlice(v any) ([]string, bool) {
arr, ok := v.([]any)
if !ok {
return nil, false
}
out := make([]string, 0, len(arr))
for _, item := range arr {
out = append(out, strings.TrimSpace(fmt.Sprintf("%v", item)))
}
return out, true
}
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"),
ProxyID: fieldString(m, "proxy_id"),
}
}
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 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 {
return ""
}
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
}
return v
}
func accountMatchesIdentifier(acc config.Account, identifier string) bool {
id := strings.TrimSpace(identifier)
if id == "" {
return false
}
if strings.TrimSpace(acc.Email) == id {
return true
}
if mobileKey := config.CanonicalMobileKey(id); mobileKey != "" && mobileKey == config.CanonicalMobileKey(acc.Mobile) {
return true
}
return acc.Identifier() == id
}
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)
return acc
}
func toProxy(m map[string]any) config.Proxy {
return config.NormalizeProxy(config.Proxy{
ID: fieldString(m, "id"),
Name: fieldString(m, "name"),
Type: fieldString(m, "type"),
Host: fieldString(m, "host"),
Port: intFrom(m["port"]),
Username: fieldString(m, "username"),
Password: fieldString(m, "password"),
})
}
func findProxyByID(c config.Config, proxyID string) (config.Proxy, bool) {
id := strings.TrimSpace(proxyID)
if id == "" {
return config.Proxy{}, false
}
for _, proxy := range c.Proxies {
proxy = config.NormalizeProxy(proxy)
if proxy.ID == id {
return proxy, true
}
}
return config.Proxy{}, false
}
func accountDedupeKey(acc config.Account) string {
if email := strings.TrimSpace(acc.Email); email != "" {
return "email:" + email
}
if mobile := config.CanonicalMobileKey(acc.Mobile); mobile != "" {
return "mobile:" + mobile
}
if id := strings.TrimSpace(acc.Identifier()); id != "" {
return "id:" + id
}
return ""
}
func normalizeAndDedupeAccounts(accounts []config.Account) []config.Account {
if len(accounts) == 0 {
return nil
}
out := make([]config.Account, 0, len(accounts))
seen := make(map[string]struct{}, len(accounts))
for _, acc := range accounts {
acc = normalizeAccountForStorage(acc)
key := accountDedupeKey(acc)
if key == "" {
continue
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, acc)
}
return out
}
func findAccountByIdentifier(store ConfigStore, identifier string) (config.Account, bool) {
id := strings.TrimSpace(identifier)
if id == "" {
return config.Account{}, false
}
if acc, ok := store.FindAccount(id); ok {
return acc, true
}
accounts := store.Snapshot().Accounts
for _, acc := range accounts {
if accountMatchesIdentifier(acc, id) {
return acc, true
}
}
return config.Account{}, false
}

View File

@@ -0,0 +1,240 @@
package shared
import (
"net/http"
"net/http/httptest"
"testing"
"ds2api/internal/config"
)
// ─── reverseAccounts ─────────────────────────────────────────────────
func TestReverseAccountsEmpty(t *testing.T) {
a := []config.Account{}
reverseAccounts(a)
if len(a) != 0 {
t.Fatal("expected empty")
}
}
func TestReverseAccountsTwoElements(t *testing.T) {
a := []config.Account{
{Email: "a@test.com"},
{Email: "b@test.com"},
}
reverseAccounts(a)
if a[0].Email != "b@test.com" || a[1].Email != "a@test.com" {
t.Fatalf("unexpected order after reverse: %v", a)
}
}
func TestReverseAccountsThreeElements(t *testing.T) {
a := []config.Account{
{Email: "1@test.com"},
{Email: "2@test.com"},
{Email: "3@test.com"},
}
reverseAccounts(a)
if a[0].Email != "3@test.com" || a[1].Email != "2@test.com" || a[2].Email != "1@test.com" {
t.Fatalf("unexpected order: %v", a)
}
}
// ─── intFromQuery edge cases ─────────────────────────────────────────
func TestIntFromQueryPresent(t *testing.T) {
req := httptest.NewRequest("GET", "/?limit=5", nil)
if got := intFromQuery(req, "limit", 10); got != 5 {
t.Fatalf("expected 5, got %d", got)
}
}
func TestIntFromQueryMissing(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
if got := intFromQuery(req, "limit", 10); got != 10 {
t.Fatalf("expected default 10, got %d", got)
}
}
func TestIntFromQueryInvalid(t *testing.T) {
req := httptest.NewRequest("GET", "/?limit=abc", nil)
if got := intFromQuery(req, "limit", 10); got != 10 {
t.Fatalf("expected default 10 for invalid, got %d", got)
}
}
func TestIntFromQueryNegative(t *testing.T) {
req := httptest.NewRequest("GET", "/?limit=-3", nil)
if got := intFromQuery(req, "limit", 10); got != -3 {
t.Fatalf("expected -3, got %d", got)
}
}
func TestIntFromQueryZero(t *testing.T) {
req := httptest.NewRequest("GET", "/?limit=0", nil)
if got := intFromQuery(req, "limit", 10); got != 0 {
t.Fatalf("expected 0, got %d", got)
}
}
// ─── nilIfEmpty ──────────────────────────────────────────────────────
func TestNilIfEmptyEmpty(t *testing.T) {
if nilIfEmpty("") != nil {
t.Fatal("expected nil for empty string")
}
}
func TestNilIfEmptyNonEmpty(t *testing.T) {
if nilIfEmpty("hello") != "hello" {
t.Fatal("expected 'hello'")
}
}
// ─── nilIfZero ───────────────────────────────────────────────────────
func TestNilIfZeroZero(t *testing.T) {
if nilIfZero(0) != nil {
t.Fatal("expected nil for zero")
}
}
func TestNilIfZeroNonZero(t *testing.T) {
if nilIfZero(42) != int64(42) {
t.Fatal("expected 42")
}
}
func TestNilIfZeroNegative(t *testing.T) {
if nilIfZero(-1) != int64(-1) {
t.Fatal("expected -1")
}
}
// ─── toStringSlice ───────────────────────────────────────────────────
func TestToStringSliceFromAnySlice(t *testing.T) {
input := []any{"a", "b", "c"}
got, ok := toStringSlice(input)
if !ok || len(got) != 3 {
t.Fatalf("expected 3 strings, got %#v ok=%v", got, ok)
}
if got[0] != "a" || got[1] != "b" || got[2] != "c" {
t.Fatalf("unexpected values: %#v", got)
}
}
func TestToStringSliceFromMixed(t *testing.T) {
input := []any{"hello", 42, true}
got, ok := toStringSlice(input)
if !ok {
t.Fatal("expected ok for mixed types")
}
if got[0] != "hello" || got[1] != "42" || got[2] != "true" {
t.Fatalf("unexpected values: %#v", got)
}
}
func TestToStringSliceFromNonSlice(t *testing.T) {
_, ok := toStringSlice("not a slice")
if ok {
t.Fatal("expected not ok for string input")
}
}
func TestToStringSliceFromNil(t *testing.T) {
_, ok := toStringSlice(nil)
if ok {
t.Fatal("expected not ok for nil input")
}
}
func TestToStringSliceEmpty(t *testing.T) {
got, ok := toStringSlice([]any{})
if !ok {
t.Fatal("expected ok for empty slice")
}
if len(got) != 0 {
t.Fatalf("expected empty result, got %#v", got)
}
}
func TestToStringSliceTrimsWhitespace(t *testing.T) {
got, ok := toStringSlice([]any{" hello ", " world "})
if !ok {
t.Fatal("expected ok")
}
if got[0] != "hello" || got[1] != "world" {
t.Fatalf("expected trimmed values, got %#v", got)
}
}
// ─── toAccount edge cases ────────────────────────────────────────────
func TestToAccountAllFields(t *testing.T) {
acc := toAccount(map[string]any{
"email": "user@test.com",
"mobile": "13800138000",
"password": "secret",
"token": "tok123",
})
if acc.Email != "user@test.com" {
t.Fatalf("unexpected email: %q", acc.Email)
}
if acc.Mobile != "+8613800138000" {
t.Fatalf("unexpected mobile: %q", acc.Mobile)
}
if acc.Password != "secret" {
t.Fatalf("unexpected password: %q", acc.Password)
}
if acc.Token != "" {
t.Fatalf("expected token to be ignored, got %q", acc.Token)
}
}
func TestToAccountNumericValues(t *testing.T) {
acc := toAccount(map[string]any{
"email": 12345,
})
if acc.Email != "12345" {
t.Fatalf("expected numeric converted to string, got %q", acc.Email)
}
}
// ─── fieldString edge cases ──────────────────────────────────────────
func TestFieldStringNonString(t *testing.T) {
got := fieldString(map[string]any{"key": 42}, "key")
if got != "42" {
t.Fatalf("expected '42' for int, got %q", got)
}
}
func TestFieldStringBool(t *testing.T) {
got := fieldString(map[string]any{"key": true}, "key")
if got != "true" {
t.Fatalf("expected 'true', got %q", got)
}
}
func TestFieldStringWhitespace(t *testing.T) {
got := fieldString(map[string]any{"key": " hello "}, "key")
if got != "hello" {
t.Fatalf("expected trimmed 'hello', got %q", got)
}
}
// ─── statusOr ────────────────────────────────────────────────────────
func TestStatusOrZeroReturnsDefault(t *testing.T) {
if got := statusOr(0, http.StatusOK); got != http.StatusOK {
t.Fatalf("expected %d, got %d", http.StatusOK, got)
}
}
func TestStatusOrNonZeroReturnsValue(t *testing.T) {
if got := statusOr(http.StatusBadRequest, http.StatusOK); got != http.StatusBadRequest {
t.Fatalf("expected %d, got %d", http.StatusBadRequest, got)
}
}

View File

@@ -0,0 +1,31 @@
package shared
import "errors"
type requestError struct {
detail string
}
func (e *requestError) Error() string {
return e.detail
}
func newRequestError(detail string) error {
return &requestError{detail: detail}
}
func NewRequestError(detail string) error {
return newRequestError(detail)
}
func requestErrorDetail(err error) (string, bool) {
var reqErr *requestError
if errors.As(err, &reqErr) {
return reqErr.detail, true
}
return "", false
}
func RequestErrorDetail(err error) (string, bool) {
return requestErrorDetail(err)
}

View File

@@ -0,0 +1,35 @@
package shared
import (
"strings"
"ds2api/internal/config"
)
func normalizeSettingsConfig(c *config.Config) {
if c == nil {
return
}
c.Admin.PasswordHash = strings.TrimSpace(c.Admin.PasswordHash)
c.Embeddings.Provider = strings.TrimSpace(c.Embeddings.Provider)
}
func NormalizeSettingsConfig(c *config.Config) {
normalizeSettingsConfig(c)
}
func validateSettingsConfig(c config.Config) error {
return config.ValidateConfig(c)
}
func ValidateSettingsConfig(c config.Config) error {
return validateSettingsConfig(c)
}
func validateRuntimeSettings(runtime config.RuntimeConfig) error {
return config.ValidateRuntimeConfig(runtime)
}
func ValidateRuntimeSettings(runtime config.RuntimeConfig) error {
return validateRuntimeSettings(runtime)
}

View File

@@ -0,0 +1,123 @@
package admin
import (
"context"
"net/http"
"testing"
"ds2api/internal/account"
"ds2api/internal/auth"
"ds2api/internal/config"
dsclient "ds2api/internal/deepseek/client"
adminaccounts "ds2api/internal/httpapi/admin/accounts"
adminconfig "ds2api/internal/httpapi/admin/configmgmt"
adminsettings "ds2api/internal/httpapi/admin/settings"
adminshared "ds2api/internal/httpapi/admin/shared"
)
var intFrom = adminshared.IntFrom
func toAccount(m map[string]any) config.Account { return adminshared.ToAccount(m) }
func fieldString(m map[string]any, key string) string {
return adminshared.FieldString(m, key)
}
func maskSecretPreview(secret string) string { return adminshared.MaskSecretPreview(secret) }
func boolFrom(v any) bool { return adminsettings.BoolFrom(v) }
func newAdminTestHandler(t *testing.T, raw string) *Handler {
t.Helper()
t.Setenv("DS2API_CONFIG_JSON", raw)
store := config.LoadStore()
return &Handler{
Store: store,
Pool: account.NewPool(store),
}
}
type testingDSMock struct {
loginToken string
deleteAllSessionsError error
deleteAllSessionsErrorOnce bool
sessionCount *dsclient.SessionStats
loginCalls int
deleteAllCalls int
}
func (m *testingDSMock) Login(_ context.Context, _ config.Account) (string, error) {
m.loginCalls++
if m.loginToken == "" {
return "token", nil
}
return m.loginToken, nil
}
func (m *testingDSMock) CreateSession(_ context.Context, _ *auth.RequestAuth, _ int) (string, error) {
return "session-id", nil
}
func (m *testingDSMock) GetPow(_ context.Context, _ *auth.RequestAuth, _ int) (string, error) {
return "pow", nil
}
func (m *testingDSMock) CallCompletion(_ context.Context, _ *auth.RequestAuth, _ map[string]any, _ string, _ int) (*http.Response, error) {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
}
func (m *testingDSMock) DeleteAllSessionsForToken(_ context.Context, _ string) error {
m.deleteAllCalls++
if m.deleteAllSessionsError != nil {
err := m.deleteAllSessionsError
if m.deleteAllSessionsErrorOnce {
m.deleteAllSessionsError = nil
}
return err
}
return nil
}
func (m *testingDSMock) GetSessionCountForToken(_ context.Context, _ string) (*dsclient.SessionStats, error) {
if m.sessionCount != nil {
return m.sessionCount, nil
}
return &dsclient.SessionStats{}, nil
}
func (h *Handler) configHandler() *adminconfig.Handler {
return &adminconfig.Handler{Store: h.Store, Pool: h.Pool, DS: h.DS, OpenAI: h.OpenAI, ChatHistory: h.ChatHistory}
}
func (h *Handler) settingsHandler() *adminsettings.Handler {
return &adminsettings.Handler{Store: h.Store, Pool: h.Pool, DS: h.DS, OpenAI: h.OpenAI, ChatHistory: h.ChatHistory}
}
func (h *Handler) getConfig(w http.ResponseWriter, r *http.Request) {
h.configHandler().GetConfig(w, r)
}
func (h *Handler) updateConfig(w http.ResponseWriter, r *http.Request) {
h.configHandler().UpdateConfig(w, r)
}
func (h *Handler) configImport(w http.ResponseWriter, r *http.Request) {
h.configHandler().ConfigImport(w, r)
}
func (h *Handler) batchImport(w http.ResponseWriter, r *http.Request) {
h.configHandler().BatchImport(w, r)
}
func (h *Handler) getSettings(w http.ResponseWriter, r *http.Request) {
h.settingsHandler().GetSettings(w, r)
}
func (h *Handler) updateSettings(w http.ResponseWriter, r *http.Request) {
h.settingsHandler().UpdateSettings(w, r)
}
func (h *Handler) updateSettingsPassword(w http.ResponseWriter, r *http.Request) {
h.settingsHandler().UpdateSettingsPassword(w, r)
}
func runAccountTestsConcurrently(accounts []config.Account, maxConcurrency int, testFn func(int, config.Account) map[string]any) []map[string]any {
return adminaccounts.RunAccountTestsConcurrently(accounts, maxConcurrency, testFn)
}

View File

@@ -0,0 +1,109 @@
package admin
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"ds2api/internal/account"
"ds2api/internal/config"
adminshared "ds2api/internal/httpapi/admin/shared"
)
func newHTTPAdminHarness(t *testing.T, rawConfig string, ds adminshared.DeepSeekCaller) http.Handler {
t.Helper()
t.Setenv("DS2API_CONFIG_JSON", rawConfig)
store := config.LoadStore()
h := &Handler{
Store: store,
Pool: account.NewPool(store),
DS: ds,
}
r := chi.NewRouter()
RegisterRoutes(r, h)
return r
}
func adminReq(method, path string, body []byte) *http.Request {
req := httptest.NewRequest(method, path, bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer admin")
req.Header.Set("Content-Type", "application/json")
return req
}
func TestConfigImportIgnoresTokenFieldInPayload(t *testing.T) {
ds := &testingDSMock{}
router := newHTTPAdminHarness(t, `{"accounts":[]}`, ds)
payload := []byte(`{
"mode":"replace",
"config":{
"accounts":[{"email":"u@example.com","password":"pwd","token":"expired-token"}]
}
}`)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, adminReq(http.MethodPost, "/config/import", payload))
if rec.Code != http.StatusOK {
t.Fatalf("import status=%d body=%s", rec.Code, rec.Body.String())
}
readRec := httptest.NewRecorder()
router.ServeHTTP(readRec, adminReq(http.MethodGet, "/config", nil))
if readRec.Code != http.StatusOK {
t.Fatalf("get config status=%d body=%s", readRec.Code, readRec.Body.String())
}
var data map[string]any
if err := json.Unmarshal(readRec.Body.Bytes(), &data); err != nil {
t.Fatalf("decode config response: %v", err)
}
accounts, _ := data["accounts"].([]any)
if len(accounts) != 1 {
t.Fatalf("expected one account, got %d", len(accounts))
}
accountMap, _ := accounts[0].(map[string]any)
if hasToken, _ := accountMap["has_token"].(bool); hasToken {
t.Fatalf("expected imported token to be ignored, account=%#v", accountMap)
}
}
func TestAccountTestRefreshesRuntimeTokenButExportOmitsToken(t *testing.T) {
ds := &testingDSMock{}
router := newHTTPAdminHarness(t, `{
"accounts":[{"email":"batch@example.com","password":"pwd","token":"stale-token"}]
}`, ds)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, adminReq(http.MethodPost, "/accounts/test", []byte(`{"identifier":"batch@example.com"}`)))
if rec.Code != http.StatusOK {
t.Fatalf("test account status=%d body=%s", rec.Code, rec.Body.String())
}
var testResp map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &testResp); err != nil {
t.Fatalf("decode test response: %v", err)
}
if ok, _ := testResp["success"].(bool); !ok {
t.Fatalf("expected test success, got %#v", testResp)
}
if ds.loginCalls < 1 {
t.Fatalf("expected login to be called at least once, got %d", ds.loginCalls)
}
exportRec := httptest.NewRecorder()
router.ServeHTTP(exportRec, adminReq(http.MethodGet, "/config/export", nil))
if exportRec.Code != http.StatusOK {
t.Fatalf("export status=%d body=%s", exportRec.Code, exportRec.Body.String())
}
var exportResp map[string]any
if err := json.Unmarshal(exportRec.Body.Bytes(), &exportResp); err != nil {
t.Fatalf("decode export response: %v", err)
}
exportJSON, _ := exportResp["json"].(string)
if strings.Contains(exportJSON, `"token"`) {
t.Fatalf("expected export json to omit tokens, got %s", exportJSON)
}
}

View File

@@ -0,0 +1,24 @@
package vercel
import (
"ds2api/internal/chathistory"
adminshared "ds2api/internal/httpapi/admin/shared"
)
type Handler struct {
Store adminshared.ConfigStore
Pool adminshared.PoolController
DS adminshared.DeepSeekCaller
OpenAI adminshared.OpenAIChatCaller
ChatHistory *chathistory.Store
}
var writeJSON = adminshared.WriteJSON
var intFrom = adminshared.IntFrom
func nilIfZero(v int64) any { return adminshared.NilIfZero(v) }
func statusOr(v int, d int) int { return adminshared.StatusOr(v, d) }
func (h *Handler) computeSyncHash() string {
return adminshared.ComputeSyncHash(h.Store)
}

View File

@@ -0,0 +1,326 @@
package vercel
import (
"bytes"
"context"
"crypto/md5"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
"ds2api/internal/config"
)
func (h *Handler) syncVercel(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
}
opts, err := parseVercelSyncOptions(req)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
return
}
validated, failed := h.validateAccountsForVercelSync(r.Context(), opts.AutoValidate)
cfgJSON, cfgB64, err := h.exportSyncConfig(req)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": err.Error()})
return
}
client := &http.Client{Timeout: 30 * time.Second}
params := buildVercelParams(opts.TeamID)
headers := map[string]string{"Authorization": "Bearer " + opts.VercelToken}
envResp, status, err := vercelRequest(r.Context(), client, http.MethodGet, "https://api.vercel.com/v9/projects/"+opts.ProjectID+"/env", params, headers, nil)
if err != nil || status != http.StatusOK {
writeJSON(w, statusOr(status, http.StatusInternalServerError), map[string]any{"detail": "获取环境变量失败"})
return
}
envs, _ := envResp["envs"].([]any)
status, err = upsertVercelEnv(r.Context(), client, opts.ProjectID, params, headers, envs, "DS2API_CONFIG_JSON", cfgB64)
if err != nil || (status != http.StatusOK && status != http.StatusCreated) {
writeJSON(w, statusOr(status, http.StatusInternalServerError), map[string]any{"detail": "更新环境变量失败"})
return
}
savedCreds := h.saveVercelProjectCredentials(r.Context(), client, opts, params, headers, envs)
manual, deployURL := triggerVercelDeployment(r.Context(), client, opts.ProjectID, params, headers)
_ = h.Store.SetVercelSync(syncHashForJSON(cfgJSON), time.Now().Unix())
result := map[string]any{"success": true, "validated_accounts": validated}
if manual {
result["message"] = "配置已同步到 Vercel请手动触发重新部署"
result["manual_deploy_required"] = true
} else {
result["message"] = "配置已同步,正在重新部署..."
result["deployment_url"] = deployURL
}
if len(failed) > 0 {
result["failed_accounts"] = failed
}
if len(savedCreds) > 0 {
result["saved_credentials"] = savedCreds
}
writeJSON(w, http.StatusOK, result)
}
type vercelSyncOptions struct {
VercelToken string
ProjectID string
TeamID string
AutoValidate bool
SaveCreds bool
UsePreconfig bool
}
func parseVercelSyncOptions(req map[string]any) (vercelSyncOptions, error) {
vercelToken, _ := req["vercel_token"].(string)
projectID, _ := req["project_id"].(string)
teamID, _ := req["team_id"].(string)
autoValidate := true
if v, ok := req["auto_validate"].(bool); ok {
autoValidate = v
}
saveCreds := true
if v, ok := req["save_credentials"].(bool); ok {
saveCreds = v
}
usePreconfig := vercelToken == "__USE_PRECONFIG__" || strings.TrimSpace(vercelToken) == ""
if usePreconfig {
vercelToken = strings.TrimSpace(os.Getenv("VERCEL_TOKEN"))
}
if strings.TrimSpace(projectID) == "" {
projectID = strings.TrimSpace(os.Getenv("VERCEL_PROJECT_ID"))
}
if strings.TrimSpace(teamID) == "" {
teamID = strings.TrimSpace(os.Getenv("VERCEL_TEAM_ID"))
}
vercelToken = strings.TrimSpace(vercelToken)
projectID = strings.TrimSpace(projectID)
teamID = strings.TrimSpace(teamID)
if vercelToken == "" || projectID == "" {
return vercelSyncOptions{}, fmt.Errorf("需要 Vercel Token 和 Project ID")
}
return vercelSyncOptions{
VercelToken: vercelToken,
ProjectID: projectID,
TeamID: teamID,
AutoValidate: autoValidate,
SaveCreds: saveCreds,
UsePreconfig: usePreconfig,
}, nil
}
func buildVercelParams(teamID string) url.Values {
params := url.Values{}
if strings.TrimSpace(teamID) != "" {
params.Set("teamId", strings.TrimSpace(teamID))
}
return params
}
func (h *Handler) validateAccountsForVercelSync(ctx context.Context, enabled bool) (int, []string) {
if !enabled {
return 0, nil
}
validated, failed := 0, []string{}
for _, acc := range h.Store.Snapshot().Accounts {
if strings.TrimSpace(acc.Token) != "" {
continue
}
token, err := h.DS.Login(ctx, acc)
if err != nil {
failed = append(failed, acc.Identifier())
} else {
validated++
_ = h.Store.UpdateAccountToken(acc.Identifier(), token)
}
time.Sleep(500 * time.Millisecond)
}
return validated, failed
}
func upsertVercelEnv(ctx context.Context, client *http.Client, projectID string, params url.Values, headers map[string]string, envs []any, key, value string) (int, error) {
existingID := findEnvID(envs, key)
if existingID != "" {
_, status, err := vercelRequest(ctx, client, http.MethodPatch, "https://api.vercel.com/v9/projects/"+projectID+"/env/"+existingID, params, headers, map[string]any{"value": value})
return status, err
}
_, status, err := vercelRequest(ctx, client, http.MethodPost, "https://api.vercel.com/v10/projects/"+projectID+"/env", params, headers, map[string]any{
"key": key,
"value": value,
"type": "encrypted",
"target": []string{"production", "preview"},
})
return status, err
}
func (h *Handler) saveVercelProjectCredentials(ctx context.Context, client *http.Client, opts vercelSyncOptions, params url.Values, headers map[string]string, envs []any) []string {
if !opts.SaveCreds || opts.UsePreconfig {
return nil
}
saved := []string{}
creds := [][2]string{{"VERCEL_TOKEN", opts.VercelToken}, {"VERCEL_PROJECT_ID", opts.ProjectID}}
if opts.TeamID != "" {
creds = append(creds, [2]string{"VERCEL_TEAM_ID", opts.TeamID})
}
for _, kv := range creds {
status, _ := upsertVercelEnv(ctx, client, opts.ProjectID, params, headers, envs, kv[0], kv[1])
if status == http.StatusOK || status == http.StatusCreated {
saved = append(saved, kv[0])
}
}
return saved
}
func triggerVercelDeployment(ctx context.Context, client *http.Client, projectID string, params url.Values, headers map[string]string) (bool, string) {
projectResp, status, _ := vercelRequest(ctx, client, http.MethodGet, "https://api.vercel.com/v9/projects/"+projectID, params, headers, nil)
if status != http.StatusOK {
return true, ""
}
link, ok := projectResp["link"].(map[string]any)
if !ok {
return true, ""
}
linkType, _ := link["type"].(string)
if linkType != "github" {
return true, ""
}
repoID := intFrom(link["repoId"])
ref, _ := link["productionBranch"].(string)
if ref == "" {
ref = "main"
}
depResp, depStatus, _ := vercelRequest(ctx, client, http.MethodPost, "https://api.vercel.com/v13/deployments", params, headers, map[string]any{
"name": projectID,
"project": projectID,
"target": "production",
"gitSource": map[string]any{
"type": "github",
"repoId": repoID,
"ref": ref,
},
})
if depStatus != http.StatusOK && depStatus != http.StatusCreated {
return true, ""
}
deployURL, _ := depResp["url"].(string)
return false, deployURL
}
func (h *Handler) vercelStatus(w http.ResponseWriter, r *http.Request) {
snap := h.Store.Snapshot()
current := h.computeSyncHash()
synced := snap.VercelSyncHash != "" && snap.VercelSyncHash == current
draftHash := ""
draftDiffers := false
if r != nil && r.Method == http.MethodPost && r.Body != nil {
var req map[string]any
if err := json.NewDecoder(r.Body).Decode(&req); err == nil {
if cfgJSON, _, err := h.exportSyncConfig(req); err == nil {
draftHash = syncHashForJSON(cfgJSON)
draftDiffers = draftHash != "" && draftHash != current
}
}
}
writeJSON(w, http.StatusOK, map[string]any{
"synced": synced,
"last_sync_time": nilIfZero(snap.VercelSyncTime),
"has_synced_before": snap.VercelSyncHash != "",
"env_backed": h.Store.IsEnvBacked(),
"config_hash": current,
"last_synced_hash": snap.VercelSyncHash,
"draft_hash": draftHash,
"draft_differs": draftDiffers,
})
}
func (h *Handler) exportSyncConfig(req map[string]any) (string, string, error) {
override, ok := req["config_override"]
if !ok || override == nil {
return h.Store.ExportJSONAndBase64()
}
raw, err := json.Marshal(override)
if err != nil {
return "", "", err
}
var cfg config.Config
if err := json.Unmarshal(raw, &cfg); err != nil {
return "", "", err
}
cfg.DropInvalidAccounts()
cfg.ClearAccountTokens()
cfg.VercelSyncHash = ""
cfg.VercelSyncTime = 0
b, err := json.Marshal(cfg)
if err != nil {
return "", "", err
}
return string(b), base64.StdEncoding.EncodeToString(b), nil
}
func syncHashForJSON(s string) string {
var cfg config.Config
if err := json.Unmarshal([]byte(s), &cfg); err != nil {
return ""
}
cfg.VercelSyncHash = ""
cfg.VercelSyncTime = 0
cfg.ClearAccountTokens()
b, err := json.Marshal(cfg)
if err != nil {
return ""
}
sum := md5.Sum(b)
return fmt.Sprintf("%x", sum)
}
func vercelRequest(ctx context.Context, client *http.Client, method, endpoint string, params url.Values, headers map[string]string, body any) (map[string]any, int, error) {
if len(params) > 0 {
endpoint += "?" + params.Encode()
}
var reader io.Reader
if body != nil {
b, _ := json.Marshal(body)
reader = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, endpoint, reader)
if err != nil {
return nil, 0, err
}
for k, v := range headers {
req.Header.Set(k, v)
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return nil, 0, err
}
defer func() { _ = resp.Body.Close() }()
b, _ := io.ReadAll(resp.Body)
parsed := map[string]any{}
_ = json.Unmarshal(b, &parsed)
if len(parsed) == 0 {
parsed["raw"] = string(b)
}
return parsed, resp.StatusCode, nil
}
func findEnvID(envs []any, key string) string {
for _, item := range envs {
m, ok := item.(map[string]any)
if !ok {
continue
}
if k, _ := m["key"].(string); k == key {
id, _ := m["id"].(string)
return id
}
}
return ""
}

View File

@@ -0,0 +1,9 @@
package vercel
import "github.com/go-chi/chi/v5"
func RegisterRoutes(r chi.Router, h *Handler) {
r.Post("/vercel/sync", h.syncVercel)
r.Get("/vercel/status", h.vercelStatus)
r.Post("/vercel/status", h.vercelStatus)
}

View File

@@ -0,0 +1,16 @@
package version
import (
"ds2api/internal/chathistory"
adminshared "ds2api/internal/httpapi/admin/shared"
)
type Handler struct {
Store adminshared.ConfigStore
Pool adminshared.PoolController
DS adminshared.DeepSeekCaller
OpenAI adminshared.OpenAIChatCaller
ChatHistory *chathistory.Store
}
var writeJSON = adminshared.WriteJSON

View File

@@ -0,0 +1,75 @@
package version
import (
"encoding/json"
"net/http"
"strings"
"time"
"ds2api/internal/version"
)
const latestReleaseAPI = "https://api.github.com/repos/CJackHwang/ds2api/releases/latest"
type latestReleasePayload struct {
TagName string `json:"tag_name"`
HTMLURL string `json:"html_url"`
PublishedAt string `json:"published_at"`
}
func (h *Handler) getVersion(w http.ResponseWriter, _ *http.Request) {
current, source := version.Current()
resp := map[string]any{
"success": true,
"current_version": current,
"current_tag": version.Tag(current),
"source": source,
"checked_at": time.Now().UTC().Format(time.RFC3339),
}
req, err := http.NewRequest(http.MethodGet, latestReleaseAPI, nil)
if err != nil {
resp["check_error"] = err.Error()
writeJSON(w, http.StatusOK, resp)
return
}
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("User-Agent", "ds2api-version-check")
client := &http.Client{Timeout: 4 * time.Second}
r, err := client.Do(req)
if err != nil {
resp["check_error"] = err.Error()
writeJSON(w, http.StatusOK, resp)
return
}
defer func() { _ = r.Body.Close() }()
if r.StatusCode < 200 || r.StatusCode >= 300 {
resp["check_error"] = "github api status: " + r.Status
writeJSON(w, http.StatusOK, resp)
return
}
var data latestReleasePayload
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
resp["check_error"] = err.Error()
writeJSON(w, http.StatusOK, resp)
return
}
latest := strings.TrimSpace(data.TagName)
if latest == "" {
resp["check_error"] = "missing latest tag"
writeJSON(w, http.StatusOK, resp)
return
}
latestVersion := strings.TrimPrefix(latest, "v")
resp["latest_tag"] = latest
resp["latest_version"] = latestVersion
resp["release_url"] = data.HTMLURL
resp["published_at"] = data.PublishedAt
resp["has_update"] = version.Compare(current, latestVersion) < 0
writeJSON(w, http.StatusOK, resp)
}

View File

@@ -0,0 +1,7 @@
package version
import "github.com/go-chi/chi/v5"
func RegisterRoutes(r chi.Router, h *Handler) {
r.Get("/version", h.getVersion)
}