feat: 完成全系统30个功能点开发与E2E全链路验收 (8轮调度32个子代理闭环)

- 模块一: 出海案例作品集、博客动态发布、人名发音语音条、RTL阿语多语言切换
- 模块二: AI顾问人设配置、行业术语库润色、RAG切片沙盒调试、流式会话与意向留资卡片
- 模块三: 全球访客雷达大屏、多维加权意向算分、外部CRM对接推送、团队公海池流转
- 模块四: 移动端NFC安全挂失、NFC动态路由规则配置、动态防伪验签、展会制卡Zip批量导出
- 模块五: 组织架构与名片分配、移动端个人中心设置、离职交接与历史线索迁移、安全审计日志
- 模块六: Stripe国际信用卡订阅Webhook、独立二级域名与SSL检测、坐席配额预警、多币种发票
- 模块七: 纸质名片OCR解析入库、国际商务日程RFC 5545 ICS日历、微官网离线SWR预加载、展会实时排行榜大屏
- 模块八: 全球出海SEO OpenGraph元标签注入与社交爬虫探测、30点端到端全链路业务流集成测试套件
这个提交包含在:
wikiboss
2026-09-20 23:47:47 +08:00
父节点 acd10ffb59
当前提交 eec259a3a7
共有 276 个文件被更改,包括 54906 次插入 和 145 次删除

查看文件

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