feat: 完成全系统30个功能点开发与E2E全链路验收 (8轮调度32个子代理闭环)
- 模块一: 出海案例作品集、博客动态发布、人名发音语音条、RTL阿语多语言切换 - 模块二: AI顾问人设配置、行业术语库润色、RAG切片沙盒调试、流式会话与意向留资卡片 - 模块三: 全球访客雷达大屏、多维加权意向算分、外部CRM对接推送、团队公海池流转 - 模块四: 移动端NFC安全挂失、NFC动态路由规则配置、动态防伪验签、展会制卡Zip批量导出 - 模块五: 组织架构与名片分配、移动端个人中心设置、离职交接与历史线索迁移、安全审计日志 - 模块六: Stripe国际信用卡订阅Webhook、独立二级域名与SSL检测、坐席配额预警、多币种发票 - 模块七: 纸质名片OCR解析入库、国际商务日程RFC 5545 ICS日历、微官网离线SWR预加载、展会实时排行榜大屏 - 模块八: 全球出海SEO OpenGraph元标签注入与社交爬虫探测、30点端到端全链路业务流集成测试套件
这个提交包含在:
@@ -0,0 +1,14 @@
|
||||
import { ModuleConfig } from '@cool-midway/core';
|
||||
|
||||
/**
|
||||
* 企业团队与品牌模块配置
|
||||
*/
|
||||
export default () => {
|
||||
return {
|
||||
name: '企业团队模块',
|
||||
description: '品牌 VI、资产交接与企业设置',
|
||||
middlewares: [],
|
||||
globalMiddlewares: [],
|
||||
order: 50,
|
||||
} as ModuleConfig;
|
||||
};
|
||||
@@ -0,0 +1,222 @@
|
||||
import { Body, Inject, Post, Provide } from '@midwayjs/core';
|
||||
import { Context } from '@midwayjs/koa';
|
||||
import {
|
||||
CoolController,
|
||||
BaseController,
|
||||
CoolTag,
|
||||
TagTypes,
|
||||
CoolCommException,
|
||||
} from '@cool-midway/core';
|
||||
import { TeamBillingService } from '../../service/billing';
|
||||
import { TeamQuotaService } from '../../service/quota';
|
||||
import { TeamInvoiceService } from '../../service/invoice';
|
||||
import { TeamStripeService } from '../../service/stripe';
|
||||
import {
|
||||
CalculateProrationDTO,
|
||||
CreateInvoiceDTO,
|
||||
PlanLimits,
|
||||
ProrationResult,
|
||||
QuotaCheckQuery,
|
||||
QuotaCheckResult,
|
||||
StripeBillingCycle,
|
||||
StripeWebhookEvent,
|
||||
TenantPlanType,
|
||||
} from '../../../../comm/types/omni';
|
||||
import { getTenantId } from '../../../../comm/context/tenant';
|
||||
|
||||
/**
|
||||
* 后台订阅账单与配额控制配置
|
||||
*/
|
||||
export const adminTeamBillingCurdOption = {
|
||||
api: [] as Array<
|
||||
'add' | 'delete' | 'update' | 'info' | 'list' | 'page'
|
||||
>,
|
||||
service: TeamBillingService,
|
||||
serviceApis: [
|
||||
{ method: 'currentPlan', summary: '当前套餐与配额' },
|
||||
{ method: 'getPlanLimits', summary: '获取套餐配额规格' },
|
||||
{ method: 'checkQuota', summary: '检查资源配额用量' },
|
||||
{ method: 'assertQuotaAvailable', summary: '断言资源配额可用性' },
|
||||
{ method: 'calculateUpgradeProration', summary: '计算套餐升级补差价' },
|
||||
{ method: 'createCheckoutSession', summary: '创建 Stripe 结算会话' },
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Cool-Admin 标准通用响应包装类型
|
||||
*/
|
||||
export interface CoolAdminResponse<T = unknown> {
|
||||
code: number;
|
||||
message: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
/**
|
||||
* 企业团队-订阅账单、配额、发票与 Stripe 支付流转
|
||||
* 路由前缀:/admin/team/billing
|
||||
*/
|
||||
@Provide()
|
||||
@CoolController(adminTeamBillingCurdOption)
|
||||
export class AdminTeamBillingController extends BaseController {
|
||||
@Inject()
|
||||
ctx: Context;
|
||||
|
||||
@Inject()
|
||||
teamBillingService: TeamBillingService;
|
||||
|
||||
@Inject()
|
||||
teamQuotaService: TeamQuotaService;
|
||||
|
||||
@Inject()
|
||||
teamInvoiceService: TeamInvoiceService;
|
||||
|
||||
@Inject()
|
||||
teamStripeService: TeamStripeService;
|
||||
|
||||
/**
|
||||
* 当前套餐与配额信息
|
||||
*/
|
||||
@Post('/currentPlan', { summary: '当前套餐与配额' })
|
||||
async postCurrentPlan(
|
||||
@Body()
|
||||
body?: {
|
||||
planType?: TenantPlanType;
|
||||
seatsUsed?: number;
|
||||
cardsUsed?: number;
|
||||
}
|
||||
): Promise<CoolAdminResponse<unknown>> {
|
||||
const result = await this.teamBillingService.currentPlan(body);
|
||||
return this.ok(result) as unknown as CoolAdminResponse<unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查企业坐席/名片/NFC配额用量与80%预警
|
||||
*/
|
||||
@Post('/quota', { summary: '检查企业坐席/名片/NFC配额用量' })
|
||||
async postQuota(
|
||||
@Body() body: QuotaCheckQuery
|
||||
): Promise<CoolAdminResponse<QuotaCheckResult>> {
|
||||
const tenantId = body.tenantId ?? 1;
|
||||
const result = await this.teamQuotaService.checkQuota(
|
||||
tenantId,
|
||||
body.resourceType,
|
||||
{
|
||||
plan: body.plan,
|
||||
currentUsed: body.currentUsed,
|
||||
}
|
||||
);
|
||||
return this.ok(result) as unknown as CoolAdminResponse<QuotaCheckResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 套餐升级补差价精确折算计算器
|
||||
*/
|
||||
@Post('/proration', { summary: '计算套餐升级补差价' })
|
||||
async postProration(
|
||||
@Body() body: CalculateProrationDTO
|
||||
): Promise<CoolAdminResponse<ProrationResult>> {
|
||||
const result = this.teamQuotaService.calculateUpgradeProration(
|
||||
body.currentPlan,
|
||||
body.targetPlan,
|
||||
body.remainingDays,
|
||||
body.totalDays,
|
||||
{
|
||||
currentPrice: body.currentPlanPrice,
|
||||
targetPrice: body.targetPlanPrice,
|
||||
}
|
||||
);
|
||||
return this.ok(result) as unknown as CoolAdminResponse<ProrationResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定套餐规格上限
|
||||
*/
|
||||
@Post('/limits', { summary: '获取套餐配额规格' })
|
||||
async postLimits(
|
||||
@Body() body: { plan?: TenantPlanType }
|
||||
): Promise<CoolAdminResponse<PlanLimits>> {
|
||||
const plan = body.plan ?? 'free';
|
||||
const limits = this.teamQuotaService.getPlanLimits(plan);
|
||||
return this.ok(limits) as unknown as CoolAdminResponse<PlanLimits>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建发票
|
||||
* 路由: POST /admin/team/billing/invoice/generate
|
||||
*/
|
||||
@Post('/invoice/generate', { summary: '生成发票' })
|
||||
async generateInvoice(@Body() body: CreateInvoiceDTO) {
|
||||
const tenantId = getTenantId(this.ctx);
|
||||
const result = await this.teamInvoiceService.generateInvoice(
|
||||
Number(tenantId),
|
||||
body
|
||||
);
|
||||
return this.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取发票历史列表
|
||||
* 路由: POST /admin/team/billing/invoice/list
|
||||
*/
|
||||
@Post('/invoice/list', { summary: '获取发票历史收据列表' })
|
||||
async listInvoices(@Body() _body?: Record<string, unknown>) {
|
||||
const tenantId = getTenantId(this.ctx);
|
||||
const result = await this.teamInvoiceService.listInvoices(Number(tenantId));
|
||||
return this.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 Stripe Checkout 订阅结算会话
|
||||
* 路由: POST /admin/team/billing/stripe-checkout
|
||||
*/
|
||||
@Post('/stripe-checkout', { summary: '创建 Stripe Checkout 订阅结算会话' })
|
||||
async createCheckout(
|
||||
@Body()
|
||||
body: {
|
||||
plan: TenantPlanType;
|
||||
billingCycle?: StripeBillingCycle;
|
||||
tenantId?: number | string;
|
||||
}
|
||||
) {
|
||||
const currentTenantId = body?.tenantId ?? getTenantId(this.ctx);
|
||||
if (!currentTenantId) {
|
||||
throw new CoolCommException('缺少企业租户 ID', 400);
|
||||
}
|
||||
|
||||
const session = await this.teamStripeService.createCheckoutSession({
|
||||
tenantId: currentTenantId,
|
||||
plan: body?.plan,
|
||||
billingCycle: body?.billingCycle || 'monthly',
|
||||
});
|
||||
|
||||
return this.ok(session);
|
||||
}
|
||||
|
||||
/**
|
||||
* 接收 Stripe Webhook 自动化入账通知
|
||||
* 路由: POST /admin/team/billing/webhook
|
||||
*/
|
||||
@CoolTag(TagTypes.IGNORE_TOKEN)
|
||||
@Post('/webhook', { summary: 'Stripe Webhook 自动化入账通知回调' })
|
||||
async webhook(@Body() body: StripeWebhookEvent) {
|
||||
const signature =
|
||||
(this.ctx?.get && typeof this.ctx.get === 'function'
|
||||
? this.ctx.get('stripe-signature')
|
||||
: this.ctx?.headers?.['stripe-signature']) || '';
|
||||
|
||||
const payload =
|
||||
(this.ctx?.request as { rawBody?: string })?.rawBody || body;
|
||||
|
||||
const isValid = this.teamStripeService.verifyWebhookSignature(
|
||||
payload as string | Record<string, unknown>,
|
||||
signature as string
|
||||
);
|
||||
|
||||
if (!isValid) {
|
||||
return this.fail('Stripe Webhook 签名验证失败', 400);
|
||||
}
|
||||
|
||||
const result = await this.teamStripeService.handleWebhookEvent(body);
|
||||
return this.ok({ received: true, result });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { CoolController, BaseController } from '@cool-midway/core';
|
||||
import { TeamBrandEntity } from '../../entity/brand';
|
||||
import { TeamBrandService } from '../../service/brand';
|
||||
|
||||
/**
|
||||
* 后台品牌 VI 标准 CRUD 配置
|
||||
*/
|
||||
export const adminTeamBrandCurdOption = {
|
||||
api: ['add', 'delete', 'update', 'info', 'list', 'page'] as Array<
|
||||
'add' | 'delete' | 'update' | 'info' | 'list' | 'page'
|
||||
>,
|
||||
entity: TeamBrandEntity,
|
||||
service: TeamBrandService,
|
||||
pageQueryOp: {
|
||||
keyWordLikeFields: ['a.companyName'],
|
||||
addOrderBy: { updateTime: 'desc' as const },
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 企业团队-后台品牌规范
|
||||
* 路由前缀:/admin/team/brand
|
||||
*/
|
||||
@CoolController(adminTeamBrandCurdOption)
|
||||
export class AdminTeamBrandController extends BaseController {}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Body, Inject, Post, Provide } from '@midwayjs/core';
|
||||
import { Context } from '@midwayjs/koa';
|
||||
import { CoolController, BaseController } from '@cool-midway/core';
|
||||
import { TeamDomainEntity } from '../../entity/domain';
|
||||
import { TeamDomainService } from '../../service/domain';
|
||||
import { BindDomainDTO, SslCertMockInfo } from '../../../../comm/types/omni';
|
||||
|
||||
/**
|
||||
* 后台租户域名标准 CRUD 配置
|
||||
*/
|
||||
export const adminTeamDomainCurdOption = {
|
||||
api: ['add', 'delete', 'update', 'info', 'list', 'page'] as Array<
|
||||
'add' | 'delete' | 'update' | 'info' | 'list' | 'page'
|
||||
>,
|
||||
entity: TeamDomainEntity,
|
||||
service: TeamDomainService,
|
||||
serviceApis: [
|
||||
{ method: 'validate', summary: '域名格式校验' },
|
||||
{ method: 'verifyDns', summary: 'CNAME 解析核验' },
|
||||
{ method: 'checkSsl', summary: 'SSL 证书检测' },
|
||||
{ method: 'bind', summary: '绑定独立域名' },
|
||||
],
|
||||
pageQueryOp: {
|
||||
keyWordLikeFields: ['a.customDomain'],
|
||||
fieldEq: ['a.sslStatus'],
|
||||
addOrderBy: { updateTime: 'desc' as const },
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 企业团队-租户域名与 SSL 治理
|
||||
* 路由前缀:/admin/team/domain
|
||||
*/
|
||||
@Provide()
|
||||
@CoolController(adminTeamDomainCurdOption)
|
||||
export class AdminTeamDomainController extends BaseController {
|
||||
@Inject()
|
||||
ctx: Context;
|
||||
|
||||
@Inject()
|
||||
teamDomainService: TeamDomainService;
|
||||
|
||||
/**
|
||||
* 域名格式严格正则检验
|
||||
*/
|
||||
@Post('/validate', { summary: '域名格式校验' })
|
||||
async validate(@Body() body: { domain: string }) {
|
||||
const result = this.teamDomainService.validateDomainFormat(body?.domain);
|
||||
return this.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* CNAME DNS 解析核验
|
||||
*/
|
||||
@Post('/verify-dns', { summary: 'CNAME 解析核验' })
|
||||
async verifyDns(
|
||||
@Body()
|
||||
body: {
|
||||
domain: string;
|
||||
targetCname?: string;
|
||||
mockRecords?: string[];
|
||||
}
|
||||
) {
|
||||
const targetCname = body?.targetCname || 'cname.omnicard.ai';
|
||||
const result = await this.teamDomainService.verifyDnsRecord(
|
||||
body?.domain,
|
||||
targetCname,
|
||||
{ mockRecords: body?.mockRecords }
|
||||
);
|
||||
return this.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* SSL 证书状态与过期时间检测
|
||||
*/
|
||||
@Post('/check-ssl', { summary: 'SSL 证书检测' })
|
||||
async checkSsl(
|
||||
@Body()
|
||||
body: {
|
||||
domain: string;
|
||||
port?: number;
|
||||
mockCert?: SslCertMockInfo;
|
||||
}
|
||||
) {
|
||||
const result = await this.teamDomainService.checkSslCertificate(
|
||||
body?.domain,
|
||||
body?.port,
|
||||
{ mockCert: body?.mockCert }
|
||||
);
|
||||
return this.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定独立域名
|
||||
*/
|
||||
@Post('/bind', { summary: '绑定独立域名' })
|
||||
async bind(@Body() body: BindDomainDTO) {
|
||||
const tenantId =
|
||||
body?.tenantId ||
|
||||
((this.ctx?.admin as unknown as { tenantId?: number })?.tenantId) ||
|
||||
((this.ctx as unknown as { tenantId?: number })?.tenantId);
|
||||
|
||||
const result = await this.teamDomainService.bindDomain(
|
||||
tenantId || 0,
|
||||
body?.domain,
|
||||
{ cnameTarget: body?.cnameTarget }
|
||||
);
|
||||
return this.ok(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Body, Inject, Post, Provide } from '@midwayjs/core';
|
||||
import { Context } from '@midwayjs/koa';
|
||||
import { CoolController, BaseController } from '@cool-midway/core';
|
||||
import { TeamHandoverService } from '../../service/handover';
|
||||
import {
|
||||
ExecuteHandoverDTO,
|
||||
HandoverRequest,
|
||||
} from '../../../../comm/types/omni';
|
||||
|
||||
/**
|
||||
* 后台离职交接配置
|
||||
*/
|
||||
export const adminTeamHandoverCurdOption = {
|
||||
api: [] as Array<
|
||||
'add' | 'delete' | 'update' | 'info' | 'list' | 'page'
|
||||
>,
|
||||
service: TeamHandoverService,
|
||||
serviceApis: [
|
||||
{ method: 'transfer', summary: '名片资产交接' },
|
||||
{ method: 'execute', summary: '离职资产安全交接' },
|
||||
{ method: 'logs', summary: '离职交接工单列表' },
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* 企业团队-离职交接
|
||||
* 路由前缀:/admin/team/handover
|
||||
*/
|
||||
@Provide()
|
||||
@CoolController(adminTeamHandoverCurdOption)
|
||||
export class AdminTeamHandoverController extends BaseController {
|
||||
@Inject()
|
||||
ctx: Context;
|
||||
|
||||
@Inject()
|
||||
teamHandoverService: TeamHandoverService;
|
||||
|
||||
/**
|
||||
* 执行离职资产安全交接
|
||||
* 包含名片短链重定向、CRM历史客户线索批量转移/划入公海及安全工单留痕
|
||||
*/
|
||||
@Post('/execute', { summary: '执行离职资产交接' })
|
||||
async execute(@Body() dto: ExecuteHandoverDTO) {
|
||||
const operatorId =
|
||||
dto?.operatorId || (this.ctx?.admin?.userId as number | undefined);
|
||||
const result = await this.teamHandoverService.executeHandover({
|
||||
...dto,
|
||||
operatorId,
|
||||
});
|
||||
return this.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 调阅离职资产交接历史工单列表
|
||||
*/
|
||||
@Post('/logs', { summary: '离职交接工单列表' })
|
||||
async logs(
|
||||
@Body()
|
||||
query?: {
|
||||
fromUserId?: number;
|
||||
toUserId?: number;
|
||||
}
|
||||
) {
|
||||
const list = await this.teamHandoverService.getHandoverLogs(query);
|
||||
return this.ok(list);
|
||||
}
|
||||
|
||||
/**
|
||||
* 兼容保留轻量级名片资产交接
|
||||
*/
|
||||
@Post('/transfer', { summary: '名片资产交接' })
|
||||
async transfer(@Body() req: HandoverRequest) {
|
||||
const result = await this.teamHandoverService.transfer(req);
|
||||
return this.ok(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Body, Get, Inject, Post, Provide, Query } from '@midwayjs/core';
|
||||
import { Context } from '@midwayjs/koa';
|
||||
import { CoolController, BaseController } from '@cool-midway/core';
|
||||
import { BaseSysUserEntity } from '../../../base/entity/sys/user';
|
||||
import { TeamMembersService } from '../../service/members';
|
||||
import { TeamLiveRankService } from '../../service/live-rank';
|
||||
import { getTenantId } from '../../../../comm/context/tenant';
|
||||
import type {
|
||||
AssignCardRequest,
|
||||
MoveMembersRequest,
|
||||
} from '../../../../comm/types/omni';
|
||||
|
||||
/**
|
||||
* 组织架构与成员名片分配 CRUD 配置
|
||||
*/
|
||||
export const adminTeamMembersCurdOption = {
|
||||
api: ['add', 'delete', 'update', 'info', 'list', 'page'] as Array<
|
||||
'add' | 'delete' | 'update' | 'info' | 'list' | 'page'
|
||||
>,
|
||||
entity: BaseSysUserEntity,
|
||||
service: TeamMembersService,
|
||||
serviceApis: [
|
||||
{ method: 'getDepartmentTree', summary: '查询多级部门树' },
|
||||
{ method: 'assignCard', summary: '分配名片资产' },
|
||||
{ method: 'moveMembers', summary: '批量调整部门' },
|
||||
{ method: 'getMemberPage', summary: '成员分页列表与关联名片' },
|
||||
{ method: 'addDepartment', summary: '添加部门' },
|
||||
{ method: 'updateDepartment', summary: '更新部门' },
|
||||
{ method: 'deleteDepartment', summary: '删除部门' },
|
||||
{ method: 'liveRank', summary: '展会现场团队获客实时排行榜' },
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* 企业团队-组织架构、部门树与成员名片分配
|
||||
* 路由前缀:/admin/team/members
|
||||
*/
|
||||
@Provide()
|
||||
@CoolController(adminTeamMembersCurdOption)
|
||||
export class AdminTeamMembersController extends BaseController {
|
||||
@Inject()
|
||||
ctx: Context;
|
||||
|
||||
@Inject()
|
||||
teamMembersService: TeamMembersService;
|
||||
|
||||
@Inject()
|
||||
teamLiveRankService: TeamLiveRankService;
|
||||
|
||||
@Post('/department-tree', { summary: '查询多级部门树' })
|
||||
async postDepartmentTree() {
|
||||
return this.ok(await this.teamMembersService.getDepartmentTree());
|
||||
}
|
||||
|
||||
@Post('/assign-card', { summary: '分配名片资产' })
|
||||
async postAssignCard(@Body() body: AssignCardRequest) {
|
||||
return this.ok(
|
||||
await this.teamMembersService.assignCard(body.memberId, body.cardId)
|
||||
);
|
||||
}
|
||||
|
||||
@Post('/move-members', { summary: '批量调整部门' })
|
||||
async postMoveMembers(@Body() body: MoveMembersRequest) {
|
||||
return this.ok(
|
||||
await this.teamMembersService.moveMembers(
|
||||
body.memberIds,
|
||||
body.departmentId
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Post('/live-rank', { summary: '展会现场团队获客实时排行榜' })
|
||||
async postLiveRank(@Body() body?: { tenantId?: string; eventId?: string }) {
|
||||
const tenantId =
|
||||
body?.tenantId ||
|
||||
(this.ctx ? String(getTenantId(this.ctx) || '1') : '1');
|
||||
const eventId = body?.eventId;
|
||||
return this.ok(
|
||||
await this.teamLiveRankService.getTeamLiveRank(tenantId, eventId)
|
||||
);
|
||||
}
|
||||
|
||||
@Get('/live-rank', { summary: '展会现场团队获客实时排行榜' })
|
||||
async getLiveRank(@Query() query?: { tenantId?: string; eventId?: string }) {
|
||||
const tenantId =
|
||||
query?.tenantId ||
|
||||
(this.ctx ? String(getTenantId(this.ctx) || '1') : '1');
|
||||
const eventId = query?.eventId;
|
||||
return this.ok(
|
||||
await this.teamLiveRankService.getTeamLiveRank(tenantId, eventId)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { BaseEntity } from '../../../comm/entity/base';
|
||||
import { Column, Entity } from 'typeorm';
|
||||
|
||||
/**
|
||||
* 企业品牌 VI
|
||||
* 表名: team_brand
|
||||
*/
|
||||
@Entity('team_brand')
|
||||
export class TeamBrandEntity extends BaseEntity {
|
||||
@Column({
|
||||
name: 'company_name',
|
||||
comment: '企业/团队官方名称',
|
||||
length: 150,
|
||||
})
|
||||
companyName: string;
|
||||
|
||||
@Column({
|
||||
name: 'logo_url',
|
||||
comment: '官方 Logo URL',
|
||||
length: 500,
|
||||
nullable: true,
|
||||
})
|
||||
logoUrl?: string;
|
||||
|
||||
@Column({
|
||||
name: 'primary_color',
|
||||
comment: '品牌主色 Hex',
|
||||
length: 16,
|
||||
default: '#0B5CFF',
|
||||
})
|
||||
primaryColor: string;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { BaseEntity } from '../../../comm/entity/base';
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
import { DomainSslStatus, TenantDomain } from '../../../comm/types/omni';
|
||||
|
||||
/**
|
||||
* 租户自定义域名
|
||||
* 表名: team_domain
|
||||
*/
|
||||
@Entity('team_domain')
|
||||
export class TeamDomainEntity extends BaseEntity implements TenantDomain {
|
||||
@Index({ unique: true })
|
||||
@Column({
|
||||
name: 'custom_domain',
|
||||
comment: '自定义主域名',
|
||||
length: 255,
|
||||
})
|
||||
customDomain: string;
|
||||
|
||||
@Column({
|
||||
name: 'ssl_status',
|
||||
comment: 'SSL 状态 pending/active/failed',
|
||||
length: 16,
|
||||
default: 'pending',
|
||||
})
|
||||
sslStatus: DomainSslStatus;
|
||||
|
||||
@Column({
|
||||
name: 'cname_target',
|
||||
comment: '解析目标 CNAME',
|
||||
length: 255,
|
||||
nullable: true,
|
||||
})
|
||||
cnameTarget?: string | null;
|
||||
|
||||
@Column({
|
||||
name: 'ssl_expire_time',
|
||||
comment: 'SSL 证书到期时间',
|
||||
length: 32,
|
||||
nullable: true,
|
||||
})
|
||||
sslExpireTime?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { BaseEntity } from '../../../comm/entity/base';
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
import {
|
||||
HandoverLeadAction,
|
||||
HandoverRedirectType,
|
||||
HandoverStatus,
|
||||
TeamHandoverLog,
|
||||
} from '../../../comm/types/omni';
|
||||
|
||||
/**
|
||||
* 离职资产安全交接工单历史表
|
||||
* 表名: team_handover_log
|
||||
*/
|
||||
@Entity('team_handover_log')
|
||||
export class TeamHandoverLogEntity extends BaseEntity implements TeamHandoverLog {
|
||||
@Index({ unique: true })
|
||||
@Column({
|
||||
name: 'handover_no',
|
||||
comment: '不可逆工单流水号 (如 HO20260920xxxx)',
|
||||
length: 64,
|
||||
})
|
||||
handoverNo: string;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
name: 'from_user_id',
|
||||
comment: '离职员工用户 ID',
|
||||
})
|
||||
fromUserId: number;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
name: 'to_user_id',
|
||||
comment: '接任人用户 ID (若仅划入公海则可为空)',
|
||||
nullable: true,
|
||||
})
|
||||
toUserId?: number | null;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
name: 'card_id',
|
||||
comment: '交接的原名片 ID',
|
||||
nullable: true,
|
||||
})
|
||||
cardId?: number | null;
|
||||
|
||||
@Column({
|
||||
name: 'redirect_type',
|
||||
comment: '短链重定向策略 (none/card/portal)',
|
||||
length: 20,
|
||||
default: 'none',
|
||||
})
|
||||
redirectType: HandoverRedirectType;
|
||||
|
||||
@Column({
|
||||
name: 'redirect_target',
|
||||
comment: '重定向目标 (接替人名片标识或微官网URL)',
|
||||
length: 500,
|
||||
nullable: true,
|
||||
})
|
||||
redirectTarget?: string | null;
|
||||
|
||||
@Column({
|
||||
name: 'lead_action',
|
||||
comment: '历史线索处理策略 (none/transfer/pool)',
|
||||
length: 20,
|
||||
default: 'none',
|
||||
})
|
||||
leadAction: HandoverLeadAction;
|
||||
|
||||
@Column({
|
||||
name: 'migrated_lead_count',
|
||||
comment: '实际迁移或划入公海的线索数量',
|
||||
default: 0,
|
||||
})
|
||||
migratedLeadCount: number;
|
||||
|
||||
@Column({
|
||||
name: 'operator_id',
|
||||
comment: '操作人/管理员 ID',
|
||||
nullable: true,
|
||||
})
|
||||
operatorId?: number | null;
|
||||
|
||||
@Column({
|
||||
name: 'status',
|
||||
comment: '工单状态 (completed: 已完成, failed: 失败)',
|
||||
length: 20,
|
||||
default: 'completed',
|
||||
})
|
||||
status: HandoverStatus;
|
||||
|
||||
@Column({
|
||||
name: 'remark',
|
||||
comment: '交接事由与操作说明',
|
||||
length: 500,
|
||||
nullable: true,
|
||||
})
|
||||
remark?: string | null;
|
||||
|
||||
@Column({
|
||||
name: 'snapshot',
|
||||
comment: '资产交接前安全快照 (不可逆审计防篡改存证)',
|
||||
type: 'text',
|
||||
nullable: true,
|
||||
})
|
||||
snapshot?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { BaseEntity, transformerJson } from '../../../comm/entity/base';
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
import {
|
||||
InvoiceBillingInfo,
|
||||
InvoiceItem,
|
||||
InvoiceStatus,
|
||||
SupportedCurrency,
|
||||
} from '../../../comm/types/omni';
|
||||
|
||||
/**
|
||||
* 国际多币种财税发票与收据实体
|
||||
* 表名: team_invoice
|
||||
*/
|
||||
@Entity('team_invoice')
|
||||
export class TeamInvoiceEntity extends BaseEntity {
|
||||
@Index({ unique: true })
|
||||
@Column({
|
||||
name: 'invoice_no',
|
||||
comment: '发票唯一流水号 (如 INV-YYYYMMDD-XXXX)',
|
||||
length: 64,
|
||||
})
|
||||
invoiceNo: string;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
name: 'order_id',
|
||||
comment: '关联业务/订阅订单号',
|
||||
length: 64,
|
||||
nullable: true,
|
||||
})
|
||||
orderId?: string | null;
|
||||
|
||||
@Column({
|
||||
name: 'currency',
|
||||
comment: '国际货币代码 (USD, EUR, GBP, CNY)',
|
||||
length: 10,
|
||||
default: 'USD',
|
||||
})
|
||||
currency: SupportedCurrency;
|
||||
|
||||
@Column({
|
||||
name: 'currency_symbol',
|
||||
comment: '货币符号 ($, €, £, ¥)',
|
||||
length: 10,
|
||||
default: '$',
|
||||
})
|
||||
currencySymbol: string;
|
||||
|
||||
@Column({
|
||||
name: 'subtotal',
|
||||
comment: '净额/不含税小计金额',
|
||||
type: 'decimal',
|
||||
precision: 12,
|
||||
scale: 2,
|
||||
default: 0,
|
||||
})
|
||||
subtotal: number;
|
||||
|
||||
@Column({
|
||||
name: 'tax_rate',
|
||||
comment: '税率 (如 0.1000 代表 10%)',
|
||||
type: 'decimal',
|
||||
precision: 6,
|
||||
scale: 4,
|
||||
default: 0,
|
||||
})
|
||||
taxRate: number;
|
||||
|
||||
@Column({
|
||||
name: 'tax_amount',
|
||||
comment: '税额',
|
||||
type: 'decimal',
|
||||
precision: 12,
|
||||
scale: 2,
|
||||
default: 0,
|
||||
})
|
||||
taxAmount: number;
|
||||
|
||||
@Column({
|
||||
name: 'total_amount',
|
||||
comment: '含税总额',
|
||||
type: 'decimal',
|
||||
precision: 12,
|
||||
scale: 2,
|
||||
default: 0,
|
||||
})
|
||||
totalAmount: number;
|
||||
|
||||
@Column({
|
||||
name: 'title',
|
||||
comment: '发票开具项目/订阅标题',
|
||||
length: 255,
|
||||
})
|
||||
title: string;
|
||||
|
||||
@Column({
|
||||
name: 'items',
|
||||
comment: '发票订单条目明细 JSON',
|
||||
type: 'text',
|
||||
nullable: true,
|
||||
transformer: transformerJson,
|
||||
})
|
||||
items?: InvoiceItem[] | null;
|
||||
|
||||
@Column({
|
||||
name: 'billing_info',
|
||||
comment: '买方开票抬头与信息 JSON',
|
||||
type: 'text',
|
||||
nullable: true,
|
||||
transformer: transformerJson,
|
||||
})
|
||||
billingInfo?: InvoiceBillingInfo | null;
|
||||
|
||||
@Column({
|
||||
name: 'status',
|
||||
comment: '发票状态 (paid, issued, refunded, void)',
|
||||
length: 20,
|
||||
default: 'paid',
|
||||
})
|
||||
status: InvoiceStatus;
|
||||
|
||||
@Column({
|
||||
name: 'paid_at',
|
||||
comment: '支付结算时间',
|
||||
length: 30,
|
||||
nullable: true,
|
||||
})
|
||||
paidAt?: string | null;
|
||||
|
||||
@Column({
|
||||
name: 'html_template',
|
||||
comment: '格式化 HTML 打印收据模板',
|
||||
type: 'text',
|
||||
nullable: true,
|
||||
})
|
||||
htmlTemplate?: string | null;
|
||||
|
||||
@Column({
|
||||
name: 'text_template',
|
||||
comment: '纯文本打印收据模板',
|
||||
type: 'text',
|
||||
nullable: true,
|
||||
})
|
||||
textTemplate?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { BaseEntity } from '../../../comm/entity/base';
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
import { TenantPlanType, StripeBillingCycle } from '../../../comm/types/omni';
|
||||
|
||||
export type SubscriptionStatus = 'active' | 'canceled' | 'past_due' | 'incomplete' | 'trialing';
|
||||
|
||||
/**
|
||||
* 团队订阅与 Stripe 支付账单台账
|
||||
* 表名: team_subscription
|
||||
*/
|
||||
@Entity('team_subscription')
|
||||
export class TeamSubscriptionEntity extends BaseEntity {
|
||||
@Index()
|
||||
@Column({
|
||||
name: 'stripe_customer_id',
|
||||
comment: 'Stripe 客户唯一标识 (cus_xxx)',
|
||||
length: 100,
|
||||
nullable: true,
|
||||
})
|
||||
stripeCustomerId?: string;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
name: 'stripe_subscription_id',
|
||||
comment: 'Stripe 订阅唯一标识 (sub_xxx)',
|
||||
length: 100,
|
||||
nullable: true,
|
||||
})
|
||||
stripeSubscriptionId?: string;
|
||||
|
||||
@Column({
|
||||
name: 'plan_type',
|
||||
comment: '当前订阅套餐类型',
|
||||
length: 32,
|
||||
default: 'free',
|
||||
})
|
||||
planType: TenantPlanType;
|
||||
|
||||
@Column({
|
||||
name: 'billing_cycle',
|
||||
comment: '结算周期 (monthly / yearly)',
|
||||
length: 16,
|
||||
default: 'monthly',
|
||||
})
|
||||
billingCycle: StripeBillingCycle;
|
||||
|
||||
@Column({
|
||||
name: 'status',
|
||||
comment: '订阅生命周期状态',
|
||||
length: 32,
|
||||
default: 'active',
|
||||
})
|
||||
status: SubscriptionStatus;
|
||||
|
||||
@Column({
|
||||
name: 'current_period_end',
|
||||
comment: '当前周期结束时间戳/日期',
|
||||
type: 'varchar',
|
||||
nullable: true,
|
||||
})
|
||||
currentPeriodEnd?: string | null;
|
||||
|
||||
@Column({
|
||||
name: 'cancel_at_period_end',
|
||||
comment: '周期结束时是否终止续费',
|
||||
default: false,
|
||||
})
|
||||
cancelAtPeriodEnd: boolean;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Provide } from '@midwayjs/core';
|
||||
import { BaseService } from '@cool-midway/core';
|
||||
import { BillingQuota, TenantPlanType } from '../../../comm/types/omni';
|
||||
|
||||
const PLAN_QUOTA: Record<
|
||||
TenantPlanType,
|
||||
{ seatsLimit: number; cardsLimit: number }
|
||||
> = {
|
||||
free: { seatsLimit: 1, cardsLimit: 1 },
|
||||
pro: { seatsLimit: 10, cardsLimit: 5 },
|
||||
enterprise: { seatsLimit: 100, cardsLimit: 999 },
|
||||
};
|
||||
|
||||
/**
|
||||
* 订阅账单骨架(不调用 Stripe,不产生真实扣款)
|
||||
*/
|
||||
@Provide()
|
||||
export class TeamBillingService extends BaseService {
|
||||
/**
|
||||
* 当前套餐与配额
|
||||
*/
|
||||
async currentPlan(req?: {
|
||||
planType?: TenantPlanType;
|
||||
seatsUsed?: number;
|
||||
cardsUsed?: number;
|
||||
}): Promise<BillingQuota> {
|
||||
const planType = req?.planType && PLAN_QUOTA[req.planType] ? req.planType : 'free';
|
||||
const quota = PLAN_QUOTA[planType];
|
||||
return {
|
||||
planType,
|
||||
seatsUsed: Math.max(0, Number(req?.seatsUsed || 0)),
|
||||
seatsLimit: quota.seatsLimit,
|
||||
cardsUsed: Math.max(0, Number(req?.cardsUsed || 0)),
|
||||
cardsLimit: quota.cardsLimit,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Init, Provide } from '@midwayjs/core';
|
||||
import { BaseService } from '@cool-midway/core';
|
||||
import { InjectEntityModel } from '@midwayjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { TeamBrandEntity } from '../entity/brand';
|
||||
|
||||
/**
|
||||
* 企业品牌 VI 服务
|
||||
*/
|
||||
@Provide()
|
||||
export class TeamBrandService extends BaseService {
|
||||
@InjectEntityModel(TeamBrandEntity)
|
||||
teamBrandEntity: Repository<TeamBrandEntity>;
|
||||
|
||||
@Init()
|
||||
async init() {
|
||||
await super.init();
|
||||
this.setEntity(this.teamBrandEntity);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
import { Init, Provide } from '@midwayjs/core';
|
||||
import { BaseService, CoolCommException } from '@cool-midway/core';
|
||||
import { InjectEntityModel } from '@midwayjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import * as dns from 'dns';
|
||||
import * as tls from 'tls';
|
||||
import { TeamDomainEntity } from '../entity/domain';
|
||||
import {
|
||||
DnsVerifyResult,
|
||||
SslCertMockInfo,
|
||||
SslCheckResult,
|
||||
ValidateDomainResult,
|
||||
} from '../../../comm/types/omni';
|
||||
|
||||
/**
|
||||
* 租户独立域名与 SSL 证书治理服务
|
||||
*/
|
||||
@Provide()
|
||||
export class TeamDomainService extends BaseService {
|
||||
@InjectEntityModel(TeamDomainEntity)
|
||||
teamDomainEntity: Repository<TeamDomainEntity>;
|
||||
|
||||
@Init()
|
||||
async init() {
|
||||
await super.init();
|
||||
this.setEntity(this.teamDomainEntity);
|
||||
}
|
||||
|
||||
/**
|
||||
* 正则严查企业二级域名格式
|
||||
* 拦截端口、特殊字符、IP 与内部保留域名
|
||||
*/
|
||||
validateDomainFormat(domain: string): ValidateDomainResult {
|
||||
if (!domain || typeof domain !== 'string' || !domain.trim()) {
|
||||
return { valid: false, reason: '域名不能为空' };
|
||||
}
|
||||
|
||||
const trimmed = domain.trim();
|
||||
|
||||
// 拦截带有协议前缀或路径
|
||||
if (
|
||||
trimmed.includes('://') ||
|
||||
trimmed.startsWith('//') ||
|
||||
trimmed.includes('/')
|
||||
) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: '域名不能包含协议前缀 (http:// 或 https://) 或路径',
|
||||
};
|
||||
}
|
||||
|
||||
// 拦截 IPv4 与 IPv6 地址
|
||||
const isIpv4 = /^(\d{1,3}\.){3}\d{1,3}$/.test(trimmed);
|
||||
const isIpv6 =
|
||||
trimmed === '::1' ||
|
||||
/^[0-9a-fA-F:]+$/.test(trimmed) && trimmed.includes(':');
|
||||
if (isIpv4 || isIpv6) {
|
||||
return { valid: false, reason: '不能使用 IP 地址作为自定义域名' };
|
||||
}
|
||||
|
||||
// 拦截端口号
|
||||
if (trimmed.includes(':')) {
|
||||
return { valid: false, reason: '域名不能包含端口号' };
|
||||
}
|
||||
|
||||
// 拦截内部保留域名及特定后缀
|
||||
const lower = trimmed.toLowerCase();
|
||||
const reservedSuffixes = [
|
||||
'.local',
|
||||
'.internal',
|
||||
'.lan',
|
||||
'.test',
|
||||
'.example',
|
||||
'.invalid',
|
||||
'.localhost',
|
||||
];
|
||||
if (
|
||||
lower === 'localhost' ||
|
||||
reservedSuffixes.some(suffix => lower.endsWith(suffix))
|
||||
) {
|
||||
return { valid: false, reason: '不能使用内部或保留域名' };
|
||||
}
|
||||
|
||||
// 拦截非法特殊字符(仅允许字母、数字、点号与连字符)
|
||||
if (/[^a-zA-Z0-9.-]/.test(trimmed)) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: '域名包含非法字符,仅支持英文字母、数字和连字符',
|
||||
};
|
||||
}
|
||||
|
||||
// 长度检查
|
||||
if (trimmed.length > 253) {
|
||||
return { valid: false, reason: '域名长度不能超过 253 个字符' };
|
||||
}
|
||||
|
||||
// 分段检查
|
||||
const labels = trimmed.split('.');
|
||||
if (labels.length < 2 || labels.some(l => l.length === 0)) {
|
||||
return { valid: false, reason: '域名必须是有效的二级或多级域名' };
|
||||
}
|
||||
|
||||
for (const label of labels) {
|
||||
if (label.length < 1 || label.length > 63) {
|
||||
return { valid: false, reason: '单个域名分段长度必须在 1-63 字符之间' };
|
||||
}
|
||||
if (label.startsWith('-') || label.endsWith('-')) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: '域名分段不能以连字符 (-) 开头或结尾',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 顶级域 TLD 必须全为字母且至少 2 位
|
||||
const tld = labels[labels.length - 1];
|
||||
if (!/^[a-zA-Z]{2,}$/.test(tld)) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: '顶级域名必须由至少 2 位英文字母组成',
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* CNAME DNS 解析核验(支持模拟记录与真实解析)
|
||||
*/
|
||||
async verifyDnsRecord(
|
||||
domain: string,
|
||||
targetCname: string,
|
||||
options?: { mockRecords?: string[] }
|
||||
): Promise<DnsVerifyResult> {
|
||||
const normalizedTarget = targetCname
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\.$/, '');
|
||||
|
||||
let records: string[] = [];
|
||||
if (options?.mockRecords !== undefined) {
|
||||
records = options.mockRecords;
|
||||
} else {
|
||||
try {
|
||||
records = await dns.promises.resolveCname(domain.trim());
|
||||
} catch {
|
||||
return {
|
||||
verified: false,
|
||||
records: [],
|
||||
targetCname: targetCname.trim(),
|
||||
message: 'DNS CNAME 记录未找到或解析失败',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedRecords = records.map(r =>
|
||||
r.trim().toLowerCase().replace(/\.$/, '')
|
||||
);
|
||||
const verified = normalizedRecords.includes(normalizedTarget);
|
||||
|
||||
return {
|
||||
verified,
|
||||
records,
|
||||
targetCname: targetCname.trim(),
|
||||
message: verified ? 'CNAME 解析配置正确' : 'CNAME 记录与目标地址不匹配',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* SSL 证书状态与过期时间计算(支持 TLS 真实握手与 Mock 注入)
|
||||
*/
|
||||
async checkSslCertificate(
|
||||
domain: string,
|
||||
port = 443,
|
||||
options?: { mockCert?: SslCertMockInfo }
|
||||
): Promise<SslCheckResult> {
|
||||
if (options?.mockCert) {
|
||||
const mock = options.mockCert;
|
||||
const issuer = mock.issuer || 'Mock CA';
|
||||
const validFrom = mock.validFrom
|
||||
? new Date(mock.validFrom).toISOString()
|
||||
: new Date().toISOString();
|
||||
const validTo = mock.validTo
|
||||
? new Date(mock.validTo).toISOString()
|
||||
: new Date().toISOString();
|
||||
const validToTime = new Date(validTo).getTime();
|
||||
const daysRemaining = Math.floor(
|
||||
(validToTime - Date.now()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
const expired = daysRemaining <= 0;
|
||||
const authorized = mock.authorized !== false;
|
||||
const valid = !expired && authorized;
|
||||
|
||||
return {
|
||||
valid,
|
||||
status: valid ? 'active' : 'failed',
|
||||
issuer,
|
||||
validFrom,
|
||||
validTo,
|
||||
daysRemaining,
|
||||
expired,
|
||||
errorMessage: expired
|
||||
? 'SSL 证书已过期'
|
||||
: !authorized
|
||||
? '证书不受信任'
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return new Promise<SslCheckResult>(resolve => {
|
||||
const host = domain.trim();
|
||||
const socket = tls.connect(
|
||||
{
|
||||
host,
|
||||
port,
|
||||
servername: host,
|
||||
timeout: 5000,
|
||||
rejectUnauthorized: false,
|
||||
},
|
||||
() => {
|
||||
try {
|
||||
const cert = socket.getPeerCertificate();
|
||||
const authorized = socket.authorized;
|
||||
socket.end();
|
||||
|
||||
if (!cert || Object.keys(cert).length === 0) {
|
||||
return resolve({
|
||||
valid: false,
|
||||
status: 'failed',
|
||||
errorMessage: '未获取到有效 SSL 证书',
|
||||
});
|
||||
}
|
||||
|
||||
const validTo = cert.valid_to;
|
||||
const validFrom = cert.valid_from;
|
||||
const rawIssuer = cert.issuer;
|
||||
let issuer = 'Unknown';
|
||||
if (rawIssuer && typeof rawIssuer === 'object') {
|
||||
const o = Array.isArray(rawIssuer.O) ? rawIssuer.O[0] : rawIssuer.O;
|
||||
const cn = Array.isArray(rawIssuer.CN) ? rawIssuer.CN[0] : rawIssuer.CN;
|
||||
issuer = o || cn || 'Unknown';
|
||||
} else if (rawIssuer) {
|
||||
issuer = String(rawIssuer);
|
||||
}
|
||||
const validToTime = new Date(validTo).getTime();
|
||||
const daysRemaining = Math.floor(
|
||||
(validToTime - Date.now()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
const expired = daysRemaining <= 0;
|
||||
const valid = !expired && authorized;
|
||||
|
||||
resolve({
|
||||
valid,
|
||||
status: valid ? 'active' : 'failed',
|
||||
issuer,
|
||||
validFrom,
|
||||
validTo,
|
||||
daysRemaining,
|
||||
expired,
|
||||
errorMessage: expired
|
||||
? 'SSL 证书已过期'
|
||||
: !authorized
|
||||
? socket.authorizationError?.message || '证书不受信任'
|
||||
: undefined,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
socket.destroy();
|
||||
resolve({
|
||||
valid: false,
|
||||
status: 'failed',
|
||||
errorMessage:
|
||||
err instanceof Error ? err.message : '解析证书异常',
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
socket.on('error', err => {
|
||||
resolve({
|
||||
valid: false,
|
||||
status: 'failed',
|
||||
errorMessage: err.message,
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('timeout', () => {
|
||||
socket.destroy();
|
||||
resolve({
|
||||
valid: false,
|
||||
status: 'failed',
|
||||
errorMessage: 'TLS 连接超时',
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定独立域名持久化
|
||||
*/
|
||||
async bindDomain(
|
||||
tenantId: number,
|
||||
domain: string,
|
||||
options?: { cnameTarget?: string }
|
||||
): Promise<TeamDomainEntity> {
|
||||
if (!tenantId || Number(tenantId) <= 0) {
|
||||
throw new CoolCommException('租户 ID 不能为空');
|
||||
}
|
||||
|
||||
const formatCheck = this.validateDomainFormat(domain);
|
||||
if (!formatCheck.valid) {
|
||||
throw new CoolCommException(`域名格式非法: ${formatCheck.reason}`);
|
||||
}
|
||||
|
||||
const normalizedDomain = domain.trim().toLowerCase();
|
||||
|
||||
// 冲突检查:是否已被其他租户占用
|
||||
const occupied = await this.teamDomainEntity.findOne({
|
||||
where: { customDomain: normalizedDomain },
|
||||
});
|
||||
if (occupied && occupied.tenantId !== tenantId) {
|
||||
throw new CoolCommException('该域名已被其他企业绑定');
|
||||
}
|
||||
|
||||
let target = occupied;
|
||||
if (!target) {
|
||||
target = await this.teamDomainEntity.findOne({
|
||||
where: { tenantId, customDomain: normalizedDomain },
|
||||
});
|
||||
}
|
||||
|
||||
if (!target) {
|
||||
target = new TeamDomainEntity();
|
||||
target.tenantId = tenantId;
|
||||
target.customDomain = normalizedDomain;
|
||||
target.sslStatus = 'pending';
|
||||
}
|
||||
|
||||
if (options?.cnameTarget) {
|
||||
target.cnameTarget = options.cnameTarget;
|
||||
}
|
||||
|
||||
return await this.teamDomainEntity.save(target);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import { Provide } from '@midwayjs/core';
|
||||
import { BaseService, CoolCommException } from '@cool-midway/core';
|
||||
import { InjectEntityModel } from '@midwayjs/typeorm';
|
||||
import { Repository, FindOptionsWhere } from 'typeorm';
|
||||
import { CardInfoEntity } from '../../card/entity/info';
|
||||
import { TeamHandoverLogEntity } from '../entity/handover-log';
|
||||
import { CrmLeadEntity } from '../../crm/entity/lead';
|
||||
import { CrmLeadAuditEntity } from '../../crm/entity/audit';
|
||||
import {
|
||||
ExecuteHandoverDTO,
|
||||
HandoverRequest,
|
||||
} from '../../../comm/types/omni';
|
||||
import { CardPortalService } from '../../card/service/portal';
|
||||
|
||||
/**
|
||||
* 离职资产安全交接服务
|
||||
* 涵盖:名片短链重定向、历史CRM线索批量迁移/公海流转、不可逆安全工单审计
|
||||
*/
|
||||
@Provide()
|
||||
export class TeamHandoverService extends BaseService {
|
||||
@InjectEntityModel(CardInfoEntity)
|
||||
cardInfoEntity: Repository<CardInfoEntity>;
|
||||
|
||||
@InjectEntityModel(TeamHandoverLogEntity)
|
||||
teamHandoverLogEntity: Repository<TeamHandoverLogEntity>;
|
||||
|
||||
@InjectEntityModel(CrmLeadEntity)
|
||||
crmLeadEntity: Repository<CrmLeadEntity>;
|
||||
|
||||
@InjectEntityModel(CrmLeadAuditEntity)
|
||||
crmLeadAuditEntity: Repository<CrmLeadAuditEntity>;
|
||||
|
||||
/**
|
||||
* 执行离职资产全流程安全交接
|
||||
*/
|
||||
async executeHandover(
|
||||
dto: ExecuteHandoverDTO
|
||||
): Promise<TeamHandoverLogEntity> {
|
||||
const fromUserId = Number(dto?.fromUserId);
|
||||
const toUserId =
|
||||
dto?.toUserId !== undefined && dto?.toUserId !== null && dto?.toUserId !== ''
|
||||
? Number(dto.toUserId)
|
||||
: null;
|
||||
|
||||
if (!fromUserId) {
|
||||
throw new CoolCommException('离职员工标识不能为空');
|
||||
}
|
||||
|
||||
if (toUserId !== null && fromUserId === toUserId) {
|
||||
throw new CoolCommException('接替人不能与原负责人相同');
|
||||
}
|
||||
|
||||
const leadAction = dto.leadAction || 'none';
|
||||
if (leadAction === 'transfer' && !toUserId) {
|
||||
throw new CoolCommException('转移线索时接替人不能为空');
|
||||
}
|
||||
|
||||
// 1. 离职员工名片短链安全重定向
|
||||
const redirectType = dto.redirectType || 'none';
|
||||
const redirectTarget = dto.redirectTarget || null;
|
||||
|
||||
if (dto.cardId) {
|
||||
const cardId = Number(dto.cardId);
|
||||
const card = await this.cardInfoEntity.findOneBy({ id: cardId });
|
||||
if (!card) {
|
||||
throw new CoolCommException('名片不存在');
|
||||
}
|
||||
if (Number(card.userId) !== fromUserId) {
|
||||
throw new CoolCommException('名片不属于原负责人');
|
||||
}
|
||||
card.redirectType = redirectType;
|
||||
card.redirectTarget = redirectTarget;
|
||||
if (toUserId && redirectType !== 'portal') {
|
||||
card.userId = toUserId;
|
||||
}
|
||||
this.syncInternalConfigRedirect(card, redirectType, redirectTarget);
|
||||
await this.cardInfoEntity.save(card);
|
||||
CardPortalService.clearAllCache();
|
||||
} else if (redirectType !== 'none') {
|
||||
const userCards = await this.cardInfoEntity.find({
|
||||
where: { userId: fromUserId },
|
||||
});
|
||||
for (const c of userCards) {
|
||||
c.redirectType = redirectType;
|
||||
c.redirectTarget = redirectTarget;
|
||||
if (toUserId && redirectType !== 'portal') {
|
||||
c.userId = toUserId;
|
||||
}
|
||||
this.syncInternalConfigRedirect(c, redirectType, redirectTarget);
|
||||
await this.cardInfoEntity.save(c);
|
||||
}
|
||||
if (userCards.length > 0) {
|
||||
CardPortalService.clearAllCache();
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 历史 CRM 客户线索批量原子流转 (转移至接任人或划入公海池)
|
||||
const leads = await this.crmLeadEntity.find({
|
||||
where: {
|
||||
userId: fromUserId,
|
||||
isPool: false,
|
||||
},
|
||||
});
|
||||
|
||||
const leadIds = leads.map(l => l.id);
|
||||
let migratedLeadCount = 0;
|
||||
|
||||
if (leads.length > 0) {
|
||||
const now = new Date();
|
||||
if (leadAction === 'transfer' && toUserId) {
|
||||
for (const lead of leads) {
|
||||
lead.userId = toUserId;
|
||||
lead.isPool = false;
|
||||
lead.lastFollowUpTime = now;
|
||||
}
|
||||
await this.crmLeadEntity.save(leads);
|
||||
|
||||
const audits = leads.map(lead => {
|
||||
const audit = new CrmLeadAuditEntity();
|
||||
audit.leadId = lead.id;
|
||||
audit.action = 'assign';
|
||||
audit.operatorId = dto.operatorId ? Number(dto.operatorId) : null;
|
||||
audit.fromUserId = fromUserId;
|
||||
audit.toUserId = toUserId;
|
||||
audit.reason = dto.remark || '员工离职资产交接-转移至接任人';
|
||||
return audit;
|
||||
});
|
||||
await this.crmLeadAuditEntity.save(audits);
|
||||
migratedLeadCount = leads.length;
|
||||
} else if (leadAction === 'pool') {
|
||||
for (const lead of leads) {
|
||||
lead.userId = null;
|
||||
lead.isPool = true;
|
||||
lead.lastFollowUpTime = now;
|
||||
}
|
||||
await this.crmLeadEntity.save(leads);
|
||||
|
||||
const audits = leads.map(lead => {
|
||||
const audit = new CrmLeadAuditEntity();
|
||||
audit.leadId = lead.id;
|
||||
audit.action = 'recycle';
|
||||
audit.operatorId = dto.operatorId ? Number(dto.operatorId) : null;
|
||||
audit.fromUserId = fromUserId;
|
||||
audit.toUserId = null;
|
||||
audit.reason = dto.remark || '员工离职资产交接-划入公海池';
|
||||
return audit;
|
||||
});
|
||||
await this.crmLeadAuditEntity.save(audits);
|
||||
migratedLeadCount = leads.length;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 写入不可逆安全交接工单历史表
|
||||
const handoverNo = `HO${Date.now()}${Math.floor(Math.random() * 9000 + 1000)}`;
|
||||
const snapshot = JSON.stringify({
|
||||
fromUserId,
|
||||
toUserId,
|
||||
cardId: dto.cardId ? Number(dto.cardId) : null,
|
||||
leadIds,
|
||||
leadAction,
|
||||
redirectType,
|
||||
redirectTarget,
|
||||
migratedLeadCount,
|
||||
handoverTime: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const log = new TeamHandoverLogEntity();
|
||||
log.handoverNo = handoverNo;
|
||||
log.fromUserId = fromUserId;
|
||||
log.toUserId = toUserId;
|
||||
log.cardId = dto.cardId ? Number(dto.cardId) : null;
|
||||
log.redirectType = redirectType;
|
||||
log.redirectTarget = redirectTarget;
|
||||
log.leadAction = leadAction;
|
||||
log.migratedLeadCount = migratedLeadCount;
|
||||
log.operatorId = dto.operatorId ? Number(dto.operatorId) : null;
|
||||
log.status = 'completed';
|
||||
log.remark = dto.remark || null;
|
||||
log.snapshot = snapshot;
|
||||
|
||||
return await this.teamHandoverLogEntity.save(log);
|
||||
}
|
||||
|
||||
/**
|
||||
* 调阅离职交接工单历史列表
|
||||
*/
|
||||
async getHandoverLogs(query?: {
|
||||
fromUserId?: number;
|
||||
toUserId?: number;
|
||||
}): Promise<TeamHandoverLogEntity[]> {
|
||||
const where: FindOptionsWhere<TeamHandoverLogEntity> = {};
|
||||
if (query?.fromUserId) where.fromUserId = Number(query.fromUserId);
|
||||
if (query?.toUserId) where.toUserId = Number(query.toUserId);
|
||||
return await this.teamHandoverLogEntity.find({
|
||||
where: Object.keys(where).length > 0 ? where : undefined,
|
||||
order: { id: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 兼容原有轻量 transfer 接口
|
||||
*/
|
||||
async transfer(req: HandoverRequest): Promise<CardInfoEntity> {
|
||||
const cardId = Number(req?.cardId);
|
||||
const fromUserId = Number(req?.fromUserId);
|
||||
const toUserId = Number(req?.toUserId);
|
||||
|
||||
if (!cardId || !fromUserId || !toUserId) {
|
||||
throw new CoolCommException('交接参数不完整');
|
||||
}
|
||||
|
||||
await this.executeHandover({
|
||||
cardId,
|
||||
fromUserId,
|
||||
toUserId,
|
||||
redirectType: 'card',
|
||||
redirectTarget: String(toUserId),
|
||||
leadAction: 'transfer',
|
||||
remark: '快速名片资产交接',
|
||||
});
|
||||
|
||||
const card = await this.cardInfoEntity.findOneBy({ id: cardId });
|
||||
if (!card) {
|
||||
throw new CoolCommException('名片不存在');
|
||||
}
|
||||
return card;
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步更新名片内部配置
|
||||
*/
|
||||
private syncInternalConfigRedirect(
|
||||
card: CardInfoEntity,
|
||||
redirectType: string,
|
||||
redirectTarget: string | null
|
||||
): void {
|
||||
let cfg: Record<string, unknown> = {};
|
||||
if (card.internalConfig) {
|
||||
try {
|
||||
cfg = JSON.parse(card.internalConfig) as Record<string, unknown>;
|
||||
} catch {
|
||||
cfg = {};
|
||||
}
|
||||
}
|
||||
cfg.redirectType = redirectType;
|
||||
cfg.redirectTarget = redirectTarget;
|
||||
cfg.handoverAt = new Date().toISOString();
|
||||
card.internalConfig = JSON.stringify(cfg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
import { Provide } from '@midwayjs/core';
|
||||
import { BaseService, CoolCommException } from '@cool-midway/core';
|
||||
import { InjectEntityModel } from '@midwayjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import * as moment from 'moment';
|
||||
import { TeamInvoiceEntity } from '../entity/invoice';
|
||||
import {
|
||||
CreateInvoiceDTO,
|
||||
InvoiceRenderParams,
|
||||
SupportedCurrency,
|
||||
TaxCalculationResult,
|
||||
} from '../../../comm/types/omni';
|
||||
|
||||
/**
|
||||
* 货币符号映射表
|
||||
*/
|
||||
const CURRENCY_SYMBOLS: Record<SupportedCurrency, string> = {
|
||||
USD: '$',
|
||||
EUR: '€',
|
||||
GBP: '£',
|
||||
CNY: '¥',
|
||||
};
|
||||
|
||||
/**
|
||||
* 国际多币种发票生成与历史收据服务
|
||||
*/
|
||||
@Provide()
|
||||
export class TeamInvoiceService extends BaseService {
|
||||
@InjectEntityModel(TeamInvoiceEntity)
|
||||
teamInvoiceEntity: Repository<TeamInvoiceEntity>;
|
||||
|
||||
/**
|
||||
* 获取币种符号 (USD $, EUR €, GBP £, CNY ¥)
|
||||
*/
|
||||
getCurrencySymbol(currency?: string | null): string {
|
||||
if (!currency) return '$';
|
||||
const normalized = currency.trim().toUpperCase() as SupportedCurrency;
|
||||
return CURRENCY_SYMBOLS[normalized] || '$';
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化币种代码
|
||||
*/
|
||||
normalizeCurrency(currency?: string | null): SupportedCurrency {
|
||||
if (!currency) return 'USD';
|
||||
const normalized = currency.trim().toUpperCase() as SupportedCurrency;
|
||||
return CURRENCY_SYMBOLS[normalized] ? normalized : 'USD';
|
||||
}
|
||||
|
||||
/**
|
||||
* 金额格式化带货币符号 (如 $100.00, €85.50)
|
||||
*/
|
||||
formatCurrency(amount: number, currency?: string | null): string {
|
||||
const symbol = this.getCurrencySymbol(currency);
|
||||
const validAmount = Number.isFinite(amount) ? amount : 0;
|
||||
return `${symbol}${validAmount.toFixed(2)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成唯一发票流水号 (INV-YYYYMMDD-XXXX)
|
||||
*/
|
||||
generateInvoiceNumber(date: Date = new Date(), sequence: number = 1): string {
|
||||
const dateStr = moment(date).format('YYYYMMDD');
|
||||
const seqStr = String(sequence).padStart(4, '0');
|
||||
return `INV-${dateStr}-${seqStr}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 精确计算税费与净额 (支持含税总额与不含税净额折算,四舍五入防浮点误差)
|
||||
*/
|
||||
calculateTaxAndTotals(params: {
|
||||
subtotal?: number;
|
||||
totalAmount?: number;
|
||||
taxRate?: number;
|
||||
isTaxInclusive?: boolean;
|
||||
taxAmount?: number;
|
||||
}): TaxCalculationResult {
|
||||
const rawRate = Number(params.taxRate || 0);
|
||||
const rate = rawRate > 1 ? rawRate / 100 : Math.max(0, rawRate);
|
||||
const round2 = (num: number): number =>
|
||||
Math.round((num + Number.EPSILON) * 100) / 100;
|
||||
|
||||
let subtotal = 0;
|
||||
let taxAmount = 0;
|
||||
let totalAmount = 0;
|
||||
|
||||
if (params.isTaxInclusive) {
|
||||
// 含税总额逆向折算净额与税额
|
||||
const inputTotal = Number(params.totalAmount ?? params.subtotal ?? 0);
|
||||
totalAmount = round2(inputTotal);
|
||||
subtotal = round2(totalAmount / (1 + rate));
|
||||
taxAmount = round2(totalAmount - subtotal);
|
||||
} else {
|
||||
// 不含税净额正向折算税额与总额
|
||||
subtotal = round2(Number(params.subtotal ?? params.totalAmount ?? 0));
|
||||
if (typeof params.taxAmount === 'number') {
|
||||
taxAmount = round2(params.taxAmount);
|
||||
} else {
|
||||
taxAmount = round2(subtotal * rate);
|
||||
}
|
||||
totalAmount = round2(subtotal + taxAmount);
|
||||
}
|
||||
|
||||
return {
|
||||
subtotal,
|
||||
taxRate: rate,
|
||||
taxAmount,
|
||||
totalAmount,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染瑞士国际主义风格结构化 HTML 发票模板
|
||||
*/
|
||||
renderHtmlTemplate(params: InvoiceRenderParams): string {
|
||||
const {
|
||||
invoiceNo,
|
||||
issueDate,
|
||||
currency,
|
||||
subtotal,
|
||||
taxRate,
|
||||
taxAmount,
|
||||
totalAmount,
|
||||
billingInfo,
|
||||
items = [],
|
||||
} = params;
|
||||
|
||||
const companyName = billingInfo?.companyName || 'Valued Customer';
|
||||
const taxId = billingInfo?.taxId ? `Tax ID: ${billingInfo.taxId}` : '';
|
||||
const address = billingInfo?.address || '';
|
||||
|
||||
const itemsRows = items.length > 0
|
||||
? items
|
||||
.map(
|
||||
item => `
|
||||
<tr>
|
||||
<td style="padding: 12px 0; border-bottom: 1px solid #E3E7ED; color: #101828; font-size: 14px;">${item.description}</td>
|
||||
<td style="padding: 12px 0; border-bottom: 1px solid #E3E7ED; color: #667085; font-size: 14px; text-align: center;">${item.quantity}</td>
|
||||
<td style="padding: 12px 0; border-bottom: 1px solid #E3E7ED; color: #667085; font-size: 14px; text-align: right;">${this.formatCurrency(item.unitPrice, currency)}</td>
|
||||
<td style="padding: 12px 0; border-bottom: 1px solid #E3E7ED; color: #101828; font-size: 14px; text-align: right; font-weight: 500;">${this.formatCurrency(item.amount, currency)}</td>
|
||||
</tr>`
|
||||
)
|
||||
.join('')
|
||||
: `
|
||||
<tr>
|
||||
<td style="padding: 12px 0; border-bottom: 1px solid #E3E7ED; color: #101828; font-size: 14px;">SaaS Subscription Service</td>
|
||||
<td style="padding: 12px 0; border-bottom: 1px solid #E3E7ED; color: #667085; font-size: 14px; text-align: center;">1</td>
|
||||
<td style="padding: 12px 0; border-bottom: 1px solid #E3E7ED; color: #667085; font-size: 14px; text-align: right;">${this.formatCurrency(subtotal, currency)}</td>
|
||||
<td style="padding: 12px 0; border-bottom: 1px solid #E3E7ED; color: #101828; font-size: 14px; text-align: right; font-weight: 500;">${this.formatCurrency(subtotal, currency)}</td>
|
||||
</tr>`;
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Invoice ${invoiceNo}</title>
|
||||
<style>
|
||||
@media print {
|
||||
body { margin: 0; background: #FFF; }
|
||||
.no-print { display: none; }
|
||||
}
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Arial, sans-serif;
|
||||
background: #F7F8FA;
|
||||
margin: 0;
|
||||
padding: 32px 16px;
|
||||
color: #101828;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.invoice-card {
|
||||
max-width: 680px;
|
||||
margin: 0 auto;
|
||||
background: #FFFFFF;
|
||||
border: 1px solid #E3E7ED;
|
||||
border-radius: 16px;
|
||||
padding: 36px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
background: #EAF1FF;
|
||||
color: #0B5CFF;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
border-radius: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.section-title {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
color: #667085;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 24px 0 16px;
|
||||
}
|
||||
th {
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
color: #667085;
|
||||
letter-spacing: 0.5px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #101828;
|
||||
font-weight: 600;
|
||||
}
|
||||
.totals-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 6px 0;
|
||||
font-size: 14px;
|
||||
color: #667085;
|
||||
}
|
||||
.totals-row.final {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #101828;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #101828;
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="invoice-card">
|
||||
<div class="header">
|
||||
<div>
|
||||
<h1 style="margin: 0 0 4px; font-size: 24px; font-weight: 700; letter-spacing: -0.5px;">INVOICE</h1>
|
||||
<div style="font-size: 13px; color: #667085;">OmniCard AI Global Platform</div>
|
||||
</div>
|
||||
<div style="text-align: right;">
|
||||
<span class="badge">PAID</span>
|
||||
<div style="margin-top: 8px; font-size: 13px; font-weight: 600; color: #101828;">${invoiceNo}</div>
|
||||
<div style="font-size: 12px; color: #667085;">Issue Date: ${issueDate}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 24px; padding-bottom: 20px; border-bottom: 1px solid #E3E7ED;">
|
||||
<div>
|
||||
<div class="section-title">Billed To</div>
|
||||
<div style="font-size: 14px; font-weight: 600; color: #101828;">${companyName}</div>
|
||||
${taxId ? `<div style="font-size: 13px; color: #667085;">${taxId}</div>` : ''}
|
||||
${address ? `<div style="font-size: 13px; color: #667085;">${address}</div>` : ''}
|
||||
</div>
|
||||
<div style="text-align: right;">
|
||||
<div class="section-title">Currency</div>
|
||||
<div style="font-size: 14px; font-weight: 600; color: #101828;">${currency} (${params.currencySymbol})</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Description</th>
|
||||
<th style="text-align: center; width: 60px;">Qty</th>
|
||||
<th style="text-align: right; width: 100px;">Price</th>
|
||||
<th style="text-align: right; width: 100px;">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${itemsRows}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div style="width: 260px; margin-left: auto;">
|
||||
<div class="totals-row">
|
||||
<span>Net Subtotal:</span>
|
||||
<span style="font-weight: 500; color: #101828;">${this.formatCurrency(subtotal, currency)}</span>
|
||||
</div>
|
||||
<div class="totals-row">
|
||||
<span>Tax (${(taxRate * 100).toFixed(1)}%):</span>
|
||||
<span style="font-weight: 500; color: #101828;">${this.formatCurrency(taxAmount, currency)}</span>
|
||||
</div>
|
||||
<div class="totals-row final">
|
||||
<span>Total:</span>
|
||||
<span>${this.formatCurrency(totalAmount, currency)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染纯文本打印收据模板
|
||||
*/
|
||||
renderTextTemplate(params: InvoiceRenderParams): string {
|
||||
const {
|
||||
invoiceNo,
|
||||
issueDate,
|
||||
currency,
|
||||
subtotal,
|
||||
taxRate,
|
||||
taxAmount,
|
||||
totalAmount,
|
||||
billingInfo,
|
||||
items = [],
|
||||
} = params;
|
||||
|
||||
const companyName = billingInfo?.companyName || 'Valued Customer';
|
||||
const lines: string[] = [
|
||||
'============================================================',
|
||||
' INVOICE / RECEIPT ',
|
||||
' OmniCard AI Platform ',
|
||||
'============================================================',
|
||||
`Invoice No : ${invoiceNo}`,
|
||||
`Date : ${issueDate}`,
|
||||
`Status : PAID`,
|
||||
`Currency : ${currency} (${params.currencySymbol})`,
|
||||
'------------------------------------------------------------',
|
||||
`Billed To : ${companyName}`,
|
||||
billingInfo?.taxId ? `Tax ID : ${billingInfo.taxId}` : '',
|
||||
billingInfo?.address ? `Address : ${billingInfo.address}` : '',
|
||||
'------------------------------------------------------------',
|
||||
'Item Description Qty Price Amount ',
|
||||
'------------------------------------------------------------',
|
||||
].filter(Boolean);
|
||||
|
||||
if (items.length > 0) {
|
||||
items.forEach(item => {
|
||||
const desc = item.description.padEnd(30, ' ').slice(0, 30);
|
||||
const qty = String(item.quantity).padStart(4, ' ');
|
||||
const price = this.formatCurrency(item.unitPrice, currency).padStart(9, ' ');
|
||||
const amt = this.formatCurrency(item.amount, currency).padStart(11, ' ');
|
||||
lines.push(`${desc} ${qty} ${price} ${amt}`);
|
||||
});
|
||||
} else {
|
||||
const desc = 'SaaS Subscription Service'.padEnd(30, ' ');
|
||||
const qty = '1'.padStart(4, ' ');
|
||||
const price = this.formatCurrency(subtotal, currency).padStart(9, ' ');
|
||||
const amt = this.formatCurrency(subtotal, currency).padStart(11, ' ');
|
||||
lines.push(`${desc} ${qty} ${price} ${amt}`);
|
||||
}
|
||||
|
||||
lines.push('------------------------------------------------------------');
|
||||
lines.push(`Net Subtotal : ${this.formatCurrency(subtotal, currency)}`);
|
||||
lines.push(`Tax (${(taxRate * 100).toFixed(1)}%) : ${this.formatCurrency(taxAmount, currency)}`);
|
||||
lines.push('============================================================');
|
||||
lines.push(`TOTAL AMOUNT : ${this.formatCurrency(totalAmount, currency)}`);
|
||||
lines.push('============================================================');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成包含唯一流水号的国际发票
|
||||
*/
|
||||
async generateInvoice(
|
||||
tenantId: number,
|
||||
orderData: CreateInvoiceDTO
|
||||
): Promise<TeamInvoiceEntity> {
|
||||
const validTenantId = Number(tenantId);
|
||||
if (!validTenantId || validTenantId <= 0) {
|
||||
throw new CoolCommException('企业租户 ID 不合法');
|
||||
}
|
||||
|
||||
const currency = this.normalizeCurrency(orderData?.currency);
|
||||
const currencySymbol = this.getCurrencySymbol(currency);
|
||||
const calc = this.calculateTaxAndTotals({
|
||||
subtotal: orderData?.subtotal,
|
||||
totalAmount: orderData?.totalAmount,
|
||||
taxRate: orderData?.taxRate,
|
||||
isTaxInclusive: orderData?.isTaxInclusive,
|
||||
taxAmount: orderData?.taxAmount,
|
||||
});
|
||||
|
||||
const count = await this.teamInvoiceEntity.count();
|
||||
const invoiceNo = this.generateInvoiceNumber(new Date(), count + 1);
|
||||
const issueDate = moment().format('YYYY-MM-DD');
|
||||
|
||||
const renderParams: InvoiceRenderParams = {
|
||||
invoiceNo,
|
||||
issueDate,
|
||||
currency,
|
||||
currencySymbol,
|
||||
subtotal: calc.subtotal,
|
||||
taxRate: calc.taxRate,
|
||||
taxAmount: calc.taxAmount,
|
||||
totalAmount: calc.totalAmount,
|
||||
billingInfo: orderData?.billingInfo,
|
||||
items: orderData?.items,
|
||||
};
|
||||
|
||||
const htmlTemplate = this.renderHtmlTemplate(renderParams);
|
||||
const textTemplate = this.renderTextTemplate(renderParams);
|
||||
|
||||
const invoice = new TeamInvoiceEntity();
|
||||
invoice.tenantId = validTenantId;
|
||||
invoice.invoiceNo = invoiceNo;
|
||||
invoice.orderId = orderData?.orderId || null;
|
||||
invoice.currency = currency;
|
||||
invoice.currencySymbol = currencySymbol;
|
||||
invoice.subtotal = calc.subtotal;
|
||||
invoice.taxRate = calc.taxRate;
|
||||
invoice.taxAmount = calc.taxAmount;
|
||||
invoice.totalAmount = calc.totalAmount;
|
||||
invoice.title = orderData?.title || 'SaaS Subscription Invoice';
|
||||
invoice.items = orderData?.items || null;
|
||||
invoice.billingInfo = orderData?.billingInfo || null;
|
||||
invoice.status = orderData?.status || 'paid';
|
||||
invoice.paidAt =
|
||||
orderData?.paidAt ? String(orderData.paidAt) : moment().format('YYYY-MM-DD HH:mm:ss');
|
||||
invoice.htmlTemplate = htmlTemplate;
|
||||
invoice.textTemplate = textTemplate;
|
||||
|
||||
return await this.teamInvoiceEntity.save(invoice);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询租户历史发票收据列表
|
||||
*/
|
||||
async listInvoices(tenantId: number): Promise<TeamInvoiceEntity[]> {
|
||||
const validTenantId = Number(tenantId);
|
||||
if (!validTenantId || validTenantId <= 0) {
|
||||
throw new CoolCommException('企业租户 ID 不合法');
|
||||
}
|
||||
|
||||
return await this.teamInvoiceEntity.find({
|
||||
where: { tenantId: validTenantId },
|
||||
order: { createTime: 'DESC' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { Provide } from '@midwayjs/core';
|
||||
import { BaseService } from '@cool-midway/core';
|
||||
import { InjectEntityModel } from '@midwayjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { BaseSysUserEntity } from '../../base/entity/sys/user';
|
||||
import { CardInfoEntity } from '../../card/entity/info';
|
||||
import { CrmLeadEntity } from '../../crm/entity/lead';
|
||||
import { CrmTrackEntity } from '../../crm/entity/track';
|
||||
import type {
|
||||
TeamMemberRankItem,
|
||||
TeamRankBroadcastPayload,
|
||||
TeamRankBroadcastMeta,
|
||||
} from '../../../comm/types/omni';
|
||||
|
||||
/**
|
||||
* 实时大屏 WebSocket 广播默认事件标识
|
||||
*/
|
||||
export const TEAM_LIVE_RANK_EVENT = 'team.live_rank' as const;
|
||||
|
||||
/**
|
||||
* 展会现场团队获客实时排行榜服务
|
||||
* 职责:按成员聚合线索获取数 (Leads)、名片触碰量 (Touches),并按每线索 10 分、每触碰 1 分进行加权算分与同分并列排序,
|
||||
* 并支持展会事件 (eventId) 细粒度过滤与 WebSocket 广播载荷结构化生成。
|
||||
*/
|
||||
@Provide()
|
||||
export class TeamLiveRankService extends BaseService {
|
||||
@InjectEntityModel(BaseSysUserEntity)
|
||||
baseSysUserEntity: Repository<BaseSysUserEntity>;
|
||||
|
||||
@InjectEntityModel(CardInfoEntity)
|
||||
cardInfoEntity: Repository<CardInfoEntity>;
|
||||
|
||||
@InjectEntityModel(CrmLeadEntity)
|
||||
crmLeadEntity: Repository<CrmLeadEntity>;
|
||||
|
||||
@InjectEntityModel(CrmTrackEntity)
|
||||
crmTrackEntity: Repository<CrmTrackEntity>;
|
||||
|
||||
/**
|
||||
* 获取展会现场团队获客实时降序排行榜
|
||||
* @param tenantId 企业租户 ID
|
||||
* @param eventId 可选展会事件标识 (如 'CES-2026')
|
||||
*/
|
||||
async getTeamLiveRank(
|
||||
tenantId: string,
|
||||
eventId?: string
|
||||
): Promise<TeamMemberRankItem[]> {
|
||||
const numTenantId = Number(tenantId);
|
||||
const tenantCriteria =
|
||||
!isNaN(numTenantId) && numTenantId > 0 ? { tenantId: numTenantId } : {};
|
||||
|
||||
// 1. 查询该租户下全部成员
|
||||
const users = await this.baseSysUserEntity.find({
|
||||
where: tenantCriteria,
|
||||
});
|
||||
if (!users || users.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 2. 查询该租户下名片列表
|
||||
const cards = await this.cardInfoEntity.find({
|
||||
where: tenantCriteria,
|
||||
});
|
||||
|
||||
// 建立 memberId -> CardInfoEntity[] 映射
|
||||
const memberCardMap = new Map<number, CardInfoEntity[]>();
|
||||
for (const card of cards) {
|
||||
const uId = Number(card.userId);
|
||||
if (uId) {
|
||||
const list = memberCardMap.get(uId) || [];
|
||||
list.push(card);
|
||||
memberCardMap.set(uId, list);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 查询该租户下潜客线索
|
||||
const rawLeads = await this.crmLeadEntity.find({
|
||||
where: tenantCriteria,
|
||||
});
|
||||
|
||||
// 4. 查询该租户下访客触碰足迹
|
||||
const rawTracks = await this.crmTrackEntity.find({
|
||||
where: tenantCriteria,
|
||||
});
|
||||
|
||||
// 5. 按 eventId 过滤线索与触碰 (若传入 eventId)
|
||||
const filterEvent = <T extends { notes?: string }>(item: T): boolean => {
|
||||
if (!eventId) return true;
|
||||
const explicitEvent = (item as unknown as { eventId?: string }).eventId;
|
||||
if (explicitEvent !== undefined && explicitEvent !== null && explicitEvent !== '') {
|
||||
return String(explicitEvent) === String(eventId);
|
||||
}
|
||||
if (item.notes && typeof item.notes === 'string' && item.notes.includes(eventId)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const leads = rawLeads.filter(filterEvent);
|
||||
const tracks = rawTracks.filter(item => {
|
||||
if (!eventId) return true;
|
||||
const explicitEvent = (item as unknown as { eventId?: string }).eventId;
|
||||
if (explicitEvent !== undefined && explicitEvent !== null && explicitEvent !== '') {
|
||||
return String(explicitEvent) === String(eventId);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// 6. 聚合每个成员的线索数与触碰量
|
||||
const memberRankList: TeamMemberRankItem[] = users.map(user => {
|
||||
const userCards = memberCardMap.get(user.id) || [];
|
||||
const userCardIds = new Set<number>(userCards.map(c => Number(c.id)));
|
||||
|
||||
// 聚合线索数 (去重防重计)
|
||||
const userLeads = leads.filter(l => {
|
||||
if (Number(l.userId) === user.id) return true;
|
||||
if (l.cardId !== undefined && userCardIds.has(Number(l.cardId))) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
// 聚合触碰量 (名片触碰)
|
||||
const userTouches = tracks.filter(t => {
|
||||
if (t.cardId !== undefined && userCardIds.has(Number(t.cardId))) return true;
|
||||
const trackUserId = (t as unknown as { userId?: number }).userId;
|
||||
if (trackUserId !== undefined && Number(trackUserId) === user.id) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
const leadsCount = userLeads.length;
|
||||
const touchesCount = userTouches.length;
|
||||
// 算分加权公式:每线索 10 分,每触碰 1 分
|
||||
const score = leadsCount * 10 + touchesCount * 1;
|
||||
const primaryCard = userCards[0];
|
||||
|
||||
return {
|
||||
memberId: user.id,
|
||||
memberName: user.name || user.nickName || user.username || `Member ${user.id}`,
|
||||
avatarUrl: user.headImg || '',
|
||||
departmentId: user.departmentId,
|
||||
departmentName: user.departmentName || '',
|
||||
cardId: primaryCard ? Number(primaryCard.id) : undefined,
|
||||
leads: leadsCount,
|
||||
touches: touchesCount,
|
||||
score,
|
||||
rank: 1, // 稍后排序并计算同分并列
|
||||
isTied: false,
|
||||
};
|
||||
});
|
||||
|
||||
// 7. 综合降序排序:分数降序 -> 线索数降序 -> 触碰量降序 -> 成员ID升序 (稳定排序)
|
||||
memberRankList.sort((a, b) => {
|
||||
if (b.score !== a.score) {
|
||||
return b.score - a.score;
|
||||
}
|
||||
if (b.leads !== a.leads) {
|
||||
return b.leads - a.leads;
|
||||
}
|
||||
if (b.touches !== a.touches) {
|
||||
return b.touches - a.touches;
|
||||
}
|
||||
return a.memberId - b.memberId;
|
||||
});
|
||||
|
||||
// 8. 计算同分并列排名 (标准竞赛并列规则:1, 2, 2, 4...)
|
||||
const scoreFrequency = new Map<number, number>();
|
||||
for (const item of memberRankList) {
|
||||
scoreFrequency.set(item.score, (scoreFrequency.get(item.score) || 0) + 1);
|
||||
}
|
||||
|
||||
for (let i = 0; i < memberRankList.length; i++) {
|
||||
if (i === 0) {
|
||||
memberRankList[i].rank = 1;
|
||||
} else {
|
||||
if (memberRankList[i].score === memberRankList[i - 1].score) {
|
||||
memberRankList[i].rank = memberRankList[i - 1].rank;
|
||||
} else {
|
||||
memberRankList[i].rank = i + 1;
|
||||
}
|
||||
}
|
||||
memberRankList[i].isTied = (scoreFrequency.get(memberRankList[i].score) || 0) > 1;
|
||||
}
|
||||
|
||||
return memberRankList;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成结构化 WebSocket 广播事件载荷
|
||||
* @param ranks 实时排行榜列表
|
||||
* @param options 可选广播元信息 (租户 ID、展会事件 ID)
|
||||
*/
|
||||
formatRankBroadcastPayload(
|
||||
ranks: TeamMemberRankItem[],
|
||||
options?: { tenantId?: string; eventId?: string }
|
||||
): TeamRankBroadcastPayload {
|
||||
const topScore = ranks.length > 0 ? ranks[0].score : 0;
|
||||
const totalLeads = ranks.reduce((sum, item) => sum + item.leads, 0);
|
||||
const totalTouches = ranks.reduce((sum, item) => sum + item.touches, 0);
|
||||
|
||||
const meta: TeamRankBroadcastMeta = {
|
||||
tenantId: options?.tenantId,
|
||||
eventId: options?.eventId,
|
||||
topScore,
|
||||
totalLeads,
|
||||
totalTouches,
|
||||
};
|
||||
|
||||
return {
|
||||
event: TEAM_LIVE_RANK_EVENT,
|
||||
timestamp: Date.now(),
|
||||
total: ranks.length,
|
||||
ranks,
|
||||
data: ranks,
|
||||
meta,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
import { Inject, Provide } from '@midwayjs/core';
|
||||
import { BaseService, CoolCommException } from '@cool-midway/core';
|
||||
import { InjectEntityModel } from '@midwayjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { BaseSysDepartmentEntity } from '../../base/entity/sys/department';
|
||||
import { BaseSysUserEntity } from '../../base/entity/sys/user';
|
||||
import { CardInfoEntity } from '../../card/entity/info';
|
||||
import type {
|
||||
AssignCardRequest,
|
||||
DepartmentTreeNode,
|
||||
MoveMembersRequest,
|
||||
TeamMemberItem,
|
||||
} from '../../../comm/types/omni';
|
||||
|
||||
/**
|
||||
* 成员分页查询参数
|
||||
*/
|
||||
export interface MemberPageQuery {
|
||||
page?: number;
|
||||
size?: number;
|
||||
departmentId?: number;
|
||||
keyword?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 组织架构、部门树与成员名片分配服务
|
||||
*/
|
||||
@Provide()
|
||||
export class TeamMembersService extends BaseService {
|
||||
@InjectEntityModel(BaseSysDepartmentEntity)
|
||||
baseSysDepartmentEntity: Repository<BaseSysDepartmentEntity>;
|
||||
|
||||
@InjectEntityModel(BaseSysUserEntity)
|
||||
baseSysUserEntity: Repository<BaseSysUserEntity>;
|
||||
|
||||
@InjectEntityModel(CardInfoEntity)
|
||||
cardInfoEntity: Repository<CardInfoEntity>;
|
||||
|
||||
@Inject()
|
||||
ctx: unknown;
|
||||
|
||||
/**
|
||||
* 获取多层级部门树并统计成员人数
|
||||
*/
|
||||
async getDepartmentTree(): Promise<DepartmentTreeNode[]> {
|
||||
const departments = await this.baseSysDepartmentEntity.find({
|
||||
order: { orderNum: 'ASC', id: 'ASC' },
|
||||
});
|
||||
|
||||
const users = await this.baseSysUserEntity.find();
|
||||
const countMap = new Map<number, number>();
|
||||
for (const u of users) {
|
||||
if (u.departmentId) {
|
||||
countMap.set(u.departmentId, (countMap.get(u.departmentId) || 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const nodeMap = new Map<number, DepartmentTreeNode>();
|
||||
for (const d of departments) {
|
||||
nodeMap.set(d.id, {
|
||||
id: d.id,
|
||||
name: d.name,
|
||||
parentId: d.parentId ?? null,
|
||||
orderNum: d.orderNum ?? 0,
|
||||
parentName: d.parentName,
|
||||
memberCount: countMap.get(d.id) || 0,
|
||||
key: d.id,
|
||||
title: d.name,
|
||||
children: [],
|
||||
});
|
||||
}
|
||||
|
||||
const roots: DepartmentTreeNode[] = [];
|
||||
for (const d of departments) {
|
||||
const node = nodeMap.get(d.id)!;
|
||||
if (d.parentId && nodeMap.has(d.parentId)) {
|
||||
nodeMap.get(d.parentId)!.children!.push(node);
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
const sortTree = (nodes: DepartmentTreeNode[]) => {
|
||||
nodes.sort((a, b) => (a.orderNum ?? 0) - (b.orderNum ?? 0));
|
||||
for (const node of nodes) {
|
||||
if (node.children && node.children.length > 0) {
|
||||
sortTree(node.children);
|
||||
}
|
||||
}
|
||||
};
|
||||
sortTree(roots);
|
||||
|
||||
return roots;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加部门
|
||||
*/
|
||||
async addDepartment(
|
||||
data: Partial<BaseSysDepartmentEntity>
|
||||
): Promise<BaseSysDepartmentEntity> {
|
||||
if (!data?.name || !data.name.trim()) {
|
||||
throw new CoolCommException('部门名称不能为空');
|
||||
}
|
||||
|
||||
const dept = new BaseSysDepartmentEntity();
|
||||
dept.name = data.name.trim();
|
||||
dept.parentId = data.parentId ? Number(data.parentId) : (null as unknown as number);
|
||||
dept.orderNum = data.orderNum !== undefined ? Number(data.orderNum) : 0;
|
||||
|
||||
return await this.baseSysDepartmentEntity.save(dept);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新部门
|
||||
*/
|
||||
async updateDepartment(
|
||||
idOrPayload: number | (Partial<BaseSysDepartmentEntity> & { id: number }),
|
||||
data?: Partial<BaseSysDepartmentEntity>
|
||||
): Promise<BaseSysDepartmentEntity> {
|
||||
const id =
|
||||
typeof idOrPayload === 'object'
|
||||
? Number(idOrPayload.id)
|
||||
: Number(idOrPayload);
|
||||
const payload =
|
||||
typeof idOrPayload === 'object' ? idOrPayload : data || {};
|
||||
|
||||
const dept = await this.baseSysDepartmentEntity.findOneBy({ id });
|
||||
if (!dept) {
|
||||
throw new CoolCommException('部门不存在');
|
||||
}
|
||||
|
||||
if (payload.name !== undefined) {
|
||||
if (!payload.name.trim()) {
|
||||
throw new CoolCommException('部门名称不能为空');
|
||||
}
|
||||
dept.name = payload.name.trim();
|
||||
}
|
||||
if (payload.parentId !== undefined) {
|
||||
dept.parentId = payload.parentId ? Number(payload.parentId) : (null as unknown as number);
|
||||
}
|
||||
if (payload.orderNum !== undefined) {
|
||||
dept.orderNum = Number(payload.orderNum);
|
||||
}
|
||||
|
||||
return await this.baseSysDepartmentEntity.save(dept);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除部门
|
||||
*/
|
||||
async deleteDepartment(
|
||||
idOrPayload: number | { id: number }
|
||||
): Promise<{ success: boolean; id: number }> {
|
||||
const id =
|
||||
typeof idOrPayload === 'object'
|
||||
? Number(idOrPayload.id)
|
||||
: Number(idOrPayload);
|
||||
|
||||
const dept = await this.baseSysDepartmentEntity.findOneBy({ id });
|
||||
if (!dept) {
|
||||
throw new CoolCommException('部门不存在');
|
||||
}
|
||||
|
||||
await this.baseSysDepartmentEntity.delete(id);
|
||||
return { success: true, id };
|
||||
}
|
||||
|
||||
/**
|
||||
* 成员分页列表与名片聚合
|
||||
*/
|
||||
async getMemberPage(query?: MemberPageQuery): Promise<{
|
||||
list: TeamMemberItem[];
|
||||
pagination: { page: number; size: number; total: number };
|
||||
}> {
|
||||
const page = Math.max(1, Number(query?.page || 1));
|
||||
const size = Math.max(1, Number(query?.size || 20));
|
||||
const departmentId = query?.departmentId ? Number(query.departmentId) : undefined;
|
||||
const keyword = query?.keyword ? query.keyword.trim().toLowerCase() : undefined;
|
||||
|
||||
const allUsers = await this.baseSysUserEntity.find();
|
||||
const filteredUsers = allUsers.filter(u => {
|
||||
if (departmentId && u.departmentId !== departmentId) {
|
||||
return false;
|
||||
}
|
||||
if (keyword) {
|
||||
const nameMatch = u.name && u.name.toLowerCase().includes(keyword);
|
||||
const usernameMatch = u.username && u.username.toLowerCase().includes(keyword);
|
||||
const phoneMatch = u.phone && u.phone.includes(keyword);
|
||||
if (!nameMatch && !usernameMatch && !phoneMatch) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const total = filteredUsers.length;
|
||||
const pagedUsers = filteredUsers.slice((page - 1) * size, page * size);
|
||||
|
||||
const departments = await this.baseSysDepartmentEntity.find();
|
||||
const deptMap = new Map<number, string>();
|
||||
for (const d of departments) {
|
||||
deptMap.set(d.id, d.name);
|
||||
}
|
||||
|
||||
const cards = await this.cardInfoEntity.find();
|
||||
const cardMap = new Map<number, CardInfoEntity>();
|
||||
for (const c of cards) {
|
||||
if (c.userId) {
|
||||
cardMap.set(Number(c.userId), c);
|
||||
}
|
||||
}
|
||||
|
||||
const list: TeamMemberItem[] = pagedUsers.map(u => {
|
||||
const card = cardMap.get(u.id);
|
||||
return {
|
||||
id: u.id,
|
||||
name: u.name,
|
||||
username: u.username,
|
||||
phone: u.phone,
|
||||
email: u.email,
|
||||
departmentId: u.departmentId,
|
||||
departmentName: u.departmentId ? deptMap.get(u.departmentId) || '' : '',
|
||||
status: u.status,
|
||||
card: card
|
||||
? {
|
||||
id: card.id,
|
||||
name: card.name,
|
||||
title: card.title,
|
||||
company: card.company,
|
||||
slug: card.slug,
|
||||
status: card.status,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
list,
|
||||
pagination: {
|
||||
page,
|
||||
size,
|
||||
total,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 将名片分配给指定成员
|
||||
*/
|
||||
async assignCard(
|
||||
memberIdOrReq: number | AssignCardRequest,
|
||||
cardId?: number
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
memberId: number;
|
||||
cardId: number;
|
||||
card: CardInfoEntity;
|
||||
}> {
|
||||
const memberId =
|
||||
typeof memberIdOrReq === 'object'
|
||||
? Number(memberIdOrReq.memberId)
|
||||
: Number(memberIdOrReq);
|
||||
const targetCardId =
|
||||
typeof memberIdOrReq === 'object'
|
||||
? Number(memberIdOrReq.cardId)
|
||||
: Number(cardId);
|
||||
|
||||
if (!memberId || !targetCardId) {
|
||||
throw new CoolCommException('参数不完整');
|
||||
}
|
||||
|
||||
const user = await this.baseSysUserEntity.findOneBy({ id: memberId });
|
||||
if (!user) {
|
||||
throw new CoolCommException('成员不存在');
|
||||
}
|
||||
|
||||
const card = await this.cardInfoEntity.findOneBy({ id: targetCardId });
|
||||
if (!card) {
|
||||
throw new CoolCommException('名片不存在');
|
||||
}
|
||||
|
||||
card.userId = memberId;
|
||||
const savedCard = await this.cardInfoEntity.save(card);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
memberId,
|
||||
cardId: targetCardId,
|
||||
card: savedCard,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量调整成员所属部门
|
||||
*/
|
||||
async moveMembers(
|
||||
memberIdsOrReq: number[] | MoveMembersRequest,
|
||||
departmentId?: number
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
updatedCount: number;
|
||||
memberIds: number[];
|
||||
departmentId: number;
|
||||
}> {
|
||||
let ids: number[] = [];
|
||||
let targetDeptId = 0;
|
||||
|
||||
if (Array.isArray(memberIdsOrReq)) {
|
||||
ids = memberIdsOrReq.map(Number);
|
||||
targetDeptId = Number(departmentId);
|
||||
} else if (memberIdsOrReq && typeof memberIdsOrReq === 'object') {
|
||||
ids = (memberIdsOrReq.memberIds || []).map(Number);
|
||||
targetDeptId = Number(memberIdsOrReq.departmentId);
|
||||
}
|
||||
|
||||
if (!ids || ids.length === 0) {
|
||||
throw new CoolCommException('未指定成员列表');
|
||||
}
|
||||
|
||||
if (targetDeptId > 0) {
|
||||
const dept = await this.baseSysDepartmentEntity.findOneBy({
|
||||
id: targetDeptId,
|
||||
});
|
||||
if (!dept) {
|
||||
throw new CoolCommException('目标部门不存在');
|
||||
}
|
||||
}
|
||||
|
||||
await this.baseSysUserEntity.update(ids, {
|
||||
departmentId: targetDeptId,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
updatedCount: ids.length,
|
||||
memberIds: ids,
|
||||
departmentId: targetDeptId,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import { Provide } from '@midwayjs/core';
|
||||
import { BaseService, CoolCommException } from '@cool-midway/core';
|
||||
import { InjectEntityModel } from '@midwayjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { CardInfoEntity } from '../../card/entity/info';
|
||||
import { BaseSysUserEntity } from '../../base/entity/sys/user';
|
||||
import { NfcDeviceEntity } from '../../nfc/entity/device';
|
||||
import type {
|
||||
PlanLimits,
|
||||
ProrationResult,
|
||||
QuotaCheckQuery,
|
||||
QuotaCheckResult,
|
||||
QuotaResourceType,
|
||||
TenantPlanType,
|
||||
} from '../../../comm/types/omni';
|
||||
|
||||
/**
|
||||
* 默认套餐标准年费定价表 (单位:元/年)
|
||||
*/
|
||||
export const DEFAULT_PLAN_PRICES: Record<TenantPlanType, number> = {
|
||||
free: 0,
|
||||
pro: 299,
|
||||
enterprise: 1999,
|
||||
};
|
||||
|
||||
/**
|
||||
* 各套餐版本的配额上限常量规格
|
||||
*/
|
||||
export const DEFAULT_PLAN_LIMITS: Record<TenantPlanType, PlanLimits> = {
|
||||
free: {
|
||||
plan: 'free',
|
||||
cardsLimit: 1,
|
||||
seatsLimit: 1,
|
||||
nfcDevicesLimit: 1,
|
||||
},
|
||||
pro: {
|
||||
plan: 'pro',
|
||||
cardsLimit: 5,
|
||||
seatsLimit: 10,
|
||||
nfcDevicesLimit: 10,
|
||||
},
|
||||
enterprise: {
|
||||
plan: 'enterprise',
|
||||
cardsLimit: 999,
|
||||
seatsLimit: 100,
|
||||
nfcDevicesLimit: 100,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 配额检查选项参数
|
||||
*/
|
||||
export interface QuotaCheckOptions {
|
||||
plan?: TenantPlanType;
|
||||
currentUsed?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 升级差价折算可选参数
|
||||
*/
|
||||
export interface ProrationOptions {
|
||||
currentPrice?: number;
|
||||
targetPrice?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 企业多坐席配额用量预警与升级差价计算服务
|
||||
*/
|
||||
@Provide()
|
||||
export class TeamQuotaService extends BaseService {
|
||||
@InjectEntityModel(CardInfoEntity)
|
||||
cardInfoEntity: Repository<CardInfoEntity>;
|
||||
|
||||
@InjectEntityModel(BaseSysUserEntity)
|
||||
baseSysUserEntity: Repository<BaseSysUserEntity>;
|
||||
|
||||
@InjectEntityModel(NfcDeviceEntity)
|
||||
nfcDeviceEntity: Repository<NfcDeviceEntity>;
|
||||
|
||||
/**
|
||||
* 获取指定套餐版本的配额上限规格
|
||||
* 兜底保护:非合法套餐一律回退为 'free'
|
||||
*/
|
||||
getPlanLimits(plan: TenantPlanType): PlanLimits {
|
||||
if (plan && DEFAULT_PLAN_LIMITS[plan]) {
|
||||
return { ...DEFAULT_PLAN_LIMITS[plan] };
|
||||
}
|
||||
return { ...DEFAULT_PLAN_LIMITS.free };
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化配额资源名称 (支持单复数别名)
|
||||
*/
|
||||
private normalizeResourceType(
|
||||
resourceType: QuotaResourceType
|
||||
): 'cards' | 'seats' | 'nfcDevices' {
|
||||
if (resourceType === 'card' || resourceType === 'cards') {
|
||||
return 'cards';
|
||||
}
|
||||
if (resourceType === 'seat' || resourceType === 'seats') {
|
||||
return 'seats';
|
||||
}
|
||||
return 'nfcDevices';
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查租户当前资源的配额用量与 80% 预警状态
|
||||
*/
|
||||
async checkQuota(
|
||||
tenantId: number | string,
|
||||
resourceType: QuotaResourceType,
|
||||
options?: QuotaCheckOptions
|
||||
): Promise<QuotaCheckResult> {
|
||||
const plan: TenantPlanType =
|
||||
options?.plan && DEFAULT_PLAN_LIMITS[options.plan]
|
||||
? options.plan
|
||||
: 'free';
|
||||
const limits = this.getPlanLimits(plan);
|
||||
const normalizedType = this.normalizeResourceType(resourceType);
|
||||
|
||||
// 计算上限
|
||||
let limit = 1;
|
||||
if (normalizedType === 'cards') {
|
||||
limit = limits.cardsLimit;
|
||||
} else if (normalizedType === 'seats') {
|
||||
limit = limits.seatsLimit;
|
||||
} else if (normalizedType === 'nfcDevices') {
|
||||
limit = limits.nfcDevicesLimit;
|
||||
}
|
||||
|
||||
// 计算已使用量
|
||||
let used = 0;
|
||||
if (options?.currentUsed !== undefined && options?.currentUsed !== null) {
|
||||
used = Math.max(0, Number(options.currentUsed));
|
||||
} else {
|
||||
const tenantNum = Number(tenantId) || 0;
|
||||
if (normalizedType === 'cards' && this.cardInfoEntity) {
|
||||
used = await this.cardInfoEntity.count({
|
||||
where: { tenantId: tenantNum },
|
||||
});
|
||||
} else if (normalizedType === 'seats' && this.baseSysUserEntity) {
|
||||
used = await this.baseSysUserEntity.count({
|
||||
where: { tenantId: tenantNum },
|
||||
});
|
||||
} else if (normalizedType === 'nfcDevices' && this.nfcDeviceEntity) {
|
||||
used = await this.nfcDeviceEntity.count({
|
||||
where: { tenantId: tenantNum },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const remaining = Math.max(0, limit - used);
|
||||
let percentage = 0;
|
||||
if (limit > 0) {
|
||||
percentage = Math.round((used / limit) * 100);
|
||||
} else {
|
||||
percentage = used > 0 ? 100 : 0;
|
||||
}
|
||||
|
||||
// 达到 80% 给出预警标志
|
||||
const warning = percentage >= 80;
|
||||
const exceeded = used >= limit;
|
||||
|
||||
return {
|
||||
tenantId,
|
||||
resourceType,
|
||||
plan,
|
||||
used,
|
||||
limit,
|
||||
remaining,
|
||||
percentage,
|
||||
warning,
|
||||
exceeded,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 断言当前配额可用,超限阻断抛出 RESOURCE_LIMIT_EXCEEDED
|
||||
*/
|
||||
async assertQuotaAvailable(
|
||||
tenantId: number | string,
|
||||
resourceType: QuotaResourceType,
|
||||
additionalCount: number = 1,
|
||||
options?: QuotaCheckOptions
|
||||
): Promise<QuotaCheckResult> {
|
||||
const checkResult = await this.checkQuota(tenantId, resourceType, options);
|
||||
const countToAdd = Math.max(1, Number(additionalCount) || 1);
|
||||
|
||||
if (checkResult.used + countToAdd > checkResult.limit) {
|
||||
throw new CoolCommException(
|
||||
`RESOURCE_LIMIT_EXCEEDED: ${resourceType} 配额已超限 (当前: ${checkResult.used}, 上限: ${checkResult.limit}, 拟新增: ${countToAdd})`
|
||||
);
|
||||
}
|
||||
|
||||
return checkResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按天精确折算套餐升级补差价金额 (基于分位整型算法防精度损失)
|
||||
*/
|
||||
calculateUpgradeProration(
|
||||
currentPlan: TenantPlanType,
|
||||
targetPlan: TenantPlanType,
|
||||
remainingDays: number,
|
||||
totalDays: number,
|
||||
options?: ProrationOptions
|
||||
): ProrationResult {
|
||||
if (totalDays <= 0 || !Number.isFinite(totalDays)) {
|
||||
throw new CoolCommException(
|
||||
'INVALID_TOTAL_DAYS: 计费周期总天数必须大于0'
|
||||
);
|
||||
}
|
||||
|
||||
const currentPlanPrice =
|
||||
options?.currentPrice !== undefined
|
||||
? Number(options.currentPrice)
|
||||
: (DEFAULT_PLAN_PRICES[currentPlan] ?? 0);
|
||||
|
||||
const targetPlanPrice =
|
||||
options?.targetPrice !== undefined
|
||||
? Number(options.targetPrice)
|
||||
: (DEFAULT_PLAN_PRICES[targetPlan] ?? 0);
|
||||
|
||||
const safeRemainingDays = Math.max(0, Math.min(remainingDays, totalDays));
|
||||
|
||||
// 剩余天数为 0 或降级/平级时,补差价为 0
|
||||
if (safeRemainingDays <= 0 || targetPlanPrice <= currentPlanPrice) {
|
||||
return {
|
||||
currentPlan,
|
||||
targetPlan,
|
||||
currentPlanPrice,
|
||||
targetPlanPrice,
|
||||
remainingDays,
|
||||
totalDays,
|
||||
proratedCurrentCredit: 0,
|
||||
proratedTargetCost: 0,
|
||||
upgradeDiffAmount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// 金额转换为“分”进行整型运算,杜绝 JavaScript IEEE 754 浮点累加误差
|
||||
const currentPriceCents = Math.round(currentPlanPrice * 100);
|
||||
const targetPriceCents = Math.round(targetPlanPrice * 100);
|
||||
const diffPriceCents = Math.max(0, targetPriceCents - currentPriceCents);
|
||||
|
||||
const proratedCreditCents = Math.round(
|
||||
(currentPriceCents * safeRemainingDays) / totalDays
|
||||
);
|
||||
const proratedCostCents = Math.round(
|
||||
(targetPriceCents * safeRemainingDays) / totalDays
|
||||
);
|
||||
const upgradeDiffCents = Math.round(
|
||||
(diffPriceCents * safeRemainingDays) / totalDays
|
||||
);
|
||||
|
||||
return {
|
||||
currentPlan,
|
||||
targetPlan,
|
||||
currentPlanPrice,
|
||||
targetPlanPrice,
|
||||
remainingDays,
|
||||
totalDays,
|
||||
proratedCurrentCredit: proratedCreditCents / 100,
|
||||
proratedTargetCost: proratedCostCents / 100,
|
||||
upgradeDiffAmount: upgradeDiffCents / 100,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
import * as crypto from 'crypto';
|
||||
import { Provide } from '@midwayjs/core';
|
||||
import { BaseService, CoolCommException } from '@cool-midway/core';
|
||||
import { InjectEntityModel } from '@midwayjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { TeamSubscriptionEntity } from '../entity/subscription';
|
||||
import {
|
||||
StripeBillingCycle,
|
||||
StripeCheckoutParams,
|
||||
StripeCheckoutResult,
|
||||
StripeWebhookEvent,
|
||||
StripeWebhookHandleResult,
|
||||
TenantPlanType,
|
||||
} from '../../../comm/types/omni';
|
||||
|
||||
/**
|
||||
* 套餐价格表 (单位:美分 cents)
|
||||
*/
|
||||
const PLAN_PRICING: Record<
|
||||
Exclude<TenantPlanType, 'free'>,
|
||||
Record<StripeBillingCycle, number>
|
||||
> = {
|
||||
pro: {
|
||||
monthly: 1900, // $19 / 月
|
||||
yearly: 19000, // $190 / 年 (立省 2 个月)
|
||||
},
|
||||
enterprise: {
|
||||
monthly: 9900, // $99 / 月
|
||||
yearly: 99000, // $990 / 年 (立省 2 个月)
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Stripe 国际信用卡订阅支付与 Webhook 自动化入账服务
|
||||
*/
|
||||
@Provide()
|
||||
export class TeamStripeService extends BaseService {
|
||||
@InjectEntityModel(TeamSubscriptionEntity)
|
||||
teamSubscriptionEntity: Repository<TeamSubscriptionEntity>;
|
||||
|
||||
/** Webhook 验签密钥,生产环境可读取环境变量 */
|
||||
webhookSecret: string =
|
||||
process.env.STRIPE_WEBHOOK_SECRET || 'whsec_default_team_secret';
|
||||
|
||||
/**
|
||||
* 创建 Stripe Checkout 结算会话
|
||||
* @param tenantIdOrParams 租户 ID 或完整请求参数
|
||||
* @param planOpt 套餐版本 (pro / enterprise)
|
||||
* @param cycleOpt 结算周期 (monthly / yearly)
|
||||
*/
|
||||
async createCheckoutSession(
|
||||
tenantIdOrParams: number | string | StripeCheckoutParams,
|
||||
planOpt?: TenantPlanType,
|
||||
cycleOpt?: StripeBillingCycle
|
||||
): Promise<StripeCheckoutResult> {
|
||||
let tenantId: number | string;
|
||||
let plan: TenantPlanType;
|
||||
let billingCycle: StripeBillingCycle = 'monthly';
|
||||
|
||||
if (typeof tenantIdOrParams === 'object' && tenantIdOrParams !== null) {
|
||||
tenantId = tenantIdOrParams.tenantId;
|
||||
plan = tenantIdOrParams.plan;
|
||||
billingCycle = tenantIdOrParams.billingCycle || 'monthly';
|
||||
} else {
|
||||
tenantId = tenantIdOrParams as number | string;
|
||||
plan = planOpt as TenantPlanType;
|
||||
billingCycle = cycleOpt || 'monthly';
|
||||
}
|
||||
|
||||
// 参数校验
|
||||
if (!tenantId || Number(tenantId) <= 0) {
|
||||
throw new CoolCommException('缺少企业租户 ID', 400);
|
||||
}
|
||||
|
||||
if (plan === 'free') {
|
||||
throw new CoolCommException('无需为免费套餐创建结算', 400);
|
||||
}
|
||||
|
||||
if (!plan || !PLAN_PRICING[plan as Exclude<TenantPlanType, 'free'>]) {
|
||||
throw new CoolCommException(`不支持的付费套餐类型: ${plan}`, 400);
|
||||
}
|
||||
|
||||
const pricing = PLAN_PRICING[plan as Exclude<TenantPlanType, 'free'>];
|
||||
const amount = pricing[billingCycle] || pricing.monthly;
|
||||
|
||||
// 生成符合 Stripe 标准的模拟/直连会话
|
||||
const sessionRandom = crypto.randomBytes(16).toString('hex');
|
||||
const sessionId = `cs_test_${sessionRandom}`;
|
||||
const url = `https://checkout.stripe.com/c/pay/${sessionId}`;
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
url,
|
||||
tenantId: Number(tenantId) || tenantId,
|
||||
plan,
|
||||
billingCycle,
|
||||
amount,
|
||||
currency: 'usd',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于 HMAC-SHA256 验证 Stripe 官方签名头防伪
|
||||
* @param payload 原始 Webhook 报文 (string / Buffer / Record)
|
||||
* @param signatureHeader Stripe-Signature 请求头
|
||||
* @param secret Webhook 签名私钥
|
||||
* @param timestampOrTolerance 测试指定当前时间戳或最大容忍秒数
|
||||
*/
|
||||
verifyWebhookSignature(
|
||||
payload: string | Buffer | Record<string, unknown>,
|
||||
signatureHeader: string,
|
||||
secret?: string,
|
||||
timestampOrTolerance?: number
|
||||
): boolean {
|
||||
if (!signatureHeader || typeof signatureHeader !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const webhookSecret = secret || this.webhookSecret;
|
||||
if (!webhookSecret) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const payloadString =
|
||||
typeof payload === 'string'
|
||||
? payload
|
||||
: Buffer.isBuffer(payload)
|
||||
? payload.toString('utf8')
|
||||
: JSON.stringify(payload);
|
||||
|
||||
// 解析 signatureHeader: t=1492774577,v1=5257a869e7...
|
||||
const headerParts = signatureHeader.split(',');
|
||||
let timestampStr = '';
|
||||
const v1Signatures: string[] = [];
|
||||
|
||||
for (const part of headerParts) {
|
||||
const [key, val] = part.trim().split('=');
|
||||
if (key === 't') {
|
||||
timestampStr = val;
|
||||
} else if (key === 'v1' && val) {
|
||||
v1Signatures.push(val);
|
||||
}
|
||||
}
|
||||
|
||||
if (!timestampStr || v1Signatures.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const headerTimestamp = parseInt(timestampStr, 10);
|
||||
if (isNaN(headerTimestamp)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 防重放攻击与宽容度校验
|
||||
const defaultTolerance = 300; // 默认 5 分钟
|
||||
let nowSeconds: number;
|
||||
let tolerance = defaultTolerance;
|
||||
|
||||
if (timestampOrTolerance && timestampOrTolerance > 1000000000) {
|
||||
// 传入的是当前模拟时间戳
|
||||
nowSeconds = timestampOrTolerance;
|
||||
} else {
|
||||
nowSeconds = Math.floor(Date.now() / 1000);
|
||||
if (timestampOrTolerance && timestampOrTolerance > 0) {
|
||||
tolerance = timestampOrTolerance;
|
||||
}
|
||||
}
|
||||
|
||||
if (Math.abs(nowSeconds - headerTimestamp) > tolerance) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 计算预期签名
|
||||
const signedPayload = `${headerTimestamp}.${payloadString}`;
|
||||
const hmac = crypto.createHmac('sha256', webhookSecret);
|
||||
hmac.update(signedPayload, 'utf8');
|
||||
const expectedSignature = hmac.digest('hex');
|
||||
|
||||
const expectedBuffer = Buffer.from(expectedSignature, 'utf8');
|
||||
|
||||
// 比对所有提取到的 v1 签名
|
||||
for (const sig of v1Signatures) {
|
||||
const sigBuffer = Buffer.from(sig, 'utf8');
|
||||
if (
|
||||
expectedBuffer.length === sigBuffer.length &&
|
||||
crypto.timingSafeEqual(expectedBuffer, sigBuffer)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 Stripe Webhook 核心事件并执行自动化入账与状态机流转
|
||||
* @param event Stripe 官方事件对象
|
||||
*/
|
||||
async handleWebhookEvent(
|
||||
event: StripeWebhookEvent
|
||||
): Promise<StripeWebhookHandleResult> {
|
||||
const { type, data } = event;
|
||||
const object = (data?.object || {}) as Record<string, unknown>;
|
||||
|
||||
switch (type) {
|
||||
case 'checkout.session.completed': {
|
||||
// 套餐即时升级入账
|
||||
const metadata = (object.metadata || {}) as Record<string, string>;
|
||||
const rawTenantId = String(
|
||||
object.client_reference_id || metadata.tenantId || ''
|
||||
);
|
||||
const tenantId: number | string = Number(rawTenantId) || rawTenantId;
|
||||
const plan = (metadata.plan || 'pro') as TenantPlanType;
|
||||
const billingCycle = (metadata.billingCycle ||
|
||||
'monthly') as StripeBillingCycle;
|
||||
const stripeCustomerId = (object.customer as string) || undefined;
|
||||
const stripeSubscriptionId =
|
||||
(object.subscription as string) || (object.id as string) || undefined;
|
||||
|
||||
// 计算当前周期结束时间 (月付加 30 天,年付加 365 天)
|
||||
const periodDays = billingCycle === 'yearly' ? 365 : 30;
|
||||
const periodEndDate = new Date(Date.now() + periodDays * 86400 * 1000);
|
||||
|
||||
if (this.teamSubscriptionEntity) {
|
||||
let sub = await this.teamSubscriptionEntity.findOne({
|
||||
where: { tenantId: Number(tenantId) || 0 },
|
||||
});
|
||||
if (!sub) {
|
||||
sub = new TeamSubscriptionEntity();
|
||||
sub.tenantId = Number(tenantId) || 0;
|
||||
}
|
||||
sub.planType = plan;
|
||||
sub.billingCycle = billingCycle;
|
||||
sub.status = 'active';
|
||||
sub.stripeCustomerId = stripeCustomerId;
|
||||
sub.stripeSubscriptionId = stripeSubscriptionId;
|
||||
sub.currentPeriodEnd = periodEndDate.toISOString();
|
||||
sub.cancelAtPeriodEnd = false;
|
||||
|
||||
await this.teamSubscriptionEntity.save(sub);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
eventType: type,
|
||||
tenantId,
|
||||
plan,
|
||||
status: 'active',
|
||||
action: 'upgraded',
|
||||
};
|
||||
}
|
||||
|
||||
case 'invoice.payment_succeeded': {
|
||||
// 周期续费成功
|
||||
const metadata = (object.metadata || {}) as Record<string, string>;
|
||||
const subDetails = (object.subscription_details || {}) as {
|
||||
metadata?: Record<string, string>;
|
||||
};
|
||||
const rawTenantId = String(
|
||||
metadata.tenantId || subDetails.metadata?.tenantId || ''
|
||||
);
|
||||
const stripeSubId = (object.subscription as string) || '';
|
||||
|
||||
let targetSub: TeamSubscriptionEntity | null = null;
|
||||
if (this.teamSubscriptionEntity) {
|
||||
if (rawTenantId) {
|
||||
targetSub = await this.teamSubscriptionEntity.findOne({
|
||||
where: { tenantId: Number(rawTenantId) || 0 },
|
||||
});
|
||||
}
|
||||
if (!targetSub && stripeSubId) {
|
||||
targetSub = await this.teamSubscriptionEntity.findOne({
|
||||
where: { stripeSubscriptionId: stripeSubId },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const lines = (object.lines || {}) as {
|
||||
data?: Array<{ period?: { end?: number } }>;
|
||||
};
|
||||
const periodEndSeconds = lines.data?.[0]?.period?.end;
|
||||
const periodEndDate = periodEndSeconds
|
||||
? new Date(periodEndSeconds * 1000)
|
||||
: new Date(Date.now() + 30 * 86400 * 1000);
|
||||
|
||||
const tenantId: number | string =
|
||||
targetSub?.tenantId ?? (Number(rawTenantId) || rawTenantId);
|
||||
|
||||
if (targetSub && this.teamSubscriptionEntity) {
|
||||
targetSub.status = 'active';
|
||||
targetSub.currentPeriodEnd = periodEndDate.toISOString();
|
||||
await this.teamSubscriptionEntity.save(targetSub);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
eventType: type,
|
||||
tenantId,
|
||||
status: 'active',
|
||||
action: 'renewed',
|
||||
};
|
||||
}
|
||||
|
||||
case 'customer.subscription.deleted': {
|
||||
// 订阅终止,安全降级为 free
|
||||
const metadata = (object.metadata || {}) as Record<string, string>;
|
||||
const stripeSubId = (object.id as string) || '';
|
||||
const rawTenantId = String(metadata.tenantId || '');
|
||||
|
||||
let targetSub: TeamSubscriptionEntity | null = null;
|
||||
if (this.teamSubscriptionEntity) {
|
||||
if (rawTenantId) {
|
||||
targetSub = await this.teamSubscriptionEntity.findOne({
|
||||
where: { tenantId: Number(rawTenantId) || 0 },
|
||||
});
|
||||
}
|
||||
if (!targetSub && stripeSubId) {
|
||||
targetSub = await this.teamSubscriptionEntity.findOne({
|
||||
where: { stripeSubscriptionId: stripeSubId },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const tenantId: number | string =
|
||||
targetSub?.tenantId ?? (Number(rawTenantId) || rawTenantId);
|
||||
|
||||
if (targetSub && this.teamSubscriptionEntity) {
|
||||
targetSub.planType = 'free';
|
||||
targetSub.status = 'canceled';
|
||||
await this.teamSubscriptionEntity.save(targetSub);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
eventType: type,
|
||||
tenantId,
|
||||
plan: 'free',
|
||||
status: 'canceled',
|
||||
action: 'downgraded',
|
||||
};
|
||||
}
|
||||
|
||||
default: {
|
||||
return {
|
||||
success: true,
|
||||
eventType: type,
|
||||
action: 'ignored',
|
||||
ignored: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户