diff --git a/backend/internal/service/claude_reset_credits.go b/backend/internal/service/claude_reset_credits.go index 8c4aee7da0..059aaa4b5e 100644 --- a/backend/internal/service/claude_reset_credits.go +++ b/backend/internal/service/claude_reset_credits.go @@ -2,8 +2,6 @@ package service import ( "context" - "crypto/sha256" - "encoding/hex" "encoding/json" "fmt" "io" @@ -22,7 +20,6 @@ const claudeResetUsageURL = "https://api.anthropic.com/api/oauth/usage?cedar_emb // ClaudeResetCredit is deliberately free of upstream grant and organization IDs. type ClaudeResetCredit struct { - SelectionToken string `json:"selection_token"` Label string `json:"label"` ResetsLeft int `json:"resets_left"` StartsAt *time.Time `json:"starts_at,omitempty"` @@ -137,50 +134,43 @@ func (s *ClaudeResetCreditService) headers(ctx context.Context, req *http.Reques req.Header.Set("User-Agent", "claude-cli/"+s.settings.GetClaudeCodeClientVersion(ctx)+" (external, cli)") } -func (s *ClaudeResetCreditService) query(ctx context.Context, id int64) (*ClaudeResetCredits, *claudeResetBlock, error) { +func (s *ClaudeResetCreditService) query(ctx context.Context, id int64) (*ClaudeResetCredits, error) { _, token, proxy, err := s.account(ctx, id) if err != nil { - return nil, nil, err + return nil, err } req, err := http.NewRequestWithContext(ctx, http.MethodGet, claudeResetUsageURL, nil) if err != nil { - return nil, nil, err + return nil, err } s.headers(ctx, req, token) resp, err := s.do(req, proxy) if err != nil { - return nil, nil, infraerrors.ServiceUnavailable("CLAUDE_RESET_QUERY_FAILED", "reset status request failed") + return nil, infraerrors.ServiceUnavailable("CLAUDE_RESET_QUERY_FAILED", "reset status request failed") } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { - return nil, nil, infraerrors.New(http.StatusBadGateway, "CLAUDE_RESET_QUERY_FAILED", fmt.Sprintf("reset status upstream HTTP %d", resp.StatusCode)) + return nil, infraerrors.New(http.StatusBadGateway, "CLAUDE_RESET_QUERY_FAILED", fmt.Sprintf("reset status upstream HTTP %d", resp.StatusCode)) } var envelope map[string]json.RawMessage if err = json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&envelope); err != nil || envelope == nil { - return nil, nil, infraerrors.New(http.StatusBadGateway, "CLAUDE_RESET_STATUS_INVALID", "invalid reset status") + return nil, infraerrors.New(http.StatusBadGateway, "CLAUDE_RESET_STATUS_INVALID", "invalid reset status") } if _, ok := envelope["error"]; ok { - return nil, nil, infraerrors.New(http.StatusBadGateway, "CLAUDE_RESET_STATUS_INVALID", "invalid reset status") + return nil, infraerrors.New(http.StatusBadGateway, "CLAUDE_RESET_STATUS_INVALID", "invalid reset status") } var block *claudeResetBlock raw, present := envelope["cedar_ember"] if present && string(raw) != "null" { if err = json.Unmarshal(raw, &block); err != nil || block == nil || block.Grants == nil { - return nil, nil, infraerrors.New(http.StatusBadGateway, "CLAUDE_RESET_STATUS_INVALID", "invalid reset grants") + return nil, infraerrors.New(http.StatusBadGateway, "CLAUDE_RESET_STATUS_INVALID", "invalid reset grants") } } - result := projectClaudeResetCredits(block, s.now()) - return result, block, nil + return projectClaudeResetCredits(block, s.now()), nil } func (s *ClaudeResetCreditService) Query(ctx context.Context, id int64) (*ClaudeResetCredits, error) { - r, _, e := s.query(ctx, id) - return r, e -} - -func claudeGrantSelection(g claudeResetGrant) string { - h := sha256.Sum256([]byte(fmt.Sprintf("%s:%d", g.ID, g.ResetsLeft))) - return hex.EncodeToString(h[:]) + return s.query(ctx, id) } func projectClaudeResetCredits(b *claudeResetBlock, now time.Time) *ClaudeResetCredits { @@ -189,7 +179,9 @@ func projectClaudeResetCredits(b *claudeResetBlock, now time.Time) *ClaudeResetC return r } r.Eligible = b.Eligible - r.CooldownUntil = b.CooldownUntil + if b.CooldownUntil != nil && now.Before(*b.CooldownUntil) { + r.CooldownUntil = b.CooldownUntil + } r.WeeklyResetsAt = b.WeeklyResetsAt for _, g := range b.Grants { if !claudeResetGrantIDPattern.MatchString(g.ID) || len(g.Clears) == 0 || g.ResetsLeft <= 0 || g.Paused || (g.StartsAt != nil && now.Before(*g.StartsAt)) || (g.EndsAt != nil && !now.Before(*g.EndsAt)) { @@ -203,7 +195,7 @@ func projectClaudeResetCredits(b *claudeResetBlock, now time.Time) *ClaudeResetC used[k] = v } } - r.Credits = append(r.Credits, ClaudeResetCredit{SelectionToken: claudeGrantSelection(g), Label: g.Label, ResetsLeft: g.ResetsLeft, StartsAt: g.StartsAt, ExpiresAt: g.EndsAt, Clears: g.Clears, PercentUsed: used, Blocking: g.Blocking, UseRequiresLimit: requires, Redeemable: usable}) + r.Credits = append(r.Credits, ClaudeResetCredit{Label: g.Label, ResetsLeft: g.ResetsLeft, StartsAt: g.StartsAt, ExpiresAt: g.EndsAt, Clears: g.Clears, PercentUsed: used, Blocking: g.Blocking, UseRequiresLimit: requires, Redeemable: usable}) if usable { r.AvailableCount += g.ResetsLeft } diff --git a/backend/internal/service/claude_reset_credits_test.go b/backend/internal/service/claude_reset_credits_test.go index e2f4b99a4a..fc11f2a5ef 100644 --- a/backend/internal/service/claude_reset_credits_test.go +++ b/backend/internal/service/claude_reset_credits_test.go @@ -40,6 +40,19 @@ func TestClaudeResetStatusNativeContract(t *testing.T) { require.NotContains(t, string(b), `"id"`) require.NotContains(t, string(b), "launch") require.NotContains(t, string(b), "synthetic-token") + require.NotContains(t, string(b), "selection_token") +} +func TestClaudeResetPastCooldownIsCleared(t *testing.T) { + now := time.Now() + past := now.Add(-time.Minute) + no := false + g := claudeResetGrant{ID: "grant", ResetsLeft: 1, UsableNow: true, UseRequiresLimit: &no, Clears: []string{"five_hour"}} + r := projectClaudeResetCredits(&claudeResetBlock{Eligible: true, NextGrantID: g.ID, CooldownUntil: &past, Grants: []claudeResetGrant{g}}, now) + require.Nil(t, r.CooldownUntil) + require.Equal(t, 1, r.AvailableCount) + future := now.Add(time.Hour) + r = projectClaudeResetCredits(&claudeResetBlock{Eligible: true, NextGrantID: g.ID, CooldownUntil: &future, Grants: []claudeResetGrant{g}}, now) + require.Equal(t, &future, r.CooldownUntil) } func TestClaudeResetEligibilityFailClosed(t *testing.T) { now := time.Now() diff --git a/frontend/src/api/admin/claudeResetCredits.ts b/frontend/src/api/admin/claudeResetCredits.ts index 68edb5afb9..f7011d05cc 100644 --- a/frontend/src/api/admin/claudeResetCredits.ts +++ b/frontend/src/api/admin/claudeResetCredits.ts @@ -1,7 +1,6 @@ import { apiClient } from '../client' export interface ClaudeResetCredit { - selection_token: string label: string resets_left: number starts_at?: string diff --git a/frontend/src/components/account/ClaudeResetCreditsCell.vue b/frontend/src/components/account/ClaudeResetCreditsCell.vue index 1fc6a4df85..4a139b1665 100644 --- a/frontend/src/components/account/ClaudeResetCreditsCell.vue +++ b/frontend/src/components/account/ClaudeResetCreditsCell.vue @@ -36,7 +36,7 @@ -
+
{{ t('admin.accounts.claudeResetCredits.ineligible') }} - - {{ t('admin.accounts.claudeResetCredits.cooldown', { time: formatTime(status.cooldown_until, 'short') }) }} + + {{ t('admin.accounts.claudeResetCredits.cooldown', { time: formatTime(status.cooldown_until!, 'short') }) }}
@@ -81,7 +81,7 @@ let generation = 0 const visible = computed(() => props.account.platform === 'anthropic' && props.account.type === 'oauth') -watch(() => props.account.id, () => { generation++; status.value = null; loading.value = false; error.value = false }) +watch(() => [props.account.id, props.account.platform, props.account.type], () => { generation++; status.value = null; loading.value = false; error.value = false }) // 与 OpenAIQuotaResetCell 的到期时间格式保持一致 const formatTime = (value: string, style: 'short' | 'full'): string => { @@ -98,7 +98,19 @@ const totalResets = computed(() => (status.value?.credits ?? []).reduce((sum, c) // 优先展示下一张会被使用的券(仅它可能 redeemable),否则取最早到期的一张 const primaryCredit = computed(() => { const credits = status.value?.credits ?? [] - return credits.find(c => c.redeemable) ?? [...credits].sort((a, b) => (a.expires_at ?? '9999').localeCompare(b.expires_at ?? '9999'))[0] ?? null + return credits.find(c => c.redeemable) ?? [...credits].sort((a, b) => expiryMs(a.expires_at) - expiryMs(b.expires_at))[0] ?? null +}) + +// 缺失或无法解析的到期时间排在最后 +function expiryMs(value?: string): number { + const ms = value ? new Date(value).getTime() : NaN + return Number.isNaN(ms) ? Number.POSITIVE_INFINITY : ms +} + +// 仅在冷却时间仍在未来时提示(后端已清理过期值,这里防御性再判断一次) +const cooldownActive = computed(() => { + const until = status.value?.cooldown_until + return !!until && new Date(until).getTime() > Date.now() }) const creditTitle = computed(() => { diff --git a/frontend/src/components/account/__tests__/ClaudeResetCreditsCell.spec.ts b/frontend/src/components/account/__tests__/ClaudeResetCreditsCell.spec.ts index 475c2f1032..81aca5a479 100644 --- a/frontend/src/components/account/__tests__/ClaudeResetCreditsCell.spec.ts +++ b/frontend/src/components/account/__tests__/ClaudeResetCreditsCell.spec.ts @@ -7,7 +7,7 @@ vi.mock('@/api/admin/claudeResetCredits', () => ({ getClaudeResetCredits: getCre vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) })) const account = { id: 1, platform: 'anthropic', type: 'oauth' } as Account const credit = { - selection_token: 'tok', label: 'Launch reset', resets_left: 1, + label: 'Launch reset', resets_left: 1, expires_at: '2026-10-22T16:00:00Z', clears: ['five_hour', 'seven_day'], percent_used: {}, blocking: [], use_requires_limit: false, redeemable: true } @@ -31,8 +31,8 @@ describe('Claude reset credit status', () => { }) it('counts held resets and prefers the next redeemable grant', async () => { - const later = { ...credit, selection_token: 'a', label: 'Later', expires_at: '2026-12-01T00:00:00Z', redeemable: false } - const next = { ...credit, selection_token: 'b', label: 'Next', expires_at: '2026-11-01T00:00:00Z', redeemable: true } + const later = { ...credit, label: 'Later', expires_at: '2026-12-01T00:00:00Z', redeemable: false } + const next = { ...credit, label: 'Next', expires_at: '2026-11-01T00:00:00Z', redeemable: true } getCredits.mockResolvedValue({ ...snapshot, available_count: 1, credits: [later, next] }) const wrapper = mount(ClaudeResetCreditsCell, { props: { account } }) await countButton(wrapper).trigger('click') @@ -72,4 +72,39 @@ describe('Claude reset credit status', () => { expect(wrapper.find('[data-testid="local-query"]').exists()).toBe(true) expect(wrapper.find('[data-testid="claude-reset-expiry"]').exists()).toBe(false) }) + + it('falls back to the earliest parsed expiry across offsets, invalid last', async () => { + const a = { ...credit, label: 'Tokyo', expires_at: '2026-11-01T08:00:00+09:00', redeemable: false } + const b = { ...credit, label: 'Chicago', expires_at: '2026-10-31T20:00:00-05:00', redeemable: false } + const bad = { ...credit, label: 'Bad', expires_at: 'not-a-date', redeemable: false } + const none = { ...credit, label: 'None', expires_at: undefined, redeemable: false } + getCredits.mockResolvedValue({ ...snapshot, available_count: 0, credits: [none, bad, b, a] }) + const wrapper = mount(ClaudeResetCreditsCell, { props: { account } }) + await countButton(wrapper).trigger('click') + await flushPromises() + expect(wrapper.get('[data-testid="claude-reset-expiry"]').attributes('title')).toContain('Tokyo') + expect(wrapper.get('[data-testid="claude-reset-expiry"]').attributes('title')).not.toContain('Chicago') + }) + + it('hides a cooldown hint that is already in the past', async () => { + getCredits.mockResolvedValue({ ...snapshot, cooldown_until: '2000-01-01T00:00:00Z' }) + const wrapper = mount(ClaudeResetCreditsCell, { props: { account } }) + await countButton(wrapper).trigger('click') + await flushPromises() + expect(wrapper.find('[data-testid="claude-reset-cooldown"]').exists()).toBe(false) + }) + + it('discards in-flight responses when the same account changes type', async () => { + let resolve!: (value: typeof snapshot) => void + getCredits.mockReturnValue(new Promise(r => { resolve = r })) + const wrapper = mount(ClaudeResetCreditsCell, { props: { account } }) + await countButton(wrapper).trigger('click') + await wrapper.setProps({ account: { ...account, type: 'setup-token' } }) + await wrapper.setProps({ account: { ...account } }) + resolve(snapshot) + await flushPromises() + expect(countButton(wrapper).text()).toBe('admin.accounts.claudeResetCredits.count') + expect(countButton(wrapper).attributes('disabled')).toBeUndefined() + expect(wrapper.find('[data-testid="claude-reset-expiry"]').exists()).toBe(false) + }) })