feat: 同步管理端、用户端与后端业务,并接通线上 Web 接口

补齐平台订阅、外部登录、CRM 同步与用户端页面;Nginx 转发用户端接口,登录页使用 Google 与 Apple 标志。

Co-authored-by: Cursor <cursoragent@cursor.com>
这个提交包含在:
wikiboss
2026-09-23 18:01:47 +08:00
父节点 8ea9c25386
当前提交 62e0ae4435
共有 227 个文件被更改,包括 21855 次插入 和 5264 次删除

查看文件

@@ -1,3 +1,4 @@
import * as dns from 'dns';
import { getMetadataArgsStorage } from 'typeorm';
import { CoolCommException } from '@cool-midway/core';
import { BaseEntity } from '../../../src/comm/entity/base';
@@ -171,13 +172,39 @@ describe('Point 22: 独立企业二级域名绑定向导与 SSL 证书检测 单
expect(res.message).toContain('不匹配');
});
it('模拟或真实解析发生空记录或错误时友好降级为 verified=false', async () => {
it('显式传入空模拟记录时 verified=false 且不假装已核验', async () => {
const res = await service.verifyDnsRecord('card.mycompany.com', 'cname.omnicard.ai', {
mockRecords: [],
});
expect(res.verified).toBe(false);
expect(res.records).toEqual([]);
expect(res.message).toMatch(/未找到/);
});
it('不传模拟记录且 DNS 解析失败时 verified 为 false', async () => {
const spy = jest
.spyOn(dns.promises, 'resolveCname')
.mockRejectedValue(
Object.assign(new Error('queryCname ENOTFOUND card.mycompany.com'), {
code: 'ENOTFOUND',
})
);
try {
const res = await service.verifyDnsRecord(
'card.mycompany.com',
'cname.omnicard.ai'
);
expect(spy).toHaveBeenCalledWith('card.mycompany.com');
expect(res.verified).toBe(false);
expect(res.records).toEqual([]);
expect(res.message).toMatch(/解析失败/);
expect(res.message).toMatch(/ENOTFOUND/);
} finally {
spy.mockRestore();
}
});
});

查看文件

