From 32bf85e1065e418fba162afc7e6dffa80650b501 Mon Sep 17 00:00:00 2001 From: kfjie Date: Wed, 28 Oct 2026 10:54:20 +0000 Subject: [PATCH] feat: add transactional expiring balance credits --- apps/web/features/admin/api/billing-types.ts | 9 + apps/web/features/admin/api/billing.ts | 14 + .../billing-redemption-records-dialog.tsx | 5 +- .../sections/billing/billing-redemption.tsx | 26 +- .../sections/logs/logs-detail-sheet.tsx | 4 + .../sections/logs/logs-redemption-records.tsx | 5 +- .../sections/users/users-editor.tsx | 27 ++ .../use-admin-billing-redemption-codes.ts | 6 + .../admin/hooks/use-admin-users-page.ts | 46 ++- apps/web/features/admin/types/users.ts | 2 + .../i18n/messages/en-US/admin-billing.json | 5 + apps/web/i18n/messages/en-US/admin-users.json | 5 + apps/web/i18n/messages/en-US/errors.json | 2 + .../i18n/messages/zh-CN/admin-billing.json | 5 + apps/web/i18n/messages/zh-CN/admin-users.json | 5 + apps/web/i18n/messages/zh-CN/errors.json | 2 + backend/docs/docs.go | 202 ++++++++++- backend/docs/swagger.json | 202 ++++++++++- backend/docs/swagger.yaml | 143 +++++++- backend/internal/app/app.go | 2 + backend/internal/application/billing/errs.go | 4 + .../internal/application/billing/service.go | 13 +- .../billing/service_expiring_credit.go | 105 ++++++ .../service_expiring_credit_errors_test.go | 45 +++ .../billing/service_expiring_credit_test.go | 101 ++++++ .../application/billing/service_redemption.go | 145 +++++--- backend/internal/domain/billing/types.go | 66 +++- .../infra/persistence/models/billing.go | 53 ++- .../persistence/models/table_names_test.go | 1 + .../expiring_credit_regression_test.go | 220 ++++++++++++ .../billing/expiring_credit_review_test.go | 64 ++++ .../postgres/billing/repository.go | 169 ++++++--- .../billing/repository_expiring_credit.go | 292 ++++++++++++++++ ...epository_expiring_credit_postgres_test.go | 154 +++++++++ .../repository_expiring_credit_test.go | 320 ++++++++++++++++++ .../repository_postgres_integration_test.go | 2 +- .../billing/repository_reservation.go | 12 +- .../billing/repository_sqlite_test.go | 2 +- .../infra/persistence/schema/schema.go | 1 + backend/internal/repository/billing.go | 15 + backend/internal/transport/http/admin/dto.go | 80 ++--- .../internal/transport/http/billing/dto.go | 168 ++++++--- .../transport/http/billing/handler.go | 78 ++++- .../billing/handler_expiring_credit_test.go | 59 ++++ .../internal/transport/http/billing/router.go | 1 + .../transport/http/feature_gate_test.go | 1 + docs/EXPIRING_CREDITS.md | 78 +++++ packages/api-contract/src/types.generated.ts | 73 +++- 48 files changed, 2777 insertions(+), 262 deletions(-) create mode 100644 backend/internal/application/billing/service_expiring_credit.go create mode 100644 backend/internal/application/billing/service_expiring_credit_errors_test.go create mode 100644 backend/internal/application/billing/service_expiring_credit_test.go create mode 100644 backend/internal/infra/persistence/postgres/billing/expiring_credit_regression_test.go create mode 100644 backend/internal/infra/persistence/postgres/billing/expiring_credit_review_test.go create mode 100644 backend/internal/infra/persistence/postgres/billing/repository_expiring_credit.go create mode 100644 backend/internal/infra/persistence/postgres/billing/repository_expiring_credit_postgres_test.go create mode 100644 backend/internal/infra/persistence/postgres/billing/repository_expiring_credit_test.go create mode 100644 backend/internal/transport/http/billing/handler_expiring_credit_test.go create mode 100644 docs/EXPIRING_CREDITS.md diff --git a/apps/web/features/admin/api/billing-types.ts b/apps/web/features/admin/api/billing-types.ts index 9129c5876..5b1767aca 100644 --- a/apps/web/features/admin/api/billing-types.ts +++ b/apps/web/features/admin/api/billing-types.ts @@ -11,6 +11,9 @@ import type { BillingPlanResponse, BillingPriceResponse, CreateRedemptionCodeRequest, + ExpiringCreditGrantDataResponse, + ExpiringCreditResponse, + GrantExpiringCreditRequest, ModelPricingDataResponse, ModelPricingResponse, NativeToolPricingResponse, @@ -85,6 +88,12 @@ export type AdminBillingAccountData = Omit { + return authedRequest( + `/api/v1/admin/billing/accounts/${userID}/expiring-credits`, + { method: "POST", accessToken, body: payload }, + true, + ); +} + export async function listAdminRedemptionCodes( accessToken: string, options: ListAdminRedemptionCodeOptions = {}, diff --git a/apps/web/features/admin/components/sections/billing/billing-redemption-records-dialog.tsx b/apps/web/features/admin/components/sections/billing/billing-redemption-records-dialog.tsx index 0db1b72f9..d802023d2 100644 --- a/apps/web/features/admin/components/sections/billing/billing-redemption-records-dialog.tsx +++ b/apps/web/features/admin/components/sections/billing/billing-redemption-records-dialog.tsx @@ -39,7 +39,10 @@ function RedemptionRecordsDialogBody({ code }: { code: AdminRedemptionCodeDTO }) ? `${planName} · ${tLogs("redemptions.rewards.durationDays", { count: item.durationDays })}` : planName; } - return `${tLogs("redemptions.rewards.balance")} +${formatCreditUSD(item.creditUSD)}`; + const duration = item.balanceDurationSeconds > 0 + ? ` · ${tLogs("redemptions.rewards.durationDays", { count: item.balanceDurationSeconds / 86400 })}` + : ""; + return `${tLogs("redemptions.rewards.balance")} +${formatCreditUSD(item.creditUSD)}${duration}`; }; return ( diff --git a/apps/web/features/admin/components/sections/billing/billing-redemption.tsx b/apps/web/features/admin/components/sections/billing/billing-redemption.tsx index 23c21b7b4..10b5cdf3c 100644 --- a/apps/web/features/admin/components/sections/billing/billing-redemption.tsx +++ b/apps/web/features/admin/components/sections/billing/billing-redemption.tsx @@ -51,6 +51,7 @@ function createRedemptionFormState(mode: AdminBillingMode, planID = ""): Redempt creditUSD: "20", planID, durationDays: "30", + balanceDurationDays: "", maxRedemptions: "1", perUserLimit: "1", expiresAt: "", @@ -72,6 +73,7 @@ function redemptionFormFromCode(item: AdminRedemptionCodeDTO): RedemptionFormSta creditUSD: String(item.creditUSD || 0), planID: item.planID ? String(item.planID) : "", durationDays: String(item.durationDays || 0), + balanceDurationDays: item.balanceDurationSeconds > 0 ? String(item.balanceDurationSeconds / 86400) : "", maxRedemptions: item.maxRedemptions == null ? "" : String(item.maxRedemptions), perUserLimit: String(item.perUserLimit || 1), expiresAt: redemptionExpiresFormValue(item.expiresAt), @@ -250,6 +252,9 @@ export function BillingRedemptionSection({ plans, billingMode, loading }: Billin const planLabel = planNameByID.get(item.planID) || t("redemption.unknownPlan"); return t("redemption.periodReward", { plan: planLabel, days: item.durationDays || 0 }); } + if (item.balanceDurationSeconds > 0) { + return t("redemption.usageRewardExpiring", { amount: formatCreditUSD(item.creditUSD), days: item.balanceDurationSeconds / 86400 }); + } return t("redemption.usageReward", { amount: formatCreditUSD(item.creditUSD) }); } @@ -597,8 +602,7 @@ export function BillingRedemptionSection({ plans, billingMode, loading }: Billin
void saveRedemptionCode(event)} className="flex min-h-0 flex-1 flex-col">
{!redemptionDialogForm.id ? ( -
-
+

{t("redemption.code")}

)} + {redemptionDialogForm.mode === "usage" ? ( +
+

{t("redemption.creditDurationDays")}

+ setRedemptionForm((current) => current ? { ...current, balanceDurationDays: event.target.value } : current)} + /> +

{t("redemption.creditDurationHint")}

+
+ ) : null} +

{t("redemption.maxRedemptions")}

