From 5be0616e7869a4aa549eeadd5e90af1646f61728 Mon Sep 17 00:00:00 2001 From: erio Date: Tue, 3 Mar 2026 22:00:44 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E4=BB=98=E6=89=8B=E7=BB=AD?= =?UTF-8?q?=E8=B4=B9=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 支持提供商级别和渠道级别手续费率配置(FEE_RATE_PROVIDER_* / FEE_RATE_*) - 用户多付手续费,到账金额不变(充值 ¥100 + 1.6% = 实付 ¥101.60) - 前端显示手续费明细和实付金额 - 退款时按实付金额退款,余额扣减到账金额 --- .env.example | 65 +++++++++++++++++++ .../migration.sql | 3 + prisma/schema.prisma | 2 + src/__tests__/lib/payment/registry.test.ts | 2 + src/app/pay/page.tsx | 4 ++ src/components/PaymentForm.tsx | 37 ++++++++++- src/components/PaymentQRCode.tsx | 11 +++- src/lib/easy-pay/provider.ts | 1 + src/lib/order/fee.ts | 38 +++++++++++ src/lib/order/limits.ts | 5 ++ src/lib/order/service.ts | 34 ++++++---- src/lib/payment/registry.ts | 6 ++ src/lib/payment/types.ts | 1 + src/lib/stripe/provider.ts | 1 + 14 files changed, 197 insertions(+), 13 deletions(-) create mode 100644 .env.example create mode 100644 prisma/migrations/20260303100000_add_fee_fields/migration.sql create mode 100644 src/lib/order/fee.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a208a4a --- /dev/null +++ b/.env.example @@ -0,0 +1,65 @@ +# 数据库 +DATABASE_URL="postgresql://sub2apipay:password@localhost:5432/sub2apipay" + +# Sub2API +SUB2API_BASE_URL="https://your-sub2api-domain.com" +SUB2API_ADMIN_API_KEY="your-admin-api-key" + +# ── 支付服务商(逗号分隔,决定加载哪些服务商) ─────────────────────────────── +# 可选值: easypay, stripe +# 示例(仅易支付): PAYMENT_PROVIDERS=easypay +# 示例(仅 Stripe): PAYMENT_PROVIDERS=stripe +# 示例(两者都用): PAYMENT_PROVIDERS=easypay,stripe +PAYMENT_PROVIDERS=easypay + +# ── 易支付配置(PAYMENT_PROVIDERS 含 easypay 时必填) ──────────────────────── +EASY_PAY_PID="your-pid" +EASY_PAY_PKEY="your-pkey" +EASY_PAY_API_BASE="https://zpayz.cn" +EASY_PAY_NOTIFY_URL="https://pay.example.com/api/easy-pay/notify" +EASY_PAY_RETURN_URL="https://pay.example.com/pay/result" +# 渠道 ID(部分易支付平台需要,可选) +#EASY_PAY_CID_ALIPAY="" +#EASY_PAY_CID_WXPAY="" + +# ── Stripe 配置(PAYMENT_PROVIDERS 含 stripe 时必填) ──────────────────────── +#STRIPE_SECRET_KEY="sk_live_..." +#STRIPE_PUBLISHABLE_KEY="pk_live_..." +#STRIPE_WEBHOOK_SECRET="whsec_..." + +# ── 启用的支付渠道(在已配置服务商支持的渠道中选择) ───────────────────────── +# 易支付支持: alipay, wxpay +# Stripe 支持: stripe +ENABLED_PAYMENT_TYPES="alipay,wxpay" + +# ── 订单配置 ────────────────────────────────────────────────────────────────── +ORDER_TIMEOUT_MINUTES="5" +MIN_RECHARGE_AMOUNT="1" +MAX_RECHARGE_AMOUNT="10000" +# 每用户每日累计充值上限,0 = 不限制 +MAX_DAILY_RECHARGE_AMOUNT="0" +# 各渠道全平台每日总限额,0 = 不限制(未设置则使用各服务商默认值) +#MAX_DAILY_AMOUNT_ALIPAY="0" +#MAX_DAILY_AMOUNT_WXPAY="0" +#MAX_DAILY_AMOUNT_STRIPE="0" +PRODUCT_NAME="Sub2API 余额充值" + +# ── 手续费(百分比,可选) ───────────────────────────────────────────────────── +# 提供商级别(应用于该提供商下所有渠道) +#FEE_RATE_PROVIDER_EASYPAY=1.6 +#FEE_RATE_PROVIDER_STRIPE=5.9 +# 渠道级别(覆盖提供商级别) +#FEE_RATE_ALIPAY= +#FEE_RATE_WXPAY= +#FEE_RATE_STRIPE= + +# ── 管理员 ──────────────────────────────────────────────────────────────────── +ADMIN_TOKEN="your-admin-token" + +# ── 应用 ────────────────────────────────────────────────────────────────────── +NEXT_PUBLIC_APP_URL="https://pay.example.com" +# iframe 允许嵌入的域名(逗号分隔) +IFRAME_ALLOW_ORIGINS="https://example.com" +# 充值页面底部帮助内容(可选) +#PAY_HELP_IMAGE_URL="https://example.com/qrcode.png" +#PAY_HELP_TEXT="如需帮助请联系客服微信:xxxxx" diff --git a/prisma/migrations/20260303100000_add_fee_fields/migration.sql b/prisma/migrations/20260303100000_add_fee_fields/migration.sql new file mode 100644 index 0000000..7d07466 --- /dev/null +++ b/prisma/migrations/20260303100000_add_fee_fields/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "orders" ADD COLUMN "pay_amount" DECIMAL(10,2), +ADD COLUMN "fee_rate" DECIMAL(5,2); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1dba27e..d5d2351 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -13,6 +13,8 @@ model Order { userName String? @map("user_name") userNotes String? @map("user_notes") amount Decimal @db.Decimal(10, 2) + payAmount Decimal? @db.Decimal(10, 2) @map("pay_amount") + feeRate Decimal? @db.Decimal(5, 2) @map("fee_rate") rechargeCode String @unique @map("recharge_code") status OrderStatus @default(PENDING) paymentType String @map("payment_type") diff --git a/src/__tests__/lib/payment/registry.test.ts b/src/__tests__/lib/payment/registry.test.ts index bd0d744..e2c6628 100644 --- a/src/__tests__/lib/payment/registry.test.ts +++ b/src/__tests__/lib/payment/registry.test.ts @@ -12,10 +12,12 @@ import type { class MockProvider implements PaymentProvider { readonly name: string; + readonly providerKey: string; readonly supportedTypes: PaymentType[]; constructor(name: string, types: PaymentType[]) { this.name = name; + this.providerKey = name; this.supportedTypes = types; } diff --git a/src/app/pay/page.tsx b/src/app/pay/page.tsx index 5114959..ecdfbea 100644 --- a/src/app/pay/page.tsx +++ b/src/app/pay/page.tsx @@ -13,6 +13,7 @@ import type { MethodLimitInfo } from '@/components/PaymentForm'; interface OrderResult { orderId: string; amount: number; + payAmount?: number; status: string; paymentType: 'alipay' | 'wxpay' | 'stripe'; payUrl?: string | null; @@ -255,6 +256,7 @@ function PayContent() { setOrderResult({ orderId: data.orderId, amount: data.amount, + payAmount: data.payAmount, status: data.status, paymentType: data.paymentType || paymentType, payUrl: data.payUrl, @@ -410,6 +412,7 @@ function PayContent() { userName={userInfo?.username} userBalance={userInfo?.balance} enabledPaymentTypes={config.enabledPaymentTypes} + methodLimits={config.methodLimits} minAmount={config.minAmount} maxAmount={config.maxAmount} onSubmit={handleSubmit} @@ -465,6 +468,7 @@ function PayContent() { checkoutUrl={orderResult.checkoutUrl} paymentType={orderResult.paymentType} amount={orderResult.amount} + payAmount={orderResult.payAmount} expiresAt={orderResult.expiresAt} onStatusChange={handleStatusChange} onBack={handleBack} diff --git a/src/components/PaymentForm.tsx b/src/components/PaymentForm.tsx index fac376d..6dec01b 100644 --- a/src/components/PaymentForm.tsx +++ b/src/components/PaymentForm.tsx @@ -8,6 +8,8 @@ export interface MethodLimitInfo { remaining: number | null; /** 单笔限额,0 = 使用全局 maxAmount */ singleMax?: number; + /** 手续费率百分比,0 = 无手续费 */ + feeRate?: number; } interface PaymentFormProps { @@ -75,6 +77,13 @@ export default function PaymentForm({ const isMethodAvailable = !methodLimits || (methodLimits[paymentType]?.available !== false); const methodSingleMax = methodLimits?.[paymentType]?.singleMax; const effectiveMax = (methodSingleMax !== undefined && methodSingleMax > 0) ? methodSingleMax : maxAmount; + const feeRate = methodLimits?.[paymentType]?.feeRate ?? 0; + const feeAmount = feeRate > 0 && selectedAmount > 0 + ? Math.ceil(selectedAmount * feeRate / 100 * 100) / 100 + : 0; + const payAmount = feeRate > 0 && selectedAmount > 0 + ? Math.round((selectedAmount + feeAmount) * 100) / 100 + : selectedAmount; const isValid = selectedAmount >= minAmount && selectedAmount <= effectiveMax && hasValidCentPrecision(selectedAmount) && isMethodAvailable; const handleSubmit = async (e: React.FormEvent) => { @@ -277,6 +286,32 @@ export default function PaymentForm({ })()} + {/* Fee Detail */} + {feeRate > 0 && selectedAmount > 0 && ( +
+
+ 充值金额 + ¥{selectedAmount.toFixed(2)} +
+
+ 手续费({feeRate}%) + ¥{feeAmount.toFixed(2)} +
+
+ 实付金额 + ¥{payAmount.toFixed(2)} +
+
+ )} + {/* Submit */} ); diff --git a/src/components/PaymentQRCode.tsx b/src/components/PaymentQRCode.tsx index 1c2d198..5bc15c0 100644 --- a/src/components/PaymentQRCode.tsx +++ b/src/components/PaymentQRCode.tsx @@ -11,6 +11,7 @@ interface PaymentQRCodeProps { checkoutUrl?: string | null; paymentType?: 'alipay' | 'wxpay' | 'stripe'; amount: number; + payAmount?: number; expiresAt: string; onStatusChange: (status: string) => void; onBack: () => void; @@ -42,11 +43,14 @@ export default function PaymentQRCode({ checkoutUrl, paymentType, amount, + payAmount: payAmountProp, expiresAt, onStatusChange, onBack, dark = false, }: PaymentQRCodeProps) { + const displayAmount = payAmountProp ?? amount; + const hasFeeDiff = payAmountProp !== undefined && payAmountProp !== amount; const [timeLeft, setTimeLeft] = useState(''); const [expired, setExpired] = useState(false); const [qrDataUrl, setQrDataUrl] = useState(''); @@ -196,7 +200,12 @@ export default function PaymentQRCode({ return (
-
{'\u00A5'}{amount.toFixed(2)}
+
{'\u00A5'}{displayAmount.toFixed(2)}
+ {hasFeeDiff && ( +
+ 到账 ¥{amount.toFixed(2)} +
+ )}
{expired ? TEXT_EXPIRED : `${TEXT_REMAINING}: ${timeLeft}`}
diff --git a/src/lib/easy-pay/provider.ts b/src/lib/easy-pay/provider.ts index fcab030..df6bd9b 100644 --- a/src/lib/easy-pay/provider.ts +++ b/src/lib/easy-pay/provider.ts @@ -14,6 +14,7 @@ import { getEnv } from '@/lib/config'; export class EasyPayProvider implements PaymentProvider { readonly name = 'easy-pay'; + readonly providerKey = 'easypay'; readonly supportedTypes: PaymentType[] = ['alipay', 'wxpay']; readonly defaultLimits = { alipay: { singleMax: 1000, dailyMax: 10000 }, diff --git a/src/lib/order/fee.ts b/src/lib/order/fee.ts new file mode 100644 index 0000000..16fb626 --- /dev/null +++ b/src/lib/order/fee.ts @@ -0,0 +1,38 @@ +import { initPaymentProviders, paymentRegistry } from '@/lib/payment'; + +/** + * 获取指定支付渠道的手续费率(百分比)。 + * 优先级:FEE_RATE_{TYPE} > FEE_RATE_PROVIDER_{KEY} > 0 + */ +export function getMethodFeeRate(paymentType: string): number { + // 渠道级别:FEE_RATE_ALIPAY / FEE_RATE_WXPAY / FEE_RATE_STRIPE + const methodRaw = process.env[`FEE_RATE_${paymentType.toUpperCase()}`]; + if (methodRaw !== undefined && methodRaw !== '') { + const num = Number(methodRaw); + if (Number.isFinite(num) && num >= 0) return num; + } + + // 提供商级别:FEE_RATE_PROVIDER_EASYPAY / FEE_RATE_PROVIDER_STRIPE + initPaymentProviders(); + const providerKey = paymentRegistry.getProviderKey(paymentType); + if (providerKey) { + const providerRaw = process.env[`FEE_RATE_PROVIDER_${providerKey.toUpperCase()}`]; + if (providerRaw !== undefined && providerRaw !== '') { + const num = Number(providerRaw); + if (Number.isFinite(num) && num >= 0) return num; + } + } + + return 0; +} + +/** + * 根据到账金额和手续费率计算实付金额。 + * feeAmount = ceil(rechargeAmount * feeRate / 100 * 100) / 100 (进一制到分) + * payAmount = rechargeAmount + feeAmount + */ +export function calculatePayAmount(rechargeAmount: number, feeRate: number): number { + if (feeRate <= 0) return rechargeAmount; + const feeAmount = Math.ceil(rechargeAmount * feeRate / 100 * 100) / 100; + return Math.round((rechargeAmount + feeAmount) * 100) / 100; +} diff --git a/src/lib/order/limits.ts b/src/lib/order/limits.ts index fa117e5..cd198ba 100644 --- a/src/lib/order/limits.ts +++ b/src/lib/order/limits.ts @@ -1,6 +1,7 @@ import { prisma } from '@/lib/db'; import { getEnv } from '@/lib/config'; import { initPaymentProviders, paymentRegistry } from '@/lib/payment'; +import { getMethodFeeRate } from './fee'; /** * 获取指定支付渠道的每日全平台限额(0 = 不限制)。 @@ -55,6 +56,8 @@ export interface MethodLimitStatus { available: boolean; /** 单笔限额,0 = 使用全局配置 MAX_RECHARGE_AMOUNT */ singleMax: number; + /** 手续费率百分比,0 = 无手续费 */ + feeRate: number; } /** @@ -85,6 +88,7 @@ export async function queryMethodLimits( for (const type of paymentTypes) { const dailyLimit = getMethodDailyLimit(type); const singleMax = getMethodSingleLimit(type); + const feeRate = getMethodFeeRate(type); const used = usageMap[type] ?? 0; const remaining = dailyLimit > 0 ? Math.max(0, dailyLimit - used) : null; result[type] = { @@ -93,6 +97,7 @@ export async function queryMethodLimits( remaining, available: dailyLimit === 0 || used < dailyLimit, singleMax, + feeRate, }; } return result; diff --git a/src/lib/order/service.ts b/src/lib/order/service.ts index 8f31487..fad0bd7 100644 --- a/src/lib/order/service.ts +++ b/src/lib/order/service.ts @@ -2,6 +2,7 @@ import { prisma } from '@/lib/db'; import { getEnv } from '@/lib/config'; import { generateRechargeCode } from './code-gen'; import { getMethodDailyLimit } from './limits'; +import { getMethodFeeRate, calculatePayAmount } from './fee'; import { initPaymentProviders, paymentRegistry } from '@/lib/payment'; import type { PaymentType, PaymentNotification } from '@/lib/payment'; import { getUser, createAndRedeem, subtractBalance } from '@/lib/sub2api/client'; @@ -22,6 +23,8 @@ export interface CreateOrderInput { export interface CreateOrderResult { orderId: string; amount: number; + payAmount: number; + feeRate: number; status: string; paymentType: PaymentType; userName: string; @@ -96,6 +99,9 @@ export async function createOrder(input: CreateOrderInput): Promise 0 ? new Prisma.Decimal(feeRate.toFixed(2)) : null, rechargeCode: '', status: 'PENDING', paymentType: input.paymentType, @@ -125,9 +133,9 @@ export async function createOrder(input: CreateOrderInput): Promise { throw new OrderError('INVALID_STATUS', 'Only completed orders can be refunded', 400); } - const amount = Number(order.amount); + const rechargeAmount = Number(order.amount); + const refundAmount = Number(order.payAmount ?? order.amount); if (!input.force) { try { const user = await getUser(order.userId); - if (user.balance < amount) { + if (user.balance < rechargeAmount) { return { success: false, - warning: `User balance ${user.balance} is lower than refund ${amount}`, + warning: `User balance ${user.balance} is lower than refund ${rechargeAmount}`, requireForce: true, }; } @@ -587,18 +599,18 @@ export async function processRefund(input: RefundInput): Promise { await provider.refund({ tradeNo: order.paymentTradeNo, orderId: order.id, - amount, + amount: refundAmount, reason: input.reason, }); } - await subtractBalance(order.userId, amount, `sub2apipay refund order:${order.id}`, `sub2apipay:refund:${order.id}`); + await subtractBalance(order.userId, rechargeAmount, `sub2apipay refund order:${order.id}`, `sub2apipay:refund:${order.id}`); await prisma.order.update({ where: { id: input.orderId }, data: { status: 'REFUNDED', - refundAmount: new Prisma.Decimal(amount.toFixed(2)), + refundAmount: new Prisma.Decimal(refundAmount.toFixed(2)), refundReason: input.reason || null, refundAt: new Date(), forceRefund: input.force || false, @@ -609,7 +621,7 @@ export async function processRefund(input: RefundInput): Promise { data: { orderId: input.orderId, action: 'REFUND_SUCCESS', - detail: JSON.stringify({ amount, reason: input.reason, force: input.force }), + detail: JSON.stringify({ rechargeAmount, refundAmount, reason: input.reason, force: input.force }), operator: 'admin', }, }); diff --git a/src/lib/payment/registry.ts b/src/lib/payment/registry.ts index c805543..e66a16f 100644 --- a/src/lib/payment/registry.ts +++ b/src/lib/payment/registry.ts @@ -30,6 +30,12 @@ export class PaymentProviderRegistry { const provider = this.providers.get(type as PaymentType); return provider?.defaultLimits?.[type]; } + + /** 获取指定渠道对应的提供商 key(如 'easypay'、'stripe') */ + getProviderKey(type: string): string | undefined { + const provider = this.providers.get(type as PaymentType); + return provider?.providerKey; + } } export const paymentRegistry = new PaymentProviderRegistry(); diff --git a/src/lib/payment/types.ts b/src/lib/payment/types.ts index 813080a..9cbb890 100644 --- a/src/lib/payment/types.ts +++ b/src/lib/payment/types.ts @@ -62,6 +62,7 @@ export interface MethodDefaultLimits { /** Common interface that all payment providers must implement */ export interface PaymentProvider { readonly name: string; + readonly providerKey: string; readonly supportedTypes: PaymentType[]; /** 各渠道默认限额,key 为 PaymentType(如 'alipay'),可被环境变量覆盖 */ readonly defaultLimits?: Record; diff --git a/src/lib/stripe/provider.ts b/src/lib/stripe/provider.ts index 3f8c28c..e4c73f2 100644 --- a/src/lib/stripe/provider.ts +++ b/src/lib/stripe/provider.ts @@ -14,6 +14,7 @@ import type { export class StripeProvider implements PaymentProvider { readonly name = 'stripe'; + readonly providerKey = 'stripe'; readonly supportedTypes: PaymentType[] = ['stripe']; readonly defaultLimits = { stripe: { singleMax: 0, dailyMax: 0 }, // 0 = unlimited