@@ -82,4 +82,171 @@ describe('【财务与运营新账统计】TeamFinanceAnalyticsService 单元测
expect(response.code).toBe(1000);
expect(response.data.summary.mrr).toBeDefined();
});
it('6. 无发票/无订阅可归属时 regionalRevenue 与 recentLedger 必须为空数组,不得填演示数据', async () => {
service.teamInvoiceEntity = {
find: async () => [],
} as unknown as TeamFinanceAnalyticsService['teamInvoiceEntity'];
service.teamSubscriptionEntity = {
find: async () => [],
} as unknown as TeamFinanceAnalyticsService['teamSubscriptionEntity'];
const result = await service.getOverviewAnalytics({ period: 'all' });
expect(result.regionalRevenue).toEqual([]);
expect(result.recentLedger).toEqual([]);
const ledgerDump = JSON.stringify(result.recentLedger);
expect(ledgerDump).not.toMatch(/Vanguard|Dubai Apex|Berlin Precision|Silicon Horizon|Nordic Clean/i);
const regionPercents = result.regionalRevenue.map((r) => r.percentage);
expect(regionPercents).not.toEqual([46, 28, 14, 12]);
});
it('7. 区域占比必须按已有发票币种归属真实汇总,百分比来自实际收款占比而非固定比例', async () => {
const invoices: Partial<TeamInvoiceEntity>[] = [
{
id: 11,
invoiceNo: 'INV-20260921-0001',
currency: 'USD',
totalAmount: 1000,
taxAmount: 0,
status: 'paid',
title: 'Pro Plan',
billingInfo: { companyName: 'Acme North LLC' },
tenantId: 101,
createTime: new Date('2026-09-10T10:00:00Z') as unknown as Date,
},
{
id: 12,
invoiceNo: 'INV-20260921-0002',
currency: 'EUR',
totalAmount: 500,
taxAmount: 0,
status: 'paid',
title: 'Enterprise Plan',
billingInfo: { companyName: 'Rhine Tools GmbH' },
tenantId: 102,
createTime: new Date('2026-09-11T10:00:00Z') as unknown as Date,
},
{
id: 13,
invoiceNo: 'INV-20260921-0003',
currency: 'AED',
totalAmount: 500,
taxAmount: 0,
status: 'paid',
title: 'Pro Plan',
billingInfo: { companyName: 'Gulf Bridge Trading' },
tenantId: 103,
createTime: new Date('2026-09-12T10:00:00Z') as unknown as Date,
},
];
service.teamInvoiceEntity = {
find: async () => invoices as TeamInvoiceEntity[],
} as unknown as TeamFinanceAnalyticsService['teamInvoiceEntity'];
service.teamSubscriptionEntity = {
find: async () => [],
} as unknown as TeamFinanceAnalyticsService['teamSubscriptionEntity'];
const result = await service.getOverviewAnalytics({ period: 'all' });
expect(result.regionalRevenue.length).toBeGreaterThanOrEqual(1);
const totalPct = result.regionalRevenue.reduce((s, r) => s + r.percentage, 0);
expect(totalPct).toBeCloseTo(100, 0);
const byRegion = Object.fromEntries(
result.regionalRevenue.map((r) => [r.region, r])
);
// USD 1000 / 2000 = 50%, EUR 500 = 25%, AED 500 = 25%
const usdRegion = Object.values(byRegion).find((r) => r.revenue === 1000);
const eurRegion = Object.values(byRegion).find((r) => r.revenue === 500 && r.percentage === 25);
expect(usdRegion?.percentage).toBe(50);
expect(eurRegion).toBeDefined();
const fixedDemo = result.regionalRevenue.map((r) => r.percentage);
expect(fixedDemo).not.toEqual([46, 28, 14, 12]);
});
it('8. recentLedger 只能映射已有发票字段,租户名取自 billingInfo,不得出现演示租户', async () => {
const invoices: Partial<TeamInvoiceEntity>[] = [
{
id: 21,
invoiceNo: 'INV-20260920-0100',
currency: 'USD',
totalAmount: 196,
taxAmount: 0,
status: 'paid',
title: 'Pro Seat Addon',
billingInfo: { companyName: 'Orbit Studio Co.' },
tenantId: 201,
createTime: new Date('2026-09-20T08:00:00Z') as unknown as Date,
},
{
id: 22,
invoiceNo: 'INV-20260919-0099',
currency: 'EUR',
totalAmount: 588,
taxAmount: 0,
status: 'issued',
title: 'Enterprise Renewal',
billingInfo: { companyName: 'Nordic Labs AB' },
tenantId: 202,
createTime: new Date('2026-09-19T08:00:00Z') as unknown as Date,
},
];
service.teamInvoiceEntity = {
find: async () => invoices as TeamInvoiceEntity[],
} as unknown as TeamFinanceAnalyticsService['teamInvoiceEntity'];
service.teamSubscriptionEntity = {
find: async () => [],
} as unknown as TeamFinanceAnalyticsService['teamSubscriptionEntity'];
const result = await service.getOverviewAnalytics({ period: 'all' });
expect(result.recentLedger.length).toBe(2);
expect(result.recentLedger.map((l) => l.invoiceNo)).toEqual([
'INV-20260920-0100',
'INV-20260919-0099',
]);
expect(result.recentLedger[0].tenantName).toBe('Orbit Studio Co.');
expect(result.recentLedger[1].tenantName).toBe('Nordic Labs AB');
expect(result.recentLedger[0].amount).toBe(196);
expect(result.recentLedger[1].status).toBe('issued');
const names = result.recentLedger.map((l) => l.tenantName).join(' ');
expect(names).not.toMatch(/Vanguard|Dubai Apex|Berlin Precision|Silicon Horizon/i);
});
it('9. 仅有未付款发票时区域营收为空,流水仍可来自真实发票', async () => {
const invoices: Partial<TeamInvoiceEntity>[] = [
{
id: 31,
invoiceNo: 'INV-20260918-0001',
currency: 'USD',
totalAmount: 299,
taxAmount: 0,
status: 'issued',
title: 'Pro Plan',
billingInfo: { companyName: 'Pending Buyer Inc.' },
tenantId: 301,
createTime: new Date('2026-09-18T08:00:00Z') as unknown as Date,
},
];
service.teamInvoiceEntity = {
find: async () => invoices as TeamInvoiceEntity[],
} as unknown as TeamFinanceAnalyticsService['teamInvoiceEntity'];
service.teamSubscriptionEntity = {
find: async () => [],
} as unknown as TeamFinanceAnalyticsService['teamSubscriptionEntity'];
const result = await service.getOverviewAnalytics({ period: 'all' });
expect(result.regionalRevenue).toEqual([]);
expect(result.recentLedger).toHaveLength(1);
expect(result.recentLedger[0].tenantName).toBe('Pending Buyer Inc.');
expect(result.recentLedger[0].invoiceNo).toBe('INV-20260918-0001');
});
});

