Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6815fc2a3 |
@@ -6,6 +6,8 @@ import { PAYMENT_TYPE_META } from '@/lib/pay-utils';
|
|||||||
export interface MethodLimitInfo {
|
export interface MethodLimitInfo {
|
||||||
available: boolean;
|
available: boolean;
|
||||||
remaining: number | null;
|
remaining: number | null;
|
||||||
|
/** 单笔限额,0 = 使用全局 maxAmount */
|
||||||
|
singleMax?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PaymentFormProps {
|
interface PaymentFormProps {
|
||||||
@@ -71,7 +73,9 @@ export default function PaymentForm({
|
|||||||
|
|
||||||
const selectedAmount = amount || 0;
|
const selectedAmount = amount || 0;
|
||||||
const isMethodAvailable = !methodLimits || (methodLimits[paymentType]?.available !== false);
|
const isMethodAvailable = !methodLimits || (methodLimits[paymentType]?.available !== false);
|
||||||
const isValid = selectedAmount >= minAmount && selectedAmount <= maxAmount && hasValidCentPrecision(selectedAmount) && isMethodAvailable;
|
const methodSingleMax = methodLimits?.[paymentType]?.singleMax;
|
||||||
|
const effectiveMax = (methodSingleMax !== undefined && methodSingleMax > 0) ? methodSingleMax : maxAmount;
|
||||||
|
const isValid = selectedAmount >= minAmount && selectedAmount <= effectiveMax && hasValidCentPrecision(selectedAmount) && isMethodAvailable;
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -151,7 +155,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.filter((val) => val <= maxAmount).map((val) => (
|
{QUICK_AMOUNTS.filter((val) => val <= effectiveMax).map((val) => (
|
||||||
<button
|
<button
|
||||||
key={val}
|
key={val}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -188,10 +192,10 @@ export default function PaymentForm({
|
|||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
min={minAmount}
|
min={minAmount}
|
||||||
max={maxAmount}
|
max={effectiveMax}
|
||||||
value={customAmount}
|
value={customAmount}
|
||||||
onChange={(e) => handleCustomAmountChange(e.target.value)}
|
onChange={(e) => handleCustomAmountChange(e.target.value)}
|
||||||
placeholder={`${minAmount} - ${maxAmount}`}
|
placeholder={`${minAmount} - ${effectiveMax}`}
|
||||||
className={[
|
className={[
|
||||||
'w-full rounded-lg border py-3 pl-8 pr-4 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500',
|
'w-full rounded-lg border py-3 pl-8 pr-4 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500',
|
||||||
dark ? 'border-slate-700 bg-slate-900 text-slate-100' : 'border-gray-300 bg-white text-gray-900',
|
dark ? 'border-slate-700 bg-slate-900 text-slate-100' : 'border-gray-300 bg-white text-gray-900',
|
||||||
@@ -205,7 +209,7 @@ export default function PaymentForm({
|
|||||||
let msg = '金额需在范围内,且最多支持 2 位小数(精确到分)';
|
let msg = '金额需在范围内,且最多支持 2 位小数(精确到分)';
|
||||||
if (!isNaN(num)) {
|
if (!isNaN(num)) {
|
||||||
if (num < minAmount) msg = `单笔最低充值 ¥${minAmount}`;
|
if (num < minAmount) msg = `单笔最低充值 ¥${minAmount}`;
|
||||||
else if (num > maxAmount) msg = `单笔最高充值 ¥${maxAmount}`;
|
else if (num > effectiveMax) msg = `单笔最高充值 ¥${effectiveMax}`;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className={['text-xs', dark ? 'text-amber-300' : 'text-amber-700'].join(' ')}>
|
<div className={['text-xs', dark ? 'text-amber-300' : 'text-amber-700'].join(' ')}>
|
||||||
|
|||||||
@@ -37,11 +37,11 @@ const envSchema = z.object({
|
|||||||
// 每日每用户最大累计充值额,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 = 不限制
|
// 每日各渠道全平台总限额,可选覆盖(0 = 不限制)。
|
||||||
// 新增渠道按 MAX_DAILY_AMOUNT_{TYPE大写} 命名即可自动生效
|
// 未设置时由各 PaymentProvider.defaultLimits 提供默认值。
|
||||||
MAX_DAILY_AMOUNT_ALIPAY: z.string().default('10000').transform(Number).pipe(z.number().min(0)),
|
MAX_DAILY_AMOUNT_ALIPAY: z.string().optional().transform((v) => (v !== undefined ? Number(v) : undefined)).pipe(z.number().min(0).optional()),
|
||||||
MAX_DAILY_AMOUNT_WXPAY: z.string().default('10000').transform(Number).pipe(z.number().min(0)),
|
MAX_DAILY_AMOUNT_WXPAY: z.string().optional().transform((v) => (v !== undefined ? Number(v) : undefined)).pipe(z.number().min(0).optional()),
|
||||||
MAX_DAILY_AMOUNT_STRIPE: z.string().default('0').transform(Number).pipe(z.number().min(0)),
|
MAX_DAILY_AMOUNT_STRIPE: z.string().optional().transform((v) => (v !== undefined ? Number(v) : undefined)).pipe(z.number().min(0).optional()),
|
||||||
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),
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ import { getEnv } from '@/lib/config';
|
|||||||
export class EasyPayProvider implements PaymentProvider {
|
export class EasyPayProvider implements PaymentProvider {
|
||||||
readonly name = 'easy-pay';
|
readonly name = 'easy-pay';
|
||||||
readonly supportedTypes: PaymentType[] = ['alipay', 'wxpay'];
|
readonly supportedTypes: PaymentType[] = ['alipay', 'wxpay'];
|
||||||
|
readonly defaultLimits = {
|
||||||
|
alipay: { singleMax: 1000, dailyMax: 10000 },
|
||||||
|
wxpay: { singleMax: 1000, dailyMax: 10000 },
|
||||||
|
};
|
||||||
|
|
||||||
async createPayment(request: CreatePaymentRequest): Promise<CreatePaymentResponse> {
|
async createPayment(request: CreatePaymentRequest): Promise<CreatePaymentResponse> {
|
||||||
const result = await createPayment({
|
const result = await createPayment({
|
||||||
|
|||||||
@@ -1,17 +1,23 @@
|
|||||||
import { prisma } from '@/lib/db';
|
import { prisma } from '@/lib/db';
|
||||||
import { getEnv } from '@/lib/config';
|
import { getEnv } from '@/lib/config';
|
||||||
|
import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取指定支付渠道的每日全平台限额(0 = 不限制)。
|
* 获取指定支付渠道的每日全平台限额(0 = 不限制)。
|
||||||
* 优先读 config(Zod 验证),兜底读 process.env,适配未来动态注册的新渠道。
|
* 优先级:环境变量显式配置 > provider 默认值 > process.env 兜底 > 0
|
||||||
*/
|
*/
|
||||||
export function getMethodDailyLimit(paymentType: string): number {
|
export function getMethodDailyLimit(paymentType: string): number {
|
||||||
const env = getEnv();
|
const env = getEnv();
|
||||||
const key = `MAX_DAILY_AMOUNT_${paymentType.toUpperCase()}` as keyof typeof env;
|
const key = `MAX_DAILY_AMOUNT_${paymentType.toUpperCase()}` as keyof typeof env;
|
||||||
const val = env[key];
|
const val = env[key];
|
||||||
if (typeof val === 'number') return val;
|
if (typeof val === 'number') return val; // 明确配置(含 0)
|
||||||
|
|
||||||
// 兜底:支持动态渠道(未在 schema 中声明的 MAX_DAILY_AMOUNT_* 变量)
|
// 尝试从已注册的 provider 取默认值
|
||||||
|
initPaymentProviders();
|
||||||
|
const providerDefault = paymentRegistry.getDefaultLimit(paymentType);
|
||||||
|
if (providerDefault?.dailyMax !== undefined) return providerDefault.dailyMax;
|
||||||
|
|
||||||
|
// 兜底:process.env(支持未在 schema 中声明的动态渠道)
|
||||||
const raw = process.env[`MAX_DAILY_AMOUNT_${paymentType.toUpperCase()}`];
|
const raw = process.env[`MAX_DAILY_AMOUNT_${paymentType.toUpperCase()}`];
|
||||||
if (raw !== undefined) {
|
if (raw !== undefined) {
|
||||||
const num = Number(raw);
|
const num = Number(raw);
|
||||||
@@ -20,15 +26,35 @@ export function getMethodDailyLimit(paymentType: string): number {
|
|||||||
return 0; // 默认不限制
|
return 0; // 默认不限制
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定支付渠道的单笔限额(0 = 使用全局 MAX_RECHARGE_AMOUNT)。
|
||||||
|
* 优先级:process.env MAX_SINGLE_AMOUNT_* > provider 默认值 > 0
|
||||||
|
*/
|
||||||
|
export function getMethodSingleLimit(paymentType: string): number {
|
||||||
|
const raw = process.env[`MAX_SINGLE_AMOUNT_${paymentType.toUpperCase()}`];
|
||||||
|
if (raw !== undefined) {
|
||||||
|
const num = Number(raw);
|
||||||
|
if (Number.isFinite(num) && num >= 0) return num;
|
||||||
|
}
|
||||||
|
|
||||||
|
initPaymentProviders();
|
||||||
|
const providerDefault = paymentRegistry.getDefaultLimit(paymentType);
|
||||||
|
if (providerDefault?.singleMax !== undefined) return providerDefault.singleMax;
|
||||||
|
|
||||||
|
return 0; // 使用全局 MAX_RECHARGE_AMOUNT
|
||||||
|
}
|
||||||
|
|
||||||
export interface MethodLimitStatus {
|
export interface MethodLimitStatus {
|
||||||
/** 每日限额,0 = 不限 */
|
/** 每日限额,0 = 不限 */
|
||||||
dailyLimit: number;
|
dailyLimit: number;
|
||||||
/** 今日已使用金额 */
|
/** 今日已使用金额 */
|
||||||
used: number;
|
used: number;
|
||||||
/** 剩余额度,null = 不限 */
|
/** 剩余每日额度,null = 不限 */
|
||||||
remaining: number | null;
|
remaining: number | null;
|
||||||
/** 是否还可使用(false = 今日额度已满) */
|
/** 是否还可使用(false = 今日额度已满) */
|
||||||
available: boolean;
|
available: boolean;
|
||||||
|
/** 单笔限额,0 = 使用全局配置 MAX_RECHARGE_AMOUNT */
|
||||||
|
singleMax: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -58,6 +84,7 @@ export async function queryMethodLimits(
|
|||||||
const result: Record<string, MethodLimitStatus> = {};
|
const result: Record<string, MethodLimitStatus> = {};
|
||||||
for (const type of paymentTypes) {
|
for (const type of paymentTypes) {
|
||||||
const dailyLimit = getMethodDailyLimit(type);
|
const dailyLimit = getMethodDailyLimit(type);
|
||||||
|
const singleMax = getMethodSingleLimit(type);
|
||||||
const used = usageMap[type] ?? 0;
|
const used = usageMap[type] ?? 0;
|
||||||
const remaining = dailyLimit > 0 ? Math.max(0, dailyLimit - used) : null;
|
const remaining = dailyLimit > 0 ? Math.max(0, dailyLimit - used) : null;
|
||||||
result[type] = {
|
result[type] = {
|
||||||
@@ -65,6 +92,7 @@ export async function queryMethodLimits(
|
|||||||
used,
|
used,
|
||||||
remaining,
|
remaining,
|
||||||
available: dailyLimit === 0 || used < dailyLimit,
|
available: dailyLimit === 0 || used < dailyLimit,
|
||||||
|
singleMax,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { PaymentProvider, PaymentType } from './types';
|
import type { PaymentProvider, PaymentType, MethodDefaultLimits } from './types';
|
||||||
|
|
||||||
export class PaymentProviderRegistry {
|
export class PaymentProviderRegistry {
|
||||||
private providers = new Map<PaymentType, PaymentProvider>();
|
private providers = new Map<PaymentType, PaymentProvider>();
|
||||||
@@ -24,6 +24,12 @@ export class PaymentProviderRegistry {
|
|||||||
getSupportedTypes(): PaymentType[] {
|
getSupportedTypes(): PaymentType[] {
|
||||||
return Array.from(this.providers.keys());
|
return Array.from(this.providers.keys());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 获取指定渠道的提供商默认限额(未注册时返回 undefined) */
|
||||||
|
getDefaultLimit(type: string): MethodDefaultLimits | undefined {
|
||||||
|
const provider = this.providers.get(type as PaymentType);
|
||||||
|
return provider?.defaultLimits?.[type];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const paymentRegistry = new PaymentProviderRegistry();
|
export const paymentRegistry = new PaymentProviderRegistry();
|
||||||
|
|||||||
@@ -51,10 +51,20 @@ export interface RefundResponse {
|
|||||||
status: 'success' | 'pending' | 'failed';
|
status: 'success' | 'pending' | 'failed';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Per-method default limits declared by the provider */
|
||||||
|
export interface MethodDefaultLimits {
|
||||||
|
/** 单笔最大金额,0 = 不限(使用全局 MAX_RECHARGE_AMOUNT) */
|
||||||
|
singleMax?: number;
|
||||||
|
/** 每日全平台最大金额,0 = 不限 */
|
||||||
|
dailyMax?: number;
|
||||||
|
}
|
||||||
|
|
||||||
/** Common interface that all payment providers must implement */
|
/** Common interface that all payment providers must implement */
|
||||||
export interface PaymentProvider {
|
export interface PaymentProvider {
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly supportedTypes: PaymentType[];
|
readonly supportedTypes: PaymentType[];
|
||||||
|
/** 各渠道默认限额,key 为 PaymentType(如 'alipay'),可被环境变量覆盖 */
|
||||||
|
readonly defaultLimits?: Record<string, MethodDefaultLimits>;
|
||||||
|
|
||||||
createPayment(request: CreatePaymentRequest): Promise<CreatePaymentResponse>;
|
createPayment(request: CreatePaymentRequest): Promise<CreatePaymentResponse>;
|
||||||
queryOrder(tradeNo: string): Promise<QueryOrderResponse>;
|
queryOrder(tradeNo: string): Promise<QueryOrderResponse>;
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ import type {
|
|||||||
export class StripeProvider implements PaymentProvider {
|
export class StripeProvider implements PaymentProvider {
|
||||||
readonly name = 'stripe';
|
readonly name = 'stripe';
|
||||||
readonly supportedTypes: PaymentType[] = ['stripe'];
|
readonly supportedTypes: PaymentType[] = ['stripe'];
|
||||||
|
readonly defaultLimits = {
|
||||||
|
stripe: { singleMax: 0, dailyMax: 0 }, // 0 = unlimited
|
||||||
|
};
|
||||||
|
|
||||||
private client: Stripe | null = null;
|
private client: Stripe | null = null;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user