Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c083880cbc | ||
|
|
a9ea9d4862 | ||
|
|
e170d5451e | ||
|
|
e5424e6c5e | ||
|
|
310fa1020f | ||
|
|
85239e97f8 | ||
|
|
c6815fc2a3 | ||
|
|
136723b8af |
BIN
0e10fd7fa68c9dda45b221f98145dd7a.jpg
Normal file
BIN
0e10fd7fa68c9dda45b221f98145dd7a.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 109 KiB |
43
README.en.md
43
README.en.md
@@ -94,12 +94,24 @@ See [`.env.example`](./.env.example) for the full template.
|
||||
|
||||
> `DATABASE_URL` is automatically injected by Docker Compose when using the bundled database.
|
||||
|
||||
### Payment Methods
|
||||
### Payment Providers & Methods
|
||||
|
||||
Control which payment methods are enabled via `ENABLED_PAYMENT_TYPES` (comma-separated):
|
||||
**Step 1**: Declare which payment providers to load via `PAYMENT_PROVIDERS` (comma-separated):
|
||||
|
||||
```env
|
||||
ENABLED_PAYMENT_TYPES=alipay,wxpay,stripe
|
||||
# EasyPay only
|
||||
PAYMENT_PROVIDERS=easypay
|
||||
# Stripe only
|
||||
PAYMENT_PROVIDERS=stripe
|
||||
# Both
|
||||
PAYMENT_PROVIDERS=easypay,stripe
|
||||
```
|
||||
|
||||
**Step 2**: Control which channels are shown to users via `ENABLED_PAYMENT_TYPES`:
|
||||
|
||||
```env
|
||||
# EasyPay supports: alipay, wxpay | Stripe supports: stripe
|
||||
ENABLED_PAYMENT_TYPES=alipay,wxpay
|
||||
```
|
||||
|
||||
#### EasyPay (Alipay / WeChat Pay)
|
||||
@@ -137,10 +149,31 @@ ENABLED_PAYMENT_TYPES=alipay,wxpay,stripe
|
||||
|
||||
### UI Customization (Optional)
|
||||
|
||||
Display a support contact image and description on the right side of the payment page.
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `NEXT_PUBLIC_PAY_HELP_IMAGE_URL` | Help image URL (e.g. customer service QR code) |
|
||||
| `NEXT_PUBLIC_PAY_HELP_TEXT` | Help text displayed on payment page |
|
||||
| `PAY_HELP_IMAGE_URL` | Help image URL — external URL or local path (see below) |
|
||||
| `PAY_HELP_TEXT` | Help text; use `\n` for line breaks, e.g. `Scan to add WeChat\nMon–Fri 9am–6pm` |
|
||||
|
||||
**Two ways to provide the image:**
|
||||
|
||||
- **External URL** (recommended — no Compose changes needed): any publicly accessible image link (CDN, OSS, image hosting).
|
||||
```env
|
||||
PAY_HELP_IMAGE_URL=https://cdn.example.com/help-qr.jpg
|
||||
```
|
||||
|
||||
- **Local file**: place the image in `./uploads/` and reference it as `/uploads/<filename>`.
|
||||
The directory must be mounted in `docker-compose.app.yml` (included by default):
|
||||
```yaml
|
||||
volumes:
|
||||
- ./uploads:/app/public/uploads:ro
|
||||
```
|
||||
```env
|
||||
PAY_HELP_IMAGE_URL=/uploads/help-qr.jpg
|
||||
```
|
||||
|
||||
> Clicking the help image opens it full-screen in the center of the screen.
|
||||
|
||||
### Docker Compose Variables
|
||||
|
||||
|
||||
43
README.md
43
README.md
@@ -94,12 +94,24 @@ docker compose up -d --build
|
||||
|
||||
> `DATABASE_URL` 使用自带数据库时由 Compose 自动注入,无需手动填写。
|
||||
|
||||
### 支付方式
|
||||
### 支付服务商与支付方式
|
||||
|
||||
通过 `ENABLED_PAYMENT_TYPES` 控制开启哪些支付方式(逗号分隔):
|
||||
**第一步**:通过 `PAYMENT_PROVIDERS` 声明启用哪些支付服务商(逗号分隔):
|
||||
|
||||
```env
|
||||
ENABLED_PAYMENT_TYPES=alipay,wxpay,stripe
|
||||
# 仅易支付
|
||||
PAYMENT_PROVIDERS=easypay
|
||||
# 仅 Stripe
|
||||
PAYMENT_PROVIDERS=stripe
|
||||
# 两者都用
|
||||
PAYMENT_PROVIDERS=easypay,stripe
|
||||
```
|
||||
|
||||
**第二步**:通过 `ENABLED_PAYMENT_TYPES` 控制向用户展示哪些支付渠道:
|
||||
|
||||
```env
|
||||
# 易支付支持: alipay, wxpay;Stripe 支持: stripe
|
||||
ENABLED_PAYMENT_TYPES=alipay,wxpay
|
||||
```
|
||||
|
||||
#### EasyPay(支付宝 / 微信支付)
|
||||
@@ -137,10 +149,31 @@ ENABLED_PAYMENT_TYPES=alipay,wxpay,stripe
|
||||
|
||||
### UI 定制(可选)
|
||||
|
||||
在充值页面右侧可展示客服联系方式、说明图片等帮助内容。
|
||||
|
||||
| 变量 | 说明 |
|
||||
|------|------|
|
||||
| `NEXT_PUBLIC_PAY_HELP_IMAGE_URL` | 帮助图片 URL(如客服二维码) |
|
||||
| `NEXT_PUBLIC_PAY_HELP_TEXT` | 帮助说明文字 |
|
||||
| `PAY_HELP_IMAGE_URL` | 帮助图片地址(支持外部 URL 或本地路径,见下方说明) |
|
||||
| `PAY_HELP_TEXT` | 帮助说明文字,用 `\n` 换行,如 `扫码加微信\n工作日 9-18 点在线` |
|
||||
|
||||
**图片地址两种方式:**
|
||||
|
||||
- **外部 URL**(推荐,无需改 Compose 配置):直接填图片的公网地址,如 OSS / CDN / 图床链接。
|
||||
```env
|
||||
PAY_HELP_IMAGE_URL=https://cdn.example.com/help-qr.jpg
|
||||
```
|
||||
|
||||
- **本地文件**:将图片放到 `./uploads/` 目录,通过 `/uploads/文件名` 引用。
|
||||
需在 `docker-compose.app.yml` 中挂载目录(默认已包含):
|
||||
```yaml
|
||||
volumes:
|
||||
- ./uploads:/app/public/uploads:ro
|
||||
```
|
||||
```env
|
||||
PAY_HELP_IMAGE_URL=/uploads/help-qr.jpg
|
||||
```
|
||||
|
||||
> 点击帮助图片可在屏幕中央全屏放大查看。
|
||||
|
||||
### Docker Compose 专用
|
||||
|
||||
|
||||
@@ -12,4 +12,7 @@ services:
|
||||
ports:
|
||||
- '${APP_PORT:-3001}:3000'
|
||||
env_file: .env
|
||||
volumes:
|
||||
# 宿主机 uploads 目录挂载到 Next.js public/uploads,可通过 /uploads/* 访问
|
||||
- ./uploads:/app/public/uploads:ro
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
app:
|
||||
image: sub2apipay:latest
|
||||
image: touwaeriol/sub2apipay:${IMAGE_TAG:-latest}
|
||||
container_name: sub2apipay
|
||||
ports:
|
||||
- '8087:3000'
|
||||
|
||||
31
src/app/api/limits/route.ts
Normal file
31
src/app/api/limits/route.ts
Normal file
@@ -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 });
|
||||
}
|
||||
@@ -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,9 @@ export async function GET(request: NextRequest) {
|
||||
minAmount: env.MIN_RECHARGE_AMOUNT,
|
||||
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) {
|
||||
|
||||
@@ -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,9 @@ interface AppConfig {
|
||||
minAmount: number;
|
||||
maxAmount: number;
|
||||
maxDailyAmount: number;
|
||||
methodLimits?: Record<string, MethodLimitInfo>;
|
||||
helpImageUrl?: string | null;
|
||||
helpText?: string | null;
|
||||
}
|
||||
|
||||
function PayContent() {
|
||||
@@ -54,15 +58,17 @@ function PayContent() {
|
||||
const [config, setConfig] = useState<AppConfig>({
|
||||
enabledPaymentTypes: ['alipay', 'wxpay', 'stripe'],
|
||||
minAmount: 1,
|
||||
maxAmount: 10000,
|
||||
maxAmount: 1000,
|
||||
maxDailyAmount: 0,
|
||||
});
|
||||
const [userNotFound, setUserNotFound] = useState(false);
|
||||
const [helpImageOpen, setHelpImageOpen] = 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(() => {
|
||||
@@ -84,14 +90,26 @@ 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}`);
|
||||
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,
|
||||
helpImageUrl: cfgData.config.helpImageUrl ?? null,
|
||||
helpText: cfgData.config.helpText ?? null,
|
||||
});
|
||||
}
|
||||
} else if (cfgRes.status === 404) {
|
||||
setUserNotFound(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// 有 token 时才尝试获取用户详情和订单
|
||||
@@ -175,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 params = new URLSearchParams();
|
||||
if (effectiveUserId) params.set('user_id', String(effectiveUserId));
|
||||
@@ -212,6 +241,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 +380,7 @@ function PayContent() {
|
||||
userName={userInfo?.username}
|
||||
userBalance={userInfo?.balance}
|
||||
enabledPaymentTypes={config.enabledPaymentTypes}
|
||||
methodLimits={config.methodLimits}
|
||||
minAmount={config.minAmount}
|
||||
maxAmount={config.maxAmount}
|
||||
onSubmit={handleSubmit}
|
||||
@@ -402,13 +433,16 @@ function PayContent() {
|
||||
<img
|
||||
src={helpImageUrl}
|
||||
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 && (
|
||||
<p className={['mt-3 text-sm leading-6', isDark ? 'text-slate-300' : 'text-slate-600'].join(' ')}>
|
||||
{helpText}
|
||||
</p>
|
||||
<div className={['mt-3 space-y-1 text-sm leading-6', isDark ? 'text-slate-300' : 'text-slate-600'].join(' ')}>
|
||||
{helpText.split('\\n').map((line, i) => (
|
||||
<p key={i}>{line}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -437,6 +471,20 @@ function PayContent() {
|
||||
{step === 'result' && (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,11 +3,19 @@
|
||||
import { useState } from 'react';
|
||||
import { PAYMENT_TYPE_META } from '@/lib/pay-utils';
|
||||
|
||||
export interface MethodLimitInfo {
|
||||
available: boolean;
|
||||
remaining: number | null;
|
||||
/** 单笔限额,0 = 使用全局 maxAmount */
|
||||
singleMax?: number;
|
||||
}
|
||||
|
||||
interface PaymentFormProps {
|
||||
userId: number;
|
||||
userName?: string;
|
||||
userBalance?: number;
|
||||
enabledPaymentTypes: string[];
|
||||
methodLimits?: Record<string, MethodLimitInfo>;
|
||||
minAmount: number;
|
||||
maxAmount: number;
|
||||
onSubmit: (amount: number, paymentType: string) => Promise<void>;
|
||||
@@ -27,6 +35,7 @@ export default function PaymentForm({
|
||||
userName,
|
||||
userBalance,
|
||||
enabledPaymentTypes,
|
||||
methodLimits,
|
||||
minAmount,
|
||||
maxAmount,
|
||||
onSubmit,
|
||||
@@ -63,7 +72,10 @@ export default function PaymentForm({
|
||||
};
|
||||
|
||||
const selectedAmount = amount || 0;
|
||||
const isValid = selectedAmount >= minAmount && selectedAmount <= maxAmount && hasValidCentPrecision(selectedAmount);
|
||||
const isMethodAvailable = !methodLimits || (methodLimits[paymentType]?.available !== false);
|
||||
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();
|
||||
@@ -82,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>
|
||||
);
|
||||
@@ -143,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"
|
||||
@@ -180,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',
|
||||
@@ -197,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(' ')}>
|
||||
@@ -215,36 +222,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 (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
onClick={() => setPaymentType(type)}
|
||||
className={`flex h-[58px] flex-1 items-center justify-center rounded-lg border px-3 transition-all ${
|
||||
isSelected
|
||||
? `${meta?.selectedBorder || 'border-blue-500'} ${meta?.selectedBg || 'bg-blue-50'} text-slate-900 shadow-sm`
|
||||
: dark
|
||||
? 'border-slate-700 bg-slate-900 text-slate-200 hover:border-slate-500'
|
||||
: 'border-gray-300 bg-white text-slate-700 hover:border-gray-400'
|
||||
}`}
|
||||
disabled={isUnavailable}
|
||||
onClick={() => !isUnavailable && setPaymentType(type)}
|
||||
title={isUnavailable ? '今日充值额度已满,请使用其他支付方式' : undefined}
|
||||
className={[
|
||||
'relative flex h-[58px] flex-1 flex-col items-center justify-center rounded-lg border px-3 transition-all',
|
||||
isUnavailable
|
||||
? dark
|
||||
? 'cursor-not-allowed border-slate-700 bg-slate-800/50 opacity-50'
|
||||
: 'cursor-not-allowed border-gray-200 bg-gray-50 opacity-50'
|
||||
: isSelected
|
||||
? `${meta?.selectedBorder || 'border-blue-500'} ${meta?.selectedBg || 'bg-blue-50'} text-slate-900 shadow-sm`
|
||||
: dark
|
||||
? 'border-slate-700 bg-slate-900 text-slate-200 hover:border-slate-500'
|
||||
: 'border-gray-300 bg-white text-slate-700 hover:border-gray-400',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{renderPaymentIcon(type)}
|
||||
<span className="flex flex-col items-start leading-none">
|
||||
<span className="text-xl font-semibold tracking-tight">{meta?.label || type}</span>
|
||||
{meta?.sublabel && (
|
||||
{isUnavailable ? (
|
||||
<span className="text-[10px] tracking-wide text-red-400">今日额度已满</span>
|
||||
) : meta?.sublabel ? (
|
||||
<span
|
||||
className={`text-[10px] tracking-wide ${dark && !isSelected ? 'text-slate-400' : 'text-slate-600'}`}
|
||||
>
|
||||
{meta.sublabel}
|
||||
</span>
|
||||
)}
|
||||
) : null}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 当前选中渠道额度不足时的提示 */}
|
||||
{(() => {
|
||||
const limitInfo = methodLimits?.[paymentType];
|
||||
if (!limitInfo || limitInfo.available) return null;
|
||||
return (
|
||||
<p className={['mt-2 text-xs', dark ? 'text-amber-300' : 'text-amber-600'].join(' ')}>
|
||||
所选支付方式今日额度已满,请切换到其他支付方式
|
||||
</p>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
|
||||
@@ -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-Pay(PAYMENT_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,
|
||||
|
||||
// ── Stripe(PAYMENT_PROVIDERS 含 stripe 时必填) ──
|
||||
STRIPE_SECRET_KEY: optionalTrimmedString,
|
||||
STRIPE_PUBLISHABLE_KEY: optionalTrimmedString,
|
||||
STRIPE_WEBHOOK_SECRET: optionalTrimmedString,
|
||||
|
||||
// ── 启用的支付渠道(在已配置服务商支持的渠道中选择) ──
|
||||
// 易支付支持: alipay, wxpay;Stripe 支持: stripe
|
||||
ENABLED_PAYMENT_TYPES: z
|
||||
.string()
|
||||
.default('alipay,wxpay')
|
||||
@@ -36,13 +45,19 @@ 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 = 不限制)。
|
||||
// 未设置时由各 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>;
|
||||
|
||||
@@ -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({
|
||||
|
||||
99
src/lib/order/limits.ts
Normal file
99
src/lib/order/limits.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getEnv } from '@/lib/config';
|
||||
import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
|
||||
|
||||
/**
|
||||
* 获取指定支付渠道的每日全平台限额(0 = 不限制)。
|
||||
* 优先级:环境变量显式配置 > 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; // 明确配置(含 0)
|
||||
|
||||
// 尝试从已注册的 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);
|
||||
return Number.isFinite(num) && num >= 0 ? num : 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 {
|
||||
/** 每日限额,0 = 不限 */
|
||||
dailyLimit: number;
|
||||
/** 今日已使用金额 */
|
||||
used: number;
|
||||
/** 剩余每日额度,null = 不限 */
|
||||
remaining: number | null;
|
||||
/** 是否还可使用(false = 今日额度已满) */
|
||||
available: boolean;
|
||||
/** 单笔限额,0 = 使用全局配置 MAX_RECHARGE_AMOUNT */
|
||||
singleMax: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量查询多个支付渠道的今日使用情况。
|
||||
* 一次 DB groupBy 完成,调用方按需传入渠道列表。
|
||||
*/
|
||||
export async function queryMethodLimits(
|
||||
paymentTypes: string[],
|
||||
): Promise<Record<string, MethodLimitStatus>> {
|
||||
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<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] = {
|
||||
dailyLimit,
|
||||
used,
|
||||
remaining,
|
||||
available: dailyLimit === 0 || used < dailyLimit,
|
||||
singleMax,
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -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<CreateOrderR
|
||||
}
|
||||
}
|
||||
|
||||
// 渠道每日全平台限额校验(0 = 不限)
|
||||
const methodDailyLimit = getMethodDailyLimit(input.paymentType);
|
||||
if (methodDailyLimit > 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: {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user