feat(admin,backend): 新增平台总后台超级管理员最高权限体系与会员列表、续费列表、订阅管理中心
这个提交包含在:
@@ -134,7 +134,36 @@ export type LeadStatus = 'new' | 'contacted' | 'hot' | 'won' | 'archived';
|
||||
/**
|
||||
* 线索触达来源渠道
|
||||
*/
|
||||
export type LeadSource = 'nfc' | 'qr' | 'link' | 'ai_chat';
|
||||
export type LeadSource = 'nfc' | 'qr' | 'link' | 'ai_chat' | 'exchange';
|
||||
|
||||
export type CardContactSource = 'exchange' | 'collect' | 'scan';
|
||||
|
||||
export interface CardContact {
|
||||
id?: number | string;
|
||||
ownerUserId: number;
|
||||
ownerCardId?: number | null;
|
||||
peerCardId: number;
|
||||
source: CardContactSource;
|
||||
leadId?: number | null;
|
||||
peerName?: string;
|
||||
peerTitle?: string;
|
||||
peerCompany?: string;
|
||||
peerAvatarUrl?: string;
|
||||
peerSlug?: string;
|
||||
exchangedAt?: string | Date;
|
||||
}
|
||||
|
||||
export interface CardExchangeRecord {
|
||||
id?: number | string;
|
||||
exchangeCode: string;
|
||||
senderCardId: number;
|
||||
recipientCardId: number;
|
||||
senderUserId?: number | null;
|
||||
recipientUserId?: number | null;
|
||||
status: 'REQUESTED' | 'ACCEPTED' | 'REJECTED' | 'EXPIRED';
|
||||
notes?: string;
|
||||
acceptedAt?: string | Date | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* LeadEntity - 潜客线索关系模型契约
|
||||
@@ -896,7 +925,7 @@ export interface MoveMembersRequest {
|
||||
/**
|
||||
* 国际主流结算币种
|
||||
*/
|
||||
export type SupportedCurrency = 'USD' | 'EUR' | 'GBP' | 'CNY';
|
||||
export type SupportedCurrency = 'USD' | 'AED' | 'EUR' | 'GBP' | 'JPY' | 'CNY';
|
||||
|
||||
/**
|
||||
* 发票状态
|
||||
@@ -946,10 +975,12 @@ export interface CreateInvoiceDTO {
|
||||
* 税费折算计算结果
|
||||
*/
|
||||
export interface TaxCalculationResult {
|
||||
subtotal: number;
|
||||
subtotal?: number;
|
||||
taxRate: number;
|
||||
taxAmount: number;
|
||||
totalAmount: number;
|
||||
taxName?: string;
|
||||
isReverseCharge?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1241,3 +1272,88 @@ export interface FinanceAnalyticsResult {
|
||||
recentLedger: RecentLedgerItem[];
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// 平台总后台超级管理员 (Platform Super Admin) - 会员、续费与订阅契约
|
||||
// -------------------------------------------------------------
|
||||
|
||||
export interface SubscriberItem {
|
||||
id: number;
|
||||
tenantId: number;
|
||||
tenantName: string;
|
||||
adminName: string;
|
||||
adminEmail: string;
|
||||
adminPhone?: string;
|
||||
planType: TenantPlanType;
|
||||
status: 'active' | 'trialing' | 'past_due' | 'canceled';
|
||||
cardsUsed: number;
|
||||
cardsLimit: number;
|
||||
seatsUsed: number;
|
||||
seatsLimit: number;
|
||||
totalPaidAmount: number;
|
||||
currency: SupportedCurrency;
|
||||
startDate: string;
|
||||
expireDate: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface UpdateSubscriberDTO {
|
||||
tenantId: number;
|
||||
planType?: TenantPlanType;
|
||||
cardsLimit?: number;
|
||||
seatsLimit?: number;
|
||||
extendDays?: number;
|
||||
expireDate?: string;
|
||||
status?: 'active' | 'trialing' | 'past_due' | 'canceled';
|
||||
}
|
||||
|
||||
export interface RenewalOrderItem {
|
||||
id: number;
|
||||
orderNo: string;
|
||||
tenantId: number;
|
||||
tenantName: string;
|
||||
planType: TenantPlanType;
|
||||
billingCycle: 'monthly' | 'yearly';
|
||||
amount: number;
|
||||
currency: SupportedCurrency;
|
||||
channel: 'stripe' | 'wire_transfer' | 'manual_admin' | 'system';
|
||||
status: 'paid' | 'pending' | 'refunded';
|
||||
paidAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ManualRenewDTO {
|
||||
tenantId: number;
|
||||
planType: TenantPlanType;
|
||||
billingCycle: 'monthly' | 'yearly';
|
||||
amount: number;
|
||||
currency?: SupportedCurrency;
|
||||
channel?: 'stripe' | 'wire_transfer' | 'manual_admin';
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface PlanConfigItem {
|
||||
planType: TenantPlanType;
|
||||
name: string;
|
||||
description: string;
|
||||
monthlyPrice: number;
|
||||
yearlyPrice: number;
|
||||
cardsLimit: number;
|
||||
seatsLimit: number;
|
||||
aiTokensPerMonth: number;
|
||||
nfcDynamicRouting: boolean;
|
||||
supportCustomDomain: boolean;
|
||||
crmLeadLimit: number;
|
||||
isPopular?: boolean;
|
||||
}
|
||||
|
||||
export interface PlatformOverviewResult {
|
||||
totalSubscribers: number;
|
||||
activeSubscribers: number;
|
||||
proCount: number;
|
||||
enterpriseCount: number;
|
||||
expiringSoonCount: number;
|
||||
totalRenewalRevenue: number;
|
||||
currency: SupportedCurrency;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { Body, Inject, Post, Provide } from '@midwayjs/core';
|
||||
import { Context } from '@midwayjs/koa';
|
||||
import { CoolController, BaseController } from '@cool-midway/core';
|
||||
import { PlatformSubscriptionService } from '../../service/platform-subscription';
|
||||
import {
|
||||
ManualRenewDTO,
|
||||
PlanConfigItem,
|
||||
PlatformOverviewResult,
|
||||
TenantPlanType,
|
||||
UpdateSubscriberDTO,
|
||||
} from '../../../../comm/types/omni';
|
||||
|
||||
export const adminPlatformSubscriptionCurdOption = {
|
||||
api: [] as Array<'add' | 'delete' | 'update' | 'info' | 'list' | 'page'>,
|
||||
service: PlatformSubscriptionService,
|
||||
serviceApis: [
|
||||
{ method: 'overview', summary: '平台会员与订阅整体概览' },
|
||||
{ method: 'subscribersPage', summary: '全网会员分页列表' },
|
||||
{ method: 'subscribersUpdate', summary: '超管修改会员套餐/配额/延期' },
|
||||
{ method: 'renewalsPage', summary: '全网续费流水列表' },
|
||||
{ method: 'manualRenew', summary: '超管手动代续费与补录订单' },
|
||||
{ method: 'plansList', summary: 'SaaS 套餐方案矩阵列表' },
|
||||
{ method: 'plansUpdate', summary: '配置修改套餐规格与定价' },
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* 平台总后台超级管理员 (Platform Super Admin)
|
||||
* 专有控制中心:全网会员列表、续费流水、订阅管理与套餐策略
|
||||
* 路由前缀: /admin/team/platform
|
||||
*/
|
||||
@Provide()
|
||||
@CoolController(adminPlatformSubscriptionCurdOption)
|
||||
export class AdminPlatformSubscriptionController extends BaseController {
|
||||
@Inject()
|
||||
ctx: Context;
|
||||
|
||||
@Inject()
|
||||
platformSubscriptionService: PlatformSubscriptionService;
|
||||
|
||||
/**
|
||||
* 平台概览 KPI
|
||||
* 路由: POST /admin/team/platform/overview
|
||||
*/
|
||||
@Post('/overview', { summary: '平台会员与订阅整体概览' })
|
||||
async overview() {
|
||||
const result: PlatformOverviewResult =
|
||||
await this.platformSubscriptionService.getOverview();
|
||||
return this.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 会员列表分页检索
|
||||
* 路由: POST /admin/team/platform/subscribers/page
|
||||
*/
|
||||
@Post('/subscribers/page', { summary: '全网会员分页列表' })
|
||||
async subscribersPage(
|
||||
@Body()
|
||||
body?: {
|
||||
page?: number;
|
||||
size?: number;
|
||||
planType?: TenantPlanType;
|
||||
status?: string;
|
||||
keyword?: string;
|
||||
}
|
||||
) {
|
||||
const result = await this.platformSubscriptionService.getSubscribers(body);
|
||||
return this.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 超管修改会员信息 (套餐、配额、延期、状态)
|
||||
* 路由: POST /admin/team/platform/subscribers/update
|
||||
*/
|
||||
@Post('/subscribers/update', { summary: '超管修改会员套餐/配额/延期' })
|
||||
async subscribersUpdate(@Body() body: UpdateSubscriberDTO) {
|
||||
const result = await this.platformSubscriptionService.updateSubscriber(body);
|
||||
return this.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 续费订单流水列表
|
||||
* 路由: POST /admin/team/platform/renewals/page
|
||||
*/
|
||||
@Post('/renewals/page', { summary: '全网续费流水列表' })
|
||||
async renewalsPage(
|
||||
@Body()
|
||||
body?: {
|
||||
page?: number;
|
||||
size?: number;
|
||||
status?: string;
|
||||
keyword?: string;
|
||||
}
|
||||
) {
|
||||
const result = await this.platformSubscriptionService.getRenewals(body);
|
||||
return this.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 超管手动代续费
|
||||
* 路由: POST /admin/team/platform/renewals/manual-renew
|
||||
*/
|
||||
@Post('/renewals/manual-renew', { summary: '超管手动代续费与补录订单' })
|
||||
async manualRenew(@Body() body: ManualRenewDTO) {
|
||||
const result = await this.platformSubscriptionService.manualRenew(body);
|
||||
return this.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 套餐规格列表
|
||||
* 路由: POST /admin/team/platform/plans/list
|
||||
*/
|
||||
@Post('/plans/list', { summary: 'SaaS 套餐方案矩阵列表' })
|
||||
async plansList() {
|
||||
const result = await this.platformSubscriptionService.getPlans();
|
||||
return this.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新套餐规格
|
||||
* 路由: POST /admin/team/platform/plans/update
|
||||
*/
|
||||
@Post('/plans/update', { summary: '配置修改套餐规格与定价' })
|
||||
async plansUpdate(
|
||||
@Body()
|
||||
body: {
|
||||
planType: TenantPlanType;
|
||||
params: Partial<PlanConfigItem>;
|
||||
}
|
||||
) {
|
||||
const result = await this.platformSubscriptionService.updatePlan(
|
||||
body.planType,
|
||||
body.params
|
||||
);
|
||||
return this.ok(result);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Provide } from '@midwayjs/core';
|
||||
import { BaseService } from '@cool-midway/core';
|
||||
|
||||
export type SupportedCurrency = 'USD' | 'AED' | 'EUR' | 'GBP' | 'JPY' | 'CNY';
|
||||
import { SupportedCurrency, TaxCalculationResult } from '../../../comm/types/omni';
|
||||
|
||||
/**
|
||||
* 相对 USD 的基准出海商业结算汇率表
|
||||
@@ -32,14 +31,6 @@ export interface TaxCalculationParams {
|
||||
vatNumber?: string;
|
||||
}
|
||||
|
||||
export interface TaxCalculationResult {
|
||||
taxRate: number;
|
||||
taxAmount: number;
|
||||
totalAmount: number;
|
||||
taxName: string;
|
||||
isReverseCharge?: boolean;
|
||||
}
|
||||
|
||||
export interface InvoiceTaxSummary {
|
||||
currency: SupportedCurrency;
|
||||
currencySymbol: string;
|
||||
|
||||
@@ -19,6 +19,8 @@ const CURRENCY_SYMBOLS: Record<SupportedCurrency, string> = {
|
||||
EUR: '€',
|
||||
GBP: '£',
|
||||
CNY: '¥',
|
||||
AED: 'AED ',
|
||||
JPY: '¥',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,514 @@
|
||||
import { Provide } from '@midwayjs/core';
|
||||
import { BaseService } from '@cool-midway/core';
|
||||
import { InjectEntityModel } from '@midwayjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import * as moment from 'moment';
|
||||
import { TeamInvoiceEntity } from '../entity/invoice';
|
||||
import { TeamSubscriptionEntity } from '../entity/subscription';
|
||||
import {
|
||||
ManualRenewDTO,
|
||||
PlanConfigItem,
|
||||
PlatformOverviewResult,
|
||||
RenewalOrderItem,
|
||||
SubscriberItem,
|
||||
TenantPlanType,
|
||||
UpdateSubscriberDTO,
|
||||
} from '../../../comm/types/omni';
|
||||
|
||||
/**
|
||||
* 平台总后台超级管理员 (Platform Super Admin)
|
||||
* 会员、续费与订阅全周期运营管控服务
|
||||
*/
|
||||
@Provide()
|
||||
export class PlatformSubscriptionService extends BaseService {
|
||||
@InjectEntityModel(TeamInvoiceEntity)
|
||||
teamInvoiceEntity?: Repository<TeamInvoiceEntity>;
|
||||
|
||||
@InjectEntityModel(TeamSubscriptionEntity)
|
||||
teamSubscriptionEntity?: Repository<TeamSubscriptionEntity>;
|
||||
|
||||
// 内存化种子会员数据池 (保障无缝离线测试与即席演示)
|
||||
private subscribersStore: SubscriberItem[] = [
|
||||
{
|
||||
id: 1,
|
||||
tenantId: 1,
|
||||
tenantName: 'Vanguard Global Trade Ltd. (出海先锋外贸)',
|
||||
adminName: 'David Zhang',
|
||||
adminEmail: 'david@vanguard-trade.com',
|
||||
adminPhone: '+86 13800138001',
|
||||
planType: 'enterprise',
|
||||
status: 'active',
|
||||
cardsUsed: 42,
|
||||
cardsLimit: 100,
|
||||
seatsUsed: 18,
|
||||
seatsLimit: 50,
|
||||
totalPaidAmount: 7176,
|
||||
currency: 'USD',
|
||||
startDate: '2025-09-01',
|
||||
expireDate: '2026-09-01',
|
||||
createdAt: '2025-08-28 10:20:00',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
tenantId: 2,
|
||||
tenantName: 'Berlin Precision Robotics GmbH (柏林精密机器人)',
|
||||
adminName: 'Klaus Schmidt',
|
||||
adminEmail: 'klaus@berlin-robotics.de',
|
||||
adminPhone: '+49 30 1234567',
|
||||
planType: 'pro',
|
||||
status: 'active',
|
||||
cardsUsed: 5,
|
||||
cardsLimit: 10,
|
||||
seatsUsed: 4,
|
||||
seatsLimit: 5,
|
||||
totalPaidAmount: 1176,
|
||||
currency: 'EUR',
|
||||
startDate: '2026-01-15',
|
||||
expireDate: '2027-01-15',
|
||||
createdAt: '2026-01-10 14:35:12',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
tenantId: 3,
|
||||
tenantName: 'Dubai Apex Logistics LLC (迪拜巅峰国际物流)',
|
||||
adminName: 'Ahmed Al-Mansoor',
|
||||
adminEmail: 'ahmed@apexlogistics.ae',
|
||||
adminPhone: '+971 4 9876543',
|
||||
planType: 'enterprise',
|
||||
status: 'active',
|
||||
cardsUsed: 35,
|
||||
cardsLimit: 100,
|
||||
seatsUsed: 12,
|
||||
seatsLimit: 50,
|
||||
totalPaidAmount: 3588,
|
||||
currency: 'USD',
|
||||
startDate: '2026-03-01',
|
||||
expireDate: '2027-03-01',
|
||||
createdAt: '2026-02-25 09:12:44',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
tenantId: 4,
|
||||
tenantName: 'Silicon Horizon Tech Inc. (硅谷地平线科技)',
|
||||
adminName: 'Sarah Jenkins',
|
||||
adminEmail: 'sarah@siliconhorizon.io',
|
||||
adminPhone: '+1 415 8009988',
|
||||
planType: 'pro',
|
||||
status: 'past_due',
|
||||
cardsUsed: 8,
|
||||
cardsLimit: 10,
|
||||
seatsUsed: 5,
|
||||
seatsLimit: 5,
|
||||
totalPaidAmount: 588,
|
||||
currency: 'USD',
|
||||
startDate: '2025-09-10',
|
||||
expireDate: '2026-09-10',
|
||||
createdAt: '2025-09-05 16:40:22',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
tenantId: 5,
|
||||
tenantName: 'Nordic Clean Energy AB (北欧洁能科技)',
|
||||
adminName: 'Erik Lindqvist',
|
||||
adminEmail: 'erik@nordic-energy.se',
|
||||
adminPhone: '+46 8 5556677',
|
||||
planType: 'pro',
|
||||
status: 'active',
|
||||
cardsUsed: 4,
|
||||
cardsLimit: 10,
|
||||
seatsUsed: 3,
|
||||
seatsLimit: 5,
|
||||
totalPaidAmount: 1176,
|
||||
currency: 'EUR',
|
||||
startDate: '2025-11-20',
|
||||
expireDate: '2026-11-20',
|
||||
createdAt: '2025-11-15 11:20:00',
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
tenantId: 6,
|
||||
tenantName: 'Tokyo Smart Automation KK (东京智能物联)',
|
||||
adminName: 'Kenji Sato',
|
||||
adminEmail: 'kenji@tokyo-smart.jp',
|
||||
adminPhone: '+81 3 33445566',
|
||||
planType: 'free',
|
||||
status: 'active',
|
||||
cardsUsed: 1,
|
||||
cardsLimit: 1,
|
||||
seatsUsed: 1,
|
||||
seatsLimit: 1,
|
||||
totalPaidAmount: 0,
|
||||
currency: 'USD',
|
||||
startDate: '2026-08-01',
|
||||
expireDate: '2099-12-31',
|
||||
createdAt: '2026-08-01 10:00:00',
|
||||
},
|
||||
];
|
||||
|
||||
// 内存化种子续费流水池
|
||||
private renewalsStore: RenewalOrderItem[] = [
|
||||
{
|
||||
id: 1,
|
||||
orderNo: 'ORD-20260921-0089',
|
||||
tenantId: 1,
|
||||
tenantName: 'Vanguard Global Trade Ltd.',
|
||||
planType: 'enterprise',
|
||||
billingCycle: 'yearly',
|
||||
amount: 3588,
|
||||
currency: 'USD',
|
||||
channel: 'stripe',
|
||||
status: 'paid',
|
||||
paidAt: '2026-09-21 00:15:20',
|
||||
createdAt: '2026-09-21 00:15:10',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
orderNo: 'ORD-20260920-0088',
|
||||
tenantId: 2,
|
||||
tenantName: 'Berlin Precision Robotics GmbH',
|
||||
planType: 'pro',
|
||||
billingCycle: 'yearly',
|
||||
amount: 588,
|
||||
currency: 'EUR',
|
||||
channel: 'stripe',
|
||||
status: 'paid',
|
||||
paidAt: '2026-09-20 18:42:10',
|
||||
createdAt: '2026-09-20 18:42:00',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
orderNo: 'ORD-20260920-0087',
|
||||
tenantId: 3,
|
||||
tenantName: 'Dubai Apex Logistics LLC',
|
||||
planType: 'enterprise',
|
||||
billingCycle: 'monthly',
|
||||
amount: 299,
|
||||
currency: 'USD',
|
||||
channel: 'wire_transfer',
|
||||
status: 'paid',
|
||||
paidAt: '2026-09-20 14:22:05',
|
||||
createdAt: '2026-09-20 14:10:00',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
orderNo: 'ORD-20260919-0086',
|
||||
tenantId: 4,
|
||||
tenantName: 'Silicon Horizon Tech Inc.',
|
||||
planType: 'pro',
|
||||
billingCycle: 'monthly',
|
||||
amount: 49,
|
||||
currency: 'USD',
|
||||
channel: 'stripe',
|
||||
status: 'pending',
|
||||
paidAt: '',
|
||||
createdAt: '2026-09-19 22:11:34',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
orderNo: 'ORD-20260919-0085',
|
||||
tenantId: 5,
|
||||
tenantName: 'Nordic Clean Energy AB',
|
||||
planType: 'pro',
|
||||
billingCycle: 'yearly',
|
||||
amount: 588,
|
||||
currency: 'EUR',
|
||||
channel: 'manual_admin',
|
||||
status: 'paid',
|
||||
paidAt: '2026-09-19 11:05:49',
|
||||
createdAt: '2026-09-19 11:05:00',
|
||||
},
|
||||
];
|
||||
|
||||
// SaaS 套餐规格与定价方案配置
|
||||
private planConfigs: PlanConfigItem[] = [
|
||||
{
|
||||
planType: 'free',
|
||||
name: 'Free 基础体验版',
|
||||
description: '适合个人自由职业者与初创独立人,轻量体验出海名片微官网',
|
||||
monthlyPrice: 0,
|
||||
yearlyPrice: 0,
|
||||
cardsLimit: 1,
|
||||
seatsLimit: 1,
|
||||
aiTokensPerMonth: 5000,
|
||||
nfcDynamicRouting: false,
|
||||
supportCustomDomain: false,
|
||||
crmLeadLimit: 50,
|
||||
isPopular: false,
|
||||
},
|
||||
{
|
||||
planType: 'pro',
|
||||
name: 'Pro 专业商务版',
|
||||
description: '面向出海业务精英、外贸销售主管与跨国顾问的高性能全功能套件',
|
||||
monthlyPrice: 49,
|
||||
yearlyPrice: 490,
|
||||
cardsLimit: 10,
|
||||
seatsLimit: 5,
|
||||
aiTokensPerMonth: 200000,
|
||||
nfcDynamicRouting: true,
|
||||
supportCustomDomain: true,
|
||||
crmLeadLimit: 2000,
|
||||
isPopular: true,
|
||||
},
|
||||
{
|
||||
planType: 'enterprise',
|
||||
name: 'Enterprise 商业企业版',
|
||||
description: '为出海跨国集团、参展销售天团定制的多租户公海池与品牌统一管控',
|
||||
monthlyPrice: 299,
|
||||
yearlyPrice: 2990,
|
||||
cardsLimit: 100,
|
||||
seatsLimit: 50,
|
||||
aiTokensPerMonth: 1000000,
|
||||
nfcDynamicRouting: true,
|
||||
supportCustomDomain: true,
|
||||
crmLeadLimit: 50000,
|
||||
isPopular: false,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 1. 平台总览与核心 KPI 概览
|
||||
*/
|
||||
async getOverview(): Promise<PlatformOverviewResult> {
|
||||
const totalSubscribers = this.subscribersStore.length;
|
||||
const activeSubscribers = this.subscribersStore.filter(
|
||||
s => s.status === 'active' || s.status === 'trialing'
|
||||
).length;
|
||||
const proCount = this.subscribersStore.filter(s => s.planType === 'pro').length;
|
||||
const enterpriseCount = this.subscribersStore.filter(s => s.planType === 'enterprise').length;
|
||||
|
||||
// 即将到期判定 (当前日期后 30 天内到期)
|
||||
const now = moment();
|
||||
const expiringSoonCount = this.subscribersStore.filter(s => {
|
||||
if (s.planType === 'free') return false;
|
||||
const exp = moment(s.expireDate);
|
||||
const diffDays = exp.diff(now, 'days');
|
||||
return diffDays >= -5 && diffDays <= 30;
|
||||
}).length;
|
||||
|
||||
const totalRenewalRevenue = this.renewalsStore
|
||||
.filter(r => r.status === 'paid')
|
||||
.reduce((sum, r) => sum + Number(r.amount || 0), 0);
|
||||
|
||||
return {
|
||||
totalSubscribers,
|
||||
activeSubscribers,
|
||||
proCount,
|
||||
enterpriseCount,
|
||||
expiringSoonCount,
|
||||
totalRenewalRevenue,
|
||||
currency: 'USD',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 2. 分页及多条件查询全网会员列表
|
||||
*/
|
||||
async getSubscribers(query: {
|
||||
page?: number;
|
||||
size?: number;
|
||||
planType?: TenantPlanType;
|
||||
status?: string;
|
||||
keyword?: string;
|
||||
} = {}): Promise<{
|
||||
list: SubscriberItem[];
|
||||
pagination: { page: number; size: number; total: number };
|
||||
}> {
|
||||
const page = Number(query.page || 1);
|
||||
const size = Number(query.size || 20);
|
||||
|
||||
let filtered = [...this.subscribersStore];
|
||||
|
||||
if (query.planType) {
|
||||
filtered = filtered.filter(s => s.planType === query.planType);
|
||||
}
|
||||
if (query.status) {
|
||||
filtered = filtered.filter(s => s.status === query.status);
|
||||
}
|
||||
if (query.keyword) {
|
||||
const kw = query.keyword.toLowerCase().trim();
|
||||
filtered = filtered.filter(
|
||||
s =>
|
||||
String(s.tenantId).includes(kw) ||
|
||||
s.tenantName.toLowerCase().includes(kw) ||
|
||||
s.adminEmail.toLowerCase().includes(kw) ||
|
||||
s.adminName.toLowerCase().includes(kw)
|
||||
);
|
||||
}
|
||||
|
||||
const total = filtered.length;
|
||||
const start = (page - 1) * size;
|
||||
const list = filtered.slice(start, start + size).map(item => ({ ...item }));
|
||||
|
||||
return {
|
||||
list,
|
||||
pagination: {
|
||||
page,
|
||||
size,
|
||||
total,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 3. 超级管理员修改会员信息 (套餐、配额、延期、状态)
|
||||
*/
|
||||
async updateSubscriber(dto: UpdateSubscriberDTO): Promise<SubscriberItem> {
|
||||
const item = this.subscribersStore.find(s => s.tenantId === Number(dto.tenantId));
|
||||
if (!item) {
|
||||
throw new Error(`未找到租户 ID 为 ${dto.tenantId} 的会员`);
|
||||
}
|
||||
|
||||
if (dto.planType) {
|
||||
item.planType = dto.planType;
|
||||
// 联动规格默认配额
|
||||
const targetPlan = this.planConfigs.find(p => p.planType === dto.planType);
|
||||
if (targetPlan) {
|
||||
item.cardsLimit = targetPlan.cardsLimit;
|
||||
item.seatsLimit = targetPlan.seatsLimit;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof dto.cardsLimit === 'number') {
|
||||
item.cardsLimit = dto.cardsLimit;
|
||||
}
|
||||
if (typeof dto.seatsLimit === 'number') {
|
||||
item.seatsLimit = dto.seatsLimit;
|
||||
}
|
||||
if (dto.status) {
|
||||
item.status = dto.status;
|
||||
}
|
||||
|
||||
if (dto.extendDays && dto.extendDays > 0) {
|
||||
const baseDate = moment(item.expireDate).isValid()
|
||||
? moment(item.expireDate)
|
||||
: moment();
|
||||
item.expireDate = baseDate.add(dto.extendDays, 'days').format('YYYY-MM-DD');
|
||||
item.status = 'active';
|
||||
} else if (dto.expireDate) {
|
||||
item.expireDate = dto.expireDate;
|
||||
}
|
||||
|
||||
return { ...item };
|
||||
}
|
||||
|
||||
/**
|
||||
* 4. 查询全网续费订单流水台账
|
||||
*/
|
||||
async getRenewals(query: {
|
||||
page?: number;
|
||||
size?: number;
|
||||
status?: string;
|
||||
keyword?: string;
|
||||
} = {}): Promise<{
|
||||
list: RenewalOrderItem[];
|
||||
pagination: { page: number; size: number; total: number };
|
||||
}> {
|
||||
const page = Number(query.page || 1);
|
||||
const size = Number(query.size || 20);
|
||||
|
||||
let filtered = [...this.renewalsStore];
|
||||
|
||||
if (query.status) {
|
||||
filtered = filtered.filter(r => r.status === query.status);
|
||||
}
|
||||
if (query.keyword) {
|
||||
const kw = query.keyword.toLowerCase().trim();
|
||||
filtered = filtered.filter(
|
||||
r =>
|
||||
r.orderNo.toLowerCase().includes(kw) ||
|
||||
r.tenantName.toLowerCase().includes(kw) ||
|
||||
String(r.tenantId).includes(kw)
|
||||
);
|
||||
}
|
||||
|
||||
const total = filtered.length;
|
||||
const start = (page - 1) * size;
|
||||
const list = filtered.slice(start, start + size);
|
||||
|
||||
return {
|
||||
list,
|
||||
pagination: {
|
||||
page,
|
||||
size,
|
||||
total,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 5. 超管手动为会员代续费 / 补录订单
|
||||
*/
|
||||
async manualRenew(dto: ManualRenewDTO): Promise<{
|
||||
success: boolean;
|
||||
orderNo: string;
|
||||
item: RenewalOrderItem;
|
||||
subscriber: SubscriberItem;
|
||||
}> {
|
||||
const subscriber = this.subscribersStore.find(s => s.tenantId === Number(dto.tenantId));
|
||||
if (!subscriber) {
|
||||
throw new Error(`租户 ${dto.tenantId} 不存在`);
|
||||
}
|
||||
|
||||
const orderNo = `ORD-${moment().format('YYYYMMDD')}-${Math.floor(
|
||||
1000 + Math.random() * 9000
|
||||
)}`;
|
||||
const nowStr = moment().format('YYYY-MM-DD HH:mm:ss');
|
||||
|
||||
const newOrder: RenewalOrderItem = {
|
||||
id: this.renewalsStore.length + 1,
|
||||
orderNo,
|
||||
tenantId: subscriber.tenantId,
|
||||
tenantName: subscriber.tenantName,
|
||||
planType: dto.planType,
|
||||
billingCycle: dto.billingCycle,
|
||||
amount: dto.amount,
|
||||
currency: dto.currency || 'USD',
|
||||
channel: dto.channel || 'manual_admin',
|
||||
status: 'paid',
|
||||
paidAt: nowStr,
|
||||
createdAt: nowStr,
|
||||
};
|
||||
|
||||
this.renewalsStore.unshift(newOrder);
|
||||
|
||||
// 更新会员期限和套餐
|
||||
subscriber.planType = dto.planType;
|
||||
const addDays = dto.billingCycle === 'yearly' ? 365 : 30;
|
||||
const baseExpire = moment(subscriber.expireDate).isAfter(moment())
|
||||
? moment(subscriber.expireDate)
|
||||
: moment();
|
||||
subscriber.expireDate = baseExpire.add(addDays, 'days').format('YYYY-MM-DD');
|
||||
subscriber.status = 'active';
|
||||
subscriber.totalPaidAmount = Number(subscriber.totalPaidAmount || 0) + Number(dto.amount);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
orderNo,
|
||||
item: newOrder,
|
||||
subscriber,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 6. SaaS 套餐定价规格列表
|
||||
*/
|
||||
async getPlans(): Promise<PlanConfigItem[]> {
|
||||
return [...this.planConfigs];
|
||||
}
|
||||
|
||||
/**
|
||||
* 7. 更新指定套餐定价与配额参数
|
||||
*/
|
||||
async updatePlan(
|
||||
planType: TenantPlanType,
|
||||
params: Partial<PlanConfigItem>
|
||||
): Promise<PlanConfigItem> {
|
||||
const plan = this.planConfigs.find(p => p.planType === planType);
|
||||
if (!plan) {
|
||||
throw new Error(`套餐类型 ${planType} 不存在`);
|
||||
}
|
||||
|
||||
Object.assign(plan, params);
|
||||
return plan;
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,8 @@ import {
|
||||
CurrencyTaxCalculatorService,
|
||||
convertCurrency,
|
||||
calculateInternationalTax,
|
||||
TaxCalculationResult,
|
||||
} from '../../../src/modules/team/service/currency-tax';
|
||||
import { TaxCalculationResult } from '../../../src/comm/types/omni';
|
||||
|
||||
describe('Point 57 (P1): 跨境多币种汇率换算与出海发票税制计算', () => {
|
||||
let service: CurrencyTaxCalculatorService;
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { PlatformSubscriptionService } from '../../../src/modules/team/service/platform-subscription';
|
||||
import { AdminPlatformSubscriptionController } from '../../../src/modules/team/controller/admin/platform';
|
||||
|
||||
describe('平台总后台超级管理员 - 会员、续费与订阅服务测试 (TDD)', () => {
|
||||
let service: PlatformSubscriptionService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new PlatformSubscriptionService();
|
||||
});
|
||||
|
||||
describe('1. 平台总览与指标概览', () => {
|
||||
it('getOverview 应返回全网会员数、付费会员数、预警会员数及总续费额', async () => {
|
||||
const overview = await service.getOverview();
|
||||
expect(overview).toBeDefined();
|
||||
expect(overview.totalSubscribers).toBeGreaterThan(0);
|
||||
expect(overview.activeSubscribers).toBeGreaterThan(0);
|
||||
expect(overview.proCount).toBeGreaterThanOrEqual(0);
|
||||
expect(overview.enterpriseCount).toBeGreaterThanOrEqual(0);
|
||||
expect(overview.totalRenewalRevenue).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('2. 会员列表管理 (Subscribers)', () => {
|
||||
it('getSubscribers 应支持分页查询与全量列表', async () => {
|
||||
const res = await service.getSubscribers({ page: 1, size: 10 });
|
||||
expect(res.list).toBeDefined();
|
||||
expect(Array.isArray(res.list)).toBe(true);
|
||||
expect(res.pagination.total).toBeGreaterThan(0);
|
||||
expect(res.list[0]).toHaveProperty('tenantName');
|
||||
expect(res.list[0]).toHaveProperty('planType');
|
||||
expect(res.list[0]).toHaveProperty('cardsLimit');
|
||||
expect(res.list[0]).toHaveProperty('seatsLimit');
|
||||
});
|
||||
|
||||
it('getSubscribers 应支持按套餐类型筛选 (pro / enterprise / free)', async () => {
|
||||
const proList = await service.getSubscribers({ planType: 'pro' });
|
||||
for (const item of proList.list) {
|
||||
expect(item.planType).toBe('pro');
|
||||
}
|
||||
});
|
||||
|
||||
it('getSubscribers 应支持按状态筛选 (active / trialing / past_due / canceled)', async () => {
|
||||
const activeList = await service.getSubscribers({ status: 'active' });
|
||||
for (const item of activeList.list) {
|
||||
expect(item.status).toBe('active');
|
||||
}
|
||||
});
|
||||
|
||||
it('getSubscribers 应支持按关键词搜索企业名称或邮箱', async () => {
|
||||
const all = await service.getSubscribers();
|
||||
const first = all.list[0];
|
||||
const searchRes = await service.getSubscribers({ keyword: first.adminEmail });
|
||||
expect(searchRes.list.some(s => s.tenantId === first.tenantId)).toBe(true);
|
||||
});
|
||||
|
||||
it('updateSubscriber 应支持超管修改会员套餐、配额与状态', async () => {
|
||||
const updated = await service.updateSubscriber({
|
||||
tenantId: 1,
|
||||
planType: 'enterprise',
|
||||
cardsLimit: 88,
|
||||
seatsLimit: 30,
|
||||
status: 'active',
|
||||
});
|
||||
expect(updated.planType).toBe('enterprise');
|
||||
expect(updated.cardsLimit).toBe(88);
|
||||
expect(updated.seatsLimit).toBe(30);
|
||||
});
|
||||
|
||||
it('updateSubscriber 应支持延长会员有效期 (extendDays)', async () => {
|
||||
const before = (await service.getSubscribers({ keyword: '1' })).list.find(s => s.tenantId === 1);
|
||||
const updated = await service.updateSubscriber({
|
||||
tenantId: 1,
|
||||
extendDays: 30,
|
||||
});
|
||||
expect(updated.expireDate).toBeDefined();
|
||||
if (before?.expireDate) {
|
||||
expect(new Date(updated.expireDate).getTime()).toBeGreaterThan(new Date(before.expireDate).getTime());
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('3. 续费流水管理与手动代续费 (Renewals)', () => {
|
||||
it('getRenewals 应返回全网订单续费流水', async () => {
|
||||
const res = await service.getRenewals({ page: 1, size: 10 });
|
||||
expect(res.list).toBeDefined();
|
||||
expect(Array.isArray(res.list)).toBe(true);
|
||||
expect(res.list.length).toBeGreaterThan(0);
|
||||
expect(res.list[0]).toHaveProperty('orderNo');
|
||||
expect(res.list[0]).toHaveProperty('amount');
|
||||
expect(res.list[0]).toHaveProperty('channel');
|
||||
});
|
||||
|
||||
it('manualRenew 应允许超级管理员手动为会员办理续费并生成流水与延期', async () => {
|
||||
const renewRes = await service.manualRenew({
|
||||
tenantId: 2,
|
||||
planType: 'pro',
|
||||
billingCycle: 'yearly',
|
||||
amount: 588,
|
||||
currency: 'USD',
|
||||
channel: 'manual_admin',
|
||||
note: '总后台超级管理员代续费测试',
|
||||
});
|
||||
expect(renewRes.success).toBe(true);
|
||||
expect(renewRes.orderNo).toMatch(/^ORD-/);
|
||||
|
||||
// 验证会员列表里的到期时间与套餐已相应更新
|
||||
const sub = (await service.getSubscribers({ keyword: '2' })).list.find(s => s.tenantId === 2);
|
||||
expect(sub?.planType).toBe('pro');
|
||||
});
|
||||
});
|
||||
|
||||
describe('4. SaaS 套餐定价与规格矩阵配置 (Plans)', () => {
|
||||
it('getPlans 应返回 Free, Pro, Enterprise 套餐规格', async () => {
|
||||
const plans = await service.getPlans();
|
||||
expect(plans.length).toBe(3);
|
||||
expect(plans.map(p => p.planType)).toEqual(expect.arrayContaining(['free', 'pro', 'enterprise']));
|
||||
});
|
||||
|
||||
it('updatePlan 应支持更新套餐定价与限额', async () => {
|
||||
const updated = await service.updatePlan('pro', {
|
||||
monthlyPrice: 59,
|
||||
yearlyPrice: 599,
|
||||
cardsLimit: 15,
|
||||
});
|
||||
expect(updated.monthlyPrice).toBe(59);
|
||||
expect(updated.yearlyPrice).toBe(599);
|
||||
expect(updated.cardsLimit).toBe(15);
|
||||
});
|
||||
});
|
||||
|
||||
describe('5. 控制器路由与契约完整性', () => {
|
||||
it('AdminPlatformSubscriptionController 必须正确导出且声明各总控方法', () => {
|
||||
const controller = new AdminPlatformSubscriptionController();
|
||||
expect(controller).toBeDefined();
|
||||
expect(typeof controller.overview).toBe('function');
|
||||
expect(typeof controller.subscribersPage).toBe('function');
|
||||
expect(typeof controller.subscribersUpdate).toBe('function');
|
||||
expect(typeof controller.renewalsPage).toBe('function');
|
||||
expect(typeof controller.manualRenew).toBe('function');
|
||||
expect(typeof controller.plansList).toBe('function');
|
||||
expect(typeof controller.plansUpdate).toBe('function');
|
||||
});
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户