3 Commits

Author SHA1 Message Date
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
erio
0c2476f340 fix: 无 token 不显示余额;充值完成后立即预取最新余额
- UserInfo.balance 改为可选字段
- 无 token 时不设 balance,PaymentForm 不渲染余额行
- COMPLETED 触发时立即调 loadUserAndOrders 刷新余额
  2.2s 结果页展示期间余额已在后台拉取,回表单时即刻呈现新值
2026-03-01 21:24:11 +08:00
13 changed files with 262 additions and 25 deletions

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,7 @@ export async function GET(request: NextRequest) {
minAmount: env.MIN_RECHARGE_AMOUNT,
maxAmount: env.MAX_RECHARGE_AMOUNT,
maxDailyAmount: env.MAX_DAILY_RECHARGE_AMOUNT,
methodLimits,
},
});
} catch (error) {

View File

@@ -72,7 +72,7 @@ function OrdersContent() {
return;
}
if (!hasToken) {
setUserInfo({ id: userId, username: `用户 #${userId}`, balance: 0 });
setUserInfo({ id: userId, username: `用户 #${userId}` });
setOrders([]);
setError('当前链接未携带登录 token无法查询"我的订单"。');
return;

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,7 @@ interface AppConfig {
minAmount: number;
maxAmount: number;
maxDailyAmount: number;
methodLimits?: Record<string, MethodLimitInfo>;
}
function PayContent() {
@@ -54,7 +56,7 @@ function PayContent() {
const [config, setConfig] = useState<AppConfig>({
enabledPaymentTypes: ['alipay', 'wxpay', 'stripe'],
minAmount: 1,
maxAmount: 10000,
maxAmount: 1000,
maxDailyAmount: 0,
});
@@ -90,7 +92,13 @@ function PayContent() {
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,
});
}
}
@@ -111,7 +119,7 @@ function PayContent() {
(typeof meUser.displayName === 'string' && meUser.displayName.trim()) ||
(typeof meUser.username === 'string' && meUser.username.trim()) ||
`用户 #${userId}`,
balance: typeof meUser.balance === 'number' ? meUser.balance : 0,
balance: typeof meUser.balance === 'number' ? meUser.balance : undefined,
});
if (Array.isArray(meData.orders)) {
@@ -127,8 +135,8 @@ function PayContent() {
}
}
// 无 token 或 token 失效:只显示用户 ID不展示隐私信息
setUserInfo({ id: userId, username: `用户 #${userId}`, balance: 0 });
// 无 token 或 token 失效:只显示用户 ID不展示隐私信息(不显示余额)
setUserInfo({ id: userId, username: `用户 #${userId}` });
setMyOrders([]);
setOrdersPage(1);
setOrdersHasMore(false);
@@ -212,6 +220,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 || '创建订单失败');
@@ -254,12 +263,13 @@ function PayContent() {
useEffect(() => {
if (step !== 'result' || finalStatus !== 'COMPLETED') return;
// 立即在后台刷新余额2.2s 显示结果页后再切回表单(届时余额已更新)
loadUserAndOrders();
const timer = setTimeout(() => {
setStep('form');
setOrderResult(null);
setFinalStatus('');
setError('');
loadUserAndOrders();
}, 2200);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -349,6 +359,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();
@@ -143,7 +155,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 +192,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 +209,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 +227,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

@@ -36,6 +36,12 @@ 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),

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

@@ -1,7 +1,7 @@
export interface UserInfo {
id?: number;
username: string;
balance: number;
balance?: number;
}
export interface MyOrder {

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;