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,新增错误码映射
This commit is contained in:
erio
2026-03-01 21:53:09 +08:00
parent 0c2476f340
commit 136723b8af
7 changed files with 195 additions and 14 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

@@ -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,
});
}
}
@@ -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 || '创建订单失败');
@@ -350,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,17 @@
import { useState } from 'react';
import { PAYMENT_TYPE_META } from '@/lib/pay-utils';
export interface MethodLimitInfo {
available: boolean;
remaining: number | null;
}
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 +33,7 @@ export default function PaymentForm({
userName,
userBalance,
enabledPaymentTypes,
methodLimits,
minAmount,
maxAmount,
onSubmit,
@@ -63,7 +70,8 @@ export default function PaymentForm({
};
const selectedAmount = amount || 0;
const isValid = selectedAmount >= minAmount && selectedAmount <= maxAmount && hasValidCentPrecision(selectedAmount);
const isMethodAvailable = !methodLimits || (methodLimits[paymentType]?.available !== false);
const isValid = selectedAmount >= minAmount && selectedAmount <= maxAmount && hasValidCentPrecision(selectedAmount) && isMethodAvailable;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -215,36 +223,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 = 不限制
// 新增渠道按 MAX_DAILY_AMOUNT_{TYPE大写} 命名即可自动生效
MAX_DAILY_AMOUNT_ALIPAY: z.string().default('10000').transform(Number).pipe(z.number().min(0)),
MAX_DAILY_AMOUNT_WXPAY: z.string().default('10000').transform(Number).pipe(z.number().min(0)),
MAX_DAILY_AMOUNT_STRIPE: z.string().default('0').transform(Number).pipe(z.number().min(0)),
PRODUCT_NAME: z.string().default('Sub2API Balance Recharge'),
ADMIN_TOKEN: z.string().min(1),

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

@@ -0,0 +1,71 @@
import { prisma } from '@/lib/db';
import { getEnv } from '@/lib/config';
/**
* 获取指定支付渠道的每日全平台限额0 = 不限制)。
* 优先读 configZod 验证),兜底读 process.env适配未来动态注册的新渠道。
*/
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;
// 兜底:支持动态渠道(未在 schema 中声明的 MAX_DAILY_AMOUNT_* 变量)
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; // 默认不限制
}
export interface MethodLimitStatus {
/** 每日限额0 = 不限 */
dailyLimit: number;
/** 今日已使用金额 */
used: number;
/** 剩余额度null = 不限 */
remaining: number | null;
/** 是否还可使用false = 今日额度已满) */
available: boolean;
}
/**
* 批量查询多个支付渠道的今日使用情况。
* 一次 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 used = usageMap[type] ?? 0;
const remaining = dailyLimit > 0 ? Math.max(0, dailyLimit - used) : null;
result[type] = {
dailyLimit,
used,
remaining,
available: dailyLimit === 0 || used < dailyLimit,
};
}
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: {