Skip to content

Commit b6e4813

Browse files
committed
feat: 多账号池与手动切换(账号页 + accounts API + 旧 PAT 配置自动迁移)
- store: Config 新增 accounts/active_account_id/auto_switch 池结构; 旧 data.json(裸 PAT)加载时迁成「默认账号」,池外手改 PAT 按导入处理; CRUD/切换/快照/备注方法仿照 APIKeys 写法,ActivateAccount 在同一锁节 内同步 PAT 镜像,切号即 SetPAT(),bridge 惰性重建与 PAT-change 清快照 链路全部自动复用 - admin: /admin/api/accounts CRUD(列表只回掩码 PAT;删除激活账号 400; 相同 PAT 重复提交视为切换)、/switch、/note、/check(经临时 bridge 完 整会话引导查一次真实额度并落快照);/admin/api/pat 保留兼容,写入自 动同步账号池(新 PAT 添加并激活、清空取消激活) - web: 「令牌」页升级为「账号」页(路由 key 仍为 token):账号池表格 (掩码、使用中徽标、剩余额度、状态、切换/检测/备注/删除)+ 添加弹窗; 页面契约断言与前端行为 harness 同步更新 - main: 注入 AccountChecker(额度池合计口径与统计页一致:零容量池不计、 组织资源包仅在可用时计入)
1 parent f841b56 commit b6e4813

11 files changed

Lines changed: 1146 additions & 49 deletions

File tree

‎README.md‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
- **多模态支持** —— 支持图片输入
1414
- **Tool Calls** —— 支持函数调用
1515
- **模型动态加载** —— 自动从网关获取可用模型列表
16-
- **Web 管理面板** —— 浅色排版风格中文界面(支持深浅模式),支持密码登录、API 密钥管理、PAT 配置、模型查看、请求统计(总量 / 成功失败 / 总输入 / 总输出 / 缓存命中 / **本服务实际扣费额度与均值** / 本周期扣费与重置倒计时 / 按模型消耗 / 近 24 小时趋势)
16+
- **Web 管理面板** —— 浅色排版风格中文界面(支持深浅模式),支持密码登录、API 密钥管理、多账号池(多 PAT 添加 / 一键切换 / 额度检测)、模型查看、请求统计(总量 / 成功失败 / 总输入 / 总输出 / 缓存命中 / **本服务实际扣费额度与均值** / 本周期扣费与重置倒计时 / 按模型消耗 / 近 24 小时趋势)
1717
- **请求日志** —— 记录每次调用的元信息(时间、模型、密钥、流式与否、token 用量、扣费额度、耗时、状态码与错误摘要,不保存对话内容),面板「日志」页支持按模型 / 密钥 / 状态筛选与分页,保留天数与条数上限可配置、自动清理最旧条目
1818
- **订阅周期感知** —— 从网关 `/algo/api/v3/user/status` 读取真实订阅信息(套餐、组织、`nextResetAt` 重置时间、`isQuotaExceeded` 超额标记),使面板额度按计费周期统计而非终身累计
1919
- **单文件部署** —— 编译为单一二进制文件,零外部依赖
@@ -132,7 +132,7 @@ docker run -d -p 10081:10081 -v qoder2api-data:/app/data -e QODER_DATA_PATH=/app
132132

133133
### 1. 配置 PAT
134134

135-
启动服务后访问 `http://localhost:10081/admin`,输入默认密码 `password` 登录,在「令牌」标签页中填入你的 Qoder PAT。
135+
启动服务后访问 `http://localhost:10081/admin`,输入默认密码 `password` 登录,在「账号」标签页中添加你的 Qoder PAT(支持多个账号一键切换;旧版的「令牌」配置会自动迁移为池中的「默认账号」)。
136136

