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