5 Commits

Author SHA1 Message Date
erio
e170d5451e fix: 帮助内容改为服务端变量经 API 下发,运行时可配无需重新构建 2026-03-02 02:46:51 +08:00
erio
e5424e6c5e feat: 显式 PAYMENT_PROVIDERS 配置服务商,缺密钥启动即报错 2026-03-02 02:04:53 +08:00
erio
310fa1020f fix: loadUserAndOrders 开始时重置 userNotFound,防止状态残留 2026-03-02 01:23:04 +08:00
erio
85239e97f8 fix: 用户不存在时前端提示错误;修正微信支付图标;beta compose 改用 Docker Hub 镜像 2026-03-02 01:05:01 +08:00
erio
c6815fc2a3 feat: 插件化支付渠道限额 — provider 自声明单笔/每日默认限额
- PaymentProvider 接口新增 defaultLimits(单笔 singleMax + 每日 dailyMax)
- EasyPay 默认:支付宝/微信各 单笔 ¥1000、每日 ¥10000
- Stripe 默认:不限额(0 = unlimited)
- getMethodDailyLimit / getMethodSingleLimit 优先读 env var,再回退 provider 默认
- queryMethodLimits 返回 singleMax,PaymentForm 按渠道动态调整最大单笔金额
- MAX_DAILY_AMOUNT_* 改为可选 env var 覆盖(不再有硬编码默认值)
2026-03-01 22:51:09 +08:00
12 changed files with 123 additions and 31 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

View File

@@ -1,6 +1,6 @@
services:
app:
image: sub2apipay:latest
image: touwaeriol/sub2apipay:${IMAGE_TAG:-latest}
container_name: sub2apipay
ports:
- '8087:3000'

View File

