mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-05 00:45:29 +08:00
- Account 结构加 TestStatus 字段,测试后写入 config.json - listAccounts 接口返回 test_status,前端根据结果显示红/绿/黄状态点 - 分页选择器支持 10/20/50/100/500/1000/2000/5000 - 点击账号名自动复制到剪贴板,hover 显示复制图标,复制后显示绿色对勾
206 lines
4.5 KiB
Go
206 lines
4.5 KiB
Go
package config
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"os"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
type Store struct {
|
|
mu sync.RWMutex
|
|
cfg Config
|
|
path string
|
|
fromEnv bool
|
|
keyMap map[string]struct{} // O(1) API key lookup index
|
|
accMap map[string]int // O(1) account lookup: identifier -> slice index
|
|
}
|
|
|
|
func LoadStore() *Store {
|
|
cfg, fromEnv, err := loadConfig()
|
|
if err != nil {
|
|
Logger.Warn("[config] load failed", "error", err)
|
|
}
|
|
if len(cfg.Keys) == 0 && len(cfg.Accounts) == 0 {
|
|
Logger.Warn("[config] empty config loaded")
|
|
}
|
|
s := &Store{cfg: cfg, path: ConfigPath(), fromEnv: fromEnv}
|
|
s.rebuildIndexes()
|
|
return s
|
|
}
|
|
|
|
func loadConfig() (Config, bool, error) {
|
|
rawCfg := strings.TrimSpace(os.Getenv("DS2API_CONFIG_JSON"))
|
|
if rawCfg == "" {
|
|
rawCfg = strings.TrimSpace(os.Getenv("CONFIG_JSON"))
|
|
}
|
|
if rawCfg != "" {
|
|
cfg, err := parseConfigString(rawCfg)
|
|
return cfg, true, err
|
|
}
|
|
|
|
content, err := os.ReadFile(ConfigPath())
|
|
if err != nil {
|
|
if IsVercel() {
|
|
// Vercel one-click deploy may start without a writable/present config file.
|
|
// Keep an in-memory config so users can bootstrap via WebUI then sync env.
|
|
return Config{}, true, nil
|
|
}
|
|
return Config{}, false, err
|
|
}
|
|
var cfg Config
|
|
if err := json.Unmarshal(content, &cfg); err != nil {
|
|
return Config{}, false, err
|
|
}
|
|
if IsVercel() {
|
|
// Vercel filesystem is ephemeral/read-only for runtime writes; avoid save errors.
|
|
return cfg, true, nil
|
|
}
|
|
return cfg, false, nil
|
|
}
|
|
|
|
func (s *Store) Snapshot() Config {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.cfg.Clone()
|
|
}
|
|
|
|
func (s *Store) HasAPIKey(k string) bool {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
_, ok := s.keyMap[k]
|
|
return ok
|
|
}
|
|
|
|
func (s *Store) Keys() []string {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return slices.Clone(s.cfg.Keys)
|
|
}
|
|
|
|
func (s *Store) Accounts() []Account {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return slices.Clone(s.cfg.Accounts)
|
|
}
|
|
|
|
func (s *Store) FindAccount(identifier string) (Account, bool) {
|
|
identifier = strings.TrimSpace(identifier)
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
if idx, ok := s.findAccountIndexLocked(identifier); ok {
|
|
return s.cfg.Accounts[idx], true
|
|
}
|
|
return Account{}, false
|
|
}
|
|
|
|
func (s *Store) UpdateAccountTestStatus(identifier, status string) error {
|
|
identifier = strings.TrimSpace(identifier)
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
idx, ok := s.findAccountIndexLocked(identifier)
|
|
if !ok {
|
|
return errors.New("account not found")
|
|
}
|
|
s.cfg.Accounts[idx].TestStatus = status
|
|
return s.saveLocked()
|
|
}
|
|
|
|
func (s *Store) UpdateAccountToken(identifier, token string) error {
|
|
identifier = strings.TrimSpace(identifier)
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
idx, ok := s.findAccountIndexLocked(identifier)
|
|
if !ok {
|
|
return errors.New("account not found")
|
|
}
|
|
oldID := s.cfg.Accounts[idx].Identifier()
|
|
s.cfg.Accounts[idx].Token = token
|
|
newID := s.cfg.Accounts[idx].Identifier()
|
|
// Keep historical aliases usable for long-lived queues while also adding
|
|
// the latest identifier after token refresh.
|
|
if identifier != "" {
|
|
s.accMap[identifier] = idx
|
|
}
|
|
if oldID != "" {
|
|
s.accMap[oldID] = idx
|
|
}
|
|
if newID != "" {
|
|
s.accMap[newID] = idx
|
|
}
|
|
return s.saveLocked()
|
|
}
|
|
|
|
func (s *Store) Replace(cfg Config) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.cfg = cfg.Clone()
|
|
s.rebuildIndexes()
|
|
return s.saveLocked()
|
|
}
|
|
|
|
func (s *Store) Update(mutator func(*Config) error) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
cfg := s.cfg.Clone()
|
|
if err := mutator(&cfg); err != nil {
|
|
return err
|
|
}
|
|
s.cfg = cfg
|
|
s.rebuildIndexes()
|
|
return s.saveLocked()
|
|
}
|
|
|
|
func (s *Store) Save() error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.fromEnv {
|
|
Logger.Info("[save_config] source from env, skip write")
|
|
return nil
|
|
}
|
|
b, err := json.MarshalIndent(s.cfg, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(s.path, b, 0o644)
|
|
}
|
|
|
|
func (s *Store) saveLocked() error {
|
|
if s.fromEnv {
|
|
Logger.Info("[save_config] source from env, skip write")
|
|
return nil
|
|
}
|
|
b, err := json.MarshalIndent(s.cfg, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(s.path, b, 0o644)
|
|
}
|
|
|
|
func (s *Store) IsEnvBacked() bool {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.fromEnv
|
|
}
|
|
|
|
func (s *Store) SetVercelSync(hash string, ts int64) error {
|
|
return s.Update(func(c *Config) error {
|
|
c.VercelSyncHash = hash
|
|
c.VercelSyncTime = ts
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (s *Store) ExportJSONAndBase64() (string, string, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
b, err := json.Marshal(s.cfg)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
return string(b), base64.StdEncoding.EncodeToString(b), nil
|
|
}
|