查看文件

@@ -361,6 +361,136 @@ describe('Point 24: 国际多币种发票生成与历史收据导出 单元测
});
});
// ==========================================
// 6b. 发票下载真实文件内容 (P38)
// ==========================================
describe('6b. 发票下载真实文件内容 (P38)', () => {
let service: TeamInvoiceService;
let invoices: TeamInvoiceEntity[];
let nextId = 1;
beforeEach(() => {
invoices = [];
nextId = 1;
const mockRepo = {
count: jest.fn(async () => invoices.length),
save: jest.fn(async (entity: TeamInvoiceEntity) => {
const saved = Object.assign(new TeamInvoiceEntity(), entity, {
id: nextId++,
createTime: new Date(),
updateTime: new Date(),
});
invoices.push(saved);
return saved;
}),
find: jest.fn(async (options?: { where?: Partial<TeamInvoiceEntity> }) => {
if (!options?.where) return [...invoices];
return invoices.filter(item => {
const itemMap = item as unknown as Record<string, unknown>;
for (const [key, val] of Object.entries(options.where!)) {
if (itemMap[key] !== val) return false;
}
return true;
});
}),
findOne: jest.fn(
async (options?: { where?: Partial<TeamInvoiceEntity> }) => {
if (!options?.where) return null;
const found = invoices.find(item => {
const itemMap = item as unknown as Record<string, unknown>;
for (const [key, val] of Object.entries(options.where!)) {
if (itemMap[key] !== val) return false;
}
return true;
});
return found || null;
}
),
findOneBy: jest.fn(async (where: Partial<TeamInvoiceEntity>) => {
const found = invoices.find(item => {
const itemMap = item as unknown as Record<string, unknown>;
for (const [key, val] of Object.entries(where)) {
if (itemMap[key] !== val) return false;
}
return true;
});
return found || null;
}),
};
service = new TeamInvoiceService();
service.teamInvoiceEntity =
mockRepo as unknown as TeamInvoiceService['teamInvoiceEntity'];
});
it('downloadInvoice 发票不存在时应抛 CoolCommException,不得返回成功 URL', async () => {
await expect(service.downloadInvoice(99999, 101)).rejects.toThrow(
CoolCommException
);
await expect(service.downloadInvoice(99999, 101)).rejects.toThrow(
/不存在|not found/i
);
});
it('downloadInvoice 应返回真实文件内容,且含发票号与金额', async () => {
const created = await service.generateInvoice(101, {
subtotal: 200,
taxRate: 0.1,
currency: 'USD',
title: 'Enterprise Plan',
});
const result = await service.downloadInvoice(created.id, 101);
expect(result).toBeDefined();
expect(result.content).toBeTruthy();
expect(typeof result.content).toBe('string');
expect(result.content.length).toBeGreaterThan(20);
expect(result.content).toContain(created.invoiceNo);
expect(result.content).toMatch(/220\.00|\$220/);
expect(result.filename).toMatch(
new RegExp(`${created.invoiceNo}\\.(txt|pdf)$`)
);
expect(result.contentType).toMatch(/text\/plain|application\/pdf/);
expect(result.invoiceNo).toBe(created.invoiceNo);
// 禁止假 CDN / 空壳外链
expect(result).not.toHaveProperty('url');
expect(result.content).not.toMatch(
/cdn\.omnicard|https?:\/\/.*invoice.*\.pdf/i
);
});
it('downloadInvoice 跨租户或无权访问时应报错,不得返回成功 URL', async () => {
const created = await service.generateInvoice(101, {
subtotal: 50,
currency: 'USD',
title: 'Cross-tenant check',
});
await expect(service.downloadInvoice(created.id, 202)).rejects.toThrow(
CoolCommException
);
});
it('downloadInvoice 在无 Stripe 发票号时不得假装已从 Stripe 拉取 PDF', async () => {
const created = await service.generateInvoice(101, {
subtotal: 88,
currency: 'EUR',
title: 'No Stripe PDF',
});
const result = await service.downloadInvoice(created.id, 101);
expect(result.content).toContain(created.invoiceNo);
expect(result.content).not.toMatch(
/stripe\.com|invoicestripes|invoice_pdf/i
);
expect(JSON.stringify(result)).not.toMatch(
/https:\/\/files\.stripe\.com|invoice_pdf/i
);
});
});
// ==========================================
// 7. 控制器接口调用测试 (AdminTeamBillingController)
// ==========================================
@@ -389,6 +519,14 @@ describe('Point 24: 国际多币种发票生成与历史收据导出 单元测
return [inv];
});
invoiceService.downloadInvoice = jest.fn(async () => ({
content:
'INVOICE / RECEIPT\nInvoice No : INV-20260920-0001\nTOTAL AMOUNT : $110.00\n',
contentType: 'text/plain; charset=utf-8',
filename: 'INV-20260920-0001.txt',
invoiceNo: 'INV-20260920-0001',
}));
controller = new AdminTeamBillingController();
controller.teamInvoiceService = invoiceService;
controller.ok = (data: unknown) => ({
@@ -422,5 +560,32 @@ describe('Point 24: 国际多币种发票生成与历史收据导出 单元测
expect(res.code).toBe(1000);
expect(invoiceService.listInvoices).toHaveBeenCalledWith(888);
});
it('POST /invoice/download 应按 id 下载真实文件内容并委派 service', async () => {
const res = (await controller.downloadInvoice({ id: 1 })) as {
code: number;
message: string;
data: {
content: string;
filename: string;
invoiceNo: string;
contentType: string;
};
};
expect(res.code).toBe(1000);
expect(invoiceService.downloadInvoice).toHaveBeenCalledWith(1, 888);
expect(res.data.content).toContain('INV-20260920-0001');
expect(res.data.content).toMatch(/110\.00|\$110/);
expect(res.data).not.toHaveProperty('url');
});
it('POST /invoice/download 缺少 id 时应抛 CoolCommException', async () => {
await expect(controller.downloadInvoice({})).rejects.toThrow(
CoolCommException
);
await expect(controller.downloadInvoice({ id: 0 })).rejects.toThrow(
CoolCommException
);
});
});
});

