feat: 套餐分组清理 + 续费延期 + UI统一
- Schema: groupId 改为 nullable,新增迁移 - GET 套餐列表自动检测并清除 Sub2API 中已删除的分组绑定 - PUT 保存时校验分组存在性,已删除则自动解绑并返回 409 - 续费逻辑:同分组有活跃订阅时从到期日计算天数再 createAndRedeem - 提取 PlanInfoDisplay 共享组件,SubscriptionConfirm 复用 - 默认模型统一到 /v1/messages badge 内 - 前端编辑表单适配 nullable groupId,未绑定时禁用保存
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable: make group_id nullable on subscription_plans
|
||||||
|
ALTER TABLE "subscription_plans" ALTER COLUMN "group_id" DROP NOT NULL;
|
||||||
@@ -108,7 +108,7 @@ model Channel {
|
|||||||
// ── 订阅套餐配置 ──
|
// ── 订阅套餐配置 ──
|
||||||
model SubscriptionPlan {
|
model SubscriptionPlan {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
groupId Int @unique @map("group_id")
|
groupId Int? @unique @map("group_id")
|
||||||
name String
|
name String
|
||||||
description String? @db.Text
|
description String? @db.Text
|
||||||
price Decimal @db.Decimal(10, 2)
|
price Decimal @db.Decimal(10, 2)
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ interface SubscriptionPlan {
|
|||||||
validDays: number;
|
validDays: number;
|
||||||
validityUnit: 'day' | 'week' | 'month';
|
validityUnit: 'day' | 'week' | 'month';
|
||||||
features: string[];
|
features: string[];
|
||||||
groupId: string;
|
groupId: string | null;
|
||||||
groupName: string | null;
|
groupName: string | null;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -427,7 +427,7 @@ function SubscriptionsContent() {
|
|||||||
|
|
||||||
const openEdit = (plan: SubscriptionPlan) => {
|
const openEdit = (plan: SubscriptionPlan) => {
|
||||||
setEditingPlan(plan);
|
setEditingPlan(plan);
|
||||||
setFormGroupId(plan.groupId);
|
setFormGroupId(plan.groupId ?? '');
|
||||||
setFormName(plan.name);
|
setFormName(plan.name);
|
||||||
setFormDescription(plan.description ?? '');
|
setFormDescription(plan.description ?? '');
|
||||||
setFormPrice(String(plan.price));
|
setFormPrice(String(plan.price));
|
||||||
@@ -448,11 +448,11 @@ function SubscriptionsContent() {
|
|||||||
|
|
||||||
/* --- save plan (snake_case for backend) --- */
|
/* --- save plan (snake_case for backend) --- */
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!formName.trim() || !formPrice) return;
|
if (!formName.trim() || !formPrice || !formGroupId) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError('');
|
setError('');
|
||||||
const body = {
|
const body = {
|
||||||
group_id: formGroupId ? Number(formGroupId) : undefined,
|
group_id: Number(formGroupId),
|
||||||
name: formName.trim(),
|
name: formName.trim(),
|
||||||
description: formDescription.trim() || null,
|
description: formDescription.trim() || null,
|
||||||
price: parseFloat(formPrice),
|
price: parseFloat(formPrice),
|
||||||
@@ -485,7 +485,9 @@ function SubscriptionsContent() {
|
|||||||
closeModal();
|
closeModal();
|
||||||
fetchPlans();
|
fetchPlans();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
// 分组被删除等错误:刷新列表使前端状态同步
|
||||||
setError(e instanceof Error ? e.message : t.saveFailed);
|
setError(e instanceof Error ? e.message : t.saveFailed);
|
||||||
|
fetchPlans();
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -617,7 +619,9 @@ function SubscriptionsContent() {
|
|||||||
|
|
||||||
/* available groups for the form: only subscription type, exclude already used */
|
/* available groups for the form: only subscription type, exclude already used */
|
||||||
const subscriptionGroups = groups.filter((g) => g.subscription_type === 'subscription');
|
const subscriptionGroups = groups.filter((g) => g.subscription_type === 'subscription');
|
||||||
const usedGroupIds = new Set(plans.filter((p) => p.id !== editingPlan?.id).map((p) => p.groupId));
|
const usedGroupIds = new Set(
|
||||||
|
plans.filter((p) => p.id !== editingPlan?.id && p.groupId != null).map((p) => p.groupId!),
|
||||||
|
);
|
||||||
const availableGroups = subscriptionGroups.filter((g) => !usedGroupIds.has(String(g.id)));
|
const availableGroups = subscriptionGroups.filter((g) => !usedGroupIds.has(String(g.id)));
|
||||||
|
|
||||||
/* group id → name map (all groups, for subscription display) */
|
/* group id → name map (all groups, for subscription display) */
|
||||||
@@ -850,12 +854,20 @@ function SubscriptionsContent() {
|
|||||||
{t.colGroup}
|
{t.colGroup}
|
||||||
</span>
|
</span>
|
||||||
<div className={isDark ? 'text-slate-200' : 'text-slate-800'}>
|
<div className={isDark ? 'text-slate-200' : 'text-slate-800'}>
|
||||||
|
{plan.groupId ? (
|
||||||
|
<>
|
||||||
<span className="font-mono text-xs">{plan.groupId}</span>
|
<span className="font-mono text-xs">{plan.groupId}</span>
|
||||||
{plan.groupName && (
|
{plan.groupName && (
|
||||||
<span className={`ml-1 text-xs ${isDark ? 'text-slate-400' : 'text-slate-500'}`}>
|
<span className={`ml-1 text-xs ${isDark ? 'text-slate-400' : 'text-slate-500'}`}>
|
||||||
({plan.groupName})
|
({plan.groupName})
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className={`text-xs ${isDark ? 'text-yellow-400' : 'text-yellow-600'}`}>
|
||||||
|
{locale === 'en' ? 'Unbound' : '未绑定'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -1221,8 +1233,10 @@ function SubscriptionsContent() {
|
|||||||
{g.name} ({g.id})
|
{g.name} ({g.id})
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
{/* If editing, ensure the current group is always visible */}
|
{/* If editing, ensure the current group is always visible (only if still bound) */}
|
||||||
{editingPlan && !availableGroups.some((g) => String(g.id) === editingPlan.groupId) && (
|
{editingPlan &&
|
||||||
|
editingPlan.groupId &&
|
||||||
|
!availableGroups.some((g) => String(g.id) === editingPlan.groupId) && (
|
||||||
<option value={editingPlan.groupId}>
|
<option value={editingPlan.groupId}>
|
||||||
{editingPlan.groupName ?? editingPlan.groupId} ({editingPlan.groupId})
|
{editingPlan.groupName ?? editingPlan.groupId} ({editingPlan.groupId})
|
||||||
</option>
|
</option>
|
||||||
@@ -1462,7 +1476,7 @@ function SubscriptionsContent() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={saving || !formName.trim() || !formPrice}
|
disabled={saving || !formName.trim() || !formPrice || !formGroupId}
|
||||||
className={[
|
className={[
|
||||||
'rounded-lg px-4 py-2 text-sm font-medium transition-colors disabled:opacity-50',
|
'rounded-lg px-4 py-2 text-sm font-medium transition-colors disabled:opacity-50',
|
||||||
isDark
|
isDark
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
|
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
|
||||||
import { prisma } from '@/lib/db';
|
import { prisma } from '@/lib/db';
|
||||||
|
import { getGroup } from '@/lib/sub2api/client';
|
||||||
|
|
||||||
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
if (!(await verifyAdminToken(request))) return unauthorizedResponse(request);
|
if (!(await verifyAdminToken(request))) return unauthorizedResponse(request);
|
||||||
@@ -14,6 +15,14 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
|||||||
return NextResponse.json({ error: '订阅套餐不存在' }, { status: 404 });
|
return NextResponse.json({ error: '订阅套餐不存在' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 确定最终 groupId:如果传了 group_id 用传入值,否则用现有值
|
||||||
|
const finalGroupId = body.group_id !== undefined ? (body.group_id ? Number(body.group_id) : null) : existing.groupId;
|
||||||
|
|
||||||
|
// 必须绑定分组才能保存
|
||||||
|
if (finalGroupId === null || finalGroupId === undefined) {
|
||||||
|
return NextResponse.json({ error: '必须关联一个 Sub2API 分组' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
// 如果更新了 group_id,检查唯一性
|
// 如果更新了 group_id,检查唯一性
|
||||||
if (body.group_id !== undefined && Number(body.group_id) !== existing.groupId) {
|
if (body.group_id !== undefined && Number(body.group_id) !== existing.groupId) {
|
||||||
const conflict = await prisma.subscriptionPlan.findUnique({
|
const conflict = await prisma.subscriptionPlan.findUnique({
|
||||||
@@ -27,6 +36,20 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 校验分组在 Sub2API 中仍然存在
|
||||||
|
const group = await getGroup(finalGroupId);
|
||||||
|
if (!group) {
|
||||||
|
// 分组已被删除,自动解绑
|
||||||
|
await prisma.subscriptionPlan.update({
|
||||||
|
where: { id },
|
||||||
|
data: { groupId: null, forSale: false },
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '该分组在 Sub2API 中已被删除,已自动解绑,请重新选择分组' },
|
||||||
|
{ status: 409 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (body.price !== undefined && (typeof body.price !== 'number' || body.price <= 0 || body.price > 99999999.99)) {
|
if (body.price !== undefined && (typeof body.price !== 'number' || body.price <= 0 || body.price > 99999999.99)) {
|
||||||
return NextResponse.json({ error: 'price 必须是 0.01 ~ 99999999.99 之间的数值' }, { status: 400 });
|
return NextResponse.json({ error: 'price 必须是 0.01 ~ 99999999.99 之间的数值' }, { status: 400 });
|
||||||
}
|
}
|
||||||
@@ -63,7 +86,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
|||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
id: plan.id,
|
id: plan.id,
|
||||||
groupId: String(plan.groupId),
|
groupId: plan.groupId != null ? String(plan.groupId) : null,
|
||||||
groupName: null,
|
groupName: null,
|
||||||
name: plan.name,
|
name: plan.name,
|
||||||
description: plan.description,
|
description: plan.description,
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export async function GET(request: NextRequest) {
|
|||||||
let groupExists = false;
|
let groupExists = false;
|
||||||
let groupName: string | null = null;
|
let groupName: string | null = null;
|
||||||
let group: Awaited<ReturnType<typeof getGroup>> | null = null;
|
let group: Awaited<ReturnType<typeof getGroup>> | null = null;
|
||||||
|
|
||||||
|
if (plan.groupId !== null) {
|
||||||
try {
|
try {
|
||||||
group = await getGroup(plan.groupId);
|
group = await getGroup(plan.groupId);
|
||||||
groupExists = group !== null;
|
groupExists = group !== null;
|
||||||
@@ -24,9 +26,21 @@ export async function GET(request: NextRequest) {
|
|||||||
} catch {
|
} catch {
|
||||||
groupExists = false;
|
groupExists = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 分组已失效:自动清除绑定并下架
|
||||||
|
if (!groupExists) {
|
||||||
|
prisma.subscriptionPlan
|
||||||
|
.update({
|
||||||
|
where: { id: plan.id },
|
||||||
|
data: { groupId: null, forSale: false },
|
||||||
|
})
|
||||||
|
.catch((err) => console.error(`Failed to unbind stale group for plan ${plan.id}:`, err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: plan.id,
|
id: plan.id,
|
||||||
groupId: String(plan.groupId),
|
groupId: groupExists ? String(plan.groupId) : null,
|
||||||
groupName,
|
groupName,
|
||||||
name: plan.name,
|
name: plan.name,
|
||||||
description: plan.description,
|
description: plan.description,
|
||||||
@@ -36,7 +50,7 @@ export async function GET(request: NextRequest) {
|
|||||||
validityUnit: plan.validityUnit,
|
validityUnit: plan.validityUnit,
|
||||||
features: plan.features ? JSON.parse(plan.features) : [],
|
features: plan.features ? JSON.parse(plan.features) : [],
|
||||||
sortOrder: plan.sortOrder,
|
sortOrder: plan.sortOrder,
|
||||||
enabled: plan.forSale,
|
enabled: groupExists ? plan.forSale : false,
|
||||||
groupExists,
|
groupExists,
|
||||||
groupPlatform: group?.platform ?? null,
|
groupPlatform: group?.platform ?? null,
|
||||||
groupRateMultiplier: group?.rate_multiplier ?? null,
|
groupRateMultiplier: group?.rate_multiplier ?? null,
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ export async function GET(request: NextRequest) {
|
|||||||
// 并发校验每个套餐对应的 Sub2API 分组是否存在
|
// 并发校验每个套餐对应的 Sub2API 分组是否存在
|
||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
plans.map(async (plan) => {
|
plans.map(async (plan) => {
|
||||||
|
if (plan.groupId === null) return null;
|
||||||
|
|
||||||
let groupActive = false;
|
let groupActive = false;
|
||||||
let group: Awaited<ReturnType<typeof getGroup>> = null;
|
let group: Awaited<ReturnType<typeof getGroup>> = null;
|
||||||
let groupInfo: {
|
let groupInfo: {
|
||||||
|
|||||||
@@ -6,8 +6,7 @@ import type { Locale } from '@/lib/locale';
|
|||||||
import { pickLocaleText } from '@/lib/locale';
|
import { pickLocaleText } from '@/lib/locale';
|
||||||
import { getPaymentTypeLabel, getPaymentIconSrc } from '@/lib/pay-utils';
|
import { getPaymentTypeLabel, getPaymentIconSrc } from '@/lib/pay-utils';
|
||||||
import type { PlanInfo } from '@/components/SubscriptionPlanCard';
|
import type { PlanInfo } from '@/components/SubscriptionPlanCard';
|
||||||
import { formatValidityLabel } from '@/lib/subscription-utils';
|
import { PlanInfoDisplay } from '@/components/SubscriptionPlanCard';
|
||||||
import { PlatformBadge } from '@/lib/platform-style';
|
|
||||||
|
|
||||||
interface SubscriptionConfirmProps {
|
interface SubscriptionConfirmProps {
|
||||||
plan: PlanInfo;
|
plan: PlanInfo;
|
||||||
@@ -30,16 +29,6 @@ export default function SubscriptionConfirm({
|
|||||||
}: SubscriptionConfirmProps) {
|
}: SubscriptionConfirmProps) {
|
||||||
const [selectedPayment, setSelectedPayment] = useState(paymentTypes[0] || '');
|
const [selectedPayment, setSelectedPayment] = useState(paymentTypes[0] || '');
|
||||||
|
|
||||||
const periodLabel = formatValidityLabel(plan.validityDays, plan.validityUnit ?? 'day', locale);
|
|
||||||
|
|
||||||
const hasLimits =
|
|
||||||
plan.limits &&
|
|
||||||
(plan.limits.daily_limit_usd !== null ||
|
|
||||||
plan.limits.weekly_limit_usd !== null ||
|
|
||||||
plan.limits.monthly_limit_usd !== null);
|
|
||||||
|
|
||||||
const isOpenAI = plan.platform?.toLowerCase() === 'openai';
|
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
if (selectedPayment && !loading) {
|
if (selectedPayment && !loading) {
|
||||||
onSubmit(selectedPayment);
|
onSubmit(selectedPayment);
|
||||||
@@ -68,142 +57,14 @@ export default function SubscriptionConfirm({
|
|||||||
{pickLocaleText(locale, '确认订单', 'Confirm Order')}
|
{pickLocaleText(locale, '确认订单', 'Confirm Order')}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{/* Plan detail card */}
|
{/* Plan detail card — reuse shared component */}
|
||||||
<div
|
<div
|
||||||
className={[
|
className={[
|
||||||
'rounded-2xl border p-5 space-y-4',
|
'rounded-2xl border p-5',
|
||||||
isDark ? 'border-slate-700 bg-slate-800/80' : 'border-slate-200 bg-white',
|
isDark ? 'border-slate-700 bg-slate-800/80' : 'border-slate-200 bg-white',
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
{/* Header: Platform badge + Name + Period + messages dispatch */}
|
<PlanInfoDisplay plan={plan} isDark={isDark} locale={locale} />
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
|
||||||
{plan.platform && <PlatformBadge platform={plan.platform} />}
|
|
||||||
<span className={['text-lg font-bold', isDark ? 'text-slate-100' : 'text-slate-900'].join(' ')}>
|
|
||||||
{plan.name}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className={[
|
|
||||||
'rounded-full px-2.5 py-0.5 text-xs font-medium',
|
|
||||||
isDark ? 'bg-emerald-900/40 text-emerald-300' : 'bg-emerald-50 text-emerald-700',
|
|
||||||
].join(' ')}
|
|
||||||
>
|
|
||||||
{periodLabel}
|
|
||||||
</span>
|
|
||||||
{isOpenAI && plan.allowMessagesDispatch && (
|
|
||||||
<span
|
|
||||||
className={[
|
|
||||||
'rounded-full px-2 py-0.5 text-xs font-medium',
|
|
||||||
isDark ? 'bg-green-500/20 text-green-300' : 'bg-green-100 text-green-700',
|
|
||||||
].join(' ')}
|
|
||||||
>
|
|
||||||
/v1/messages
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Price */}
|
|
||||||
<div className="flex items-baseline gap-2">
|
|
||||||
{plan.originalPrice !== null && (
|
|
||||||
<span className={['text-sm line-through', isDark ? 'text-slate-500' : 'text-slate-400'].join(' ')}>
|
|
||||||
¥{plan.originalPrice}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="text-2xl font-bold text-emerald-500">¥{plan.price}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
{plan.description && (
|
|
||||||
<p className={['text-sm leading-relaxed', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
|
||||||
{plan.description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Rate + Limits grid */}
|
|
||||||
{(plan.rateMultiplier != null || hasLimits) && (
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
{plan.rateMultiplier != null && (
|
|
||||||
<div>
|
|
||||||
<span className={['text-xs', isDark ? 'text-slate-500' : 'text-slate-400'].join(' ')}>
|
|
||||||
{pickLocaleText(locale, '倍率', 'Rate')}
|
|
||||||
</span>
|
|
||||||
<div className="flex items-baseline">
|
|
||||||
<span className="text-lg font-bold text-emerald-500">1</span>
|
|
||||||
<span className={['mx-1 text-base', isDark ? 'text-slate-500' : 'text-slate-400'].join(' ')}>:</span>
|
|
||||||
<span className="text-lg font-bold text-emerald-500">{plan.rateMultiplier}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{plan.limits?.daily_limit_usd != null && (
|
|
||||||
<div>
|
|
||||||
<span className={['text-xs', isDark ? 'text-slate-500' : 'text-slate-400'].join(' ')}>
|
|
||||||
{pickLocaleText(locale, '日限额', 'Daily Limit')}
|
|
||||||
</span>
|
|
||||||
<div className={['text-lg font-semibold', isDark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>
|
|
||||||
${plan.limits.daily_limit_usd}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{plan.limits?.weekly_limit_usd != null && (
|
|
||||||
<div>
|
|
||||||
<span className={['text-xs', isDark ? 'text-slate-500' : 'text-slate-400'].join(' ')}>
|
|
||||||
{pickLocaleText(locale, '周限额', 'Weekly Limit')}
|
|
||||||
</span>
|
|
||||||
<div className={['text-lg font-semibold', isDark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>
|
|
||||||
${plan.limits.weekly_limit_usd}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{plan.limits?.monthly_limit_usd != null && (
|
|
||||||
<div>
|
|
||||||
<span className={['text-xs', isDark ? 'text-slate-500' : 'text-slate-400'].join(' ')}>
|
|
||||||
{pickLocaleText(locale, '月限额', 'Monthly Limit')}
|
|
||||||
</span>
|
|
||||||
<div className={['text-lg font-semibold', isDark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>
|
|
||||||
${plan.limits.monthly_limit_usd}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* OpenAI specific: default model */}
|
|
||||||
{isOpenAI && plan.defaultMappedModel && (
|
|
||||||
<div
|
|
||||||
className={[
|
|
||||||
'flex items-center justify-between rounded-lg border px-3 py-2 text-sm',
|
|
||||||
isDark ? 'border-green-500/30 bg-green-500/10' : 'border-green-200 bg-green-50/50',
|
|
||||||
].join(' ')}
|
|
||||||
>
|
|
||||||
<span className={isDark ? 'text-slate-400' : 'text-slate-500'}>
|
|
||||||
{pickLocaleText(locale, '默认模型', 'Default Model')}
|
|
||||||
</span>
|
|
||||||
<span className={['text-xs font-mono', isDark ? 'text-slate-300' : 'text-slate-700'].join(' ')}>
|
|
||||||
{plan.defaultMappedModel}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Features */}
|
|
||||||
{plan.features.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<p className={['mb-2 text-xs', isDark ? 'text-slate-500' : 'text-slate-400'].join(' ')}>
|
|
||||||
{pickLocaleText(locale, '功能特性', 'Features')}
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{plan.features.map((feature) => (
|
|
||||||
<span
|
|
||||||
key={feature}
|
|
||||||
className={[
|
|
||||||
'rounded-md px-2 py-1 text-xs',
|
|
||||||
isDark ? 'bg-emerald-500/10 text-emerald-400' : 'bg-emerald-50 text-emerald-700',
|
|
||||||
].join(' ')}
|
|
||||||
>
|
|
||||||
{feature}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Payment method selector */}
|
{/* Payment method selector */}
|
||||||
|
|||||||
@@ -28,14 +28,16 @@ export interface PlanInfo {
|
|||||||
defaultMappedModel: string | null;
|
defaultMappedModel: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SubscriptionPlanCardProps {
|
/** 套餐信息展示(Header + 价格 + 描述 + 倍率/限额 + 特性),不含操作按钮 */
|
||||||
|
export function PlanInfoDisplay({
|
||||||
|
plan,
|
||||||
|
isDark,
|
||||||
|
locale,
|
||||||
|
}: {
|
||||||
plan: PlanInfo;
|
plan: PlanInfo;
|
||||||
onSubscribe: (planId: string) => void;
|
|
||||||
isDark: boolean;
|
isDark: boolean;
|
||||||
locale: Locale;
|
locale: Locale;
|
||||||
}
|
}) {
|
||||||
|
|
||||||
export default function SubscriptionPlanCard({ plan, onSubscribe, isDark, locale }: SubscriptionPlanCardProps) {
|
|
||||||
const unit = plan.validityUnit ?? 'day';
|
const unit = plan.validityUnit ?? 'day';
|
||||||
const periodLabel = formatValidityLabel(plan.validityDays, unit, locale);
|
const periodLabel = formatValidityLabel(plan.validityDays, unit, locale);
|
||||||
const periodSuffix = formatValiditySuffix(plan.validityDays, unit, locale);
|
const periodSuffix = formatValiditySuffix(plan.validityDays, unit, locale);
|
||||||
@@ -51,13 +53,8 @@ export default function SubscriptionPlanCard({ plan, onSubscribe, isDark, locale
|
|||||||
const accentCls = isDark ? ps.accent.dark : ps.accent.light;
|
const accentCls = isDark ? ps.accent.dark : ps.accent.light;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
className={[
|
{/* Header: Platform badge + Name + Period + /v1/messages */}
|
||||||
'flex flex-col rounded-2xl border p-6 transition-shadow hover:shadow-lg',
|
|
||||||
isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-white',
|
|
||||||
].join(' ')}
|
|
||||||
>
|
|
||||||
{/* Header: Platform badge + Name + Period */}
|
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||||
{plan.platform && <PlatformBadge platform={plan.platform} />}
|
{plan.platform && <PlatformBadge platform={plan.platform} />}
|
||||||
@@ -175,6 +172,28 @@ export default function SubscriptionPlanCard({ plan, onSubscribe, isDark, locale
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SubscriptionPlanCardProps {
|
||||||
|
plan: PlanInfo;
|
||||||
|
onSubscribe: (planId: string) => void;
|
||||||
|
isDark: boolean;
|
||||||
|
locale: Locale;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SubscriptionPlanCard({ plan, onSubscribe, isDark, locale }: SubscriptionPlanCardProps) {
|
||||||
|
const ps = getPlatformStyle(plan.platform ?? '');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
'flex flex-col rounded-2xl border p-6 transition-shadow hover:shadow-lg',
|
||||||
|
isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-white',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<PlanInfoDisplay plan={plan} isDark={isDark} locale={locale} />
|
||||||
|
|
||||||
{/* Spacer */}
|
{/* Spacer */}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|||||||
@@ -6,7 +6,14 @@ import { getMethodDailyLimit } from './limits';
|
|||||||
import { getMethodFeeRate, calculatePayAmount } from './fee';
|
import { getMethodFeeRate, calculatePayAmount } from './fee';
|
||||||
import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
|
import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
|
||||||
import type { PaymentType, PaymentNotification } from '@/lib/payment';
|
import type { PaymentType, PaymentNotification } from '@/lib/payment';
|
||||||
import { getUser, createAndRedeem, subtractBalance, addBalance, getGroup } from '@/lib/sub2api/client';
|
import {
|
||||||
|
getUser,
|
||||||
|
createAndRedeem,
|
||||||
|
subtractBalance,
|
||||||
|
addBalance,
|
||||||
|
getGroup,
|
||||||
|
getUserSubscriptions,
|
||||||
|
} from '@/lib/sub2api/client';
|
||||||
import { computeValidityDays, type ValidityUnit } from '@/lib/subscription-utils';
|
import { computeValidityDays, type ValidityUnit } from '@/lib/subscription-utils';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { deriveOrderState, isRefundStatus } from './status';
|
import { deriveOrderState, isRefundStatus } from './status';
|
||||||
@@ -62,7 +69,7 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
|
|||||||
// ── 订阅订单前置校验 ──
|
// ── 订阅订单前置校验 ──
|
||||||
let subscriptionPlan: {
|
let subscriptionPlan: {
|
||||||
id: string;
|
id: string;
|
||||||
groupId: number;
|
groupId: number | null;
|
||||||
price: Prisma.Decimal;
|
price: Prisma.Decimal;
|
||||||
validityDays: number;
|
validityDays: number;
|
||||||
validityUnit: string;
|
validityUnit: string;
|
||||||
@@ -99,6 +106,14 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
|
|||||||
404,
|
404,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// 校验分组绑定有效
|
||||||
|
if (plan.groupId === null) {
|
||||||
|
throw new OrderError(
|
||||||
|
'GROUP_NOT_BOUND',
|
||||||
|
message(locale, '该套餐尚未绑定分组,无法购买', 'Plan is not bound to a group'),
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
// 校验 Sub2API 分组仍然存在
|
// 校验 Sub2API 分组仍然存在
|
||||||
const group = await getGroup(plan.groupId);
|
const group = await getGroup(plan.groupId);
|
||||||
if (!group || group.status !== 'active') {
|
if (!group || group.status !== 'active') {
|
||||||
@@ -668,6 +683,33 @@ export async function executeSubscriptionFulfillment(orderId: string): Promise<v
|
|||||||
throw new Error(`Subscription group ${order.subscriptionGroupId} no longer exists or inactive`);
|
throw new Error(`Subscription group ${order.subscriptionGroupId} no longer exists or inactive`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检测是否续费:查找同分组的活跃订阅,决定天数计算起点
|
||||||
|
let validityDays = order.subscriptionDays;
|
||||||
|
let fulfillMethod: 'renew' | 'new' = 'new';
|
||||||
|
let renewedSubscriptionId: number | undefined;
|
||||||
|
|
||||||
|
const userSubs = await getUserSubscriptions(order.userId);
|
||||||
|
const activeSub = userSubs.find(
|
||||||
|
(s) => s.group_id === order.subscriptionGroupId && s.status === 'active',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (activeSub) {
|
||||||
|
// 续费:从到期日往后推算天数
|
||||||
|
const plan = await prisma.subscriptionPlan.findFirst({
|
||||||
|
where: { groupId: order.subscriptionGroupId },
|
||||||
|
select: { validityDays: true, validityUnit: true },
|
||||||
|
});
|
||||||
|
if (plan) {
|
||||||
|
validityDays = computeValidityDays(
|
||||||
|
plan.validityDays,
|
||||||
|
plan.validityUnit as ValidityUnit,
|
||||||
|
new Date(activeSub.expires_at),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
fulfillMethod = 'renew';
|
||||||
|
renewedSubscriptionId = activeSub.id;
|
||||||
|
}
|
||||||
|
|
||||||
await createAndRedeem(
|
await createAndRedeem(
|
||||||
order.rechargeCode,
|
order.rechargeCode,
|
||||||
Number(order.amount),
|
Number(order.amount),
|
||||||
@@ -676,7 +718,7 @@ export async function executeSubscriptionFulfillment(orderId: string): Promise<v
|
|||||||
{
|
{
|
||||||
type: 'subscription',
|
type: 'subscription',
|
||||||
groupId: order.subscriptionGroupId,
|
groupId: order.subscriptionGroupId,
|
||||||
validityDays: order.subscriptionDays,
|
validityDays,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -693,6 +735,8 @@ export async function executeSubscriptionFulfillment(orderId: string): Promise<v
|
|||||||
groupId: order.subscriptionGroupId,
|
groupId: order.subscriptionGroupId,
|
||||||
days: order.subscriptionDays,
|
days: order.subscriptionDays,
|
||||||
amount: Number(order.amount),
|
amount: Number(order.amount),
|
||||||
|
method: fulfillMethod,
|
||||||
|
...(renewedSubscriptionId && { renewedSubscriptionId }),
|
||||||
}),
|
}),
|
||||||
operator: 'system',
|
operator: 'system',
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user