6 Commits

Author SHA1 Message Date
erio
e170d5451e fix: 帮助内容改为服务端变量经 API 下发,运行时可配无需重新构建 2026-03-02 02:46:51 +08:00
erio
e5424e6c5e feat: 显式 PAYMENT_PROVIDERS 配置服务商,缺密钥启动即报错 2026-03-02 02:04:53 +08:00
erio
310fa1020f fix: loadUserAndOrders 开始时重置 userNotFound,防止状态残留 2026-03-02 01:23:04 +08:00
erio
85239e97f8 fix: 用户不存在时前端提示错误;修正微信支付图标;beta compose 改用 Docker Hub 镜像 2026-03-02 01:05:01 +08:00
erio
c6815fc2a3 feat: 插件化支付渠道限额 — provider 自声明单笔/每日默认限额
- PaymentProvider 接口新增 defaultLimits(单笔 singleMax + 每日 dailyMax)
- EasyPay 默认:支付宝/微信各 单笔 ¥1000、每日 ¥10000
- Stripe 默认:不限额(0 = unlimited)
- getMethodDailyLimit / getMethodSingleLimit 优先读 env var,再回退 provider 默认
- queryMethodLimits 返回 singleMax,PaymentForm 按渠道动态调整最大单笔金额
- MAX_DAILY_AMOUNT_* 改为可选 env var 覆盖(不再有硬编码默认值)
2026-03-01 22:51:09 +08:00
erio
136723b8af feat: 支付渠道每日限额(渠道维度全平台统计)
- config.ts:新增 MAX_DAILY_AMOUNT_ALIPAY/WXPAY/STRIPE(默认 alipay/wxpay 各 1w,stripe 不限)
- lib/order/limits.ts:getMethodDailyLimit + queryMethodLimits 共用工具,支持动态渠道兜底
- order/service.ts:createOrder 校验渠道限额,超限抛 METHOD_DAILY_LIMIT_EXCEEDED
- api/limits/route.ts:公开接口 GET /api/limits,返回各渠道今日用量/剩余/是否可用
- api/user/route.ts:config 响应中加入 methodLimits,前端一次请求即可获取限额状态
- PaymentForm.tsx:额度已满的渠道置灰 + 标注「今日额度已满」,无法选择
- pay/page.tsx:AppConfig 加 methodLimits,传给 PaymentForm,新增错误码映射
2026-03-01 21:53:09 +08:00
14 changed files with 308 additions and 35 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

View File

@@ -1,6 +1,6 @@
services:
app:
image: sub2apipay:latest
image: touwaeriol/sub2apipay:${IMAGE_TAG:-latest}
container_name: sub2apipay
ports:
- '8087:3000'

View File

@@ -0,0 +1,31 @@
import { NextResponse } from 'next/server';
import { getEnv } from '@/lib/config';
import { queryMethodLimits } from '@/lib/order/limits';
/**
* GET /api/limits
* 返回各支付渠道今日限额使用情况,公开接口(无需鉴权)。
*
* Response:
* {
* methods: {
* alipay: { dailyLimit: 10000, used: 3500, remaining: 6500, available: true },
* wxpay: { dailyLimit: 10000, used: 10000, remaining: 0, available: false },
* stripe: { dailyLimit: 0, used: 500, remaining: null, available: true }
* },
* resetAt: "2026-03-02T00:00:00.000Z" // UTC 次日零点(限额重置时间)
* }
*/
export async function GET() {
const env = getEnv();
const types = env.ENABLED_PAYMENT_TYPES;
const todayStart = new Date();
todayStart.setUTCHours(0, 0, 0, 0);
const resetAt = new Date(todayStart);
resetAt.setUTCDate(resetAt.getUTCDate() + 1);
const methods = await queryMethodLimits(types);
return NextResponse.json({ methods, resetAt });
}

View File