@@ -27,6 +27,8 @@ export async function GET(request: NextRequest) {
maxAmount: env.MAX_RECHARGE_AMOUNT,
maxDailyAmount: env.MAX_DAILY_RECHARGE_AMOUNT,
methodLimits,
helpImageUrl: env.PAY_HELP_IMAGE_URL ?? null,
helpText: env.PAY_HELP_TEXT ?? null,
},
});
} catch (error) {

View File

@@ -27,6 +27,8 @@ interface AppConfig {
maxAmount: number;
maxDailyAmount: number;
methodLimits?: Record<string, MethodLimitInfo>;
helpImageUrl?: string | null;
helpText?: string | null;
}
function PayContent() {
@@ -59,12 +61,13 @@ function PayContent() {
maxAmount: 1000,
maxDailyAmount: 0,
});
const [userNotFound, setUserNotFound] = useState(false);
const effectiveUserId = resolvedUserId || userId;
const isEmbedded = uiMode === 'embedded' && isIframeContext;
const hasToken = token.length > 0;
const helpImageUrl = (process.env.NEXT_PUBLIC_PAY_HELP_IMAGE_URL || '').trim();
const helpText = (process.env.NEXT_PUBLIC_PAY_HELP_TEXT || '').trim();
const helpImageUrl = (config.helpImageUrl || '').trim();
const helpText = (config.helpText || '').trim();
const hasHelpContent = Boolean(helpImageUrl || helpText);
useEffect(() => {
@@ -86,6 +89,7 @@ function PayContent() {
const loadUserAndOrders = async () => {
if (!userId || Number.isNaN(userId) || userId <= 0) return;
setUserNotFound(false);
try {
// 始终获取服务端配置(不含隐私信息)
const cfgRes = await fetch(`/api/user?user_id=${userId}`);
@@ -98,8 +102,13 @@ function PayContent() {
maxAmount: cfgData.config.maxAmount ?? 1000,
maxDailyAmount: cfgData.config.maxDailyAmount ?? 0,
methodLimits: cfgData.config.methodLimits,
helpImageUrl: cfgData.config.helpImageUrl ?? null,
helpText: cfgData.config.helpText ?? null,
});
}
} else if (cfgRes.status === 404) {
setUserNotFound(true);
return;
}
// 有 token 时才尝试获取用户详情和订单
@@ -183,6 +192,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 params = new URLSearchParams();
if (effectiveUserId) params.set('user_id', String(effectiveUserId));

View File

@@ -6,6 +6,8 @@ import { PAYMENT_TYPE_META } from '@/lib/pay-utils';
export interface MethodLimitInfo {
available: boolean;
remaining: number | null;
/** 单笔限额0 = 使用全局 maxAmount */
singleMax?: number;
}
interface PaymentFormProps {
@@ -71,7 +73,9 @@ export default function PaymentForm({
const selectedAmount = amount || 0;
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) => {
e.preventDefault();
@@ -90,14 +94,9 @@ export default function PaymentForm({
if (type === 'wxpay') {
return (
<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">
<path
d="M5 12.5 10.2 17 19 8"
stroke="currentColor"
strokeWidth="2.4"
strokeLinecap="round"
strokeLinejoin="round"
/>
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="currentColor">
<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" />
<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" />
</svg>
</span>
);
@@ -151,7 +150,7 @@ export default function PaymentForm({
</label>
<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
key={val}
type="button"
@@ -188,10 +187,10 @@ export default function PaymentForm({
inputMode="decimal"
step="0.01"
min={minAmount}
max={maxAmount}
max={effectiveMax}
value={customAmount}
onChange={(e) => handleCustomAmountChange(e.target.value)}
placeholder={`${minAmount} - ${maxAmount}`}
placeholder={`${minAmount} - ${effectiveMax}`}
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',
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 位小数(精确到分)';
if (!isNaN(num)) {
if (num < minAmount) msg = `单笔最低充值 ¥${minAmount}`;
else if (num > maxAmount) msg = `单笔最高充值 ¥${maxAmount}`;
else if (num > effectiveMax) msg = `单笔最高充值 ¥${effectiveMax}`;
}
return (
<div className={['text-xs', dark ? 'text-amber-300' : 'text-amber-700'].join(' ')}>

View File

@@ -12,7 +12,13 @@ const envSchema = z.object({
SUB2API_BASE_URL: z.string().url(),
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-PayPAYMENT_PROVIDERS 含 easypay 时必填) ──
EASY_PAY_PID: optionalTrimmedString,
EASY_PAY_PKEY: optionalTrimmedString,
EASY_PAY_API_BASE: optionalTrimmedString,
@@ -22,10 +28,13 @@ const envSchema = z.object({
EASY_PAY_CID_ALIPAY: optionalTrimmedString,
EASY_PAY_CID_WXPAY: optionalTrimmedString,
// ── StripePAYMENT_PROVIDERS 含 stripe 时必填) ──
STRIPE_SECRET_KEY: optionalTrimmedString,
STRIPE_PUBLISHABLE_KEY: optionalTrimmedString,
STRIPE_WEBHOOK_SECRET: optionalTrimmedString,
// ── 启用的支付渠道(在已配置服务商支持的渠道中选择) ──
// 易支付支持: alipay, wxpayStripe 支持: stripe
ENABLED_PAYMENT_TYPES: z
.string()
.default('alipay,wxpay')
@@ -37,18 +46,18 @@ const envSchema = z.object({
// 每日每用户最大累计充值额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)),
// 每日各渠道全平台总限额,可选覆盖(0 = 不限制)。
// 未设置时由各 PaymentProvider.defaultLimits 提供默认值。
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().optional().transform((v) => (v !== undefined ? Number(v) : undefined)).pipe(z.number().min(0).optional()),
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'),
ADMIN_TOKEN: z.string().min(1),
NEXT_PUBLIC_APP_URL: z.string().url(),
NEXT_PUBLIC_PAY_HELP_IMAGE_URL: optionalTrimmedString,
NEXT_PUBLIC_PAY_HELP_TEXT: optionalTrimmedString,
PAY_HELP_IMAGE_URL: optionalTrimmedString,
PAY_HELP_TEXT: optionalTrimmedString,
});
export type Env = z.infer<typeof envSchema>;

View File

@@ -15,6 +15,10 @@ import { getEnv } from '@/lib/config';
export class EasyPayProvider implements PaymentProvider {
readonly name = 'easy-pay';
readonly supportedTypes: PaymentType[] = ['alipay', 'wxpay'];
readonly defaultLimits = {
alipay: { singleMax: 1000, dailyMax: 10000 },
wxpay: { singleMax: 1000, dailyMax: 10000 },
};
async createPayment(request: CreatePaymentRequest): Promise<CreatePaymentResponse> {
const result = await createPayment({

View File

@@ -1,17 +1,23 @@
import { prisma } from '@/lib/db';
import { getEnv } from '@/lib/config';
import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
/**
* 获取指定支付渠道的每日全平台限额0 = 不限制)。
* 优先读 configZod 验证),兜底读 process.env适配未来动态注册的新渠道。
* 优先级:环境变量显式配置 > provider 默认值 > process.env 兜底 > 0
*/
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;
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()}`];
if (raw !== undefined) {
const num = Number(raw);
@@ -20,15 +26,35 @@ export function getMethodDailyLimit(paymentType: string): number {
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 {
/** 每日限额0 = 不限 */
dailyLimit: number;
/** 今日已使用金额 */
used: number;
/** 剩余额度null = 不限 */
/** 剩余每日额度null = 不限 */
remaining: number | null;
/** 是否还可使用false = 今日额度已满) */
available: boolean;
/** 单笔限额0 = 使用全局配置 MAX_RECHARGE_AMOUNT */
singleMax: number;
}
/**
@@ -58,6 +84,7 @@ export async function queryMethodLimits(
const result: Record<string, MethodLimitStatus> = {};
for (const type of paymentTypes) {
const dailyLimit = getMethodDailyLimit(type);
const singleMax = getMethodSingleLimit(type);
const used = usageMap[type] ?? 0;
const remaining = dailyLimit > 0 ? Math.max(0, dailyLimit - used) : null;
result[type] = {
@@ -65,6 +92,7 @@ export async function queryMethodLimits(
used,
remaining,
available: dailyLimit === 0 || used < dailyLimit,
singleMax,
};
}
return result;

View File

@@ -19,10 +19,21 @@ let initialized = false;
export function initPaymentProviders(): void {
if (initialized) return;
paymentRegistry.register(new EasyPayProvider());
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());
}

View File

@@ -1,4 +1,4 @@
import type { PaymentProvider, PaymentType } from './types';
import type { PaymentProvider, PaymentType, MethodDefaultLimits } from './types';
export class PaymentProviderRegistry {
private providers = new Map<PaymentType, PaymentProvider>();
@@ -24,6 +24,12 @@ export class PaymentProviderRegistry {
getSupportedTypes(): PaymentType[] {
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();

View File

@@ -51,10 +51,20 @@ export interface RefundResponse {
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 */
export interface PaymentProvider {
readonly name: string;
readonly supportedTypes: PaymentType[];
/** 各渠道默认限额key 为 PaymentType如 'alipay'),可被环境变量覆盖 */
readonly defaultLimits?: Record<string, MethodDefaultLimits>;
createPayment(request: CreatePaymentRequest): Promise<CreatePaymentResponse>;
queryOrder(tradeNo: string): Promise<QueryOrderResponse>;

View File

@@ -15,6 +15,9 @@ import type {
export class StripeProvider implements PaymentProvider {
readonly name = 'stripe';
readonly supportedTypes: PaymentType[] = ['stripe'];
readonly defaultLimits = {
stripe: { singleMax: 0, dailyMax: 0 }, // 0 = unlimited
};
private client: Stripe | null = null;