137137
> ⚠️ **默认密码仅用于首次登录,请立即在「设置」标签页中修改**。登录失败会按客户端 IP 触发指数退避限流,部署到公网时务必启用 HTTPS 反代。
138138
@@ -244,7 +244,11 @@ curl http://localhost:10081/v1/models \
244244
| `/admin` | GET | Web 管理面板 |
245245
| `/admin/api/login` | POST | 管理面板登录 |
246246
| `/admin/api/keys` | GET/POST/DELETE | API 密钥管理 |
247-
| `/admin/api/pat` | GET/POST | PAT 令牌管理 |
247+
| `/admin/api/pat` | GET/POST | PAT 令牌管理(写入自动同步账号池) |
248+
| `/admin/api/accounts` | GET/POST/DELETE | 账号池管理(相同 PAT 重复提交视为切换;删除激活中的账号返回 `400`) |
249+
| `/admin/api/accounts/switch` | POST | 手动切换激活账号 |
250+
| `/admin/api/accounts/note` | POST | 修改账号备注 |
251+
| `/admin/api/accounts/check` | POST | 检测账号真实额度(临时会话查询一次并落快照) |
248252
| `/admin/api/models` | GET | 获取模型列表 |
249253
| `/admin/api/stats` | GET | 请求统计(总量 / 按模型 / 近 24 小时趋势 / token 用量 / 实际扣费额度与均值 / 计费周期与套餐信息) |
250254
| `/admin/api/config` | GET/POST | 服务器配置 |

‎admin/admin.go‎

Lines changed: 229 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,21 @@ const sessionDuration = 24 * time.Hour
2929
// adminMaxBody caps JSON request bodies handled by the admin API.
3030
const adminMaxBody = 1 << 16 // 64 KiB
3131

32+
// AccountChecker probes one PAT's real allowance through a throwaway bridge
33+
// (full session bootstrap) and returns the quota snapshot. Implemented by the
34+
// caller (main package) — the admin package stays free of bridge wiring.
35+
type AccountChecker func(ctx context.Context, pat string) (*store.AccountSnapshot, error)
36+
3237
// Admin manages the web UI and admin API endpoints.
3338
type Admin struct {
34-
store *store.Store
35-
modelFetcher ModelFetcher
36-
stats *stats.Recorder
37-
logRec *logs.Recorder
38-
sessions map[string]time.Time
39-
sessionMu sync.Mutex
40-
limiter *loginLimiter
39+
store *store.Store
40+
modelFetcher ModelFetcher
41+
stats *stats.Recorder
42+
logRec *logs.Recorder
43+
accountChecker AccountChecker
44+
sessions map[string]time.Time
45+
sessionMu sync.Mutex
46+
limiter *loginLimiter
4147
}
4248

4349
// ModelFetcher returns the current model catalog (dynamic or fallback).
@@ -55,6 +61,12 @@ func New(s *store.Store, mf ModelFetcher, rec *stats.Recorder, lr *logs.Recorder
5561
}
5662
}
5763

64+
// SetAccountChecker installs the account quota probe used by
65+
// /admin/api/accounts/check. Wiring happens before the server starts serving.
66+
func (a *Admin) SetAccountChecker(fn AccountChecker) {
67+
a.accountChecker = fn
68+
}
69+
5870
// RegisterRoutes registers all admin API routes on the given mux.
5971
//
6072
// The WebUI itself is served by the web package (pages under /admin, assets
@@ -69,6 +81,10 @@ func (a *Admin) RegisterRoutes(mux *http.ServeMux) {
6981
// Protected API endpoints (require auth)
7082
mux.HandleFunc("/admin/api/keys", a.requireAuth(a.handleKeys))
7183
mux.HandleFunc("/admin/api/pat", a.requireAuth(a.handlePAT))
84+
mux.HandleFunc("/admin/api/accounts", a.requireAuth(a.handleAccounts))
85+
mux.HandleFunc("/admin/api/accounts/switch", a.requireAuth(a.handleAccountSwitch))
86+
mux.HandleFunc("/admin/api/accounts/note", a.requireAuth(a.handleAccountNote))
87+
mux.HandleFunc("/admin/api/accounts/check", a.requireAuth(a.handleAccountCheck))
7288
mux.HandleFunc("/admin/api/models", a.requireAuth(a.handleModels))
7389
mux.HandleFunc("/admin/api/stats", a.requireAuth(a.handleStats))
7490
mux.HandleFunc("/admin/api/logs", a.requireAuth(a.handleLogs))
@@ -600,16 +616,222 @@ func (a *Admin) handlePAT(w http.ResponseWriter, r *http.Request) {
600616
writeJSONError(w, 400, "请求格式错误")
601617
return
602618
}
619+
// Store first, then align the account pool with whatever the admin PAT
620+
// form submitted (the mirror write stays low-level): a PAT already in
621+
// the pool is activated, a new one is added+activated, and clearing the
622+
// PAT always deactivates (an empty mirror must not coexist with an
623+
// active pool entry — re-activation is one click on the account page).
624+
oldPAT := a.store.GetPAT()
603625
if err := a.store.SetPAT(body.PAT); err != nil {
604626
writeJSONError(w, 500, err.Error())
605627
return
606628
}
629+
switch {
630+
case body.PAT == "":
631+
if a.store.GetActiveAccountID() != "" {
632+
a.store.ClearActiveAccount()
633+
}
634+
case body.PAT != oldPAT:
635+
a.ensureAccount(body.PAT)
636+
}
607637
writeJSON(w, 200, map[string]string{"status": "ok"})
608638
default:
609639
writeJSONError(w, 405, "方法不允许")
610640
}
611641
}
612642