diff --git a/apps/web/features/admin/components/sections/logs/logs-detail-sheet.tsx b/apps/web/features/admin/components/sections/logs/logs-detail-sheet.tsx index 6a9f365d9..f38463a00 100644 --- a/apps/web/features/admin/components/sections/logs/logs-detail-sheet.tsx +++ b/apps/web/features/admin/components/sections/logs/logs-detail-sheet.tsx @@ -336,6 +336,7 @@ export function LogDetailSheet({ + @@ -386,6 +387,9 @@ export function LogDetailSheet({ ) : ( <> + {detail.item.balanceDurationSeconds > 0 ? ( + + ) : null} 0 + ? ` · ${t("redemptions.rewards.durationDays", { count: item.balanceDurationSeconds / 86400 })}` + : ""; + return `${t("redemptions.rewards.balance")} +${formatTooltipUsageCost(item.creditUSD, billingDisplay)}${duration}`; }, [billingDisplay, t], ); diff --git a/apps/web/features/admin/components/sections/users/users-editor.tsx b/apps/web/features/admin/components/sections/users/users-editor.tsx index 2557e4ed5..515b6345f 100644 --- a/apps/web/features/admin/components/sections/users/users-editor.tsx +++ b/apps/web/features/admin/components/sections/users/users-editor.tsx @@ -647,6 +647,33 @@ export function EditUserSheet({
+
+
+ + setEditPayload((current) => ({ ...current, expiringCreditUSD: event.target.value }))} + disabled={pending} + /> +
+
+ + setEditPayload((current) => ({ ...current, expiringCreditDurationDays: event.target.value }))} + disabled={pending} + /> +
+

{t("editor.expiringCreditHelp")}

+
) : null} diff --git a/apps/web/features/admin/hooks/use-admin-billing-redemption-codes.ts b/apps/web/features/admin/hooks/use-admin-billing-redemption-codes.ts index f4b5c2ca1..d35e2e5d5 100644 --- a/apps/web/features/admin/hooks/use-admin-billing-redemption-codes.ts +++ b/apps/web/features/admin/hooks/use-admin-billing-redemption-codes.ts @@ -22,6 +22,7 @@ export type RedemptionFormState = { creditUSD: string; planID: string; durationDays: string; + balanceDurationDays: string; maxRedemptions: string; perUserLimit: string; expiresAt: string; @@ -245,12 +246,17 @@ export function useAdminBillingRedemptionCodes({ billingMode }: UseAdminBillingR const data = redemptionForm.mode === "usage" ? await (async () => { const creditUSD = Number(redemptionForm.creditUSD); + const balanceDurationDays = Number(redemptionForm.balanceDurationDays); + if (!Number.isFinite(balanceDurationDays) || (balanceDurationDays !== 0 && (balanceDurationDays < 0.1 || balanceDurationDays > 3650))) { + throw new Error(t("toast.redemptionInvalidDuration")); + } if (!Number.isFinite(creditUSD) || creditUSD <= 0) { throw new Error(t("toast.redemptionInvalidCredit")); } return createAdminRedemptionCodes(token, { ...payload, creditUSD, + balanceDurationDays: balanceDurationDays || undefined, }); })() : await (async () => { diff --git a/apps/web/features/admin/hooks/use-admin-users-page.ts b/apps/web/features/admin/hooks/use-admin-users-page.ts index 7c8adecb7..cc0fc65cb 100644 --- a/apps/web/features/admin/hooks/use-admin-users-page.ts +++ b/apps/web/features/admin/hooks/use-admin-users-page.ts @@ -12,6 +12,7 @@ import { createAdminUser, deleteAdminUser, getAdminBillingConfig, + grantAdminExpiringCredit, listAdminBillingPlans, patchAdminUser, resetAdminUserPassword, @@ -173,6 +174,8 @@ function createEditPayload(user: AdminUserDTO, fallbackSubscriptionTier = "free" subscriptionTier, subscriptionExpiresAt: resolveSubscriptionExpiryInputValue(user.subscriptionExpiresAt), billingBalanceUSD: String(user.billingBalanceUSD ?? 0), + expiringCreditUSD: "", + expiringCreditDurationDays: "2", profilePreferences: user.profilePreferences, reason: "", }; @@ -270,9 +273,12 @@ export function useAdminUsersPage({ subscriptionTier: "free", subscriptionExpiresAt: "", billingBalanceUSD: "0", + expiringCreditUSD: "", + expiringCreditDurationDays: "2", profilePreferences: "", reason: "", }); + const expiringGrantRef = React.useRef<{ fingerprint: string; key: string } | null>(null); const [resetPasswordDraft, setResetPasswordDraft] = React.useState(""); const [billingMode, setBillingMode] = React.useState("self"); const [billingDisplay, setBillingDisplay] = React.useState({ currency: "USD" }); @@ -371,6 +377,7 @@ export function useAdminUsersPage({ return; } const fallbackSubscriptionTier = billingPlans[0]?.code || "free"; + expiringGrantRef.current = null; setEditDialogTarget(user); setEditPayload(createEditPayload(user, fallbackSubscriptionTier)); }, [billingPlans, canManageUser]); @@ -562,6 +569,10 @@ export function useAdminUsersPage({ const nextLocale = editPayload.locale.trim() || "en-US"; const nextSubscriptionTier = editPayload.subscriptionTier.trim(); const nextBillingBalance = Number(editPayload.billingBalanceUSD); + const expiringCreditRequested = editPayload.expiringCreditUSD.trim() !== ""; + const expiringCreditUSD = Number(editPayload.expiringCreditUSD); + const roundedExpiringCreditUSD = roundBillingBalance(expiringCreditUSD); + const expiringCreditDurationDays = Number(editPayload.expiringCreditDurationDays); const nextProfilePreferences = editPayload.profilePreferences.trim(); const patchPayload: AdminUserPatchPayload = {}; @@ -641,7 +652,19 @@ export function useAdminUsersPage({ return; } - if (!hasPatchChanges(patchPayload) && !billingBalanceChanged) { + if ( + expiringCreditRequested && + (!Number.isFinite(expiringCreditUSD) || + roundedExpiringCreditUSD <= 0 || + !Number.isFinite(expiringCreditDurationDays) || + expiringCreditDurationDays < 0.1 || + expiringCreditDurationDays > 3650) + ) { + toast.error(t("toast.editFailed"), { description: t("validation.invalidExpiringCredit") }); + return; + } + + if (!hasPatchChanges(patchPayload) && !billingBalanceChanged && !expiringCreditRequested) { setEditDialogTarget(null); return; } @@ -671,8 +694,29 @@ export function useAdminUsersPage({ billingAccountStatus: response.account.status, }; } + if (expiringCreditRequested) { + const fingerprint = JSON.stringify([editDialogTarget.id, roundedExpiringCreditUSD, expiringCreditDurationDays]); + if (expiringGrantRef.current?.fingerprint !== fingerprint) { + expiringGrantRef.current = { fingerprint, key: crypto.randomUUID() }; + } + // Preserve the confirmed balance after a partial save, so a retry cannot + // reset the account and revoke a grant whose response was lost. + setEditDialogTarget(nextUser); + const response = await grantAdminExpiringCredit(token, editDialogTarget.id, { + idempotencyKey: expiringGrantRef.current.key, + creditUSD: roundedExpiringCreditUSD, + durationDays: expiringCreditDurationDays, + description: t("toast.expiringCreditDescription", { days: expiringCreditDurationDays }), + }); + nextUser = { + ...nextUser, + billingBalanceUSD: response.account.balanceUSD, + billingAccountStatus: response.account.status, + }; + } onSetUsers((current) => replaceByID(current, editDialogTarget.id, (user) => user.id, nextUser)); toast.success(t("toast.userUpdated")); + expiringGrantRef.current = null; setEditDialogTarget(null); } catch (error) { toast.error(t("toast.editFailed"), { description: resolveAdminErrorMessage(error) }); diff --git a/apps/web/features/admin/types/users.ts b/apps/web/features/admin/types/users.ts index 4573f2b7b..779514617 100644 --- a/apps/web/features/admin/types/users.ts +++ b/apps/web/features/admin/types/users.ts @@ -76,6 +76,8 @@ export type EditUserPayload = { subscriptionTier: UserTier; subscriptionExpiresAt: string; billingBalanceUSD: string; + expiringCreditUSD: string; + expiringCreditDurationDays: string; profilePreferences: string; reason: string; }; diff --git a/apps/web/i18n/messages/en-US/admin-billing.json b/apps/web/i18n/messages/en-US/admin-billing.json index a7e9d9b59..18d3e4240 100644 --- a/apps/web/i18n/messages/en-US/admin-billing.json +++ b/apps/web/i18n/messages/en-US/admin-billing.json @@ -123,6 +123,8 @@ "active": "Active", "inactive": "Inactive", "creditUSD": "Redemption amount", + "creditDurationDays": "Credit validity (days)", + "creditDurationHint": "Starts when the user redeems the code. Decimals are supported (for example, 0.1 day); leave blank for permanent credit. Only the unused remainder is reclaimed at expiration.", "plan": "Plan", "planPlaceholder": "Select plan", "durationDays": "Duration days", @@ -137,8 +139,10 @@ "copyAll": "Copy all", "unlimited": "Unlimited", "never": "Never", + "permanent": "Permanent", "unknownPlan": "Unknown plan", "usageReward": "{amount} credit", + "usageRewardExpiring": "{amount} credit · {days} days", "periodReward": "{plan} · {days} days", "empty": "No redemption codes", "columns": { @@ -349,6 +353,7 @@ "redemptionInvalidCodeFormat": "Code must be 3-64 letters, numbers, hyphens, or underscores.", "redemptionInvalidQuantity": "Quantity must be a positive integer.", "redemptionInvalidCredit": "Credit amount must be greater than 0.", + "redemptionInvalidCreditDuration": "Credit validity must be blank or between 0.1 and 3650 days; decimals are supported.", "redemptionInvalidPlan": "Choose an active non-Free plan.", "redemptionInvalidDuration": "Duration days must be greater than 0.", "redemptionInvalidMaxRedemptions": "Total uses must be a positive integer, or empty for unlimited.", diff --git a/apps/web/i18n/messages/en-US/admin-users.json b/apps/web/i18n/messages/en-US/admin-users.json index 250ad30bc..b5e3bb4a6 100644 --- a/apps/web/i18n/messages/en-US/admin-users.json +++ b/apps/web/i18n/messages/en-US/admin-users.json @@ -242,6 +242,9 @@ "reason": "Reason", "reasonPlaceholder": "Record the reason for this status change (optional)", "accountBalance": "Account balance (USD)", + "expiringCreditAmount": "New expiring credit (USD)", + "expiringCreditDays": "Valid for (days)", + "expiringCreditHelp": "Credited immediately on save. Decimal durations are supported (for example, 0.1 day). Usage consumes the earliest-expiring credit first, and any unused amount is reclaimed at expiration. Leave the amount blank to skip.", "billingStatus": "Billing status", "twoFactor": "Two-factor authentication", "twoFactorEnabled": "Enabled (recovery codes {count})", @@ -260,6 +263,7 @@ "invalidSubscriptionExpiry": "Enter a valid subscription expiration time.", "selectSubscriptionPlan": "Select a subscription plan.", "invalidUsageBalance": "Enter a valid usage balance.", + "invalidExpiringCredit": "Enter an expiring credit greater than 0 and a duration from 0.1 to 3650 days; decimals are supported.", "passwordMinLength": "The new password must be at least 8 characters and not digits only.", "usernameLength": "Username must be 3-16 characters.", "displayNameLength": "Display name must be 3-16 characters." @@ -276,6 +280,7 @@ "avatarUpdateFailed": "Failed to update avatar", "editFailed": "Failed to edit user", "balanceAdjustmentDescription": "Balance set by an administrator in user management", + "expiringCreditDescription": "Administrator granted a {days}-day expiring credit", "userUpdated": "User updated", "resetPasswordFailed": "Failed to reset password", "passwordReset": "Password reset", diff --git a/apps/web/i18n/messages/en-US/errors.json b/apps/web/i18n/messages/en-US/errors.json index 01ebb386c..4e25823b6 100644 --- a/apps/web/i18n/messages/en-US/errors.json +++ b/apps/web/i18n/messages/en-US/errors.json @@ -103,6 +103,8 @@ "invalidDailyUsageDays": "Invalid usage day count.", "invalidModelPricing": "Invalid model pricing.", "invalidAccountBalance": "Invalid billing account balance.", + "invalidExpiringCredit": "Enter a positive credit amount and a duration from 0.1 to 3650 days.", + "expiringCreditConflict": "This grant key was already used with different details. Restore the original request or start a new grant.", "invalidNativeToolPricing": "Invalid native tool pricing.", "subscribeFailed": "Subscription failed. Please try again later.", "subscriptionEntitlementActive": "A paid subscription entitlement is still active, so Free cannot be selected.", diff --git a/apps/web/i18n/messages/zh-CN/admin-billing.json b/apps/web/i18n/messages/zh-CN/admin-billing.json index 32cf2e83f..b687863ff 100644 --- a/apps/web/i18n/messages/zh-CN/admin-billing.json +++ b/apps/web/i18n/messages/zh-CN/admin-billing.json @@ -123,6 +123,8 @@ "active": "启用", "inactive": "停用", "creditUSD": "兑换金额", + "creditDurationDays": "余额有效天数", + "creditDurationHint": "从用户成功兑换时开始计算;支持小数(例如 0.1 天),留空表示永久余额。到期只回收尚未使用的部分。", "plan": "套餐", "planPlaceholder": "选择套餐", "durationDays": "有效天数", @@ -137,8 +139,10 @@ "copyAll": "复制全部", "unlimited": "不限", "never": "不过期", + "permanent": "永久", "unknownPlan": "未知套餐", "usageReward": "{amount} 余额", + "usageRewardExpiring": "{amount} 余额 · {days} 天", "periodReward": "{plan} · {days} 天", "empty": "暂无兑换码", "columns": { @@ -349,6 +353,7 @@ "redemptionInvalidCodeFormat": "兑换码只能包含 3-64 位字母、数字、横线或下划线。", "redemptionInvalidQuantity": "数量必须是大于 0 的整数。", "redemptionInvalidCredit": "余额金额必须大于 0。", + "redemptionInvalidCreditDuration": "余额有效天数必须留空,或填写 0.1–3650 天(支持小数)。", "redemptionInvalidPlan": "请选择一个启用的非 Free 套餐。", "redemptionInvalidDuration": "有效天数必须大于 0。", "redemptionInvalidMaxRedemptions": "总次数必须是正整数,或留空表示不限。", diff --git a/apps/web/i18n/messages/zh-CN/admin-users.json b/apps/web/i18n/messages/zh-CN/admin-users.json index 85e022ba6..b290f35ca 100644 --- a/apps/web/i18n/messages/zh-CN/admin-users.json +++ b/apps/web/i18n/messages/zh-CN/admin-users.json @@ -242,6 +242,9 @@ "reason": "原因", "reasonPlaceholder": "记录本次状态修改原因(可选)", "accountBalance": "账号余额(USD)", + "expiringCreditAmount": "新增限时额度(USD)", + "expiringCreditDays": "有效天数", + "expiringCreditHelp": "保存后立即入账;有效期支持小数(例如 0.1 天)。模型消费优先使用最早到期额度,到期自动回收未使用部分。金额留空则不发放。", "billingStatus": "计费状态", "twoFactor": "两步验证", "twoFactorEnabled": "已开启(恢复码 {count})", @@ -260,6 +263,7 @@ "invalidSubscriptionExpiry": "请输入有效的订阅到期时间。", "selectSubscriptionPlan": "请选择订阅方案。", "invalidUsageBalance": "请输入有效的按量余额。", + "invalidExpiringCredit": "请输入大于 0 的限时额度,并将有效天数设为 0.1–3650 天(支持小数)。", "passwordMinLength": "新密码至少 8 位,且不能为纯数字。", "usernameLength": "用户名需为 3-16 位。", "displayNameLength": "昵称需为 3-16 位。" @@ -276,6 +280,7 @@ "avatarUpdateFailed": "头像更新失败", "editFailed": "编辑用户失败", "balanceAdjustmentDescription": "管理员在用户管理中设置余额", + "expiringCreditDescription": "管理员发放 {days} 天限时额度", "userUpdated": "用户已更新", "resetPasswordFailed": "重置密码失败", "passwordReset": "密码已重置", diff --git a/apps/web/i18n/messages/zh-CN/errors.json b/apps/web/i18n/messages/zh-CN/errors.json index 2a0677640..72515da79 100644 --- a/apps/web/i18n/messages/zh-CN/errors.json +++ b/apps/web/i18n/messages/zh-CN/errors.json @@ -103,6 +103,8 @@ "invalidDailyUsageDays": "用量天数无效。", "invalidModelPricing": "模型定价无效。", "invalidAccountBalance": "计费账户余额无效。", + "invalidExpiringCredit": "请输入大于 0 的余额及 0.1 至 3650 天的有效期。", + "expiringCreditConflict": "该发放编号已用于其他请求,请恢复原请求内容,或重新创建一笔发放。", "invalidNativeToolPricing": "原生工具定价无效。", "subscribeFailed": "订阅失败,请稍后重试。", "subscriptionEntitlementActive": "当前仍有有效付费订阅权益,不能切换到免费方案。", diff --git a/backend/docs/docs.go b/backend/docs/docs.go index 4e0ed54f4..32e15f6bc 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -634,6 +634,70 @@ const docTemplate = `{ } } }, + "/admin/billing/accounts/{user_id}/expiring-credits": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "发放指定美元额度;消费优先使用最早到期批次,到期后自动回收未使用部分", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin-billing" + ], + "summary": "管理员发放限时额度", + "parameters": [ + { + "type": "integer", + "description": "用户ID", + "name": "user_id", + "in": "path", + "required": true + }, + { + "description": "限时额度", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/GrantExpiringCreditRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/ExpiringCreditGrantResponseDoc" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/BillingErrorDoc" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/BillingErrorDoc" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/BillingErrorDoc" + } + } + } + } + }, "/admin/billing/config": { "get": { "security": [ @@ -22701,6 +22765,11 @@ const docTemplate = `{ "mode" ], "properties": { + "balanceDurationDays": { + "type": "number", + "maximum": 3650, + "minimum": 0.1 + }, "code": { "type": "string", "maxLength": 64, @@ -22716,8 +22785,8 @@ const docTemplate = `{ }, "durationDays": { "type": "integer", - "maximum": 3660, - "minimum": 0 + "maximum": 3650, + "minimum": 1 }, "expiresAt": { "type": "string", @@ -23687,6 +23756,99 @@ const docTemplate = `{ } } }, + "ExpiringCreditGrantDataResponse": { + "type": "object", + "required": [ + "account", + "credit" + ], + "properties": { + "account": { + "$ref": "#/definitions/BillingAccountResponse" + }, + "credit": { + "$ref": "#/definitions/ExpiringCreditResponse" + } + } + }, + "ExpiringCreditGrantResponseDoc": { + "type": "object", + "required": [ + "data", + "errorMsg" + ], + "properties": { + "data": { + "$ref": "#/definitions/ExpiringCreditGrantDataResponse" + }, + "errorMsg": { + "type": "string" + } + } + }, + "ExpiringCreditResponse": { + "type": "object", + "required": [ + "consumedAt", + "createdAt", + "createdByUserID", + "description", + "expiredAt", + "expiresAt", + "id", + "originalNanousd", + "originalUSD", + "remainingNanousd", + "remainingUSD", + "status", + "userID" + ], + "properties": { + "consumedAt": { + "type": "string", + "x-nullable": true, + "x-omitempty": false + }, + "createdAt": { + "type": "string" + }, + "createdByUserID": { + "type": "integer" + }, + "description": { + "type": "string" + }, + "expiredAt": { + "type": "string", + "x-nullable": true, + "x-omitempty": false + }, + "expiresAt": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "originalNanousd": { + "type": "integer" + }, + "originalUSD": { + "type": "number" + }, + "remainingNanousd": { + "type": "integer" + }, + "remainingUSD": { + "type": "number" + }, + "status": { + "type": "string" + }, + "userID": { + "type": "integer" + } + } + }, "FeaturePolicyResponse": { "type": "object", "required": [ @@ -24179,6 +24341,34 @@ const docTemplate = `{ } } }, + "GrantExpiringCreditRequest": { + "type": "object", + "required": [ + "creditUSD", + "durationDays", + "idempotencyKey" + ], + "properties": { + "creditUSD": { + "type": "number" + }, + "description": { + "type": "string", + "maxLength": 255 + }, + "durationDays": { + "type": "number", + "maximum": 3650, + "minimum": 0.1, + "example": 0.1 + }, + "idempotencyKey": { + "type": "string", + "maxLength": 128, + "minLength": 8 + } + } + }, "GroupModelsResponse": { "type": "object", "required": [ @@ -30130,6 +30320,7 @@ const docTemplate = `{ "RedemptionCodeResponse": { "type": "object", "required": [ + "balanceDurationSeconds", "codeHint", "createdAt", "createdByUserID", @@ -30150,6 +30341,9 @@ const docTemplate = `{ "updatedAt" ], "properties": { + "balanceDurationSeconds": { + "type": "integer" + }, "code": { "type": "string" }, @@ -30265,6 +30459,7 @@ const docTemplate = `{ "required": [ "balanceAfterNanousd", "balanceBeforeNanousd", + "balanceDurationSeconds", "codeDescription", "codeHint", "codeID", @@ -30298,6 +30493,9 @@ const docTemplate = `{ "x-nullable": true, "x-omitempty": false }, + "balanceDurationSeconds": { + "type": "integer" + }, "codeDescription": { "type": "string" }, diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index 17f5622c6..aba8dbc45 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -627,6 +627,70 @@ } } }, + "/admin/billing/accounts/{user_id}/expiring-credits": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "发放指定美元额度;消费优先使用最早到期批次,到期后自动回收未使用部分", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin-billing" + ], + "summary": "管理员发放限时额度", + "parameters": [ + { + "type": "integer", + "description": "用户ID", + "name": "user_id", + "in": "path", + "required": true + }, + { + "description": "限时额度", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/GrantExpiringCreditRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/ExpiringCreditGrantResponseDoc" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/BillingErrorDoc" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/BillingErrorDoc" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/BillingErrorDoc" + } + } + } + } + }, "/admin/billing/config": { "get": { "security": [ @@ -22694,6 +22758,11 @@ "mode" ], "properties": { + "balanceDurationDays": { + "type": "number", + "maximum": 3650, + "minimum": 0.1 + }, "code": { "type": "string", "maxLength": 64, @@ -22709,8 +22778,8 @@ }, "durationDays": { "type": "integer", - "maximum": 3660, - "minimum": 0 + "maximum": 3650, + "minimum": 1 }, "expiresAt": { "type": "string", @@ -23680,6 +23749,99 @@ } } }, + "ExpiringCreditGrantDataResponse": { + "type": "object", + "required": [ + "account", + "credit" + ], + "properties": { + "account": { + "$ref": "#/definitions/BillingAccountResponse" + }, + "credit": { + "$ref": "#/definitions/ExpiringCreditResponse" + } + } + }, + "ExpiringCreditGrantResponseDoc": { + "type": "object", + "required": [ + "data", + "errorMsg" + ], + "properties": { + "data": { + "$ref": "#/definitions/ExpiringCreditGrantDataResponse" + }, + "errorMsg": { + "type": "string" + } + } + }, + "ExpiringCreditResponse": { + "type": "object", + "required": [ + "consumedAt", + "createdAt", + "createdByUserID", + "description", + "expiredAt", + "expiresAt", + "id", + "originalNanousd", + "originalUSD", + "remainingNanousd", + "remainingUSD", + "status", + "userID" + ], + "properties": { + "consumedAt": { + "type": "string", + "x-nullable": true, + "x-omitempty": false + }, + "createdAt": { + "type": "string" + }, + "createdByUserID": { + "type": "integer" + }, + "description": { + "type": "string" + }, + "expiredAt": { + "type": "string", + "x-nullable": true, + "x-omitempty": false + }, + "expiresAt": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "originalNanousd": { + "type": "integer" + }, + "originalUSD": { + "type": "number" + }, + "remainingNanousd": { + "type": "integer" + }, + "remainingUSD": { + "type": "number" + }, + "status": { + "type": "string" + }, + "userID": { + "type": "integer" + } + } + }, "FeaturePolicyResponse": { "type": "object", "required": [ @@ -24172,6 +24334,34 @@ } } }, + "GrantExpiringCreditRequest": { + "type": "object", + "required": [ + "creditUSD", + "durationDays", + "idempotencyKey" + ], + "properties": { + "creditUSD": { + "type": "number" + }, + "description": { + "type": "string", + "maxLength": 255 + }, + "durationDays": { + "type": "number", + "maximum": 3650, + "minimum": 0.1, + "example": 0.1 + }, + "idempotencyKey": { + "type": "string", + "maxLength": 128, + "minLength": 8 + } + } + }, "GroupModelsResponse": { "type": "object", "required": [ @@ -30123,6 +30313,7 @@ "RedemptionCodeResponse": { "type": "object", "required": [ + "balanceDurationSeconds", "codeHint", "createdAt", "createdByUserID", @@ -30143,6 +30334,9 @@ "updatedAt" ], "properties": { + "balanceDurationSeconds": { + "type": "integer" + }, "code": { "type": "string" }, @@ -30258,6 +30452,7 @@ "required": [ "balanceAfterNanousd", "balanceBeforeNanousd", + "balanceDurationSeconds", "codeDescription", "codeHint", "codeID", @@ -30291,6 +30486,9 @@ "x-nullable": true, "x-omitempty": false }, + "balanceDurationSeconds": { + "type": "integer" + }, "codeDescription": { "type": "string" }, diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 57ce68b53..5a362454f 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -3144,6 +3144,10 @@ definitions: type: object CreateRedemptionCodeRequest: properties: + balanceDurationDays: + maximum: 3650 + minimum: 0.1 + type: number code: maxLength: 64 minLength: 3 @@ -3155,8 +3159,8 @@ definitions: maxLength: 255 type: string durationDays: - maximum: 3660 - minimum: 0 + maximum: 3650 + minimum: 1 type: integer expiresAt: type: string @@ -3837,6 +3841,73 @@ definitions: - userLabel - userUsername type: object + ExpiringCreditGrantDataResponse: + properties: + account: + $ref: '#/definitions/BillingAccountResponse' + credit: + $ref: '#/definitions/ExpiringCreditResponse' + required: + - account + - credit + type: object + ExpiringCreditGrantResponseDoc: + properties: + data: + $ref: '#/definitions/ExpiringCreditGrantDataResponse' + errorMsg: + type: string + required: + - data + - errorMsg + type: object + ExpiringCreditResponse: + properties: + consumedAt: + type: string + x-nullable: true + x-omitempty: false + createdAt: + type: string + createdByUserID: + type: integer + description: + type: string + expiredAt: + type: string + x-nullable: true + x-omitempty: false + expiresAt: + type: string + id: + type: integer + originalNanousd: + type: integer + originalUSD: + type: number + remainingNanousd: + type: integer + remainingUSD: + type: number + status: + type: string + userID: + type: integer + required: + - consumedAt + - createdAt + - createdByUserID + - description + - expiredAt + - expiresAt + - id + - originalNanousd + - originalUSD + - remainingNanousd + - remainingUSD + - status + - userID + type: object FeaturePolicyResponse: properties: desktopDownloadEnabled: @@ -4186,6 +4257,27 @@ definitions: required: - fileIDs type: object + GrantExpiringCreditRequest: + properties: + creditUSD: + type: number + description: + maxLength: 255 + type: string + durationDays: + example: 0.1 + maximum: 3650 + minimum: 0.1 + type: number + idempotencyKey: + maxLength: 128 + minLength: 8 + type: string + required: + - creditUSD + - durationDays + - idempotencyKey + type: object GroupModelsResponse: properties: modelIDs: @@ -8407,6 +8499,8 @@ definitions: type: object RedemptionCodeResponse: properties: + balanceDurationSeconds: + type: integer code: type: string codeHint: @@ -8452,6 +8546,7 @@ definitions: updatedAt: type: string required: + - balanceDurationSeconds - codeHint - createdAt - createdByUserID @@ -8513,6 +8608,8 @@ definitions: type: integer x-nullable: true x-omitempty: false + balanceDurationSeconds: + type: integer codeDescription: type: string codeHint: @@ -8556,6 +8653,7 @@ definitions: required: - balanceAfterNanousd - balanceBeforeNanousd + - balanceDurationSeconds - codeDescription - codeHint - codeID @@ -12487,6 +12585,47 @@ paths: summary: 管理员设置用户按量余额 tags: - admin-billing + /admin/billing/accounts/{user_id}/expiring-credits: + post: + consumes: + - application/json + description: 发放指定美元额度;消费优先使用最早到期批次,到期后自动回收未使用部分 + parameters: + - description: 用户ID + in: path + name: user_id + required: true + type: integer + - description: 限时额度 + in: body + name: body + required: true + schema: + $ref: '#/definitions/GrantExpiringCreditRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/ExpiringCreditGrantResponseDoc' + "400": + description: Bad Request + schema: + $ref: '#/definitions/BillingErrorDoc' + "409": + description: Conflict + schema: + $ref: '#/definitions/BillingErrorDoc' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/BillingErrorDoc' + security: + - BearerAuth: [] + summary: 管理员发放限时额度 + tags: + - admin-billing /admin/billing/config: get: consumes: diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index ac9d2d1cb..bc653d8c8 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -290,6 +290,7 @@ func NewAppWithOptions(opts Options) (*App, error) { userService := user.NewService(userRepo) billingRepo := billingrepo.NewRepo(db) billingService := billing.NewService(billingRepo) + billingService.SetLogger(log) billingService.SetAuditWriter(auditService) billingService.SetRedemptionCodeKeyring(keyring) officialPricingService := billing.NewOfficialPricingService( @@ -582,6 +583,7 @@ func NewAppWithOptions(opts Options) (*App, error) { log.Warn("embedding index reconciliation failed", zap.Error(reconcileErr)) } embeddingService.StartBackgroundWorkers(backgroundCtx) + billingService.StartBackgroundWorkers(backgroundCtx) conversationService.StartBackgroundWorkers(backgroundCtx) contentModerationService.StartBackgroundWorkers(backgroundCtx) channelService.StartModelIconAssetCleanup(backgroundCtx) diff --git a/backend/internal/application/billing/errs.go b/backend/internal/application/billing/errs.go index 3066b7d00..769d11d2f 100644 --- a/backend/internal/application/billing/errs.go +++ b/backend/internal/application/billing/errs.go @@ -69,6 +69,10 @@ var ( ErrPaymentOrderStateInvalid = apperr.New("payment.order_state_invalid", "payment order state is invalid") // ErrInvalidBillingAccountBalance 表示管理员提交的余额无效。 ErrInvalidBillingAccountBalance = apperr.New("billing.invalid_account_balance", "invalid billing account balance") + // ErrInvalidExpiringCredit 表示限时余额发放参数无效。 + ErrInvalidExpiringCredit = apperr.New("billing.invalid_expiring_credit", "invalid expiring credit grant") + // ErrExpiringCreditConflict 表示幂等键已用于不同的发放请求。 + ErrExpiringCreditConflict = apperr.New("billing.expiring_credit_conflict", "expiring credit grant conflicts with an earlier request") // ErrInvalidNativeToolPricing 表示原生工具计费覆盖配置无效。 ErrInvalidNativeToolPricing = apperr.New("billing.invalid_native_tool_pricing", "invalid native tool pricing") ) diff --git a/backend/internal/application/billing/service.go b/backend/internal/application/billing/service.go index 17cb29a87..93090c3b3 100644 --- a/backend/internal/application/billing/service.go +++ b/backend/internal/application/billing/service.go @@ -7,7 +7,6 @@ import ( "encoding/json" "errors" "fmt" - "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/pkg/textutil" "math" "sort" "strconv" @@ -18,9 +17,11 @@ import ( domainbilling "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/billing" domainpersonalprovider "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/personalprovider" "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/pkg/secretbox" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/pkg/textutil" "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/repository" "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/shared/nativetool" "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/shared/pagination" + "go.uber.org/zap" ) const ( @@ -53,7 +54,8 @@ type UserBillingAccountSnapshot struct { // Service 封装计费业务能力。 type Service struct { - repo repository.BillingRepository + repo repository.BillingRepository + expiringCreditRepo repository.ExpiringCreditRepository publicPricingMu sync.RWMutex publicPricingByModel map[string]PublicModelPricing @@ -67,6 +69,8 @@ type Service struct { permissionGroupLookup permissionGroupLookup permissionGroupPlanCounter permissionGroupPlanCounter redemptionCodeKeyring *secretbox.Keyring + logger *zap.Logger + workerOnce sync.Once } // groupRateMultiplierResolver 提供用户权限组计费倍率查询能力。 @@ -369,7 +373,10 @@ type BillingAccountBalanceInput struct { // NewService 创建服务。 func NewService(repo repository.BillingRepository) *Service { - service := &Service{repo: repo} + service := &Service{repo: repo, logger: zap.NewNop()} + if expiringRepo, ok := repo.(repository.ExpiringCreditRepository); ok { + service.expiringCreditRepo = expiringRepo + } if counter, ok := repo.(permissionGroupPlanCounter); ok { service.permissionGroupPlanCounter = counter } diff --git a/backend/internal/application/billing/service_expiring_credit.go b/backend/internal/application/billing/service_expiring_credit.go new file mode 100644 index 000000000..7dff8e192 --- /dev/null +++ b/backend/internal/application/billing/service_expiring_credit.go @@ -0,0 +1,105 @@ +package billing + +import ( + "context" + "errors" + "math" + "time" + + domainbilling "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/billing" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/repository" + "go.uber.org/zap" +) + +// ExpiringCreditGrantInput 定义管理员发放限时额度的入参。 +type ExpiringCreditGrantInput struct { + UserID uint + CreditUSD float64 + DurationDays float64 + CreatedByUserID uint + RefNo string + Description string +} + +// StartBackgroundWorkers 启动限时额度到期回收任务。 +func (s *Service) StartBackgroundWorkers(ctx context.Context) { + if s == nil || s.expiringCreditRepo == nil { + return + } + s.workerOnce.Do(func() { + go func() { + const sweepInterval = time.Minute + s.sweepExpiringCredits(ctx, time.Now()) + ticker := time.NewTicker(sweepInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case now := <-ticker.C: + s.sweepExpiringCredits(ctx, now) + } + } + }() + }) +} + +func (s *Service) SetLogger(logger *zap.Logger) { + if logger != nil { + s.logger = logger + } +} + +func (s *Service) sweepExpiringCredits(ctx context.Context, now time.Time) { + sweepCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + if _, err := s.expiringCreditRepo.ExpireDueExpiringCredits(sweepCtx, now, 1000); err != nil && ctx.Err() == nil { + s.logger.Warn("expiring_credit_sweep_failed", zap.Error(err)) + } +} + +// GrantExpiringCredit 管理员发放一笔到期自动回收剩余值的限时额度。 +func (s *Service) GrantExpiringCredit(ctx context.Context, input ExpiringCreditGrantInput) (*domainbilling.ExpiringCredit, *domainbilling.BillingAccount, error) { + durationSeconds, validDuration := fractionalDaysToSeconds(input.DurationDays, false) + if s.expiringCreditRepo == nil || input.UserID == 0 || input.CreatedByUserID == 0 || !validDuration || + input.CreditUSD <= 0 || input.CreditUSD >= float64(math.MaxInt64)/1e9 || math.IsNaN(input.CreditUSD) || math.IsInf(input.CreditUSD, 0) { + return nil, nil, ErrInvalidExpiringCredit + } + mode, err := s.repo.GetBillingMode(ctx) + if err != nil { + return nil, nil, err + } + if mode != "usage" && mode != "period" { + return nil, nil, ErrPaymentRequired + } + credit, account, err := s.expiringCreditRepo.GrantExpiringCredit(ctx, repository.ExpiringCreditGrantInput{ + UserID: input.UserID, + AmountNanousd: usdToNanousd(input.CreditUSD), + DurationSeconds: durationSeconds, + CreatedByUserID: input.CreatedByUserID, + RefNo: input.RefNo, + Description: input.Description, + }) + switch { + case errors.Is(err, repository.ErrInvalidInput): + return nil, nil, ErrInvalidExpiringCredit + case errors.Is(err, repository.ErrConflict): + return nil, nil, ErrExpiringCreditConflict + default: + return credit, account, err + } +} + +func fractionalDaysToSeconds(days float64, allowZero bool) (int64, bool) { + if math.IsNaN(days) || math.IsInf(days, 0) || days < 0 || days > 3650 { + return 0, false + } + if days == 0 { + return 0, allowZero + } + if days < 0.1 { + return 0, false + } + seconds := int64(math.Round(days * 24 * 60 * 60)) + return seconds, seconds > 0 +} diff --git a/backend/internal/application/billing/service_expiring_credit_errors_test.go b/backend/internal/application/billing/service_expiring_credit_errors_test.go new file mode 100644 index 000000000..be8157664 --- /dev/null +++ b/backend/internal/application/billing/service_expiring_credit_errors_test.go @@ -0,0 +1,45 @@ +package billing + +import ( + "context" + "errors" + "testing" + "time" + + domainbilling "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/billing" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/repository" +) + +type expiringCreditErrorRepository struct { + repository.BillingRepository + failure error +} + +func (*expiringCreditErrorRepository) GetBillingMode(context.Context) (string, error) { + return "usage", nil +} + +func (r *expiringCreditErrorRepository) GrantExpiringCredit(context.Context, repository.ExpiringCreditGrantInput) (*domainbilling.ExpiringCredit, *domainbilling.BillingAccount, error) { + return nil, nil, r.failure +} + +func (*expiringCreditErrorRepository) ExpireDueExpiringCredits(context.Context, time.Time, int) (int64, error) { + return 0, nil +} + +func TestExpiringGrantMapsRepositoryErrorsAtApplicationBoundary(t *testing.T) { + internal := errors.New("database unavailable") + for _, tc := range []struct{ input, want error }{ + {repository.ErrInvalidInput, ErrInvalidExpiringCredit}, + {repository.ErrConflict, ErrExpiringCreditConflict}, + {internal, internal}, + } { + svc := NewService(&expiringCreditErrorRepository{failure: tc.input}) + _, _, err := svc.GrantExpiringCredit(t.Context(), ExpiringCreditGrantInput{ + UserID: 1, CreatedByUserID: 9, CreditUSD: 20, DurationDays: 0.1, + }) + if !errors.Is(err, tc.want) { + t.Fatalf("repository error %v mapped to %v, want %v", tc.input, err, tc.want) + } + } +} diff --git a/backend/internal/application/billing/service_expiring_credit_test.go b/backend/internal/application/billing/service_expiring_credit_test.go new file mode 100644 index 000000000..21cba8218 --- /dev/null +++ b/backend/internal/application/billing/service_expiring_credit_test.go @@ -0,0 +1,101 @@ +package billing + +import ( + "context" + "errors" + "testing" + + domainbilling "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/billing" +) + +func TestCreateUsageRedemptionCodePreservesOptionalCreditDuration(t *testing.T) { + repo := newRedemptionRepositoryStub(domainbilling.RedemptionCodeModeUsage) + service := NewService(repo) + service.SetRedemptionCodeKeyring(testRedemptionKeyring(t)) + + _, err := service.CreateRedemptionCodes(context.Background(), 7, RedemptionCodeInput{ + Code: "LIMITED-01D", + Mode: domainbilling.RedemptionCodeModeUsage, + CreditUSD: 20, + BalanceDurationDays: 0.1, + PerUserLimit: 1, + }) + if err != nil { + t.Fatalf("CreateRedemptionCodes() error = %v", err) + } + if len(repo.created) != 1 || repo.created[0].DurationDays != 0 || repo.created[0].BalanceDurationSeconds != 8640 { + t.Fatalf("created usage duration = %+v, want 8640 seconds", repo.created) + } + + _, err = service.CreateRedemptionCodes(context.Background(), 7, RedemptionCodeInput{ + Code: "TOO-SHORT", + Mode: domainbilling.RedemptionCodeModeUsage, + CreditUSD: 20, + BalanceDurationDays: 0.05, + PerUserLimit: 1, + }) + if !errors.Is(err, ErrInvalidRedemptionCode) { + t.Fatalf("CreateRedemptionCodes() short duration error = %v, want ErrInvalidRedemptionCode", err) + } + + _, err = service.CreateRedemptionCodes(context.Background(), 7, RedemptionCodeInput{ + Code: "TOO-LONG", + Mode: domainbilling.RedemptionCodeModeUsage, + CreditUSD: 20, + BalanceDurationDays: 3651, + PerUserLimit: 1, + }) + if !errors.Is(err, ErrInvalidRedemptionCode) { + t.Fatalf("CreateRedemptionCodes() duration error = %v, want ErrInvalidRedemptionCode", err) + } +} + +func TestFractionalDaysToSeconds(t *testing.T) { + testCases := []struct { + name string + days float64 + allowZero bool + seconds int64 + valid bool + }{ + {name: "tenth day", days: 0.1, seconds: 8640, valid: true}, + {name: "permanent", days: 0, allowZero: true, seconds: 0, valid: true}, + {name: "zero disallowed", days: 0, valid: false}, + {name: "below minimum", days: 0.05, valid: false}, + {name: "above maximum", days: 3650.1, valid: false}, + } + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + seconds, valid := fractionalDaysToSeconds(testCase.days, testCase.allowZero) + if seconds != testCase.seconds || valid != testCase.valid { + t.Fatalf("fractionalDaysToSeconds(%v, %v) = (%d, %v), want (%d, %v)", testCase.days, testCase.allowZero, seconds, valid, testCase.seconds, testCase.valid) + } + }) + } +} + +func TestRedemptionDurationFieldsCannotBeMixed(t *testing.T) { + service := NewService(newRedemptionRepositoryStub(domainbilling.RedemptionCodeModeUsage)) + for _, input := range []RedemptionCodeInput{ + {Mode: "usage", CreditUSD: 20, DurationDays: 1}, + {Mode: "period", PlanID: 1, DurationDays: 30, BalanceDurationDays: 0.1}, + } { + if _, err := service.normalizeRedemptionCodeInput(t.Context(), input); !errors.Is(err, ErrInvalidRedemptionCode) { + t.Fatalf("mixed duration fields accepted: %+v, err=%v", input, err) + } + } +} + +func TestRedemptionSnapshotKeepsFractionalBalanceSeparate(t *testing.T) { + for _, raw := range []string{ + `{"reward_type":"balance","duration_days":0,"balance_duration_seconds":8640}`, + `{"reward_type":"balance","duration_days":0.1}`, + } { + if got := redemptionSnapshotBalanceDuration(raw); got != 8640 { + t.Fatalf("balance duration=%d", got) + } + if got := redemptionSnapshotDurationDays(raw); got != 0 { + t.Fatalf("subscription duration=%d", got) + } + } +} diff --git a/backend/internal/application/billing/service_redemption.go b/backend/internal/application/billing/service_redemption.go index 0331ff2ce..a1f9e3222 100644 --- a/backend/internal/application/billing/service_redemption.go +++ b/backend/internal/application/billing/service_redemption.go @@ -28,16 +28,17 @@ const ( // RedemptionCodeInput 定义管理员创建兑换码的入参。 type RedemptionCodeInput struct { - Code string - Quantity int - Mode string - CreditUSD float64 - PlanID uint - DurationDays int - MaxRedemptions *int - PerUserLimit int - ExpiresAt *time.Time - Description string + Code string + Quantity int + Mode string + CreditUSD float64 + PlanID uint + DurationDays int + BalanceDurationDays float64 + MaxRedemptions *int + PerUserLimit int + ExpiresAt *time.Time + Description string } // RedemptionCodeListInput 定义管理员查询兑换码列表的入参。 @@ -125,7 +126,8 @@ type RedemptionRecordView struct { CodeStatus string PlanName string // DurationDays 来自兑换时的快照,订阅类兑换表示套餐时长(天)。 - DurationDays int + DurationDays int + BalanceDurationSeconds int64 // BalanceAmountNanousd / BalanceAfterNanousd 来自余额流水;订阅类兑换无流水时为 nil。 BalanceAmountNanousd *int64 BalanceAfterNanousd *int64 @@ -160,14 +162,15 @@ func (s *Service) ListRedemptions(ctx context.Context, page int, pageSize int, f views := make([]RedemptionRecordView, 0, len(records)) for _, record := range records { views = append(views, RedemptionRecordView{ - Redemption: record.Redemption, - CodeHint: record.CodeHint, - CodeDescription: record.CodeDescription, - CodeStatus: record.CodeStatus, - PlanName: record.PlanName, - DurationDays: redemptionSnapshotDurationDays(record.Redemption.SnapshotJSON), - BalanceAmountNanousd: record.BalanceAmountNanousd, - BalanceAfterNanousd: record.BalanceAfterNanousd, + Redemption: record.Redemption, + CodeHint: record.CodeHint, + CodeDescription: record.CodeDescription, + CodeStatus: record.CodeStatus, + PlanName: record.PlanName, + DurationDays: redemptionSnapshotDurationDays(record.Redemption.SnapshotJSON), + BalanceDurationSeconds: redemptionSnapshotBalanceDuration(record.Redemption.SnapshotJSON), + BalanceAmountNanousd: record.BalanceAmountNanousd, + BalanceAfterNanousd: record.BalanceAfterNanousd, }) } return views, total, nil @@ -270,20 +273,21 @@ func (s *Service) CreateRedemptionCodes(ctx context.Context, actorUserID uint, i return nil, encryptErr } item := &domainbilling.RedemptionCode{ - CodeHash: codeHash, - CodeEncrypted: codeEncrypted, - CodeHint: redemptionCodeHint(code), - Mode: normalized.Mode, - RewardType: normalized.RewardType, - CreditNanousd: normalized.CreditNanousd, - PlanID: normalized.PlanID, - DurationDays: normalized.DurationDays, - MaxRedemptions: copyIntPointer(normalized.MaxRedemptions), - PerUserLimit: normalized.PerUserLimit, - Status: domainbilling.RedemptionCodeStatusActive, - ExpiresAt: normalized.ExpiresAt, - Description: normalized.Description, - CreatedByUserID: actorUserID, + CodeHash: codeHash, + CodeEncrypted: codeEncrypted, + CodeHint: redemptionCodeHint(code), + Mode: normalized.Mode, + RewardType: normalized.RewardType, + CreditNanousd: normalized.CreditNanousd, + PlanID: normalized.PlanID, + DurationDays: normalized.DurationDays, + BalanceDurationSeconds: normalized.BalanceDurationSeconds, + MaxRedemptions: copyIntPointer(normalized.MaxRedemptions), + PerUserLimit: normalized.PerUserLimit, + Status: domainbilling.RedemptionCodeStatusActive, + ExpiresAt: normalized.ExpiresAt, + Description: normalized.Description, + CreatedByUserID: actorUserID, } created, createErr := s.repo.CreateRedemptionCode(ctx, item) if createErr != nil { @@ -415,17 +419,18 @@ func (s *Service) RedeemCode(ctx context.Context, userID uint, code string) (*Re } type normalizedRedemptionCodeInput struct { - Code string - Quantity int - Mode string - RewardType string - CreditNanousd int64 - PlanID uint - DurationDays int - MaxRedemptions *int - PerUserLimit int - ExpiresAt *time.Time - Description string + Code string + Quantity int + Mode string + RewardType string + CreditNanousd int64 + PlanID uint + DurationDays int + BalanceDurationSeconds int64 + MaxRedemptions *int + PerUserLimit int + ExpiresAt *time.Time + Description string } func (s *Service) normalizeRedemptionCodeInput(ctx context.Context, input RedemptionCodeInput) (normalizedRedemptionCodeInput, error) { @@ -464,21 +469,32 @@ func (s *Service) normalizeRedemptionCodeInput(ctx context.Context, input Redemp mode := normalizeRedemptionMode(input.Mode) switch mode { case domainbilling.RedemptionCodeModeUsage: - if input.CreditUSD <= 0 || math.IsNaN(input.CreditUSD) || math.IsInf(input.CreditUSD, 0) { + if input.DurationDays != 0 { + return normalizedRedemptionCodeInput{}, redemptionCodeValidationError("balanceDurationDays", "duration") + } + if input.CreditUSD <= 0 || input.CreditUSD >= float64(math.MaxInt64)/1e9 || math.IsNaN(input.CreditUSD) || math.IsInf(input.CreditUSD, 0) { return normalizedRedemptionCodeInput{}, redemptionCodeValidationError("creditUSD", "credit") } + balanceDurationSeconds, validDuration := fractionalDaysToSeconds(input.BalanceDurationDays, true) + if !validDuration { + return normalizedRedemptionCodeInput{}, redemptionCodeValidationError("balanceDurationDays", "duration") + } return normalizedRedemptionCodeInput{ - Code: code, - Quantity: quantity, - Mode: mode, - RewardType: domainbilling.RedemptionRewardTypeBalance, - CreditNanousd: usdToNanousd(input.CreditUSD), - MaxRedemptions: maxRedemptions, - PerUserLimit: perUserLimit, - ExpiresAt: input.ExpiresAt, - Description: strings.TrimSpace(input.Description), + Code: code, + Quantity: quantity, + Mode: mode, + RewardType: domainbilling.RedemptionRewardTypeBalance, + CreditNanousd: usdToNanousd(input.CreditUSD), + BalanceDurationSeconds: balanceDurationSeconds, + MaxRedemptions: maxRedemptions, + PerUserLimit: perUserLimit, + ExpiresAt: input.ExpiresAt, + Description: strings.TrimSpace(input.Description), }, nil case domainbilling.RedemptionCodeModePeriod: + if input.BalanceDurationDays != 0 { + return normalizedRedemptionCodeInput{}, redemptionCodeValidationError("balanceDurationDays", "duration") + } if input.PlanID == 0 { return normalizedRedemptionCodeInput{}, redemptionCodeValidationError("planID", "plan") } @@ -492,10 +508,10 @@ func (s *Service) normalizeRedemptionCodeInput(ctx context.Context, input Redemp if !plan.IsActive || strings.TrimSpace(plan.Code) == "free" { return normalizedRedemptionCodeInput{}, redemptionCodeValidationError("planID", "plan") } - durationDays := input.DurationDays - if durationDays <= 0 { + if input.DurationDays <= 0 || input.DurationDays > 3650 { return normalizedRedemptionCodeInput{}, redemptionCodeValidationError("durationDays", "duration") } + durationDays := int(input.DurationDays) return normalizedRedemptionCodeInput{ Code: code, Quantity: quantity, @@ -662,3 +678,20 @@ func mapRedemptionRepositoryError(err error) error { return err } } + +func redemptionSnapshotBalanceDuration(raw string) int64 { + var snapshot struct { + RewardType string `json:"reward_type"` + DurationDays float64 `json:"duration_days"` + BalanceDurationSeconds int64 `json:"balance_duration_seconds"` + } + if json.Unmarshal([]byte(raw), &snapshot) != nil || snapshot.RewardType != domainbilling.RedemptionRewardTypeBalance { + return 0 + } + if snapshot.BalanceDurationSeconds > 0 { + return snapshot.BalanceDurationSeconds + } + // Old custom snapshots stored fractional balance lifetimes in duration_days. + seconds, _ := fractionalDaysToSeconds(snapshot.DurationDays, true) + return seconds +} diff --git a/backend/internal/domain/billing/types.go b/backend/internal/domain/billing/types.go index 7ab7f5e88..da84765c5 100644 --- a/backend/internal/domain/billing/types.go +++ b/backend/internal/domain/billing/types.go @@ -133,6 +133,10 @@ const ( BalanceTransactionTypeAdminSet = "admin_set" // BalanceTransactionTypeRedemption 表示兑换码入账。 BalanceTransactionTypeRedemption = "redemption" + // BalanceTransactionTypeExpiringCreditGrant 表示限时额度入账。 + BalanceTransactionTypeExpiringCreditGrant = "expiring_credit_grant" + // BalanceTransactionTypeExpiringCreditExpire 表示限时额度到期回收。 + BalanceTransactionTypeExpiringCreditExpire = "expiring_credit_expire" // UsageReservationStatusActive 表示用量预算仍被当前请求占用。 UsageReservationStatusActive = "active" @@ -201,6 +205,31 @@ type BalanceTransaction struct { UpdatedAt time.Time } +const ( + ExpiringCreditStatusActive = "active" + ExpiringCreditStatusConsumed = "consumed" + ExpiringCreditStatusExpired = "expired" + ExpiringCreditStatusRevoked = "revoked" +) + +// ExpiringCredit 表示一笔具有独立到期时间的限时额度。 +type ExpiringCredit struct { + ID uint + AccountID uint + UserID uint + OriginalNanousd int64 + RemainingNanousd int64 + Status string + ExpiresAt time.Time + ConsumedAt *time.Time + ExpiredAt *time.Time + CreatedByUserID uint + RefNo string + Description string + CreatedAt time.Time + UpdatedAt time.Time +} + const ( // RedemptionCodeModeUsage 表示按量余额兑换码。 RedemptionCodeModeUsage = "usage" @@ -222,24 +251,25 @@ const ( // RedemptionCode 表示一组可兑换的计费权益码。 type RedemptionCode struct { - ID uint - CodeHash string - CodeEncrypted string - CodeHint string - Mode string - RewardType string - CreditNanousd int64 - PlanID uint - DurationDays int - MaxRedemptions *int - PerUserLimit int - RedeemedCount int - Status string - ExpiresAt *time.Time - Description string - CreatedByUserID uint - CreatedAt time.Time - UpdatedAt time.Time + ID uint + CodeHash string + CodeEncrypted string + CodeHint string + Mode string + RewardType string + CreditNanousd int64 + PlanID uint + DurationDays int + BalanceDurationSeconds int64 + MaxRedemptions *int + PerUserLimit int + RedeemedCount int + Status string + ExpiresAt *time.Time + Description string + CreatedByUserID uint + CreatedAt time.Time + UpdatedAt time.Time } // Redemption 表示用户的一次兑换记录。 diff --git a/backend/internal/infra/persistence/models/billing.go b/backend/internal/infra/persistence/models/billing.go index 625f3548a..df46365b3 100644 --- a/backend/internal/infra/persistence/models/billing.go +++ b/backend/internal/infra/persistence/models/billing.go @@ -131,6 +131,28 @@ func (BalanceTransaction) TableName() string { return "billing_balance_transactions" } +// ExpiringCredit 记录一笔具有独立到期时间的限时余额。 +type ExpiringCredit struct { + BaseModel + AccountID uint `gorm:"not null;index:idx_billing_expiring_credits_account_id;comment:余额账户ID"` + UserID uint `gorm:"not null;index:idx_billing_expiring_credits_user_status_expiry,priority:1;uniqueIndex:idx_expiring_credit_user_ref,priority:1,where:ref_no <> '';comment:用户ID"` + GrantDurationSeconds int64 `gorm:"not null;default:0;comment:管理员发放请求时长(秒),用于幂等校验"` + OriginalNanousd int64 `gorm:"not null;comment:初始发放金额(纳美元)"` + RemainingNanousd int64 `gorm:"not null;default:0;comment:尚未消费且待回收金额(纳美元)"` + Status string `gorm:"size:24;not null;default:'active';index:idx_billing_expiring_credits_user_status_expiry,priority:2;index:idx_billing_expiring_credits_status_due,priority:1;comment:状态(active/consumed/expired/revoked)"` + ExpiresAt time.Time `gorm:"not null;index:idx_billing_expiring_credits_user_status_expiry,priority:3;index:idx_billing_expiring_credits_status_due,priority:2;index:idx_billing_expiring_credits_due;comment:到期时间"` + ConsumedAt *time.Time `gorm:"comment:全部消费时间"` + ExpiredAt *time.Time `gorm:"comment:到期回收时间"` + CreatedByUserID uint `gorm:"not null;default:0;index:idx_billing_expiring_credits_created_by;comment:发放管理员ID"` + RefNo string `gorm:"size:128;not null;default:'';index:idx_billing_expiring_credits_ref_no;uniqueIndex:idx_expiring_credit_user_ref,priority:2,where:ref_no <> '';comment:发放幂等/审计编号"` + Description string `gorm:"size:255;not null;default:'';comment:发放说明"` +} + +// TableName 指定表名。 +func (ExpiringCredit) TableName() string { + return "billing_expiring_credits" +} + // UsageReservation 记录模型调用在结算前占用的余额与周期额度预算。 type UsageReservation struct { BaseModel @@ -159,21 +181,22 @@ func (UsageReservation) TableName() string { // RedemptionCode 记录管理员创建的兑换码定义。 type RedemptionCode struct { BaseModel - CodeHash string `gorm:"size:64;not null;uniqueIndex:idx_billing_redemption_codes_hash;comment:兑换码HMAC-SHA256哈希"` - CodeEncrypted string `gorm:"type:text;not null;default:'';comment:AES-GCM加密后的兑换码明文"` - CodeHint string `gorm:"size:32;not null;default:'';comment:兑换码展示提示,不包含完整明文"` - Mode string `gorm:"size:16;not null;default:'usage';index:idx_billing_redemption_codes_mode;comment:适用计费模式(usage/period)"` - RewardType string `gorm:"size:32;not null;default:'balance';index:idx_billing_redemption_codes_reward_type;comment:奖励类型(balance/subscription)"` - CreditNanousd int64 `gorm:"not null;default:0;comment:余额奖励金额(纳美元)"` - PlanID uint `gorm:"not null;default:0;index:idx_billing_redemption_codes_plan_id;comment:订阅套餐ID"` - DurationDays int `gorm:"not null;default:0;comment:订阅有效天数"` - MaxRedemptions *int `gorm:"comment:总兑换次数上限,空表示不限"` - PerUserLimit int `gorm:"not null;default:1;comment:单用户兑换次数上限"` - RedeemedCount int `gorm:"not null;default:0;comment:已兑换次数"` - Status string `gorm:"size:32;not null;default:'active';index:idx_billing_redemption_codes_status;comment:状态(active/inactive/deleted)"` - ExpiresAt *time.Time `gorm:"index:idx_billing_redemption_codes_expires_at;comment:过期时间"` - Description string `gorm:"size:255;not null;default:'';comment:兑换码说明"` - CreatedByUserID uint `gorm:"not null;default:0;index:idx_billing_redemption_codes_created_by;comment:创建管理员ID"` + CodeHash string `gorm:"size:64;not null;uniqueIndex:idx_billing_redemption_codes_hash;comment:兑换码HMAC-SHA256哈希"` + CodeEncrypted string `gorm:"type:text;not null;default:'';comment:AES-GCM加密后的兑换码明文"` + CodeHint string `gorm:"size:32;not null;default:'';comment:兑换码展示提示,不包含完整明文"` + Mode string `gorm:"size:16;not null;default:'usage';index:idx_billing_redemption_codes_mode;comment:适用计费模式(usage/period)"` + RewardType string `gorm:"size:32;not null;default:'balance';index:idx_billing_redemption_codes_reward_type;comment:奖励类型(balance/subscription)"` + CreditNanousd int64 `gorm:"not null;default:0;comment:余额奖励金额(纳美元)"` + PlanID uint `gorm:"not null;default:0;index:idx_billing_redemption_codes_plan_id;comment:订阅套餐ID"` + DurationDays int `gorm:"not null;default:0;comment:奖励有效天数,余额为0表示永久"` + BalanceDurationSeconds int64 `gorm:"not null;default:0;comment:余额奖励有效秒数,0表示永久"` + MaxRedemptions *int `gorm:"comment:总兑换次数上限,空表示不限"` + PerUserLimit int `gorm:"not null;default:1;comment:单用户兑换次数上限"` + RedeemedCount int `gorm:"not null;default:0;comment:已兑换次数"` + Status string `gorm:"size:32;not null;default:'active';index:idx_billing_redemption_codes_status;comment:状态(active/inactive/deleted)"` + ExpiresAt *time.Time `gorm:"index:idx_billing_redemption_codes_expires_at;comment:过期时间"` + Description string `gorm:"size:255;not null;default:'';comment:兑换码说明"` + CreatedByUserID uint `gorm:"not null;default:0;index:idx_billing_redemption_codes_created_by;comment:创建管理员ID"` } // TableName 指定表名。 diff --git a/backend/internal/infra/persistence/models/table_names_test.go b/backend/internal/infra/persistence/models/table_names_test.go index 2bcd4ad76..1cddbd213 100644 --- a/backend/internal/infra/persistence/models/table_names_test.go +++ b/backend/internal/infra/persistence/models/table_names_test.go @@ -34,6 +34,7 @@ func TestTableNamesUseRestructuredDomains(t *testing.T) { PaymentOrder{}, BillingAccount{}, BalanceTransaction{}, + ExpiringCredit{}, RedemptionCode{}, Redemption{}, ModelPricing{}, diff --git a/backend/internal/infra/persistence/postgres/billing/expiring_credit_regression_test.go b/backend/internal/infra/persistence/postgres/billing/expiring_credit_regression_test.go new file mode 100644 index 000000000..b130a4b50 --- /dev/null +++ b/backend/internal/infra/persistence/postgres/billing/expiring_credit_regression_test.go @@ -0,0 +1,220 @@ +package billing + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + domainbilling "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/billing" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/persistence/models" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/repository" + "gorm.io/gorm" +) + +func grantRegressionCredit(t *testing.T, repo *Repo, userID uint) *domainbilling.ExpiringCredit { + t.Helper() + credit, _, err := repo.GrantExpiringCredit(t.Context(), repository.ExpiringCreditGrantInput{ + UserID: userID, AmountNanousd: 100, DurationSeconds: 8640, CreatedByUserID: 9, RefNo: "campaign", + }) + if err != nil { + t.Fatal(err) + } + return credit +} + +func makeCreditDue(t *testing.T, db *gorm.DB, creditID uint) { + t.Helper() + if err := db.Model(&models.ExpiringCredit{}).Where("id = ?", creditID). + Update("expires_at", time.Now().Add(-time.Minute)).Error; err != nil { + t.Fatal(err) + } +} + +func TestExpiringGrantConcurrentRetryIsIdempotent(t *testing.T) { + db := openBillingSQLiteTestDB(t) + repo := NewRepo(db) + input := repository.ExpiringCreditGrantInput{UserID: 1, AmountNanousd: 100, DurationSeconds: 8640, CreatedByUserID: 9, RefNo: "retry-key"} + var wg sync.WaitGroup + errs := make(chan error, 8) + for range 8 { + wg.Add(1) + go func() { + defer wg.Done() + _, _, err := repo.GrantExpiringCredit(context.Background(), input) + errs <- err + }() + } + wg.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Fatal(err) + } + } + assertBillingBalance(t, db, 1, 100) + var count int64 + if err := db.Model(&models.BalanceTransaction{}).Where("type = ?", domainbilling.BalanceTransactionTypeExpiringCreditGrant).Count(&count).Error; err != nil { + t.Fatal(err) + } + if count != 1 { + t.Fatalf("grant ledger entries = %d, want 1", count) + } + input.DurationSeconds *= 2 + if _, _, err := repo.GrantExpiringCredit(t.Context(), input); !errors.Is(err, repository.ErrConflict) { + t.Fatalf("changed duration: %v", err) + } + assertBillingBalance(t, db, 1, 100) +} + +func TestRaiseReservationRechecksExpiredCreditEvenBelowExistingBudget(t *testing.T) { + for _, required := range []int64{30, 90} { + t.Run(time.Duration(required).String(), func(t *testing.T) { + db := openBillingSQLiteTestDB(t) + repo := NewRepo(db) + credit := grantRegressionCredit(t, repo, 1) + _, err := repo.ReserveUsageBalance(t.Context(), domainbilling.UsageBalanceReservationRequest{UserID: 1, RefNo: "run", Mode: "usage", RequestedNanousd: 50}) + if err != nil { + t.Fatal(err) + } + makeCreditDue(t, db, credit.ID) + if err = repo.RaiseUsageBalanceReservation(t.Context(), 1, "run", required); !errors.Is(err, repository.ErrInsufficientBalance) { + t.Fatalf("raise = %v", err) + } + account, err := repo.GetOrCreateBillingAccount(t.Context(), 1) + if err != nil || account.BalanceNanousd != 0 { + t.Fatalf("account = %+v, error = %v", account, err) + } + }) + } +} + +func TestAccountListExpiresOnlyRequestedUsers(t *testing.T) { + db := openBillingSQLiteTestDB(t) + repo := NewRepo(db) + one, two := grantRegressionCredit(t, repo, 1), grantRegressionCredit(t, repo, 2) + makeCreditDue(t, db, one.ID) + makeCreditDue(t, db, two.ID) + accounts, err := repo.ListBillingAccountsByUserIDs(t.Context(), []uint{1}) + if err != nil || len(accounts) != 1 || accounts[0].BalanceNanousd != 0 { + t.Fatalf("accounts = %+v, error = %v", accounts, err) + } + assertExpiringCredit(t, db, two.ID, domainbilling.ExpiringCreditStatusActive, 100) + assertBillingBalance(t, db, 2, 100) +} + +func TestExpiryAndLedgerRollbackTogether(t *testing.T) { + db := openBillingSQLiteTestDB(t) + repo := NewRepo(db) + credit := grantRegressionCredit(t, repo, 1) + makeCreditDue(t, db, credit.ID) + if err := db.Exec(`CREATE TRIGGER fail_expiry_ledger BEFORE INSERT ON billing_balance_transactions + WHEN NEW.type = 'expiring_credit_expire' BEGIN SELECT RAISE(ABORT, 'injected ledger failure'); END`).Error; err != nil { + t.Fatal(err) + } + count, err := repo.ExpireDueExpiringCredits(t.Context(), time.Now(), 10) + if err == nil || count != 0 { + t.Fatalf("count=%d err=%v", count, err) + } + assertBillingBalance(t, db, 1, 100) + assertExpiringCredit(t, db, credit.ID, domainbilling.ExpiringCreditStatusActive, 100) +} + +func TestConcurrentSweepDeductsOnce(t *testing.T) { + db := openBillingSQLiteTestDB(t) + repo := NewRepo(db) + credit := grantRegressionCredit(t, repo, 1) + makeCreditDue(t, db, credit.ID) + var wg sync.WaitGroup + for range 4 { + wg.Add(1) + go func() { + defer wg.Done() + if _, err := repo.ExpireDueExpiringCredits(context.Background(), time.Now(), 10); err != nil { + t.Error(err) + } + }() + } + wg.Wait() + assertBillingBalance(t, db, 1, 0) + var count int64 + if err := db.Model(&models.BalanceTransaction{}).Where("type = ?", domainbilling.BalanceTransactionTypeExpiringCreditExpire).Count(&count).Error; err != nil { + t.Fatal(err) + } + if count != 1 { + t.Fatalf("expiry entries=%d", count) + } +} + +func TestSettlementAfterExpiryUsesPermanentBalanceAndIsIdempotent(t *testing.T) { + db := openBillingSQLiteTestDB(t) + repo := NewRepo(db) + createBillingAccount(t, db, 1, 200) + credit := grantRegressionCredit(t, repo, 1) + reservation, err := repo.ReserveUsageBalance(t.Context(), domainbilling.UsageBalanceReservationRequest{UserID: 1, RefNo: "late-run", Mode: "usage", RequestedNanousd: 50}) + if err != nil { + t.Fatal(err) + } + makeCreditDue(t, db, credit.ID) + usage := &domainbilling.UsageLedger{UserID: 1, PlatformModelName: "mcp:test", BillingAt: time.Now(), UsageDate: time.Now(), BilledCurrency: "USD", BilledNanousd: 30} + for range 2 { + if err := repo.AddUsageAndSettleBalance(t.Context(), usage, reservation); err != nil { + t.Fatal(err) + } + } + assertBillingBalance(t, db, 1, 170) + assertExpiringCredit(t, db, credit.ID, domainbilling.ExpiringCreditStatusExpired, 0) +} + +func TestPeriodOverageConsumesExpiringCredit(t *testing.T) { + db := openBillingSQLiteTestDB(t) + repo := NewRepo(db) + credit := grantRegressionCredit(t, repo, 1) + now := time.Now() + usage := &domainbilling.UsageLedger{UserID: 1, PlatformModelName: "period-test", BillingAt: now, UsageDate: now, BilledCurrency: "USD", BilledNanousd: 70} + if err := repo.AddPeriodUsageAndSettleOverage(t.Context(), usage, now.Add(-time.Hour), now.Add(time.Hour), 40, nil); err != nil { + t.Fatal(err) + } + assertBillingBalance(t, db, 1, 70) + assertExpiringCredit(t, db, credit.ID, domainbilling.ExpiringCreditStatusActive, 70) +} + +func TestExpiringGrantPaysDebtWithoutExpiringFutureTopups(t *testing.T) { + db := openBillingSQLiteTestDB(t) + repo := NewRepo(db) + createBillingAccount(t, db, 1, -50) + credit := grantRegressionCredit(t, repo, 1) + assertExpiringCredit(t, db, credit.ID, domainbilling.ExpiringCreditStatusActive, 50) + assertBillingBalance(t, db, 1, 50) + // Adding permanent credit must not revive the 50 already used to pay debt. + if _, err := repo.SetBillingAccountBalance(t.Context(), 1, 150, "permanent", ""); err != nil { + t.Fatal(err) + } + makeCreditDue(t, db, credit.ID) + if _, err := repo.ExpireDueExpiringCredits(t.Context(), time.Now(), 10); err != nil { + t.Fatal(err) + } + assertBillingBalance(t, db, 1, 100) +} + +func TestPaymentAfterExpiryUsesCurrentTimeNotPaymentTimestamp(t *testing.T) { + db := openBillingSQLiteTestDB(t) + if err := db.AutoMigrate(&models.PaymentOrder{}); err != nil { + t.Fatal(err) + } + repo := NewRepo(db) + credit := grantRegressionCredit(t, repo, 1) + makeCreditDue(t, db, credit.ID) + order := models.PaymentOrder{OrderNo: "delayed-payment", UserID: 1, + OrderType: domainbilling.PaymentOrderTypeTopUp, Status: domainbilling.PaymentStatusPending, + CreditNanousd: 60} + if err := db.Create(&order).Error; err != nil { + t.Fatal(err) + } + if _, credited, err := repo.MarkPaymentOrderPaidAndCreditBalance(t.Context(), order.OrderNo, "provider-id", time.Now().Add(-time.Hour)); err != nil || !credited { + t.Fatalf("credited=%v err=%v", credited, err) + } + assertBillingBalance(t, db, 1, 60) + assertExpiringCredit(t, db, credit.ID, domainbilling.ExpiringCreditStatusExpired, 0) +} diff --git a/backend/internal/infra/persistence/postgres/billing/expiring_credit_review_test.go b/backend/internal/infra/persistence/postgres/billing/expiring_credit_review_test.go new file mode 100644 index 000000000..1089c097f --- /dev/null +++ b/backend/internal/infra/persistence/postgres/billing/expiring_credit_review_test.go @@ -0,0 +1,64 @@ +package billing + +import ( + "encoding/json" + "sync/atomic" + "testing" + + domainbilling "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/billing" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/persistence/models" + "gorm.io/gorm" +) + +func TestAccountListBatchesExpirationChecks(t *testing.T) { + db := openBillingSQLiteTestDB(t) + repo := NewRepo(db) + userIDs := make([]uint, 32) + for i := range userIDs { + userIDs[i] = uint(i + 1) + createBillingAccount(t, db, userIDs[i], 100) + } + credit := grantRegressionCredit(t, repo, userIDs[0]) + makeCreditDue(t, db, credit.ID) + + var queries atomic.Int32 + const callback = "test:count-expiration-queries" + if err := db.Callback().Query().After("gorm:query").Register(callback, func(tx *gorm.DB) { + if tx.Statement.Table == "billing_expiring_credits" { + queries.Add(1) + } + }); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Callback().Query().Remove(callback) }) + items, err := repo.ListBillingAccountsByUserIDs(t.Context(), userIDs) + if err != nil || len(items) != len(userIDs) { + t.Fatalf("account list: count=%d err=%v", len(items), err) + } + if n := queries.Load(); n > 3 { + t.Fatalf("expiration lookups scale with account count: %d queries for %d accounts", n, len(userIDs)) + } + for _, item := range items { + if item.BalanceNanousd != 100 { + t.Fatalf("user %d: balance=%d, want 100", item.UserID, item.BalanceNanousd) + } + } + assertExpiringCredit(t, db, credit.ID, domainbilling.ExpiringCreditStatusExpired, 0) +} + +func TestSubscriptionSnapshotDoesNotAcquireBalanceLifetime(t *testing.T) { + code := models.RedemptionCode{ + Mode: domainbilling.RedemptionCodeModePeriod, RewardType: domainbilling.RedemptionRewardTypeSubscription, + DurationDays: 30, + } + var snapshot struct { + DurationDays int `json:"duration_days"` + BalanceDurationSeconds int64 `json:"balance_duration_seconds"` + } + if err := json.Unmarshal([]byte(redemptionSnapshotJSON(code)), &snapshot); err != nil { + t.Fatal(err) + } + if snapshot.DurationDays != 30 || snapshot.BalanceDurationSeconds != 0 { + t.Fatalf("subscription acquired a balance lifetime: %+v", snapshot) + } +} diff --git a/backend/internal/infra/persistence/postgres/billing/repository.go b/backend/internal/infra/persistence/postgres/billing/repository.go index c92eac096..cf4083b40 100644 --- a/backend/internal/infra/persistence/postgres/billing/repository.go +++ b/backend/internal/infra/persistence/postgres/billing/repository.go @@ -516,7 +516,7 @@ func (r *Repo) AddUsageAndSettleBalance(ctx context.Context, usage *domainbillin if usage.IsFreeModel || chargeNanousd <= 0 { chargeNanousd = 0 } - account, err := getOrCreateBillingAccountForUpdate(tx, usage.UserID) + account, now, err := getOrCreateBillingAccountForUpdate(tx, usage.UserID) if err != nil { return err } @@ -543,6 +543,9 @@ func (r *Repo) AddUsageAndSettleBalance(ctx context.Context, usage *domainbillin return dberror.Translate(err) } if chargeNanousd > 0 { + if err := reduceActiveExpiringCredits(tx, usage.UserID, chargeNanousd, now, domainbilling.ExpiringCreditStatusConsumed); err != nil { + return err + } if err := tx.Model(account).Updates(map[string]any{ "balance_nanousd": gorm.Expr("balance_nanousd - ?", chargeNanousd), "currency": "USD", @@ -587,7 +590,7 @@ func (r *Repo) AddPeriodUsageAndSettleOverage( settledSnapshotJSON := "" err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { // 余额账户行锁是周期额度结算的串行化锚点,确保同一用户并发请求按提交顺序重算已用额度。 - account, err := getOrCreateBillingAccountForUpdate(tx, usage.UserID) + account, now, err := getOrCreateBillingAccountForUpdate(tx, usage.UserID) if err != nil { return err } @@ -673,6 +676,9 @@ func (r *Repo) AddPeriodUsageAndSettleOverage( return dberror.Translate(err) } if overageNanousd > 0 { + if err := reduceActiveExpiringCredits(tx, usage.UserID, overageNanousd, now, domainbilling.ExpiringCreditStatusConsumed); err != nil { + return err + } // 超出周期额度的真实用量必须完整入账;预留仅限制并发风险,不改变最终扣费金额。 // 扣减使用表达式更新而非内存值写回,避免任何绕开行锁的并发写导致覆盖。 if err := tx.Model(account).Updates(map[string]any{ @@ -755,7 +761,7 @@ func (r *Repo) GetOrCreateBillingAccount(ctx context.Context, userID uint) (*dom } var result *domainbilling.BillingAccount err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - account, err := getOrCreateBillingAccountForUpdate(tx, userID) + account, _, err := getOrCreateBillingAccountForUpdate(tx, userID) if err != nil { return err } @@ -772,9 +778,35 @@ func (r *Repo) ListBillingAccountsByUserIDs(ctx context.Context, userIDs []uint) return []domainbilling.BillingAccount{}, nil } items := make([]models.BillingAccount, 0, len(userIDs)) - if err := r.db.WithContext(ctx). - Where("user_id IN ?", userIDs). - Find(&items).Error; err != nil { + if err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("user_id IN ?", userIDs).Order("user_id ASC").Find(&items).Error; err != nil { + return err + } + if len(items) == 0 { + return nil + } + now := time.Now() + var dueUserIDs []uint + if err := tx.Model(&models.ExpiringCredit{}). + Where("user_id IN ? AND status = ? AND remaining_nanousd > 0 AND expires_at <= ?", userIDs, domainbilling.ExpiringCreditStatusActive, now). + Distinct("user_id").Pluck("user_id", &dueUserIDs).Error; err != nil { + return err + } + dueUsers := make(map[uint]struct{}, len(dueUserIDs)) + for _, userID := range dueUserIDs { + dueUsers[userID] = struct{}{} + } + for i := range items { + if _, due := dueUsers[items[i].UserID]; !due { + continue + } + if _, err := expireDueExpiringCreditsForAccount(tx, &items[i], now); err != nil { + return err + } + } + return nil + }); err != nil { return nil, dberror.Translate(err) } results := make([]domainbilling.BillingAccount, 0, len(items)) @@ -791,11 +823,16 @@ func (r *Repo) SetBillingAccountBalance(ctx context.Context, userID uint, balanc } var result domainbilling.BillingAccount err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - account, err := getOrCreateBillingAccountForUpdate(tx, userID) + account, now, err := getOrCreateBillingAccountForUpdate(tx, userID) if err != nil { return err } amount := balanceNanousd - account.BalanceNanousd + if amount < 0 { + if err = reduceActiveExpiringCredits(tx, userID, -amount, now, domainbilling.ExpiringCreditStatusRevoked); err != nil { + return err + } + } if err := tx.Model(account).Updates(map[string]any{ "balance_nanousd": balanceNanousd, "currency": "USD", @@ -868,7 +905,7 @@ func (r *Repo) MarkPaymentOrderPaidAndCreditBalance( return repository.ErrInvalidInput } - account, err := getOrCreateBillingAccountForUpdate(tx, order.UserID) + account, _, err := getOrCreateBillingAccountForUpdate(tx, order.UserID) if err != nil { return err } @@ -1104,20 +1141,21 @@ func (r *Repo) CreateRedemptionCode(ctx context.Context, item *domainbilling.Red return nil, repository.ErrInvalidInput } record := models.RedemptionCode{ - CodeHash: strings.TrimSpace(item.CodeHash), - CodeEncrypted: strings.TrimSpace(item.CodeEncrypted), - CodeHint: strings.TrimSpace(item.CodeHint), - Mode: normalizeRedemptionMode(item.Mode), - RewardType: normalizeRedemptionRewardType(item.RewardType), - CreditNanousd: clampNonNegative(item.CreditNanousd), - PlanID: item.PlanID, - DurationDays: item.DurationDays, - MaxRedemptions: copyIntPointer(item.MaxRedemptions), - PerUserLimit: item.PerUserLimit, - Status: normalizeRedemptionStatus(item.Status), - ExpiresAt: item.ExpiresAt, - Description: strings.TrimSpace(item.Description), - CreatedByUserID: item.CreatedByUserID, + CodeHash: strings.TrimSpace(item.CodeHash), + CodeEncrypted: strings.TrimSpace(item.CodeEncrypted), + CodeHint: strings.TrimSpace(item.CodeHint), + Mode: normalizeRedemptionMode(item.Mode), + RewardType: normalizeRedemptionRewardType(item.RewardType), + CreditNanousd: clampNonNegative(item.CreditNanousd), + PlanID: item.PlanID, + DurationDays: item.DurationDays, + BalanceDurationSeconds: item.BalanceDurationSeconds, + MaxRedemptions: copyIntPointer(item.MaxRedemptions), + PerUserLimit: item.PerUserLimit, + Status: normalizeRedemptionStatus(item.Status), + ExpiresAt: item.ExpiresAt, + Description: strings.TrimSpace(item.Description), + CreatedByUserID: item.CreatedByUserID, } if record.PerUserLimit <= 0 { record.PerUserLimit = 1 @@ -1257,7 +1295,7 @@ func (r *Repo) RedeemCode(ctx context.Context, input repository.RedemptionApplyI switch code.RewardType { case domainbilling.RedemptionRewardTypeBalance: - account, balanceTxID, applyErr := applyRedemptionBalance(tx, input.UserID, code, redemption.RefNo) + account, balanceTxID, applyErr := applyRedemptionBalance(tx, input.UserID, code, redemption.RefNo, now) if applyErr != nil { return applyErr } @@ -2328,7 +2366,7 @@ func toDomainUsageLedger(item models.UsageLedger) domainbilling.UsageLedger { } } -func getOrCreateBillingAccountForUpdate(tx *gorm.DB, userID uint) (*models.BillingAccount, error) { +func lockBillingAccount(tx *gorm.DB, userID uint) (*models.BillingAccount, error) { if userID == 0 { return nil, repository.ErrInvalidInput } @@ -2390,24 +2428,25 @@ func toDomainSubscription(item models.Subscription) domainbilling.Subscription { func toDomainRedemptionCode(item models.RedemptionCode) domainbilling.RedemptionCode { return domainbilling.RedemptionCode{ - ID: item.ID, - CodeHash: item.CodeHash, - CodeEncrypted: item.CodeEncrypted, - CodeHint: item.CodeHint, - Mode: item.Mode, - RewardType: item.RewardType, - CreditNanousd: item.CreditNanousd, - PlanID: item.PlanID, - DurationDays: item.DurationDays, - MaxRedemptions: copyIntPointer(item.MaxRedemptions), - PerUserLimit: item.PerUserLimit, - RedeemedCount: item.RedeemedCount, - Status: item.Status, - ExpiresAt: item.ExpiresAt, - Description: item.Description, - CreatedByUserID: item.CreatedByUserID, - CreatedAt: item.CreatedAt, - UpdatedAt: item.UpdatedAt, + ID: item.ID, + CodeHash: item.CodeHash, + CodeEncrypted: item.CodeEncrypted, + CodeHint: item.CodeHint, + Mode: item.Mode, + RewardType: item.RewardType, + CreditNanousd: item.CreditNanousd, + PlanID: item.PlanID, + DurationDays: item.DurationDays, + BalanceDurationSeconds: item.BalanceDurationSeconds, + MaxRedemptions: copyIntPointer(item.MaxRedemptions), + PerUserLimit: item.PerUserLimit, + RedeemedCount: item.RedeemedCount, + Status: item.Status, + ExpiresAt: item.ExpiresAt, + Description: item.Description, + CreatedByUserID: item.CreatedByUserID, + CreatedAt: item.CreatedAt, + UpdatedAt: item.UpdatedAt, } } @@ -2466,11 +2505,25 @@ func validateRedeemableCode(tx *gorm.DB, code models.RedemptionCode, userID uint return nil } -func applyRedemptionBalance(tx *gorm.DB, userID uint, code models.RedemptionCode, refNo string) (*models.BillingAccount, uint, error) { - account, err := getOrCreateBillingAccountForUpdate(tx, userID) +func applyRedemptionBalance(tx *gorm.DB, userID uint, code models.RedemptionCode, refNo string, redeemedAt time.Time) (*models.BillingAccount, uint, error) { + account, now, err := getOrCreateBillingAccountForUpdate(tx, userID) if err != nil { return nil, 0, err } + if redeemedAt.IsZero() { + redeemedAt = now + } + if code.CreditNanousd <= 0 || account.BalanceNanousd > math.MaxInt64-code.CreditNanousd { + return nil, 0, repository.ErrInvalidInput + } + balanceDurationSeconds := effectiveBalanceDurationSeconds(code) + if balanceDurationSeconds > 0 { + if _, err := createExpiringCreditBatch(tx, account, code.CreditNanousd, + redeemedAt.Add(time.Duration(balanceDurationSeconds)*time.Second), code.CreatedByUserID, refNo, + textutil.FirstNonEmpty(strings.TrimSpace(code.Description), "兑换码限时额度"), balanceDurationSeconds); err != nil { + return nil, 0, err + } + } if err := tx.Model(account).Updates(map[string]any{ "balance_nanousd": gorm.Expr("balance_nanousd + ?", code.CreditNanousd), "currency": "USD", @@ -2498,6 +2551,19 @@ func applyRedemptionBalance(tx *gorm.DB, userID uint, code models.RedemptionCode return account, transaction.ID, nil } +func effectiveBalanceDurationSeconds(code models.RedemptionCode) int64 { + if code.RewardType != domainbilling.RedemptionRewardTypeBalance { + return 0 + } + if code.BalanceDurationSeconds > 0 { + return code.BalanceDurationSeconds + } + if code.DurationDays > 0 { + return int64(code.DurationDays) * 24 * 60 * 60 + } + return 0 +} + func applyRedemptionSubscription(tx *gorm.DB, userID uint, code models.RedemptionCode, now time.Time) (*models.Subscription, error) { var plan models.BillingPlan if err := tx.Where("id = ? AND is_active = ?", code.PlanID, true).First(&plan).Error; err != nil { @@ -3012,13 +3078,14 @@ func subscriptionPlanRank(plan models.BillingPlan) int { func redemptionSnapshotJSON(code models.RedemptionCode) string { payload := map[string]any{ - "code_id": code.ID, - "mode": code.Mode, - "reward_type": code.RewardType, - "credit_nanousd": code.CreditNanousd, - "plan_id": code.PlanID, - "duration_days": code.DurationDays, - "description": strings.TrimSpace(code.Description), + "code_id": code.ID, + "mode": code.Mode, + "reward_type": code.RewardType, + "credit_nanousd": code.CreditNanousd, + "plan_id": code.PlanID, + "duration_days": code.DurationDays, + "balance_duration_seconds": effectiveBalanceDurationSeconds(code), + "description": strings.TrimSpace(code.Description), } raw, err := json.Marshal(payload) if err != nil { diff --git a/backend/internal/infra/persistence/postgres/billing/repository_expiring_credit.go b/backend/internal/infra/persistence/postgres/billing/repository_expiring_credit.go new file mode 100644 index 000000000..0871e12e6 --- /dev/null +++ b/backend/internal/infra/persistence/postgres/billing/repository_expiring_credit.go @@ -0,0 +1,292 @@ +package billing + +import ( + "context" + "errors" + "math" + "strings" + "time" + + domainbilling "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/billing" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/persistence/dberror" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/persistence/models" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/pkg/textutil" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/repository" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// GrantExpiringCredit 发放一笔独立到期的限时额度,并同步增加账户总余额。 +func (r *Repo) GrantExpiringCredit(ctx context.Context, input repository.ExpiringCreditGrantInput) (*domainbilling.ExpiringCredit, *domainbilling.BillingAccount, error) { + userID, amountNanousd := input.UserID, input.AmountNanousd + createdByUserID, refNo, description := input.CreatedByUserID, input.RefNo, input.Description + if input.DurationSeconds < 8640 || input.DurationSeconds > 3650*86400 { + return nil, nil, repository.ErrInvalidInput + } + if userID == 0 || amountNanousd <= 0 { + return nil, nil, repository.ErrInvalidInput + } + var creditResult domainbilling.ExpiringCredit + var accountResult domainbilling.BillingAccount + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + account, now, err := getOrCreateBillingAccountForUpdate(tx, userID) + if err != nil { + return err + } + refNo = strings.TrimSpace(refNo) + if refNo != "" { + var existing models.ExpiringCredit + err = tx.Where("user_id = ? AND ref_no = ?", userID, refNo).First(&existing).Error + if err == nil { + if existing.OriginalNanousd != amountNanousd || existing.CreatedByUserID != createdByUserID || + existing.Description != textutil.FirstNonEmpty(strings.TrimSpace(description), "管理员发放限时额度") || + existing.GrantDurationSeconds != input.DurationSeconds { + return repository.ErrConflict + } + creditResult = toDomainExpiringCredit(existing) + accountResult = toDomainBillingAccount(*account) + return nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return dberror.Translate(err) + } + } + if account.BalanceNanousd > math.MaxInt64-amountNanousd { + return repository.ErrInvalidInput + } + expiresAt := now.Add(time.Duration(input.DurationSeconds) * time.Second) + credit, err := createExpiringCreditBatch(tx, account, amountNanousd, expiresAt, createdByUserID, refNo, + textutil.FirstNonEmpty(strings.TrimSpace(description), "管理员发放限时额度"), input.DurationSeconds) + if err != nil { + return err + } + account.BalanceNanousd += amountNanousd + account.Currency = "USD" + account.Status = "active" + if err = tx.Model(account).Updates(map[string]any{ + "balance_nanousd": account.BalanceNanousd, + "currency": account.Currency, + "status": account.Status, + }).Error; err != nil { + return dberror.Translate(err) + } + transaction := models.BalanceTransaction{ + AccountID: account.ID, + UserID: userID, + Type: domainbilling.BalanceTransactionTypeExpiringCreditGrant, + AmountNanousd: amountNanousd, + BalanceAfterNanousd: account.BalanceNanousd, + RefType: "expiring_credit", + RefID: credit.ID, + RefNo: credit.RefNo, + Description: credit.Description, + } + if err = tx.Create(&transaction).Error; err != nil { + return dberror.Translate(err) + } + creditResult = toDomainExpiringCredit(*credit) + accountResult = toDomainBillingAccount(*account) + return nil + }) + if err != nil { + return nil, nil, err + } + return &creditResult, &accountResult, nil +} + +// ExpireDueExpiringCredits 回收已到期批次的剩余额度。 +func (r *Repo) ExpireDueExpiringCredits(ctx context.Context, now time.Time, limit int) (int64, error) { + if now.IsZero() { + now = time.Now() + } + if limit <= 0 || limit > 1000 { + limit = 200 + } + dueRows := make([]struct { + UserID uint + }, 0, limit) + if err := r.db.WithContext(ctx).Model(&models.ExpiringCredit{}). + Select("user_id").Group("user_id"). + Where("status = ? AND remaining_nanousd > 0 AND expires_at <= ?", domainbilling.ExpiringCreditStatusActive, now). + Order("MIN(expires_at) ASC, user_id ASC"). + Limit(limit). + Find(&dueRows).Error; err != nil { + return 0, dberror.Translate(err) + } + var expiredCount int64 + for _, row := range dueRows { + userID := row.UserID + if userID == 0 { + continue + } + var count int64 + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + account, err := lockBillingAccount(tx, userID) + if err != nil { + return err + } + count, err = expireDueExpiringCreditsForAccount(tx, account, now) + return err + }) + if err != nil { + return expiredCount, err + } + expiredCount += count + } + return expiredCount, nil +} + +func expireDueExpiringCreditsForAccount(tx *gorm.DB, account *models.BillingAccount, now time.Time) (int64, error) { + if account == nil || account.UserID == 0 { + return 0, repository.ErrInvalidInput + } + credits := make([]models.ExpiringCredit, 0) + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("user_id = ? AND status = ? AND remaining_nanousd > 0 AND expires_at <= ?", account.UserID, domainbilling.ExpiringCreditStatusActive, now). + Order("expires_at ASC, id ASC"). + Find(&credits).Error; err != nil { + return 0, dberror.Translate(err) + } + for index := range credits { + credit := &credits[index] + deduction := credit.RemainingNanousd + if deduction > account.BalanceNanousd { + deduction = account.BalanceNanousd + } + if deduction < 0 { + deduction = 0 + } + account.BalanceNanousd -= deduction + if err := tx.Model(credit).Updates(map[string]any{ + "remaining_nanousd": 0, + "status": domainbilling.ExpiringCreditStatusExpired, + "expired_at": now, + }).Error; err != nil { + return 0, dberror.Translate(err) + } + if deduction > 0 { + transaction := models.BalanceTransaction{ + AccountID: account.ID, + UserID: account.UserID, + Type: domainbilling.BalanceTransactionTypeExpiringCreditExpire, + AmountNanousd: -deduction, + BalanceAfterNanousd: account.BalanceNanousd, + RefType: "expiring_credit", + RefID: credit.ID, + RefNo: credit.RefNo, + Description: "限时额度到期回收", + } + if err := tx.Create(&transaction).Error; err != nil { + return 0, dberror.Translate(err) + } + } + } + if len(credits) > 0 { + if err := tx.Model(account).Update("balance_nanousd", account.BalanceNanousd).Error; err != nil { + return 0, dberror.Translate(err) + } + } + return int64(len(credits)), nil +} + +// reduceActiveExpiringCredits 按最早到期优先减少批次剩余值;账户余额由调用方统一变更。 +func reduceActiveExpiringCredits(tx *gorm.DB, userID uint, amountNanousd int64, now time.Time, exhaustedStatus string) error { + if userID == 0 || amountNanousd <= 0 { + return nil + } + credits := make([]models.ExpiringCredit, 0) + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("user_id = ? AND status = ? AND remaining_nanousd > 0 AND expires_at > ?", userID, domainbilling.ExpiringCreditStatusActive, now). + Order("expires_at ASC, id ASC"). + Find(&credits).Error; err != nil { + return dberror.Translate(err) + } + remaining := amountNanousd + for index := range credits { + if remaining <= 0 { + break + } + credit := &credits[index] + used := credit.RemainingNanousd + if used > remaining { + used = remaining + } + next := credit.RemainingNanousd - used + updates := map[string]any{"remaining_nanousd": next} + if next == 0 { + updates["status"] = exhaustedStatus + if exhaustedStatus == domainbilling.ExpiringCreditStatusConsumed { + updates["consumed_at"] = now + } + } + if err := tx.Model(credit).Updates(updates).Error; err != nil { + return dberror.Translate(err) + } + remaining -= used + } + return nil +} + +func toDomainExpiringCredit(item models.ExpiringCredit) domainbilling.ExpiringCredit { + return domainbilling.ExpiringCredit{ + ID: item.ID, + AccountID: item.AccountID, + UserID: item.UserID, + OriginalNanousd: item.OriginalNanousd, + RemainingNanousd: item.RemainingNanousd, + Status: item.Status, + ExpiresAt: item.ExpiresAt, + ConsumedAt: item.ConsumedAt, + ExpiredAt: item.ExpiredAt, + CreatedByUserID: item.CreatedByUserID, + RefNo: item.RefNo, + Description: item.Description, + CreatedAt: item.CreatedAt, + UpdatedAt: item.UpdatedAt, + } +} + +// getOrCreateBillingAccountForUpdate is the single balance gate. Call only inside +// a transaction: lock the account before credit batches, then expire due funds. +// Reservations are budget checks, not an extension of a credit's lifetime; +// eligibility is evaluated when settling the actual usage. +func getOrCreateBillingAccountForUpdate(tx *gorm.DB, userID uint) (*models.BillingAccount, time.Time, error) { + account, err := lockBillingAccount(tx, userID) + if err != nil { + return nil, time.Time{}, err + } + // Reuse one timestamp after the lock wait for expiration and consumption. + now := time.Now() + _, err = expireDueExpiringCreditsForAccount(tx, account, now) + return account, now, err +} + +// createExpiringCreditBatch records the expirable portion of a deposit. A deposit +// first pays existing debt; that portion is already spent and must never expire +// again against a subsequent permanent top-up. Account/ledger writes remain in +// the caller's same transaction. +func createExpiringCreditBatch(tx *gorm.DB, account *models.BillingAccount, amount int64, expiresAt time.Time, + actor uint, refNo, description string, durationSeconds int64) (*models.ExpiringCredit, error) { + if amount <= 0 || account.BalanceNanousd > math.MaxInt64-amount { + return nil, repository.ErrInvalidInput + } + remaining := amount + if account.BalanceNanousd < 0 { + remaining = max(int64(0), account.BalanceNanousd+amount) + } + credit := models.ExpiringCredit{ + AccountID: account.ID, UserID: account.UserID, OriginalNanousd: amount, + RemainingNanousd: remaining, GrantDurationSeconds: durationSeconds, + Status: domainbilling.ExpiringCreditStatusActive, ExpiresAt: expiresAt, + CreatedByUserID: actor, RefNo: strings.TrimSpace(refNo), Description: description, + } + if remaining == 0 { + now := time.Now() + credit.Status = domainbilling.ExpiringCreditStatusConsumed + credit.ConsumedAt = &now + } + if err := tx.Create(&credit).Error; err != nil { + return nil, dberror.Translate(err) + } + return &credit, nil +} diff --git a/backend/internal/infra/persistence/postgres/billing/repository_expiring_credit_postgres_test.go b/backend/internal/infra/persistence/postgres/billing/repository_expiring_credit_postgres_test.go new file mode 100644 index 000000000..be912b9f5 --- /dev/null +++ b/backend/internal/infra/persistence/postgres/billing/repository_expiring_credit_postgres_test.go @@ -0,0 +1,154 @@ +package billing + +import ( + "context" + "errors" + "os" + "strings" + "sync" + "testing" + "time" + + domainbilling "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/billing" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/persistence/models" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/repository" + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +func TestExpiringCreditPostgresConcurrentGrantAndSweep(t *testing.T) { + dsn := strings.TrimSpace(os.Getenv("DEEIX_TEST_DATABASE_DSN")) + if dsn == "" { + t.Skip("set DEEIX_TEST_DATABASE_DSN to run PostgreSQL expiring-credit concurrency tests") + } + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + sqlDB, err := db.DB() + if err != nil { + t.Fatal(err) + } + sqlDB.SetMaxOpenConns(8) + t.Cleanup(func() { _ = sqlDB.Close() }) + if err = db.AutoMigrate(&models.BillingAccount{}, &models.BalanceTransaction{}, &models.ExpiringCredit{}); err != nil { + t.Fatal(err) + } + userID := uint(time.Now().UnixNano()%1_000_000_000) + 1 + t.Cleanup(func() { + for _, model := range []any{&models.ExpiringCredit{}, &models.BalanceTransaction{}, &models.BillingAccount{}} { + if err := db.Where("user_id = ?", userID).Delete(model).Error; err != nil { + t.Error(err) + } + } + }) + input := repository.ExpiringCreditGrantInput{UserID: userID, CreatedByUserID: 9, AmountNanousd: 100, DurationSeconds: 8640, RefNo: "same-grant"} + start := make(chan struct{}) + errs := make(chan error, 8) + var wg sync.WaitGroup + for range 8 { + wg.Go(func() { + <-start + // Independent Repo values use separate connections from the real pool. + _, _, err := NewRepo(db).GrantExpiringCredit(context.Background(), input) + errs <- err + }) + } + close(start) + wg.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Fatal(err) + } + } + assertBillingBalance(t, db, userID, 100) + var credit models.ExpiringCredit + if err := db.Where("user_id = ?", userID).First(&credit).Error; err != nil { + t.Fatal(err) + } + input.AmountNanousd++ + if _, _, err := NewRepo(db).GrantExpiringCredit(t.Context(), input); !errors.Is(err, repository.ErrConflict) { + t.Fatalf("changed retry accepted: %v", err) + } + if err := db.Model(&credit).Update("expires_at", time.Now().Add(-time.Minute)).Error; err != nil { + t.Fatal(err) + } + for range 4 { + wg.Go(func() { + if _, err := NewRepo(db).ExpireDueExpiringCredits(context.Background(), time.Now(), 1000); err != nil { + t.Error(err) + } + }) + } + wg.Wait() + assertBillingBalance(t, db, userID, 0) + assertExpiringCredit(t, db, credit.ID, domainbilling.ExpiringCreditStatusExpired, 0) + for _, kind := range []string{domainbilling.BalanceTransactionTypeExpiringCreditGrant, domainbilling.BalanceTransactionTypeExpiringCreditExpire} { + var count int64 + if err := db.Model(&models.BalanceTransaction{}).Where("user_id = ? AND type = ?", userID, kind).Count(&count).Error; err != nil { + t.Fatal(err) + } + if count != 1 { + t.Fatalf("%s ledger entries = %d, want 1", kind, count) + } + } + + t.Run("expiration after account lock wait", func(t *testing.T) { + grant := input + grant.AmountNanousd, grant.RefNo = 100, "lock-wait" + credit, _, err := NewRepo(db).GrantExpiringCredit(t.Context(), grant) + if err != nil { + t.Fatal(err) + } + expiresAt := time.Now().Add(2 * time.Second) + if err := db.Model(&models.ExpiringCredit{}).Where("id = ?", credit.ID).Update("expires_at", expiresAt).Error; err != nil { + t.Fatal(err) + } + lock := db.Begin() + if lock.Error != nil { + t.Fatal(lock.Error) + } + defer func() { _ = lock.Rollback().Error }() + if _, err := lockBillingAccount(lock, userID); err != nil { + t.Fatal(err) + } + type readResult struct { + account *domainbilling.BillingAccount + err error + } + results := make(chan readResult, 1) + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + go func() { + account, err := NewRepo(db).GetOrCreateBillingAccount(ctx, userID) + results <- readResult{account, err} + }() + // Observe the actual PostgreSQL row-lock wait before crossing expiry. + deadline := time.Now().Add(time.Second) + for { + var blocked int64 + if err := db.Raw("SELECT count(*) FROM pg_stat_activity WHERE datname = current_database() AND wait_event_type = 'Lock' AND query LIKE '%billing_accounts%'").Scan(&blocked).Error; err != nil { + t.Fatal(err) + } + if blocked > 0 { + break + } + if time.Now().After(deadline) { + t.Fatal("account reader did not reach the row lock") + } + time.Sleep(10 * time.Millisecond) + } + if !time.Now().Before(expiresAt) { + t.Fatal("reader reached row lock after expiry") + } + time.Sleep(time.Until(expiresAt) + 25*time.Millisecond) + if err := lock.Commit().Error; err != nil { + t.Fatal(err) + } + result := <-results + if result.err != nil || result.account == nil || result.account.BalanceNanousd != 0 { + t.Fatalf("expired credit survived lock wait: account=%+v err=%v", result.account, result.err) + } + }) +} diff --git a/backend/internal/infra/persistence/postgres/billing/repository_expiring_credit_test.go b/backend/internal/infra/persistence/postgres/billing/repository_expiring_credit_test.go new file mode 100644 index 000000000..8f65357ec --- /dev/null +++ b/backend/internal/infra/persistence/postgres/billing/repository_expiring_credit_test.go @@ -0,0 +1,320 @@ +package billing + +import ( + "context" + "testing" + "time" + + domainbilling "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/billing" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/persistence/models" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/repository" + "gorm.io/gorm" +) + +const testNanousdPerUSD int64 = 1_000_000_000 + +func TestExpiringCreditGrantConsumeAndExpire(t *testing.T) { + db := openBillingSQLiteTestDB(t) + repo := NewRepo(db) + ctx := context.Background() + createBillingAccount(t, db, 1, 10*testNanousdPerUSD) + + expiresAt := time.Now().Add(48 * time.Hour) + credit, account, err := grantTestCredit(ctx, repo, 1, 20*testNanousdPerUSD, expiresAt, 9, "grant-1", "two day credit") + if err != nil { + t.Fatalf("GrantExpiringCredit() error = %v", err) + } + if account.BalanceNanousd != 30*testNanousdPerUSD || credit.RemainingNanousd != 20*testNanousdPerUSD { + t.Fatalf("unexpected grant result: account=%+v credit=%+v", account, credit) + } + + settleUsage(t, repo, 1, 5*testNanousdPerUSD, "usage-1") + assertBillingBalance(t, db, 1, 25*testNanousdPerUSD) + assertExpiringCredit(t, db, credit.ID, domainbilling.ExpiringCreditStatusActive, 15*testNanousdPerUSD) + + count, err := repo.ExpireDueExpiringCredits(ctx, expiresAt.Add(time.Second), 100) + if err != nil { + t.Fatalf("ExpireDueExpiringCredits() error = %v", err) + } + if count != 1 { + t.Fatalf("expired count = %d, want 1", count) + } + assertBillingBalance(t, db, 1, 10*testNanousdPerUSD) + assertExpiringCredit(t, db, credit.ID, domainbilling.ExpiringCreditStatusExpired, 0) + + var transaction models.BalanceTransaction + if err := db.Where("user_id = ? AND type = ?", 1, domainbilling.BalanceTransactionTypeExpiringCreditExpire).First(&transaction).Error; err != nil { + t.Fatalf("load expiration transaction: %v", err) + } + if transaction.AmountNanousd != -15*testNanousdPerUSD || transaction.BalanceAfterNanousd != 10*testNanousdPerUSD { + t.Fatalf("unexpected expiration transaction: %+v", transaction) + } +} + +func TestExpiringCreditFullyConsumedIsNotDeductedAtExpiration(t *testing.T) { + db := openBillingSQLiteTestDB(t) + repo := NewRepo(db) + ctx := context.Background() + createBillingAccount(t, db, 1, 10*testNanousdPerUSD) + + expiresAt := time.Now().Add(48 * time.Hour) + credit, _, err := grantTestCredit(ctx, repo, 1, 20*testNanousdPerUSD, expiresAt, 9, "grant-full", "") + if err != nil { + t.Fatalf("GrantExpiringCredit() error = %v", err) + } + settleUsage(t, repo, 1, 25*testNanousdPerUSD, "usage-full") + assertBillingBalance(t, db, 1, 5*testNanousdPerUSD) + assertExpiringCredit(t, db, credit.ID, domainbilling.ExpiringCreditStatusConsumed, 0) + + count, err := repo.ExpireDueExpiringCredits(ctx, expiresAt.Add(time.Second), 100) + if err != nil { + t.Fatalf("ExpireDueExpiringCredits() error = %v", err) + } + if count != 0 { + t.Fatalf("expired count = %d, want 0", count) + } + assertBillingBalance(t, db, 1, 5*testNanousdPerUSD) +} + +func TestExpiringCreditsConsumeEarliestExpirationFirst(t *testing.T) { + db := openBillingSQLiteTestDB(t) + repo := NewRepo(db) + ctx := context.Background() + createBillingAccount(t, db, 1, 0) + + now := time.Now() + early, _, err := grantTestCredit(ctx, repo, 1, 10*testNanousdPerUSD, now.Add(24*time.Hour), 9, "grant-early", "") + if err != nil { + t.Fatalf("grant early credit: %v", err) + } + late, _, err := grantTestCredit(ctx, repo, 1, 20*testNanousdPerUSD, now.Add(48*time.Hour), 9, "grant-late", "") + if err != nil { + t.Fatalf("grant late credit: %v", err) + } + + settleUsage(t, repo, 1, 15*testNanousdPerUSD, "usage-fifo") + assertExpiringCredit(t, db, early.ID, domainbilling.ExpiringCreditStatusConsumed, 0) + assertExpiringCredit(t, db, late.ID, domainbilling.ExpiringCreditStatusActive, 15*testNanousdPerUSD) + assertBillingBalance(t, db, 1, 15*testNanousdPerUSD) +} + +func TestAdminBalanceReductionDoesNotCauseDoubleExpiration(t *testing.T) { + db := openBillingSQLiteTestDB(t) + repo := NewRepo(db) + ctx := context.Background() + createBillingAccount(t, db, 1, 10*testNanousdPerUSD) + + expiresAt := time.Now().Add(48 * time.Hour) + credit, _, err := grantTestCredit(ctx, repo, 1, 20*testNanousdPerUSD, expiresAt, 9, "grant-admin", "") + if err != nil { + t.Fatalf("GrantExpiringCredit() error = %v", err) + } + if _, err = repo.SetBillingAccountBalance(ctx, 1, 25*testNanousdPerUSD, "admin-set", ""); err != nil { + t.Fatalf("SetBillingAccountBalance() error = %v", err) + } + assertExpiringCredit(t, db, credit.ID, domainbilling.ExpiringCreditStatusActive, 15*testNanousdPerUSD) + + if _, err = repo.ExpireDueExpiringCredits(ctx, expiresAt.Add(time.Second), 100); err != nil { + t.Fatalf("ExpireDueExpiringCredits() error = %v", err) + } + assertBillingBalance(t, db, 1, 10*testNanousdPerUSD) + assertExpiringCredit(t, db, credit.ID, domainbilling.ExpiringCreditStatusExpired, 0) +} + +func TestUsageRedemptionCodeCreatesFractionalDayExpiringCredit(t *testing.T) { + db := openBillingSQLiteTestDB(t) + if err := db.AutoMigrate(&models.RedemptionCode{}); err != nil { + t.Fatalf("migrate redemption codes: %v", err) + } + repo := NewRepo(db) + ctx := context.Background() + now := time.Date(2026, 8, 20, 12, 0, 0, 0, time.UTC) + maxRedemptions := 1 + code := models.RedemptionCode{ + CodeHash: "limited-code-hash", + Mode: domainbilling.RedemptionCodeModeUsage, + RewardType: domainbilling.RedemptionRewardTypeBalance, + CreditNanousd: 20 * testNanousdPerUSD, + BalanceDurationSeconds: 8640, + MaxRedemptions: &maxRedemptions, + PerUserLimit: 1, + Status: domainbilling.RedemptionCodeStatusActive, + CreatedByUserID: 9, + Description: "fractional-day campaign", + } + if err := db.Create(&code).Error; err != nil { + t.Fatalf("create redemption code: %v", err) + } + + result, err := repo.RedeemCode(ctx, repository.RedemptionApplyInput{ + CodeHash: code.CodeHash, + UserID: 1, + CurrentMode: domainbilling.RedemptionCodeModeUsage, + RefNo: "redeem-limited", + SubscriptionAt: now, + }) + if err != nil { + t.Fatalf("RedeemCode() error = %v", err) + } + if result.Account == nil || result.Account.BalanceNanousd != 20*testNanousdPerUSD { + t.Fatalf("unexpected redemption account: %+v", result.Account) + } + + var credit models.ExpiringCredit + if err := db.Where("user_id = ? AND ref_no = ?", 1, "redeem-limited").First(&credit).Error; err != nil { + t.Fatalf("load redemption expiring credit: %v", err) + } + if credit.OriginalNanousd != 20*testNanousdPerUSD || credit.RemainingNanousd != 20*testNanousdPerUSD { + t.Fatalf("unexpected redemption expiring credit: %+v", credit) + } + wantExpiresAt := now.Add(2*time.Hour + 24*time.Minute) + if !credit.ExpiresAt.Equal(wantExpiresAt) { + t.Fatalf("credit expires at = %v, want %v", credit.ExpiresAt, wantExpiresAt) + } + + if _, err = repo.ExpireDueExpiringCredits(ctx, wantExpiresAt.Add(time.Second), 100); err != nil { + t.Fatalf("ExpireDueExpiringCredits() error = %v", err) + } + assertBillingBalance(t, db, 1, 0) + assertExpiringCredit(t, db, credit.ID, domainbilling.ExpiringCreditStatusExpired, 0) +} + +func TestUsageRedemptionCodeSupportsLegacyIntegerDayDuration(t *testing.T) { + db := openBillingSQLiteTestDB(t) + if err := db.AutoMigrate(&models.RedemptionCode{}); err != nil { + t.Fatalf("migrate redemption codes: %v", err) + } + repo := NewRepo(db) + now := time.Date(2026, 8, 20, 12, 0, 0, 0, time.UTC) + maxRedemptions := 1 + code := models.RedemptionCode{ + CodeHash: "legacy-duration-code-hash", + Mode: domainbilling.RedemptionCodeModeUsage, + RewardType: domainbilling.RedemptionRewardTypeBalance, + CreditNanousd: 5 * testNanousdPerUSD, + DurationDays: 2, + MaxRedemptions: &maxRedemptions, + PerUserLimit: 1, + Status: domainbilling.RedemptionCodeStatusActive, + } + if err := db.Create(&code).Error; err != nil { + t.Fatalf("create redemption code: %v", err) + } + + if _, err := repo.RedeemCode(context.Background(), repository.RedemptionApplyInput{ + CodeHash: code.CodeHash, + UserID: 1, + CurrentMode: domainbilling.RedemptionCodeModeUsage, + RefNo: "redeem-legacy-duration", + SubscriptionAt: now, + }); err != nil { + t.Fatalf("RedeemCode() error = %v", err) + } + + var credit models.ExpiringCredit + if err := db.Where("user_id = ?", 1).First(&credit).Error; err != nil { + t.Fatalf("load redemption expiring credit: %v", err) + } + if want := now.Add(48 * time.Hour); !credit.ExpiresAt.Equal(want) { + t.Fatalf("credit expires at = %v, want %v", credit.ExpiresAt, want) + } +} + +func TestUsageRedemptionCodeWithoutDurationRemainsPermanent(t *testing.T) { + db := openBillingSQLiteTestDB(t) + if err := db.AutoMigrate(&models.RedemptionCode{}); err != nil { + t.Fatalf("migrate redemption codes: %v", err) + } + repo := NewRepo(db) + ctx := context.Background() + now := time.Date(2026, 8, 20, 12, 0, 0, 0, time.UTC) + maxRedemptions := 1 + code := models.RedemptionCode{ + CodeHash: "permanent-code-hash", + Mode: domainbilling.RedemptionCodeModeUsage, + RewardType: domainbilling.RedemptionRewardTypeBalance, + CreditNanousd: 20 * testNanousdPerUSD, + DurationDays: 0, + MaxRedemptions: &maxRedemptions, + PerUserLimit: 1, + Status: domainbilling.RedemptionCodeStatusActive, + } + if err := db.Create(&code).Error; err != nil { + t.Fatalf("create redemption code: %v", err) + } + + if _, err := repo.RedeemCode(ctx, repository.RedemptionApplyInput{ + CodeHash: code.CodeHash, + UserID: 1, + CurrentMode: domainbilling.RedemptionCodeModeUsage, + RefNo: "redeem-permanent", + SubscriptionAt: now, + }); err != nil { + t.Fatalf("RedeemCode() error = %v", err) + } + assertBillingBalance(t, db, 1, 20*testNanousdPerUSD) + + var creditCount int64 + if err := db.Model(&models.ExpiringCredit{}).Where("user_id = ?", 1).Count(&creditCount).Error; err != nil { + t.Fatalf("count expiring credits: %v", err) + } + if creditCount != 0 { + t.Fatalf("expiring credit count = %d, want 0", creditCount) + } +} + +func createBillingAccount(t *testing.T, db *gorm.DB, userID uint, balance int64) { + t.Helper() + if err := db.Create(&models.BillingAccount{ + UserID: userID, + Currency: "USD", + BalanceNanousd: balance, + Status: "active", + }).Error; err != nil { + t.Fatalf("create billing account: %v", err) + } +} + +func settleUsage(t *testing.T, repo *Repo, userID uint, amount int64, modelName string) { + t.Helper() + now := time.Now() + if err := repo.AddUsageAndSettleBalance(context.Background(), &domainbilling.UsageLedger{ + UserID: userID, + PlatformModelName: modelName, + BillingAt: now, + UsageDate: now, + BilledCurrency: "USD", + BilledNanousd: amount, + }, nil); err != nil { + t.Fatalf("AddUsageAndSettleBalance() error = %v", err) + } +} + +func assertBillingBalance(t *testing.T, db *gorm.DB, userID uint, want int64) { + t.Helper() + var account models.BillingAccount + if err := db.Where("user_id = ?", userID).First(&account).Error; err != nil { + t.Fatalf("load billing account: %v", err) + } + if account.BalanceNanousd != want { + t.Fatalf("balance = %d, want %d", account.BalanceNanousd, want) + } +} + +func assertExpiringCredit(t *testing.T, db *gorm.DB, creditID uint, status string, remaining int64) { + t.Helper() + var credit models.ExpiringCredit + if err := db.First(&credit, creditID).Error; err != nil { + t.Fatalf("load expiring credit: %v", err) + } + if credit.Status != status || credit.RemainingNanousd != remaining { + t.Fatalf("credit = %+v, want status=%s remaining=%d", credit, status, remaining) + } +} + +func grantTestCredit(ctx context.Context, repo *Repo, userID uint, amount int64, expires time.Time, actor uint, ref, description string) (*domainbilling.ExpiringCredit, *domainbilling.BillingAccount, error) { + return repo.GrantExpiringCredit(ctx, repository.ExpiringCreditGrantInput{ + UserID: userID, AmountNanousd: amount, DurationSeconds: int64(time.Until(expires).Round(time.Second) / time.Second), + CreatedByUserID: actor, RefNo: ref, Description: description, + }) +} diff --git a/backend/internal/infra/persistence/postgres/billing/repository_postgres_integration_test.go b/backend/internal/infra/persistence/postgres/billing/repository_postgres_integration_test.go index 81f9699ec..0afe81e5e 100644 --- a/backend/internal/infra/persistence/postgres/billing/repository_postgres_integration_test.go +++ b/backend/internal/infra/persistence/postgres/billing/repository_postgres_integration_test.go @@ -32,7 +32,7 @@ func TestReserveUsageBalanceSerializesConcurrentPostgresRequests(t *testing.T) { } sqlDB.SetMaxOpenConns(8) defer sqlDB.Close() - if err = db.AutoMigrate(&models.UsageLedger{}, &models.BillingAccount{}, &models.BalanceTransaction{}, &models.UsageReservation{}); err != nil { + if err = db.AutoMigrate(&models.UsageLedger{}, &models.BillingAccount{}, &models.BalanceTransaction{}, &models.UsageReservation{}, &models.ExpiringCredit{}); err != nil { t.Fatalf("migrate billing tables: %v", err) } diff --git a/backend/internal/infra/persistence/postgres/billing/repository_reservation.go b/backend/internal/infra/persistence/postgres/billing/repository_reservation.go index f0bbdfee5..7db19d91b 100644 --- a/backend/internal/infra/persistence/postgres/billing/repository_reservation.go +++ b/backend/internal/infra/persistence/postgres/billing/repository_reservation.go @@ -33,7 +33,7 @@ func (r *Repo) ReserveUsageBalance(ctx context.Context, input domainbilling.Usag var result *domainbilling.UsageBalanceReservation err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - account, err := getOrCreateBillingAccountForUpdate(tx, input.UserID) + account, now, err := getOrCreateBillingAccountForUpdate(tx, input.UserID) if err != nil { return err } @@ -63,7 +63,6 @@ func (r *Repo) ReserveUsageBalance(ctx context.Context, input domainbilling.Usag return repository.ErrConflict } - now := time.Now() activeReservationCount, err := countActiveUsageReservations(tx, input.UserID, now) if err != nil { return err @@ -149,11 +148,10 @@ func (r *Repo) RaiseUsageBalanceReservation(ctx context.Context, userID uint, re return repository.ErrInvalidInput } return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - account, err := getOrCreateBillingAccountForUpdate(tx, userID) + account, now, err := getOrCreateBillingAccountForUpdate(tx, userID) if err != nil { return err } - now := time.Now() var reservation models.UsageReservation if err = tx.Clauses(clause.Locking{Strength: "UPDATE"}). Where("user_id = ? AND ref_no = ?", userID, refNo). @@ -166,9 +164,6 @@ func (r *Repo) RaiseUsageBalanceReservation(ctx context.Context, userID uint, re if reservation.Status != domainbilling.UsageReservationStatusActive || !reservation.ExpiresAt.After(now) { return repository.ErrConflict } - if requiredNanousd <= addNonNegativeInt64(reservation.BalanceNanousd, reservation.PeriodCreditNanousd) { - return nil - } otherBalanceReservedNanousd, err := sumActiveBalanceReservations(tx, userID, reservation.ID, now) if err != nil { @@ -197,6 +192,9 @@ func (r *Repo) RaiseUsageBalanceReservation(ctx context.Context, userID uint, re if requiredNanousd > addNonNegativeInt64(availableCreditNanousd, availableBalanceNanousd) { return repository.ErrInsufficientBalance } + if requiredNanousd <= addNonNegativeInt64(reservation.BalanceNanousd, reservation.PeriodCreditNanousd) { + return nil + } periodCreditNanousd := minInt64(requiredNanousd, availableCreditNanousd) return dberror.Translate(tx.Model(&reservation).Updates(map[string]any{ "balance_nanousd": requiredNanousd - periodCreditNanousd, diff --git a/backend/internal/infra/persistence/postgres/billing/repository_sqlite_test.go b/backend/internal/infra/persistence/postgres/billing/repository_sqlite_test.go index 7b1a5647a..24a404670 100644 --- a/backend/internal/infra/persistence/postgres/billing/repository_sqlite_test.go +++ b/backend/internal/infra/persistence/postgres/billing/repository_sqlite_test.go @@ -1415,7 +1415,7 @@ func openBillingSQLiteTestDB(t *testing.T) *gorm.DB { _ = sqlDB.Close() }) - if err := db.AutoMigrate(&models.UsageLedger{}, &models.BillingAccount{}, &models.BalanceTransaction{}, &models.UsageReservation{}, &models.Redemption{}, &models.ConversationRun{}); err != nil { + if err := db.AutoMigrate(&models.UsageLedger{}, &models.BillingAccount{}, &models.BalanceTransaction{}, &models.ExpiringCredit{}, &models.UsageReservation{}, &models.Redemption{}, &models.ConversationRun{}); err != nil { t.Fatalf("migrate billing tables: %v", err) } return db diff --git a/backend/internal/infra/persistence/schema/schema.go b/backend/internal/infra/persistence/schema/schema.go index a1e3d0917..cc2fcfc2d 100644 --- a/backend/internal/infra/persistence/schema/schema.go +++ b/backend/internal/infra/persistence/schema/schema.go @@ -53,6 +53,7 @@ func Models() []any { &models.PaymentOrder{}, &models.BillingAccount{}, &models.BalanceTransaction{}, + &models.ExpiringCredit{}, &models.UsageReservation{}, &models.RedemptionCode{}, &models.Redemption{}, diff --git a/backend/internal/repository/billing.go b/backend/internal/repository/billing.go index fcf551732..a3d94f19e 100644 --- a/backend/internal/repository/billing.go +++ b/backend/internal/repository/billing.go @@ -60,6 +60,21 @@ type BillingRepository interface { SumTotalBilledNanousd(ctx context.Context, userID uint) (int64, error) } +// ExpiringCreditRepository 提供限时额度能力;独立接口避免扩大所有计费测试桩。 +type ExpiringCreditGrantInput struct { + UserID uint + AmountNanousd int64 + DurationSeconds int64 + CreatedByUserID uint + RefNo string + Description string +} + +type ExpiringCreditRepository interface { + GrantExpiringCredit(ctx context.Context, input ExpiringCreditGrantInput) (*domainbilling.ExpiringCredit, *domainbilling.BillingAccount, error) + ExpireDueExpiringCredits(ctx context.Context, now time.Time, limit int) (int64, error) +} + // RedemptionCodeListFilter 描述管理员兑换码列表筛选条件。 type RedemptionCodeListFilter struct { Mode string diff --git a/backend/internal/transport/http/admin/dto.go b/backend/internal/transport/http/admin/dto.go index 534ef021d..cb1dbc2f9 100644 --- a/backend/internal/transport/http/admin/dto.go +++ b/backend/internal/transport/http/admin/dto.go @@ -359,23 +359,24 @@ type PaymentOrderResponse struct { // RedemptionRecordResponse 兑换记录响应。 type RedemptionRecordResponse struct { - ID uint `json:"id"` - CodeID uint `json:"codeID"` - CodeHint string `json:"codeHint"` - CodeDescription string `json:"codeDescription"` - CodeStatus string `json:"codeStatus"` - UserID uint `json:"userID"` - Username string `json:"username"` - UserDisplayName string `json:"userDisplayName"` - UserLabel string `json:"userLabel"` - Mode string `json:"mode"` - RewardType string `json:"rewardType"` - CreditNanousd int64 `json:"creditNanousd"` - CreditUSD float64 `json:"creditUSD"` - PlanID uint `json:"planID"` - PlanName string `json:"planName"` - DurationDays int `json:"durationDays"` - SubscriptionID uint `json:"subscriptionID"` + ID uint `json:"id"` + CodeID uint `json:"codeID"` + CodeHint string `json:"codeHint"` + CodeDescription string `json:"codeDescription"` + CodeStatus string `json:"codeStatus"` + UserID uint `json:"userID"` + Username string `json:"username"` + UserDisplayName string `json:"userDisplayName"` + UserLabel string `json:"userLabel"` + Mode string `json:"mode"` + RewardType string `json:"rewardType"` + CreditNanousd int64 `json:"creditNanousd"` + CreditUSD float64 `json:"creditUSD"` + PlanID uint `json:"planID"` + PlanName string `json:"planName"` + DurationDays int `json:"durationDays"` + BalanceDurationSeconds int64 `json:"balanceDurationSeconds"` + SubscriptionID uint `json:"subscriptionID"` // BalanceBeforeNanousd / BalanceAfterNanousd 来自余额流水;订阅类兑换无流水时为 null。 BalanceBeforeNanousd *int64 `json:"balanceBeforeNanousd" extensions:"x-nullable,!x-omitempty"` BalanceAfterNanousd *int64 `json:"balanceAfterNanousd" extensions:"x-nullable,!x-omitempty"` @@ -985,28 +986,29 @@ func toRedemptionRecordResponse(item appbilling.RedemptionRecordView, label appa balanceBefore = &before } return RedemptionRecordResponse{ - ID: item.Redemption.ID, - CodeID: item.Redemption.CodeID, - CodeHint: item.CodeHint, - CodeDescription: item.CodeDescription, - CodeStatus: item.CodeStatus, - UserID: item.Redemption.UserID, - Username: label.Username, - UserDisplayName: label.DisplayName, - UserLabel: label.Label, - Mode: item.Redemption.Mode, - RewardType: item.Redemption.RewardType, - CreditNanousd: item.Redemption.CreditNanousd, - CreditUSD: float64(item.Redemption.CreditNanousd) / 1_000_000_000, - PlanID: item.Redemption.PlanID, - PlanName: item.PlanName, - DurationDays: item.DurationDays, - SubscriptionID: item.Redemption.SubscriptionID, - BalanceBeforeNanousd: balanceBefore, - BalanceAfterNanousd: item.BalanceAfterNanousd, - RefNo: item.Redemption.RefNo, - SnapshotJSON: item.Redemption.SnapshotJSON, - CreatedAt: item.Redemption.CreatedAt, + ID: item.Redemption.ID, + CodeID: item.Redemption.CodeID, + CodeHint: item.CodeHint, + CodeDescription: item.CodeDescription, + CodeStatus: item.CodeStatus, + UserID: item.Redemption.UserID, + Username: label.Username, + UserDisplayName: label.DisplayName, + UserLabel: label.Label, + Mode: item.Redemption.Mode, + RewardType: item.Redemption.RewardType, + CreditNanousd: item.Redemption.CreditNanousd, + CreditUSD: float64(item.Redemption.CreditNanousd) / 1_000_000_000, + PlanID: item.Redemption.PlanID, + PlanName: item.PlanName, + DurationDays: item.DurationDays, + BalanceDurationSeconds: item.BalanceDurationSeconds, + SubscriptionID: item.Redemption.SubscriptionID, + BalanceBeforeNanousd: balanceBefore, + BalanceAfterNanousd: item.BalanceAfterNanousd, + RefNo: item.Redemption.RefNo, + SnapshotJSON: item.Redemption.SnapshotJSON, + CreatedAt: item.Redemption.CreatedAt, } } diff --git a/backend/internal/transport/http/billing/dto.go b/backend/internal/transport/http/billing/dto.go index 4b9eb6134..b4c1e3eb2 100644 --- a/backend/internal/transport/http/billing/dto.go +++ b/backend/internal/transport/http/billing/dto.go @@ -65,18 +65,27 @@ type UpdateBillingAccountBalanceRequest struct { Description string `json:"description,omitempty" binding:"omitempty,max=255"` } +// GrantExpiringCreditRequest 管理员发放限时额度。 +type GrantExpiringCreditRequest struct { + CreditUSD *float64 `json:"creditUSD" binding:"required,gt=0"` + DurationDays *float64 `json:"durationDays" binding:"required,gte=0.1,lte=3650" swaggertype:"number" minimum:"0.1" maximum:"3650" example:"0.1"` + Description string `json:"description,omitempty" binding:"omitempty,max=255"` + IdempotencyKey string `json:"idempotencyKey" binding:"required,min=8,max=128"` +} + // CreateRedemptionCodeRequest 创建兑换码请求。 type CreateRedemptionCodeRequest struct { - Code string `json:"code,omitempty" binding:"omitempty,min=3,max=64"` - Quantity int `json:"quantity,omitempty" binding:"omitempty,min=1,max=100"` - Mode string `json:"mode" binding:"required,oneof=usage period"` - CreditUSD float64 `json:"creditUSD,omitempty" binding:"omitempty,min=0"` - PlanID uint `json:"planID,omitempty" binding:"omitempty,min=1"` - DurationDays int `json:"durationDays,omitempty" binding:"omitempty,min=0,max=3660"` - MaxRedemptions *int `json:"maxRedemptions,omitempty" binding:"omitempty,min=1"` - PerUserLimit int `json:"perUserLimit,omitempty" binding:"omitempty,min=1,max=100"` - ExpiresAt *time.Time `json:"expiresAt,omitempty" extensions:"x-nullable"` - Description string `json:"description,omitempty" binding:"omitempty,max=255"` + Code string `json:"code,omitempty" binding:"omitempty,min=3,max=64"` + Quantity int `json:"quantity,omitempty" binding:"omitempty,min=1,max=100"` + Mode string `json:"mode" binding:"required,oneof=usage period"` + CreditUSD float64 `json:"creditUSD,omitempty" binding:"omitempty,min=0"` + PlanID uint `json:"planID,omitempty" binding:"omitempty,min=1"` + DurationDays int `json:"durationDays,omitempty" binding:"omitempty,min=1,max=3650"` + BalanceDurationDays float64 `json:"balanceDurationDays,omitempty" binding:"omitempty,gte=0.1,lte=3650"` + MaxRedemptions *int `json:"maxRedemptions,omitempty" binding:"omitempty,min=1"` + PerUserLimit int `json:"perUserLimit,omitempty" binding:"omitempty,min=1,max=100"` + ExpiresAt *time.Time `json:"expiresAt,omitempty" extensions:"x-nullable"` + Description string `json:"description,omitempty" binding:"omitempty,max=255"` } // PatchRedemptionCodeRequest 更新兑换码请求。 @@ -348,6 +357,29 @@ type BillingAccountDataResponse struct { Account BillingAccountResponse `json:"account"` } +// ExpiringCreditResponse 限时额度响应。 +type ExpiringCreditResponse struct { + ID uint `json:"id"` + UserID uint `json:"userID"` + OriginalNanousd int64 `json:"originalNanousd"` + OriginalUSD float64 `json:"originalUSD"` + RemainingNanousd int64 `json:"remainingNanousd"` + RemainingUSD float64 `json:"remainingUSD"` + Status string `json:"status"` + ExpiresAt time.Time `json:"expiresAt"` + ConsumedAt *time.Time `json:"consumedAt" extensions:"x-nullable,!x-omitempty"` + ExpiredAt *time.Time `json:"expiredAt" extensions:"x-nullable,!x-omitempty"` + CreatedByUserID uint `json:"createdByUserID"` + Description string `json:"description"` + CreatedAt time.Time `json:"createdAt"` +} + +// ExpiringCreditGrantDataResponse 发放限时额度响应。 +type ExpiringCreditGrantDataResponse struct { + Credit ExpiringCreditResponse `json:"credit"` + Account BillingAccountResponse `json:"account"` +} + // BillingOverviewResponse 当前用户计费概览响应。 type BillingOverviewResponse struct { Mode string `json:"mode"` @@ -373,25 +405,26 @@ type BillingOverviewDataResponse struct { // RedemptionCodeResponse 后台兑换码响应。 type RedemptionCodeResponse struct { - ID uint `json:"id"` - Code string `json:"code,omitempty"` - CodeHint string `json:"codeHint"` - Mode string `json:"mode"` - RewardType string `json:"rewardType"` - CreditUSD float64 `json:"creditUSD"` - CreditNanousd int64 `json:"creditNanousd"` - PlanID uint `json:"planID"` - DurationDays int `json:"durationDays"` - MaxRedemptions *int `json:"maxRedemptions" extensions:"x-nullable,!x-omitempty"` - PerUserLimit int `json:"perUserLimit"` - RedeemedCount int `json:"redeemedCount"` - RemainingRedemptions *int `json:"remainingRedemptions" extensions:"x-nullable,!x-omitempty"` - Status string `json:"status"` - ExpiresAt *time.Time `json:"expiresAt" extensions:"x-nullable,!x-omitempty"` - Description string `json:"description"` - CreatedByUserID uint `json:"createdByUserID"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` + ID uint `json:"id"` + Code string `json:"code,omitempty"` + CodeHint string `json:"codeHint"` + Mode string `json:"mode"` + RewardType string `json:"rewardType"` + CreditUSD float64 `json:"creditUSD"` + CreditNanousd int64 `json:"creditNanousd"` + PlanID uint `json:"planID"` + DurationDays int `json:"durationDays"` + BalanceDurationSeconds int64 `json:"balanceDurationSeconds"` + MaxRedemptions *int `json:"maxRedemptions" extensions:"x-nullable,!x-omitempty"` + PerUserLimit int `json:"perUserLimit"` + RedeemedCount int `json:"redeemedCount"` + RemainingRedemptions *int `json:"remainingRedemptions" extensions:"x-nullable,!x-omitempty"` + Status string `json:"status"` + ExpiresAt *time.Time `json:"expiresAt" extensions:"x-nullable,!x-omitempty"` + Description string `json:"description"` + CreatedByUserID uint `json:"createdByUserID"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` } // RedemptionCodeValidationErrorResponse 兑换码字段校验错误详情。 @@ -610,6 +643,12 @@ type BillingAccountResponseDoc struct { Data BillingAccountDataResponse `json:"data"` } +// ExpiringCreditGrantResponseDoc 限时额度发放响应。 +type ExpiringCreditGrantResponseDoc struct { + ErrorMsg string `json:"errorMsg"` + Data ExpiringCreditGrantDataResponse `json:"data"` +} + // BillingOverviewResponseDoc 当前用户计费概览响应。 type BillingOverviewResponseDoc struct { ErrorMsg string `json:"errorMsg"` @@ -850,6 +889,27 @@ func toBillingAccountResponse(item *domainbilling.BillingAccount) BillingAccount } } +func toExpiringCreditResponse(item *domainbilling.ExpiringCredit) ExpiringCreditResponse { + if item == nil { + return ExpiringCreditResponse{} + } + return ExpiringCreditResponse{ + ID: item.ID, + UserID: item.UserID, + OriginalNanousd: item.OriginalNanousd, + OriginalUSD: signedNanousdToUSD(item.OriginalNanousd), + RemainingNanousd: item.RemainingNanousd, + RemainingUSD: signedNanousdToUSD(item.RemainingNanousd), + Status: item.Status, + ExpiresAt: item.ExpiresAt, + ConsumedAt: item.ConsumedAt, + ExpiredAt: item.ExpiredAt, + CreatedByUserID: item.CreatedByUserID, + Description: item.Description, + CreatedAt: item.CreatedAt, + } +} + func toBillingAccountViewResponse(item *appbilling.BillingAccountView) *BillingAccountResponse { if item == nil { return nil @@ -901,26 +961,38 @@ func toRedemptionCodeResponse(item appbilling.RedemptionCodeView) RedemptionCode remaining = &value } return RedemptionCodeResponse{ - ID: item.ID, - Code: item.Code, - CodeHint: item.CodeHint, - Mode: item.Mode, - RewardType: item.RewardType, - CreditUSD: nanousdToUSD(item.CreditNanousd), - CreditNanousd: item.CreditNanousd, - PlanID: item.PlanID, - DurationDays: item.DurationDays, - MaxRedemptions: item.MaxRedemptions, - PerUserLimit: item.PerUserLimit, - RedeemedCount: item.RedeemedCount, - RemainingRedemptions: remaining, - Status: item.Status, - ExpiresAt: item.ExpiresAt, - Description: item.Description, - CreatedByUserID: item.CreatedByUserID, - CreatedAt: item.CreatedAt, - UpdatedAt: item.UpdatedAt, + ID: item.ID, + Code: item.Code, + CodeHint: item.CodeHint, + Mode: item.Mode, + RewardType: item.RewardType, + CreditUSD: nanousdToUSD(item.CreditNanousd), + CreditNanousd: item.CreditNanousd, + PlanID: item.PlanID, + DurationDays: item.DurationDays, + BalanceDurationSeconds: redemptionBalanceDurationSeconds(item.RedemptionCode), + MaxRedemptions: item.MaxRedemptions, + PerUserLimit: item.PerUserLimit, + RedeemedCount: item.RedeemedCount, + RemainingRedemptions: remaining, + Status: item.Status, + ExpiresAt: item.ExpiresAt, + Description: item.Description, + CreatedByUserID: item.CreatedByUserID, + CreatedAt: item.CreatedAt, + UpdatedAt: item.UpdatedAt, + } +} + +func redemptionBalanceDurationSeconds(item domainbilling.RedemptionCode) int64 { + if item.Mode != domainbilling.RedemptionCodeModeUsage { + return 0 + } + seconds := item.BalanceDurationSeconds + if seconds <= 0 && item.DurationDays > 0 { + seconds = int64(item.DurationDays) * 24 * 60 * 60 } + return seconds } func toRedemptionCodeResponses(items []appbilling.RedemptionCodeView) []RedemptionCodeResponse { diff --git a/backend/internal/transport/http/billing/handler.go b/backend/internal/transport/http/billing/handler.go index d03cf8072..ee84fa1a0 100644 --- a/backend/internal/transport/http/billing/handler.go +++ b/backend/internal/transport/http/billing/handler.go @@ -375,6 +375,63 @@ func (h *Handler) UpdateBillingAccountBalance(c *gin.Context) { response.Success(c, BillingAccountDataResponse{Account: toBillingAccountResponse(account)}) } +// GrantExpiringCredit godoc +// @Summary 管理员发放限时额度 +// @Description 发放指定美元额度;消费优先使用最早到期批次,到期后自动回收未使用部分 +// @Tags admin-billing +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param user_id path int true "用户ID" +// @Param body body GrantExpiringCreditRequest true "限时额度" +// @Success 200 {object} ExpiringCreditGrantResponseDoc +// @Failure 400 {object} ErrorDoc +// @Failure 409 {object} ErrorDoc +// @Failure 500 {object} ErrorDoc +// @Router /admin/billing/accounts/{user_id}/expiring-credits [post] +func (h *Handler) GrantExpiringCredit(c *gin.Context) { + targetUserID, err := strconv.ParseUint(c.Param("user_id"), 10, strconv.IntSize) + if err != nil || targetUserID == 0 { + response.ErrorFrom(c, http.StatusBadRequest, errInvalidUserID) + return + } + var req GrantExpiringCreditRequest + if err = c.ShouldBindJSON(&req); err != nil { + response.InvalidRequestBody(c, err) + return + } + actorUserID := middleware.MustUserID(c) + credit, account, err := h.service.GrantExpiringCredit(c.Request.Context(), appbilling.ExpiringCreditGrantInput{ + UserID: uint(targetUserID), + CreditUSD: *req.CreditUSD, + DurationDays: *req.DurationDays, + CreatedByUserID: actorUserID, + RefNo: req.IdempotencyKey, + Description: req.Description, + }) + if err != nil { + switch { + case errors.Is(err, appbilling.ErrExpiringCreditConflict): + response.ErrorFrom(c, http.StatusConflict, err) + case errors.Is(err, appbilling.ErrInvalidExpiringCredit), errors.Is(err, appbilling.ErrPaymentRequired): + response.ErrorFrom(c, http.StatusBadRequest, err) + default: + response.InternalError(c, err) + } + return + } + h.recordAudit(c, actorUserID, "grant_expiring_credit", "billing_account", strconv.FormatUint(targetUserID, 10), map[string]any{ + "user_id": targetUserID, + "credit_usd": *req.CreditUSD, + "duration_days": *req.DurationDays, + "expires_at": credit.ExpiresAt, + }) + response.Success(c, ExpiringCreditGrantDataResponse{ + Credit: toExpiringCreditResponse(credit), + Account: toBillingAccountResponse(account), + }) +} + // ListRedemptionCodes godoc // @Summary 管理员查询兑换码 // @Description 分页查询计费兑换码配置 @@ -428,16 +485,17 @@ func (h *Handler) CreateRedemptionCodes(c *gin.Context) { } actorUserID := middleware.MustUserID(c) items, err := h.service.CreateRedemptionCodes(c.Request.Context(), actorUserID, appbilling.RedemptionCodeInput{ - Code: req.Code, - Quantity: req.Quantity, - Mode: req.Mode, - CreditUSD: req.CreditUSD, - PlanID: req.PlanID, - DurationDays: req.DurationDays, - MaxRedemptions: req.MaxRedemptions, - PerUserLimit: req.PerUserLimit, - ExpiresAt: req.ExpiresAt, - Description: req.Description, + Code: req.Code, + Quantity: req.Quantity, + Mode: req.Mode, + CreditUSD: req.CreditUSD, + PlanID: req.PlanID, + DurationDays: req.DurationDays, + BalanceDurationDays: req.BalanceDurationDays, + MaxRedemptions: req.MaxRedemptions, + PerUserLimit: req.PerUserLimit, + ExpiresAt: req.ExpiresAt, + Description: req.Description, }) if err != nil { writeRedemptionCodeError(c, err) diff --git a/backend/internal/transport/http/billing/handler_expiring_credit_test.go b/backend/internal/transport/http/billing/handler_expiring_credit_test.go new file mode 100644 index 000000000..8f6a50ac3 --- /dev/null +++ b/backend/internal/transport/http/billing/handler_expiring_credit_test.go @@ -0,0 +1,59 @@ +package billing + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/gin-gonic/gin/binding" +) + +func TestGrantExpiringCreditRejectsInvalidUserID(t *testing.T) { + for _, id := range []string{"0", "-1", "invalid", "18446744073709551616"} { + t.Run(id, func(t *testing.T) { + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest("POST", "/admin/billing/accounts/"+id+"/expiring-credits", nil) + ctx.Params = gin.Params{{Key: "user_id", Value: id}} + (&Handler{}).GrantExpiringCredit(ctx) + if recorder.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", recorder.Code) + } + var body struct { + ErrorCode string `json:"errorCode"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body.ErrorCode == "" { + t.Fatalf("missing error code: %s", recorder.Body) + } + }) + } +} + +func TestGrantExpiringCreditRequestValidation(t *testing.T) { + for _, tc := range []struct { + body string + valid bool + }{ + {`{"creditUSD":20,"durationDays":0.1,"idempotencyKey":"grant-0001"}`, true}, + {`{"creditUSD":20,"durationDays":3650,"idempotencyKey":"grant-0001"}`, true}, + {`{"creditUSD":0,"durationDays":1,"idempotencyKey":"grant-0001"}`, false}, + {`{"creditUSD":20,"durationDays":0.05,"idempotencyKey":"grant-0001"}`, false}, + {`{"creditUSD":20,"durationDays":3651,"idempotencyKey":"grant-0001"}`, false}, + {`{"creditUSD":20,"durationDays":1}`, false}, + {`{"creditUSD":20,"idempotencyKey":"grant-0001"}`, false}, + } { + var req GrantExpiringCreditRequest + if err := json.Unmarshal([]byte(tc.body), &req); err != nil { + t.Fatal(err) + } + err := binding.Validator.ValidateStruct(req) + if (err == nil) != tc.valid { + t.Fatalf("request %s: validation error %v, valid=%v", tc.body, err, tc.valid) + } + } +} diff --git a/backend/internal/transport/http/billing/router.go b/backend/internal/transport/http/billing/router.go index 56079d8c4..def64375f 100644 --- a/backend/internal/transport/http/billing/router.go +++ b/backend/internal/transport/http/billing/router.go @@ -46,6 +46,7 @@ func (m *Module) RegisterAdminRoutes(adminGroup *gin.RouterGroup, gate middlewar gating.GET("/billing/plans", m.Handler.ListPlans) gating.PATCH("/billing/plans/:id", m.Handler.UpdatePlan) gating.PATCH("/billing/accounts/:user_id/balance", m.Handler.UpdateBillingAccountBalance) + gating.POST("/billing/accounts/:user_id/expiring-credits", m.Handler.GrantExpiringCredit) gating.GET("/billing/redemption-codes", m.Handler.ListRedemptionCodes) gating.POST("/billing/redemption-codes", m.Handler.CreateRedemptionCodes) gating.POST("/billing/redemption-codes/batch-delete", m.Handler.BatchDeleteRedemptionCodes) diff --git a/backend/internal/transport/http/feature_gate_test.go b/backend/internal/transport/http/feature_gate_test.go index 0b0506397..61869abca 100644 --- a/backend/internal/transport/http/feature_gate_test.go +++ b/backend/internal/transport/http/feature_gate_test.go @@ -89,6 +89,7 @@ var gatedRoutes = []struct { {"billingGating", "POST", "/billing/payments/stripe/webhook"}, {"billingGating", "GET", "/admin/billing/plans"}, {"billingGating", "PATCH", "/admin/billing/accounts/1/balance"}, + {"billingGating", "POST", "/admin/billing/accounts/1/expiring-credits"}, {"billingGating", "GET", "/admin/billing/redemption-codes"}, {"billingGating", "GET", "/admin/payment-orders"}, {"billingGating", "GET", "/admin/redemptions"}, diff --git a/docs/EXPIRING_CREDITS.md b/docs/EXPIRING_CREDITS.md new file mode 100644 index 000000000..e2898c558 --- /dev/null +++ b/docs/EXPIRING_CREDITS.md @@ -0,0 +1,78 @@ +# Expiring credits + +Expiring credits are optional balance grants with an independent expiration time. +They use the existing USD account balance and balance transaction ledger. +The feature is available in usage billing and for overage in period billing. + +## Administrator grants + +Use the user editor to enter a credit amount and its duration. Leaving the amount +blank skips the grant. Durations may be fractional, from 0.1 through 3650 days; +the server stores whole seconds. + +The existing administrator authentication and billingGating feature gate protect: + +`POST /api/v1/admin/billing/accounts/{user_id}/expiring-credits` + +```json +{ + "creditUSD": 20, + "durationDays": 0.1, + "idempotencyKey": "campaign-user-42-001", + "description": "Trial credit" +} +``` + +Reuse the same key when retrying a request whose outcome is unknown. The same +user, amount, duration, administrator and description return the original grant +without another deposit. Changing these fields while reusing the key returns a +conflict. The response includes the grant and the current account balance. + +## Redemption codes + +Usage-mode codes accept the optional `balanceDurationDays` field. Omission or zero +keeps the existing permanent-credit behavior. The lifetime starts at redemption; +the code's `expiresAt` separately controls when the code itself can be redeemed. +Subscription codes continue to use integer `durationDays`. Mixing subscription +duration and balance duration is rejected. + +Responses and redemption snapshots expose `balanceDurationSeconds`. Existing +permanent codes remain permanent. Previous custom balance snapshots with a +fractional `duration_days` value remain readable. + +## Accounting and expiration + +- Account, grant batch and ledger changes commit in one database transaction. + The lock order is account first, then credit batches. + Capture one accounting timestamp after acquiring the account lock and reuse it + for expiration and consumption, including when a request waits across expiry. +- Usage consumes active batches in expiration order, then permanent balance. +- A grant first repays existing debt. Only its unspent remainder can expire. +- Administrative balance reductions reduce active batches so expiration cannot + deduct the same money again. +- Balance-sensitive reads, grants, top-ups, redemption, reservation and settlement + expire due credits before calculating the available balance. A cancellable + background sweep also runs on startup and every minute. + Account lists check due users in one batch before reclaiming affected balances. +- Reservations remain budget checks; they do not extend a grant's lifetime. + Usage settling after expiration is billed against the balance then available. + Real costs can produce a negative balance, preserving existing settlement rules. + +The shared schema migration adds `billing_expiring_credits` and +`billing_redemption_codes.balance_duration_seconds`. Existing account balances +are not converted into temporary grants. Existing custom grants retain their +amounts and expiration times; a missing grant-duration field defaults to zero. + +Back up the database before deploying this schema change. To roll back the +application, restore the matching pre-upgrade database rather than running older +code against the migrated schema. + +## Validation + +SQLite regression coverage includes consumption order, redemption lifetime, +idempotent grants, conflicts, expiration and ledger rollback, concurrent sweeps, +debt repayment, delayed payment callbacks, reservation rechecks and settlement +after expiration. Account-list coverage also bounds expiration lookups as the +number of users grows, and subscription snapshots keep their duration separate +from balance lifetimes. PostgreSQL concurrency and lock-wait tests run when +`DEEIX_TEST_DATABASE_DSN` points to a dedicated test database. diff --git a/packages/api-contract/src/types.generated.ts b/packages/api-contract/src/types.generated.ts index 0cad258ef..fa67c942e 100644 --- a/packages/api-contract/src/types.generated.ts +++ b/packages/api-contract/src/types.generated.ts @@ -1339,6 +1339,11 @@ export interface CreatePersonalProviderRequest { } export interface CreateRedemptionCodeRequest { + /** + * @min 0.1 + * @max 3650 + */ + balanceDurationDays?: number; /** * @minLength 3 * @maxLength 64 @@ -1349,8 +1354,8 @@ export interface CreateRedemptionCodeRequest { /** @maxLength 255 */ description?: string; /** - * @min 0 - * @max 3660 + * @min 1 + * @max 3650 */ durationDays?: number; expiresAt?: string | null; @@ -1712,6 +1717,32 @@ export interface ErrorLogResponse { userUsername: string; } +export interface ExpiringCreditGrantDataResponse { + account: BillingAccountResponse; + credit: ExpiringCreditResponse; +} + +export interface ExpiringCreditGrantResponseDoc { + data: ExpiringCreditGrantDataResponse; + errorMsg: string; +} + +export interface ExpiringCreditResponse { + consumedAt: string | null; + createdAt: string; + createdByUserID: number; + description: string; + expiredAt: string | null; + expiresAt: string; + id: number; + originalNanousd: number; + originalUSD: number; + remainingNanousd: number; + remainingUSD: number; + status: string; + userID: number; +} + export interface FeaturePolicyResponse { /** DesktopDownloadEnabled 为 true 时网页端用户菜单展示「下载桌面端」入口;桌面端内由前端自行隐藏。 */ desktopDownloadEnabled: boolean; @@ -1863,6 +1894,23 @@ export interface GetKnowledgeBaseFileProcessingStatusesRequest { fileIDs: string[]; } +export interface GrantExpiringCreditRequest { + creditUSD: number; + /** @maxLength 255 */ + description?: string; + /** + * @min 0.1 + * @max 3650 + * @example 0.1 + */ + durationDays: number; + /** + * @minLength 8 + * @maxLength 128 + */ + idempotencyKey: string; +} + export interface GroupModelsResponse { modelIDs: number[]; rules: PermissionGroupModelRuleResponse[]; @@ -3781,6 +3829,7 @@ export interface RedemptionCodeListResponseDoc { } export interface RedemptionCodeResponse { + balanceDurationSeconds: number; code?: string; codeHint: string; createdAt: string; @@ -3819,6 +3868,7 @@ export interface RedemptionRecordResponse { balanceAfterNanousd: number | null; /** BalanceBeforeNanousd / BalanceAfterNanousd 来自余额流水;订阅类兑换无流水时为 null。 */ balanceBeforeNanousd: number | null; + balanceDurationSeconds: number; codeDescription: string; codeHint: string; codeID: number; @@ -5721,6 +5771,25 @@ export namespace Admin { export type ResponseBody = BillingAccountResponseDoc; } + /** + * @description 发放指定美元额度;消费优先使用最早到期批次,到期后自动回收未使用部分 + * @tags admin-billing + * @name BillingAccountsExpiringCreditsCreate + * @summary 管理员发放限时额度 + * @request POST:/admin/billing/accounts/{user_id}/expiring-credits + * @secure + */ + export namespace BillingAccountsExpiringCreditsCreate { + export type RequestParams = { + /** 用户ID */ + userId: number; + }; + export type RequestQuery = {}; + export type RequestBody = GrantExpiringCreditRequest; + export type RequestHeaders = {}; + export type ResponseBody = ExpiringCreditGrantResponseDoc; + } + /** * @description 查询当前全局计费模式 * @tags admin-billing