feat: 订阅管理增强、商品名称配置、余额充值开关

- R1: 用户订阅搜索改为模糊关键词(邮箱/用户名/备注/APIKey)
- R2: "分组状态"列名改为"Sub2API 状态"
- R3: 订阅套餐可配置支付商品名称(productName)
- R4: 订阅订单校验 subscription_type 必须为 subscription
- R5: 渠道管理配置余额充值商品名前缀/后缀
- R6: 渠道管理可关闭余额充值,前端隐藏入口,API 拒绝
- R7: 所有入口关闭时显示"入口被管理员关闭"提示
- fix: easy-pay client 测试 mock 方式修复(vi.fn + 参数快照)
This commit is contained in:
erio
2026-03-14 00:43:00 +08:00
parent 1bb11ee32b
commit 6c61c3f877
16 changed files with 873 additions and 32 deletions

View File

@@ -13,6 +13,7 @@ import { deriveOrderState, isRefundStatus } from './status';
import { pickLocaleText, type Locale } from '@/lib/locale';
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;
/** Decimal(10,2) 允许的最大金额 */
@@ -59,8 +60,21 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
const orderType = input.orderType ?? 'balance';
// ── 订阅订单前置校验 ──
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; productName: string | null } | null = null;
let subscriptionGroupName = '';
// R6: 余额充值禁用检查
if (orderType === 'balance') {
const balanceDisabled = await getSystemConfig('BALANCE_PAYMENT_DISABLED');
if (balanceDisabled === 'true') {
throw new OrderError(
'BALANCE_PAYMENT_DISABLED',
message(locale, '余额充值已被管理员关闭', 'Balance recharge has been disabled by the administrator'),
403,
);
}
}
if (orderType === 'subscription') {
if (!input.planId) {
throw new OrderError('INVALID_INPUT', message(locale, '订阅订单必须指定套餐', 'Subscription order requires a plan'), 400);
@@ -78,6 +92,14 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
410,
);
}
// R4: 校验分组必须为订阅类型
if (group.subscription_type !== 'subscription') {
throw new OrderError(
'GROUP_TYPE_MISMATCH',
message(locale, '该分组不是订阅类型,无法购买订阅', 'This group is not a subscription type'),
400,
);
}
subscriptionGroupName = group?.name || plan.name;
subscriptionPlan = plan;
// 订阅订单金额使用服务端套餐价格,不信任客户端
@@ -216,13 +238,28 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
returnUrl = orderResultUrl;
}
// R3+R5: 构建支付商品名称
let paymentSubject: string;
if (subscriptionPlan) {
// R3: 订阅订单优先使用套餐自定义商品名称
paymentSubject = subscriptionPlan.productName || `Sub2API 订阅 ${subscriptionGroupName || subscriptionPlan.name}`;
} else {
// R5: 余额订单使用前缀/后缀配置
const nameConfigs = await getSystemConfigs(['PRODUCT_NAME_PREFIX', 'PRODUCT_NAME_SUFFIX']);
const prefix = nameConfigs['PRODUCT_NAME_PREFIX']?.trim();
const suffix = nameConfigs['PRODUCT_NAME_SUFFIX']?.trim();
if (prefix || suffix) {
paymentSubject = `${prefix || ''} ${payAmountStr} ${suffix || ''}`.trim();
} else {
paymentSubject = `${env.PRODUCT_NAME} ${payAmountStr} CNY`;
}
}
const paymentResult = await provider.createPayment({
orderId: order.id,
amount: payAmountNum,
paymentType: input.paymentType,
subject: subscriptionPlan
? `Sub2API 订阅 ${subscriptionGroupName || subscriptionPlan.name}`
: `${env.PRODUCT_NAME} ${payAmountStr} CNY`,
subject: paymentSubject,
notifyUrl,
returnUrl,
clientIp: input.clientIp,

View File

@@ -227,6 +227,26 @@ export async function subtractBalance(
}
}
// ── 用户搜索 API ──
export async function searchUsers(keyword: string): Promise<{ id: number; email: string; username: string; notes?: string }[]> {
const env = getEnv();
const response = await fetch(
`${env.SUB2API_BASE_URL}/api/v1/admin/users?search=${encodeURIComponent(keyword)}&page=1&page_size=30`,
{
headers: getHeaders(),
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
},
);
if (!response.ok) {
throw new Error(`Failed to search users: ${response.status}`);
}
const data = await response.json();
return (data.data ?? []) as { id: number; email: string; username: string; notes?: string }[];
}
export async function addBalance(userId: number, amount: number, notes: string, idempotencyKey: string): Promise<void> {
const env = getEnv();
const response = await fetch(`${env.SUB2API_BASE_URL}/api/v1/admin/users/${userId}/balance`, {