643+
// --- Account endpoints ---
644+
645+
// accountView is the JSON shape of one pool entry. The plaintext PAT never
646+
// leaves the server: the response carries the same masked form as the legacy
647+
// PAT endpoint plus the id used by switch/delete.
648+
type accountView struct {
649+
ID string `json:"id"`
650+
PATMasked string `json:"pat_masked"`
651+
Note string `json:"note"`
652+
CreatedAt int64 `json:"created_at"`
653+
Active bool `json:"active"`
654+
Snapshot *store.AccountSnapshot `json:"snapshot,omitempty"`
655+
}
656+
657+
// accountViews maps the pool to its wire form, marking the active entry.
658+
func accountViews(accounts []store.Account, activeID string) []accountView {
659+
out := make([]accountView, 0, len(accounts))
660+
for i := range accounts {
661+
acc := accounts[i]
662+
out = append(out, accountView{
663+
ID: acc.ID,
664+
PATMasked: maskPAT(acc.PAT),
665+
Note: acc.Note,
666+
CreatedAt: acc.CreatedAt,
667+
Active: acc.ID == activeID,
668+
Snapshot: acc.Snapshot,
669+
})
670+
}
671+
return out
672+
}
673+
674+
// handleAccounts serves GET/POST/DELETE /admin/api/accounts: the multi-account
675+
// pool CRUD. POST is idempotent on PAT — re-submitting an existing token just
676+
// activates it, so the old single-PAT panel flow keeps working against this
677+
// endpoint too.
678+
func (a *Admin) handleAccounts(w http.ResponseWriter, r *http.Request) {
679+
switch r.Method {
680+
case http.MethodGet:
681+
writeJSON(w, 200, map[string]interface{}{"accounts": accountViews(a.store.ListAccounts(), a.store.GetActiveAccountID())})
682+
case http.MethodPost:
683+
var body struct {
684+
PAT string `json:"pat"`
685+
Note string `json:"note"`
686+
}
687+
if err := readJSON(w, r, &body); err != nil {
688+
writeJSONError(w, 400, "请求格式错误")
689+
return
690+
}
691+
if a.activateExisting(body.PAT) {
692+
writeJSON(w, 200, map[string]string{"status": "switched"})
693+
return
694+
}
695+
entry, err := a.store.AddAccount(body.PAT, body.Note)
696+
if err != nil {
697+
writeJSONError(w, http.StatusConflict, err.Error())
698+
return
699+
}
700+
writeJSON(w, 201, entry)
701+
case http.MethodDelete:
702+
id := r.URL.Query().Get("id")
703+
if id == "" {
704+
writeJSONError(w, 400, "缺少 id 参数")
705+
return
706+
}
707+
if err := a.store.DeleteAccount(id); err != nil {
708+
if errors.Is(err, store.ErrActiveAccount) {
709+
writeJSONError(w, 400, err.Error())
710+
} else {
711+
writeJSONError(w, 404, err.Error())
712+
}
713+
return
714+
}
715+
writeJSON(w, 200, map[string]string{"status": "deleted"})
716+
default:
717+
writeJSONError(w, 405, "方法不允许")
718+
}
719+
}
720+
721+
// activateExisting switches the active account to an already-pooled PAT.
722+
// Returns false when pat is not in the pool (caller then adds it).
723+
func (a *Admin) activateExisting(pat string) bool {
724+
if pat == "" {
725+
return false
726+
}
727+
for _, acc := range a.store.ListAccounts() {
728+
if acc.PAT == pat {
729+
_ = a.store.ActivateAccount(acc.ID)
730+
return true
731+
}
732+
}
733+
return false
734+
}
735+
736+
// ensureAccount aligns the pool after a legacy PAT write: an already-pooled
737+
// PAT becomes active, a new one is added and activated. Used by the legacy
738+
// /admin/api/pat endpoint so the pool can never silently diverge from the
739+
// mirrored PAT field.
740+
func (a *Admin) ensureAccount(pat string) {
741+
if a.activateExisting(pat) {
742+
return
743+
}
744+
_, _ = a.store.AddAccount(pat, "")
745+
}
746+
747+
// handleAccountSwitch serves POST /admin/api/accounts/switch {id}: explicit
748+
// account switch from the panel.
749+
func (a *Admin) handleAccountSwitch(w http.ResponseWriter, r *http.Request) {
750+
if r.Method != http.MethodPost {
751+
writeJSONError(w, 405, "方法不允许")
752+
return
753+
}
754+
var body struct {
755+
ID string `json:"id"`
756+
}
757+
if err := readJSON(w, r, &body); err != nil {
758+
writeJSONError(w, 400, "请求格式错误")
759+
return
760+
}
761+
if err := a.store.ActivateAccount(body.ID); err != nil {
762+
writeJSONError(w, 404, err.Error())
763+
return
764+
}
765+
writeJSON(w, 200, map[string]string{"status": "ok"})
766+
}
767+
768+
// handleAccountNote serves POST /admin/api/accounts/note {id, note}: renames
769+
// an account's note (separate endpoint so the account CRUD bodies stay small).
770+
func (a *Admin) handleAccountNote(w http.ResponseWriter, r *http.Request) {
771+
if r.Method != http.MethodPost {
772+
writeJSONError(w, 405, "方法不允许")
773+
return
774+
}
775+
var body struct {
776+
ID string `json:"id"`
777+
Note string `json:"note"`
778+
}
779+
if err := readJSON(w, r, &body); err != nil {
780+
writeJSONError(w, 400, "请求格式错误")
781+
return
782+
}
783+
if err := a.store.UpdateAccountNote(body.ID, body.Note); err != nil {
784+
writeJSONError(w, 404, err.Error())
785+
return
786+
}
787+
writeJSON(w, 200, map[string]string{"status": "ok"})
788+
}
789+
790+
// handleAccountCheck serves POST /admin/api/accounts/check {id?}: probes one
791+
// account (default: the active one) through a throwaway bridge and stores the
792+
// returned quota snapshot. This is the only account-side upstream call —
793+
// deliberately on-demand, since every check costs a full session bootstrap.
794+
func (a *Admin) handleAccountCheck(w http.ResponseWriter, r *http.Request) {
795+
if r.Method != http.MethodPost {
796+
writeJSONError(w, 405, "方法不允许")
797+
return
798+
}
799+
var body struct {
800+
ID string `json:"id"`
801+
}
802+
_ = readJSON(w, r, &body) // body optional: default to the active account
803+
id := body.ID
804+
if id == "" {
805+
id = a.store.GetActiveAccountID()
806+
}
807+
var target *store.Account
808+
for _, acc := range a.store.ListAccounts() {
809+
if acc.ID == id {
810+
target = &acc
811+
break
812+
}
813+
}
814+
if target == nil {
815+
writeJSONError(w, 404, "账号不存在")
816+
return
817+
}
818+
if a.accountChecker == nil {
819+
writeJSONError(w, 503, "检测功能不可用")
820+
return
821+
}
822+
snap, err := a.accountChecker(r.Context(), target.PAT)
823+
if err != nil {
824+
_ = a.store.SetAccountSnapshot(target.ID, &store.AccountSnapshot{CheckedAt: time.Now().Unix(), LastError: err.Error()})
825+
writeJSONError(w, 502, "检测失败: "+err.Error())
826+
return
827+
}
828+
if err := a.store.SetAccountSnapshot(target.ID, snap); err != nil {
829+
writeJSONError(w, 500, err.Error())
830+
return
831+
}
832+
writeJSON(w, 200, map[string]interface{}{"snapshot": snap})
833+
}
834+
613835
// --- Models endpoint ---
614836

615837
func (a *Admin) handleModels(w http.ResponseWriter, r *http.Request) {

0 commit comments

Comments
 (0)