Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9ea9d4862 | ||
|
|
e170d5451e | ||
|
|
e5424e6c5e | ||
|
|
310fa1020f | ||
|
|
85239e97f8 | ||
|
|
c6815fc2a3 |
BIN
0e10fd7fa68c9dda45b221f98145dd7a.jpg
Normal file
BIN
0e10fd7fa68c9dda45b221f98145dd7a.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 109 KiB |
@@ -12,4 +12,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- '${APP_PORT:-3001}:3000'
|
- '${APP_PORT:-3001}:3000'
|
||||||
env_file: .env
|
env_file: .env
|
||||||
|
volumes:
|
||||||
|
# 宿主机 uploads 目录挂载到 Next.js public/uploads,可通过 /uploads/* 访问
|
||||||
|
- ./uploads:/app/public/uploads:ro
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
image: sub2apipay:latest
|
image: touwaeriol/sub2apipay:${IMAGE_TAG:-latest}
|
||||||
container_name: sub2apipay
|
container_name: sub2apipay
|
||||||
ports:
|
ports:
|
||||||
- '8087:3000'
|
- '8087:3000'
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ export async function GET(request: NextRequest) {
|
|||||||
maxAmount: env.MAX_RECHARGE_AMOUNT,
|
maxAmount: env.MAX_RECHARGE_AMOUNT,
|
||||||
maxDailyAmount: env.MAX_DAILY_RECHARGE_AMOUNT,
|
maxDailyAmount: env.MAX_DAILY_RECHARGE_AMOUNT,
|
||||||
methodLimits,
|
methodLimits,
|
||||||
|
helpImageUrl: env.PAY_HELP_IMAGE_URL ?? null,
|
||||||
|
helpText: env.PAY_HELP_TEXT ?? null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ interface AppConfig {
|
|||||||
maxAmount: number;
|
maxAmount: number;
|
||||||
maxDailyAmount: number;
|
maxDailyAmount: number;
|
||||||
methodLimits?: Record<string, MethodLimitInfo>;
|
methodLimits?: Record<string, MethodLimitInfo>;
|
||||||
|
helpImageUrl?: string | null;
|
||||||
|
helpText?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PayContent() {
|
function PayContent() {
|
||||||
@@ -59,12 +61,14 @@ function PayContent() {
|
|||||||
maxAmount: 1000,
|
maxAmount: 1000,
|
||||||
maxDailyAmount: 0,
|
maxDailyAmount: 0,
|
||||||
});
|
});
|
||||||
|
const [userNotFound, setUserNotFound] = useState(false);
|
||||||
|
const [helpImageOpen, setHelpImageOpen] = useState(false);
|
||||||
|
|
||||||
const effectiveUserId = resolvedUserId || userId;
|
const effectiveUserId = resolvedUserId || userId;
|
||||||
const isEmbedded = uiMode === 'embedded' && isIframeContext;
|
const isEmbedded = uiMode === 'embedded' && isIframeContext;
|
||||||
const hasToken = token.length > 0;
|
const hasToken = token.length > 0;
|
||||||
const helpImageUrl = (process.env.NEXT_PUBLIC_PAY_HELP_IMAGE_URL || '').trim();
|
const helpImageUrl = (config.helpImageUrl || '').trim();
|
||||||
const helpText = (process.env.NEXT_PUBLIC_PAY_HELP_TEXT || '').trim();
|
const helpText = (config.helpText || '').trim();
|
||||||
const hasHelpContent = Boolean(helpImageUrl || helpText);
|
const hasHelpContent = Boolean(helpImageUrl || helpText);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -86,6 +90,7 @@ function PayContent() {
|
|||||||
const loadUserAndOrders = async () => {
|
const loadUserAndOrders = async () => {
|
||||||
if (!userId || Number.isNaN(userId) || userId <= 0) return;
|
if (!userId || Number.isNaN(userId) || userId <= 0) return;
|
||||||
|
|
||||||
|
setUserNotFound(false);
|
||||||
try {
|
try {
|
||||||
// 始终获取服务端配置(不含隐私信息)
|
// 始终获取服务端配置(不含隐私信息)
|
||||||
const cfgRes = await fetch(`/api/user?user_id=${userId}`);
|
const cfgRes = await fetch(`/api/user?user_id=${userId}`);
|
||||||
@@ -98,8 +103,13 @@ function PayContent() {
|
|||||||
maxAmount: cfgData.config.maxAmount ?? 1000,
|
maxAmount: cfgData.config.maxAmount ?? 1000,
|
||||||
maxDailyAmount: cfgData.config.maxDailyAmount ?? 0,
|
maxDailyAmount: cfgData.config.maxDailyAmount ?? 0,
|
||||||
methodLimits: cfgData.config.methodLimits,
|
methodLimits: cfgData.config.methodLimits,
|
||||||
|
helpImageUrl: cfgData.config.helpImageUrl ?? null,
|
||||||
|
helpText: cfgData.config.helpText ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} else if (cfgRes.status === 404) {
|
||||||
|
setUserNotFound(true);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 有 token 时才尝试获取用户详情和订单
|
// 有 token 时才尝试获取用户详情和订单
|
||||||
@@ -183,6 +193,17 @@ function PayContent() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (userNotFound) {
|
||||||
|
return (
|
||||||
|
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
|
||||||
|
<div className="text-center text-red-500">
|
||||||
|
<p className="text-lg font-medium">用户不存在</p>
|
||||||
|
<p className="mt-2 text-sm text-gray-500">请检查链接是否正确,或联系管理员</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const buildScopedUrl = (path: string, forceOrdersTab = false) => {
|
const buildScopedUrl = (path: string, forceOrdersTab = false) => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (effectiveUserId) params.set('user_id', String(effectiveUserId));
|
if (effectiveUserId) params.set('user_id', String(effectiveUserId));
|
||||||
@@ -412,7 +433,8 @@ function PayContent() {
|
|||||||
<img
|
<img
|
||||||
src={helpImageUrl}
|
src={helpImageUrl}
|
||||||
alt='help'
|
alt='help'
|
||||||
className='mt-3 max-h-40 w-full rounded-lg object-contain bg-white/70 p-2'
|
onClick={() => setHelpImageOpen(true)}
|
||||||
|
className='mt-3 max-h-40 w-full cursor-zoom-in rounded-lg object-contain bg-white/70 p-2'
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{helpText && (
|
{helpText && (
|
||||||
@@ -447,6 +469,20 @@ function PayContent() {
|
|||||||
{step === 'result' && (
|
{step === 'result' && (
|
||||||
<OrderStatus status={finalStatus} onBack={handleBack} dark={isDark} />
|
<OrderStatus status={finalStatus} onBack={handleBack} dark={isDark} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{helpImageOpen && helpImageUrl && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/75 p-4 backdrop-blur-sm"
|
||||||
|
onClick={() => setHelpImageOpen(false)}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={helpImageUrl}
|
||||||
|
alt='help'
|
||||||
|
className='max-h-[90vh] max-w-full rounded-xl object-contain shadow-2xl'
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</PayPageLayout>
|
</PayPageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -90,14 +94,9 @@ export default function PaymentForm({
|
|||||||
if (type === 'wxpay') {
|
if (type === 'wxpay') {
|
||||||
return (
|
return (
|
||||||
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-[#2BB741] text-white">
|
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-[#2BB741] text-white">
|
||||||
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none">
|
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="currentColor">
|
||||||
<path
|
<path d="M10 3C6.13 3 3 5.58 3 8.75c0 1.7.84 3.23 2.17 4.29l-.5 2.21 2.4-1.32c.61.17 1.25.27 1.93.27.22 0 .43-.01.64-.03C9.41 13.72 9 12.88 9 12c0-3.31 3.13-6 7-6 .26 0 .51.01.76.03C15.96 3.98 13.19 3 10 3z" />
|
||||||
d="M5 12.5 10.2 17 19 8"
|
<path d="M16 8c-3.31 0-6 2.24-6 5s2.69 5 6 5c.67 0 1.31-.1 1.9-.28l2.1 1.15-.55-2.44C20.77 15.52 22 13.86 22 12c0-2.21-2.69-4-6-4z" />
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2.4"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -151,7 +150,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 +187,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 +204,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(' ')}>
|
||||||
|
|||||||
@@ -12,7 +12,13 @@ const envSchema = z.object({
|
|||||||
SUB2API_BASE_URL: z.string().url(),
|
SUB2API_BASE_URL: z.string().url(),
|
||||||
SUB2API_ADMIN_API_KEY: z.string().min(1),
|
SUB2API_ADMIN_API_KEY: z.string().min(1),
|
||||||
|
|
||||||
// ── Easy-Pay (optional when only using Stripe) ──
|
// ── 支付服务商(显式声明启用哪些服务商,逗号分隔:easypay, stripe) ──
|
||||||
|
PAYMENT_PROVIDERS: z
|
||||||
|
.string()
|
||||||
|
.default('')
|
||||||
|
.transform((v) => v.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean)),
|
||||||
|
|
||||||
|
// ── Easy-Pay(PAYMENT_PROVIDERS 含 easypay 时必填) ──
|
||||||
EASY_PAY_PID: optionalTrimmedString,
|
EASY_PAY_PID: optionalTrimmedString,
|
||||||
EASY_PAY_PKEY: optionalTrimmedString,
|
EASY_PAY_PKEY: optionalTrimmedString,
|
||||||
EASY_PAY_API_BASE: optionalTrimmedString,
|
EASY_PAY_API_BASE: optionalTrimmedString,
|
||||||
@@ -22,10 +28,13 @@ const envSchema = z.object({
|
|||||||
EASY_PAY_CID_ALIPAY: optionalTrimmedString,
|
EASY_PAY_CID_ALIPAY: optionalTrimmedString,
|
||||||
EASY_PAY_CID_WXPAY: optionalTrimmedString,
|
EASY_PAY_CID_WXPAY: optionalTrimmedString,
|
||||||
|
|
||||||
|
// ── Stripe(PAYMENT_PROVIDERS 含 stripe 时必填) ──
|
||||||
STRIPE_SECRET_KEY: optionalTrimmedString,
|
STRIPE_SECRET_KEY: optionalTrimmedString,
|
||||||
STRIPE_PUBLISHABLE_KEY: optionalTrimmedString,
|
STRIPE_PUBLISHABLE_KEY: optionalTrimmedString,
|
||||||
STRIPE_WEBHOOK_SECRET: optionalTrimmedString,
|
STRIPE_WEBHOOK_SECRET: optionalTrimmedString,
|
||||||
|
|
||||||
|
// ── 启用的支付渠道(在已配置服务商支持的渠道中选择) ──
|
||||||
|
// 易支付支持: alipay, wxpay;Stripe 支持: stripe
|
||||||
ENABLED_PAYMENT_TYPES: z
|
ENABLED_PAYMENT_TYPES: z
|
||||||
.string()
|
.string()
|
||||||
.default('alipay,wxpay')
|
.default('alipay,wxpay')
|
||||||
@@ -37,18 +46,18 @@ 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),
|
||||||
|
|
||||||
NEXT_PUBLIC_APP_URL: z.string().url(),
|
NEXT_PUBLIC_APP_URL: z.string().url(),
|
||||||
NEXT_PUBLIC_PAY_HELP_IMAGE_URL: optionalTrimmedString,
|
PAY_HELP_IMAGE_URL: optionalTrimmedString,
|
||||||
NEXT_PUBLIC_PAY_HELP_TEXT: optionalTrimmedString,
|
PAY_HELP_TEXT: optionalTrimmedString,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Env = z.infer<typeof envSchema>;
|
export type Env = z.infer<typeof envSchema>;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -19,10 +19,21 @@ let initialized = false;
|
|||||||
|
|
||||||
export function initPaymentProviders(): void {
|
export function initPaymentProviders(): void {
|
||||||
if (initialized) return;
|
if (initialized) return;
|
||||||
paymentRegistry.register(new EasyPayProvider());
|
|
||||||
|
|
||||||
const env = getEnv();
|
const env = getEnv();
|
||||||
if (env.STRIPE_SECRET_KEY) {
|
const providers = env.PAYMENT_PROVIDERS;
|
||||||
|
|
||||||
|
if (providers.includes('easypay')) {
|
||||||
|
if (!env.EASY_PAY_PID || !env.EASY_PAY_PKEY) {
|
||||||
|
throw new Error('PAYMENT_PROVIDERS 含 easypay,但缺少 EASY_PAY_PID 或 EASY_PAY_PKEY');
|
||||||
|
}
|
||||||
|
paymentRegistry.register(new EasyPayProvider());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (providers.includes('stripe')) {
|
||||||
|
if (!env.STRIPE_SECRET_KEY) {
|
||||||
|
throw new Error('PAYMENT_PROVIDERS 含 stripe,但缺少 STRIPE_SECRET_KEY');
|
||||||
|
}
|
||||||
paymentRegistry.register(new StripeProvider());
|
paymentRegistry.register(new StripeProvider());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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