mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 20:12:39 +08:00
fix(claude): harden reset credit projection and cell state (review R2)
- Drop selection_token (unsalted hash of a short grant ID, reversible) and claudeGrantSelection from the read-only DTO; assert it never serializes. - query() returns only the projected result; no raw upstream block escapes. - Clear a cooldown_until that is already past, using the same `now` as the redeemable check; the cell also shows the cooldown only when in the future. - Invalidate in-flight queries when account id, platform or type changes. - Sort the earliest-expiry fallback by parsed timestamp, invalid/missing last. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
35232c3949
commit
860782e6fb
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { apiClient } from '../client'
|
||||
|
||||
export interface ClaudeResetCredit {
|
||||
selection_token: string
|
||||
label: string
|
||||
resets_left: number
|
||||
starts_at?: string
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="visible && status && (primaryCredit || !status.eligible || status.cooldown_until)" class="flex flex-wrap items-center gap-1">
|
||||
<div v-if="visible && status && (primaryCredit || !status.eligible || cooldownActive)" class="flex flex-wrap items-center gap-1">
|
||||
<span
|
||||
v-if="primaryCredit?.expires_at"
|
||||
data-testid="claude-reset-expiry"
|
||||
@@ -55,8 +55,8 @@
|
||||
<span v-if="!status.eligible" class="text-[10px] text-amber-600 dark:text-amber-400">
|
||||
{{ t('admin.accounts.claudeResetCredits.ineligible') }}
|
||||
</span>
|
||||
<span v-if="status.cooldown_until" class="text-[10px] text-amber-600 dark:text-amber-400">
|
||||
{{ t('admin.accounts.claudeResetCredits.cooldown', { time: formatTime(status.cooldown_until, 'short') }) }}
|
||||
<span v-if="cooldownActive" data-testid="claude-reset-cooldown" class="text-[10px] text-amber-600 dark:text-amber-400">
|
||||
{{ t('admin.accounts.claudeResetCredits.cooldown', { time: formatTime(status.cooldown_until!, 'short') }) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user