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