diff --git a/src/app/api/limits/route.ts b/src/app/api/limits/route.ts new file mode 100644 index 0000000..3cc33fc --- /dev/null +++ b/src/app/api/limits/route.ts @@ -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 }); +} diff --git a/src/app/api/user/route.ts b/src/app/api/user/route.ts index d2136c4..4d1d365 100644 --- a/src/app/api/user/route.ts +++ b/src/app/api/user/route.ts @@ -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) { diff --git a/src/app/pay/page.tsx b/src/app/pay/page.tsx index 629a7f0..2e5ce9d 100644 --- a/src/app/pay/page.tsx +++ b/src/app/pay/page.tsx @@ -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; } function PayContent() { @@ -54,7 +56,7 @@ function PayContent() { const [config, setConfig] = useState({ 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} diff --git a/src/components/PaymentForm.tsx b/src/components/PaymentForm.tsx index 23606aa..c5e9746 100644 --- a/src/components/PaymentForm.tsx +++ b/src/components/PaymentForm.tsx @@ -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; minAmount: number; maxAmount: number; onSubmit: (amount: number, paymentType: string) => Promise; @@ -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 ( ); })} + + {/* 当前选中渠道额度不足时的提示 */} + {(() => { + const limitInfo = methodLimits?.[paymentType]; + if (!limitInfo || limitInfo.available) return null; + return ( +

+ 所选支付方式今日额度已满,请切换到其他支付方式 +

+ ); + })()} {/* Submit */} diff --git a/src/lib/config.ts b/src/lib/config.ts index bf21ba3..c9316ef 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -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), diff --git a/src/lib/order/limits.ts b/src/lib/order/limits.ts new file mode 100644 index 0000000..afab946 --- /dev/null +++ b/src/lib/order/limits.ts @@ -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> { + 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 = {}; + 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; +} diff --git a/src/lib/order/service.ts b/src/lib/order/service.ts index dbc8f85..91a74a9 100644 --- a/src/lib/order/service.ts +++ b/src/lib/order/service.ts @@ -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 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: {