feat: 同步管理端、用户端与后端业务,并接通线上 Web 接口
补齐平台订阅、外部登录、CRM 同步与用户端页面;Nginx 转发用户端接口,登录页使用 Google 与 Apple 标志。 Co-authored-by: Cursor <cursoragent@cursor.com>
这个提交包含在:
@@ -1,5 +1,172 @@
|
||||
import { PlatformSubscriptionService } from '../../../src/modules/team/service/platform-subscription';
|
||||
import { AdminPlatformSubscriptionController } from '../../../src/modules/team/controller/admin/platform';
|
||||
import { TeamSubscriptionEntity } from '../../../src/modules/team/entity/subscription';
|
||||
import { TeamInvoiceEntity } from '../../../src/modules/team/entity/invoice';
|
||||
import {
|
||||
RenewalOrderItem,
|
||||
SubscriberItem,
|
||||
} from '../../../src/comm/types/omni';
|
||||
|
||||
/** 纯内存单测用样例会员(非生产种子) */
|
||||
function memorySubscribers(): SubscriberItem[] {
|
||||
return [
|
||||
{
|
||||
id: 1,
|
||||
tenantId: 1,
|
||||
tenantName: 'Acme Memory Tenant',
|
||||
adminName: 'Alice Admin',
|
||||
adminEmail: 'alice@acme.test',
|
||||
adminPhone: '+1 555 0100',
|
||||
planType: 'enterprise',
|
||||
status: 'active',
|
||||
cardsUsed: 2,
|
||||
cardsLimit: 100,
|
||||
seatsUsed: 1,
|
||||
seatsLimit: 50,
|
||||
totalPaidAmount: 1000,
|
||||
currency: 'USD',
|
||||
startDate: '2025-09-01',
|
||||
expireDate: '2026-09-01',
|
||||
createdAt: '2025-08-28 10:20:00',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
tenantId: 2,
|
||||
tenantName: 'Beta Memory Co',
|
||||
adminName: 'Bob Manager',
|
||||
adminEmail: 'bob@beta.test',
|
||||
planType: 'pro',
|
||||
status: 'active',
|
||||
cardsUsed: 3,
|
||||
cardsLimit: 10,
|
||||
seatsUsed: 2,
|
||||
seatsLimit: 5,
|
||||
totalPaidAmount: 588,
|
||||
currency: 'USD',
|
||||
startDate: '2026-01-15',
|
||||
expireDate: '2027-01-15',
|
||||
createdAt: '2026-01-10 14:35:12',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
tenantId: 3,
|
||||
tenantName: 'Gamma Free Org',
|
||||
adminName: 'Carol Free',
|
||||
adminEmail: 'carol@gamma.test',
|
||||
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',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function memoryRenewals(): RenewalOrderItem[] {
|
||||
return [
|
||||
{
|
||||
id: 1,
|
||||
orderNo: 'ORD-MEM-0001',
|
||||
tenantId: 1,
|
||||
tenantName: 'Acme Memory Tenant',
|
||||
planType: 'enterprise',
|
||||
billingCycle: 'yearly',
|
||||
amount: 2990,
|
||||
currency: 'USD',
|
||||
channel: 'stripe',
|
||||
status: 'paid',
|
||||
paidAt: '2026-09-21 00:15:20',
|
||||
createdAt: '2026-09-21 00:15:10',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
orderNo: 'ORD-MEM-0002',
|
||||
tenantId: 2,
|
||||
tenantName: 'Beta Memory Co',
|
||||
planType: 'pro',
|
||||
billingCycle: 'yearly',
|
||||
amount: 490,
|
||||
currency: 'USD',
|
||||
channel: 'manual_admin',
|
||||
status: 'paid',
|
||||
paidAt: '2026-09-20 18:42:10',
|
||||
createdAt: '2026-09-20 18:42:00',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function seedMemory(service: PlatformSubscriptionService) {
|
||||
(service as unknown as { subscribersStore: SubscriberItem[] }).subscribersStore =
|
||||
memorySubscribers();
|
||||
(service as unknown as { renewalsStore: RenewalOrderItem[] }).renewalsStore =
|
||||
memoryRenewals();
|
||||
}
|
||||
|
||||
function createMockSubRepo(rows: Partial<TeamSubscriptionEntity>[] = []) {
|
||||
const list = rows.map((r, i) => ({
|
||||
id: r.id ?? i + 1,
|
||||
tenantId: r.tenantId ?? i + 1,
|
||||
planType: r.planType ?? 'free',
|
||||
billingCycle: r.billingCycle ?? 'monthly',
|
||||
status: r.status ?? 'active',
|
||||
currentPeriodEnd: r.currentPeriodEnd ?? null,
|
||||
cancelAtPeriodEnd: r.cancelAtPeriodEnd ?? false,
|
||||
createTime: r.createTime ?? '2026-01-01 00:00:00',
|
||||
updateTime: r.updateTime ?? '2026-01-01 00:00:00',
|
||||
...r,
|
||||
})) as TeamSubscriptionEntity[];
|
||||
|
||||
return {
|
||||
find: jest.fn(async () => [...list]),
|
||||
findOne: jest.fn(async (opts?: { where?: { tenantId?: number } }) => {
|
||||
const tid = opts?.where?.tenantId;
|
||||
if (tid === undefined) return null;
|
||||
return list.find(s => Number(s.tenantId) === Number(tid)) || null;
|
||||
}),
|
||||
save: jest.fn(async (entity: TeamSubscriptionEntity) => {
|
||||
const idx = list.findIndex(s => Number(s.tenantId) === Number(entity.tenantId));
|
||||
if (idx >= 0) {
|
||||
Object.assign(list[idx], entity);
|
||||
return list[idx];
|
||||
}
|
||||
list.push(entity);
|
||||
return entity;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function createMockInvoiceRepo(rows: Partial<TeamInvoiceEntity>[] = []) {
|
||||
const list = rows.map((r, i) => ({
|
||||
id: r.id ?? i + 1,
|
||||
tenantId: r.tenantId ?? i + 1,
|
||||
invoiceNo: r.invoiceNo ?? `INV-${i + 1}`,
|
||||
orderId: r.orderId ?? null,
|
||||
currency: r.currency ?? 'USD',
|
||||
currencySymbol: r.currencySymbol ?? '$',
|
||||
subtotal: r.subtotal ?? 0,
|
||||
taxRate: r.taxRate ?? 0,
|
||||
taxAmount: r.taxAmount ?? 0,
|
||||
totalAmount: r.totalAmount ?? 0,
|
||||
title: r.title ?? 'Subscription',
|
||||
status: r.status ?? 'paid',
|
||||
paidAt: r.paidAt ?? null,
|
||||
createTime: r.createTime ?? '2026-01-01 00:00:00',
|
||||
updateTime: r.updateTime ?? '2026-01-01 00:00:00',
|
||||
...r,
|
||||
})) as TeamInvoiceEntity[];
|
||||
|
||||
return {
|
||||
find: jest.fn(async () => [...list]),
|
||||
findOne: jest.fn(async () => null),
|
||||
save: jest.fn(async (entity: TeamInvoiceEntity) => entity),
|
||||
};
|
||||
}
|
||||
|
||||
describe('平台总后台超级管理员 - 会员、续费与订阅服务测试 (TDD)', () => {
|
||||
let service: PlatformSubscriptionService;
|
||||
@@ -8,8 +175,122 @@ describe('平台总后台超级管理员 - 会员、续费与订阅服务测试
|
||||
service = new PlatformSubscriptionService();
|
||||
});
|
||||
|
||||
describe('0. 有仓库时只读库,空库不回退种子', () => {
|
||||
it('注入空订阅仓库时 getSubscribers / getOverview 返回空列表与 0,不含 Vanguard/Dubai', async () => {
|
||||
service.teamSubscriptionEntity = createMockSubRepo(
|
||||
[]
|
||||
) as unknown as PlatformSubscriptionService['teamSubscriptionEntity'];
|
||||
|
||||
const overview = await service.getOverview();
|
||||
expect(overview.totalSubscribers).toBe(0);
|
||||
expect(overview.activeSubscribers).toBe(0);
|
||||
expect(overview.proCount).toBe(0);
|
||||
expect(overview.enterpriseCount).toBe(0);
|
||||
expect(overview.expiringSoonCount).toBe(0);
|
||||
expect(overview.totalRenewalRevenue).toBe(0);
|
||||
|
||||
const res = await service.getSubscribers();
|
||||
expect(res.list).toEqual([]);
|
||||
expect(res.pagination.total).toBe(0);
|
||||
const blob = JSON.stringify({ overview, res });
|
||||
expect(blob).not.toMatch(/Vanguard/i);
|
||||
expect(blob).not.toMatch(/Dubai/i);
|
||||
expect(blob).not.toMatch(/Berlin Precision/i);
|
||||
});
|
||||
|
||||
it('注入空发票仓库时 getRenewals 返回空列表,不含种子租户名', async () => {
|
||||
service.teamInvoiceEntity = createMockInvoiceRepo(
|
||||
[]
|
||||
) as unknown as PlatformSubscriptionService['teamInvoiceEntity'];
|
||||
|
||||
const res = await service.getRenewals();
|
||||
expect(res.list).toEqual([]);
|
||||
expect(res.pagination.total).toBe(0);
|
||||
expect(JSON.stringify(res)).not.toMatch(/Vanguard|Dubai|Berlin Precision/i);
|
||||
});
|
||||
|
||||
it('有仓库时从库映射会员与续费,不混入内存种子', async () => {
|
||||
service.teamSubscriptionEntity = createMockSubRepo([
|
||||
{
|
||||
id: 10,
|
||||
tenantId: 88,
|
||||
planType: 'pro',
|
||||
status: 'active',
|
||||
currentPeriodEnd: '2027-06-01',
|
||||
createTime: '2026-01-01 12:00:00' as unknown as Date,
|
||||
},
|
||||
]) as unknown as PlatformSubscriptionService['teamSubscriptionEntity'];
|
||||
service.teamInvoiceEntity = createMockInvoiceRepo([
|
||||
{
|
||||
id: 20,
|
||||
tenantId: 88,
|
||||
invoiceNo: 'INV-DB-88',
|
||||
totalAmount: 490,
|
||||
currency: 'USD',
|
||||
status: 'paid',
|
||||
paidAt: '2026-09-01 10:00:00',
|
||||
title: 'Pro yearly',
|
||||
},
|
||||
]) as unknown as PlatformSubscriptionService['teamInvoiceEntity'];
|
||||
|
||||
const subs = await service.getSubscribers();
|
||||
expect(subs.pagination.total).toBe(1);
|
||||
expect(subs.list[0].tenantId).toBe(88);
|
||||
expect(subs.list[0].planType).toBe('pro');
|
||||
expect(JSON.stringify(subs)).not.toMatch(/Vanguard|Dubai/i);
|
||||
|
||||
const renewals = await service.getRenewals();
|
||||
expect(renewals.pagination.total).toBe(1);
|
||||
expect(renewals.list[0].orderNo).toBe('INV-DB-88');
|
||||
expect(renewals.list[0].amount).toBe(490);
|
||||
|
||||
const overview = await service.getOverview();
|
||||
expect(overview.totalSubscribers).toBe(1);
|
||||
expect(overview.proCount).toBe(1);
|
||||
expect(overview.totalRenewalRevenue).toBe(490);
|
||||
});
|
||||
|
||||
it('有订阅仓库但找不到租户时 manualRenew 必须报错,不得造成功续费', async () => {
|
||||
service.teamSubscriptionEntity = createMockSubRepo(
|
||||
[]
|
||||
) as unknown as PlatformSubscriptionService['teamSubscriptionEntity'];
|
||||
|
||||
await expect(
|
||||
service.manualRenew({
|
||||
tenantId: 9999,
|
||||
planType: 'pro',
|
||||
billingCycle: 'monthly',
|
||||
amount: 49,
|
||||
})
|
||||
).rejects.toThrow(/9999|不存在|未找到/);
|
||||
});
|
||||
|
||||
it('有仓库时即便内存已灌数也只读库,不回退内存种子', async () => {
|
||||
seedMemory(service);
|
||||
service.teamSubscriptionEntity = createMockSubRepo(
|
||||
[]
|
||||
) as unknown as PlatformSubscriptionService['teamSubscriptionEntity'];
|
||||
service.teamInvoiceEntity = createMockInvoiceRepo(
|
||||
[]
|
||||
) as unknown as PlatformSubscriptionService['teamInvoiceEntity'];
|
||||
|
||||
const subs = await service.getSubscribers();
|
||||
const renewals = await service.getRenewals();
|
||||
const overview = await service.getOverview();
|
||||
|
||||
expect(subs.list).toEqual([]);
|
||||
expect(renewals.list).toEqual([]);
|
||||
expect(overview.totalSubscribers).toBe(0);
|
||||
expect(overview.totalRenewalRevenue).toBe(0);
|
||||
expect(JSON.stringify({ subs, renewals, overview })).not.toMatch(
|
||||
/Acme Memory|Vanguard|Dubai/i
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('1. 平台总览与指标概览', () => {
|
||||
it('getOverview 应返回全网会员数、付费会员数、预警会员数及总续费额', async () => {
|
||||
it('无仓库时 getOverview 基于内存数据返回指标', async () => {
|
||||
seedMemory(service);
|
||||
const overview = await service.getOverview();
|
||||
expect(overview).toBeDefined();
|
||||
expect(overview.totalSubscribers).toBeGreaterThan(0);
|
||||
@@ -18,9 +299,21 @@ describe('平台总后台超级管理员 - 会员、续费与订阅服务测试
|
||||
expect(overview.enterpriseCount).toBeGreaterThanOrEqual(0);
|
||||
expect(overview.totalRenewalRevenue).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('无仓库且内存为空时 overview 全为 0', async () => {
|
||||
const overview = await service.getOverview();
|
||||
expect(overview.totalSubscribers).toBe(0);
|
||||
expect(overview.activeSubscribers).toBe(0);
|
||||
expect(overview.totalRenewalRevenue).toBe(0);
|
||||
expect(JSON.stringify(overview)).not.toMatch(/Vanguard|Dubai/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('2. 会员列表管理 (Subscribers)', () => {
|
||||
beforeEach(() => {
|
||||
seedMemory(service);
|
||||
});
|
||||
|
||||
it('getSubscribers 应支持分页查询与全量列表', async () => {
|
||||
const res = await service.getSubscribers({ page: 1, size: 10 });
|
||||
expect(res.list).toBeDefined();
|
||||
@@ -67,19 +360,33 @@ describe('平台总后台超级管理员 - 会员、续费与订阅服务测试
|
||||
});
|
||||
|
||||
it('updateSubscriber 应支持延长会员有效期 (extendDays)', async () => {
|
||||
const before = (await service.getSubscribers({ keyword: '1' })).list.find(s => s.tenantId === 1);
|
||||
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());
|
||||
expect(new Date(updated.expireDate).getTime()).toBeGreaterThan(
|
||||
new Date(before.expireDate).getTime()
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('updateSubscriber 找不到租户时应报错', async () => {
|
||||
await expect(
|
||||
service.updateSubscriber({ tenantId: 40404, status: 'canceled' })
|
||||
).rejects.toThrow(/40404|未找到/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('3. 续费流水管理与手动代续费 (Renewals)', () => {
|
||||
beforeEach(() => {
|
||||
seedMemory(service);
|
||||
});
|
||||
|
||||
it('getRenewals 应返回全网订单续费流水', async () => {
|
||||
const res = await service.getRenewals({ page: 1, size: 10 });
|
||||
expect(res.list).toBeDefined();
|
||||
@@ -103,17 +410,31 @@ describe('平台总后台超级管理员 - 会员、续费与订阅服务测试
|
||||
expect(renewRes.success).toBe(true);
|
||||
expect(renewRes.orderNo).toMatch(/^ORD-/);
|
||||
|
||||
// 验证会员列表里的到期时间与套餐已相应更新
|
||||
const sub = (await service.getSubscribers({ keyword: '2' })).list.find(s => s.tenantId === 2);
|
||||
const sub = (await service.getSubscribers({ keyword: '2' })).list.find(
|
||||
s => s.tenantId === 2
|
||||
);
|
||||
expect(sub?.planType).toBe('pro');
|
||||
});
|
||||
|
||||
it('manualRenew 找不到租户时必须报错,不得返回成功', async () => {
|
||||
await expect(
|
||||
service.manualRenew({
|
||||
tenantId: 7777,
|
||||
planType: 'pro',
|
||||
billingCycle: 'monthly',
|
||||
amount: 49,
|
||||
})
|
||||
).rejects.toThrow(/7777|不存在|未找到/);
|
||||
});
|
||||
});
|
||||
|
||||
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']));
|
||||
expect(plans.map(p => p.planType)).toEqual(
|
||||
expect.arrayContaining(['free', 'pro', 'enterprise'])
|
||||
);
|
||||
});
|
||||
|
||||
it('updatePlan 应支持更新套餐定价与限额', async () => {
|
||||
@@ -141,4 +462,36 @@ describe('平台总后台超级管理员 - 会员、续费与订阅服务测试
|
||||
expect(typeof controller.plansUpdate).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('6. 生产路径自审:源码不得再把种子当默认返回', () => {
|
||||
it('默认构造的服务列表/流水不含 Vanguard / Dubai Apex', async () => {
|
||||
const bare = new PlatformSubscriptionService();
|
||||
const subs = await bare.getSubscribers();
|
||||
const renewals = await bare.getRenewals();
|
||||
const overview = await bare.getOverview();
|
||||
const blob = JSON.stringify({ subs, renewals, overview });
|
||||
expect(blob).not.toMatch(/Vanguard/i);
|
||||
expect(blob).not.toMatch(/Dubai Apex/i);
|
||||
expect(blob).not.toMatch(/Berlin Precision/i);
|
||||
expect(subs.list).toEqual([]);
|
||||
expect(renewals.list).toEqual([]);
|
||||
});
|
||||
|
||||
it('platform-subscription.ts 源码不得再硬编码 Vanguard / Dubai 种子租户', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const fs = require('fs') as typeof import('fs');
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const path = require('path') as typeof import('path');
|
||||
const src = fs.readFileSync(
|
||||
path.join(
|
||||
__dirname,
|
||||
'../../../src/modules/team/service/platform-subscription.ts'
|
||||
),
|
||||
'utf8'
|
||||
);
|
||||
expect(src).not.toMatch(/Vanguard/i);
|
||||
expect(src).not.toMatch(/Dubai/i);
|
||||
expect(src).not.toMatch(/Berlin Precision/i);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户