mirror of
https://github.com/touwaeriol/sub2apipay.git
synced 2026-08-29 02:26:50 +08:00
feat: 充值配置新增订单取消频率限制和最大支付中订单数
- 新增订单取消频率限制:开关 + 窗口值 + 周期单位(分钟/小时/天) + 最大次数 - 新增最大可存在支付中订单数配置,替代原硬编码的 MAX_PENDING_ORDERS=3 - 后端 cancelOrder 增加频率限制检查(基于 audit_logs 统计) - 前端 pay 页面动态读取 maxPendingOrders 配置
This commit is contained in:
+173
-22
@@ -10,7 +10,7 @@ import { PlatformBadge, getPlatformStyle } from '@/lib/platform-style';
|
||||
|
||||
interface Channel {
|
||||
id: string;
|
||||
groupId: number;
|
||||
groupId: number | null;
|
||||
name: string;
|
||||
platform: string;
|
||||
rateMultiplier: number;
|
||||
@@ -112,6 +112,16 @@ function getTexts(locale: Locale) {
|
||||
savingConfig: 'Saving...',
|
||||
configSaved: 'Configuration saved',
|
||||
configSaveFailed: 'Failed to save configuration',
|
||||
cancelRateLimit: 'Order Cancel Rate Limit',
|
||||
cancelRateLimitWindow: 'Window',
|
||||
cancelRateLimitUnit: 'Unit',
|
||||
cancelRateLimitMax: 'Max Cancellations',
|
||||
cancelRateLimitUnitMinute: 'Minutes',
|
||||
cancelRateLimitUnitHour: 'Hours',
|
||||
cancelRateLimitUnitDay: 'Days',
|
||||
maxPendingOrders: 'Max Pending Orders',
|
||||
cancelRateLimitHint: (w: string, u: string, m: string) =>
|
||||
`Within ${w} ${u === 'minute' ? 'minute(s)' : u === 'day' ? 'day(s)' : 'hour(s)'}, max ${m} cancellation(s)`,
|
||||
}
|
||||
: {
|
||||
missingToken: '缺少管理员凭证',
|
||||
@@ -174,6 +184,16 @@ function getTexts(locale: Locale) {
|
||||
savingConfig: '保存中...',
|
||||
configSaved: '配置已保存',
|
||||
configSaveFailed: '保存配置失败',
|
||||
cancelRateLimit: '订单取消频率限制',
|
||||
cancelRateLimitWindow: '窗口',
|
||||
cancelRateLimitUnit: '周期单位',
|
||||
cancelRateLimitMax: '最大取消次数',
|
||||
cancelRateLimitUnitMinute: '分钟',
|
||||
cancelRateLimitUnitHour: '小时',
|
||||
cancelRateLimitUnitDay: '天',
|
||||
maxPendingOrders: '最多可存在支付中订单',
|
||||
cancelRateLimitHint: (w: string, u: string, m: string) =>
|
||||
`${w} ${u === 'minute' ? '分钟' : u === 'day' ? '天' : '小时'}内最多可取消 ${m} 次`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -239,6 +259,11 @@ function ChannelsContent() {
|
||||
const [rcPrefix, setRcPrefix] = useState('');
|
||||
const [rcSuffix, setRcSuffix] = useState('');
|
||||
const [rcBalanceEnabled, setRcBalanceEnabled] = useState(true);
|
||||
const [rcCancelRateLimitEnabled, setRcCancelRateLimitEnabled] = useState(false);
|
||||
const [rcCancelRateLimitWindow, setRcCancelRateLimitWindow] = useState('1');
|
||||
const [rcCancelRateLimitUnit, setRcCancelRateLimitUnit] = useState('hour');
|
||||
const [rcCancelRateLimitMax, setRcCancelRateLimitMax] = useState('3');
|
||||
const [rcMaxPendingOrders, setRcMaxPendingOrders] = useState('3');
|
||||
const [rcSaving, setRcSaving] = useState(false);
|
||||
|
||||
// Sync modal state
|
||||
@@ -283,6 +308,11 @@ function ChannelsContent() {
|
||||
if (c.key === 'PRODUCT_NAME_PREFIX') setRcPrefix(c.value);
|
||||
if (c.key === 'PRODUCT_NAME_SUFFIX') setRcSuffix(c.value);
|
||||
if (c.key === 'BALANCE_PAYMENT_DISABLED') setRcBalanceEnabled(c.value !== 'true');
|
||||
if (c.key === 'CANCEL_RATE_LIMIT_ENABLED') setRcCancelRateLimitEnabled(c.value === 'true');
|
||||
if (c.key === 'CANCEL_RATE_LIMIT_WINDOW') setRcCancelRateLimitWindow(c.value || '1');
|
||||
if (c.key === 'CANCEL_RATE_LIMIT_UNIT') setRcCancelRateLimitUnit(c.value || 'hour');
|
||||
if (c.key === 'CANCEL_RATE_LIMIT_MAX') setRcCancelRateLimitMax(c.value || '3');
|
||||
if (c.key === 'MAX_PENDING_ORDERS') setRcMaxPendingOrders(c.value || '3');
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -310,6 +340,36 @@ function ChannelsContent() {
|
||||
group: 'payment',
|
||||
label: '余额充值禁用',
|
||||
},
|
||||
{
|
||||
key: 'CANCEL_RATE_LIMIT_ENABLED',
|
||||
value: rcCancelRateLimitEnabled ? 'true' : 'false',
|
||||
group: 'payment',
|
||||
label: '订单取消频率限制',
|
||||
},
|
||||
{
|
||||
key: 'CANCEL_RATE_LIMIT_WINDOW',
|
||||
value: rcCancelRateLimitWindow,
|
||||
group: 'payment',
|
||||
label: '频率限制窗口',
|
||||
},
|
||||
{
|
||||
key: 'CANCEL_RATE_LIMIT_UNIT',
|
||||
value: rcCancelRateLimitUnit,
|
||||
group: 'payment',
|
||||
label: '频率限制周期单位',
|
||||
},
|
||||
{
|
||||
key: 'CANCEL_RATE_LIMIT_MAX',
|
||||
value: rcCancelRateLimitMax,
|
||||
group: 'payment',
|
||||
label: '频率限制最大次数',
|
||||
},
|
||||
{
|
||||
key: 'MAX_PENDING_ORDERS',
|
||||
value: rcMaxPendingOrders,
|
||||
group: 'payment',
|
||||
label: '最多可存在支付中订单',
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
@@ -352,7 +412,7 @@ function ChannelsContent() {
|
||||
const openEditModal = (channel: Channel) => {
|
||||
setEditingChannel(channel);
|
||||
setForm({
|
||||
group_id: channel.groupId,
|
||||
group_id: channel.groupId ?? '',
|
||||
name: channel.name,
|
||||
platform: channel.platform,
|
||||
rate_multiplier: String(channel.rateMultiplier),
|
||||
@@ -371,12 +431,11 @@ function ChannelsContent() {
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.name.trim() || form.group_id === '' || !form.rate_multiplier) return;
|
||||
if (!form.name.trim() || !form.rate_multiplier) return;
|
||||
setSaving(true);
|
||||
setError('');
|
||||
|
||||
const body = {
|
||||
group_id: Number(form.group_id),
|
||||
const body: Record<string, unknown> = {
|
||||
name: form.name.trim(),
|
||||
platform: form.platform,
|
||||
rate_multiplier: parseFloat(form.rate_multiplier),
|
||||
@@ -386,6 +445,11 @@ function ChannelsContent() {
|
||||
sort_order: parseInt(form.sort_order, 10) || 0,
|
||||
enabled: form.enabled,
|
||||
};
|
||||
if (form.group_id !== '') {
|
||||
body.group_id = Number(form.group_id);
|
||||
} else {
|
||||
body.group_id = null;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = editingChannel ? `/api/admin/channels/${editingChannel.id}` : '/api/admin/channels';
|
||||
@@ -480,7 +544,7 @@ function ChannelsContent() {
|
||||
setSyncSelected(new Set());
|
||||
};
|
||||
|
||||
const existingGroupIds = new Set(channels.map((c) => c.groupId));
|
||||
const existingGroupIds = new Set(channels.map((c) => c.groupId).filter((id): id is number => id !== null));
|
||||
|
||||
const toggleSyncGroup = (id: number) => {
|
||||
setSyncSelected((prev) => {
|
||||
@@ -671,6 +735,89 @@ function ChannelsContent() {
|
||||
{t.enableBalanceRecharge}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 最多可存在支付中订单 */}
|
||||
<div className="mt-3">
|
||||
<label className={labelCls}>{t.maxPendingOrders}</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="99"
|
||||
value={rcMaxPendingOrders}
|
||||
onChange={(e) => setRcMaxPendingOrders(e.target.value)}
|
||||
className={[inputCls, 'w-24'].join(' ')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 订单取消频率限制 */}
|
||||
<div className="mt-3">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRcCancelRateLimitEnabled(!rcCancelRateLimitEnabled)}
|
||||
className={[
|
||||
'relative inline-flex h-5 w-9 items-center rounded-full transition-colors',
|
||||
rcCancelRateLimitEnabled ? 'bg-emerald-500' : isDark ? 'bg-slate-600' : 'bg-slate-300',
|
||||
].join(' ')}
|
||||
>
|
||||
<span
|
||||
className={[
|
||||
'inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform',
|
||||
rcCancelRateLimitEnabled ? 'translate-x-4.5' : 'translate-x-0.5',
|
||||
].join(' ')}
|
||||
/>
|
||||
</button>
|
||||
<span className={['text-sm', isDark ? 'text-slate-300' : 'text-slate-700'].join(' ')}>
|
||||
{t.cancelRateLimit}
|
||||
</span>
|
||||
</div>
|
||||
{rcCancelRateLimitEnabled && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className={labelCls}>{t.cancelRateLimitWindow}</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="999"
|
||||
value={rcCancelRateLimitWindow}
|
||||
onChange={(e) => setRcCancelRateLimitWindow(e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>{t.cancelRateLimitUnit}</label>
|
||||
<select
|
||||
value={rcCancelRateLimitUnit}
|
||||
onChange={(e) => setRcCancelRateLimitUnit(e.target.value)}
|
||||
className={inputCls}
|
||||
>
|
||||
<option value="minute">{t.cancelRateLimitUnitMinute}</option>
|
||||
<option value="hour">{t.cancelRateLimitUnitHour}</option>
|
||||
<option value="day">{t.cancelRateLimitUnitDay}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>{t.cancelRateLimitMax}</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="999"
|
||||
value={rcCancelRateLimitMax}
|
||||
onChange={(e) => setRcCancelRateLimitMax(e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className={['mt-1 text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||
{t.cancelRateLimitHint(rcCancelRateLimitWindow, rcCancelRateLimitUnit, rcCancelRateLimitMax)}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={saveRechargeConfig}
|
||||
@@ -726,7 +873,7 @@ function ChannelsContent() {
|
||||
<td className={`px-4 py-3 font-medium ${isDark ? 'text-slate-100' : 'text-slate-900'}`}>
|
||||
<div>{channel.name}</div>
|
||||
<div className={`text-xs ${isDark ? 'text-slate-500' : 'text-slate-400'}`}>
|
||||
Group #{channel.groupId}
|
||||
{channel.groupId !== null ? `Group #${channel.groupId}` : '—'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
@@ -736,7 +883,13 @@ function ChannelsContent() {
|
||||
{channel.rateMultiplier}x
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{channel.groupExists ? (
|
||||
{channel.groupId === null ? (
|
||||
<span
|
||||
className={`inline-flex h-5 w-5 items-center justify-center rounded-full ${isDark ? 'bg-slate-700 text-slate-500' : 'bg-slate-100 text-slate-400'}`}
|
||||
>
|
||||
—
|
||||
</span>
|
||||
) : channel.groupExists ? (
|
||||
<span
|
||||
className={`inline-flex h-5 w-5 items-center justify-center rounded-full ${isDark ? 'bg-emerald-900/40 text-emerald-400' : 'bg-emerald-100 text-emerald-600'}`}
|
||||
>
|
||||
@@ -833,19 +986,17 @@ function ChannelsContent() {
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Group ID (only for create) */}
|
||||
{!editingChannel && (
|
||||
<div>
|
||||
<label className={labelCls}>{t.fieldGroupId}</label>
|
||||
<input
|
||||
type="number"
|
||||
value={form.group_id}
|
||||
onChange={(e) => setForm({ ...form, group_id: e.target.value ? Number(e.target.value) : '' })}
|
||||
className={inputCls}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Group ID (optional) */}
|
||||
<div>
|
||||
<label className={labelCls}>{t.fieldGroupId}</label>
|
||||
<input
|
||||
type="number"
|
||||
value={form.group_id}
|
||||
onChange={(e) => setForm({ ...form, group_id: e.target.value ? Number(e.target.value) : '' })}
|
||||
className={inputCls}
|
||||
placeholder={locale === 'en' ? 'Optional' : '可选'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<div>
|
||||
@@ -972,7 +1123,7 @@ function ChannelsContent() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving || !form.name.trim() || form.group_id === '' || !form.rate_multiplier}
|
||||
disabled={saving || !form.name.trim() || !form.rate_multiplier}
|
||||
className="rounded-lg bg-emerald-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-emerald-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{saving ? t.saving : t.save}
|
||||
|
||||
@@ -50,6 +50,11 @@ export async function PUT(request: NextRequest) {
|
||||
'PRODUCT_NAME_PREFIX',
|
||||
'PRODUCT_NAME_SUFFIX',
|
||||
'BALANCE_PAYMENT_DISABLED',
|
||||
'CANCEL_RATE_LIMIT_ENABLED',
|
||||
'CANCEL_RATE_LIMIT_WINDOW',
|
||||
'CANCEL_RATE_LIMIT_UNIT',
|
||||
'CANCEL_RATE_LIMIT_MAX',
|
||||
'MAX_PENDING_ORDERS',
|
||||
]);
|
||||
|
||||
// 校验每条配置
|
||||
|
||||
@@ -47,13 +47,22 @@ export async function GET(request: NextRequest) {
|
||||
const configPromise = Promise.all([
|
||||
getSystemConfig('ENABLED_PAYMENT_TYPES'),
|
||||
getSystemConfig('BALANCE_PAYMENT_DISABLED'),
|
||||
]).then(async ([configuredPaymentTypesRaw, balanceDisabledVal]) => {
|
||||
getSystemConfig('MAX_PENDING_ORDERS'),
|
||||
]).then(async ([configuredPaymentTypesRaw, balanceDisabledVal, maxPendingVal]) => {
|
||||
const enabledTypes = resolveEnabledPaymentTypes(supportedTypes, configuredPaymentTypesRaw);
|
||||
const methodLimits = await queryMethodLimits(enabledTypes);
|
||||
return { enabledTypes, methodLimits, balanceDisabled: balanceDisabledVal === 'true' };
|
||||
return {
|
||||
enabledTypes,
|
||||
methodLimits,
|
||||
balanceDisabled: balanceDisabledVal === 'true',
|
||||
maxPendingOrders: maxPendingVal ? parseInt(maxPendingVal, 10) || 3 : 3,
|
||||
};
|
||||
});
|
||||
|
||||
const [user, { enabledTypes, methodLimits, balanceDisabled }] = await Promise.all([getUser(userId), configPromise]);
|
||||
const [user, { enabledTypes, methodLimits, balanceDisabled, maxPendingOrders }] = await Promise.all([
|
||||
getUser(userId),
|
||||
configPromise,
|
||||
]);
|
||||
|
||||
// 收集 sublabel 覆盖
|
||||
const sublabelOverrides: Record<string, string> = {};
|
||||
@@ -98,6 +107,7 @@ export async function GET(request: NextRequest) {
|
||||
stripePublishableKey:
|
||||
enabledTypes.includes('stripe') && env.STRIPE_PUBLISHABLE_KEY ? env.STRIPE_PUBLISHABLE_KEY : null,
|
||||
balanceDisabled,
|
||||
maxPendingOrders,
|
||||
sublabelOverrides: Object.keys(sublabelOverrides).length > 0 ? sublabelOverrides : null,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -44,6 +44,7 @@ interface AppConfig {
|
||||
helpText?: string | null;
|
||||
stripePublishableKey?: string | null;
|
||||
balanceDisabled?: boolean;
|
||||
maxPendingOrders?: number;
|
||||
}
|
||||
|
||||
function PayContent() {
|
||||
@@ -132,7 +133,7 @@ function PayContent() {
|
||||
);
|
||||
};
|
||||
|
||||
const MAX_PENDING = 3;
|
||||
const MAX_PENDING = config.maxPendingOrders ?? 3;
|
||||
const pendingBlocked = pendingCount >= MAX_PENDING;
|
||||
|
||||
// R6: 余额充值是否被禁用
|
||||
@@ -213,6 +214,7 @@ function PayContent() {
|
||||
helpText: cfgData.config.helpText ?? null,
|
||||
stripePublishableKey: cfgData.config.stripePublishableKey ?? null,
|
||||
balanceDisabled: cfgData.config.balanceDisabled ?? false,
|
||||
maxPendingOrders: cfgData.config.maxPendingOrders ?? 3,
|
||||
});
|
||||
if (cfgData.config.sublabelOverrides) {
|
||||
applySublabelOverrides(cfgData.config.sublabelOverrides);
|
||||
|
||||
@@ -22,7 +22,7 @@ import { getBizDayStartUTC } from '@/lib/time/biz-day';
|
||||
import { buildOrderResultUrl, createOrderStatusAccessToken } from '@/lib/order/status-access';
|
||||
import { getSystemConfig, getSystemConfigs } from '@/lib/system-config';
|
||||
|
||||
const MAX_PENDING_ORDERS = 3;
|
||||
const DEFAULT_MAX_PENDING_ORDERS = 3;
|
||||
/** Decimal(10,2) 允许的最大金额 */
|
||||
export const MAX_AMOUNT = 99999999.99;
|
||||
|
||||
@@ -148,19 +148,25 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
|
||||
|
||||
const expiresAt = new Date(Date.now() + env.ORDER_TIMEOUT_MINUTES * 60 * 1000);
|
||||
|
||||
// 读取最大支付中订单数配置
|
||||
const maxPendingConfig = await getSystemConfig('MAX_PENDING_ORDERS');
|
||||
const maxPendingOrders = maxPendingConfig
|
||||
? parseInt(maxPendingConfig, 10) || DEFAULT_MAX_PENDING_ORDERS
|
||||
: DEFAULT_MAX_PENDING_ORDERS;
|
||||
|
||||
// 将限额校验与订单创建放在同一个 serializable 事务中,防止并发突破限额
|
||||
const order = await prisma.$transaction(async (tx) => {
|
||||
// 待支付订单数限制
|
||||
const pendingCount = await tx.order.count({
|
||||
where: { userId: input.userId, status: ORDER_STATUS.PENDING },
|
||||
});
|
||||
if (pendingCount >= MAX_PENDING_ORDERS) {
|
||||
if (pendingCount >= maxPendingOrders) {
|
||||
throw new OrderError(
|
||||
'TOO_MANY_PENDING',
|
||||
message(
|
||||
locale,
|
||||
`待支付订单过多(最多 ${MAX_PENDING_ORDERS} 笔)`,
|
||||
`Too many pending orders (${MAX_PENDING_ORDERS})`,
|
||||
`待支付订单过多(最多 ${maxPendingOrders} 笔)`,
|
||||
`Too many pending orders (${maxPendingOrders})`,
|
||||
),
|
||||
429,
|
||||
);
|
||||
@@ -453,6 +459,53 @@ export async function cancelOrder(orderId: string, userId: number, locale: Local
|
||||
if (order.status !== ORDER_STATUS.PENDING)
|
||||
throw new OrderError('INVALID_STATUS', message(locale, '订单当前状态不可取消', 'Order cannot be cancelled'), 400);
|
||||
|
||||
// 取消频率限制检查
|
||||
const rateLimitConfigs = await getSystemConfigs([
|
||||
'CANCEL_RATE_LIMIT_ENABLED',
|
||||
'CANCEL_RATE_LIMIT_WINDOW',
|
||||
'CANCEL_RATE_LIMIT_UNIT',
|
||||
'CANCEL_RATE_LIMIT_MAX',
|
||||
]);
|
||||
if (rateLimitConfigs['CANCEL_RATE_LIMIT_ENABLED'] === 'true') {
|
||||
const window = parseInt(rateLimitConfigs['CANCEL_RATE_LIMIT_WINDOW'] || '0', 10);
|
||||
const maxCount = parseInt(rateLimitConfigs['CANCEL_RATE_LIMIT_MAX'] || '0', 10);
|
||||
const unit = rateLimitConfigs['CANCEL_RATE_LIMIT_UNIT'] || 'hour';
|
||||
if (window > 0 && maxCount > 0) {
|
||||
const unitMs = unit === 'minute' ? 60_000 : unit === 'day' ? 86_400_000 : 3_600_000;
|
||||
const windowStart = new Date(Date.now() - window * unitMs);
|
||||
const recentCancelCount = await prisma.auditLog.count({
|
||||
where: {
|
||||
action: 'ORDER_CANCELLED',
|
||||
operator: `user:${userId}`,
|
||||
createdAt: { gte: windowStart },
|
||||
},
|
||||
});
|
||||
if (recentCancelCount >= maxCount) {
|
||||
const unitLabel =
|
||||
locale === 'en'
|
||||
? unit === 'minute'
|
||||
? 'minute(s)'
|
||||
: unit === 'day'
|
||||
? 'day(s)'
|
||||
: 'hour(s)'
|
||||
: unit === 'minute'
|
||||
? '分钟'
|
||||
: unit === 'day'
|
||||
? '天'
|
||||
: '小时';
|
||||
throw new OrderError(
|
||||
'CANCEL_RATE_LIMITED',
|
||||
message(
|
||||
locale,
|
||||
`取消订单过于频繁,${window} ${unitLabel}内最多可取消 ${maxCount} 次`,
|
||||
`Too many cancellations. Maximum ${maxCount} cancellations per ${window} ${unitLabel}`,
|
||||
),
|
||||
429,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cancelOrderCore({
|
||||
orderId: order.id,
|
||||
paymentTradeNo: order.paymentTradeNo,
|
||||
|
||||
Reference in New Issue
Block a user