mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
show reset credit expiration
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@@ -11,6 +13,7 @@ import (
|
||||
"time"
|
||||
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/imroc/req/v3"
|
||||
)
|
||||
|
||||
// ErrSparkShadowResetNotSupported is returned when ResetCredit is called on a
|
||||
@@ -23,8 +26,10 @@ var ErrSparkShadowResetNotSupported = infraerrors.New(http.StatusConflict, "SPAR
|
||||
// Endpoints used by the OpenAI/ChatGPT/Codex quota query and reset feature.
|
||||
const (
|
||||
chatGPTUsageURL = "https://chatgpt.com/backend-api/wham/usage"
|
||||
chatGPTRateLimitCreditsURL = "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits"
|
||||
chatGPTRateLimitResetURL = "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume"
|
||||
openaiQuotaUpstreamTimeout = 20 * time.Second
|
||||
openaiQuotaCodexBeta = "codex-1"
|
||||
openaiQuotaCodexOriginator = "Codex Desktop"
|
||||
openaiQuotaCodexLanguageTag = "zh-CN"
|
||||
openaiQuotaSecFetchSite = "none"
|
||||
@@ -57,10 +62,17 @@ type OpenAIAdditionalRateLimit struct {
|
||||
RateLimit *OpenAIRateLimit `json:"rate_limit,omitempty"`
|
||||
}
|
||||
|
||||
// OpenAIRateLimitResetCreditDetail is the sanitized metadata surfaced for one
|
||||
// available reset credit. Do not add upstream ids or tokens here.
|
||||
type OpenAIRateLimitResetCreditDetail struct {
|
||||
ExpiresAt string `json:"expires_at,omitempty"`
|
||||
}
|
||||
|
||||
// OpenAIRateLimitResetCredits captures the "available_count" surfaced for the
|
||||
// rate_limit_reset_credit grant type, which the reset action consumes.
|
||||
type OpenAIRateLimitResetCredits struct {
|
||||
AvailableCount int `json:"available_count"`
|
||||
AvailableCount int `json:"available_count"`
|
||||
Credits []OpenAIRateLimitResetCreditDetail `json:"credits,omitempty"`
|
||||
}
|
||||
|
||||
// OpenAIQuotaUsage is the typed projection of /wham/usage we expose to the UI.
|
||||
@@ -159,9 +171,34 @@ func (s *OpenAIQuotaService) QueryUsage(ctx context.Context, accountID int64) (*
|
||||
}
|
||||
|
||||
payload.FetchedAt = time.Now().Unix()
|
||||
if payload.RateLimitResetCredits != nil && payload.RateLimitResetCredits.AvailableCount > 0 {
|
||||
payload.RateLimitResetCredits.Credits = s.queryResetCreditDetails(callCtx, client, accessToken, chatGPTAccountID, fedRAMP, accountID)
|
||||
}
|
||||
return &payload, nil
|
||||
}
|
||||
|
||||
func (s *OpenAIQuotaService) queryResetCreditDetails(ctx context.Context, client *req.Client, accessToken, chatGPTAccountID string, fedRAMP bool, accountID int64) []OpenAIRateLimitResetCreditDetail {
|
||||
resp, err := client.R().
|
||||
SetContext(ctx).
|
||||
SetHeaders(buildCodexCommonHeaders(accessToken, chatGPTAccountID, fedRAMP)).
|
||||
Get(chatGPTRateLimitCreditsURL)
|
||||
if err != nil {
|
||||
slog.Warn("openai_quota_reset_credit_details_failed", "account_id", accountID, "error", err)
|
||||
return nil
|
||||
}
|
||||
if !resp.IsSuccessState() {
|
||||
slog.Warn("openai_quota_reset_credit_details_failed", "account_id", accountID, "status", resp.StatusCode)
|
||||
return nil
|
||||
}
|
||||
|
||||
credits, err := parseOpenAIRateLimitResetCreditDetails(resp.Bytes())
|
||||
if err != nil {
|
||||
slog.Warn("openai_quota_reset_credit_details_parse_failed", "account_id", accountID, "error", err)
|
||||
return nil
|
||||
}
|
||||
return credits
|
||||
}
|
||||
|
||||
// ResetCredit consumes one rate_limit_reset_credit for the given OpenAI account.
|
||||
// The redeem_request_id is auto-generated (uuid-like) — upstream uses it for
|
||||
// idempotency. Returns the consumed credit metadata so the UI can refresh.
|
||||
@@ -306,6 +343,7 @@ func buildCodexCommonHeaders(accessToken, chatGPTAccountID string, fedRAMP bool)
|
||||
headers := map[string]string{
|
||||
"authorization": "Bearer " + accessToken,
|
||||
"chatgpt-account-id": chatGPTAccountID,
|
||||
"openai-beta": openaiQuotaCodexBeta,
|
||||
"oai-language": openaiQuotaCodexLanguageTag,
|
||||
"originator": openaiQuotaCodexOriginator,
|
||||
"accept": "application/json",
|
||||
@@ -334,6 +372,65 @@ func generateRedeemRequestID() (string, error) {
|
||||
return fmt.Sprintf("%s-%s-%s-%s-%s", hexStr[0:8], hexStr[8:12], hexStr[12:16], hexStr[16:20], hexStr[20:]), nil
|
||||
}
|
||||
|
||||
type openAIRateLimitResetCreditDetailPayload struct {
|
||||
ExpiresAt string `json:"expires_at,omitempty"`
|
||||
ExpiresAtCamel string `json:"expiresAt,omitempty"`
|
||||
}
|
||||
|
||||
type openAIRateLimitResetCreditDetailsPayload struct {
|
||||
Credits []openAIRateLimitResetCreditDetailPayload `json:"credits,omitempty"`
|
||||
RateLimitResetCredits []openAIRateLimitResetCreditDetailPayload `json:"rate_limit_reset_credits,omitempty"`
|
||||
Items []openAIRateLimitResetCreditDetailPayload `json:"items,omitempty"`
|
||||
Data []openAIRateLimitResetCreditDetailPayload `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
func parseOpenAIRateLimitResetCreditDetails(body []byte) ([]OpenAIRateLimitResetCreditDetail, error) {
|
||||
trimmed := bytes.TrimSpace(body)
|
||||
if len(trimmed) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var rawCredits []openAIRateLimitResetCreditDetailPayload
|
||||
if trimmed[0] == '[' {
|
||||
if err := json.Unmarshal(trimmed, &rawCredits); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
var payload openAIRateLimitResetCreditDetailsPayload
|
||||
if err := json.Unmarshal(trimmed, &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rawCredits = firstNonEmptyResetCreditPayload(
|
||||
payload.Credits,
|
||||
payload.RateLimitResetCredits,
|
||||
payload.Items,
|
||||
payload.Data,
|
||||
)
|
||||
}
|
||||
|
||||
credits := make([]OpenAIRateLimitResetCreditDetail, 0, len(rawCredits))
|
||||
for _, raw := range rawCredits {
|
||||
expiresAt := strings.TrimSpace(raw.ExpiresAt)
|
||||
if expiresAt == "" {
|
||||
expiresAt = strings.TrimSpace(raw.ExpiresAtCamel)
|
||||
}
|
||||
if expiresAt == "" {
|
||||
continue
|
||||
}
|
||||
credits = append(credits, OpenAIRateLimitResetCreditDetail{ExpiresAt: expiresAt})
|
||||
}
|
||||
return credits, nil
|
||||
}
|
||||
|
||||
func firstNonEmptyResetCreditPayload(lists ...[]openAIRateLimitResetCreditDetailPayload) []openAIRateLimitResetCreditDetailPayload {
|
||||
for _, list := range lists {
|
||||
if len(list) > 0 {
|
||||
return list
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildCodexSparkWindowExtraUpdates extracts Codex Spark usage windows from the
|
||||
// /wham/usage response body's additional_rate_limits, matching the entry with
|
||||
// MeteredFeature == "codex_bengalfox". It produces plain codex_* keys (NOT the
|
||||
|
||||
@@ -214,6 +214,154 @@ func TestPrepareUpstreamCallShadowResolve(t *testing.T) {
|
||||
"prepareUpstreamCall should use parent's chatgpt_account_id after shadow resolve")
|
||||
}
|
||||
|
||||
func TestParseOpenAIRateLimitResetCreditDetails_CompatibleContainers(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "credits",
|
||||
body: `{"credits":[{"id":"secret-id","expires_at":"2026-07-03T04:05:06Z"}]}`,
|
||||
want: []string{"2026-07-03T04:05:06Z"},
|
||||
},
|
||||
{
|
||||
name: "rate limit reset credits",
|
||||
body: `{"rate_limit_reset_credits":[{"expiresAt":"2026-07-04T04:05:06Z"}]}`,
|
||||
want: []string{"2026-07-04T04:05:06Z"},
|
||||
},
|
||||
{
|
||||
name: "items",
|
||||
body: `{"items":[{"expires_at":"2026-07-05T04:05:06Z"}]}`,
|
||||
want: []string{"2026-07-05T04:05:06Z"},
|
||||
},
|
||||
{
|
||||
name: "data",
|
||||
body: `{"data":[{"expires_at":"2026-07-06T04:05:06Z"}]}`,
|
||||
want: []string{"2026-07-06T04:05:06Z"},
|
||||
},
|
||||
{
|
||||
name: "array",
|
||||
body: `[{"expires_at":"2026-07-07T04:05:06Z"}]`,
|
||||
want: []string{"2026-07-07T04:05:06Z"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := parseOpenAIRateLimitResetCreditDetails([]byte(tt.body))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, got, len(tt.want))
|
||||
for i := range tt.want {
|
||||
require.Equal(t, tt.want[i], got[i].ExpiresAt)
|
||||
}
|
||||
encoded, err := json.Marshal(got)
|
||||
require.NoError(t, err)
|
||||
require.NotContains(t, string(encoded), "secret-id")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryUsageIncludesResetCreditExpirations_EndToEnd(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
account := &Account{
|
||||
ID: 100,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Status: StatusActive,
|
||||
Credentials: map[string]any{
|
||||
"chatgpt_account_id": "org-parent123",
|
||||
},
|
||||
}
|
||||
repo := &stubQuotaAccountRepo{accounts: map[int64]*Account{100: account}}
|
||||
tokenCache := &stubQuotaTokenCache{tokens: map[string]string{
|
||||
OpenAITokenCacheKey(account): "fake-token",
|
||||
}}
|
||||
tokenProvider := NewOpenAITokenProvider(repo, tokenCache, nil)
|
||||
|
||||
var capturedBeta string
|
||||
var detailCalls int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("content-type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/backend-api/wham/usage":
|
||||
_ = json.NewEncoder(w).Encode(OpenAIQuotaUsage{
|
||||
RateLimitResetCredits: &OpenAIRateLimitResetCredits{AvailableCount: 2},
|
||||
})
|
||||
case "/backend-api/wham/rate-limit-reset-credits":
|
||||
detailCalls++
|
||||
capturedBeta = r.Header.Get("OpenAI-Beta")
|
||||
require.Equal(t, "org-parent123", r.Header.Get("ChatGPT-Account-ID"))
|
||||
_, _ = w.Write([]byte(`{"credits":[{"id":"secret-credit-id","expires_at":"2026-07-03T04:05:06Z"},{"expiresAt":"2026-07-04T04:05:06Z"}]}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
svc := NewOpenAIQuotaService(repo, nil, tokenProvider, newQuotaRedirectingFactory(srv))
|
||||
usage, err := svc.QueryUsage(ctx, 100)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, usage)
|
||||
require.NotNil(t, usage.RateLimitResetCredits)
|
||||
require.Equal(t, 2, usage.RateLimitResetCredits.AvailableCount)
|
||||
require.Equal(t, 1, detailCalls)
|
||||
require.Equal(t, openaiQuotaCodexBeta, capturedBeta)
|
||||
require.Equal(t, []OpenAIRateLimitResetCreditDetail{
|
||||
{ExpiresAt: "2026-07-03T04:05:06Z"},
|
||||
{ExpiresAt: "2026-07-04T04:05:06Z"},
|
||||
}, usage.RateLimitResetCredits.Credits)
|
||||
|
||||
encoded, err := json.Marshal(usage)
|
||||
require.NoError(t, err)
|
||||
require.NotContains(t, string(encoded), "secret-credit-id")
|
||||
}
|
||||
|
||||
func TestQueryUsageResetCreditDetails401NonFatal(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
account := &Account{
|
||||
ID: 100,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Status: StatusActive,
|
||||
Credentials: map[string]any{
|
||||
"chatgpt_account_id": "org-parent123",
|
||||
},
|
||||
}
|
||||
repo := &stubQuotaAccountRepo{accounts: map[int64]*Account{100: account}}
|
||||
tokenCache := &stubQuotaTokenCache{tokens: map[string]string{
|
||||
OpenAITokenCacheKey(account): "fake-token",
|
||||
}}
|
||||
tokenProvider := NewOpenAITokenProvider(repo, tokenCache, nil)
|
||||
|
||||
var detailCalls int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("content-type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/backend-api/wham/usage":
|
||||
_ = json.NewEncoder(w).Encode(OpenAIQuotaUsage{
|
||||
RateLimitResetCredits: &OpenAIRateLimitResetCredits{AvailableCount: 1},
|
||||
})
|
||||
case "/backend-api/wham/rate-limit-reset-credits":
|
||||
detailCalls++
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`{"error":"unauthorized","id":"secret-error-id"}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
svc := NewOpenAIQuotaService(repo, nil, tokenProvider, newQuotaRedirectingFactory(srv))
|
||||
usage, err := svc.QueryUsage(ctx, 100)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, usage)
|
||||
require.NotNil(t, usage.RateLimitResetCredits)
|
||||
require.Equal(t, 1, usage.RateLimitResetCredits.AvailableCount)
|
||||
require.Equal(t, 1, detailCalls)
|
||||
require.Empty(t, usage.RateLimitResetCredits.Credits)
|
||||
}
|
||||
|
||||
// TestResetCreditGetByIDError_FailsClosed 验证守卫「失败关闭」语义:
|
||||
// 当守卫的 GetByID 发生瞬时错误时,ResetCredit 必须立即返回该错误,
|
||||
// 不得旁路进入 prepareUpstreamCall(否则影子账号会借 resolve 路径操作母账号)。
|
||||
|
||||
@@ -734,8 +734,13 @@ export interface OpenAIAdditionalRateLimit {
|
||||
rate_limit?: OpenAIRateLimit | null
|
||||
}
|
||||
|
||||
export interface OpenAIRateLimitResetCreditDetail {
|
||||
expires_at?: string
|
||||
}
|
||||
|
||||
export interface OpenAIRateLimitResetCredits {
|
||||
available_count: number
|
||||
credits?: OpenAIRateLimitResetCreditDetail[]
|
||||
}
|
||||
|
||||
export interface OpenAIQuotaUsage {
|
||||
|
||||
@@ -63,6 +63,46 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="primaryResetCreditExpiry" class="space-y-1">
|
||||
<div class="flex flex-wrap items-center gap-1">
|
||||
<span
|
||||
class="inline-flex max-w-full items-center rounded bg-gray-100 px-1.5 py-0.5 text-[10px] leading-4 text-gray-600 tabular-nums dark:bg-gray-800 dark:text-gray-300"
|
||||
:title="t('admin.accounts.openaiQuotaReset.expiresAtFull', { time: formatResetCreditExpiry(primaryResetCreditExpiry, 'full') })"
|
||||
>
|
||||
{{ t('admin.accounts.openaiQuotaReset.expiresAt', { time: formatResetCreditExpiry(primaryResetCreditExpiry, 'short') }) }}
|
||||
</span>
|
||||
<button
|
||||
v-if="hiddenResetCreditCount > 0"
|
||||
type="button"
|
||||
data-testid="reset-credit-expiry-toggle"
|
||||
class="inline-flex items-center rounded-full bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium leading-4 text-gray-600 transition-colors hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
:aria-expanded="showResetCreditDetails"
|
||||
:aria-label="resetCreditDetailsToggleLabel"
|
||||
:title="resetCreditDetailsTitle"
|
||||
@click="toggleResetCreditDetails"
|
||||
>
|
||||
+{{ hiddenResetCreditCount }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showResetCreditDetails && resetCreditExpirations.length > 1"
|
||||
data-testid="reset-credit-expiry-details"
|
||||
class="inline-grid max-w-full gap-0.5 rounded border border-gray-200 bg-white px-1.5 py-1 text-[10px] leading-4 text-gray-600 shadow-sm dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300"
|
||||
>
|
||||
<span class="sr-only">{{ t('admin.accounts.openaiQuotaReset.expirationDetails') }}</span>
|
||||
<span
|
||||
v-for="(expiresAt, index) in resetCreditExpirations"
|
||||
:key="`${expiresAt}-${index}`"
|
||||
class="flex min-w-0 items-center gap-1 tabular-nums"
|
||||
:title="t('admin.accounts.openaiQuotaReset.expiresAtFull', { time: formatResetCreditExpiry(expiresAt, 'full') })"
|
||||
>
|
||||
<span class="h-1 w-1 shrink-0 rounded-full bg-gray-400 dark:bg-gray-500" />
|
||||
<span class="truncate">{{ formatResetCreditExpiry(expiresAt, 'short') }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error / success feedback -->
|
||||
<div
|
||||
v-if="error"
|
||||
@@ -118,14 +158,36 @@ const error = ref<string | null>(null)
|
||||
const data = ref<OpenAIQuotaUsage | null>(null)
|
||||
const resetMessage = ref<string | null>(null)
|
||||
const showResetConfirm = ref(false)
|
||||
const showResetCreditDetails = ref(false)
|
||||
|
||||
// 影子账号的额度查询会 resolve 到母账号,但影子本身不支持重置(后端返回 409);
|
||||
// 重置必须在母账号上进行。前端据此禁用影子的重置入口(外审 F6)。
|
||||
const isShadow = computed(() => props.account.parent_account_id != null)
|
||||
|
||||
const availableResetCount = computed(() => data.value?.rate_limit_reset_credits?.available_count ?? 0)
|
||||
const resetCreditExpirations = computed(() =>
|
||||
(data.value?.rate_limit_reset_credits?.credits ?? [])
|
||||
.map((credit) => credit.expires_at?.trim() ?? '')
|
||||
.filter((expiresAt) => expiresAt.length > 0)
|
||||
.sort(compareResetCreditExpiry)
|
||||
)
|
||||
const primaryResetCreditExpiry = computed(() => resetCreditExpirations.value[0] ?? '')
|
||||
const hiddenResetCreditCount = computed(() => Math.max(resetCreditExpirations.value.length - 1, 0))
|
||||
const canReset = computed(() => availableResetCount.value > 0 && !isShadow.value)
|
||||
|
||||
const resetCreditDetailsTitle = computed(() =>
|
||||
resetCreditExpirations.value
|
||||
.map((expiresAt) => formatResetCreditExpiry(expiresAt, 'full'))
|
||||
.join('\n')
|
||||
)
|
||||
|
||||
const resetCreditDetailsToggleLabel = computed(() => {
|
||||
if (showResetCreditDetails.value) {
|
||||
return t('admin.accounts.openaiQuotaReset.collapseExpirations')
|
||||
}
|
||||
return t('admin.accounts.openaiQuotaReset.expandExpirations', { count: hiddenResetCreditCount.value })
|
||||
})
|
||||
|
||||
const resetButtonTitle = computed(() => {
|
||||
if (isShadow.value) return t('admin.accounts.openaiQuotaReset.resetTooltipShadow')
|
||||
if (!data.value) return t('admin.accounts.openaiQuotaReset.resetTooltipNeedQuery')
|
||||
@@ -145,6 +207,34 @@ const truncatedError = computed(() => {
|
||||
return error.value.length > 80 ? `${error.value.slice(0, 80)}…` : error.value
|
||||
})
|
||||
|
||||
const getResetCreditExpiryTime = (value: string): number => {
|
||||
const time = new Date(value).getTime()
|
||||
return Number.isNaN(time) ? Number.POSITIVE_INFINITY : time
|
||||
}
|
||||
|
||||
const compareResetCreditExpiry = (a: string, b: string): number => {
|
||||
const diff = getResetCreditExpiryTime(a) - getResetCreditExpiryTime(b)
|
||||
if (diff !== 0) return diff
|
||||
return a.localeCompare(b)
|
||||
}
|
||||
|
||||
const formatResetCreditExpiry = (value: string, style: 'short' | 'full'): string => {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return value
|
||||
|
||||
const options: Intl.DateTimeFormatOptions = {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}
|
||||
if (style === 'full') {
|
||||
options.year = 'numeric'
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat(undefined, options).format(date)
|
||||
}
|
||||
|
||||
const extractErrorMessage = (e: unknown): string => {
|
||||
// The project's axios response interceptor (api/client.ts) flattens server
|
||||
// errors into { status, code, message, reason, ... } and re-rejects them, so
|
||||
@@ -165,11 +255,17 @@ const extractErrorMessage = (e: unknown): string => {
|
||||
)
|
||||
}
|
||||
|
||||
const toggleResetCreditDetails = () => {
|
||||
if (hiddenResetCreditCount.value <= 0) return
|
||||
showResetCreditDetails.value = !showResetCreditDetails.value
|
||||
}
|
||||
|
||||
const handleQuery = async () => {
|
||||
if (loading.value) return
|
||||
loading.value = true
|
||||
error.value = null
|
||||
resetMessage.value = null
|
||||
showResetCreditDetails.value = false
|
||||
try {
|
||||
data.value = await queryOpenAIQuota(props.account.id)
|
||||
} catch (e) {
|
||||
@@ -224,6 +320,16 @@ watch(
|
||||
loading.value = false
|
||||
resetting.value = false
|
||||
showResetConfirm.value = false
|
||||
showResetCreditDetails.value = false
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
resetCreditExpirations,
|
||||
() => {
|
||||
if (hiddenResetCreditCount.value <= 0) {
|
||||
showResetCreditDetails.value = false
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
+75
-3
@@ -1,13 +1,22 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import OpenAIQuotaResetCell from '../OpenAIQuotaResetCell.vue'
|
||||
import type { Account } from '@/types'
|
||||
import { queryOpenAIQuota } from '@/api/admin/accounts'
|
||||
|
||||
vi.mock('@/api/admin/accounts', () => ({
|
||||
queryOpenAIQuota: vi.fn(),
|
||||
resetOpenAIQuota: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', async () => {
|
||||
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
useI18n: () => ({
|
||||
t: (key: string, params?: Record<string, unknown>) =>
|
||||
params?.time ? `${key}:${params.time}` : params?.count ? `${key}:${params.count}` : key,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -44,6 +53,10 @@ function makeAccount(overrides: Partial<Account>): Account {
|
||||
const resetButton = (wrapper: ReturnType<typeof mount>) =>
|
||||
wrapper.findAll('button')[1]
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(queryOpenAIQuota).mockReset()
|
||||
})
|
||||
|
||||
describe('OpenAIQuotaResetCell — 外审 F6:影子禁用重置', () => {
|
||||
it('影子账号(parent_account_id 非空)的 reset 按钮被禁用且提示在母账号重置', () => {
|
||||
const account = makeAccount({ parent_account_id: 100 })
|
||||
@@ -64,4 +77,63 @@ describe('OpenAIQuotaResetCell — 外审 F6:影子禁用重置', () => {
|
||||
expect(btn.attributes('title')).toBe('admin.accounts.openaiQuotaReset.resetTooltipNeedQuery')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('查询后默认折叠为最早到期时间,点击 +N 展开完整列表', async () => {
|
||||
vi.mocked(queryOpenAIQuota).mockResolvedValue({
|
||||
rate_limit_reset_credits: {
|
||||
available_count: 3,
|
||||
credits: [
|
||||
{ expires_at: '2026-07-05T04:05:06Z' },
|
||||
{ expires_at: '2026-07-03T04:05:06Z' },
|
||||
{ expires_at: 'not-a-date' },
|
||||
],
|
||||
},
|
||||
fetched_at: 1770000000,
|
||||
})
|
||||
|
||||
const account = makeAccount({ parent_account_id: null })
|
||||
const wrapper = mount(OpenAIQuotaResetCell, { props: { account } })
|
||||
|
||||
await wrapper.findAll('button')[0].trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(queryOpenAIQuota).toHaveBeenCalledWith(1)
|
||||
expect(wrapper.text()).toContain('admin.accounts.openaiQuotaReset.expiresAt:')
|
||||
expect(wrapper.text()).toContain('+2')
|
||||
expect(wrapper.text()).not.toContain('not-a-date')
|
||||
|
||||
const toggle = wrapper.find('[data-testid="reset-credit-expiry-toggle"]')
|
||||
expect(toggle.exists()).toBe(true)
|
||||
expect(toggle.attributes('aria-expanded')).toBe('false')
|
||||
await toggle.trigger('click')
|
||||
|
||||
expect(toggle.attributes('aria-expanded')).toBe('true')
|
||||
expect(wrapper.find('[data-testid="reset-credit-expiry-details"]').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('not-a-date')
|
||||
expect(wrapper.text()).not.toContain('undefined')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('只有一张重置卡时不显示展开按钮', async () => {
|
||||
vi.mocked(queryOpenAIQuota).mockResolvedValue({
|
||||
rate_limit_reset_credits: {
|
||||
available_count: 1,
|
||||
credits: [
|
||||
{ expires_at: '2026-07-03T04:05:06Z' },
|
||||
],
|
||||
},
|
||||
fetched_at: 1770000000,
|
||||
})
|
||||
|
||||
const account = makeAccount({ parent_account_id: null })
|
||||
const wrapper = mount(OpenAIQuotaResetCell, { props: { account } })
|
||||
|
||||
await wrapper.findAll('button')[0].trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('[data-testid="reset-credit-expiry-toggle"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-testid="reset-credit-expiry-details"]').exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain('admin.accounts.openaiQuotaReset.expiresAt:')
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4251,6 +4251,11 @@ export default {
|
||||
resetTooltipNeedQuery: 'Click Credits first to load the available count',
|
||||
resetTooltipNoCredits: 'No reset credits available',
|
||||
resetTooltipShadow: 'Spark shadow accounts cannot reset credits; reset on the parent account',
|
||||
expiresAt: 'Expires {time}',
|
||||
expiresAtFull: 'Reset credit expires at {time}',
|
||||
expandExpirations: 'Expand the other {count} reset credit expiration(s)',
|
||||
collapseExpirations: 'Collapse reset credit expirations',
|
||||
expirationDetails: 'Reset credit expiration details',
|
||||
noCreditsAvailable: 'No reset credits available',
|
||||
resetSuccess: 'Reset {windows} window(s)',
|
||||
confirmTitle: 'Confirm Weekly Limit Reset',
|
||||
|
||||
@@ -3488,6 +3488,11 @@ export default {
|
||||
resetTooltipNeedQuery: '先点击「次数」加载剩余重置次数',
|
||||
resetTooltipNoCredits: '没有可用的重置次数',
|
||||
resetTooltipShadow: 'Spark 影子账号不能重置次数;请在母账号上重置',
|
||||
expiresAt: '到期 {time}',
|
||||
expiresAtFull: '重置次数到期时间: {time}',
|
||||
expandExpirations: '展开其余 {count} 张重置次数到期时间',
|
||||
collapseExpirations: '收起重置次数到期时间',
|
||||
expirationDetails: '重置次数到期明细',
|
||||
noCreditsAvailable: '没有可用的重置次数',
|
||||
resetSuccess: '已重置 {windows} 个窗口',
|
||||
confirmTitle: '确认重置周限',
|
||||
|
||||
Reference in New Issue
Block a user