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