diff --git a/README.md b/README.md index c35e7d2..18e10f1 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ - 支持在同一总览页查看多个 Codex 账号或工作区(包括同一邮箱的个人订阅与 Team 工作区) - 展示 Codex 账户、套餐、剩余额度和下次重置时间 - 展示当前重置周期 Token 合计、本周期单日峰值及每日 Token 趋势 +- 登录后有可用重置卡时展示卡片数量和到期时间 - 在本地 SQLite 中保留历史用量和限额快照 - 在限额重置前、重置后发送 Telegram 或邮件提醒 - 通过 Telegram 菜单查询当前用量、重置时间、历史概览和账户信息 diff --git a/backend/CONTRACT.md b/backend/CONTRACT.md index 3473cb8..b2e915b 100644 --- a/backend/CONTRACT.md +++ b/backend/CONTRACT.md @@ -33,6 +33,7 @@ usedPercent, windowDurationMinutes, resetsAt, planType:string|null}], currentCycle?:{limitId, windowType, windowDurationMinutes, startedAt, resetsAt, totalTokens}, + resetCredits?:{availableCount, expiresAt:[Unix seconds]}, summary:{lifetimeTokens?, peakDailyTokens?, longestRunningTurnSec?, currentStreakDays?, longestStreakDays?, callCount?, inputTokens?, outputTokens?}, usage:[{date,totalTokens,callCount?,inputTokens?,outputTokens?}], @@ -40,6 +41,7 @@ ``` 时间字段为 Unix 秒。`currentCycle` 是按当前仍有效的最长限额窗口计算的当前重置周期;`totalTokens` 由 `account/usage/read` 的每日 Token bucket 汇总,受每日粒度影响,无法精确切分周期起止日期内的单日数据。`summary.peakDailyTokens` 是同一当前重置周期内每日 Token bucket 的最大值,不再直接采用 app-server 未限定口径的峰值摘要;没有有效窗口或周期内没有每日数据时为 `null`。app-server 未提供的其他摘要字段可以是 `null`;列表应返回数组而非 `null`。 +`resetCredits` 仅在 app-server 返回可用重置卡且 `availableCount > 0` 时出现;`expiresAt` 是已返回卡片的去重到期时间列表,服务端未提供卡片详情时可以为空。匿名公开 Dashboard 不返回该字段。 ## 3. 系统、初始化与会话 @@ -64,7 +66,7 @@ | `POST /api/v1/accounts/{id}/login/device` | 启动并初始化 app-server,调用 `account/login/start` 的 `chatgptDeviceCode` 流程;返回含 `verificationUrl`、`userCode` 和 `loginId` 的结果。 | | `POST /api/v1/accounts/{id}/logout` | 调用 `account/logout` 并将连接状态置为 false;返回 `200 {ok:true}`。 | | `POST /api/v1/accounts/{id}/sync` | 同步指定账号;成功 `200 {ok:true}`,上游失败 502。 | -| `GET`/`HEAD /api/v1/dashboard?accountId={id}` | 初始化后匿名可读公开账号,返回内存中的 `Dashboard`;匿名访问未公开账号返回 404,匿名响应隐藏邮箱、认证方式和内部错误字段。登录后可读取全部账号。省略或无效的零值 ID 使用账号 1,前端使用 `GET`。 | +| `GET`/`HEAD /api/v1/dashboard?accountId={id}` | 初始化后匿名可读公开账号,返回内存中的 `Dashboard`;匿名访问未公开账号返回 404,匿名响应隐藏邮箱、认证方式、重置卡和内部错误字段。登录后可读取全部账号。省略或无效的零值 ID 使用账号 1,前端使用 `GET`。 | | `任意非读方法 /api/v1/dashboard?accountId={id}` | 要求 session;非 `GET`/`HEAD` 还需来源头。保持兼容的读取行为,省略或无效的零值 ID 使用账号 1。 | | `POST /api/v1/sync?accountId={id}` | 旧兼容入口,同步指定账号;省略或零值 ID 使用账号 1。 | diff --git a/backend/internal/app/api.go b/backend/internal/app/api.go index 5acc6a5..3e042f9 100644 --- a/backend/internal/app/api.go +++ b/backend/internal/app/api.go @@ -8,6 +8,7 @@ import ( "net/http" "os" "path/filepath" + "sort" "strconv" "strings" "time" @@ -229,6 +230,7 @@ func (a *App) dashboardAPI(w http.ResponseWriter, r *http.Request) { func publicDashboard(d Dashboard) Dashboard { d.Account.Email = nil d.Account.AuthMode = nil + d.ResetCredits = nil d.LastError = "" return d } @@ -489,10 +491,12 @@ func (a *App) syncAccount(ctx context.Context, id int64) error { d.Account.AuthMode = &ar.Account.Type } var lr struct { - RateLimits *rawLimit `json:"rateLimits"` - By map[string]rawLimit `json:"rateLimitsByLimitId"` + RateLimits *rawLimit `json:"rateLimits"` + By map[string]rawLimit `json:"rateLimitsByLimitId"` + ResetCredits *rawResetCredits `json:"rateLimitResetCredits"` } if e := rt.client.Call(ctx, "account/rateLimits/read", map[string]any{}, &lr); e == nil { + d.ResetCredits = normalizeResetCredits(lr.ResetCredits) if len(lr.By) > 0 { for _, x := range lr.By { d.Limits = append(d.Limits, flattenLimit(x)...) @@ -605,6 +609,35 @@ type rawLimit struct { Secondary *LimitWindow `json:"secondary"` } +type rawResetCredits struct { + AvailableCount int `json:"availableCount"` + Credits []rawResetCredit `json:"credits"` +} + +type rawResetCredit struct { + ExpiresAt *int64 `json:"expiresAt"` +} + +func normalizeResetCredits(raw *rawResetCredits) *ResetCreditsSummary { + if raw == nil || raw.AvailableCount <= 0 { + return nil + } + expiresAt := make([]int64, 0, len(raw.Credits)) + for _, credit := range raw.Credits { + if credit.ExpiresAt != nil && *credit.ExpiresAt > 0 { + expiresAt = append(expiresAt, *credit.ExpiresAt) + } + } + sort.Slice(expiresAt, func(i, j int) bool { return expiresAt[i] < expiresAt[j] }) + uniqueExpiresAt := expiresAt[:0] + for _, value := range expiresAt { + if len(uniqueExpiresAt) == 0 || uniqueExpiresAt[len(uniqueExpiresAt)-1] != value { + uniqueExpiresAt = append(uniqueExpiresAt, value) + } + } + return &ResetCreditsSummary{AvailableCount: raw.AvailableCount, ExpiresAt: uniqueExpiresAt} +} + func currentTokenCycle(limits []LimitBucket, usage []UsagePoint, fetchedAt int64) *TokenCycle { current := LimitBucket{} found := false diff --git a/backend/internal/app/runtime_test.go b/backend/internal/app/runtime_test.go index 2d6286a..eeddd73 100644 --- a/backend/internal/app/runtime_test.go +++ b/backend/internal/app/runtime_test.go @@ -6,6 +6,7 @@ import ( "errors" "net/http" "net/http/httptest" + "reflect" "strconv" "strings" "sync" @@ -93,12 +94,13 @@ func TestAnonymousOverviewIsReadOnly(t *testing.T) { } a.runtimes[1] = &accountRuntime{ dash: Dashboard{ - AccountID: 1, - DisplayName: "默认账号", - Account: AccountView{Email: &email, PlanType: &plan, Connected: true}, - Limits: []LimitBucket{}, - Usage: []UsagePoint{}, - FetchedAt: time.Now().Unix(), + AccountID: 1, + DisplayName: "默认账号", + Account: AccountView{Email: &email, PlanType: &plan, Connected: true}, + Limits: []LimitBucket{}, + Usage: []UsagePoint{}, + ResetCredits: &ResetCreditsSummary{AvailableCount: 2, ExpiresAt: []int64{1784246400}}, + FetchedAt: time.Now().Unix(), }, } @@ -132,6 +134,9 @@ func TestAnonymousOverviewIsReadOnly(t *testing.T) { if publicDashboardBody.Account.Email != nil || publicDashboardBody.Account.AuthMode != nil { t.Fatalf("anonymous dashboard account = %#v; identity fields were not redacted", publicDashboardBody.Account) } + if publicDashboardBody.ResetCredits != nil { + t.Fatalf("anonymous dashboard reset credits = %#v; entitlement data was not redacted", publicDashboardBody.ResetCredits) + } for _, path := range []string{"/api/v1/settings/general", "/api/v1/accounts", "/api/v1/accounts/1/sync"} { recorder := httptest.NewRecorder() @@ -163,6 +168,9 @@ func TestAnonymousOverviewIsReadOnly(t *testing.T) { if privateDashboardBody.Account.Email == nil || *privateDashboardBody.Account.Email != email { t.Fatalf("authenticated dashboard email = %v; want %q", privateDashboardBody.Account.Email, email) } + if privateDashboardBody.ResetCredits == nil || privateDashboardBody.ResetCredits.AvailableCount != 2 { + t.Fatalf("authenticated dashboard reset credits = %#v; want two credits", privateDashboardBody.ResetCredits) + } configRecorder := httptest.NewRecorder() configRequest := httptest.NewRequest(http.MethodPut, "/api/v1/settings/general", strings.NewReader(`{"timezone":"UTC","theme":"system","syncMinutes":5,"retentionDays":90,"beforeMinutes":30,"notifyBefore":true,"notifyAfter":true}`)) configRequest.AddCookie(&http.Cookie{Name: "session", Value: session}) @@ -320,6 +328,29 @@ func TestCurrentTokenCycleUsesLongestWindowAndFiltersDailyUsage(t *testing.T) { } } +func TestNormalizeResetCreditsKeepsAvailableCountAndUniqueExpiryTimes(t *testing.T) { + earlier := int64(1781654400) + later := int64(1784246400) + credits := normalizeResetCredits(&rawResetCredits{ + AvailableCount: 3, + Credits: []rawResetCredit{ + {ExpiresAt: &later}, + {ExpiresAt: nil}, + {ExpiresAt: &earlier}, + {ExpiresAt: &later}, + }, + }) + if credits == nil || credits.AvailableCount != 3 { + t.Fatalf("reset credits = %#v; want three available credits", credits) + } + if want := []int64{earlier, later}; !reflect.DeepEqual(credits.ExpiresAt, want) { + t.Fatalf("expiry times = %v; want %v", credits.ExpiresAt, want) + } + if normalizeResetCredits(&rawResetCredits{}) != nil { + t.Fatal("zero available credits should be hidden") + } +} + func TestCurrentTokenCycleRequiresAValidFutureResetWindow(t *testing.T) { cycle := currentTokenCycle( []LimitBucket{{WindowDurationMinutes: 0, ResetsAt: 1}}, diff --git a/backend/internal/app/types.go b/backend/internal/app/types.go index 9f6a8b6..fa5a112 100644 --- a/backend/internal/app/types.go +++ b/backend/internal/app/types.go @@ -81,17 +81,22 @@ type TokenCycle struct { ResetsAt int64 `json:"resetsAt"` TotalTokens int64 `json:"totalTokens"` } +type ResetCreditsSummary struct { + AvailableCount int `json:"availableCount"` + ExpiresAt []int64 `json:"expiresAt"` +} type Dashboard struct { - AccountID int64 `json:"accountId"` - DisplayName string `json:"displayName"` - Account AccountView `json:"account"` - Limits []LimitBucket `json:"limits"` - Summary UsageSummary `json:"summary"` - Usage []UsagePoint `json:"usage"` - CurrentCycle *TokenCycle `json:"currentCycle,omitempty"` - 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"` + CurrentCycle *TokenCycle `json:"currentCycle,omitempty"` + ResetCredits *ResetCreditsSummary `json:"resetCredits,omitempty"` + FetchedAt int64 `json:"fetchedAt"` + Stale bool `json:"stale"` + LastError string `json:"lastError,omitempty"` } func defaults() GeneralSettings { diff --git a/docs/backend/codex-integration-and-usage.md b/docs/backend/codex-integration-and-usage.md index fcff75a..cd03514 100644 --- a/docs/backend/codex-integration-and-usage.md +++ b/docs/backend/codex-integration-and-usage.md @@ -23,13 +23,15 @@ account/login/start {type:"chatgptDeviceCode"} 一次同步依次读取 `account/read`、`account/rateLimits/read` 和 `account/usage/read`: - `account/read` 决定连接、邮箱、认证模式和套餐;读取失败会使整次同步失败。 -- 限额读取兼容 `rateLimitsByLimitId` 多 bucket 和旧 `rateLimits` 单 bucket,再把 primary/secondary 展平。失败时保留空限额而不令整次同步失败。 +- 限额读取兼容 `rateLimitsByLimitId` 多 bucket 和旧 `rateLimits` 单 bucket,再把 primary/secondary 展平;同时读取 `rateLimitResetCredits`,只在有可用卡时保留数量和到期时间。失败时保留空限额而不令整次同步失败。 - 套餐未知时,可从所有可分类且一致的限额 bucket 回填;冲突或未知时必须保持 unknown。 - 用量读取保存 summary 和每日 Token bucket;接口失败时使用空摘要和空历史,不令整次同步失败。同步成功后,以当前仍有效的最长限额窗口作为当前重置周期,按每日 bucket 汇总周期起点至当前日期的 Token,写入 Dashboard 的 `currentCycle`;周期外和未来日期不会计入。 - 每次限额同步写入快照、检测用量百分比显著回落,并更新账号元数据及内存 Dashboard。 `account/usage/read` 的 optional 指标和 daily buckets 可能暂未提供。仅 API Key 或 Bedrock 登录不能保证读取 ChatGPT 用量;不得在缺失数据时合成调用次数、输入/输出 Token、价格或账单日期。 +重置卡来自 `account/rateLimits/read` 的 `rateLimitResetCredits`。该信息只保存在内存 Dashboard 中,下一次同步会重新读取;没有可用卡时不返回到 Dashboard。当前项目只展示数量和到期时间,不执行消耗操作。 + `currentCycle` 的周期长度和重置时间来自 `account/rateLimits/read`,不硬编码为七天;通常会选择 secondary 周窗口。由于 daily bucket 只有日期没有每次请求时间,周期边界所在日期按整日汇总,因此该值是当前周期的日级统计,不是 Credits 或美元估值。 ## 套餐类型 diff --git a/docs/frontend/application.md b/docs/frontend/application.md index fb6bf6e..eb9ace3 100644 --- a/docs/frontend/application.md +++ b/docs/frontend/application.md @@ -18,7 +18,7 @@ API 类型精确区分后端 `null` 与 optional,并为秘密设置拆分读 ## 总览与设置 -总览显示账户连接、套餐、每个限额窗口的剩余百分比和重置时间、当前重置周期 Token 合计、本周期单日峰值、其他摘要及每日 Token 图。当前周期 Token 与本周期单日峰值都由后端按 app-server 的有效最长限额窗口和每日 bucket 汇总,不包含周期外或未来日期;详情会显示周期起止时间和“按每日数据汇总”提示。`usedPercent` 展示前限制到 0–100,但原始数据语义不得在 API 类型层改写。缺失摘要显示“暂无”,缺失限额显示明确空状态。 +总览显示账户连接、套餐、每个限额窗口的剩余百分比和重置时间、当前重置周期 Token 合计、本周期单日峰值、其他摘要及每日 Token 图;登录后账号有可用重置卡时,详情额外显示卡片数量和到期时间。当前周期 Token 与本周期单日峰值都由后端按 app-server 的有效最长限额窗口和每日 bucket 汇总,不包含周期外或未来日期;详情会显示周期起止时间和“按每日数据汇总”提示。`usedPercent` 展示前限制到 0–100,但原始数据语义不得在 API 类型层改写。缺失摘要显示“暂无”,缺失限额显示明确空状态。匿名公开 Dashboard 不显示重置卡信息。 设置页四个 tab 全部保持挂载,以保留未提交表单状态;非活动 panel 使用 `aria-hidden` 和 `inert` 隔离。tab 支持方向键、Home 和 End,程序化切换不得改变页面滚动和布局。账号删除必须保留不可撤销确认,并清理正在显示的设备码和本地账号选择。 diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 969befd..f440735 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -24,6 +24,7 @@ import { MoreHorizontal, Settings, Sun, + Ticket, Trash2, TrendingUp, Zap, @@ -714,6 +715,21 @@ function AccountDashboardDetails({ value={dashboard.stale ? "数据可能已过期" : "数据已更新"} /> + {!publicView && + dashboard.resetCredits && + dashboard.resetCredits.availableCount > 0 && ( +