查看文件

@@ -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);
});
});
});

查看文件

@@ -9,6 +9,7 @@ import {
import {
TeamQuotaService,
DEFAULT_PLAN_PRICES,
DEFAULT_PLAN_LIMITS,
} from '../../../src/modules/team/service/quota';
import type {
PlanLimits,
@@ -196,23 +197,23 @@ describe('Point 23: 企业多坐席配额用量预警与升级差价计算 单
});
});
it('3.2 当前用量已达上限时,抛出 RESOURCE_LIMIT_EXCEEDED 阻断异常', async () => {
it('3.2 当前用量已达上限时,抛出 CoolCommException 且中文说明已达上限', async () => {
await expect(
quotaService.assertQuotaAvailable(101, 'seats', 1, {
plan: 'free',
currentUsed: 1,
})
).rejects.toThrow('RESOURCE_LIMIT_EXCEEDED');
).rejects.toThrow(/已达上限/);
});
it('3.3 增加批次数量 (additionalCount) 导致超限时,同样抛出 RESOURCE_LIMIT_EXCEEDED', async () => {
it('3.3 增加批次数量 (additionalCount) 导致超限时,同样抛出已达上限', async () => {
// 当前 8/10,增加 3 个 => 11 > 10,应拦截阻断
await expect(
quotaService.assertQuotaAvailable(101, 'seats', 3, {
plan: 'pro',
currentUsed: 8,
})
).rejects.toThrow('RESOURCE_LIMIT_EXCEEDED');
).rejects.toThrow(/已达上限/);
// 增加 2 个 => 10 <= 10,刚好达标,不抛出
await expect(
@@ -223,7 +224,7 @@ describe('Point 23: 企业多坐席配额用量预警与升级差价计算 单
).resolves.toBeDefined();
});
it('3.4 异常类型必须为 CoolCommException', async () => {
it('3.4 异常类型必须为 CoolCommException,名片超限文案含已达上限', async () => {
try {
await quotaService.assertQuotaAvailable(101, 'cards', 1, {
plan: 'free',
@@ -232,7 +233,129 @@ describe('Point 23: 企业多坐席配额用量预警与升级差价计算 单
fail('应当抛出异常');
} catch (err) {
expect(err).toBeInstanceOf(CoolCommException);
expect((err as CoolCommException).message).toContain('RESOURCE_LIMIT_EXCEEDED');
expect((err as CoolCommException).message).toContain('已达上限');
expect((err as CoolCommException).message).toContain('名片');
}
});
it('3.5 P33: Free 套餐名片配额用尽时阻断新增,文案为中文已达上限', async () => {
await expect(
quotaService.assertQuotaAvailable(101, 'cards', 1, {
plan: 'free',
currentUsed: 1,
})
).rejects.toMatchObject({
message: '当前套餐名片配额已达上限,请升级套餐',
});
});
it('3.6 P33: Pro 套餐名片未超限时允许新增', async () => {
await expect(
quotaService.assertQuotaAvailable(101, 'cards', 1, {
plan: 'pro',
currentUsed: 4,
})
).resolves.toMatchObject({
used: 4,
limit: 5,
exceeded: false,
});
});
it('3.7 R45: Free 名片上限必须来自套餐配置,按配置值放行', async () => {
expect(DEFAULT_PLAN_LIMITS.free.cardsLimit).toBe(1);
await expect(
quotaService.assertQuotaAvailable(101, 'cards', 1, {
plan: 'free',
currentUsed: 0,
})
).resolves.toMatchObject({
used: 0,
limit: DEFAULT_PLAN_LIMITS.free.cardsLimit,
exceeded: false,
});
});
it('3.8 R45: Free 套餐未配置 cardsLimit 时拒绝新增,不得当成无限', async () => {
const original = DEFAULT_PLAN_LIMITS.free.cardsLimit;
try {
delete (DEFAULT_PLAN_LIMITS.free as { cardsLimit?: number }).cardsLimit;
await expect(
quotaService.assertQuotaAvailable(101, 'cards', 1, {
plan: 'free',
currentUsed: 0,
})
).rejects.toThrow(/未配置|未设置/);
await expect(
quotaService.assertQuotaAvailable(101, 'cards', 1, {
plan: 'free',
currentUsed: 0,
})
).rejects.toBeInstanceOf(CoolCommException);
} finally {
DEFAULT_PLAN_LIMITS.free.cardsLimit = original;
}
});
it('3.9 R45: Free 套餐 cardsLimit 为 NaN/Infinity 视为未设置上限,拒绝新增', async () => {
const original = DEFAULT_PLAN_LIMITS.free.cardsLimit;
try {
DEFAULT_PLAN_LIMITS.free.cardsLimit = Number.NaN;
await expect(
quotaService.assertQuotaAvailable(101, 'cards', 1, {
plan: 'free',
currentUsed: 0,
})
).rejects.toThrow(/未配置|未设置/);
DEFAULT_PLAN_LIMITS.free.cardsLimit = Number.POSITIVE_INFINITY;
await expect(
quotaService.assertQuotaAvailable(101, 'cards', 1, {
plan: 'free',
currentUsed: 0,
})
).rejects.toThrow(/未配置|未设置/);
} finally {
DEFAULT_PLAN_LIMITS.free.cardsLimit = original;
}
});
it('3.10 R45: 未配置上限的拒绝文案不得冒充 P33 配额用尽文案', async () => {
const original = DEFAULT_PLAN_LIMITS.free.cardsLimit;
try {
delete (DEFAULT_PLAN_LIMITS.free as { cardsLimit?: number }).cardsLimit;
try {
await quotaService.assertQuotaAvailable(101, 'cards', 1, {
plan: 'free',
currentUsed: 0,
});
fail('应当抛出异常');
} catch (err) {
expect(err).toBeInstanceOf(CoolCommException);
expect((err as CoolCommException).message).not.toBe(
'当前套餐名片配额已达上限,请升级套餐'
);
expect((err as CoolCommException).message).toMatch(/未配置|未设置/);
}
} finally {
DEFAULT_PLAN_LIMITS.free.cardsLimit = original;
}
});
it('3.11 R45: checkQuota 在未配置上限时 exceeded=true、remaining=0,不得当成无限', async () => {
const original = DEFAULT_PLAN_LIMITS.free.cardsLimit;
try {
delete (DEFAULT_PLAN_LIMITS.free as { cardsLimit?: number }).cardsLimit;
const result = await quotaService.checkQuota(101, 'cards', {
plan: 'free',
currentUsed: 0,
});
expect(result.limit).toBe(0);
expect(result.remaining).toBe(0);
expect(result.exceeded).toBe(true);
expect(result.percentage).toBe(100);
} finally {
DEFAULT_PLAN_LIMITS.free.cardsLimit = original;
}
});
});

查看文件

@@ -1,4 +1,5 @@
import * as crypto from 'crypto';
import axios from 'axios';
import { CoolCommException } from '@cool-midway/core';
import { TeamStripeService } from '../../../src/modules/team/service/stripe';
import { TeamSubscriptionEntity } from '../../../src/modules/team/entity/subscription';
@@ -8,56 +9,121 @@ import {
StripeWebhookEvent,
} from '../../../src/comm/types/omni';
jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;
describe('Point 21: Stripe 国际信用卡订阅支付与 Webhook 自动化入账', () => {
let stripeService: TeamStripeService;
let subscriptions: TeamSubscriptionEntity[];
let processedStripeEvents: { id?: number; eventId: string; eventType?: string }[];
const testWebhookSecret = 'whsec_test_secret_key_1234567890abcdef';
const originalStripeSecret = process.env.STRIPE_SECRET_KEY;
const createMockSubscriptionRepo = () => ({
findOne: jest.fn(async ({ where }: { where: { tenantId?: number | string; stripeSubscriptionId?: string } }) => {
return subscriptions.find(s => {
if (where.tenantId !== undefined && s.tenantId === Number(where.tenantId)) return true;
if (where.stripeSubscriptionId && s.stripeSubscriptionId === where.stripeSubscriptionId) return true;
return false;
}) || null;
}),
findOneBy: jest.fn(async (q: { tenantId?: number | string; stripeSubscriptionId?: string }) => {
return subscriptions.find(s => {
if (q.tenantId !== undefined && s.tenantId === Number(q.tenantId)) return true;
if (q.stripeSubscriptionId && s.stripeSubscriptionId === q.stripeSubscriptionId) return true;
return false;
}) || null;
}),
save: jest.fn(async (entity: TeamSubscriptionEntity) => {
if (!entity.id) {
entity.id = subscriptions.length ? Math.max(...subscriptions.map(s => s.id || 0)) + 1 : 1;
subscriptions.push(entity);
return entity;
}
const idx = subscriptions.findIndex(s => s.id === entity.id);
if (idx >= 0) {
Object.assign(subscriptions[idx], entity);
return subscriptions[idx];
}
subscriptions.push(entity);
return entity;
}),
});
const createMockStripeEventRepo = () => ({
findOne: jest.fn(async ({ where }: { where: { eventId?: string } }) => {
return processedStripeEvents.find(e => e.eventId === where.eventId) || null;
}),
save: jest.fn(async (entity: { id?: number; eventId: string; eventType?: string }) => {
const existing = processedStripeEvents.find(e => e.eventId === entity.eventId);
if (existing) {
Object.assign(existing, entity);
return existing;
}
entity.id =
processedStripeEvents.length
? Math.max(...processedStripeEvents.map(e => e.id || 0)) + 1
: 1;
processedStripeEvents.push(entity);
return entity;
}),
});
beforeEach(() => {
subscriptions = [];
// Mock Subscription Repository
const mockSubscriptionRepo = {
findOne: jest.fn(async ({ where }: { where: { tenantId?: number | string; stripeSubscriptionId?: string } }) => {
return subscriptions.find(s => {
if (where.tenantId !== undefined && s.tenantId === Number(where.tenantId)) return true;
if (where.stripeSubscriptionId && s.stripeSubscriptionId === where.stripeSubscriptionId) return true;
return false;
}) || null;
}),
findOneBy: jest.fn(async (q: { tenantId?: number | string; stripeSubscriptionId?: string }) => {
return subscriptions.find(s => {
if (q.tenantId !== undefined && s.tenantId === Number(q.tenantId)) return true;
if (q.stripeSubscriptionId && s.stripeSubscriptionId === q.stripeSubscriptionId) return true;
return false;
}) || null;
}),
save: jest.fn(async (entity: TeamSubscriptionEntity) => {
if (!entity.id) {
entity.id = subscriptions.length ? Math.max(...subscriptions.map(s => s.id || 0)) + 1 : 1;
subscriptions.push(entity);
return entity;
}
const idx = subscriptions.findIndex(s => s.id === entity.id);
if (idx >= 0) {
Object.assign(subscriptions[idx], entity);
return subscriptions[idx];
}
subscriptions.push(entity);
return entity;
}),
};
processedStripeEvents = [];
delete process.env.STRIPE_SECRET_KEY;
mockedAxios.post.mockReset();
stripeService = new TeamStripeService();
stripeService.teamSubscriptionEntity = mockSubscriptionRepo as unknown as TeamStripeService['teamSubscriptionEntity'];
stripeService.teamSubscriptionEntity =
createMockSubscriptionRepo() as unknown as TeamStripeService['teamSubscriptionEntity'];
stripeService.teamStripeEventEntity =
createMockStripeEventRepo() as unknown as TeamStripeService['teamStripeEventEntity'];
stripeService.webhookSecret = testWebhookSecret;
});
afterEach(() => {
if (originalStripeSecret === undefined) {
delete process.env.STRIPE_SECRET_KEY;
} else {
process.env.STRIPE_SECRET_KEY = originalStripeSecret;
}
});
// =========================================================================
// 1. Checkout 会话创建 (createCheckoutSession)
// =========================================================================
describe('1. Checkout 会话创建 (createCheckoutSession)', () => {
it('1.1 成功为租户创建 Pro 月付结算会话,返回模拟/真实 Stripe URL 与 sessionId', async () => {
it('1.0 未配置 STRIPE_SECRET_KEY 时抛出 CoolCommException,不返回 sessionId/url', async () => {
await expect(
stripeService.createCheckoutSession({
tenantId: 101,
plan: 'pro',
billingCycle: 'monthly',
})
).rejects.toThrow(CoolCommException);
await expect(
stripeService.createCheckoutSession({
tenantId: 101,
plan: 'pro',
billingCycle: 'monthly',
})
).rejects.toThrow(/未配置 Stripe/);
expect(mockedAxios.post).not.toHaveBeenCalled();
});
it('1.1 有密钥时通过 Stripe API 创建 Pro 月付结算会话,映射 id/url', async () => {
process.env.STRIPE_SECRET_KEY = 'sk_test_unit_mock_key';
mockedAxios.post.mockResolvedValue({
data: {
id: 'cs_test_pro_monthly_abc',
url: 'https://checkout.stripe.com/c/pay/cs_test_pro_monthly_abc',
},
});
const session = await stripeService.createCheckoutSession({
tenantId: 101,
plan: 'pro',
@@ -68,14 +134,33 @@ describe('Point 21: Stripe 国际信用卡订阅支付与 Webhook 自动化入
expect(session.tenantId).toBe(101);
expect(session.plan).toBe('pro');
expect(session.billingCycle).toBe('monthly');
expect(session.sessionId).toMatch(/^cs_test_[a-zA-Z0-9_-]+$/);
expect(session.url).toContain('checkout.stripe.com');
expect(session.url).toContain(session.sessionId);
expect(session.sessionId).toBe('cs_test_pro_monthly_abc');
expect(session.url).toBe(
'https://checkout.stripe.com/c/pay/cs_test_pro_monthly_abc'
);
expect(session.currency).toBe('usd');
expect(session.amount).toBe(1900); // $19.00 / 月
expect(mockedAxios.post).toHaveBeenCalledWith(
'https://api.stripe.com/v1/checkout/sessions',
expect.any(String),
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer sk_test_unit_mock_key',
'Content-Type': 'application/x-www-form-urlencoded',
}),
})
);
});
it('1.2 成功为租户创建 Enterprise 年付结算会话,金额折算符合 SaaS 阶梯定价', async () => {
it('1.2 有密钥时创建 Enterprise 年付结算会话,金额折算符合 SaaS 阶梯定价', async () => {
process.env.STRIPE_SECRET_KEY = 'sk_test_unit_mock_key';
mockedAxios.post.mockResolvedValue({
data: {
id: 'cs_test_ent_yearly_xyz',
url: 'https://checkout.stripe.com/c/pay/cs_test_ent_yearly_xyz',
},
});
const session = await stripeService.createCheckoutSession({
tenantId: 202,
plan: 'enterprise',
@@ -86,10 +171,19 @@ describe('Point 21: Stripe 国际信用卡订阅支付与 Webhook 自动化入
expect(session.plan).toBe('enterprise');
expect(session.billingCycle).toBe('yearly');
expect(session.amount).toBe(99000); // $990.00 / 年 (优惠2个月)
expect(session.sessionId).toBe('cs_test_ent_yearly_xyz');
expect(session.url).toContain('checkout.stripe.com');
});
it('1.3 默认周期为 monthly,且兼容独立参数传参 (tenantId, plan, billingCycle)', async () => {
it('1.3 有密钥时默认周期为 monthly,且兼容独立参数传参 (tenantId, plan, billingCycle)', async () => {
process.env.STRIPE_SECRET_KEY = 'sk_test_unit_mock_key';
mockedAxios.post.mockResolvedValue({
data: {
id: 'cs_test_default_monthly',
url: 'https://checkout.stripe.com/c/pay/cs_test_default_monthly',
},
});
const session = await stripeService.createCheckoutSession(303, 'pro');
expect(session.tenantId).toBe(303);
@@ -456,11 +550,22 @@ describe('Point 21: Stripe 国际信用卡订阅支付与 Webhook 自动化入
const firstRes = await stripeService.handleWebhookEvent(event);
expect(firstRes.action).toBe('upgraded');
expect(subscriptions.length).toBe(1);
expect(processedStripeEvents.some(e => e.eventId === 'evt_unique_1001')).toBe(
true
);
// 重复推送相同事件
// 重复推送相同事件:只入账一次
const secondRes = await stripeService.handleWebhookEvent(event);
expect(secondRes.success).toBe(true);
expect(secondRes.action).toBe('idempotent_ignored');
expect(subscriptions.length).toBe(1);
expect(
(
stripeService.teamSubscriptionEntity
.save as unknown as jest.Mock
).mock.calls.length
).toBe(1);
});
it('5.2 收到 invoice.payment_failed 扣款失败时流转为 past_due 宽限期', async () => {
@@ -494,5 +599,73 @@ describe('Point 21: Stripe 国际信用卡订阅支付与 Webhook 自动化入
const updated = subscriptions.find(s => s.tenantId === 66);
expect(updated?.status).toBe('past_due');
});
it('5.3 进程重启后凭持久化记录仍识别已处理 event.id,不重复入账', async () => {
const event: StripeWebhookEvent = {
id: 'evt_persist_restart_2002',
type: 'checkout.session.completed',
created: 1700000000,
data: {
object: {
client_reference_id: '2002',
metadata: { tenantId: '2002', plan: 'pro' },
},
},
};
const firstRes = await stripeService.handleWebhookEvent(event);
expect(firstRes.action).toBe('upgraded');
expect(subscriptions.length).toBe(1);
expect(processedStripeEvents).toEqual(
expect.arrayContaining([
expect.objectContaining({ eventId: 'evt_persist_restart_2002' }),
])
);
// 模拟进程重启:新 Service 实例清空内存 Set,但共享同一持久化表
const restarted = new TeamStripeService();
restarted.teamSubscriptionEntity =
createMockSubscriptionRepo() as unknown as TeamStripeService['teamSubscriptionEntity'];
restarted.teamStripeEventEntity =
createMockStripeEventRepo() as unknown as TeamStripeService['teamStripeEventEntity'];
restarted.webhookSecret = testWebhookSecret;
expect(restarted.processedEventIds.size).toBe(0);
const secondRes = await restarted.handleWebhookEvent(event);
expect(secondRes.action).toBe('idempotent_ignored');
expect(subscriptions.length).toBe(1);
expect(
(restarted.teamSubscriptionEntity.save as unknown as jest.Mock).mock
.calls.length
).toBe(0);
});
it('5.4 无事件表 repository 时仍可用内存 Set 兜底幂等', async () => {
const memoryOnly = new TeamStripeService();
memoryOnly.teamSubscriptionEntity =
createMockSubscriptionRepo() as unknown as TeamStripeService['teamSubscriptionEntity'];
memoryOnly.teamStripeEventEntity = undefined as unknown as TeamStripeService['teamStripeEventEntity'];
memoryOnly.webhookSecret = testWebhookSecret;
const event: StripeWebhookEvent = {
id: 'evt_memory_only_55',
type: 'checkout.session.completed',
created: 1700000000,
data: {
object: {
client_reference_id: '55',
metadata: { tenantId: '55', plan: 'pro' },
},
},
};
const firstRes = await memoryOnly.handleWebhookEvent(event);
expect(firstRes.action).toBe('upgraded');
expect(subscriptions.length).toBe(1);
const secondRes = await memoryOnly.handleWebhookEvent(event);
expect(secondRes.action).toBe('idempotent_ignored');
expect(subscriptions.length).toBe(1);
});
});
});