feat: 金额上限校验、订阅详情展示优化、支付商品名称区分
- 硬编码 MAX_AMOUNT=99999999.99,所有金额输入(API+前端)统一校验上限
- 管理后台订阅列表改为卡片布局,Sub2API 分组信息嵌套只读展示(平台/倍率/限额/模型)
- 用户端套餐卡片和确认页展示平台、倍率、用量限制
- 订阅订单支付商品名改为 "Sub2API 订阅 {分组名}",余额充值保持原格式
This commit is contained in:
@@ -21,6 +21,12 @@ interface SubscriptionPlan {
|
|||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
groupExists: boolean;
|
groupExists: boolean;
|
||||||
|
groupPlatform: string | null;
|
||||||
|
groupRateMultiplier: number | null;
|
||||||
|
groupDailyLimit: number | null;
|
||||||
|
groupWeeklyLimit: number | null;
|
||||||
|
groupMonthlyLimit: number | null;
|
||||||
|
groupModelScopes: string[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Sub2ApiGroup {
|
interface Sub2ApiGroup {
|
||||||
@@ -129,6 +135,14 @@ function buildText(locale: Locale) {
|
|||||||
unlimited: 'Unlimited',
|
unlimited: 'Unlimited',
|
||||||
resetIn: 'Reset in',
|
resetIn: 'Reset in',
|
||||||
noGroup: 'Unknown Group',
|
noGroup: 'Unknown Group',
|
||||||
|
groupInfo: 'Sub2API Group Info',
|
||||||
|
groupInfoReadonly: '(read-only, from Sub2API)',
|
||||||
|
platform: 'Platform',
|
||||||
|
rateMultiplier: 'Rate',
|
||||||
|
dailyLimit: 'Daily Limit',
|
||||||
|
weeklyLimit: 'Weekly Limit',
|
||||||
|
monthlyLimit: 'Monthly Limit',
|
||||||
|
modelScopes: 'Models',
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
missingToken: '缺少管理员凭证',
|
missingToken: '缺少管理员凭证',
|
||||||
@@ -201,6 +215,14 @@ function buildText(locale: Locale) {
|
|||||||
unlimited: '无限制',
|
unlimited: '无限制',
|
||||||
resetIn: '重置于',
|
resetIn: '重置于',
|
||||||
noGroup: '未知分组',
|
noGroup: '未知分组',
|
||||||
|
groupInfo: 'Sub2API 分组信息',
|
||||||
|
groupInfoReadonly: '(只读,来自 Sub2API)',
|
||||||
|
platform: '平台',
|
||||||
|
rateMultiplier: '倍率',
|
||||||
|
dailyLimit: '日限额',
|
||||||
|
weeklyLimit: '周限额',
|
||||||
|
monthlyLimit: '月限额',
|
||||||
|
modelScopes: '模型',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -679,115 +701,182 @@ function SubscriptionsContent() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Plans table */}
|
{/* Plans cards */}
|
||||||
<div className={tableWrapCls}>
|
{plansLoading ? (
|
||||||
{plansLoading ? (
|
<div className={`py-12 text-center ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{t.loading}</div>
|
||||||
<div className={`py-12 text-center ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{t.loading}</div>
|
) : plans.length === 0 ? (
|
||||||
) : plans.length === 0 ? (
|
<div className={`py-12 text-center ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{t.noPlans}</div>
|
||||||
<div className={`py-12 text-center ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{t.noPlans}</div>
|
) : (
|
||||||
) : (
|
<div className="space-y-4">
|
||||||
<table className="w-full">
|
{plans.map((plan) => (
|
||||||
<thead>
|
<div
|
||||||
<tr className={`border-b ${rowBorderCls}`}>
|
key={plan.id}
|
||||||
<th className={thCls}>{t.colName}</th>
|
className={[
|
||||||
<th className={thCls}>{t.colGroup}</th>
|
'rounded-xl border overflow-hidden',
|
||||||
<th className={thCls}>{t.colPrice}</th>
|
isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-white shadow-sm',
|
||||||
<th className={thCls}>{t.colOriginalPrice}</th>
|
].join(' ')}
|
||||||
<th className={thCls}>{t.colValidDays}</th>
|
>
|
||||||
<th className={thCls}>{t.colEnabled}</th>
|
{/* ── 套餐配置(上半部分) ── */}
|
||||||
<th className={thCls}>{t.colGroupStatus}</th>
|
<div className="p-4">
|
||||||
<th className={thCls}>{t.colActions}</th>
|
<div className="flex items-center justify-between mb-3">
|
||||||
</tr>
|
<div className="flex items-center gap-2">
|
||||||
</thead>
|
<h3 className={['text-base font-semibold', isDark ? 'text-slate-100' : 'text-slate-900'].join(' ')}>
|
||||||
<tbody>
|
{plan.name}
|
||||||
{plans.map((plan) => (
|
</h3>
|
||||||
<tr key={plan.id} className={`border-b ${rowBorderCls} last:border-b-0`}>
|
|
||||||
<td className={tdCls}>{plan.name}</td>
|
|
||||||
<td className={tdCls}>
|
|
||||||
<span className="font-mono text-xs">{plan.groupId}</span>
|
|
||||||
{plan.groupName && (
|
|
||||||
<span className={`ml-1 text-xs ${isDark ? 'text-slate-500' : 'text-slate-400'}`}>
|
|
||||||
({plan.groupName})
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className={tdCls}>{plan.price.toFixed(2)}</td>
|
|
||||||
<td className={tdCls}>
|
|
||||||
{plan.originalPrice != null ? plan.originalPrice.toFixed(2) : '-'}
|
|
||||||
</td>
|
|
||||||
<td className={tdCls}>
|
|
||||||
{plan.validDays}{' '}
|
|
||||||
{plan.validityUnit === 'month'
|
|
||||||
? t.unitMonth
|
|
||||||
: plan.validityUnit === 'week'
|
|
||||||
? t.unitWeek
|
|
||||||
: t.unitDay}
|
|
||||||
</td>
|
|
||||||
<td className={tdCls}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleToggleEnabled(plan)}
|
|
||||||
className={[
|
|
||||||
'relative inline-flex h-5 w-9 items-center rounded-full transition-colors',
|
|
||||||
plan.enabled ? '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',
|
|
||||||
plan.enabled ? 'translate-x-4.5' : 'translate-x-0.5',
|
|
||||||
].join(' ')}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
<td className={tdCls}>
|
|
||||||
<span
|
<span
|
||||||
className={[
|
className={[
|
||||||
'inline-block rounded-full px-2 py-0.5 text-xs font-medium',
|
'inline-block rounded-full px-2 py-0.5 text-xs font-medium',
|
||||||
plan.groupExists
|
plan.groupExists
|
||||||
? isDark
|
? isDark ? 'bg-green-500/20 text-green-300' : 'bg-green-50 text-green-700'
|
||||||
? 'bg-green-500/20 text-green-300'
|
: isDark ? 'bg-red-500/20 text-red-300' : 'bg-red-50 text-red-600',
|
||||||
: 'bg-green-50 text-green-700'
|
|
||||||
: isDark
|
|
||||||
? 'bg-red-500/20 text-red-300'
|
|
||||||
: 'bg-red-50 text-red-600',
|
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
{plan.groupExists ? t.groupExists : t.groupMissing}
|
{plan.groupExists ? t.groupExists : t.groupMissing}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</div>
|
||||||
<td className={tdCls}>
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex gap-2">
|
{/* Toggle */}
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className={['text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||||
|
{t.colEnabled}
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => openEdit(plan)}
|
onClick={() => handleToggleEnabled(plan)}
|
||||||
className={[
|
className={[
|
||||||
'rounded px-2 py-1 text-xs font-medium transition-colors',
|
'relative inline-flex h-5 w-9 items-center rounded-full transition-colors',
|
||||||
isDark
|
plan.enabled ? 'bg-emerald-500' : isDark ? 'bg-slate-600' : 'bg-slate-300',
|
||||||
? 'text-indigo-300 hover:bg-indigo-500/20'
|
|
||||||
: 'text-blue-600 hover:bg-blue-50',
|
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
{t.edit}
|
<span
|
||||||
</button>
|
className={[
|
||||||
<button
|
'inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform',
|
||||||
type="button"
|
plan.enabled ? 'translate-x-4.5' : 'translate-x-0.5',
|
||||||
onClick={() => handleDelete(plan)}
|
].join(' ')}
|
||||||
className={[
|
/>
|
||||||
'rounded px-2 py-1 text-xs font-medium transition-colors',
|
|
||||||
isDark ? 'text-red-400 hover:bg-red-500/20' : 'text-red-600 hover:bg-red-50',
|
|
||||||
].join(' ')}
|
|
||||||
>
|
|
||||||
{t.delete}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
{/* Actions */}
|
||||||
</tr>
|
<button
|
||||||
))}
|
type="button"
|
||||||
</tbody>
|
onClick={() => openEdit(plan)}
|
||||||
</table>
|
className={[
|
||||||
)}
|
'rounded px-2 py-1 text-xs font-medium transition-colors',
|
||||||
</div>
|
isDark ? 'text-indigo-300 hover:bg-indigo-500/20' : 'text-blue-600 hover:bg-blue-50',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{t.edit}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDelete(plan)}
|
||||||
|
className={[
|
||||||
|
'rounded px-2 py-1 text-xs font-medium transition-colors',
|
||||||
|
isDark ? 'text-red-400 hover:bg-red-500/20' : 'text-red-600 hover:bg-red-50',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{t.delete}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Plan fields grid */}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-x-4 gap-y-2 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className={['text-xs', isDark ? 'text-slate-500' : 'text-slate-400'].join(' ')}>{t.colGroup}</span>
|
||||||
|
<div className={isDark ? 'text-slate-200' : 'text-slate-800'}>
|
||||||
|
<span className="font-mono text-xs">{plan.groupId}</span>
|
||||||
|
{plan.groupName && <span className={`ml-1 text-xs ${isDark ? 'text-slate-400' : 'text-slate-500'}`}>({plan.groupName})</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className={['text-xs', isDark ? 'text-slate-500' : 'text-slate-400'].join(' ')}>{t.colPrice}</span>
|
||||||
|
<div className={isDark ? 'text-slate-200' : 'text-slate-800'}>
|
||||||
|
¥{plan.price.toFixed(2)}
|
||||||
|
{plan.originalPrice != null && (
|
||||||
|
<span className={`ml-1 line-through text-xs ${isDark ? 'text-slate-500' : 'text-slate-400'}`}>
|
||||||
|
¥{plan.originalPrice.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className={['text-xs', isDark ? 'text-slate-500' : 'text-slate-400'].join(' ')}>{t.colValidDays}</span>
|
||||||
|
<div className={isDark ? 'text-slate-200' : 'text-slate-800'}>
|
||||||
|
{plan.validDays} {plan.validityUnit === 'month' ? t.unitMonth : plan.validityUnit === 'week' ? t.unitWeek : t.unitDay}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className={['text-xs', isDark ? 'text-slate-500' : 'text-slate-400'].join(' ')}>{t.fieldSortOrder}</span>
|
||||||
|
<div className={isDark ? 'text-slate-200' : 'text-slate-800'}>{plan.sortOrder}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Sub2API 分组信息(嵌套只读区域) ── */}
|
||||||
|
{plan.groupExists && (
|
||||||
|
<div className={['border-t px-4 py-3', isDark ? 'border-slate-700 bg-slate-900/40' : 'border-slate-100 bg-slate-50/80'].join(' ')}>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className={['text-xs font-medium', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||||
|
{t.groupInfo}
|
||||||
|
</span>
|
||||||
|
<span className={['text-[10px]', isDark ? 'text-slate-600' : 'text-slate-400'].join(' ')}>
|
||||||
|
{t.groupInfoReadonly}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-x-4 gap-y-2 text-xs">
|
||||||
|
{plan.groupPlatform && (
|
||||||
|
<div>
|
||||||
|
<span className={isDark ? 'text-slate-500' : 'text-slate-400'}>{t.platform}</span>
|
||||||
|
<div className={isDark ? 'text-slate-300' : 'text-slate-600'}>{plan.groupPlatform}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{plan.groupRateMultiplier != null && (
|
||||||
|
<div>
|
||||||
|
<span className={isDark ? 'text-slate-500' : 'text-slate-400'}>{t.rateMultiplier}</span>
|
||||||
|
<div className={isDark ? 'text-slate-300' : 'text-slate-600'}>{plan.groupRateMultiplier}x</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<span className={isDark ? 'text-slate-500' : 'text-slate-400'}>{t.dailyLimit}</span>
|
||||||
|
<div className={isDark ? 'text-slate-300' : 'text-slate-600'}>
|
||||||
|
{plan.groupDailyLimit != null ? `$${plan.groupDailyLimit}` : t.unlimited}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className={isDark ? 'text-slate-500' : 'text-slate-400'}>{t.weeklyLimit}</span>
|
||||||
|
<div className={isDark ? 'text-slate-300' : 'text-slate-600'}>
|
||||||
|
{plan.groupWeeklyLimit != null ? `$${plan.groupWeeklyLimit}` : t.unlimited}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className={isDark ? 'text-slate-500' : 'text-slate-400'}>{t.monthlyLimit}</span>
|
||||||
|
<div className={isDark ? 'text-slate-300' : 'text-slate-600'}>
|
||||||
|
{plan.groupMonthlyLimit != null ? `$${plan.groupMonthlyLimit}` : t.unlimited}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{plan.groupModelScopes && plan.groupModelScopes.length > 0 && (
|
||||||
|
<div className="sm:col-span-3">
|
||||||
|
<span className={isDark ? 'text-slate-500' : 'text-slate-400'}>{t.modelScopes}</span>
|
||||||
|
<div className="flex flex-wrap gap-1 mt-0.5">
|
||||||
|
{plan.groupModelScopes.map((m) => (
|
||||||
|
<span
|
||||||
|
key={m}
|
||||||
|
className={['inline-block rounded px-1.5 py-0.5 text-[10px]', isDark ? 'bg-slate-700 text-slate-300' : 'bg-slate-200 text-slate-600'].join(' ')}
|
||||||
|
>
|
||||||
|
{m}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1035,7 +1124,8 @@ function SubscriptionsContent() {
|
|||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
min="0"
|
min="0.01"
|
||||||
|
max="99999999.99"
|
||||||
value={formPrice}
|
value={formPrice}
|
||||||
onChange={(e) => setFormPrice(e.target.value)}
|
onChange={(e) => setFormPrice(e.target.value)}
|
||||||
className={inputCls}
|
className={inputCls}
|
||||||
@@ -1047,7 +1137,8 @@ function SubscriptionsContent() {
|
|||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
min="0"
|
min="0.01"
|
||||||
|
max="99999999.99"
|
||||||
value={formOriginalPrice}
|
value={formOriginalPrice}
|
||||||
onChange={(e) => setFormOriginalPrice(e.target.value)}
|
onChange={(e) => setFormOriginalPrice(e.target.value)}
|
||||||
className={inputCls}
|
className={inputCls}
|
||||||
|
|||||||
@@ -27,8 +27,11 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (body.price !== undefined && (typeof body.price !== 'number' || body.price <= 0)) {
|
if (body.price !== undefined && (typeof body.price !== 'number' || body.price <= 0 || body.price > 99999999.99)) {
|
||||||
return NextResponse.json({ error: 'price 必须是正数' }, { status: 400 });
|
return NextResponse.json({ error: 'price 必须是 0.01 ~ 99999999.99 之间的数值' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (body.original_price !== undefined && body.original_price !== null && (typeof body.original_price !== 'number' || body.original_price <= 0 || body.original_price > 99999999.99)) {
|
||||||
|
return NextResponse.json({ error: 'original_price 必须是 0.01 ~ 99999999.99 之间的数值' }, { status: 400 });
|
||||||
}
|
}
|
||||||
if (body.validity_days !== undefined && (!Number.isInteger(body.validity_days) || body.validity_days <= 0)) {
|
if (body.validity_days !== undefined && (!Number.isInteger(body.validity_days) || body.validity_days <= 0)) {
|
||||||
return NextResponse.json({ error: 'validity_days 必须是正整数' }, { status: 400 });
|
return NextResponse.json({ error: 'validity_days 必须是正整数' }, { status: 400 });
|
||||||
|
|||||||
@@ -16,8 +16,9 @@ export async function GET(request: NextRequest) {
|
|||||||
plans.map(async (plan) => {
|
plans.map(async (plan) => {
|
||||||
let groupExists = false;
|
let groupExists = false;
|
||||||
let groupName: string | null = null;
|
let groupName: string | null = null;
|
||||||
|
let group: Awaited<ReturnType<typeof getGroup>> | null = null;
|
||||||
try {
|
try {
|
||||||
const group = await getGroup(plan.groupId);
|
group = await getGroup(plan.groupId);
|
||||||
groupExists = group !== null;
|
groupExists = group !== null;
|
||||||
groupName = group?.name ?? null;
|
groupName = group?.name ?? null;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -37,6 +38,12 @@ export async function GET(request: NextRequest) {
|
|||||||
sortOrder: plan.sortOrder,
|
sortOrder: plan.sortOrder,
|
||||||
enabled: plan.forSale,
|
enabled: plan.forSale,
|
||||||
groupExists,
|
groupExists,
|
||||||
|
groupPlatform: group?.platform ?? null,
|
||||||
|
groupRateMultiplier: group?.rate_multiplier ?? null,
|
||||||
|
groupDailyLimit: group?.daily_limit_usd ?? null,
|
||||||
|
groupWeeklyLimit: group?.weekly_limit_usd ?? null,
|
||||||
|
groupMonthlyLimit: group?.monthly_limit_usd ?? null,
|
||||||
|
groupModelScopes: group?.supported_model_scopes ?? null,
|
||||||
createdAt: plan.createdAt,
|
createdAt: plan.createdAt,
|
||||||
updatedAt: plan.updatedAt,
|
updatedAt: plan.updatedAt,
|
||||||
};
|
};
|
||||||
@@ -61,8 +68,11 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: '缺少必填字段: group_id, name, price' }, { status: 400 });
|
return NextResponse.json({ error: '缺少必填字段: group_id, name, price' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof price !== 'number' || price <= 0) {
|
if (typeof price !== 'number' || price <= 0 || price > 99999999.99) {
|
||||||
return NextResponse.json({ error: 'price 必须是正数' }, { status: 400 });
|
return NextResponse.json({ error: 'price 必须是 0.01 ~ 99999999.99 之间的数值' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (original_price !== undefined && original_price !== null && (typeof original_price !== 'number' || original_price <= 0 || original_price > 99999999.99)) {
|
||||||
|
return NextResponse.json({ error: 'original_price 必须是 0.01 ~ 99999999.99 之间的数值' }, { status: 400 });
|
||||||
}
|
}
|
||||||
if (validity_days !== undefined && (!Number.isInteger(validity_days) || validity_days <= 0)) {
|
if (validity_days !== undefined && (!Number.isInteger(validity_days) || validity_days <= 0)) {
|
||||||
return NextResponse.json({ error: 'validity_days 必须是正整数' }, { status: 400 });
|
return NextResponse.json({ error: 'validity_days 必须是正整数' }, { status: 400 });
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { handleApiError } from '@/lib/utils/api';
|
|||||||
|
|
||||||
const createOrderSchema = z.object({
|
const createOrderSchema = z.object({
|
||||||
token: z.string().min(1),
|
token: z.string().min(1),
|
||||||
amount: z.number().positive(),
|
amount: z.number().positive().max(99999999.99),
|
||||||
payment_type: z.string().min(1),
|
payment_type: z.string().min(1),
|
||||||
src_host: z.string().max(253).optional(),
|
src_host: z.string().max(253).optional(),
|
||||||
src_url: z.string().max(2048).optional(),
|
src_url: z.string().max(2048).optional(),
|
||||||
|
|||||||
@@ -24,9 +24,10 @@ export async function GET(request: NextRequest) {
|
|||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
plans.map(async (plan) => {
|
plans.map(async (plan) => {
|
||||||
let groupActive = false;
|
let groupActive = false;
|
||||||
|
let group: Awaited<ReturnType<typeof getGroup>> = null;
|
||||||
let groupInfo: { daily_limit_usd: number | null; weekly_limit_usd: number | null; monthly_limit_usd: number | null } | null = null;
|
let groupInfo: { daily_limit_usd: number | null; weekly_limit_usd: number | null; monthly_limit_usd: number | null } | null = null;
|
||||||
try {
|
try {
|
||||||
const group = await getGroup(plan.groupId);
|
group = await getGroup(plan.groupId);
|
||||||
groupActive = group !== null && group.status === 'active';
|
groupActive = group !== null && group.status === 'active';
|
||||||
if (group) {
|
if (group) {
|
||||||
groupInfo = {
|
groupInfo = {
|
||||||
@@ -44,6 +45,7 @@ export async function GET(request: NextRequest) {
|
|||||||
return {
|
return {
|
||||||
id: plan.id,
|
id: plan.id,
|
||||||
groupId: plan.groupId,
|
groupId: plan.groupId,
|
||||||
|
groupName: group?.name ?? null,
|
||||||
name: plan.name,
|
name: plan.name,
|
||||||
description: plan.description,
|
description: plan.description,
|
||||||
price: Number(plan.price),
|
price: Number(plan.price),
|
||||||
@@ -51,6 +53,8 @@ export async function GET(request: NextRequest) {
|
|||||||
validityDays: plan.validityDays,
|
validityDays: plan.validityDays,
|
||||||
validityUnit: plan.validityUnit,
|
validityUnit: plan.validityUnit,
|
||||||
features: plan.features ? JSON.parse(plan.features) : [],
|
features: plan.features ? JSON.parse(plan.features) : [],
|
||||||
|
platform: group?.platform ?? null,
|
||||||
|
rateMultiplier: group?.rate_multiplier ?? null,
|
||||||
limits: groupInfo,
|
limits: groupInfo,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -62,11 +62,11 @@ export default function SubscriptionConfirm({
|
|||||||
{/* Plan info card */}
|
{/* Plan info card */}
|
||||||
<div
|
<div
|
||||||
className={[
|
className={[
|
||||||
'rounded-xl border p-4',
|
'rounded-xl border p-4 space-y-3',
|
||||||
isDark ? 'border-slate-700 bg-slate-800/80' : 'border-slate-200 bg-slate-50',
|
isDark ? 'border-slate-700 bg-slate-800/80' : 'border-slate-200 bg-slate-50',
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
<div className="mb-2 flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className={['text-base font-semibold', isDark ? 'text-slate-100' : 'text-slate-900'].join(' ')}>
|
<span className={['text-base font-semibold', isDark ? 'text-slate-100' : 'text-slate-900'].join(' ')}>
|
||||||
{plan.name}
|
{plan.name}
|
||||||
@@ -81,6 +81,24 @@ export default function SubscriptionConfirm({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Platform & Rate tags */}
|
||||||
|
{(plan.platform || plan.rateMultiplier != null) && (
|
||||||
|
<div className={['flex flex-wrap gap-2 text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||||
|
{plan.platform && (
|
||||||
|
<span className={['inline-flex items-center rounded-md px-2 py-0.5', isDark ? 'bg-slate-700/60' : 'bg-slate-100'].join(' ')}>
|
||||||
|
{pickLocaleText(locale, '平台', 'Platform')}: {plan.platform}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{plan.rateMultiplier != null && (
|
||||||
|
<span className={['inline-flex items-center rounded-md px-2 py-0.5', isDark ? 'bg-slate-700/60' : 'bg-slate-100'].join(' ')}>
|
||||||
|
{pickLocaleText(locale, '倍率', 'Rate')}: {plan.rateMultiplier}x
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Features */}
|
||||||
{plan.features.length > 0 && (
|
{plan.features.length > 0 && (
|
||||||
<ul className="space-y-1">
|
<ul className="space-y-1">
|
||||||
{plan.features.map((feature) => (
|
{plan.features.map((feature) => (
|
||||||
@@ -93,6 +111,24 @@ export default function SubscriptionConfirm({
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Usage limits */}
|
||||||
|
{plan.limits && (plan.limits.daily_limit_usd != null || plan.limits.weekly_limit_usd != null || plan.limits.monthly_limit_usd != null) && (
|
||||||
|
<div className={['rounded-lg p-2.5 text-xs', isDark ? 'bg-slate-900/60 text-slate-400' : 'bg-white/80 text-slate-500'].join(' ')}>
|
||||||
|
<p className="mb-1 font-medium">{pickLocaleText(locale, '用量限制', 'Usage Limits')}</p>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{plan.limits.daily_limit_usd != null && (
|
||||||
|
<p>{pickLocaleText(locale, `每日: $${plan.limits.daily_limit_usd}`, `Daily: $${plan.limits.daily_limit_usd}`)}</p>
|
||||||
|
)}
|
||||||
|
{plan.limits.weekly_limit_usd != null && (
|
||||||
|
<p>{pickLocaleText(locale, `每周: $${plan.limits.weekly_limit_usd}`, `Weekly: $${plan.limits.weekly_limit_usd}`)}</p>
|
||||||
|
)}
|
||||||
|
{plan.limits.monthly_limit_usd != null && (
|
||||||
|
<p>{pickLocaleText(locale, `每月: $${plan.limits.monthly_limit_usd}`, `Monthly: $${plan.limits.monthly_limit_usd}`)}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Payment method selector */}
|
{/* Payment method selector */}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { formatValidityLabel, formatValiditySuffix, type ValidityUnit } from '@/
|
|||||||
export interface PlanInfo {
|
export interface PlanInfo {
|
||||||
id: string;
|
id: string;
|
||||||
groupId: number;
|
groupId: number;
|
||||||
|
groupName: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
price: number;
|
price: number;
|
||||||
originalPrice: number | null;
|
originalPrice: number | null;
|
||||||
@@ -15,6 +16,8 @@ export interface PlanInfo {
|
|||||||
validityUnit?: ValidityUnit;
|
validityUnit?: ValidityUnit;
|
||||||
features: string[];
|
features: string[];
|
||||||
description: string | null;
|
description: string | null;
|
||||||
|
platform: string | null;
|
||||||
|
rateMultiplier: number | null;
|
||||||
limits: {
|
limits: {
|
||||||
daily_limit_usd: number | null;
|
daily_limit_usd: number | null;
|
||||||
weekly_limit_usd: number | null;
|
weekly_limit_usd: number | null;
|
||||||
@@ -76,6 +79,22 @@ export default function SubscriptionPlanCard({ plan, onSubscribe, isDark, locale
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Platform & Rate */}
|
||||||
|
{(plan.platform || plan.rateMultiplier != null) && (
|
||||||
|
<div className={['mb-3 flex flex-wrap gap-2 text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||||
|
{plan.platform && (
|
||||||
|
<span className={['inline-flex items-center gap-1 rounded-md px-2 py-0.5', isDark ? 'bg-slate-700/60' : 'bg-slate-100'].join(' ')}>
|
||||||
|
{pickLocaleText(locale, '平台', 'Platform')}: {plan.platform}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{plan.rateMultiplier != null && (
|
||||||
|
<span className={['inline-flex items-center gap-1 rounded-md px-2 py-0.5', isDark ? 'bg-slate-700/60' : 'bg-slate-100'].join(' ')}>
|
||||||
|
{pickLocaleText(locale, '倍率', 'Rate')}: {plan.rateMultiplier}x
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Features */}
|
{/* Features */}
|
||||||
{plan.features.length > 0 && (
|
{plan.features.length > 0 && (
|
||||||
<ul className="mb-4 space-y-2">
|
<ul className="mb-4 space-y-2">
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import { getBizDayStartUTC } from '@/lib/time/biz-day';
|
|||||||
import { buildOrderResultUrl, createOrderStatusAccessToken } from '@/lib/order/status-access';
|
import { buildOrderResultUrl, createOrderStatusAccessToken } from '@/lib/order/status-access';
|
||||||
|
|
||||||
const MAX_PENDING_ORDERS = 3;
|
const MAX_PENDING_ORDERS = 3;
|
||||||
|
/** Decimal(10,2) 允许的最大金额 */
|
||||||
|
export const MAX_AMOUNT = 99999999.99;
|
||||||
|
|
||||||
function message(locale: Locale, zh: string, en: string): string {
|
function message(locale: Locale, zh: string, en: string): string {
|
||||||
return pickLocaleText(locale, zh, en);
|
return pickLocaleText(locale, zh, en);
|
||||||
@@ -58,6 +60,7 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
|
|||||||
|
|
||||||
// ── 订阅订单前置校验 ──
|
// ── 订阅订单前置校验 ──
|
||||||
let subscriptionPlan: { id: string; groupId: number; price: Prisma.Decimal; validityDays: number; validityUnit: string; name: string } | null = null;
|
let subscriptionPlan: { id: string; groupId: number; price: Prisma.Decimal; validityDays: number; validityUnit: string; name: string } | null = null;
|
||||||
|
let subscriptionGroupName = '';
|
||||||
if (orderType === 'subscription') {
|
if (orderType === 'subscription') {
|
||||||
if (!input.planId) {
|
if (!input.planId) {
|
||||||
throw new OrderError('INVALID_INPUT', message(locale, '订阅订单必须指定套餐', 'Subscription order requires a plan'), 400);
|
throw new OrderError('INVALID_INPUT', message(locale, '订阅订单必须指定套餐', 'Subscription order requires a plan'), 400);
|
||||||
@@ -75,6 +78,7 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
|
|||||||
410,
|
410,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
subscriptionGroupName = group?.name || plan.name;
|
||||||
subscriptionPlan = plan;
|
subscriptionPlan = plan;
|
||||||
// 订阅订单金额使用服务端套餐价格,不信任客户端
|
// 订阅订单金额使用服务端套餐价格,不信任客户端
|
||||||
input.amount = Number(plan.price);
|
input.amount = Number(plan.price);
|
||||||
@@ -216,7 +220,9 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
|
|||||||
orderId: order.id,
|
orderId: order.id,
|
||||||
amount: payAmountNum,
|
amount: payAmountNum,
|
||||||
paymentType: input.paymentType,
|
paymentType: input.paymentType,
|
||||||
subject: `${env.PRODUCT_NAME} ${payAmountStr} CNY`,
|
subject: subscriptionPlan
|
||||||
|
? `Sub2API 订阅 ${subscriptionGroupName || subscriptionPlan.name}`
|
||||||
|
: `${env.PRODUCT_NAME} ${payAmountStr} CNY`,
|
||||||
notifyUrl,
|
notifyUrl,
|
||||||
returnUrl,
|
returnUrl,
|
||||||
clientIp: input.clientIp,
|
clientIp: input.clientIp,
|
||||||
|
|||||||
Reference in New Issue
Block a user