feat: 每日充值限额 + 单笔上限默认 1000 + 前端金额校验优化
- 新增 MAX_DAILY_RECHARGE_AMOUNT 环境变量(0=不限制), 创建订单时统计当日已付款总额,超限返回友好提示 - MAX_RECHARGE_AMOUNT 默认值从 10000 改为 1000 - PaymentForm 快速金额按钮过滤掉超过 maxAmount 的选项 - 金额超限时前端显示明确提示(单笔最低/最高 ¥xxx) - 支付说明栏展示每日限额信息
This commit is contained in:
@@ -21,6 +21,7 @@ export async function GET(request: NextRequest) {
|
|||||||
enabledPaymentTypes: env.ENABLED_PAYMENT_TYPES,
|
enabledPaymentTypes: env.ENABLED_PAYMENT_TYPES,
|
||||||
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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ interface AppConfig {
|
|||||||
enabledPaymentTypes: string[];
|
enabledPaymentTypes: string[];
|
||||||
minAmount: number;
|
minAmount: number;
|
||||||
maxAmount: number;
|
maxAmount: number;
|
||||||
|
maxDailyAmount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PayContent() {
|
function PayContent() {
|
||||||
@@ -51,6 +52,7 @@ function PayContent() {
|
|||||||
enabledPaymentTypes: ['alipay', 'wxpay', 'stripe'],
|
enabledPaymentTypes: ['alipay', 'wxpay', 'stripe'],
|
||||||
minAmount: 1,
|
minAmount: 1,
|
||||||
maxAmount: 10000,
|
maxAmount: 10000,
|
||||||
|
maxDailyAmount: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const effectiveUserId = resolvedUserId || userId;
|
const effectiveUserId = resolvedUserId || userId;
|
||||||
@@ -178,6 +180,7 @@ function PayContent() {
|
|||||||
USER_INACTIVE: '账户已被禁用,无法充值,请联系管理员',
|
USER_INACTIVE: '账户已被禁用,无法充值,请联系管理员',
|
||||||
TOO_MANY_PENDING: '您有过多待支付订单,请先完成或取消现有订单后再试',
|
TOO_MANY_PENDING: '您有过多待支付订单,请先完成或取消现有订单后再试',
|
||||||
USER_NOT_FOUND: '用户不存在,请检查链接是否正确',
|
USER_NOT_FOUND: '用户不存在,请检查链接是否正确',
|
||||||
|
DAILY_LIMIT_EXCEEDED: data.error,
|
||||||
};
|
};
|
||||||
setError(codeMessages[data.code] || data.error || '创建订单失败');
|
setError(codeMessages[data.code] || data.error || '创建订单失败');
|
||||||
return;
|
return;
|
||||||
@@ -349,6 +352,9 @@ function PayContent() {
|
|||||||
<ul className={['mt-2 space-y-1 text-sm', isDark ? 'text-slate-300' : 'text-slate-600'].join(' ')}>
|
<ul className={['mt-2 space-y-1 text-sm', isDark ? 'text-slate-300' : 'text-slate-600'].join(' ')}>
|
||||||
<li>订单完成后会自动到账</li>
|
<li>订单完成后会自动到账</li>
|
||||||
<li>如需历史记录请查看"我的订单"</li>
|
<li>如需历史记录请查看"我的订单"</li>
|
||||||
|
{config.maxDailyAmount > 0 && (
|
||||||
|
<li>每日最大充值 ¥{config.maxDailyAmount.toFixed(2)}</li>
|
||||||
|
)}
|
||||||
{!hasToken && <li className={isDark ? 'text-amber-200' : 'text-amber-700'}>当前链接无 token,订单查询受限</li>}
|
{!hasToken && <li className={isDark ? 'text-amber-200' : 'text-amber-700'}>当前链接无 token,订单查询受限</li>}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ export default function PaymentForm({
|
|||||||
充值金额
|
充值金额
|
||||||
</label>
|
</label>
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
{QUICK_AMOUNTS.map((val) => (
|
{QUICK_AMOUNTS.filter((val) => val <= maxAmount).map((val) => (
|
||||||
<button
|
<button
|
||||||
key={val}
|
key={val}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -192,13 +192,19 @@ export default function PaymentForm({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{customAmount !== '' && !isValid && (
|
{customAmount !== '' && !isValid && (() => {
|
||||||
<div className={['text-xs', dark ? 'text-amber-300' : 'text-amber-700'].join(' ')}>
|
const num = parseFloat(customAmount);
|
||||||
{
|
let msg = '金额需在范围内,且最多支持 2 位小数(精确到分)';
|
||||||
'\u91D1\u989D\u9700\u5728\u8303\u56F4\u5185\uFF0C\u4E14\u6700\u591A\u652F\u6301 2 \u4F4D\u5C0F\u6570\uFF08\u7CBE\u786E\u5230\u5206\uFF09'
|
if (!isNaN(num)) {
|
||||||
}
|
if (num < minAmount) msg = `单笔最低充值 ¥${minAmount}`;
|
||||||
</div>
|
else if (num > maxAmount) msg = `单笔最高充值 ¥${maxAmount}`;
|
||||||
)}
|
}
|
||||||
|
return (
|
||||||
|
<div className={['text-xs', dark ? 'text-amber-300' : 'text-amber-700'].join(' ')}>
|
||||||
|
{msg}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* Payment Type */}
|
{/* Payment Type */}
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -33,7 +33,9 @@ const envSchema = z.object({
|
|||||||
|
|
||||||
ORDER_TIMEOUT_MINUTES: z.string().default('5').transform(Number).pipe(z.number().int().positive()),
|
ORDER_TIMEOUT_MINUTES: z.string().default('5').transform(Number).pipe(z.number().int().positive()),
|
||||||
MIN_RECHARGE_AMOUNT: z.string().default('1').transform(Number).pipe(z.number().positive()),
|
MIN_RECHARGE_AMOUNT: z.string().default('1').transform(Number).pipe(z.number().positive()),
|
||||||
MAX_RECHARGE_AMOUNT: z.string().default('10000').transform(Number).pipe(z.number().positive()),
|
MAX_RECHARGE_AMOUNT: z.string().default('1000').transform(Number).pipe(z.number().positive()),
|
||||||
|
// 每日每用户最大累计充值额,0 = 不限制
|
||||||
|
MAX_DAILY_RECHARGE_AMOUNT: 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),
|
||||||
|
|||||||
@@ -44,6 +44,29 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
|
|||||||
throw new OrderError('TOO_MANY_PENDING', `Too many pending orders (${MAX_PENDING_ORDERS})`, 429);
|
throw new OrderError('TOO_MANY_PENDING', `Too many pending orders (${MAX_PENDING_ORDERS})`, 429);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 每日累计充值限额校验(0 = 不限制)
|
||||||
|
if (env.MAX_DAILY_RECHARGE_AMOUNT > 0) {
|
||||||
|
const todayStart = new Date();
|
||||||
|
todayStart.setUTCHours(0, 0, 0, 0);
|
||||||
|
const dailyAgg = await prisma.order.aggregate({
|
||||||
|
where: {
|
||||||
|
userId: input.userId,
|
||||||
|
status: { in: ['PAID', 'RECHARGING', 'COMPLETED'] },
|
||||||
|
paidAt: { gte: todayStart },
|
||||||
|
},
|
||||||
|
_sum: { amount: true },
|
||||||
|
});
|
||||||
|
const alreadyPaid = Number(dailyAgg._sum.amount ?? 0);
|
||||||
|
if (alreadyPaid + input.amount > env.MAX_DAILY_RECHARGE_AMOUNT) {
|
||||||
|
const remaining = Math.max(0, env.MAX_DAILY_RECHARGE_AMOUNT - alreadyPaid);
|
||||||
|
throw new OrderError(
|
||||||
|
'DAILY_LIMIT_EXCEEDED',
|
||||||
|
`今日累计充值已达上限,剩余可充值 ${remaining.toFixed(2)} 元`,
|
||||||
|
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