fix(accounts): consume reset credits without card details (#81)

Allow count-only reset inventories to request upstream card selection without a credit ID. Preserve confirmation and idempotent retry guards, and place the fallback action in the shared modal footer with balanced inventory spacing.

Co-authored-by: Yanshan Chen <16632048+chenyanshan@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Yanshan Chen
2026-09-14 15:47:32 +08:00
committed by GitHub
co-authored by Yanshan Chen Codex
parent d8bc91efce
commit c9be17778d
3 changed files with 84 additions and 16 deletions
@@ -146,6 +146,50 @@ async fn consume_should_send_exact_credit_and_redeem_request_id_without_retry()
);
}
#[tokio::test]
async fn count_only_credits_should_allow_upstream_selected_consumption() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/codex/rate-limit-reset-credits"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"available_count": 1,
"credits": []
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/api/codex/rate-limit-reset-credits/consume"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "code": "reset" })))
.expect(1)
.mount(&server)
.await;
let client = client(&server.uri());
let credits = client
.list_rate_limit_reset_credits(context())
.await
.expect("count-only reset credits");
assert_eq!(credits.available_count, 1);
assert!(credits.credits.is_empty());
let redeem_request_id =
Uuid::parse_str("8fbf302d-11df-4bd5-82e4-08e4b3df7874").expect("redeem request ID");
let result = client
.consume_rate_limit_reset_credit(context(), None, redeem_request_id)
.await
.expect("upstream-selected reset-credit consume");
assert_eq!(result.code, "reset");
let requests = server.received_requests().await.expect("received requests");
let request = requests
.iter()
.find(|request| request.method == Method::POST)
.expect("consume request");
assert_eq!(
serde_json::from_slice::<serde_json::Value>(&request.body).expect("consume JSON"),
json!({ "redeem_request_id": "8fbf302d-11df-4bd5-82e4-08e4b3df7874" })
);
}
#[tokio::test]
async fn consume_should_preserve_raw_upstream_error_and_never_retry() {
let server = MockServer::start().await;
@@ -60,12 +60,18 @@ const triggerLabel = computed(() => {
return hasSnapshot.value ? `查看主动重置卡,最近查询 ${availableCount.value} 张可用` : '查看主动重置卡'
})
const showTriggerCount = computed(() => hasSnapshot.value && availableCount.value > 0)
const confirmCreditTitle = computed(() => creditTitle(consumptionCredit.value))
const confirmCreditTitle = computed(() => consumptionCredit.value
? creditTitle(consumptionCredit.value)
: '使用一次重置(由上游选择)')
const creditItems = computed(() => availableCredits.value.map(credit => ({
id: credit.id,
title: creditTitle(credit),
expiry: expiryLabel(credit.expiresAt),
})))
const showCountOnlyAction = computed(() => !loadError.value
&& hasSnapshot.value
&& availableCount.value > 0
&& creditItems.value.length === 0)
const countLabel = computed(() => {
if (!hasSnapshot.value)
return loading.value ? '查询中' : '待查询'
@@ -159,7 +165,10 @@ function handleRequestConsume(creditId: string) {
</section>
<section class="overflow-hidden rounded-cp bg-cp-fill-quaternary" aria-label="使用限额重置">
<div class="flex items-center gap-3 pt-4 pr-3 pb-1 pl-4">
<div
class="flex items-center gap-3 pt-4 pr-3 pl-4"
:class="showCountOnlyAction ? 'pb-4' : 'pb-1'"
>
<h3 class="m-0 min-w-0 flex-1 text-cp-sm font-heavy text-cp-text">
使用限额重置
</h3>
@@ -227,7 +236,7 @@ function handleRequestConsume(creditId: string) {
</ul>
<BaseEmpty
v-else
v-else-if="!showCountOnlyAction"
:icon="TicketCheck"
size="sm"
surface="none"
@@ -238,12 +247,23 @@ function handleRequestConsume(creditId: string) {
</section>
</div>
<template v-if="showConfirm" #footer>
<BaseButton variant="secondary" :disabled="consuming" @click="cancelConsume">
返回
</BaseButton>
<BaseButton variant="primary" :loading="consuming" @click="confirmConsume">
{{ ambiguous ? '再次确认' : '确认重置' }}
<template v-if="showConfirm || showCountOnlyAction" #footer>
<template v-if="showConfirm">
<BaseButton variant="secondary" :disabled="consuming" @click="cancelConsume">
返回
</BaseButton>
<BaseButton variant="primary" :loading="consuming" :disabled="loading || !canRequestConsume" @click="confirmConsume">
{{ ambiguous ? '再次确认' : '确认重置' }}
</BaseButton>
</template>
<BaseButton
v-else
size="sm"
variant="primary"
:disabled="loading || consuming || ambiguous || !canRequestConsume"
@click="requestConsume"
>
使用一次重置(由上游选择)
</BaseButton>
</template>
</BaseModal>
@@ -12,8 +12,8 @@ import { errorMessage } from '@/utils/async'
interface PendingResetCreditOperation {
accountId: string
creditId: string
credit: AccountResetCredit
creditId?: string
credit?: AccountResetCredit
redeemRequestId: string
hasTransportFailure: boolean
}
@@ -104,17 +104,21 @@ export function useAccountResetCredits(options: {
availableCredits.value.find(credit => credit.id === selectedCreditId.value),
)
const consumptionCredit = computed(() =>
pendingOperation.value?.credit ?? selectedCredit.value,
pendingOperation.value ? pendingOperation.value.credit : selectedCredit.value,
)
const ambiguous = computed(() => pendingOperation.value?.hasTransportFailure === true)
const canStartConsume = computed(() =>
hasSnapshot.value && !loadError.value && availableCount.value > 0
&& (selectedCredit.value !== undefined || availableCredits.value.length === 0),
)
const canRequestConsume = computed(() =>
ambiguous.value || (availableCount.value > 0 && selectedCredit.value !== undefined),
ambiguous.value || canStartConsume.value,
)
function reconcileSelectedCredit() {
const operation = pendingOperation.value
if (operation) {
selectedCreditId.value = operation.creditId
selectedCreditId.value = operation.creditId ?? ''
return
}
@@ -163,10 +167,10 @@ export function useAccountResetCredits(options: {
const credit = selectedCredit.value
// 确认发送时才建立操作;仅打开或取消确认弹窗不占用账号的消费状态。
const operation = target.pendingOperation ?? (credit && availableCount.value > 0
const operation = target.pendingOperation ?? (canStartConsume.value
? {
accountId: target.accountId,
creditId: credit.id,
creditId: credit?.id,
credit,
redeemRequestId: generateRedeemRequestId(),
hasTransportFailure: false,