feat: show available reset credits

This commit is contained in:
2026-08-22 09:59:10 +08:00
parent 45ceeee936
commit 6a711c1fff
12 changed files with 201 additions and 21 deletions
+3 -1
View File
@@ -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。 |
+35 -2
View File
@@ -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
+37 -6
View File
@@ -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}},
+15 -10
View File
@@ -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 {