@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { getUser } from '@/lib/sub2api/client';
import { getEnv } from '@/lib/config';
import { queryMethodLimits } from '@/lib/order/limits';
export async function GET(request: NextRequest) {
const userId = Number(request.nextUrl.searchParams.get('user_id'));
@@ -10,7 +11,10 @@ export async function GET(request: NextRequest) {
try {
const env = getEnv();
const user = await getUser(userId);
const [user, methodLimits] = await Promise.all([
getUser(userId),
queryMethodLimits(env.ENABLED_PAYMENT_TYPES),
]);
return NextResponse.json({
user: {
@@ -22,6 +26,9 @@ export async function GET(request: NextRequest) {
minAmount: env.MIN_RECHARGE_AMOUNT,
maxAmount: env.MAX_RECHARGE_AMOUNT,
maxDailyAmount: env.MAX_DAILY_RECHARGE_AMOUNT,
methodLimits,
helpImageUrl: env.PAY_HELP_IMAGE_URL ?? null,
helpText: env.PAY_HELP_TEXT ?? null,
},
});
} catch (error) {

View File

@@ -8,6 +8,7 @@ import OrderStatus from '@/components/OrderStatus';
import PayPageLayout from '@/components/PayPageLayout';
import MobileOrderList from '@/components/MobileOrderList';
import { detectDeviceIsMobile, type UserInfo, type MyOrder } from '@/lib/pay-utils';
import type { MethodLimitInfo } from '@/components/PaymentForm';
interface OrderResult {
orderId: string;
@@ -25,6 +26,9 @@ interface AppConfig {
minAmount: number;
maxAmount: number;
maxDailyAmount: number;
methodLimits?: Record<string, MethodLimitInfo>;
helpImageUrl?: string | null;
helpText?: string | null;
}
function PayContent() {
@@ -54,15 +58,16 @@ function PayContent() {
const [config, setConfig] = useState<AppConfig>({
enabledPaymentTypes: ['alipay', 'wxpay', 'stripe'],
minAmount: 1,
maxAmount: 10000,
maxAmount: 1000,
maxDailyAmount: 0,
});
const [userNotFound, setUserNotFound] = useState(false);
const effectiveUserId = resolvedUserId || userId;
const isEmbedded = uiMode === 'embedded' && isIframeContext;
const hasToken = token.length > 0;
const helpImageUrl = (process.env.NEXT_PUBLIC_PAY_HELP_IMAGE_URL || '').trim();
const helpText = (process.env.NEXT_PUBLIC_PAY_HELP_TEXT || '').trim();
const helpImageUrl = (config.helpImageUrl || '').trim();
const helpText = (config.helpText || '').trim();
const hasHelpContent = Boolean(helpImageUrl || helpText);
useEffect(() => {
@@ -84,14 +89,26 @@ function PayContent() {
const loadUserAndOrders = async () => {
if (!userId || Number.isNaN(userId) || userId <= 0) return;
setUserNotFound(false);
try {
// 始终获取服务端配置(不含隐私信息)
const cfgRes = await fetch(`/api/user?user_id=${userId}`);
if (cfgRes.ok) {
const cfgData = await cfgRes.json();
if (cfgData.config) {
setConfig(cfgData.config);
setConfig({
enabledPaymentTypes: cfgData.config.enabledPaymentTypes ?? ['alipay', 'wxpay'],
minAmount: cfgData.config.minAmount ?? 1,
maxAmount: cfgData.config.maxAmount ?? 1000,
maxDailyAmount: cfgData.config.maxDailyAmount ?? 0,
methodLimits: cfgData.config.methodLimits,
helpImageUrl: cfgData.config.helpImageUrl ?? null,
helpText: cfgData.config.helpText ?? null,
});
}
} else if (cfgRes.status === 404) {
setUserNotFound(true);
return;
}
// 有 token 时才尝试获取用户详情和订单
@@ -175,6 +192,17 @@ function PayContent() {
);
}
if (userNotFound) {
return (
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
<div className="text-center text-red-500">
<p className="text-lg font-medium"></p>
<p className="mt-2 text-sm text-gray-500"></p>
</div>
</div>
);
}
const buildScopedUrl = (path: string, forceOrdersTab = false) => {
const params = new URLSearchParams();
if (effectiveUserId) params.set('user_id', String(effectiveUserId));
@@ -212,6 +240,7 @@ function PayContent() {
TOO_MANY_PENDING: '您有过多待支付订单,请先完成或取消现有订单后再试',
USER_NOT_FOUND: '用户不存在,请检查链接是否正确',
DAILY_LIMIT_EXCEEDED: data.error,
METHOD_DAILY_LIMIT_EXCEEDED: data.error,
PAYMENT_GATEWAY_ERROR: data.error,
};
setError(codeMessages[data.code] || data.error || '创建订单失败');
@@ -350,6 +379,7 @@ function PayContent() {
userName={userInfo?.username}
userBalance={userInfo?.balance}
enabledPaymentTypes={config.enabledPaymentTypes}
methodLimits={config.methodLimits}
minAmount={config.minAmount}
maxAmount={config.maxAmount}
onSubmit={handleSubmit}

View File

@@ -3,11 +3,19 @@
import { useState } from 'react';
import { PAYMENT_TYPE_META } from '@/lib/pay-utils';
export interface MethodLimitInfo {
available: boolean;
remaining: number | null;
/** 单笔限额0 = 使用全局 maxAmount */
singleMax?: number;
}
interface PaymentFormProps {
userId: number;
userName?: string;
userBalance?: number;
enabledPaymentTypes: string[];
methodLimits?: Record<string, MethodLimitInfo>;
minAmount: number;
maxAmount: number;
onSubmit: (amount: number, paymentType: string) => Promise<void>;
@@ -27,6 +35,7 @@ export default function PaymentForm({
userName,
userBalance,
enabledPaymentTypes,
methodLimits,
minAmount,
maxAmount,
onSubmit,
@@ -63,7 +72,10 @@ export default function PaymentForm({
};
const selectedAmount = amount || 0;
const isValid = selectedAmount >= minAmount && selectedAmount <= maxAmount && hasValidCentPrecision(selectedAmount);
const isMethodAvailable = !methodLimits || (methodLimits[paymentType]?.available !== false);
const methodSingleMax = methodLimits?.[paymentType]?.singleMax;
const effectiveMax = (methodSingleMax !== undefined && methodSingleMax > 0) ? methodSingleMax : maxAmount;
const isValid = selectedAmount >= minAmount && selectedAmount <= effectiveMax && hasValidCentPrecision(selectedAmount) && isMethodAvailable;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -82,14 +94,9 @@ export default function PaymentForm({
if (type === 'wxpay') {
return (
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-[#2BB741] text-white">
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none">
<path
d="M5 12.5 10.2 17 19 8"
stroke="currentColor"
strokeWidth="2.4"
strokeLinecap="round"
strokeLinejoin="round"
/>
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="currentColor">
<path d="M10 3C6.13 3 3 5.58 3 8.75c0 1.7.84 3.23 2.17 4.29l-.5 2.21 2.4-1.32c.61.17 1.25.27 1.93.27.22 0 .43-.01.64-.03C9.41 13.72 9 12.88 9 12c0-3.31 3.13-6 7-6 .26 0 .51.01.76.03C15.96 3.98 13.19 3 10 3z" />
<path d="M16 8c-3.31 0-6 2.24-6 5s2.69 5 6 5c.67 0 1.31-.1 1.9-.28l2.1 1.15-.55-2.44C20.77 15.52 22 13.86 22 12c0-2.21-2.69-4-6-4z" />
</svg>
</span>
);
@@ -143,7 +150,7 @@ export default function PaymentForm({
</label>
<div className="grid grid-cols-3 gap-2">
{QUICK_AMOUNTS.filter((val) => val <= maxAmount).map((val) => (
{QUICK_AMOUNTS.filter((val) => val <= effectiveMax).map((val) => (
<button
key={val}
type="button"
@@ -180,10 +187,10 @@ export default function PaymentForm({
inputMode="decimal"
step="0.01"
min={minAmount}
max={maxAmount}
max={effectiveMax}
value={customAmount}
onChange={(e) => handleCustomAmountChange(e.target.value)}
placeholder={`${minAmount} - ${maxAmount}`}
placeholder={`${minAmount} - ${effectiveMax}`}
className={[
'w-full rounded-lg border py-3 pl-8 pr-4 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500',
dark ? 'border-slate-700 bg-slate-900 text-slate-100' : 'border-gray-300 bg-white text-gray-900',
@@ -197,7 +204,7 @@ export default function PaymentForm({
let msg = '金额需在范围内,且最多支持 2 位小数(精确到分)';
if (!isNaN(num)) {
if (num < minAmount) msg = `单笔最低充值 ¥${minAmount}`;
else if (num > maxAmount) msg = `单笔最高充值 ¥${maxAmount}`;
else if (num > effectiveMax) msg = `单笔最高充值 ¥${effectiveMax}`;
}
return (
<div className={['text-xs', dark ? 'text-amber-300' : 'text-amber-700'].join(' ')}>
@@ -215,36 +222,59 @@ export default function PaymentForm({
{enabledPaymentTypes.map((type) => {
const meta = PAYMENT_TYPE_META[type];
const isSelected = paymentType === type;
const limitInfo = methodLimits?.[type];
const isUnavailable = limitInfo !== undefined && !limitInfo.available;
return (
<button
key={type}
type="button"
onClick={() => setPaymentType(type)}
className={`flex h-[58px] flex-1 items-center justify-center rounded-lg border px-3 transition-all ${
isSelected
? `${meta?.selectedBorder || 'border-blue-500'} ${meta?.selectedBg || 'bg-blue-50'} text-slate-900 shadow-sm`
: dark
? 'border-slate-700 bg-slate-900 text-slate-200 hover:border-slate-500'
: 'border-gray-300 bg-white text-slate-700 hover:border-gray-400'
}`}
disabled={isUnavailable}
onClick={() => !isUnavailable && setPaymentType(type)}
title={isUnavailable ? '今日充值额度已满,请使用其他支付方式' : undefined}
className={[
'relative flex h-[58px] flex-1 flex-col items-center justify-center rounded-lg border px-3 transition-all',
isUnavailable
? dark
? 'cursor-not-allowed border-slate-700 bg-slate-800/50 opacity-50'
: 'cursor-not-allowed border-gray-200 bg-gray-50 opacity-50'
: isSelected
? `${meta?.selectedBorder || 'border-blue-500'} ${meta?.selectedBg || 'bg-blue-50'} text-slate-900 shadow-sm`
: dark
? 'border-slate-700 bg-slate-900 text-slate-200 hover:border-slate-500'
: 'border-gray-300 bg-white text-slate-700 hover:border-gray-400',
].join(' ')}
>
<span className="flex items-center gap-2">
{renderPaymentIcon(type)}
<span className="flex flex-col items-start leading-none">
<span className="text-xl font-semibold tracking-tight">{meta?.label || type}</span>
{meta?.sublabel && (
{isUnavailable ? (
<span className="text-[10px] tracking-wide text-red-400"></span>
) : meta?.sublabel ? (
<span
className={`text-[10px] tracking-wide ${dark && !isSelected ? 'text-slate-400' : 'text-slate-600'}`}
>
{meta.sublabel}
</span>
)}
) : null}
</span>
</span>
</button>
);
})}
</div>
{/* 当前选中渠道额度不足时的提示 */}
{(() => {
const limitInfo = methodLimits?.[paymentType];
if (!limitInfo || limitInfo.available) return null;
return (
<p className={['mt-2 text-xs', dark ? 'text-amber-300' : 'text-amber-600'].join(' ')}>
</p>
);
})()}
</div>
{/* Submit */}

View File

@@ -12,7 +12,13 @@ const envSchema = z.object({
SUB2API_BASE_URL: z.string().url(),
SUB2API_ADMIN_API_KEY: z.string().min(1),
// ── Easy-Pay (optional when only using Stripe) ──
// ── 支付服务商显式声明启用哪些服务商逗号分隔easypay, stripe ──
PAYMENT_PROVIDERS: z
.string()
.default('')
.transform((v) => v.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean)),
// ── Easy-PayPAYMENT_PROVIDERS 含 easypay 时必填) ──
EASY_PAY_PID: optionalTrimmedString,
EASY_PAY_PKEY: optionalTrimmedString,
EASY_PAY_API_BASE: optionalTrimmedString,
@@ -22,10 +28,13 @@ const envSchema = z.object({
EASY_PAY_CID_ALIPAY: optionalTrimmedString,
EASY_PAY_CID_WXPAY: optionalTrimmedString,
// ── StripePAYMENT_PROVIDERS 含 stripe 时必填) ──
STRIPE_SECRET_KEY: optionalTrimmedString,
STRIPE_PUBLISHABLE_KEY: optionalTrimmedString,
STRIPE_WEBHOOK_SECRET: optionalTrimmedString,
// ── 启用的支付渠道(在已配置服务商支持的渠道中选择) ──
// 易支付支持: alipay, wxpayStripe 支持: stripe
ENABLED_PAYMENT_TYPES: z
.string()
.default('alipay,wxpay')
@@ -36,13 +45,19 @@ const envSchema = z.object({
MAX_RECHARGE_AMOUNT: z.string().default('1000').transform(Number).pipe(z.number().positive()),
// 每日每用户最大累计充值额0 = 不限制
MAX_DAILY_RECHARGE_AMOUNT: z.string().default('10000').transform(Number).pipe(z.number().min(0)),
// 每日各渠道全平台总限额可选覆盖0 = 不限制)。
// 未设置时由各 PaymentProvider.defaultLimits 提供默认值。
MAX_DAILY_AMOUNT_ALIPAY: z.string().optional().transform((v) => (v !== undefined ? Number(v) : undefined)).pipe(z.number().min(0).optional()),
MAX_DAILY_AMOUNT_WXPAY: z.string().optional().transform((v) => (v !== undefined ? Number(v) : undefined)).pipe(z.number().min(0).optional()),
MAX_DAILY_AMOUNT_STRIPE: z.string().optional().transform((v) => (v !== undefined ? Number(v) : undefined)).pipe(z.number().min(0).optional()),
PRODUCT_NAME: z.string().default('Sub2API Balance Recharge'),
ADMIN_TOKEN: z.string().min(1),
NEXT_PUBLIC_APP_URL: z.string().url(),
NEXT_PUBLIC_PAY_HELP_IMAGE_URL: optionalTrimmedString,
NEXT_PUBLIC_PAY_HELP_TEXT: optionalTrimmedString,
PAY_HELP_IMAGE_URL: optionalTrimmedString,
PAY_HELP_TEXT: optionalTrimmedString,
});
export type Env = z.infer<typeof envSchema>;

View File

@@ -15,6 +15,10 @@ import { getEnv } from '@/lib/config';
export class EasyPayProvider implements PaymentProvider {
readonly name = 'easy-pay';
readonly supportedTypes: PaymentType[] = ['alipay', 'wxpay'];
readonly defaultLimits = {
alipay: { singleMax: 1000, dailyMax: 10000 },
wxpay: { singleMax: 1000, dailyMax: 10000 },
};
async createPayment(request: CreatePaymentRequest): Promise<CreatePaymentResponse> {
const result = await createPayment({

99
src/lib/order/limits.ts Normal file
View File

@@ -0,0 +1,99 @@
import { prisma } from '@/lib/db';
import { getEnv } from '@/lib/config';
import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
/**
* 获取指定支付渠道的每日全平台限额0 = 不限制)。
* 优先级:环境变量显式配置 > provider 默认值 > process.env 兜底 > 0
*/
export function getMethodDailyLimit(paymentType: string): number {
const env = getEnv();
const key = `MAX_DAILY_AMOUNT_${paymentType.toUpperCase()}` as keyof typeof env;
const val = env[key];
if (typeof val === 'number') return val; // 明确配置(含 0
// 尝试从已注册的 provider 取默认值
initPaymentProviders();
const providerDefault = paymentRegistry.getDefaultLimit(paymentType);
if (providerDefault?.dailyMax !== undefined) return providerDefault.dailyMax;
// 兜底process.env支持未在 schema 中声明的动态渠道)
const raw = process.env[`MAX_DAILY_AMOUNT_${paymentType.toUpperCase()}`];
if (raw !== undefined) {
const num = Number(raw);
return Number.isFinite(num) && num >= 0 ? num : 0;
}
return 0; // 默认不限制
}
/**
* 获取指定支付渠道的单笔限额0 = 使用全局 MAX_RECHARGE_AMOUNT
* 优先级process.env MAX_SINGLE_AMOUNT_* > provider 默认值 > 0
*/
export function getMethodSingleLimit(paymentType: string): number {
const raw = process.env[`MAX_SINGLE_AMOUNT_${paymentType.toUpperCase()}`];
if (raw !== undefined) {
const num = Number(raw);
if (Number.isFinite(num) && num >= 0) return num;
}
initPaymentProviders();
const providerDefault = paymentRegistry.getDefaultLimit(paymentType);
if (providerDefault?.singleMax !== undefined) return providerDefault.singleMax;
return 0; // 使用全局 MAX_RECHARGE_AMOUNT
}
export interface MethodLimitStatus {
/** 每日限额0 = 不限 */
dailyLimit: number;
/** 今日已使用金额 */
used: number;
/** 剩余每日额度null = 不限 */
remaining: number | null;
/** 是否还可使用false = 今日额度已满) */
available: boolean;
/** 单笔限额0 = 使用全局配置 MAX_RECHARGE_AMOUNT */
singleMax: number;
}
/**
* 批量查询多个支付渠道的今日使用情况。
* 一次 DB groupBy 完成,调用方按需传入渠道列表。
*/
export async function queryMethodLimits(
paymentTypes: string[],
): Promise<Record<string, MethodLimitStatus>> {
const todayStart = new Date();
todayStart.setUTCHours(0, 0, 0, 0);
const usageRows = await prisma.order.groupBy({
by: ['paymentType'],
where: {
paymentType: { in: paymentTypes },
status: { in: ['PAID', 'RECHARGING', 'COMPLETED'] },
paidAt: { gte: todayStart },
},
_sum: { amount: true },
});
const usageMap = Object.fromEntries(
usageRows.map((r) => [r.paymentType, Number(r._sum.amount ?? 0)]),
);
const result: Record<string, MethodLimitStatus> = {};
for (const type of paymentTypes) {
const dailyLimit = getMethodDailyLimit(type);
const singleMax = getMethodSingleLimit(type);
const used = usageMap[type] ?? 0;
const remaining = dailyLimit > 0 ? Math.max(0, dailyLimit - used) : null;
result[type] = {
dailyLimit,
used,
remaining,
available: dailyLimit === 0 || used < dailyLimit,
singleMax,
};
}
return result;
}

View File

@@ -1,6 +1,7 @@
import { prisma } from '@/lib/db';
import { getEnv } from '@/lib/config';
import { generateRechargeCode } from './code-gen';
import { getMethodDailyLimit } from './limits';
import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
import type { PaymentType, PaymentNotification } from '@/lib/payment';
import { getUser, createAndRedeem, subtractBalance } from '@/lib/sub2api/client';
@@ -67,6 +68,32 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
}
}
// 渠道每日全平台限额校验0 = 不限)
const methodDailyLimit = getMethodDailyLimit(input.paymentType);
if (methodDailyLimit > 0) {
const todayStart = new Date();
todayStart.setUTCHours(0, 0, 0, 0);
const methodAgg = await prisma.order.aggregate({
where: {
paymentType: input.paymentType,
status: { in: ['PAID', 'RECHARGING', 'COMPLETED'] },
paidAt: { gte: todayStart },
},
_sum: { amount: true },
});
const methodUsed = Number(methodAgg._sum.amount ?? 0);
if (methodUsed + input.amount > methodDailyLimit) {
const remaining = Math.max(0, methodDailyLimit - methodUsed);
throw new OrderError(
'METHOD_DAILY_LIMIT_EXCEEDED',
remaining > 0
? `${input.paymentType} 今日剩余额度 ${remaining.toFixed(2)} 元,请减少充值金额或使用其他支付方式`
: `${input.paymentType} 今日充值额度已满,请使用其他支付方式`,
429,
);
}
}
const expiresAt = new Date(Date.now() + env.ORDER_TIMEOUT_MINUTES * 60 * 1000);
const order = await prisma.order.create({
data: {

View File

@@ -19,10 +19,21 @@ let initialized = false;
export function initPaymentProviders(): void {
if (initialized) return;
paymentRegistry.register(new EasyPayProvider());
const env = getEnv();
if (env.STRIPE_SECRET_KEY) {
const providers = env.PAYMENT_PROVIDERS;
if (providers.includes('easypay')) {
if (!env.EASY_PAY_PID || !env.EASY_PAY_PKEY) {
throw new Error('PAYMENT_PROVIDERS 含 easypay但缺少 EASY_PAY_PID 或 EASY_PAY_PKEY');
}
paymentRegistry.register(new EasyPayProvider());
}
if (providers.includes('stripe')) {
if (!env.STRIPE_SECRET_KEY) {
throw new Error('PAYMENT_PROVIDERS 含 stripe但缺少 STRIPE_SECRET_KEY');
}
paymentRegistry.register(new StripeProvider());
}

View File

@@ -1,4 +1,4 @@
import type { PaymentProvider, PaymentType } from './types';
import type { PaymentProvider, PaymentType, MethodDefaultLimits } from './types';
export class PaymentProviderRegistry {
private providers = new Map<PaymentType, PaymentProvider>();
@@ -24,6 +24,12 @@ export class PaymentProviderRegistry {
getSupportedTypes(): PaymentType[] {
return Array.from(this.providers.keys());
}
/** 获取指定渠道的提供商默认限额(未注册时返回 undefined */
getDefaultLimit(type: string): MethodDefaultLimits | undefined {
const provider = this.providers.get(type as PaymentType);
return provider?.defaultLimits?.[type];
}
}
export const paymentRegistry = new PaymentProviderRegistry();

View File

@@ -51,10 +51,20 @@ export interface RefundResponse {
status: 'success' | 'pending' | 'failed';
}
/** Per-method default limits declared by the provider */
export interface MethodDefaultLimits {
/** 单笔最大金额0 = 不限(使用全局 MAX_RECHARGE_AMOUNT */
singleMax?: number;
/** 每日全平台最大金额0 = 不限 */
dailyMax?: number;
}
/** Common interface that all payment providers must implement */
export interface PaymentProvider {
readonly name: string;
readonly supportedTypes: PaymentType[];
/** 各渠道默认限额key 为 PaymentType如 'alipay'),可被环境变量覆盖 */
readonly defaultLimits?: Record<string, MethodDefaultLimits>;
createPayment(request: CreatePaymentRequest): Promise<CreatePaymentResponse>;
queryOrder(tradeNo: string): Promise<QueryOrderResponse>;

View File

@@ -15,6 +15,9 @@ import type {
export class StripeProvider implements PaymentProvider {
readonly name = 'stripe';
readonly supportedTypes: PaymentType[] = ['stripe'];
readonly defaultLimits = {
stripe: { singleMax: 0, dailyMax: 0 }, // 0 = unlimited
};
private client: Stripe | null = null;