feat: 套餐有效期支持日/周/月单位,订阅履约改用兑换码流程,UI层次感优化
- Prisma: SubscriptionPlan 新增 validityUnit 字段 (day/week/month) - 新增 subscription-utils.ts 计算实际天数及格式化显示 - Sub2API client createAndRedeem 支持 subscription 类型 (group_id, validity_days) - 订阅履约从 assignSubscription 改为 createAndRedeem,在 Sub2API 留痕 - 订单创建动态计算天数(月单位按自然月差值) - 管理后台表单支持有效期数值+单位下拉 - 前端 ChannelCard 渠道卡片视觉层次优化(模型标签渐变、倍率突出、闪电图标) - 按量付费 banner 改为渐变背景+底部倍率说明标签 - 帮助/客服信息区块添加到充值、订阅、支付全流程页面 - 移除系统配置独立页面入口,subscriptions API 返回用户信息
This commit is contained in:
@@ -6,7 +6,8 @@ import { getMethodDailyLimit } from './limits';
|
||||
import { getMethodFeeRate, calculatePayAmount } from './fee';
|
||||
import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
|
||||
import type { PaymentType, PaymentNotification } from '@/lib/payment';
|
||||
import { getUser, createAndRedeem, subtractBalance, addBalance, getGroup, assignSubscription } from '@/lib/sub2api/client';
|
||||
import { getUser, createAndRedeem, subtractBalance, addBalance, getGroup } from '@/lib/sub2api/client';
|
||||
import { computeValidityDays, type ValidityUnit } from '@/lib/subscription-utils';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { deriveOrderState, isRefundStatus } from './status';
|
||||
import { pickLocaleText, type Locale } from '@/lib/locale';
|
||||
@@ -56,7 +57,7 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
|
||||
const orderType = input.orderType ?? 'balance';
|
||||
|
||||
// ── 订阅订单前置校验 ──
|
||||
let subscriptionPlan: { id: string; groupId: number; price: Prisma.Decimal; validityDays: number; name: string } | null = null;
|
||||
let subscriptionPlan: { id: string; groupId: number; price: Prisma.Decimal; validityDays: number; validityUnit: string; name: string } | null = null;
|
||||
if (orderType === 'subscription') {
|
||||
if (!input.planId) {
|
||||
throw new OrderError('INVALID_INPUT', message(locale, '订阅订单必须指定套餐', 'Subscription order requires a plan'), 400);
|
||||
@@ -180,7 +181,9 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
|
||||
orderType,
|
||||
planId: subscriptionPlan?.id ?? null,
|
||||
subscriptionGroupId: subscriptionPlan?.groupId ?? null,
|
||||
subscriptionDays: subscriptionPlan?.validityDays ?? null,
|
||||
subscriptionDays: subscriptionPlan
|
||||
? computeValidityDays(subscriptionPlan.validityDays, subscriptionPlan.validityUnit as ValidityUnit)
|
||||
: null,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -598,12 +601,16 @@ export async function executeSubscriptionFulfillment(orderId: string): Promise<v
|
||||
throw new Error(`Subscription group ${order.subscriptionGroupId} no longer exists or inactive`);
|
||||
}
|
||||
|
||||
await assignSubscription(
|
||||
await createAndRedeem(
|
||||
order.rechargeCode,
|
||||
Number(order.amount),
|
||||
order.userId,
|
||||
order.subscriptionGroupId,
|
||||
order.subscriptionDays,
|
||||
`sub2apipay subscription order:${orderId}`,
|
||||
`sub2apipay:subscription:${order.rechargeCode}`,
|
||||
{
|
||||
type: 'subscription',
|
||||
groupId: order.subscriptionGroupId,
|
||||
validityDays: order.subscriptionDays,
|
||||
},
|
||||
);
|
||||
|
||||
await prisma.order.updateMany({
|
||||
|
||||
@@ -60,15 +60,20 @@ export async function createAndRedeem(
|
||||
value: number,
|
||||
userId: number,
|
||||
notes: string,
|
||||
options?: { type?: 'balance' | 'subscription'; groupId?: number; validityDays?: number },
|
||||
): Promise<Sub2ApiRedeemCode> {
|
||||
const env = getEnv();
|
||||
const url = `${env.SUB2API_BASE_URL}/api/v1/admin/redeem-codes/create-and-redeem`;
|
||||
const body = JSON.stringify({
|
||||
code,
|
||||
type: 'balance',
|
||||
type: options?.type ?? 'balance',
|
||||
value,
|
||||
user_id: userId,
|
||||
notes,
|
||||
...(options?.type === 'subscription' && {
|
||||
group_id: options.groupId,
|
||||
validity_days: options.validityDays,
|
||||
}),
|
||||
});
|
||||
|
||||
let lastError: unknown;
|
||||
|
||||
90
src/lib/subscription-utils.ts
Normal file
90
src/lib/subscription-utils.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
export type ValidityUnit = 'day' | 'week' | 'month';
|
||||
|
||||
/**
|
||||
* 根据数值和单位计算实际有效天数。
|
||||
* - day: 直接返回
|
||||
* - week: value * 7
|
||||
* - month: 从 fromDate 到 value 个月后同一天的天数差
|
||||
*/
|
||||
export function computeValidityDays(value: number, unit: ValidityUnit, fromDate?: Date): number {
|
||||
if (unit === 'day') return value;
|
||||
if (unit === 'week') return value * 7;
|
||||
|
||||
// month: 计算到 value 个月后同一天的天数差
|
||||
const from = fromDate ?? new Date();
|
||||
const target = new Date(from);
|
||||
target.setMonth(target.getMonth() + value);
|
||||
return Math.round((target.getTime() - from.getTime()) / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能格式化有效期显示文本。
|
||||
* - unit=month, value=1 → 包月 / Monthly
|
||||
* - unit=month, value=3 → 包3月 / 3 Months
|
||||
* - unit=week, value=2 → 包2周 / 2 Weeks
|
||||
* - unit=day, value=30 → 包月 / Monthly (特殊处理)
|
||||
* - unit=day, value=90 → 包90天 / 90 Days
|
||||
*/
|
||||
export function formatValidityLabel(
|
||||
value: number,
|
||||
unit: ValidityUnit,
|
||||
locale: 'zh' | 'en',
|
||||
): string {
|
||||
if (unit === 'month') {
|
||||
if (value === 1) return locale === 'zh' ? '包月' : 'Monthly';
|
||||
return locale === 'zh' ? `包${value}月` : `${value} Months`;
|
||||
}
|
||||
if (unit === 'week') {
|
||||
if (value === 1) return locale === 'zh' ? '包周' : 'Weekly';
|
||||
return locale === 'zh' ? `包${value}周` : `${value} Weeks`;
|
||||
}
|
||||
// day
|
||||
if (value === 30) return locale === 'zh' ? '包月' : 'Monthly';
|
||||
return locale === 'zh' ? `包${value}天` : `${value} Days`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能格式化有效期后缀(用于价格展示)。
|
||||
* - unit=month, value=1 → /月 / /mo
|
||||
* - unit=month, value=3 → /3月 / /3mo
|
||||
* - unit=week, value=2 → /2周 / /2wk
|
||||
* - unit=day, value=30 → /月 / /mo
|
||||
* - unit=day, value=90 → /90天 / /90d
|
||||
*/
|
||||
export function formatValiditySuffix(
|
||||
value: number,
|
||||
unit: ValidityUnit,
|
||||
locale: 'zh' | 'en',
|
||||
): string {
|
||||
if (unit === 'month') {
|
||||
if (value === 1) return locale === 'zh' ? '/月' : '/mo';
|
||||
return locale === 'zh' ? `/${value}月` : `/${value}mo`;
|
||||
}
|
||||
if (unit === 'week') {
|
||||
if (value === 1) return locale === 'zh' ? '/周' : '/wk';
|
||||
return locale === 'zh' ? `/${value}周` : `/${value}wk`;
|
||||
}
|
||||
// day
|
||||
if (value === 30) return locale === 'zh' ? '/月' : '/mo';
|
||||
return locale === 'zh' ? `/${value}天` : `/${value}d`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化有效期列表展示文本(管理后台表格用)。
|
||||
* - unit=day → "30 天"
|
||||
* - unit=week → "2 周"
|
||||
* - unit=month → "1 月"
|
||||
*/
|
||||
export function formatValidityDisplay(
|
||||
value: number,
|
||||
unit: ValidityUnit,
|
||||
locale: 'zh' | 'en',
|
||||
): string {
|
||||
const unitLabels: Record<ValidityUnit, { zh: string; en: string }> = {
|
||||
day: { zh: '天', en: 'day(s)' },
|
||||
week: { zh: '周', en: 'week(s)' },
|
||||
month: { zh: '月', en: 'month(s)' },
|
||||
};
|
||||
const label = locale === 'zh' ? unitLabels[unit].zh : unitLabels[unit].en;
|
||||
return `${value} ${label}`;
|
||||
}
|
||||
Reference in New Issue
Block a user