feat: 套餐分组清理 + 续费延期 + UI统一

- Schema: groupId 改为 nullable,新增迁移
- GET 套餐列表自动检测并清除 Sub2API 中已删除的分组绑定
- PUT 保存时校验分组存在性,已删除则自动解绑并返回 409
- 续费逻辑:同分组有活跃订阅时从到期日计算天数再 createAndRedeem
- 提取 PlanInfoDisplay 共享组件,SubscriptionConfirm 复用
- 默认模型统一到 /v1/messages badge 内
- 前端编辑表单适配 nullable groupId,未绑定时禁用保存
This commit is contained in:
erio
2026-03-14 05:06:36 +08:00
parent ef4241b82f
commit bd1db1efd8
9 changed files with 163 additions and 184 deletions

View File

@@ -17,7 +17,7 @@ interface SubscriptionPlan {
validDays: number;
validityUnit: 'day' | 'week' | 'month';
features: string[];
groupId: string;
groupId: string | null;
groupName: string | null;
sortOrder: number;
enabled: boolean;
@@ -427,7 +427,7 @@ function SubscriptionsContent() {
const openEdit = (plan: SubscriptionPlan) => {
setEditingPlan(plan);
setFormGroupId(plan.groupId);
setFormGroupId(plan.groupId ?? '');
setFormName(plan.name);
setFormDescription(plan.description ?? '');
setFormPrice(String(plan.price));
@@ -448,11 +448,11 @@ function SubscriptionsContent() {
/* --- save plan (snake_case for backend) --- */
const handleSave = async () => {
if (!formName.trim() || !formPrice) return;
if (!formName.trim() || !formPrice || !formGroupId) return;
setSaving(true);
setError('');
const body = {
group_id: formGroupId ? Number(formGroupId) : undefined,
group_id: Number(formGroupId),
name: formName.trim(),
description: formDescription.trim() || null,
price: parseFloat(formPrice),
@@ -485,7 +485,9 @@ function SubscriptionsContent() {
closeModal();
fetchPlans();
} catch (e) {
// 分组被删除等错误:刷新列表使前端状态同步
setError(e instanceof Error ? e.message : t.saveFailed);
fetchPlans();
} finally {
setSaving(false);
}
@@ -617,7 +619,9 @@ function SubscriptionsContent() {
/* available groups for the form: only subscription type, exclude already used */
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)));
/* group id → name map (all groups, for subscription display) */
@@ -850,10 +854,18 @@ function SubscriptionsContent() {
{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})
{plan.groupId ? (
<>
<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>
)}
</>
) : (
<span className={`text-xs ${isDark ? 'text-yellow-400' : 'text-yellow-600'}`}>
{locale === 'en' ? 'Unbound' : '未绑定'}
</span>
)}
</div>
@@ -1221,12 +1233,14 @@ function SubscriptionsContent() {
{g.name} ({g.id})
</option>
))}
{/* If editing, ensure the current group is always visible */}
{editingPlan && !availableGroups.some((g) => String(g.id) === editingPlan.groupId) && (
<option value={editingPlan.groupId}>
{editingPlan.groupName ?? editingPlan.groupId} ({editingPlan.groupId})
</option>
)}
{/* If editing, ensure the current group is always visible (only if still bound) */}
{editingPlan &&
editingPlan.groupId &&
!availableGroups.some((g) => String(g.id) === editingPlan.groupId) && (
<option value={editingPlan.groupId}>
{editingPlan.groupName ?? editingPlan.groupId} ({editingPlan.groupId})
</option>
)}
</select>
</div>
@@ -1462,7 +1476,7 @@ function SubscriptionsContent() {
<button
type="button"
onClick={handleSave}
disabled={saving || !formName.trim() || !formPrice}
disabled={saving || !formName.trim() || !formPrice || !formGroupId}
className={[
'rounded-lg px-4 py-2 text-sm font-medium transition-colors disabled:opacity-50',
isDark

View File

@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
import { prisma } from '@/lib/db';
import { getGroup } from '@/lib/sub2api/client';
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
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 });
}
// 确定最终 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检查唯一性
if (body.group_id !== undefined && Number(body.group_id) !== existing.groupId) {
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)) {
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({
id: plan.id,
groupId: String(plan.groupId),
groupId: plan.groupId != null ? String(plan.groupId) : null,
groupName: null,
name: plan.name,
description: plan.description,

View File

@@ -17,16 +17,30 @@ export async function GET(request: NextRequest) {
let groupExists = false;
let groupName: string | null = null;
let group: Awaited<ReturnType<typeof getGroup>> | null = null;
try {
group = await getGroup(plan.groupId);
groupExists = group !== null;
groupName = group?.name ?? null;
} catch {
groupExists = false;
if (plan.groupId !== null) {
try {
group = await getGroup(plan.groupId);
groupExists = group !== null;
groupName = group?.name ?? null;
} catch {
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 {
id: plan.id,
groupId: String(plan.groupId),
groupId: groupExists ? String(plan.groupId) : null,
groupName,
name: plan.name,
description: plan.description,
@@ -36,7 +50,7 @@ export async function GET(request: NextRequest) {
validityUnit: plan.validityUnit,
features: plan.features ? JSON.parse(plan.features) : [],
sortOrder: plan.sortOrder,
enabled: plan.forSale,
enabled: groupExists ? plan.forSale : false,
groupExists,
groupPlatform: group?.platform ?? null,
groupRateMultiplier: group?.rate_multiplier ?? null,

View File

@@ -23,6 +23,8 @@ export async function GET(request: NextRequest) {
// 并发校验每个套餐对应的 Sub2API 分组是否存在
const results = await Promise.all(
plans.map(async (plan) => {
if (plan.groupId === null) return null;
let groupActive = false;
let group: Awaited<ReturnType<typeof getGroup>> = null;
let groupInfo: {