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}