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 && ( +