Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
136723b8af |
31
src/app/api/limits/route.ts
Normal file
31
src/app/api/limits/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { getUser } from '@/lib/sub2api/client';
|
import { getUser } from '@/lib/sub2api/client';
|
||||||
import { getEnv } from '@/lib/config';
|
import { getEnv } from '@/lib/config';
|
||||||
|
import { queryMethodLimits } from '@/lib/order/limits';
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const userId = Number(request.nextUrl.searchParams.get('user_id'));
|
const userId = Number(request.nextUrl.searchParams.get('user_id'));
|
||||||
@@ -10,7 +11,10 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const env = getEnv();
|
const env = getEnv();
|
||||||
const user = await getUser(userId);
|
const [user, methodLimits] = await Promise.all([
|
||||||
|
getUser(userId),
|
||||||
|
queryMethodLimits(env.ENABLED_PAYMENT_TYPES),
|
||||||
|
]);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
user: {
|
user: {
|
||||||
@@ -22,6 +26,7 @@ export async function GET(request: NextRequest) {
|
|||||||
minAmount: env.MIN_RECHARGE_AMOUNT,
|
minAmount: env.MIN_RECHARGE_AMOUNT,
|
||||||
maxAmount: env.MAX_RECHARGE_AMOUNT,
|
maxAmount: env.MAX_RECHARGE_AMOUNT,
|
||||||
maxDailyAmount: env.MAX_DAILY_RECHARGE_AMOUNT,
|
maxDailyAmount: env.MAX_DAILY_RECHARGE_AMOUNT,
|
||||||
|
methodLimits,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import OrderStatus from '@/components/OrderStatus';
|
|||||||
import PayPageLayout from '@/components/PayPageLayout';
|
import PayPageLayout from '@/components/PayPageLayout';
|
||||||
import MobileOrderList from '@/components/MobileOrderList';
|
import MobileOrderList from '@/components/MobileOrderList';
|
||||||
import { detectDeviceIsMobile, type UserInfo, type MyOrder } from '@/lib/pay-utils';
|
import { detectDeviceIsMobile, type UserInfo, type MyOrder } from '@/lib/pay-utils';
|
||||||
|
import type { MethodLimitInfo } from '@/components/PaymentForm';
|
||||||
|
|
||||||
interface OrderResult {
|
interface OrderResult {
|
||||||
orderId: string;
|
orderId: string;
|
||||||
@@ -25,6 +26,7 @@ interface AppConfig {
|
|||||||
minAmount: number;
|
minAmount: number;
|
||||||
maxAmount: number;
|
maxAmount: number;
|
||||||
maxDailyAmount: number;
|
maxDailyAmount: number;
|
||||||
|
methodLimits?: Record<string, MethodLimitInfo>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PayContent() {
|
function PayContent() {
|
||||||
@@ -54,7 +56,7 @@ function PayContent() {
|
|||||||
const [config, setConfig] = useState<AppConfig>({
|
const [config, setConfig] = useState<AppConfig>({
|
||||||
enabledPaymentTypes: ['alipay', 'wxpay', 'stripe'],
|
enabledPaymentTypes: ['alipay', 'wxpay', 'stripe'],
|
||||||
minAmount: 1,
|
minAmount: 1,
|
||||||
maxAmount: 10000,
|
maxAmount: 1000,
|
||||||
maxDailyAmount: 0,
|
maxDailyAmount: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -90,7 +92,13 @@ function PayContent() {
|
|||||||
if (cfgRes.ok) {
|
if (cfgRes.ok) {
|
||||||
const cfgData = await cfgRes.json();
|
const cfgData = await cfgRes.json();
|
||||||
if (cfgData.config) {
|
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: '您有过多待支付订单,请先完成或取消现有订单后再试',
|
TOO_MANY_PENDING: '您有过多待支付订单,请先完成或取消现有订单后再试',
|
||||||
USER_NOT_FOUND: '用户不存在,请检查链接是否正确',
|
USER_NOT_FOUND: '用户不存在,请检查链接是否正确',
|
||||||
DAILY_LIMIT_EXCEEDED: data.error,
|
DAILY_LIMIT_EXCEEDED: data.error,
|
||||||
|
METHOD_DAILY_LIMIT_EXCEEDED: data.error,
|
||||||
PAYMENT_GATEWAY_ERROR: data.error,
|
PAYMENT_GATEWAY_ERROR: data.error,
|
||||||
};
|
};
|
||||||
setError(codeMessages[data.code] || data.error || '创建订单失败');
|
setError(codeMessages[data.code] || data.error || '创建订单失败');
|
||||||
@@ -350,6 +359,7 @@ function PayContent() {
|
|||||||
userName={userInfo?.username}
|
userName={userInfo?.username}
|
||||||
userBalance={userInfo?.balance}
|
userBalance={userInfo?.balance}
|
||||||
enabledPaymentTypes={config.enabledPaymentTypes}
|
enabledPaymentTypes={config.enabledPaymentTypes}
|
||||||
|
methodLimits={config.methodLimits}
|
||||||
minAmount={config.minAmount}
|
minAmount={config.minAmount}
|
||||||
maxAmount={config.maxAmount}
|
maxAmount={config.maxAmount}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
|
|||||||
@@ -3,11 +3,17 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { PAYMENT_TYPE_META } from '@/lib/pay-utils';
|
import { PAYMENT_TYPE_META } from '@/lib/pay-utils';
|
||||||
|
|
||||||
|
export interface MethodLimitInfo {
|
||||||
|
available: boolean;
|
||||||
|
remaining: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
interface PaymentFormProps {
|
interface PaymentFormProps {
|
||||||
userId: number;
|
userId: number;
|
||||||
userName?: string;
|
userName?: string;
|
||||||
userBalance?: number;
|
userBalance?: number;
|
||||||
enabledPaymentTypes: string[];
|
enabledPaymentTypes: string[];
|
||||||
|
methodLimits?: Record<string, MethodLimitInfo>;
|
||||||
minAmount: number;
|
minAmount: number;
|
||||||
maxAmount: number;
|
maxAmount: number;
|
||||||
onSubmit: (amount: number, paymentType: string) => Promise<void>;
|
onSubmit: (amount: number, paymentType: string) => Promise<void>;
|
||||||
@@ -27,6 +33,7 @@ export default function PaymentForm({
|
|||||||
userName,
|
userName,
|
||||||
userBalance,
|
userBalance,
|
||||||
enabledPaymentTypes,
|
enabledPaymentTypes,
|
||||||
|
methodLimits,
|
||||||
minAmount,
|
minAmount,
|
||||||
maxAmount,
|
maxAmount,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
@@ -63,7 +70,8 @@ export default function PaymentForm({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const selectedAmount = amount || 0;
|
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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -215,36 +223,59 @@ export default function PaymentForm({
|
|||||||
{enabledPaymentTypes.map((type) => {
|
{enabledPaymentTypes.map((type) => {
|
||||||
const meta = PAYMENT_TYPE_META[type];
|
const meta = PAYMENT_TYPE_META[type];
|
||||||
const isSelected = paymentType === type;
|
const isSelected = paymentType === type;
|
||||||
|
const limitInfo = methodLimits?.[type];
|
||||||
|
const isUnavailable = limitInfo !== undefined && !limitInfo.available;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={type}
|
key={type}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setPaymentType(type)}
|
disabled={isUnavailable}
|
||||||
className={`flex h-[58px] flex-1 items-center justify-center rounded-lg border px-3 transition-all ${
|
onClick={() => !isUnavailable && setPaymentType(type)}
|
||||||
isSelected
|
title={isUnavailable ? '今日充值额度已满,请使用其他支付方式' : undefined}
|
||||||
? `${meta?.selectedBorder || 'border-blue-500'} ${meta?.selectedBg || 'bg-blue-50'} text-slate-900 shadow-sm`
|
className={[
|
||||||
: dark
|
'relative flex h-[58px] flex-1 flex-col items-center justify-center rounded-lg border px-3 transition-all',
|
||||||
? 'border-slate-700 bg-slate-900 text-slate-200 hover:border-slate-500'
|
isUnavailable
|
||||||
: 'border-gray-300 bg-white text-slate-700 hover:border-gray-400'
|
? 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">
|
<span className="flex items-center gap-2">
|
||||||
{renderPaymentIcon(type)}
|
{renderPaymentIcon(type)}
|
||||||
<span className="flex flex-col items-start leading-none">
|
<span className="flex flex-col items-start leading-none">
|
||||||
<span className="text-xl font-semibold tracking-tight">{meta?.label || type}</span>
|
<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
|
<span
|
||||||
className={`text-[10px] tracking-wide ${dark && !isSelected ? 'text-slate-400' : 'text-slate-600'}`}
|
className={`text-[10px] tracking-wide ${dark && !isSelected ? 'text-slate-400' : 'text-slate-600'}`}
|
||||||
>
|
>
|
||||||
{meta.sublabel}
|
{meta.sublabel}
|
||||||
</span>
|
</span>
|
||||||
)}
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Submit */}
|
{/* Submit */}
|
||||||
|
|||||||
@@ -36,6 +36,12 @@ const envSchema = z.object({
|
|||||||
MAX_RECHARGE_AMOUNT: z.string().default('1000').transform(Number).pipe(z.number().positive()),
|
MAX_RECHARGE_AMOUNT: z.string().default('1000').transform(Number).pipe(z.number().positive()),
|
||||||
// 每日每用户最大累计充值额,0 = 不限制
|
// 每日每用户最大累计充值额,0 = 不限制
|
||||||
MAX_DAILY_RECHARGE_AMOUNT: z.string().default('10000').transform(Number).pipe(z.number().min(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'),
|
PRODUCT_NAME: z.string().default('Sub2API Balance Recharge'),
|
||||||
|
|
||||||
ADMIN_TOKEN: z.string().min(1),
|
ADMIN_TOKEN: z.string().min(1),
|
||||||
|
|||||||
71
src/lib/order/limits.ts
Normal file
71
src/lib/order/limits.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { getEnv } from '@/lib/config';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定支付渠道的每日全平台限额(0 = 不限制)。
|
||||||
|
* 优先读 config(Zod 验证),兜底读 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;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { prisma } from '@/lib/db';
|
import { prisma } from '@/lib/db';
|
||||||
import { getEnv } from '@/lib/config';
|
import { getEnv } from '@/lib/config';
|
||||||
import { generateRechargeCode } from './code-gen';
|
import { generateRechargeCode } from './code-gen';
|
||||||
|
import { getMethodDailyLimit } from './limits';
|
||||||
import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
|
import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
|
||||||
import type { PaymentType, PaymentNotification } from '@/lib/payment';
|
import type { PaymentType, PaymentNotification } from '@/lib/payment';
|
||||||
import { getUser, createAndRedeem, subtractBalance } from '@/lib/sub2api/client';
|
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 expiresAt = new Date(Date.now() + env.ORDER_TIMEOUT_MINUTES * 60 * 1000);
|
||||||
const order = await prisma.order.create({
|
const order = await prisma.order.create({
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
Reference in New Issue
Block a user