diff --git a/README.md b/README.md index 9cf970b..b8e2299 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,8 @@ ## 功能 -- 展示 Codex 账户、套餐、限额使用率和下次重置时间 +- 支持添加并切换多个 Codex 账号或工作区(包括同一邮箱的个人订阅与 Team 工作区) +- 展示 Codex 账户、套餐、剩余额度和下次重置时间 - 展示累计 Token、单日峰值、连续使用天数及每日 Token 趋势 - 在本地 SQLite 中保留历史用量和限额快照 - 在限额重置前、重置后发送 Telegram 或邮件提醒 @@ -57,9 +58,9 @@ http://服务器地址:8080 7. 返回 Codex Helper,等待数秒后进入“用量总览”,点击“立即刷新”。 8. 页面显示账户邮箱、套餐和限额窗口后,即表示连接成功。 -设备码授权在浏览器中完成,适用于 Docker、NAS 和远程服务器。登录凭据保存在 Docker 数据卷的 `/data/codex` 中,不会写入浏览器或项目源码。 +设备码授权在浏览器中完成,适用于 Docker、NAS 和远程服务器。默认连接的登录凭据保存在 `/data/codex`,新增连接保存在 `/data/accounts/<账号 ID>/codex`,不会写入浏览器或项目源码。 -如需切换账户,在“设置中心” → “Codex”点击“退出 Codex 账户”,再重新生成设备码。 +如需添加其他账号,或同一邮箱下的个人订阅与 Team 工作区,请在“设置中心” → “Codex”中分别创建连接并完成设备码登录。每个连接使用隔离的登录凭据,可自定义名称并在总览顶部切换。删除连接会同时删除对应凭据和历史数据。 ## 通用设置与提醒时间 @@ -90,7 +91,7 @@ http://服务器地址:8080 绑定码十分钟内有效,一个实例只绑定一个 Telegram 会话。即使关闭查询菜单,仍可完成绑定并接收提醒;开启菜单后可使用以下按钮或命令: -- 当前用量 +- 当前用量(包含所有已添加连接) - 重置时间 / `/reset` - 历史概览 / `/usage` - 账户信息 / `/account` @@ -163,10 +164,10 @@ Codex Helper 展示的是 Codex app-server 实际返回的数据。当前接口 - ChatGPT/Codex 账户和套餐类型 - 限额窗口使用百分比、窗口长度和重置时间 -- 累计 Token、单日峰值、连续使用天数等摘要 +- 累计 Token、单日峰值、连续使用天数、最长任务时长等摘要 - 每日总 Token 桶 -当前接口不提供调用次数、输入 Token、输出 Token、订阅价格或账单续期日,因此页面会将这些字段标记为不可用。部分摘要或每日数据也可能因账户或服务端暂未返回而显示为“暂无”。根据 OpenAI 官方文档,`account/usage/read` 需要 Codex 服务支持的身份认证;仅 API Key 或 Bedrock 登录不能读取这些 ChatGPT 用量数据。 +当前接口不提供调用次数、输入 Token、输出 Token、订阅价格或账单续期日,因此总览使用“最长任务时长”作为替代摘要指标。部分摘要或每日数据也可能因账户或服务端暂未返回而显示为“暂无”。根据 OpenAI 官方文档,`account/usage/read` 需要 Codex 服务支持的身份认证;仅 API Key 或 Bedrock 登录不能读取这些 ChatGPT 用量数据。 ## 常见问题 diff --git a/backend/internal/app/api.go b/backend/internal/app/api.go index 0457b6a..2ae774c 100644 --- a/backend/internal/app/api.go +++ b/backend/internal/app/api.go @@ -18,7 +18,16 @@ import ( func (a *App) api(w http.ResponseWriter, r *http.Request) { p := strings.TrimPrefix(r.URL.Path, "/api/v1/") if p == "system/status" { - jsonOut(w, 200, map[string]any{"initialized": a.store.Initialized(), "version": "0.1.0", "appServer": a.codex.Connected()}) + connected := false + a.mu.RLock() + for _, rt := range a.runtimes { + if rt.client.Connected() { + connected = true + break + } + } + a.mu.RUnlock() + jsonOut(w, 200, map[string]any{"initialized": a.store.Initialized(), "version": "0.2.0", "appServer": connected}) return } if p == "setup" && r.Method == "POST" { @@ -39,13 +48,54 @@ func (a *App) api(w http.ResponseWriter, r *http.Request) { jsonOut(w, 200, map[string]string{"username": username}) case p == "auth/logout" && r.Method == "POST": a.logout(w, r) + case p == "accounts" && r.Method == "GET": + x, e := a.store.Accounts() + if e != nil { + jsonOut(w, 500, map[string]string{"error": e.Error()}) + } else { + jsonOut(w, 200, x) + } + case p == "accounts" && r.Method == "POST": + var in struct { + DisplayName string `json:"displayName"` + } + if decode(r, &in) != nil { + jsonOut(w, 400, map[string]string{"error": "请求格式错误"}) + break + } + in.DisplayName = strings.TrimSpace(in.DisplayName) + if in.DisplayName == "" { + in.DisplayName = "新账号" + } + x, e := a.store.CreateAccount(in.DisplayName) + if e == nil { + a.addRuntime(x.ID) + jsonOut(w, 201, x) + } else { + jsonOut(w, 500, map[string]string{"error": e.Error()}) + } + case strings.HasPrefix(p, "accounts/"): + a.accountAPI(w, r, p) case p == "dashboard": - a.mu.RLock() - d := a.dash - a.mu.RUnlock() - jsonOut(w, 200, d) + id, _ := strconv.ParseInt(r.URL.Query().Get("accountId"), 10, 64) + if id == 0 { + id = 1 + } + rt := a.runtime(id) + if rt == nil { + jsonOut(w, 404, map[string]string{"error": "账号不存在"}) + } else { + rt.syncing.Lock() + d := rt.dash + rt.syncing.Unlock() + jsonOut(w, 200, d) + } case p == "sync" && r.Method == "POST": - e := a.sync(r.Context()) + id, _ := strconv.ParseInt(r.URL.Query().Get("accountId"), 10, 64) + if id == 0 { + id = 1 + } + e := a.syncAccount(r.Context(), id) if e != nil { jsonOut(w, 502, map[string]string{"error": e.Error()}) } else { @@ -65,16 +115,6 @@ func (a *App) api(w http.ResponseWriter, r *http.Request) { code := fmt.Sprintf("%06d", time.Now().UnixNano()%1000000) _ = a.store.SetJSON("telegram_bind", map[string]any{"code": code, "expires": time.Now().Add(10 * time.Minute).Unix()}) jsonOut(w, 200, map[string]string{"code": code}) - case p == "codex/login/device" && r.Method == "POST": - a.deviceLogin(w, r) - case p == "codex/logout" && r.Method == "POST": - var out any - e := a.codex.Call(r.Context(), "account/logout", map[string]any{}, &out) - if e != nil { - jsonOut(w, 502, map[string]string{"error": e.Error()}) - } else { - jsonOut(w, 200, map[string]bool{"ok": true}) - } case p == "maintenance/cleanup" && r.Method == "POST": n, e := a.store.Cleanup(a.general().RetentionDays) if e != nil { @@ -101,6 +141,78 @@ func (a *App) api(w http.ResponseWriter, r *http.Request) { } } +func (a *App) accountAPI(w http.ResponseWriter, r *http.Request, p string) { + parts := strings.Split(p, "/") + if len(parts) < 2 { + jsonOut(w, 404, map[string]string{"error": "接口不存在"}) + return + } + id, e := strconv.ParseInt(parts[1], 10, 64) + if e != nil { + jsonOut(w, 400, map[string]string{"error": "账号 ID 无效"}) + return + } + rt := a.runtime(id) + if rt == nil { + jsonOut(w, 404, map[string]string{"error": "账号不存在"}) + return + } + action := "" + if len(parts) > 2 { + action = parts[2] + } + switch { + case action == "" && r.Method == "PUT": + var in struct { + DisplayName string `json:"displayName"` + } + if decode(r, &in) != nil || strings.TrimSpace(in.DisplayName) == "" { + jsonOut(w, 400, map[string]string{"error": "名称不能为空"}) + return + } + e = a.store.RenameAccount(id, strings.TrimSpace(in.DisplayName)) + if e == nil { + jsonOut(w, 200, map[string]bool{"ok": true}) + } + case action == "" && r.Method == "DELETE": + a.mu.Lock() + delete(a.runtimes, id) + a.mu.Unlock() + _ = rt.client.Close() + e = a.store.DeleteAccount(id) + if e == nil { + dir := filepath.Join(a.dataDir, "accounts", strconv.FormatInt(id, 10)) + if id == 1 { + dir = filepath.Join(a.dataDir, "codex") + } + e = os.RemoveAll(dir) + } + if e == nil { + jsonOut(w, 200, map[string]bool{"ok": true}) + } + case action == "login" && len(parts) > 3 && parts[3] == "device" && r.Method == "POST": + a.deviceLogin(w, r, id) + case action == "logout" && r.Method == "POST": + var out any + e = rt.client.Call(r.Context(), "account/logout", map[string]any{}, &out) + if e == nil { + _ = a.store.UpdateAccount(id, nil, nil, false) + jsonOut(w, 200, map[string]bool{"ok": true}) + } + case action == "sync" && r.Method == "POST": + e = a.syncAccount(r.Context(), id) + if e == nil { + jsonOut(w, 200, map[string]bool{"ok": true}) + } + default: + jsonOut(w, 404, map[string]string{"error": "接口不存在"}) + return + } + if e != nil { + jsonOut(w, 502, map[string]string{"error": e.Error()}) + } +} + func (a *App) setup(w http.ResponseWriter, r *http.Request) { if a.store.Initialized() { jsonOut(w, 409, map[string]string{"error": "系统已初始化"}) @@ -211,9 +323,14 @@ func (a *App) generalAPI(w http.ResponseWriter, r *http.Request) { _ = a.store.SetJSON("general", g) jsonOut(w, 200, g) } -func (a *App) deviceLogin(w http.ResponseWriter, r *http.Request) { +func (a *App) deviceLogin(w http.ResponseWriter, r *http.Request, id int64) { var out map[string]any - e := a.codex.Call(r.Context(), "account/login/start", map[string]any{"type": "chatgptDeviceCode"}, &out) + rt := a.runtime(id) + if !rt.client.Connected() { + jsonOut(w, 503, map[string]string{"error": "该账号服务正在启动,请稍后重试"}) + return + } + e := rt.client.Call(r.Context(), "account/login/start", map[string]any{"type": "chatgptDeviceCode"}, &out) if e != nil { jsonOut(w, 502, map[string]string{"error": e.Error()}) return @@ -221,8 +338,14 @@ func (a *App) deviceLogin(w http.ResponseWriter, r *http.Request) { jsonOut(w, 200, out) } -func (a *App) sync(ctx context.Context) error { - if !a.codex.Connected() { +func (a *App) syncAccount(ctx context.Context, id int64) error { + rt := a.runtime(id) + if rt == nil { + return fmt.Errorf("账号不存在") + } + rt.syncing.Lock() + defer rt.syncing.Unlock() + if !rt.client.Connected() { return fmt.Errorf("app-server 未连接") } ctx, c := context.WithTimeout(ctx, 20*time.Second) @@ -234,10 +357,18 @@ func (a *App) sync(ctx context.Context) error { PlanType *string `json:"planType"` } `json:"account"` } - if e := a.codex.Call(ctx, "account/read", map[string]any{"refreshToken": false}, &ar); e != nil { + if e := rt.client.Call(ctx, "account/read", map[string]any{"refreshToken": false}, &ar); e != nil { return e } - d := Dashboard{FetchedAt: time.Now().Unix(), Account: AccountView{Connected: ar.Account != nil}, Limits: []LimitBucket{}, Usage: []UsagePoint{}} + name := "账号" + accounts, _ := a.store.Accounts() + for _, x := range accounts { + if x.ID == id { + name = x.DisplayName + break + } + } + d := Dashboard{AccountID: id, DisplayName: name, FetchedAt: time.Now().Unix(), Account: AccountView{Connected: ar.Account != nil}, Limits: []LimitBucket{}, Usage: []UsagePoint{}} if ar.Account != nil { d.Account.Email = ar.Account.Email d.Account.PlanType = ar.Account.PlanType @@ -247,7 +378,7 @@ func (a *App) sync(ctx context.Context) error { RateLimits *rawLimit `json:"rateLimits"` By map[string]rawLimit `json:"rateLimitsByLimitId"` } - if e := a.codex.Call(ctx, "account/rateLimits/read", map[string]any{}, &lr); e == nil { + if e := rt.client.Call(ctx, "account/rateLimits/read", map[string]any{}, &lr); e == nil { if len(lr.By) > 0 { for _, x := range lr.By { d.Limits = append(d.Limits, flattenLimit(x)...) @@ -263,20 +394,19 @@ func (a *App) sync(ctx context.Context) error { Tokens int64 `json:"tokens"` } `json:"dailyUsageBuckets"` } - if e := a.codex.Call(ctx, "account/usage/read", map[string]any{}, &ur); e == nil { + if e := rt.client.Call(ctx, "account/usage/read", map[string]any{}, &ur); e == nil { d.Summary = ur.Summary for _, x := range ur.Daily { p := UsagePoint{Date: x.StartDate, TotalTokens: x.Tokens} d.Usage = append(d.Usage, p) - _, _ = a.store.DB.Exec("INSERT INTO daily_usage(date,total_tokens,fetched_at) VALUES(?,?,?) ON CONFLICT(date) DO UPDATE SET total_tokens=excluded.total_tokens,fetched_at=excluded.fetched_at", x.StartDate, x.Tokens, d.FetchedAt) + _, _ = a.store.DB.Exec("INSERT INTO daily_usage(account_id,date,total_tokens,fetched_at) VALUES(?,?,?,?) ON CONFLICT(account_id,date) DO UPDATE SET total_tokens=excluded.total_tokens,fetched_at=excluded.fetched_at", id, x.StartDate, x.Tokens, d.FetchedAt) } } for _, x := range d.Limits { - _, _ = a.store.DB.Exec("INSERT INTO limit_snapshots(limit_id,window_type,used_percent,duration_mins,resets_at,fetched_at) VALUES(?,?,?,?,?,?)", x.LimitID, x.WindowType, x.UsedPercent, x.WindowDurationMinutes, x.ResetsAt, d.FetchedAt) + _, _ = a.store.DB.Exec("INSERT INTO limit_snapshots(limit_id,window_type,used_percent,duration_mins,resets_at,fetched_at,account_id) VALUES(?,?,?,?,?,?,?)", x.LimitID, x.WindowType, x.UsedPercent, x.WindowDurationMinutes, x.ResetsAt, d.FetchedAt, id) } - a.mu.Lock() - a.dash = d - a.mu.Unlock() + rt.dash = d + _ = a.store.UpdateAccount(id, d.Account.Email, d.Account.PlanType, d.Account.Connected) return nil } diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index 244f66f..2cfaecd 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -29,14 +29,18 @@ type App struct { dataDir string store *store.Store vault *security.Vault - codex *codex.Client server *http.Server ctx context.Context cancel context.CancelFunc mu sync.RWMutex - dash Dashboard + runtimes map[int64]*accountRuntime loginAttempts sync.Map } +type accountRuntime struct { + client *codex.Client + dash Dashboard + syncing sync.Mutex +} func New() (*App, error) { dir := env("DATA_DIR", "/data") @@ -49,8 +53,11 @@ func New() (*App, error) { return nil, e } ctx, cancel := context.WithCancel(context.Background()) - a := &App{dataDir: dir, store: s, vault: v, ctx: ctx, cancel: cancel, dash: Dashboard{Limits: []LimitBucket{}, Usage: []UsagePoint{}, Stale: true}} - a.codex = codex.New(filepath.Join(dir, "codex"), a.onCodexNotification) + a := &App{dataDir: dir, store: s, vault: v, ctx: ctx, cancel: cancel, runtimes: map[int64]*accountRuntime{}} + accounts, _ := s.Accounts() + for _, account := range accounts { + a.addRuntime(account.ID) + } a.server = &http.Server{Addr: env("LISTEN_ADDR", ":8080"), Handler: a.routes(), ReadHeaderTimeout: 10 * time.Second, IdleTimeout: 60 * time.Second} return a, nil } @@ -79,45 +86,81 @@ func (a *App) Close() { ctx, c := context.WithTimeout(context.Background(), 5*time.Second) defer c() _ = a.server.Shutdown(ctx) - _ = a.codex.Close() + a.mu.RLock() + for _, rt := range a.runtimes { + _ = rt.client.Close() + } + a.mu.RUnlock() _ = a.store.DB.Close() } func (a *App) keepCodex() { - delay := time.Second for { select { case <-a.ctx.Done(): return default: } - if !a.codex.Connected() { - if e := a.codex.Start(a.ctx); e == nil { + a.mu.RLock() + ids := make([]int64, 0, len(a.runtimes)) + for id := range a.runtimes { + ids = append(ids, id) + } + a.mu.RUnlock() + for _, id := range ids { + rt := a.runtime(id) + if rt == nil || rt.client.Connected() { + continue + } + if e := rt.client.Start(a.ctx); e == nil { ctx, c := context.WithTimeout(a.ctx, 20*time.Second) - e = a.codex.Initialize(ctx) + e = rt.client.Initialize(ctx) c() if e == nil { - delay = time.Second - _ = a.sync(context.Background()) + _ = a.syncAccount(context.Background(), id) } else { log.Printf("app-server initialize: %v", e) // A live process is not necessarily an initialized process. Tear it // down so the next iteration starts a fresh protocol session. - _ = a.codex.Close() - } - } - if !a.codex.Connected() { - time.Sleep(delay) - if delay < 30*time.Second { - delay *= 2 + _ = rt.client.Close() } } } time.Sleep(time.Second) } } -func (a *App) onCodexNotification(method string, _ json.RawMessage) { - if method == "account/updated" || method == "account/rateLimits/updated" { - go a.sync(context.Background()) +func (a *App) onCodexNotification(id int64) func(string, json.RawMessage) { + return func(method string, _ json.RawMessage) { + if method == "account/updated" || method == "account/rateLimits/updated" { + go a.syncAccount(context.Background(), id) + } + } +} +func (a *App) addRuntime(id int64) { + dir := filepath.Join(a.dataDir, "accounts", strconv.FormatInt(id, 10), "codex") + if id == 1 { + // Account 1 deliberately keeps the legacy path so upgrades retain the + // existing login, and fresh installs use the same deterministic path. + dir = filepath.Join(a.dataDir, "codex") + } + rt := &accountRuntime{client: codex.New(dir, a.onCodexNotification(id)), dash: Dashboard{Limits: []LimitBucket{}, Usage: []UsagePoint{}, Stale: true}} + a.mu.Lock() + a.runtimes[id] = rt + a.mu.Unlock() +} +func (a *App) runtime(id int64) *accountRuntime { + a.mu.RLock() + defer a.mu.RUnlock() + return a.runtimes[id] +} +func (a *App) syncAll(ctx context.Context) { + a.mu.RLock() + ids := make([]int64, 0, len(a.runtimes)) + for id := range a.runtimes { + ids = append(ids, id) + } + a.mu.RUnlock() + for _, id := range ids { + _ = a.syncAccount(ctx, id) } } func (a *App) scheduler() { @@ -130,7 +173,7 @@ func (a *App) scheduler() { case <-t.C: g := a.general() if time.Now().Unix()%(int64(g.SyncMinutes)*60) < 60 { - _ = a.sync(context.Background()) + a.syncAll(context.Background()) } _, _ = a.store.Cleanup(g.RetentionDays) go a.processReminders() @@ -146,7 +189,16 @@ func (a *App) routes() http.Handler { jsonOut(w, 503, map[string]string{"error": e.Error()}) return } - jsonOut(w, 200, map[string]any{"status": "ok", "appServer": a.codex.Connected()}) + connected := false + a.mu.RLock() + for _, rt := range a.runtimes { + if rt.client.Connected() { + connected = true + break + } + } + a.mu.RUnlock() + jsonOut(w, 200, map[string]any{"status": "ok", "appServer": connected}) }) m.HandleFunc("/api/v1/", a.api) sub, _ := fs.Sub(webassets.Assets, "dist") diff --git a/backend/internal/app/notify.go b/backend/internal/app/notify.go index e161e47..831bf9c 100644 --- a/backend/internal/app/notify.go +++ b/backend/internal/app/notify.go @@ -291,30 +291,45 @@ func (a *App) handleTG(t TelegramSettings, chat int64, text string) { return } if text == "立即刷新" || text == "/refresh" { - _ = a.sync(context.Background()) + a.syncAll(context.Background()) } - a.mu.RLock() - d := a.dash - a.mu.RUnlock() msg := "Codex 用量\n" + a.mu.RLock() + ds := make([]Dashboard, 0, len(a.runtimes)) + for _, rt := range a.runtimes { + rt.syncing.Lock() + ds = append(ds, rt.dash) + rt.syncing.Unlock() + } + a.mu.RUnlock() switch text { case "重置时间", "/reset": - for _, x := range d.Limits { - msg += fmt.Sprintf("%s/%s:%s\n", x.LimitID, x.WindowType, time.Unix(x.ResetsAt, 0).Format(time.RFC3339)) + for _, d := range ds { + msg += "[" + d.DisplayName + "]\n" + for _, x := range d.Limits { + msg += fmt.Sprintf("%s/%s:%s\n", x.LimitID, x.WindowType, time.Unix(x.ResetsAt, 0).Format(time.RFC3339)) + } } case "账户信息", "/account": - msg += fmt.Sprintf("连接:%v\n", d.Account.Connected) - if d.Account.Email != nil { - msg += "账户:" + *d.Account.Email + "\n" - } - if d.Account.PlanType != nil { - msg += "套餐:" + *d.Account.PlanType + for _, d := range ds { + msg += fmt.Sprintf("[%s] 连接:%v\n", d.DisplayName, d.Account.Connected) + if d.Account.Email != nil { + msg += "账户:" + *d.Account.Email + "\n" + } + if d.Account.PlanType != nil { + msg += "套餐:" + *d.Account.PlanType + "\n" + } } case "历史概览", "/usage": - msg += fmt.Sprintf("Lifetime tokens:%s\n历史天数:%d", num(d.Summary.LifetimeTokens), len(d.Usage)) + for _, d := range ds { + msg += fmt.Sprintf("[%s] Lifetime tokens:%s,历史天数:%d\n", d.DisplayName, num(d.Summary.LifetimeTokens), len(d.Usage)) + } default: - for _, x := range d.Limits { - msg += fmt.Sprintf("%s/%s:%.1f%%,重置 %s\n", x.LimitID, x.WindowType, x.UsedPercent, time.Unix(x.ResetsAt, 0).Format(time.RFC3339)) + for _, d := range ds { + msg += "[" + d.DisplayName + "]\n" + for _, x := range d.Limits { + msg += fmt.Sprintf("%s/%s:剩余 %.1f%%,重置 %s\n", x.LimitID, x.WindowType, 100-x.UsedPercent, time.Unix(x.ResetsAt, 0).Format(time.RFC3339)) + } } } _ = tgSend(t, msg) @@ -328,57 +343,64 @@ func num(n *int64) string { func (a *App) processReminders() { g := a.general() a.mu.RLock() - d := a.dash + ds := make([]Dashboard, 0, len(a.runtimes)) + for _, rt := range a.runtimes { + rt.syncing.Lock() + ds = append(ds, rt.dash) + rt.syncing.Unlock() + } a.mu.RUnlock() now := time.Now() - for _, x := range d.Limits { - for _, kind := range []string{"before", "after"} { - if kind == "before" && !g.NotifyBefore { - continue - } - if kind == "after" && !g.NotifyAfter { - continue - } - at := time.Unix(x.ResetsAt, 0) - if kind == "before" { - at = at.Add(-time.Duration(g.BeforeMinutes) * time.Minute) - } - if now.Before(at) || now.Sub(at) > 6*time.Hour { - continue - } - key := fmt.Sprintf("%s:%s:%d:%s", x.LimitID, x.WindowType, x.ResetsAt, kind) - var exists int - if a.store.DB.QueryRow("SELECT 1 FROM notifications WHERE dedupe_key=? AND status='sent'", key).Scan(&exists) == nil { - continue - } - body := fmt.Sprintf("Codex %s/%s 当前用量 %.1f%%,重置时间 %s。", x.LimitID, x.WindowType, x.UsedPercent, time.Unix(x.ResetsAt, 0).Format(time.RFC3339)) - ok := true - errs := []string{} - if t, e := a.telegramSecret(); e == nil && t.Enabled && t.ChatID != 0 { - if e = tgSend(t, body); e != nil { - ok = false - errs = append(errs, e.Error()) + for _, d := range ds { + for _, x := range d.Limits { + for _, kind := range []string{"before", "after"} { + if kind == "before" && !g.NotifyBefore { + continue } - } - if s, e := a.smtpSecret(); e == nil && s.Enabled { - if e = sendSMTP(s, "Codex 用量重置提醒", body); e != nil { - ok = false - errs = append(errs, e.Error()) + if kind == "after" && !g.NotifyAfter { + continue } - } - status := "sent" - var sent any = time.Now().Unix() - if !ok { - status = "failed" - sent = nil - } - _, _ = a.store.DB.Exec(`INSERT INTO notifications(dedupe_key,channel,kind,status,attempts,last_error,scheduled_at,sent_at) + at := time.Unix(x.ResetsAt, 0) + if kind == "before" { + at = at.Add(-time.Duration(g.BeforeMinutes) * time.Minute) + } + if now.Before(at) || now.Sub(at) > 6*time.Hour { + continue + } + key := fmt.Sprintf("%d:%s:%s:%d:%s", d.AccountID, x.LimitID, x.WindowType, x.ResetsAt, kind) + var exists int + if a.store.DB.QueryRow("SELECT 1 FROM notifications WHERE dedupe_key=? AND status='sent'", key).Scan(&exists) == nil { + continue + } + body := fmt.Sprintf("Codex [%s] %s/%s 剩余 %.1f%%,重置时间 %s。", d.DisplayName, x.LimitID, x.WindowType, 100-x.UsedPercent, time.Unix(x.ResetsAt, 0).Format(time.RFC3339)) + ok := true + errs := []string{} + if t, e := a.telegramSecret(); e == nil && t.Enabled && t.ChatID != 0 { + if e = tgSend(t, body); e != nil { + ok = false + errs = append(errs, e.Error()) + } + } + if s, e := a.smtpSecret(); e == nil && s.Enabled { + if e = sendSMTP(s, "Codex 用量重置提醒", body); e != nil { + ok = false + errs = append(errs, e.Error()) + } + } + status := "sent" + var sent any = time.Now().Unix() + if !ok { + status = "failed" + sent = nil + } + _, _ = a.store.DB.Exec(`INSERT INTO notifications(dedupe_key,channel,kind,status,attempts,last_error,scheduled_at,sent_at) VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(dedupe_key) DO UPDATE SET status=excluded.status, attempts=notifications.attempts+1, last_error=excluded.last_error, sent_at=excluded.sent_at`, key, "configured", kind, status, 1, strings.Join(errs, "; "), at.Unix(), sent) + } } } } diff --git a/backend/internal/app/types.go b/backend/internal/app/types.go index f9375e0..37e11ca 100644 --- a/backend/internal/app/types.go +++ b/backend/internal/app/types.go @@ -67,13 +67,15 @@ type UsagePoint struct { OutputTokens *int64 `json:"outputTokens"` } type Dashboard struct { - Account AccountView `json:"account"` - Limits []LimitBucket `json:"limits"` - Summary UsageSummary `json:"summary"` - Usage []UsagePoint `json:"usage"` - FetchedAt int64 `json:"fetchedAt"` - Stale bool `json:"stale"` - LastError string `json:"lastError,omitempty"` + AccountID int64 `json:"accountId"` + DisplayName string `json:"displayName"` + Account AccountView `json:"account"` + Limits []LimitBucket `json:"limits"` + Summary UsageSummary `json:"summary"` + Usage []UsagePoint `json:"usage"` + FetchedAt int64 `json:"fetchedAt"` + Stale bool `json:"stale"` + LastError string `json:"lastError,omitempty"` } func defaults() GeneralSettings { diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go index 89c3a17..9c588a5 100644 --- a/backend/internal/store/store.go +++ b/backend/internal/store/store.go @@ -42,7 +42,138 @@ CREATE TABLE IF NOT EXISTS notifications (dedupe_key TEXT PRIMARY KEY, channel T CREATE TABLE IF NOT EXISTS telegram_updates (id INTEGER PRIMARY KEY CHECK(id=1), offset INTEGER NOT NULL DEFAULT 0); INSERT OR IGNORE INTO telegram_updates(id,offset) VALUES(1,0); `) - return err + if err != nil { + return err + } + return s.migrateAccounts() +} + +func (s *Store) migrateAccounts() error { + tx, err := s.DB.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if _, err = tx.Exec(`CREATE TABLE IF NOT EXISTS accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + display_name TEXT NOT NULL, + email TEXT, + plan_type TEXT, + connected INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + )`); err != nil { + return err + } + var count int + if err = tx.QueryRow("SELECT COUNT(*) FROM accounts").Scan(&count); err != nil { + return err + } + if count == 0 { + if _, err = tx.Exec("INSERT INTO accounts(id,display_name,created_at,updated_at) VALUES(1,'默认账号',?,?)", time.Now().Unix(), time.Now().Unix()); err != nil { + return err + } + } + for _, table := range []string{"daily_usage", "limit_snapshots"} { + var found int + rows, qerr := tx.Query("PRAGMA table_info(" + table + ")") + if qerr != nil { + return qerr + } + for rows.Next() { + var cid, notnull, pk int + var name, typ string + var def any + _ = rows.Scan(&cid, &name, &typ, ¬null, &def, &pk) + if name == "account_id" { + found = 1 + } + } + rows.Close() + if found == 0 { + if table == "daily_usage" { + _, err = tx.Exec(`ALTER TABLE daily_usage RENAME TO daily_usage_legacy; + CREATE TABLE daily_usage (account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,date TEXT NOT NULL,total_tokens INTEGER NOT NULL,fetched_at INTEGER NOT NULL,PRIMARY KEY(account_id,date)); + INSERT INTO daily_usage SELECT 1,date,total_tokens,fetched_at FROM daily_usage_legacy; + DROP TABLE daily_usage_legacy;`) + } else { + _, err = tx.Exec(`ALTER TABLE limit_snapshots RENAME TO limit_snapshots_legacy; + CREATE TABLE limit_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + limit_id TEXT NOT NULL, + window_type TEXT NOT NULL, + used_percent REAL NOT NULL, + duration_mins INTEGER NOT NULL, + resets_at INTEGER NOT NULL, + fetched_at INTEGER NOT NULL + ); + INSERT INTO limit_snapshots(id,account_id,limit_id,window_type,used_percent,duration_mins,resets_at,fetched_at) + SELECT id,1,limit_id,window_type,used_percent,duration_mins,resets_at,fetched_at FROM limit_snapshots_legacy; + DROP TABLE limit_snapshots_legacy; + CREATE INDEX idx_limits_time ON limit_snapshots(fetched_at);`) + } + if err != nil { + return err + } + } + } + return tx.Commit() +} + +type Account struct { + ID int64 `json:"id"` + DisplayName string `json:"displayName"` + Email *string `json:"email"` + PlanType *string `json:"planType"` + Connected bool `json:"connected"` + CreatedAt int64 `json:"createdAt"` + UpdatedAt int64 `json:"updatedAt"` +} + +func (s *Store) Accounts() ([]Account, error) { + rows, e := s.DB.Query("SELECT id,display_name,email,plan_type,connected,created_at,updated_at FROM accounts ORDER BY id") + if e != nil { + return nil, e + } + defer rows.Close() + out := []Account{} + for rows.Next() { + var a Account + if e = rows.Scan(&a.ID, &a.DisplayName, &a.Email, &a.PlanType, &a.Connected, &a.CreatedAt, &a.UpdatedAt); e != nil { + return nil, e + } + out = append(out, a) + } + return out, rows.Err() +} +func (s *Store) CreateAccount(name string) (Account, error) { + now := time.Now().Unix() + r, e := s.DB.Exec("INSERT INTO accounts(display_name,created_at,updated_at) VALUES(?,?,?)", name, now, now) + if e != nil { + return Account{}, e + } + id, _ := r.LastInsertId() + return Account{ID: id, DisplayName: name, CreatedAt: now, UpdatedAt: now}, nil +} +func (s *Store) RenameAccount(id int64, name string) error { + r, e := s.DB.Exec("UPDATE accounts SET display_name=?,updated_at=? WHERE id=?", name, time.Now().Unix(), id) + if e != nil { + return e + } + n, _ := r.RowsAffected() + if n == 0 { + return sql.ErrNoRows + } + return nil +} +func (s *Store) UpdateAccount(id int64, email, plan *string, connected bool) error { + _, e := s.DB.Exec("UPDATE accounts SET email=?,plan_type=?,connected=?,updated_at=? WHERE id=?", email, plan, connected, time.Now().Unix(), id) + return e +} +func (s *Store) DeleteAccount(id int64) error { + _, e := s.DB.Exec("DELETE FROM accounts WHERE id=?", id) + return e } func (s *Store) Get(key string) (string, bool) { diff --git a/backend/internal/store/store_test.go b/backend/internal/store/store_test.go index 898daf9..97be351 100644 --- a/backend/internal/store/store_test.go +++ b/backend/internal/store/store_test.go @@ -26,6 +26,104 @@ func TestSettings(t *testing.T) { } } +func TestAccountsAndPerAccountUsage(t *testing.T) { + s, err := Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer s.DB.Close() + accounts, err := s.Accounts() + if err != nil || len(accounts) != 1 || accounts[0].ID != 1 { + t.Fatalf("default accounts = %#v, %v", accounts, err) + } + second, err := s.CreateAccount("Team workspace") + if err != nil { + t.Fatal(err) + } + for _, id := range []int64{1, second.ID} { + if _, err = s.DB.Exec("INSERT INTO daily_usage(account_id,date,total_tokens,fetched_at) VALUES(?,?,?,?)", id, "2026-08-13", id*100, 1); err != nil { + t.Fatal(err) + } + } + if err = s.DeleteAccount(second.ID); err != nil { + t.Fatal(err) + } + var count int + if err = s.DB.QueryRow("SELECT COUNT(*) FROM daily_usage WHERE account_id=?", second.ID).Scan(&count); err != nil || count != 0 { + t.Fatalf("usage was not cascaded: %d, %v", count, err) + } +} + +func TestLegacyUsageMigratesToDefaultAccount(t *testing.T) { + dir := t.TempDir() + db, err := sql.Open("sqlite", filepath.Join(dir, "codex-helper.db")) + if err != nil { + t.Fatal(err) + } + if _, err = db.Exec("CREATE TABLE daily_usage(date TEXT PRIMARY KEY,total_tokens INTEGER NOT NULL,fetched_at INTEGER NOT NULL); INSERT INTO daily_usage VALUES('2026-08-12',321,1)"); err != nil { + t.Fatal(err) + } + db.Close() + s, err := Open(dir) + if err != nil { + t.Fatal(err) + } + defer s.DB.Close() + var accountID, tokens int64 + if err = s.DB.QueryRow("SELECT account_id,total_tokens FROM daily_usage WHERE date='2026-08-12'").Scan(&accountID, &tokens); err != nil { + t.Fatal(err) + } + if accountID != 1 || tokens != 321 { + t.Fatalf("migrated row = account %d, tokens %d", accountID, tokens) + } +} + +func TestPopulatedLegacyLimitSnapshotsMigrateToDefaultAccount(t *testing.T) { + dir := t.TempDir() + db, err := sql.Open("sqlite", filepath.Join(dir, "codex-helper.db")) + if err != nil { + t.Fatal(err) + } + _, err = db.Exec(` + CREATE TABLE limit_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + limit_id TEXT NOT NULL, + window_type TEXT NOT NULL, + used_percent REAL NOT NULL, + duration_mins INTEGER NOT NULL, + resets_at INTEGER NOT NULL, + fetched_at INTEGER NOT NULL + ); + CREATE INDEX idx_limits_time ON limit_snapshots(fetched_at); + INSERT INTO limit_snapshots(id,limit_id,window_type,used_percent,duration_mins,resets_at,fetched_at) + VALUES(7,'codex','primary',42.5,300,1700000000,1699990000); + `) + if err != nil { + t.Fatal(err) + } + db.Close() + + s, err := Open(dir) + if err != nil { + t.Fatal(err) + } + defer s.DB.Close() + var id, accountID, duration int64 + var limitID, window string + var used float64 + err = s.DB.QueryRow("SELECT id,account_id,limit_id,window_type,used_percent,duration_mins FROM limit_snapshots").Scan(&id, &accountID, &limitID, &window, &used, &duration) + if err != nil { + t.Fatal(err) + } + if id != 7 || accountID != 1 || limitID != "codex" || window != "primary" || used != 42.5 || duration != 300 { + t.Fatalf("migrated limit = id %d, account %d, %s/%s, %.1f, duration %d", id, accountID, limitID, window, used, duration) + } + var indexCount int + if err = s.DB.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_limits_time'").Scan(&indexCount); err != nil || indexCount != 1 { + t.Fatalf("limit index was not recreated: %d, %v", indexCount, err) + } +} + func TestBackupIncludesCommittedWALData(t *testing.T) { dir := t.TempDir() s, err := Open(dir) diff --git a/backend/internal/web/dist/index.html b/backend/internal/web/dist/index.html index 3858b0f..bf3fe38 100644 --- a/backend/internal/web/dist/index.html +++ b/backend/internal/web/dist/index.html @@ -1,3 +1,3 @@ - - + +
diff --git a/frontend/src/api.ts b/frontend/src/api.ts index b9dbc30..71d69ab 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,3 +1,4 @@ export async function api一个容器内完成用量洞察、重置提醒与账户管理。
使用设备码登录,适合 Docker 与远程部署。Codex app server 将安全管理登录凭据。
{result&&{err}
}个人订阅和 Team 工作区请分别添加为独立连接;同一邮箱可以添加多次。
{err}
}{sub}