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

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

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

查看文件

@@ -1,14 +1,63 @@
/**
* 内置轻量 GeoIP 前缀表(零外部 HTTP、无 MaxMind 库文件)
* 供雷达公网 IP 兜底,私网/回环返回 null 以便仍归 Local
* GeoIP 可替换解析
* - 有 GEOIP_BASE_URL:axios 远程查询(超时 3000ms,失败不抛)
* - 无远程配置:仅用内置前缀表(本地库);未命中返回空地理,不假装城市/国家
* - 语言仅在「有国家且映射表命中」时返回;无国家不猜语言(空串,不默认 ar)
*/
import axios from 'axios';
export interface GeoIpLookup {
country: string;
city: string;
}
/** 精确 IP 或短前缀(按最长前缀优先匹配) */
/** 异步解析结果;失败/未知时 country、city、language 为空并带 reason */
export interface GeoIpResolveResult {
country: string;
city: string;
/** 国家映射语言;无国家或不在映射表时为空串,绝不默认 ar */
language: string;
reason?: string;
}
const GEOIP_TIMEOUT_MS = 3000;
/** 国家码 → 语言(仅表内命中才返回;无默认兜底) */
const COUNTRY_LANGUAGE_MAP: Record<string, string> = {
US: 'en',
GB: 'en',
AU: 'en',
CA: 'en',
NZ: 'en',
IE: 'en',
CN: 'zh',
TW: 'zh',
HK: 'zh',
SG: 'en',
AE: 'ar',
SA: 'ar',
EG: 'ar',
BH: 'ar',
QA: 'ar',
KW: 'ar',
OM: 'ar',
DE: 'de',
AT: 'de',
CH: 'de',
FR: 'fr',
JP: 'ja',
KR: 'ko',
ES: 'es',
MX: 'es',
BR: 'pt',
PT: 'pt',
IT: 'it',
RU: 'ru',
IN: 'en',
};
/** 精确 IP 或短前缀(按最长前缀优先匹配)——本地库 */
const GEOIP_PREFIXES: Array<{ prefix: string; country: string; city: string }> = [
{ prefix: '8.8.8.8', country: 'US', city: 'Mountain View' },
{ prefix: '8.8.4.4', country: 'US', city: 'Mountain View' },
@@ -26,49 +75,6 @@ const GEOIP_PREFIXES: Array<{ prefix: string; country: string; city: string }> =
{ prefix: '208.67.220.220', country: 'US', city: 'San Francisco' },
];
/** IPv4 首段粗粒度国家(RIR 常见分配,城市未知) */
const FIRST_OCTET_COUNTRY: Record<number, string> = {
1: 'AU',
8: 'US',
9: 'US',
14: 'CN',
27: 'CN',
36: 'CN',
39: 'CN',
42: 'CN',
58: 'CN',
59: 'CN',
60: 'CN',
61: 'CN',
101: 'CN',
106: 'CN',
110: 'CN',
111: 'CN',
112: 'CN',
113: 'CN',
114: 'CN',
115: 'CN',
116: 'CN',
117: 'CN',
118: 'CN',
119: 'CN',
120: 'CN',
121: 'CN',
122: 'CN',
123: 'CN',
124: 'CN',
125: 'CN',
180: 'CN',
183: 'CN',
202: 'CN',
218: 'CN',
219: 'CN',
220: 'CN',
221: 'CN',
222: 'CN',
223: 'CN',
};
function normalizeIp(ip: string): string {
return ip.trim().replace(/^::ffff:/i, '');
}
@@ -109,9 +115,41 @@ function matchPrefix(ip: string, prefix: string): boolean {
return ip === prefix || ip.startsWith(`${prefix}.`);
}
function emptyGeo(reason: string): GeoIpResolveResult {
return { country: '', city: '', language: '', reason };
}
function asString(value: unknown): string {
if (value === undefined || value === null) {
return '';
}
return String(value).trim();
}
function withLanguage(country: string, city: string): GeoIpResolveResult {
return {
country,
city,
language: resolveLanguageFromCountry(country),
};
}
/**
* 内置前缀表查询公网 IP 地理位置
* @returns 命中返回国家/城市;私网、回环、未识别返回 null
* 按国家码查语言:无国家或不在映射表 → 空串(不猜、不默认 ar)
*/
export function resolveLanguageFromCountry(
country?: string | null
): string {
const code = (country || '').trim().toUpperCase();
if (!code) {
return '';
}
return COUNTRY_LANGUAGE_MAP[code] || '';
}
/**
* 内置前缀表查询公网 IP 地理位置(本地库)
* @returns 命中返回国家/城市;私网、回环、未识别返回 null(绝不瞎填城市)
*/
export function lookupGeoIp(ip: string): GeoIpLookup | null {
if (!ip) {
@@ -131,15 +169,51 @@ export function lookupGeoIp(ip: string): GeoIpLookup | null {
hitLen = row.prefix.length;
}
}
if (hit) {
return hit;
}
const first = Number(cleanIp.split('.')[0]);
const country = FIRST_OCTET_COUNTRY[first];
if (country) {
return { country, city: 'Unknown' };
}
return null;
return hit;
}
/**
* 可替换 GeoIP 解析:优先 GEOIP_BASE_URL,否则本地前缀表;失败/未知返回空地理 + reason,不抛错
*/
export async function resolveGeoIp(ip: string): Promise<GeoIpResolveResult> {
const cleanIp = normalizeIp(ip || '');
if (!cleanIp || isPrivateOrLoopback(cleanIp)) {
return emptyGeo('private_or_empty');
}
if (isDocumentationIp(cleanIp)) {
return emptyGeo('documentation_ip');
}
const baseUrl = process.env.GEOIP_BASE_URL?.trim();
if (baseUrl) {
try {
const response = await axios.get(baseUrl, {
params: { ip: cleanIp },
timeout: GEOIP_TIMEOUT_MS,
});
const payload =
response.data && typeof response.data === 'object'
? (response.data as Record<string, unknown>)
: {};
const nested =
payload.data && typeof payload.data === 'object'
? (payload.data as Record<string, unknown>)
: payload;
const country = asString(nested.country ?? nested.countryCode);
const city = asString(nested.city);
if (!country && !city) {
return emptyGeo('remote_empty');
}
return withLanguage(country, city);
} catch (err) {
const message = err instanceof Error ? err.message : 'geoip_request_failed';
return emptyGeo(message);
}
}
const local = lookupGeoIp(cleanIp);
if (local) {
return withLanguage(local.country, local.city);
}
return emptyGeo('not_found');
}

查看文件

@@ -6,16 +6,18 @@ import { TenantSubscriber } from '../modules/base/db/tenant';
/**
* 本地开发 npm run prod 读取的配置文件
*/
const mysqlPort = Number(process.env.MYSQL_PORT || 3306);
export default {
typeorm: {
dataSource: {
default: {
type: 'mysql',
host: '127.0.0.1',
port: 3306,
username: 'root',
password: '123456',
database: 'crmmpdata',
host: process.env.MYSQL_HOST || '127.0.0.1',
port: Number.isFinite(mysqlPort) ? mysqlPort : 3306,
username: process.env.MYSQL_USER || 'crmmpdata',
password: process.env.MYSQL_PASSWORD || 'crmmpdata',
database: process.env.MYSQL_DATABASE || 'crmmpdata',
// 自动建表 注意:线上部署的时候不要使用,有可能导致数据丢失
synchronize: false,
// 打印日志

查看文件

@@ -3,48 +3,50 @@ import * as entity0 from './modules/user/entity/wx';
import * as entity1 from './modules/user/entity/info';
import * as entity2 from './modules/user/entity/address';
import * as entity3 from './modules/team/entity/subscription';
import * as entity4 from './modules/team/entity/invoice';
import * as entity5 from './modules/team/entity/handover-log';
import * as entity6 from './modules/team/entity/domain';
import * as entity7 from './modules/team/entity/brand';
import * as entity8 from './modules/task/entity/log';
import * as entity9 from './modules/task/entity/info';
import * as entity10 from './modules/space/entity/type';
import * as entity11 from './modules/space/entity/info';
import * as entity12 from './modules/recycle/entity/data';
import * as entity13 from './modules/plugin/entity/info';
import * as entity14 from './modules/nfc/entity/rule';
import * as entity15 from './modules/nfc/entity/device';
import * as entity16 from './modules/dict/entity/type';
import * as entity17 from './modules/dict/entity/info';
import * as entity18 from './modules/demo/entity/goods';
import * as entity19 from './modules/crm/entity/track';
import * as entity20 from './modules/crm/entity/lead';
import * as entity21 from './modules/crm/entity/audit';
import * as entity22 from './modules/card/entity/locale';
import * as entity23 from './modules/card/entity/info';
import * as entity24 from './modules/card/entity/exchange';
import * as entity25 from './modules/card/entity/event';
import * as entity26 from './modules/card/entity/event-member';
import * as entity27 from './modules/card/entity/contact';
import * as entity28 from './modules/card/entity/cases';
import * as entity29 from './modules/card/entity/bento';
import * as entity30 from './modules/card/entity/article';
import * as entity31 from './modules/base/entity/base';
import * as entity32 from './modules/base/entity/audit';
import * as entity33 from './modules/base/entity/sys/user_role';
import * as entity34 from './modules/base/entity/sys/user';
import * as entity35 from './modules/base/entity/sys/role_menu';
import * as entity36 from './modules/base/entity/sys/role_department';
import * as entity37 from './modules/base/entity/sys/role';
import * as entity38 from './modules/base/entity/sys/param';
import * as entity39 from './modules/base/entity/sys/menu';
import * as entity40 from './modules/base/entity/sys/log';
import * as entity41 from './modules/base/entity/sys/department';
import * as entity42 from './modules/base/entity/sys/conf';
import * as entity43 from './modules/ai/entity/knowledge';
import * as entity44 from './modules/ai/entity/glossary';
import * as entity45 from './modules/ai/entity/concierge';
import * as entity4 from './modules/team/entity/stripe-event';
import * as entity5 from './modules/team/entity/invoice';
import * as entity6 from './modules/team/entity/handover-log';
import * as entity7 from './modules/team/entity/domain';
import * as entity8 from './modules/team/entity/brand';
import * as entity9 from './modules/task/entity/log';
import * as entity10 from './modules/task/entity/info';
import * as entity11 from './modules/space/entity/type';
import * as entity12 from './modules/space/entity/info';
import * as entity13 from './modules/recycle/entity/data';
import * as entity14 from './modules/plugin/entity/info';
import * as entity15 from './modules/nfc/entity/rule';
import * as entity16 from './modules/nfc/entity/device';
import * as entity17 from './modules/dict/entity/type';
import * as entity18 from './modules/dict/entity/info';
import * as entity19 from './modules/demo/entity/goods';
import * as entity20 from './modules/crm/entity/track';
import * as entity21 from './modules/crm/entity/lead';
import * as entity22 from './modules/crm/entity/audit';
import * as entity23 from './modules/card/entity/locale';
import * as entity24 from './modules/card/entity/info';
import * as entity25 from './modules/card/entity/exchange';
import * as entity26 from './modules/card/entity/event';
import * as entity27 from './modules/card/entity/event-member';
import * as entity28 from './modules/card/entity/contact';
import * as entity29 from './modules/card/entity/cases';
import * as entity30 from './modules/card/entity/bento';
import * as entity31 from './modules/card/entity/article';
import * as entity32 from './modules/base/entity/base';
import * as entity33 from './modules/base/entity/audit';
import * as entity34 from './modules/base/entity/sys/user_role';
import * as entity35 from './modules/base/entity/sys/user';
import * as entity36 from './modules/base/entity/sys/role_menu';
import * as entity37 from './modules/base/entity/sys/role_department';
import * as entity38 from './modules/base/entity/sys/role';
import * as entity39 from './modules/base/entity/sys/param';
import * as entity40 from './modules/base/entity/sys/menu';
import * as entity41 from './modules/base/entity/sys/log';
import * as entity42 from './modules/base/entity/sys/department';
import * as entity43 from './modules/base/entity/sys/conf';
import * as entity44 from './modules/ai/entity/knowledge';
import * as entity45 from './modules/ai/entity/glossary';
import * as entity46 from './modules/ai/entity/deepseek-config';
import * as entity47 from './modules/ai/entity/concierge';
export const entities = [
...Object.values(entity0),
...Object.values(entity1),
@@ -92,4 +94,6 @@ export const entities = [
...Object.values(entity43),
...Object.values(entity44),
...Object.values(entity45),
...Object.values(entity46),
...Object.values(entity47),
];

查看文件

@@ -0,0 +1,21 @@
import { CoolController, BaseController } from '@cool-midway/core';
import { AiOcrService } from '../../service/ocr';
/**
* 公开名片 OCR 识别配置(不开标准 CRUD)
*/
export const openAiOcrCurdOption = {
api: [] as Array<
'add' | 'delete' | 'update' | 'info' | 'list' | 'page'
>,
service: AiOcrService,
serviceApis: [{ method: 'recognize', summary: '名片 OCR 识别' }],
};
/**
* AI 模块-公开名片 OCR
* 路由前缀:/open/ai/ocr
* 实际接口:POST /open/ai/ocr/recognize
*/
@CoolController(openAiOcrCurdOption)
export class OpenAiOcrController extends BaseController {}

查看文件

@@ -0,0 +1,45 @@
import { BaseEntity } from '../../base/entity/base';
import { Column, Entity, Index } from 'typeorm';
/**
* 租户 DeepSeek BYOK 配置
* 表名: ai_deepseek_config
* 注意:apiKey 严禁写入日志或对外明文回显
*/
@Entity('ai_deepseek_config')
@Index('uk_ai_deepseek_config_tenant', ['tenantId'], { unique: true })
export class AiDeepSeekConfigEntity extends BaseEntity {
@Column({
name: 'api_key',
comment: '租户 DeepSeek API Key(严禁写入日志)',
length: 255,
nullable: true,
})
apiKey?: string;
@Column({
name: 'base_url',
comment: 'DeepSeek API Base URL',
length: 255,
nullable: true,
default: 'https://api.deepseek.com',
})
baseUrl?: string;
@Column({
name: 'model',
comment: '默认模型 (deepseek-chat / deepseek-reasoner)',
length: 64,
nullable: true,
default: 'deepseek-chat',
})
model?: string;
@Column({
name: 'enabled',
comment: '是否启用租户 BYOK',
type: 'boolean',
default: false,
})
enabled: boolean;
}

查看文件

@@ -1,5 +1,7 @@
import { Provide } from '@midwayjs/core';
import { Init, Provide } from '@midwayjs/core';
import { BaseService, CoolCommException } from '@cool-midway/core';
import { InjectEntityModel } from '@midwayjs/typeorm';
import { Repository } from 'typeorm';
import axios, { AxiosInstance } from 'axios';
import {
DeepSeekChatCompletionRequest,
@@ -8,26 +10,31 @@ import {
DeepSeekModel,
DeepSeekTestConnectionResult,
} from '../../../comm/types/omni';
import { AiDeepSeekConfigEntity } from '../entity/deepseek-config';
/**
* DeepSeek 官方大模型适配服务
* 支持 deepseek-chat (V3) 与 deepseek-reasoner (R1)
* 租户 BYOK 配置优先落库;无 repository / DB 不可用时回退进程内 Map
*/
@Provide()
export class DeepSeekService extends BaseService {
@InjectEntityModel(AiDeepSeekConfigEntity)
deepSeekConfigEntity?: Repository<AiDeepSeekConfigEntity>;
private config: DeepSeekConfig;
private httpClient: AxiosInstance;
/** 多租户私有 BYOK 配置映射表 */
/** 多租户私有 BYOK 配置映射表(DB 不可用或单测无 repository 时的兜底) */
private tenantConfigs: Map<string | number, DeepSeekConfig> = new Map();
/** 租户 Token 累计消耗账本 */
/** 租户 Token 累计消耗账本(仅进程内存,无落库表,不得作账单) */
private tenantUsage: Map<
string | number,
{ totalTokens: number; promptTokens: number; completionTokens: number; callCount: number }
> = new Map();
/** 租户 Token 配额上限表 */
/** 租户 Token 配额上限表(暂存内存) */
private tenantQuotas: Map<string | number, number> = new Map();
constructor() {
@@ -51,6 +58,83 @@ export class DeepSeekService extends BaseService {
});
}
@Init()
async init() {
await super.init();
if (this.deepSeekConfigEntity) {
this.setEntity(this.deepSeekConfigEntity);
await this.hydrateTenantConfigsFromDb();
}
}
private hasRepository(): boolean {
return Boolean(this.deepSeekConfigEntity);
}
private rowToConfig(row: AiDeepSeekConfigEntity): DeepSeekConfig {
return {
apiKey: row.apiKey || undefined,
baseUrl: row.baseUrl || 'https://api.deepseek.com',
model: (row.model as DeepSeekModel) || 'deepseek-chat',
enabled: Boolean(row.enabled),
temperature: 0.7,
maxTokens: 2048,
timeoutMs: 30000,
};
}
private cacheTenantConfig(tenantId: string | number, cfg: DeepSeekConfig): void {
this.tenantConfigs.set(tenantId, cfg);
const numId = Number(tenantId);
if (!Number.isNaN(numId)) {
this.tenantConfigs.set(numId, cfg);
}
}
/** 启动时从 DB 灌入内存缓存;失败则静默保持 Map 空(走平台默认 / 后续写入) */
private async hydrateTenantConfigsFromDb(): Promise<void> {
if (!this.hasRepository()) {
return;
}
try {
const rows = await this.deepSeekConfigEntity!.find();
for (const row of rows) {
if (row.tenantId === undefined || row.tenantId === null) {
continue;
}
this.cacheTenantConfig(row.tenantId, this.rowToConfig(row));
}
} catch {
// DB 不可用:保留内存 Map 兜底
}
}
private async persistTenantConfig(
tenantId: string | number,
cfg: DeepSeekConfig
): Promise<void> {
if (!this.hasRepository()) {
return;
}
try {
const tid = Number(tenantId);
let row = await this.deepSeekConfigEntity!.findOne({
where: { tenantId: tid },
});
if (!row) {
row = new AiDeepSeekConfigEntity();
row.tenantId = tid;
}
row.apiKey = cfg.apiKey;
row.baseUrl = cfg.baseUrl;
row.model = cfg.model;
row.enabled = Boolean(cfg.enabled);
await this.deepSeekConfigEntity!.save(row);
} catch {
// DB 不可用:内存 Map 已更新,静默降级(勿打印含 apiKey 的实体)
}
}
/**
* 获取当前有效配置(脱敏 API Key)
*/
@@ -66,17 +150,72 @@ export class DeepSeekService extends BaseService {
}
/**
* 获取指定租户有效配置(未单独配置则优雅回退至平台公共默认配置)
* 获取指定租户有效配置
* 优先读内存缓存(由 DB hydrate / setTenantConfig 写穿维护);
* 无缓存则回退平台公共默认配置。异步优先读库请用 resolveTenantConfig。
*/
getTenantConfig(tenantId?: string | number): DeepSeekConfig {
if (tenantId !== undefined && tenantId !== null && this.tenantConfigs.has(tenantId)) {
return { ...this.tenantConfigs.get(tenantId)! };
if (tenantId !== undefined && tenantId !== null) {
if (this.tenantConfigs.has(tenantId)) {
return { ...this.tenantConfigs.get(tenantId)! };
}
const numId = Number(tenantId);
if (!Number.isNaN(numId) && this.tenantConfigs.has(numId)) {
return { ...this.tenantConfigs.get(numId)! };
}
}
return this.getConfig();
}
/**
* 为指定企业租户更新专属定制配置 (BYOK)
* 异步解析租户配置:有 repository 时优先读库并回填 Map;否则读 Map;再回退平台默认
*/
async resolveTenantConfig(tenantId?: string | number): Promise<DeepSeekConfig> {
if (tenantId === undefined || tenantId === null) {
return this.getConfig();
}
if (this.hasRepository()) {
try {
const row = await this.deepSeekConfigEntity!.findOne({
where: { tenantId: Number(tenantId) },
});
if (row) {
const cfg = this.rowToConfig(row);
this.cacheTenantConfig(tenantId, cfg);
return { ...cfg };
}
} catch {
// DB 不可用 → Map 兜底
}
}
return this.getTenantConfig(tenantId);
}
/**
* 为指定企业租户更新专属定制配置 (BYOK),优先落库;无 repository 时仅写内存 Map
*/
async setTenantConfig(
tenantId: string | number,
partial: Partial<DeepSeekConfig>
): Promise<DeepSeekConfig> {
const current = await this.resolveTenantConfig(tenantId);
const updated: DeepSeekConfig = {
...current,
...partial,
enabled:
partial.apiKey !== undefined
? Boolean(partial.apiKey?.trim())
: partial.enabled !== undefined
? Boolean(partial.enabled)
: current.enabled,
};
this.cacheTenantConfig(tenantId, updated);
await this.persistTenantConfig(tenantId, updated);
return { ...updated };
}
/**
* 同步更新租户配置(兼容旧调用方):写穿 Map,并异步尝试落库
*/
updateTenantConfig(tenantId: string | number, partial: Partial<DeepSeekConfig>): void {
const current = this.getTenantConfig(tenantId);
@@ -85,18 +224,53 @@ export class DeepSeekService extends BaseService {
...partial,
enabled: partial.apiKey ? Boolean(partial.apiKey.trim()) : current.enabled,
};
this.tenantConfigs.set(tenantId, updated);
this.cacheTenantConfig(tenantId, updated);
void this.persistTenantConfig(tenantId, updated);
}
/**
* 记录指定租户的 Token 消耗
* 组装内存用量快照:无用量表,仅进程 Map;明确 storage/persisted,禁止冒充账单
*/
private toMemoryUsageSnapshot(entry?: {
totalTokens: number;
promptTokens: number;
completionTokens: number;
callCount: number;
}): {
totalTokens: number;
promptTokens: number;
completionTokens: number;
callCount: number;
storage: 'memory';
persisted: false;
} {
return {
totalTokens: entry?.totalTokens ?? 0,
promptTokens: entry?.promptTokens ?? 0,
completionTokens: entry?.completionTokens ?? 0,
callCount: entry?.callCount ?? 0,
storage: 'memory',
persisted: false,
};
}
/**
* 记录指定租户的 Token 消耗(仅写入进程内存 Map,非账单/非落库)
* 返回快照带 storage:'memory' / persisted:false
*/
recordTokenUsage(
tenantId: string | number,
tokens:
| number
| { promptTokens?: number; completionTokens?: number; totalTokens?: number }
): void {
): {
totalTokens: number;
promptTokens: number;
completionTokens: number;
callCount: number;
storage: 'memory';
persisted: false;
} {
const current = this.tenantUsage.get(tenantId) || {
totalTokens: 0,
promptTokens: 0,
@@ -116,29 +290,29 @@ export class DeepSeekService extends BaseService {
total = Number(tokens.totalTokens) || prompt + completion;
}
this.tenantUsage.set(tenantId, {
const next = {
totalTokens: current.totalTokens + total,
promptTokens: current.promptTokens + prompt,
completionTokens: current.completionTokens + completion,
callCount: current.callCount + 1,
});
};
this.tenantUsage.set(tenantId, next);
return this.toMemoryUsageSnapshot(next);
}
/**
* 获取指定租户的 Token 用量统计
* 获取指定租户的 Token 用量统计(进程内存;无记录则全 0,不造示例)
* 对外结果带 storage:'memory' / persisted:false,不得当作已落库账单
*/
getTenantTokenUsage(tenantId: string | number): {
totalTokens: number;
promptTokens: number;
completionTokens: number;
callCount: number;
storage: 'memory';
persisted: false;
} {
return this.tenantUsage.get(tenantId) || {
totalTokens: 0,
promptTokens: 0,
completionTokens: 0,
callCount: 0,
};
return this.toMemoryUsageSnapshot(this.tenantUsage.get(tenantId));
}
/**
@@ -282,7 +456,7 @@ export class DeepSeekService extends BaseService {
fallbackReply?: string,
tenantId?: number | string
): Promise<string> {
const cfg = this.getTenantConfig(tenantId);
const cfg = await this.resolveTenantConfig(tenantId);
const apiKey = cfg.apiKey?.trim();
if (!apiKey) {
@@ -350,7 +524,7 @@ export class DeepSeekService extends BaseService {
testApiKey?: string,
tenantId?: number | string
): Promise<DeepSeekTestConnectionResult> {
const cfg = this.getTenantConfig(tenantId);
const cfg = await this.resolveTenantConfig(tenantId);
const key = (testApiKey || cfg.apiKey || '').trim();
if (!key) {
return {

查看文件

@@ -2,6 +2,7 @@ import { Init, Provide } from '@midwayjs/core';
import { BaseService } from '@cool-midway/core';
import { InjectEntityModel } from '@midwayjs/typeorm';
import { Repository } from 'typeorm';
import axios from 'axios';
import { AiKnowledgeEntity } from '../entity/knowledge';
import {
KnowledgeChunk,
@@ -9,6 +10,24 @@ import {
KnowledgeSandboxResult,
} from '../../../comm/types/omni';
/** 召回模式:keyword=词重叠;embedding=向量相似度(二者分数语义不同,不可混用) */
export type KnowledgeRetrieveMode = 'keyword' | 'embedding';
export interface KnowledgeRetrieveOptions {
topK?: number;
threshold?: number;
}
export interface KnowledgeRetrieveChunk extends KnowledgeChunk {
mode: KnowledgeRetrieveMode;
}
interface NormalizedChunk {
index: number;
content: string;
id?: string | number;
}
/**
* 名片知识库文档服务
*/
@@ -171,6 +190,190 @@ export class AiKnowledgeService extends BaseService {
return scoredChunks.slice(0, limit);
}
/**
* 统一召回入口。
* - 无 EMBEDDING_API_KEY:走 simulateRetrieval(词重叠),结果标明 mode=keyword
* - 有 EMBEDDING_API_KEY:必须配置 EMBEDDING_BASE_URL,否则抛错(不静默回退、不伪造向量)
*/
async retrieve(
query: string,
chunks: Array<KnowledgeChunk | string | Record<string, unknown>>,
options: KnowledgeRetrieveOptions = {}
): Promise<KnowledgeRetrieveChunk[]> {
const topK = options.topK ?? 3;
const threshold = options.threshold ?? 0;
const apiKey = process.env.EMBEDDING_API_KEY?.trim();
if (!apiKey) {
return this.simulateRetrieval(query, chunks, topK, threshold).map(
chunk => ({
...chunk,
mode: 'keyword' as const,
})
);
}
const baseUrl = process.env.EMBEDDING_BASE_URL?.trim();
if (!baseUrl) {
throw new Error(
'EMBEDDING_BASE_URL is required when EMBEDDING_API_KEY is set; refusing to fall back to keyword or fake vectors'
);
}
return this.retrieveByEmbedding(
query,
chunks,
topK,
threshold,
apiKey,
baseUrl
);
}
private normalizeChunks(
chunks: Array<KnowledgeChunk | string | Record<string, unknown>>
): NormalizedChunk[] {
const normalized: NormalizedChunk[] = [];
for (let i = 0; i < chunks.length; i++) {
const chunkItem = chunks[i];
let content = '';
let index = i;
let id: string | number | undefined;
if (typeof chunkItem === 'string') {
content = chunkItem;
} else if (chunkItem && typeof chunkItem === 'object') {
content =
typeof chunkItem.content === 'string' ? chunkItem.content : '';
if (typeof chunkItem.index === 'number') {
index = chunkItem.index;
}
if (
typeof chunkItem.id === 'string' ||
typeof chunkItem.id === 'number'
) {
id = chunkItem.id;
}
}
if (!content) {
continue;
}
const item: NormalizedChunk = { index, content };
if (id !== undefined) {
item.id = id;
}
normalized.push(item);
}
return normalized;
}
private cosineSimilarity(a: number[], b: number[]): number {
if (!a.length || a.length !== b.length) {
return 0;
}
let dot = 0;
let normA = 0;
let normB = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
if (normA === 0 || normB === 0) {
return 0;
}
return dot / (Math.sqrt(normA) * Math.sqrt(normB));
}
private async retrieveByEmbedding(
query: string,
chunks: Array<KnowledgeChunk | string | Record<string, unknown>>,
topK: number,
threshold: number,
apiKey: string,
baseUrl: string
): Promise<KnowledgeRetrieveChunk[]> {
if (!query || !query.trim() || !chunks || chunks.length === 0) {
return [];
}
const normalized = this.normalizeChunks(chunks);
if (normalized.length === 0) {
return [];
}
const inputs = [query.trim(), ...normalized.map(c => c.content)];
const body: Record<string, unknown> = { input: inputs };
const model = process.env.EMBEDDING_MODEL?.trim();
if (model) {
body.model = model;
}
let responseData: unknown;
try {
const response = await axios.post(baseUrl, body, {
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
timeout: 30000,
});
responseData = response.data;
} catch (err) {
const message =
err instanceof Error ? err.message : 'embedding request failed';
throw new Error(`EMBEDDING request failed: ${message}`);
}
const payload = responseData as {
data?: Array<{ embedding?: number[]; index?: number }>;
};
const embeddingRows = Array.isArray(payload?.data) ? [...payload.data] : [];
if (embeddingRows.length < inputs.length) {
throw new Error(
'EMBEDDING response missing embeddings for query/chunks'
);
}
embeddingRows.sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
const queryEmbedding = embeddingRows[0]?.embedding;
if (!Array.isArray(queryEmbedding) || queryEmbedding.length === 0) {
throw new Error('EMBEDDING response missing query embedding');
}
const normalizedThreshold = threshold > 1 ? threshold / 100 : threshold;
const scored: KnowledgeRetrieveChunk[] = [];
for (let i = 0; i < normalized.length; i++) {
const chunkEmbedding = embeddingRows[i + 1]?.embedding;
if (!Array.isArray(chunkEmbedding) || chunkEmbedding.length === 0) {
throw new Error(
`EMBEDDING response missing embedding for chunk index ${normalized[i].index}`
);
}
const raw = this.cosineSimilarity(queryEmbedding, chunkEmbedding);
const score = Number(Math.min(1, Math.max(0, raw)).toFixed(4));
if (score >= normalizedThreshold && score > 0) {
const item: KnowledgeRetrieveChunk = {
index: normalized[i].index,
content: normalized[i].content,
length: normalized[i].content.length,
score,
mode: 'embedding',
};
if (normalized[i].id !== undefined) {
item.id = normalized[i].id;
}
scored.push(item);
}
}
scored.sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
const limit = topK > 0 ? topK : 3;
return scored.slice(0, limit);
}
/**
* 沙盒调试统一入口
*/

查看文件

@@ -0,0 +1,78 @@
import { Provide } from '@midwayjs/core';
import { BaseService, CoolCommException } from '@cool-midway/core';
import axios from 'axios';
/** 名片 OCR 识别入参 */
export interface AiOcrRecognizeRequest {
imageBase64?: string;
imageUrl?: string;
}
/** 名片 OCR 识别结果 */
export interface AiOcrRecognizeResult {
name: string;
company: string;
phone: string;
email: string;
title: string;
}
/**
* 名片 OCR 识别(需配置 OCR_API_KEY + OCR_BASE_URL)
*/
@Provide()
export class AiOcrService extends BaseService {
/**
* 识别名片图片中的联系人字段
* 兼容公开端 serviceApis:POST body 为单参数对象
*/
async recognize(req: AiOcrRecognizeRequest): Promise<AiOcrRecognizeResult> {
const imageBase64 = req?.imageBase64?.trim() ?? '';
const imageUrl = req?.imageUrl?.trim() ?? '';
if (!imageBase64 && !imageUrl) {
throw new CoolCommException('请提供 imageBase64 或 imageUrl');
}
const apiKey = process.env.OCR_API_KEY?.trim();
const baseUrl = process.env.OCR_BASE_URL?.trim();
if (!apiKey || !baseUrl) {
throw new CoolCommException('未配置 OCR 服务(OCR_API_KEY / OCR_BASE_URL)');
}
let responseData: unknown;
try {
const response = await axios.post(baseUrl, req, {
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
timeout: 15000,
});
responseData = response.data;
} catch (err) {
const message = err instanceof Error ? err.message : 'unknown error';
throw new CoolCommException(`OCR 识别失败: ${message}`);
}
const payload =
responseData && typeof responseData === 'object'
? (responseData as Record<string, unknown>)
: {};
return {
name: this.asString(payload.name),
company: this.asString(payload.company),
phone: this.asString(payload.phone),
email: this.asString(payload.email),
title: this.asString(payload.title),
};
}
private asString(value: unknown): string {
if (value === undefined || value === null) {
return '';
}
return String(value);
}
}

查看文件

@@ -4,6 +4,12 @@ import { InjectEntityModel } from '@midwayjs/typeorm';
import { Repository } from 'typeorm';
import { BaseAuditLogEntity } from '../entity/audit';
/** NFC 一键挂失成功审计动作名 */
export const AUDIT_ACTION_NFC_FREEZE = 'nfc_freeze';
/** 线索批量导出成功审计动作名 */
export const AUDIT_ACTION_LEAD_BATCH_EXPORT = 'lead_batch_export';
export interface AuditRecordDTO {
module: string;
action: string;

查看文件

@@ -1,5 +1,6 @@
import { Inject, Post } from '@midwayjs/core';
import { CoolController, BaseController } from '@cool-midway/core';
import { App, Body, Inject, Post } from '@midwayjs/core';
import type { IMidwayApplication } from '@midwayjs/core';
import { CoolController, BaseController, CoolCommException } from '@cool-midway/core';
import { CardInfoEntity } from '../../entity/info';
import { CardInfoService } from '../../service/info';
@@ -43,17 +44,50 @@ export class AdminCardInfoController extends BaseController {
@Inject()
cardInfoService: CardInfoService;
@App()
app: IMidwayApplication;
/**
* 是否生产环境:NODE_ENV=production,或 Midway getEnv 为 prod/production
*/
private isProductionEnv(): boolean {
if (process.env.NODE_ENV === 'production') {
return true;
}
const midwayEnv = this.app?.getEnv?.();
return midwayEnv === 'prod' || midwayEnv === 'production';
}
/**
* 一键播种/同步 6 套全球顶级商务名片预览数据
* 接口地址: POST /admin/card/info/seed
* 生产环境禁止调用,避免写入演示名片
*/
@Post('/seed', { summary: '一键播种高保真名片预览数据' })
async seed() {
if (this.isProductionEnv()) {
throw new CoolCommException('生产环境禁止播种演示名片数据');
}
const count = await this.cardInfoService.seedMockCards();
return this.ok({
count,
message: `成功同步 ${count} 套全球高保真商务名片预览数据`,
});
}
/**
* 按名片 id 导出 vCard(.vcf)
* 接口地址: POST /admin/card/info/vcard
*/
@Post('/vcard', { summary: '导出名片 vCard' })
async vcard(@Body() body: { id?: number | string }) {
const id = Number(body?.id);
if (!Number.isFinite(id) || id <= 0) {
throw new CoolCommException('名片不存在');
}
const vcard = await this.cardInfoService.exportVCard(id);
const filename = `card-${id}.vcf`;
return this.ok({ vcard, filename });
}
}

查看文件

@@ -93,10 +93,15 @@ export class OpenCardPortalController extends BaseController {
/**
* 获取全部高保真名片预览清单 (供前端预览体验与免登录快速切换)
* 接口地址: GET /open/card/portal/mock-cards
* 默认返回空数组;仅当环境变量 ALLOW_PORTAL_MOCK=1 时返回演示清单。
* 真实门户详情接口不依赖本接口,不会回退到演示名片。
*/
@CoolTag(TagTypes.IGNORE_TOKEN)
@Get('/mock-cards', { summary: '获取高保真名片预览清单' })
async mockCards() {
if (process.env.ALLOW_PORTAL_MOCK !== '1') {
return this.ok([]);
}
return this.ok(
MOCK_CARDS_DATA.map(item => ({
...item.card,

查看文件

@@ -1,5 +1,5 @@
import { Init, Inject, Provide } from '@midwayjs/core';
import { BaseService } from '@cool-midway/core';
import { BaseService, CoolCommException } from '@cool-midway/core';
import { InjectEntityModel } from '@midwayjs/typeorm';
import { Repository } from 'typeorm';
import { CardInfoEntity } from '../entity/info';
@@ -7,6 +7,11 @@ import { CardPortalService } from './portal';
import { TeamQuotaService } from '../../team/service/quota';
import { getTenantId } from '../../../comm/context/tenant';
import { seedCardsToDatabase } from '../mock/seed-cards';
import {
generateVCard,
VCardData,
VCardSocialProfile,
} from '../../../comm/vcard/vcard';
/**
* 名片增删改载荷(Cool CRUD 传入对象、主键或主键列表)
@@ -109,5 +114,69 @@ export class CardInfoService extends BaseService {
CardPortalService.clearAllCache();
return count;
}
/**
* 按名片 id 导出 RFC 6350 合规 vCard 文本
*/
async exportVCard(id: number | string): Promise<string> {
const cardId = Number(id);
if (!Number.isFinite(cardId) || cardId <= 0) {
throw new CoolCommException('名片不存在');
}
const card = await this.cardInfoEntity.findOneBy({ id: cardId });
if (!card) {
throw new CoolCommException('名片不存在');
}
return generateVCard(this.toVCardData(card));
}
private toVCardData(card: CardInfoEntity): VCardData {
const socialProfiles: VCardSocialProfile[] = [];
if (card.whatsapp) {
socialProfiles.push({ type: 'whatsapp', url: card.whatsapp });
}
if (card.linkedin) {
socialProfiles.push({ type: 'linkedin', url: card.linkedin });
}
if (card.telegram) {
socialProfiles.push({ type: 'telegram', url: card.telegram });
}
if (card.twitter) {
socialProfiles.push({ type: 'twitter', url: card.twitter });
}
if (card.wechat) {
socialProfiles.push({ type: 'wechat', url: card.wechat });
}
const data: VCardData = {
fn: card.name,
name: { givenName: card.name },
};
if (card.title) {
data.title = card.title;
}
if (card.company) {
data.org = card.company;
}
if (card.phone) {
data.tel = { number: card.phone, type: 'CELL' };
}
if (card.email) {
data.email = { address: card.email, type: 'WORK' };
}
if (card.slug) {
data.url = `https://card.omnicard.ai/${card.slug}`;
}
if (card.slogan) {
data.note = card.slogan;
}
if (card.avatarUrl) {
data.photo = card.avatarUrl;
}
if (socialProfiles.length > 0) {
data.socialProfiles = socialProfiles;
}
return data;
}
}

查看文件

@@ -22,6 +22,8 @@ export interface MutualExchangeInvitation {
notes?: string;
createdAt: number;
acceptedAt?: number;
/** 是否已成功写入持久化仓库;仅内存 Map 时为 false */
persisted?: boolean;
}
export interface MutualExchangeSyncAction {
@@ -140,6 +142,7 @@ export class CardMutualExchangeService extends BaseService {
params.recipientCardId,
params.notes
);
invite.persisted = false;
this.invitations.set(invite.id, invite);
if (this.exchangeEntity) {
@@ -159,6 +162,7 @@ export class CardMutualExchangeService extends BaseService {
row.notes = params.notes;
const saved = await this.exchangeEntity.save(row);
invite.id = String(saved.id);
invite.persisted = true;
this.invitations.set(invite.id, invite);
}
@@ -188,13 +192,18 @@ export class CardMutualExchangeService extends BaseService {
async confirmExchange(
invitationId: string,
actingCardId: number
): Promise<{ status: MutualExchangeStatus; isDualSynced: boolean }> {
): Promise<{
status: MutualExchangeStatus;
isDualSynced: boolean;
persisted: boolean;
}> {
const invite = await this.loadInvitation(invitationId);
if (!invite) {
throw new Error('未找到该名片互换邀约');
}
const res = acceptMutualExchangeInvitation(invite, actingCardId);
let persisted = false;
this.invitations.set(invite.id, res.invitation);
if (this.exchangeEntity) {
@@ -205,8 +214,11 @@ export class CardMutualExchangeService extends BaseService {
row.status = 'ACCEPTED';
row.acceptedAt = new Date().toISOString();
await this.exchangeEntity.save(row);
persisted = true;
}
}
res.invitation.persisted = persisted;
this.invitations.set(invite.id, res.invitation);
let isDualSynced = res.syncActions.length === 2;
if (this.cardContactService && this.cardInfoEntity) {
@@ -228,13 +240,18 @@ export class CardMutualExchangeService extends BaseService {
return {
status: res.invitation.status,
isDualSynced,
persisted,
};
}
async confirmByCode(
exchangeCode: string,
actingCardId: number
): Promise<{ status: MutualExchangeStatus; isDualSynced: boolean }> {
): Promise<{
status: MutualExchangeStatus;
isDualSynced: boolean;
persisted: boolean;
}> {
const code = String(exchangeCode || '').trim();
if (!/^\d{6}$/.test(code)) {
throw new CoolCommException('互换码无效', 400);

查看文件

@@ -9,9 +9,13 @@ import { CrmLeadPoolService } from '../../service/lead-pool';
import {
CrmSyncService,
HubSpotContactPayload,
SalesforceLeadPayload,
WebhookResult,
} from '../../service/sync';
import { CrmHubSpotService } from '../../service/hubspot';
import {
CrmSalesforceService,
SalesforceLeadPayload,
} from '../../service/salesforce';
/**
* 外部 CRM 同步请求体
@@ -39,6 +43,7 @@ export interface SyncExternalDTO {
},
serviceApis: [
{ method: 'updateStatus', summary: '线索状态流转' },
{ method: 'batchExport', summary: '线索批量导出' },
],
})
export class AdminCrmLeadController extends BaseController {
@@ -51,6 +56,12 @@ export class AdminCrmLeadController extends BaseController {
@Inject()
crmSyncService: CrmSyncService;
@Inject()
crmHubSpotService: CrmHubSpotService;
@Inject()
crmSalesforceService: CrmSalesforceService;
@InjectEntityModel(CrmLeadEntity)
crmLeadEntity: Repository<CrmLeadEntity>;
@@ -95,6 +106,9 @@ export class AdminCrmLeadController extends BaseController {
data: {
target: 'hubspot' | 'salesforce' | 'webhook';
leadId: number;
synced?: boolean;
reason?: string;
id?: string;
payload?: HubSpotContactPayload | SalesforceLeadPayload;
result?: WebhookResult;
};
@@ -117,19 +131,96 @@ export class AdminCrmLeadController extends BaseController {
if (target === 'hubspot') {
const payload = this.crmSyncService.formatToHubSpot(lead);
return this.ok({ target, leadId: id, payload }) as unknown as {
const accessToken = process.env.HUBSPOT_ACCESS_TOKEN;
if (!accessToken || !accessToken.trim()) {
return this.ok({
target,
leadId: id,
synced: false,
reason: '未配置 HubSpot',
payload,
}) as unknown as {
code: number;
message: string;
data: {
target: 'hubspot';
leadId: number;
synced: false;
reason: string;
payload: typeof payload;
};
};
}
const created = await this.crmHubSpotService.createContact(
accessToken,
payload
);
return this.ok({
target,
leadId: id,
synced: true,
id: created.id,
}) as unknown as {
code: number;
message: string;
data: { target: 'hubspot'; leadId: number; payload: typeof payload };
data: {
target: 'hubspot';
leadId: number;
synced: true;
id: string;
};
};
}
if (target === 'salesforce') {
const payload = this.crmSyncService.formatToSalesforce(lead);
return this.ok({ target, leadId: id, payload }) as unknown as {
const accessToken = process.env.SALESFORCE_ACCESS_TOKEN;
const instanceUrl = process.env.SALESFORCE_INSTANCE_URL;
if (
!accessToken ||
!accessToken.trim() ||
!instanceUrl ||
!instanceUrl.trim()
) {
return this.ok({
target,
leadId: id,
synced: false,
reason: '未配置 Salesforce',
payload,
}) as unknown as {
code: number;
message: string;
data: {
target: 'salesforce';
leadId: number;
synced: false;
reason: string;
payload: typeof payload;
};
};
}
const created = await this.crmSalesforceService.createLead(
accessToken,
instanceUrl,
payload
);
return this.ok({
target,
leadId: id,
synced: true,
id: created.id,
}) as unknown as {
code: number;
message: string;
data: { target: 'salesforce'; leadId: number; payload: typeof payload };
data: {
target: 'salesforce';
leadId: number;
synced: true;
id: string;
};
};
}

查看文件

@@ -0,0 +1,77 @@
import axios from 'axios';
import { Provide } from '@midwayjs/core';
import { BaseService, CoolCommException } from '@cool-midway/core';
/**
* HubSpot Contacts 创建结果
*/
export interface HubSpotCreateContactResult {
id: string;
raw: unknown;
}
/**
* 可注入的 HubSpot Contacts 客户端
*/
@Provide()
export class CrmHubSpotService extends BaseService {
/**
* 调用 HubSpot Contacts v3 API 创建联系人
*/
async createContact(
accessToken: string,
payload: { properties: Record<string, string> }
): Promise<HubSpotCreateContactResult> {
if (!accessToken || !accessToken.trim()) {
throw new CoolCommException('未配置 HubSpot accessToken,无法创建联系人', 400);
}
try {
const response = await axios.post(
'https://api.hubapi.com/crm/v3/objects/contacts',
payload,
{
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
timeout: 8000,
}
);
const raw = response.data;
const id =
raw && typeof raw === 'object' && 'id' in raw
? String((raw as { id: unknown }).id)
: '';
return { id, raw };
} catch (err: unknown) {
if (err instanceof CoolCommException) {
throw err;
}
const axiosErr = err as {
response?: { status?: number; data?: unknown };
message?: string;
};
const status = axiosErr.response?.status;
const data = axiosErr.response?.data;
let detail = axiosErr.message || 'HubSpot request failed';
if (data && typeof data === 'object' && data !== null) {
const msg = (data as { message?: unknown }).message;
if (typeof msg === 'string' && msg.trim()) {
detail = msg;
}
} else if (typeof data === 'string' && data.trim()) {
detail = data;
}
if (status) {
throw new CoolCommException(`HubSpot API 错误 ${status}: ${detail}`, status);
}
throw new CoolCommException(`HubSpot API 请求失败: ${detail}`, 500);
}
}
}

查看文件

@@ -16,6 +16,27 @@ import {
LeadScoreResult,
LeadDiagnosticReport,
} from './lead-score';
import {
AUDIT_ACTION_LEAD_BATCH_EXPORT,
BaseAuditLogService,
} from '../../base/service/audit';
/**
* 线索批量导出入参(审计用操作者可显式传入,缺省取 baseCtx.admin)
*/
export interface LeadBatchExportDTO {
size?: number;
operatorId?: number | null;
operatorName?: string | null;
}
/**
* 线索批量导出结果(不含密钥;列表供调用方落盘,审计正文仅记条数)
*/
export interface LeadBatchExportResult {
list: CrmLeadEntity[];
exportCount: number;
}
/**
* 意向度评分计算结果接口
@@ -64,6 +85,9 @@ export class CrmLeadService extends BaseService {
@Inject()
leadScoreService: CrmLeadScoreService = new CrmLeadScoreService();
@Inject()
baseAuditLogService: BaseAuditLogService;
/**
* 智能意向度评分计算引擎
* 根据停留时长、滚动深度、浏览模块数加权综合评分 (0 - 100 分)
@@ -271,4 +295,51 @@ export class CrmLeadService extends BaseService {
lead.status = status;
return await this.crmLeadEntity.save(lead);
}
/**
* 线索批量导出:拉取最近线索列表,成功后写一条审计(仅条数,不含手机号)
* 兼容管理后台 serviceApis:POST body 为单参数对象
*/
async batchExport(
params: LeadBatchExportDTO = {}
): Promise<LeadBatchExportResult> {
const size = Math.min(
1000,
Math.max(1, Number(params?.size) > 0 ? Number(params.size) : 500)
);
const list = await this.crmLeadEntity.find({
take: size,
order: { createTime: 'DESC' },
});
const exportCount = list.length;
const admin = this.baseCtx?.admin as
| { userId?: number; username?: string }
| undefined;
let operatorId: number | null = null;
if (params?.operatorId !== undefined && params?.operatorId !== null) {
operatorId = Number(params.operatorId);
} else if (admin?.userId != null) {
operatorId = Number(admin.userId);
}
const operatorName =
params?.operatorName != null && String(params.operatorName).trim()
? String(params.operatorName).trim()
: admin?.username
? String(admin.username)
: null;
await this.baseAuditLogService.record({
module: 'crm',
action: AUDIT_ACTION_LEAD_BATCH_EXPORT,
operatorId,
operatorName,
ip: this.baseCtx?.ip ? String(this.baseCtx.ip) : null,
detail: JSON.stringify({ exportCount }),
status: 1,
});
return { list, exportCount };
}
}

查看文件

@@ -1,4 +1,5 @@
import { Provide, Scope, ScopeEnum } from '@midwayjs/core';
import axios from 'axios';
import { RadarTrack, TouchType } from '../../../comm/types/omni';
/** 雷达访客实时事件名,与管理端 / 客户端约定一致 */
@@ -37,11 +38,55 @@ export interface RadarPushPayload {
export type RadarPushListener = (payload: RadarPushPayload) => void;
/** dispatch 外发结果:不伪造设备推送成功 */
export interface RadarDispatchResult {
delivered: boolean;
channel: 'memory' | 'webhook';
error?: string;
}
const WEBHOOK_TIMEOUT_MS = 5000;
const BLOCKED_WEBHOOK_ERROR = '拒绝内网或非 http(s) Webhook URL';
/**
* 校验 Webhook URL:仅允许公网 http/https,拒绝环回与私有网段。
*/
export function isDisallowedRadarWebhookUrl(urlStr: string): boolean {
let parsed: URL;
try {
parsed = new URL(urlStr);
} catch {
return true;
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return true;
}
const host = parsed.hostname.toLowerCase();
if (host === 'localhost' || host === '127.0.0.1') {
return true;
}
if (/^10\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host)) {
return true;
}
const m172 = /^172\.(\d{1,3})\.\d{1,3}\.\d{1,3}$/.exec(host);
if (m172) {
const second = Number(m172[1]);
if (second >= 16 && second <= 31) {
return true;
}
}
if (/^192\.168\.\d{1,3}\.\d{1,3}$/.test(host)) {
return true;
}
return false;
}
/**
* 雷达 Push 通知服务(内存渠道骨架)
*
* 不依赖 Redis / Socket.io 进程适配器。
* publish 只通知本进程内 subscribe 的监听器,不伪造设备投递成功。
* dispatch 在内存通知后可选 Webhook 外发,仅 HTTP 2xx 才标记 delivered。
*/
@Provide()
@Scope(ScopeEnum.Singleton)
@@ -88,14 +133,60 @@ export class CrmRadarPushService {
*/
publish(track: RadarTrack): RadarPushPayload {
const payload = this.buildPayload(track);
const listeners = this.channels.get(this.channelKey(track.cardId));
this.notifyListeners(payload);
return payload;
}
/**
* 外发分发:先通知内存监听者,再按需 POST Webhook。
* 未配置或失败时 delivered 恒为 false,不抛错中断雷达主流程。
*/
async dispatch(payload: RadarPushPayload): Promise<RadarDispatchResult> {
this.notifyListeners(payload);
const url = (process.env.RADAR_PUSH_WEBHOOK_URL || '').trim();
if (!url) {
return { delivered: false, channel: 'memory' };
}
if (isDisallowedRadarWebhookUrl(url)) {
return {
delivered: false,
channel: 'memory',
error: BLOCKED_WEBHOOK_ERROR,
};
}
try {
const res = await axios.post(url, payload, { timeout: WEBHOOK_TIMEOUT_MS });
const status = Number(res?.status) || 0;
if (status >= 200 && status < 300) {
return { delivered: true, channel: 'webhook' };
}
return {
delivered: false,
channel: 'webhook',
error: `HTTP ${status || 'unknown'}`,
};
} catch (err: unknown) {
const message =
err instanceof Error ? err.message : 'Webhook 请求失败';
return {
delivered: false,
channel: 'webhook',
error: message,
};
}
}
private notifyListeners(payload: RadarPushPayload): void {
const listeners = this.channels.get(this.channelKey(payload.cardId));
if (!listeners) {
return payload;
return;
}
for (const listener of listeners) {
listener(payload);
}
return payload;
}
private channelKey(cardId: string | number): string {

查看文件

@@ -1,10 +1,13 @@
import { Inject, Provide } from '@midwayjs/core';
import { BaseService, CoolCommException } from '@cool-midway/core';
import { InjectEntityModel } from '@midwayjs/typeorm';
import { Repository } from 'typeorm';
import { In, Repository } from 'typeorm';
import { CrmTrackEntity } from '../entity/track';
import { CrmLeadEntity } from '../entity/lead';
import { CardInfoEntity } from '../../card/entity/info';
import { CardBentoEntity } from '../../card/entity/bento';
import { UserInfoEntity } from '../../user/entity/info';
import { BaseSysUserEntity } from '../../base/entity/sys/user';
import { CrmRadarBufferService } from './buffer';
import { CrmRadarPushService, isHotRadarPush } from './push';
import { RadarHotInbox, RadarTrack } from '../../../comm/types/omni';
@@ -60,6 +63,252 @@ function resolveEventTime(item: { createTime?: Date | string; createdAt?: Date |
return toTimestamp(item.createTime ?? item.createdAt);
}
/** 最近 5 分钟内的足迹视为仍在线上 */
export const RADAR_ONLINE_WINDOW_MS = 5 * 60 * 1000;
export interface RadarMonitorLead {
id?: string | number;
visitorId?: string;
name?: string;
company?: string;
phone?: string;
}
export interface RadarMonitorUser {
phone?: string;
avatarUrl?: string;
nickName?: string;
name?: string;
}
export interface RadarMonitorBlock {
id?: string | number;
cardId?: string | number;
type?: string;
title?: string;
}
export interface RadarMonitorCard {
id?: string | number;
name?: string;
title?: string;
company?: string;
}
export interface RadarViewedContentItem {
key: string;
title: string;
}
export interface RadarMonitorRow {
id?: string | number;
tenantId?: string | number;
cardId?: string | number;
visitorId?: string;
ip?: string;
country?: string;
city?: string;
touchType?: string;
stayDurationMs?: number;
scrollDepth?: number;
viewedBlocks?: string[];
viewedContents?: RadarViewedContentItem[];
visitorName?: string;
visitorCompany?: string;
avatarUrl?: string;
cardTitle?: string;
online?: boolean;
createTime?: Date | string;
createdAt?: Date | string;
}
function readText(value: unknown): string {
if (value == null) {
return '';
}
return String(value).trim();
}
function normalizeBlockKeys(value: unknown): string[] {
if (Array.isArray(value)) {
return value.map(item => readText(item)).filter(item => item !== '');
}
if (typeof value === 'string' && value.trim() !== '') {
try {
const parsed = JSON.parse(value) as unknown;
if (Array.isArray(parsed)) {
return parsed.map(item => readText(item)).filter(item => item !== '');
}
} catch {
return [value.trim()];
}
}
return [];
}
function isRadarOnlineAt(time: number, now: number): boolean {
if (time <= 0) {
return false;
}
const diff = now - time;
return diff <= RADAR_ONLINE_WINDOW_MS && diff >= -60 * 1000;
}
/**
* 把足迹补成名片主可直接看的监控行:线索姓名、内部用户头像、模块标题、是否在线。
* 对不上的字段留空,不编造地址或头像。
*/
export function decorateRadarTracks(
tracks: RadarMonitorRow[],
leads: RadarMonitorLead[],
users: RadarMonitorUser[],
blocks: RadarMonitorBlock[],
now = Date.now(),
cards: RadarMonitorCard[] = []
): RadarMonitorRow[] {
const leadByVisitor = new Map<string, RadarMonitorLead>();
for (const lead of leads) {
const visitorId = readText(lead.visitorId);
if (visitorId === '') {
continue;
}
const prev = leadByVisitor.get(visitorId);
if (!prev) {
leadByVisitor.set(visitorId, lead);
continue;
}
const prevName = readText(prev.name);
const nextName = readText(lead.name);
const prevId = Number(prev.id || 0);
const nextId = Number(lead.id || 0);
if ((prevName === '' && nextName !== '') || nextId >= prevId) {
leadByVisitor.set(visitorId, lead);
}
}
const userByPhone = new Map<string, RadarMonitorUser>();
for (const user of users) {
const phone = readText(user.phone);
if (phone === '') {
continue;
}
const prev = userByPhone.get(phone);
const prevAvatar = prev ? readText(prev.avatarUrl) : '';
const nextAvatar = readText(user.avatarUrl);
if (!prev || (prevAvatar === '' && nextAvatar !== '')) {
userByPhone.set(phone, user);
}
}
const blockTitle = new Map<string, string>();
for (const block of blocks) {
const cardId = readText(block.cardId);
const title = readText(block.title);
const id = readText(block.id);
const type = readText(block.type);
if (cardId !== '' && id !== '' && title !== '') {
blockTitle.set(`${cardId}:${id}`, title);
}
if (cardId !== '' && type !== '' && title !== '' && !blockTitle.has(`${cardId}:${type}`)) {
blockTitle.set(`${cardId}:${type}`, title);
}
}
const cardLabel = new Map<string, string>();
for (const card of cards) {
const id = readText(card.id);
if (id === '') {
continue;
}
const parts = [readText(card.name), readText(card.title)].filter(part => part !== '');
const label = parts.length > 0 ? parts.join(' · ') : readText(card.company);
if (label !== '') {
cardLabel.set(id, label);
}
}
return tracks.map(track => {
const raw = track as RadarMonitorRow & Record<string, unknown>;
const visitorId = readText(track.visitorId ?? raw.visitor_id);
const cardId = readText(track.cardId ?? raw.card_id);
const lead = visitorId !== '' ? leadByVisitor.get(visitorId) : undefined;
const phone = lead ? readText(lead.phone) : '';
const user = phone !== '' ? userByPhone.get(phone) : undefined;
const keys = normalizeBlockKeys(track.viewedBlocks ?? raw.viewed_blocks);
const viewedContents: RadarViewedContentItem[] = [];
const seen = new Set<string>();
for (const key of keys) {
if (seen.has(key)) {
continue;
}
seen.add(key);
const title = blockTitle.get(`${cardId}:${key}`) || '';
viewedContents.push({ key, title });
}
const createTime = normalizeMonitorTime(
track.createTime ?? track.createdAt ?? raw.create_time ?? raw.created_at
);
const eventTime = createTime != null ? toTimestamp(createTime) : 0;
const visitorName = readText(lead?.name) || readText(user?.nickName) || readText(user?.name);
const avatarUrl = readText(user?.avatarUrl);
const stayRaw = track.stayDurationMs ?? raw.stay_duration_ms;
const depthRaw = track.scrollDepth ?? raw.scroll_depth;
const stayDurationMs = Number.isFinite(Number(stayRaw)) ? Math.max(0, Math.round(Number(stayRaw))) : 0;
const scrollDepth = Number.isFinite(Number(depthRaw)) ? Math.max(0, Math.round(Number(depthRaw))) : 0;
const touchType = readText(track.touchType ?? raw.touch_type) || 'link';
return {
id: track.id,
tenantId: (track.tenantId ?? raw.tenant_id) as string | number | undefined,
cardId: (track.cardId ?? raw.card_id) as string | number | undefined,
visitorId,
ip: readText(track.ip ?? raw.ip),
country: readText(track.country ?? raw.country),
city: readText(track.city ?? raw.city),
touchType,
stayDurationMs,
scrollDepth,
viewedBlocks: keys,
viewedContents,
visitorName: visitorName !== '' ? visitorName : undefined,
visitorCompany: lead && readText(lead.company) !== '' ? readText(lead.company) : undefined,
avatarUrl: avatarUrl !== '' ? avatarUrl : undefined,
cardTitle: cardLabel.get(cardId),
online: isRadarOnlineAt(eventTime, now),
createTime,
createdAt: createTime,
};
});
}
function readVisitorKey(item: RadarMonitorRow): string {
const raw = item as RadarMonitorRow & Record<string, unknown>;
return readText(item.visitorId ?? raw.visitor_id);
}
function readCardKey(item: RadarMonitorRow): number {
const raw = item as RadarMonitorRow & Record<string, unknown>;
return Number(item.cardId ?? raw.card_id);
}
function normalizeMonitorTime(value: unknown): string | undefined {
if (value == null || value === '') {
return undefined;
}
if (value instanceof Date) {
const time = value.getTime();
return Number.isFinite(time) ? value.toISOString() : undefined;
}
const text = String(value).trim();
if (text === '') {
return undefined;
}
const parsed = Date.parse(text.includes('T') ? text : text.replace(' ', 'T'));
if (Number.isNaN(parsed)) {
return text;
}
return new Date(parsed).toISOString();
}
/**
* 纯函数:多因子商务意向度科学评分模型 (0 - 99 分)
* 权重:渠道价值基准 (35%) + 停留时长阶梯 (40%) + 互动/滚动深度 (25%)
@@ -315,6 +564,15 @@ export interface DeviceStatItem {
percentage: number;
}
/**
* 访客语言分布项(仅当足迹存在 language/locale 等真实字段时才可聚合)
*/
export interface LanguageStatItem {
language: string;
count: number;
percentage: number;
}
/**
* 雷达大屏综合指标聚合结果
*/
@@ -322,6 +580,7 @@ export interface RadarAnalyticsResult {
countryDistribution: CountryDistributionItem[];
hourlyStats: HourlyStayStatItem[];
deviceStats: DeviceStatItem[];
languageStats: LanguageStatItem[];
summary: {
totalVisitors: number;
avgStayDurationMs: number;
@@ -330,6 +589,16 @@ export interface RadarAnalyticsResult {
};
}
/** 从足迹读取语言字段;实体无对应列或值为空时返回 null */
function readTrackLanguage(track: Record<string, unknown>): string | null {
const raw = track.language ?? track.locale ?? track.acceptLanguage;
if (raw == null) {
return null;
}
const text = String(raw).trim();
return text || null;
}
/**
* 访客雷达与轨迹流水服务
*/
@@ -344,6 +613,15 @@ export class CrmRadarService extends BaseService {
@InjectEntityModel(CardInfoEntity)
cardInfoEntity: Repository<CardInfoEntity>;
@InjectEntityModel(CardBentoEntity)
cardBentoEntity: Repository<CardBentoEntity>;
@InjectEntityModel(UserInfoEntity)
userInfoEntity: Repository<UserInfoEntity>;
@InjectEntityModel(BaseSysUserEntity)
baseSysUserEntity: Repository<BaseSysUserEntity>;
@Inject()
crmRadarBufferService: CrmRadarBufferService;
@@ -698,6 +976,7 @@ export class CrmRadarService extends BaseService {
/**
* 获取触碰渠道/终端分析统计
* 仅按足迹 touchType 真实计数;无足迹时返回 [],不补齐零值渠道行
*/
async getDeviceStats(cardId?: number): Promise<DeviceStatItem[]> {
const where: any = {};
@@ -706,32 +985,70 @@ export class CrmRadarService extends BaseService {
}
const tracks = await this.crmTrackEntity.find({ where });
const countMap: Record<string, number> = {
nfc: 0,
qr: 0,
link: 0,
};
if (!tracks || tracks.length === 0) {
return [];
}
const countMap: Record<string, number> = {};
let total = 0;
for (const track of tracks || []) {
for (const track of tracks) {
const type = (track.touchType && String(track.touchType).trim()) || 'link';
countMap[type] = (countMap[type] || 0) + 1;
total += 1;
}
const standardTypes = ['nfc', 'qr', 'link'];
const otherTypes = Object.keys(countMap).filter((t) => !standardTypes.includes(t));
const allTypes = [...standardTypes, ...otherTypes];
return allTypes.map((type) => {
const result: DeviceStatItem[] = Object.keys(countMap).map((type) => {
const count = countMap[type] || 0;
const percentage = total > 0 ? Number(((count / total) * 100).toFixed(1)) : 0;
return {
type,
count,
percentage,
};
return { type, count, percentage };
});
result.sort((a, b) => b.count - a.count);
return result;
}
/**
* 访客语言分布:仅统计足迹上已有的 language / locale / acceptLanguage 值。
* 实体无语言列或无有效值时返回 [],禁止示例语言或固定占比填洞。
*/
async getLanguageStats(cardId?: number): Promise<LanguageStatItem[]> {
const where: any = {};
if (cardId !== undefined && cardId !== null && !isNaN(Number(cardId))) {
where.cardId = Number(cardId);
}
const tracks = await this.crmTrackEntity.find({ where });
if (!tracks || tracks.length === 0) {
return [];
}
const countMap = new Map<string, number>();
let total = 0;
for (const track of tracks) {
const language = readTrackLanguage(track as unknown as Record<string, unknown>);
if (!language) {
continue;
}
countMap.set(language, (countMap.get(language) || 0) + 1);
total += 1;
}
if (total === 0) {
return [];
}
const result: LanguageStatItem[] = [];
for (const [language, count] of countMap.entries()) {
result.push({
language,
count,
percentage: Number(((count / total) * 100).toFixed(1)),
});
}
result.sort((a, b) => b.count - a.count);
return result;
}
/**
@@ -741,10 +1058,11 @@ export class CrmRadarService extends BaseService {
const cardId = params?.cardId !== undefined ? Number(params.cardId) : undefined;
const days = params?.days !== undefined ? Number(params.days) : 7;
const [countryDistribution, hourlyStats, deviceStats] = await Promise.all([
const [countryDistribution, hourlyStats, deviceStats, languageStats] = await Promise.all([
this.getCountryDistribution(cardId),
this.getHourlyStayStats(days, cardId),
this.getDeviceStats(cardId),
this.getLanguageStats(cardId),
]);
const totalVisitors = countryDistribution.reduce((acc, cur) => acc + cur.visitorCount, 0);
@@ -760,6 +1078,7 @@ export class CrmRadarService extends BaseService {
countryDistribution,
hourlyStats,
deviceStats,
languageStats,
summary: {
totalVisitors,
avgStayDurationMs,
@@ -774,8 +1093,10 @@ export class CrmRadarService extends BaseService {
*/
async getWorkplaceOverview(params?: {
feedLimit?: number;
now?: Date;
}): Promise<WorkplaceOverviewResult> {
const feedLimit = params?.feedLimit;
const now = params?.now ?? new Date();
const [tracks, leads, cards] = await Promise.all([
this.crmTrackEntity.find({
order: { id: 'DESC' },
@@ -791,9 +1112,111 @@ export class CrmRadarService extends BaseService {
return buildWorkplaceOverview(
tracks,
leads,
new Date(),
now,
feedLimit,
cards as WorkplaceCardMeta[]
);
}
/**
* 分页足迹补上线索身份、内部用户头像与模块标题。
* 补全失败时退回原始分页,避免监控页整页不可用。
*/
async page(query?: unknown, option?: unknown, connectionName?: unknown) {
const result = await super.page(query, option, connectionName);
if (result == null || !Array.isArray(result.list)) {
return result;
}
try {
result.list = await this.attachRadarMonitor(result.list as RadarMonitorRow[]);
} catch {
return result;
}
return result;
}
private async attachRadarMonitor(tracks: RadarMonitorRow[]): Promise<RadarMonitorRow[]> {
if (tracks.length === 0) {
return tracks;
}
const visitorIds = Array.from(
new Set(tracks.map(item => readVisitorKey(item)).filter(item => item !== ''))
);
const cardIds = Array.from(
new Set(
tracks
.map(item => readCardKey(item))
.filter(item => Number.isFinite(item) && item > 0)
)
);
const leads =
visitorIds.length > 0 && this.crmLeadEntity && typeof this.crmLeadEntity.find === 'function'
? await this.crmLeadEntity.find({ where: { visitorId: In(visitorIds) } })
: [];
const phones = Array.from(
new Set(leads.map(item => readText(item.phone)).filter(item => item !== ''))
);
const appUsers =
phones.length > 0 && this.userInfoEntity && typeof this.userInfoEntity.find === 'function'
? await this.userInfoEntity.find({ where: { phone: In(phones) } })
: [];
const staffUsers =
phones.length > 0 && this.baseSysUserEntity && typeof this.baseSysUserEntity.find === 'function'
? await this.baseSysUserEntity.find({
where: { phone: In(phones) },
select: ['id', 'phone', 'nickName', 'name', 'headImg'],
})
: [];
const blocks =
cardIds.length > 0 && this.cardBentoEntity && typeof this.cardBentoEntity.find === 'function'
? await this.cardBentoEntity.find({ where: { cardId: In(cardIds) } })
: [];
const cards =
cardIds.length > 0 && this.cardInfoEntity && typeof this.cardInfoEntity.find === 'function'
? await this.cardInfoEntity.find({
where: { id: In(cardIds) },
select: ['id', 'name', 'title', 'company'],
})
: [];
const users: RadarMonitorUser[] = [
...appUsers.map(item => ({
phone: item.phone,
avatarUrl: item.avatarUrl,
nickName: item.nickName,
})),
...staffUsers.map(item => ({
phone: item.phone,
avatarUrl: item.headImg,
nickName: item.nickName,
name: item.name,
})),
];
return decorateRadarTracks(
tracks,
leads.map(item => ({
id: item.id,
visitorId: item.visitorId,
name: item.name,
company: item.company,
phone: item.phone,
})),
users,
blocks.map(item => ({
id: item.id,
cardId: item.cardId,
type: item.type,
title: item.title,
})),
Date.now(),
cards.map(item => ({
id: item.id,
name: item.name,
title: item.title,
company: item.company,
}))
);
}
}

查看文件

@@ -0,0 +1,142 @@
import axios from 'axios';
import { Provide } from '@midwayjs/core';
import { BaseService, CoolCommException } from '@cool-midway/core';
/**
* Salesforce Lead 创建结果
*/
export interface SalesforceCreateLeadResult {
id: string;
raw: unknown;
}
/**
* Salesforce Lead 创建入参
*/
export interface SalesforceLeadPayload {
FirstName: string;
LastName: string;
Email: string;
Company: string;
Phone: string;
Status: string;
}
/**
* 校验 instanceUrl:仅允许 https,且主机名以 .salesforce.com 或 .force.com 结尾
*/
function assertSafeSalesforceInstanceUrl(instanceUrl: string): string {
const trimmed = instanceUrl.trim();
let parsed: URL;
try {
parsed = new URL(trimmed);
} catch {
throw new CoolCommException('Salesforce instanceUrl 非法,无法创建线索', 400);
}
if (parsed.protocol !== 'https:') {
throw new CoolCommException(
'Salesforce instanceUrl 必须使用 https,无法创建线索',
400
);
}
const host = parsed.hostname.toLowerCase();
if (!host.endsWith('.salesforce.com') && !host.endsWith('.force.com')) {
throw new CoolCommException(
'Salesforce instanceUrl 主机名不合法,无法创建线索',
400
);
}
// 去掉尾部斜杠,避免双斜杠
return trimmed.replace(/\/+$/, '');
}
/**
* 从 Salesforce 错误响应中提取可读信息
*/
function extractSalesforceErrorDetail(data: unknown, fallback: string): string {
if (Array.isArray(data) && data.length > 0) {
const first = data[0];
if (first && typeof first === 'object' && first !== null) {
const msg = (first as { message?: unknown }).message;
if (typeof msg === 'string' && msg.trim()) {
return msg;
}
}
}
if (data && typeof data === 'object' && data !== null) {
const msg = (data as { message?: unknown }).message;
if (typeof msg === 'string' && msg.trim()) {
return msg;
}
}
if (typeof data === 'string' && data.trim()) {
return data;
}
return fallback;
}
/**
* 可注入的 Salesforce Lead 客户端
*/
@Provide()
export class CrmSalesforceService extends BaseService {
/**
* 调用 Salesforce REST API 创建 Lead
*/
async createLead(
accessToken: string,
instanceUrl: string,
payload: SalesforceLeadPayload
): Promise<SalesforceCreateLeadResult> {
if (!accessToken || !accessToken.trim()) {
throw new CoolCommException('未配置 Salesforce accessToken,无法创建线索', 400);
}
if (!instanceUrl || !instanceUrl.trim()) {
throw new CoolCommException('未配置 Salesforce instanceUrl,无法创建线索', 400);
}
const safeBase = assertSafeSalesforceInstanceUrl(instanceUrl);
const url = `${safeBase}/services/data/v59.0/sobjects/Lead`;
try {
const response = await axios.post(url, payload, {
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
timeout: 8000,
});
const raw = response.data;
const id =
raw && typeof raw === 'object' && 'id' in raw
? String((raw as { id: unknown }).id)
: '';
return { id, raw };
} catch (err: unknown) {
if (err instanceof CoolCommException) {
throw err;
}
const axiosErr = err as {
response?: { status?: number; data?: unknown };
message?: string;
};
const status = axiosErr.response?.status;
const data = axiosErr.response?.data;
const detail = extractSalesforceErrorDetail(
data,
axiosErr.message || 'Salesforce request failed'
);
if (status) {
throw new CoolCommException(`Salesforce API 错误 ${status}: ${detail}`, status);
}
throw new CoolCommException(`Salesforce API 请求失败: ${detail}`, 500);
}
}
}

查看文件

@@ -18,17 +18,7 @@ export interface HubSpotContactPayload {
};
}
/**
* Salesforce Lead sObject 格式
*/
export interface SalesforceLeadPayload {
FirstName: string;
LastName: string;
Email: string;
Company: string;
Phone: string;
Status: 'Open - Not Contacted';
}
import { SalesforceLeadPayload } from './salesforce';
/**
* Webhook 发送配置选项
@@ -49,6 +39,37 @@ export interface WebhookResult {
error?: string;
}
/**
* Slack Incoming Webhook 消息体(组包格式)
*/
export interface SlackMessagePayload {
text: string;
blocks?: Array<{
type: string;
text?: { type: string; text: string };
}>;
}
/**
* 飞书自定义机器人消息体(组包格式)
*/
export interface FeishuMessagePayload {
msg_type: 'text';
content: { text: string };
}
/**
* Slack / 飞书外发结果:仅 HTTP 明确成功才 delivered:true
*/
export interface ChannelNotifyResult {
delivered: boolean;
channel: 'slack' | 'feishu';
payload: SlackMessagePayload | FeishuMessagePayload;
reason?: string;
error?: string;
attempts?: number;
}
/**
* 国际主流 CRM (HubSpot / Salesforce / Webhook) 对接服务
*/
@@ -119,6 +140,156 @@ export class CrmSyncService extends BaseService {
};
}
/**
* 组装 Slack Incoming Webhook 消息(仅组包,不代表已送达)
*/
formatToSlack(lead: Partial<CrmLeadEntity>): SlackMessagePayload {
const name = (lead?.name || '').trim() || 'Unknown';
const company = (lead?.company || '').trim() || '-';
const email = (lead?.email || '').trim() || '-';
const phone = (lead?.phone || '').trim() || '-';
const text = `OmniCard Lead: ${name} @ ${company} | ${email} | ${phone}`;
return {
text,
blocks: [
{
type: 'section',
text: { type: 'mrkdwn', text: `*${name}* · ${company}\n${email} · ${phone}` },
},
],
};
}
/**
* 组装飞书自定义机器人文本消息(仅组包,不代表已送达)
*/
formatToFeishu(lead: Partial<CrmLeadEntity>): FeishuMessagePayload {
const name = (lead?.name || '').trim() || 'Unknown';
const company = (lead?.company || '').trim() || '-';
const email = (lead?.email || '').trim() || '-';
const phone = (lead?.phone || '').trim() || '-';
return {
msg_type: 'text',
content: {
text: `OmniCard 线索:${name} @ ${company} | ${email} | ${phone}`,
},
};
}
/**
* 发送 Slack:无 URL 只组包且 delivered:false;仅公网 HTTP 成功才 delivered:true
*/
async sendSlack(
webhookUrl: string | undefined,
lead: Partial<CrmLeadEntity>,
options?: WebhookSendOptions
): Promise<ChannelNotifyResult> {
const payload = this.formatToSlack(lead);
const url = (webhookUrl || '').trim();
if (!url) {
return {
delivered: false,
channel: 'slack',
payload,
reason: '未配置 Slack Webhook',
};
}
if (this.isDisallowedWebhookUrl(url)) {
throw new CoolCommException('拒绝内网或非 http(s) Webhook URL', 400);
}
const maxRetries = options?.maxRetries ?? 3;
const baseDelay = options?.delayMs ?? 100;
const timeout = options?.timeoutMs ?? 5000;
let lastError: Error | null = null;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
await axios.post(url, payload, {
headers: { 'Content-Type': 'application/json' },
timeout,
validateStatus: status => status >= 200 && status < 300,
});
return {
delivered: true,
channel: 'slack',
payload,
attempts: attempt,
};
} catch (err: unknown) {
lastError = err instanceof Error ? err : new Error(String(err));
if (attempt < maxRetries) {
await this.sleep(baseDelay * Math.pow(2, attempt - 1));
}
}
}
return {
delivered: false,
channel: 'slack',
payload,
attempts: maxRetries,
error: lastError?.message || 'Slack request failed',
};
}
/**
* 发送飞书:无 URL 只组包且 delivered:false;仅公网 HTTP 成功才 delivered:true
*/
async sendFeishu(
webhookUrl: string | undefined,
lead: Partial<CrmLeadEntity>,
options?: WebhookSendOptions
): Promise<ChannelNotifyResult> {
const payload = this.formatToFeishu(lead);
const url = (webhookUrl || '').trim();
if (!url) {
return {
delivered: false,
channel: 'feishu',
payload,
reason: '未配置飞书 Webhook',
};
}
if (this.isDisallowedWebhookUrl(url)) {
throw new CoolCommException('拒绝内网或非 http(s) Webhook URL', 400);
}
const maxRetries = options?.maxRetries ?? 3;
const baseDelay = options?.delayMs ?? 100;
const timeout = options?.timeoutMs ?? 5000;
let lastError: Error | null = null;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
await axios.post(url, payload, {
headers: { 'Content-Type': 'application/json' },
timeout,
validateStatus: status => status >= 200 && status < 300,
});
return {
delivered: true,
channel: 'feishu',
payload,
attempts: attempt,
};
} catch (err: unknown) {
lastError = err instanceof Error ? err : new Error(String(err));
if (attempt < maxRetries) {
await this.sleep(baseDelay * Math.pow(2, attempt - 1));
}
}
}
return {
delivered: false,
channel: 'feishu',
payload,
attempts: maxRetries,
error: lastError?.message || 'Feishu request failed',
};
}
/**
* 计算 HMAC-SHA256 签名
*/
@@ -128,6 +299,39 @@ export class CrmSyncService extends BaseService {
return crypto.createHmac('sha256', secret).update(content).digest('hex');
}
/**
* 拒绝内网 / 环回 / 非 http(s) Webhook URL(与雷达推送同等规则)
*/
private isDisallowedWebhookUrl(urlStr: string): boolean {
let parsed: URL;
try {
parsed = new URL(urlStr);
} catch {
return true;
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return true;
}
const host = parsed.hostname.toLowerCase();
if (host === 'localhost' || host === '127.0.0.1') {
return true;
}
if (/^10\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host)) {
return true;
}
const m172 = /^172\.(\d{1,3})\.\d{1,3}\.\d{1,3}$/.exec(host);
if (m172) {
const second = Number(m172[1]);
if (second >= 16 && second <= 31) {
return true;
}
}
if (/^192\.168\.\d{1,3}\.\d{1,3}$/.test(host)) {
return true;
}
return false;
}
/**
* 发送带 HMAC-SHA256 签名的 Webhook,具备模拟退避重试(最多 3 次)
*/
@@ -140,6 +344,9 @@ export class CrmSyncService extends BaseService {
if (!url || !url.trim()) {
throw new CoolCommException('缺少目标 Webhook URL', 400);
}
if (this.isDisallowedWebhookUrl(url.trim())) {
throw new CoolCommException('拒绝内网或非 http(s) Webhook URL', 400);
}
const payload = {
event: 'crm.lead.sync',

查看文件

@@ -12,6 +12,37 @@ export interface NfcSecurityAuditLog {
token?: string;
}
/**
* 审计日志查询结果
* 无持久化仓库时 logs 必须为空,并标明未落库(不得伪装已入库)
*/
export interface NfcAuditLogListResult {
logs: NfcSecurityAuditLog[];
persisted: boolean;
not_persisted?: true;
/** 兼容历史直接读取数组长度调用 */
length?: number;
}
/**
* 审计日志记录结果
* 无持久化仓库时不得返回成功落库;内存队列仅作进程内留痕
*/
export interface NfcAuditLogRecordResult {
persisted: boolean;
not_persisted?: true;
/** 是否已写入进程内内存队列(非落库) */
queuedInMemory?: boolean;
}
/**
* 可选的审计持久化仓库契约(未注入时视为无库)
*/
export interface NfcSecurityAuditRepository {
save(log: NfcSecurityAuditLog): Promise<void> | void;
list(): Promise<NfcSecurityAuditLog[]> | NfcSecurityAuditLog[];
}
/**
* 验签返回结果契约
*/
@@ -43,10 +74,23 @@ export class NfcAntiTamperService {
/**
* 安全审计日志队列 (内存循环留痕,上限 1000 条)
* 仅作进程内留痕;无持久化仓库时对外查询不得返回为已落库
*/
private readonly auditLogs: NfcSecurityAuditLog[] = [];
private readonly maxAuditLogSize = 1000;
/**
* 可选持久化仓库;未注入时查询/记录均标明 not_persisted
*/
private auditRepository: NfcSecurityAuditRepository | null = null;
/**
* 注入审计持久化仓库(可选)
*/
setAuditRepository(repo: NfcSecurityAuditRepository | null): void {
this.auditRepository = repo;
}
/**
* 生成设备动态防伪验签 Token
* 格式: `${timestamp}.${hmac_sha256_hex}`
@@ -108,7 +152,7 @@ export class NfcAntiTamperService {
typeof secret !== 'string'
) {
const reason = '缺少必要参数或参数不合法';
this.recordAuditLog(
this.recordAudit(
typeof uid === 'string' ? uid : '',
typeof cardId === 'number' ? cardId : 0,
reason,
@@ -121,7 +165,7 @@ export class NfcAntiTamperService {
const parts = token.split('.');
if (parts.length !== 2) {
const reason = 'Token 格式无效,缺少必要段落';
this.recordAuditLog(uid, cardId, reason, token);
this.recordAudit(uid, cardId, reason, token);
return { valid: false, reason };
}
@@ -129,13 +173,13 @@ export class NfcAntiTamperService {
const ts = Number(tsStr);
if (!Number.isSafeInteger(ts) || ts <= 0) {
const reason = 'Token 格式无效,时间戳非法';
this.recordAuditLog(uid, cardId, reason, token);
this.recordAudit(uid, cardId, reason, token);
return { valid: false, reason };
}
if (!/^[0-9a-f]{64}$/i.test(signature)) {
const reason = 'Token 格式无效,签名非有效 SHA256 十六进制串';
this.recordAuditLog(uid, cardId, reason, token);
this.recordAudit(uid, cardId, reason, token);
return { valid: false, reason };
}
@@ -143,13 +187,13 @@ export class NfcAntiTamperService {
const now = Date.now();
if (ts - now > maxFutureDriftMs) {
const reason = 'Token 时间戳异常(未来时钟偏差过大 clock drift detected)';
this.recordAuditLog(uid, cardId, reason, token);
this.recordAudit(uid, cardId, reason, token);
return { valid: false, reason };
}
if (now - ts > maxAgeMs) {
const reason = 'Token 已过期 (Token has expired)';
this.recordAuditLog(uid, cardId, reason, token);
this.recordAudit(uid, cardId, reason, token);
return { valid: false, reason };
}
@@ -157,7 +201,7 @@ export class NfcAntiTamperService {
this.cleanExpiredTokens(now);
if (this.usedTokens.has(token)) {
const reason = 'Token 已被使用,拦截重放攻击 (Replay attack detected)';
this.recordAuditLog(uid, cardId, reason, token);
this.recordAudit(uid, cardId, reason, token);
return { valid: false, reason };
}
@@ -176,7 +220,7 @@ export class NfcAntiTamperService {
!crypto.timingSafeEqual(sigBuf, expectedSigBuf)
) {
const reason = '签名无效,检测到恶意参数篡改 (Invalid signature / Tampered)';
this.recordAuditLog(uid, cardId, reason, token);
this.recordAudit(uid, cardId, reason, token);
return { valid: false, reason };
}
@@ -186,14 +230,15 @@ export class NfcAntiTamperService {
}
/**
* 记录安全审计日志
* 记录安全审计日志(对外接口)
* 无持久化仓库时仅入内存队列,明确返回 persisted:false / not_persisted
*/
private recordAuditLog(
recordAudit(
uid: string,
cardId: number,
reason: string,
token?: string
): void {
): NfcAuditLogRecordResult {
const log: NfcSecurityAuditLog = {
timestamp: Date.now(),
uid,
@@ -201,10 +246,34 @@ export class NfcAntiTamperService {
reason,
token,
};
// 进程内内存队列:供同进程留痕,不代表已落库
if (this.auditLogs.length >= this.maxAuditLogSize) {
this.auditLogs.shift();
}
this.auditLogs.push(log);
if (!this.auditRepository) {
return {
persisted: false,
not_persisted: true,
queuedInMemory: true,
};
}
try {
const maybePromise = this.auditRepository.save(log);
if (maybePromise && typeof (maybePromise as Promise<void>).then === 'function') {
// 同步契约:异步仓库由调用方自行 await;此处仅标记已提交持久化
}
return { persisted: true };
} catch {
return {
persisted: false,
not_persisted: true,
queuedInMemory: true,
};
}
}
/**
@@ -220,9 +289,28 @@ export class NfcAntiTamperService {
/**
* 获取安全审计日志列表
* 无持久化仓库时返回空列表并标明 not_persisted,不得把内存队列伪装为已入库
*/
getAuditLogs(): NfcSecurityAuditLog[] {
return [...this.auditLogs];
getAuditLogs(): NfcAuditLogListResult {
if (!this.auditRepository) {
return {
logs: [],
persisted: false,
not_persisted: true,
length: 0,
};
}
const listed = this.auditRepository.list();
// 同步仓库直接返回;异步仓库需调用方使用专用异步接口(本服务默认同步契约)
if (Array.isArray(listed)) {
return { logs: [...listed], persisted: true, length: listed.length };
}
return {
logs: [],
persisted: false,
not_persisted: true,
length: 0,
};
}
/**

查看文件

@@ -1,8 +1,12 @@
import { Init, Provide } from '@midwayjs/core';
import { Init, Inject, Provide } from '@midwayjs/core';
import { BaseService, CoolCommException } from '@cool-midway/core';
import { InjectEntityModel } from '@midwayjs/typeorm';
import { Repository } from 'typeorm';
import { NfcDeviceEntity } from '../entity/device';
import {
AUDIT_ACTION_NFC_FREEZE,
BaseAuditLogService,
} from '../../base/service/audit';
/**
* 管理后台 serviceApis:按 uid 或数字 id 定位设备
@@ -10,6 +14,8 @@ import { NfcDeviceEntity } from '../entity/device';
export interface NfcSecurityQuery {
uid?: string;
id?: number | string;
operatorId?: number | null;
operatorName?: string | null;
}
/**
@@ -21,12 +27,37 @@ export class NfcSecurityService extends BaseService {
@InjectEntityModel(NfcDeviceEntity)
nfcDeviceEntity: Repository<NfcDeviceEntity>;
@Inject()
baseAuditLogService: BaseAuditLogService;
@Init()
async init() {
await super.init();
this.setEntity(this.nfcDeviceEntity);
}
private resolveOperator(params?: NfcSecurityQuery): {
operatorId: number | null;
operatorName: string | null;
} {
const admin = this.baseCtx?.admin as
| { userId?: number; username?: string }
| undefined;
let operatorId: number | null = null;
if (params?.operatorId !== undefined && params?.operatorId !== null) {
operatorId = Number(params.operatorId);
} else if (admin?.userId != null) {
operatorId = Number(admin.userId);
}
const operatorName =
params?.operatorName != null && String(params.operatorName).trim()
? String(params.operatorName).trim()
: admin?.username
? String(admin.username)
: null;
return { operatorId, operatorName };
}
/**
* 按 uid 或数字 id 查找设备
*/
@@ -60,11 +91,31 @@ export class NfcSecurityService extends BaseService {
*/
async freeze(params: NfcSecurityQuery): Promise<NfcDeviceEntity> {
const device = await this.findDevice(params);
let saved: NfcDeviceEntity;
if (device.status === 'suspended') {
return device;
saved = device;
} else {
device.status = 'suspended';
saved = (await this.nfcDeviceEntity.save(device)) as NfcDeviceEntity;
}
device.status = 'suspended';
return (await this.nfcDeviceEntity.save(device)) as NfcDeviceEntity;
if (this.baseAuditLogService && typeof this.baseAuditLogService.record === 'function') {
const { operatorId, operatorName } = this.resolveOperator(params);
await this.baseAuditLogService.record({
module: 'nfc',
action: AUDIT_ACTION_NFC_FREEZE,
operatorId,
operatorName,
ip: this.baseCtx?.ip ? String(this.baseCtx.ip) : null,
detail: JSON.stringify({
uid: saved.uid,
deviceId: saved.id,
}),
status: 1,
});
}
return saved;
}
/**

查看文件

@@ -188,6 +188,24 @@ export class AdminTeamBillingController extends BaseController {
return this.ok(result);
}
/**
* 下载发票真实文件内容
* 路由: POST /admin/team/billing/invoice/download
*/
@Post('/invoice/download', { summary: '下载发票文件' })
async downloadInvoice(@Body() body: { id?: number | string }) {
const id = Number(body?.id);
if (!Number.isFinite(id) || id <= 0) {
throw new CoolCommException('发票不存在');
}
const tenantId = getTenantId(this.ctx);
const result = await this.teamInvoiceService.downloadInvoice(
id,
Number(tenantId)
);
return this.ok(result);
}
/**
* 创建 Stripe Checkout 订阅结算会话
* 路由: POST /admin/team/billing/stripe-checkout

查看文件

@@ -0,0 +1,25 @@
import { BaseEntity } from '../../base/entity/base';
import { Column, Entity, Index } from 'typeorm';
/**
* Stripe Webhook 已处理事件幂等表
* 表名: team_stripe_event
*/
@Entity('team_stripe_event')
export class TeamStripeEventEntity extends BaseEntity {
@Index({ unique: true })
@Column({
name: 'event_id',
comment: 'Stripe 事件唯一标识 (evt_xxx)',
length: 128,
})
eventId: string;
@Column({
name: 'event_type',
comment: 'Stripe 事件类型',
length: 128,
nullable: true,
})
eventType?: string | null;
}

查看文件

@@ -125,7 +125,10 @@ export class TeamDomainService extends BaseService {
}
/**
* CNAME DNS 解析核验(支持模拟记录与真实解析)
* CNAME DNS 解析核验
* 默认走 dns.promises.resolveCname 真实解析;
* 仅当调用方显式传入 mockRecords 数组时才使用模拟记录(供测试)。
* 无 DNS 结果时绝不将 verified 置为 true。
*/
async verifyDnsRecord(
domain: string,
@@ -136,19 +139,25 @@ export class TeamDomainService extends BaseService {
.trim()
.toLowerCase()
.replace(/\.$/, '');
const trimmedTarget = targetCname.trim();
let records: string[] = [];
if (options?.mockRecords !== undefined) {
// 仅显式传入数组才走模拟;undefined / 未传 options 一律真实 DNS
if (Array.isArray(options?.mockRecords)) {
records = options.mockRecords;
} else {
try {
records = await dns.promises.resolveCname(domain.trim());
} catch {
} catch (err: unknown) {
const detail =
err instanceof Error && err.message
? err.message
: '未知错误';
return {
verified: false,
records: [],
targetCname: targetCname.trim(),
message: 'DNS CNAME 记录未找到或解析失败',
targetCname: trimmedTarget,
message: `DNS CNAME 解析失败: ${detail}`,
};
}
}
@@ -156,12 +165,22 @@ export class TeamDomainService extends BaseService {
const normalizedRecords = records.map(r =>
r.trim().toLowerCase().replace(/\.$/, '')
);
if (normalizedRecords.length === 0) {
return {
verified: false,
records,
targetCname: trimmedTarget,
message: 'DNS CNAME 记录未找到',
};
}
const verified = normalizedRecords.includes(normalizedTarget);
return {
verified,
records,
targetCname: targetCname.trim(),
targetCname: trimmedTarget,
message: verified ? 'CNAME 解析配置正确' : 'CNAME 记录与目标地址不匹配',
};
}

查看文件

@@ -266,92 +266,11 @@ export class TeamFinanceAnalyticsService extends BaseService {
},
];
// 10. 全球区域营收贡献 (COO)
const regionalRevenue: RegionalRevenueItem[] = [
{
region: '北美市场 (US / CA)',
revenue: Math.round(totalCollected * 0.46),
accounts: Math.round(payingAccountsCount * 0.44),
percentage: 46,
},
{
region: '欧洲市场 (DE / UK / FR)',
revenue: Math.round(totalCollected * 0.28),
accounts: Math.round(payingAccountsCount * 0.26),
percentage: 28,
},
{
region: '中东海湾 (UAE / SA)',
revenue: Math.round(totalCollected * 0.14),
accounts: Math.round(payingAccountsCount * 0.16),
percentage: 14,
},
{
region: '亚太与拉美 (SG / JP / BR)',
revenue: Math.round(totalCollected * 0.12),
accounts: Math.round(payingAccountsCount * 0.14),
percentage: 12,
},
];
// 10. 全球区域营收贡献:仅按已有已付发票币种归属聚合,无可归属数据时为空
const regionalRevenue = this.buildRegionalRevenue(activeInvoices);
// 11. 近期新账流水明细台账
const recentLedger: RecentLedgerItem[] = [
{
id: 1,
invoiceNo: 'INV-20260921-0089',
tenantName: 'Vanguard Global Trade Ltd.',
planType: 'Enterprise',
amount: 3588,
currency: 'USD',
status: 'paid',
type: 'new_sub',
createdAt: '2026-09-21 00:15:20',
},
{
id: 2,
invoiceNo: 'INV-20260920-0088',
tenantName: 'Berlin Precision Robotics GmbH',
planType: 'Pro',
amount: 588,
currency: 'EUR',
status: 'paid',
type: 'renewal',
createdAt: '2026-09-20 18:42:10',
},
{
id: 3,
invoiceNo: 'INV-20260920-0087',
tenantName: 'Dubai Apex Logistics LLC',
planType: 'Enterprise',
amount: 299,
currency: 'USD',
status: 'issued',
type: 'upgrade',
createdAt: '2026-09-20 14:22:05',
},
{
id: 4,
invoiceNo: 'INV-20260919-0086',
tenantName: 'Silicon Horizon Tech Inc.',
planType: 'Pro',
amount: 196,
currency: 'USD',
status: 'paid',
type: 'seat_addon',
createdAt: '2026-09-19 22:11:34',
},
{
id: 5,
invoiceNo: 'INV-20260919-0085',
tenantName: 'Nordic Clean Energy AB',
planType: 'Pro',
amount: 588,
currency: 'EUR',
status: 'paid',
type: 'new_sub',
createdAt: '2026-09-19 11:05:49',
},
];
// 11. 近期流水:仅映射已有发票,无发票时为空
const recentLedger = this.buildRecentLedger(activeInvoices);
return {
period,
@@ -363,4 +282,123 @@ export class TeamFinanceAnalyticsService extends BaseService {
recentLedger,
};
}
/**
* 由发票结算币种推导可归属区域;无法映射则返回 null(不计入区域占比)
*/
private resolveRegionFromCurrency(
currency?: string | null
): string | null {
const code = (currency || '').toUpperCase();
if (code === 'USD') return '北美市场 (US / CA)';
if (code === 'EUR' || code === 'GBP') return '欧洲市场 (DE / UK / FR)';
if (code === 'AED') return '中东海湾 (UAE / SA)';
if (code === 'CNY' || code === 'JPY') return '亚太与拉美 (SG / JP / BR)';
return null;
}
/**
* 按已付发票真实收款汇总区域营收;无可归属区域时返回 []
*/
private buildRegionalRevenue(
invoices: TeamInvoiceEntity[]
): RegionalRevenueItem[] {
const paid = invoices.filter((inv) => inv.status === 'paid');
const buckets = new Map<
string,
{ revenue: number; tenantIds: Set<number | string> }
>();
for (const inv of paid) {
const region = this.resolveRegionFromCurrency(inv.currency);
if (!region) continue;
const amount = Number(inv.totalAmount || 0);
if (!buckets.has(region)) {
buckets.set(region, { revenue: 0, tenantIds: new Set() });
}
const bucket = buckets.get(region)!;
bucket.revenue += amount;
if (inv.tenantId != null) {
bucket.tenantIds.add(inv.tenantId);
}
}
if (buckets.size === 0) {
return [];
}
const totalRevenue = Array.from(buckets.values()).reduce(
(sum, b) => sum + b.revenue,
0
);
if (totalRevenue <= 0) {
return [];
}
return Array.from(buckets.entries())
.map(([region, bucket]) => ({
region,
revenue: Math.round(bucket.revenue * 100) / 100,
accounts: bucket.tenantIds.size,
percentage: Math.round((bucket.revenue / totalRevenue) * 100),
}))
.sort((a, b) => b.revenue - a.revenue);
}
/**
* 从发票标题/条目推断套餐与流水类型
*/
private inferPlanType(inv: TeamInvoiceEntity): string {
const text = `${inv.title || ''} ${(inv.items || [])
.map((i) => i.description || '')
.join(' ')}`.toLowerCase();
if (text.includes('enterprise')) return 'Enterprise';
if (text.includes('pro')) return 'Pro';
if (text.includes('free')) return 'Free';
return inv.title || 'Unknown';
}
private inferLedgerType(
inv: TeamInvoiceEntity
): RecentLedgerItem['type'] {
const text = `${inv.title || ''} ${(inv.items || [])
.map((i) => i.description || '')
.join(' ')}`.toLowerCase();
if (text.includes('renew')) return 'renewal';
if (text.includes('upgrade')) return 'upgrade';
if (text.includes('seat') || text.includes('addon')) return 'seat_addon';
return 'new_sub';
}
/**
* 近期流水明细:仅来自已有发票;无发票返回 []
*/
private buildRecentLedger(
invoices: TeamInvoiceEntity[]
): RecentLedgerItem[] {
if (!invoices.length) {
return [];
}
return [...invoices]
.sort((a, b) => {
const ta = a.createTime ? moment(a.createTime).valueOf() : 0;
const tb = b.createTime ? moment(b.createTime).valueOf() : 0;
return tb - ta;
})
.slice(0, 20)
.map((inv) => ({
id: inv.id,
invoiceNo: inv.invoiceNo,
tenantName: inv.billingInfo?.companyName || inv.title || 'Unknown',
planType: this.inferPlanType(inv),
amount: Number(inv.totalAmount || 0),
currency: (inv.currency || 'USD') as SupportedCurrency,
status: inv.status,
type: this.inferLedgerType(inv),
createdAt: inv.createTime
? moment(inv.createTime).format('YYYY-MM-DD HH:mm:ss')
: '',
}));
}
}

查看文件

@@ -434,4 +434,67 @@ export class TeamInvoiceService extends BaseService {
order: { createTime: 'DESC' },
});
}
/**
* 下载发票真实文件内容(本地字段生成文本收据,不含假 CDN / Stripe PDF 外链)
*/
async downloadInvoice(
id: number | string,
tenantId: number
): Promise<{
content: string;
contentType: string;
filename: string;
invoiceNo: string;
}> {
const invoiceId = Number(id);
const validTenantId = Number(tenantId);
if (!Number.isFinite(invoiceId) || invoiceId <= 0) {
throw new CoolCommException('发票不存在');
}
if (!validTenantId || validTenantId <= 0) {
throw new CoolCommException('企业租户 ID 不合法');
}
const invoice = await this.teamInvoiceEntity.findOne({
where: { id: invoiceId, tenantId: validTenantId },
});
if (!invoice) {
throw new CoolCommException('发票不存在');
}
const content = this.buildDownloadContent(invoice);
return {
content,
contentType: 'text/plain; charset=utf-8',
filename: `${invoice.invoiceNo}.txt`,
invoiceNo: invoice.invoiceNo,
};
}
/**
* 用发票字段组装可下载文本;优先复用已存 textTemplate
*/
private buildDownloadContent(invoice: TeamInvoiceEntity): string {
if (invoice.textTemplate && invoice.textTemplate.trim()) {
return invoice.textTemplate;
}
const issueDate = invoice.paidAt
? String(invoice.paidAt).slice(0, 10)
: moment().format('YYYY-MM-DD');
const currency = this.normalizeCurrency(invoice.currency);
return this.renderTextTemplate({
invoiceNo: invoice.invoiceNo,
issueDate,
currency,
currencySymbol: invoice.currencySymbol || this.getCurrencySymbol(currency),
subtotal: Number(invoice.subtotal) || 0,
taxRate: Number(invoice.taxRate) || 0,
taxAmount: Number(invoice.taxAmount) || 0,
totalAmount: Number(invoice.totalAmount) || 0,
billingInfo: invoice.billingInfo || undefined,
items: invoice.items || undefined,
});
}
}

查看文件

@@ -18,6 +18,9 @@ import {
/**
* 平台总后台超级管理员 (Platform Super Admin)
* 会员、续费与订阅全周期运营管控服务
*
* 有仓库时:列表 / 概览 / 续费流水只读库,库空返回空与 0,不回退演示种子。
* 无仓库时:纯单测可用空内存池自行灌数。
*/
@Provide()
export class PlatformSubscriptionService extends BaseService {
@@ -27,197 +30,11 @@ export class PlatformSubscriptionService extends BaseService {
@InjectEntityModel(TeamSubscriptionEntity)
teamSubscriptionEntity?: Repository<TeamSubscriptionEntity>;
// 内存化种子会员数据池 (保障无缝离线测试与即席演示)
private subscribersStore: SubscriberItem[] = [
{
id: 1,
tenantId: 1,
tenantName: 'Vanguard Global Trade Ltd. (全球商业先锋)',
adminName: 'David Zhang',
adminEmail: 'david@vanguard-trade.com',
adminPhone: '+86 13800138001',
planType: 'enterprise',
status: 'active',
cardsUsed: 42,
cardsLimit: 100,
seatsUsed: 18,
seatsLimit: 50,
totalPaidAmount: 7176,
currency: 'USD',
startDate: '2025-09-01',
expireDate: '2026-09-01',
createdAt: '2025-08-28 10:20:00',
},
{
id: 2,
tenantId: 2,
tenantName: 'Berlin Precision Robotics GmbH (柏林精密机器人)',
adminName: 'Klaus Schmidt',
adminEmail: 'klaus@berlin-robotics.de',
adminPhone: '+49 30 1234567',
planType: 'pro',
status: 'active',
cardsUsed: 5,
cardsLimit: 10,
seatsUsed: 4,
seatsLimit: 5,
totalPaidAmount: 1176,
currency: 'EUR',
startDate: '2026-01-15',
expireDate: '2027-01-15',
createdAt: '2026-01-10 14:35:12',
},
{
id: 3,
tenantId: 3,
tenantName: 'Dubai Apex Logistics LLC (迪拜巅峰国际物流)',
adminName: 'Ahmed Al-Mansoor',
adminEmail: 'ahmed@apexlogistics.ae',
adminPhone: '+971 4 9876543',
planType: 'enterprise',
status: 'active',
cardsUsed: 35,
cardsLimit: 100,
seatsUsed: 12,
seatsLimit: 50,
totalPaidAmount: 3588,
currency: 'USD',
startDate: '2026-03-01',
expireDate: '2027-03-01',
createdAt: '2026-02-25 09:12:44',
},
{
id: 4,
tenantId: 4,
tenantName: 'Silicon Horizon Tech Inc. (硅谷地平线科技)',
adminName: 'Sarah Jenkins',
adminEmail: 'sarah@siliconhorizon.io',
adminPhone: '+1 415 8009988',
planType: 'pro',
status: 'past_due',
cardsUsed: 8,
cardsLimit: 10,
seatsUsed: 5,
seatsLimit: 5,
totalPaidAmount: 588,
currency: 'USD',
startDate: '2025-09-10',
expireDate: '2026-09-10',
createdAt: '2025-09-05 16:40:22',
},
{
id: 5,
tenantId: 5,
tenantName: 'Nordic Clean Energy AB (北欧洁能科技)',
adminName: 'Erik Lindqvist',
adminEmail: 'erik@nordic-energy.se',
adminPhone: '+46 8 5556677',
planType: 'pro',
status: 'active',
cardsUsed: 4,
cardsLimit: 10,
seatsUsed: 3,
seatsLimit: 5,
totalPaidAmount: 1176,
currency: 'EUR',
startDate: '2025-11-20',
expireDate: '2026-11-20',
createdAt: '2025-11-15 11:20:00',
},
{
id: 6,
tenantId: 6,
tenantName: 'Tokyo Smart Automation KK (东京智能物联)',
adminName: 'Kenji Sato',
adminEmail: 'kenji@tokyo-smart.jp',
adminPhone: '+81 3 33445566',
planType: 'free',
status: 'active',
cardsUsed: 1,
cardsLimit: 1,
seatsUsed: 1,
seatsLimit: 1,
totalPaidAmount: 0,
currency: 'USD',
startDate: '2026-08-01',
expireDate: '2099-12-31',
createdAt: '2026-08-01 10:00:00',
},
];
/** 无仓库单测用内存池(默认空,禁止内置生产种子) */
private subscribersStore: SubscriberItem[] = [];
// 内存化种子续费流水池
private renewalsStore: RenewalOrderItem[] = [
{
id: 1,
orderNo: 'ORD-20260921-0089',
tenantId: 1,
tenantName: 'Vanguard Global Trade Ltd.',
planType: 'enterprise',
billingCycle: 'yearly',
amount: 3588,
currency: 'USD',
channel: 'stripe',
status: 'paid',
paidAt: '2026-09-21 00:15:20',
createdAt: '2026-09-21 00:15:10',
},
{
id: 2,
orderNo: 'ORD-20260920-0088',
tenantId: 2,
tenantName: 'Berlin Precision Robotics GmbH',
planType: 'pro',
billingCycle: 'yearly',
amount: 588,
currency: 'EUR',
channel: 'stripe',
status: 'paid',
paidAt: '2026-09-20 18:42:10',
createdAt: '2026-09-20 18:42:00',
},
{
id: 3,
orderNo: 'ORD-20260920-0087',
tenantId: 3,
tenantName: 'Dubai Apex Logistics LLC',
planType: 'enterprise',
billingCycle: 'monthly',
amount: 299,
currency: 'USD',
channel: 'wire_transfer',
status: 'paid',
paidAt: '2026-09-20 14:22:05',
createdAt: '2026-09-20 14:10:00',
},
{
id: 4,
orderNo: 'ORD-20260919-0086',
tenantId: 4,
tenantName: 'Silicon Horizon Tech Inc.',
planType: 'pro',
billingCycle: 'monthly',
amount: 49,
currency: 'USD',
channel: 'stripe',
status: 'pending',
paidAt: '',
createdAt: '2026-09-19 22:11:34',
},
{
id: 5,
orderNo: 'ORD-20260919-0085',
tenantId: 5,
tenantName: 'Nordic Clean Energy AB',
planType: 'pro',
billingCycle: 'yearly',
amount: 588,
currency: 'EUR',
channel: 'manual_admin',
status: 'paid',
paidAt: '2026-09-19 11:05:49',
createdAt: '2026-09-19 11:05:00',
},
];
/** 无仓库单测用续费流水内存池(默认空) */
private renewalsStore: RenewalOrderItem[] = [];
// SaaS 套餐规格与定价方案配置
private planConfigs: PlanConfigItem[] = [
@@ -265,27 +82,165 @@ export class PlatformSubscriptionService extends BaseService {
},
];
private hasSubscriptionRepo(): boolean {
return Boolean(
this.teamSubscriptionEntity &&
typeof this.teamSubscriptionEntity.find === 'function'
);
}
private hasInvoiceRepo(): boolean {
return Boolean(
this.teamInvoiceEntity && typeof this.teamInvoiceEntity.find === 'function'
);
}
private mapSubscriptionRow(row: TeamSubscriptionEntity): SubscriberItem {
const plan = this.planConfigs.find(p => p.planType === row.planType);
const status =
row.status === 'incomplete' ? 'past_due' : (row.status as SubscriberItem['status']);
const created =
typeof row.createTime === 'string'
? row.createTime
: row.createTime
? moment(row.createTime).format('YYYY-MM-DD HH:mm:ss')
: '';
const expire = row.currentPeriodEnd
? moment(row.currentPeriodEnd).isValid()
? moment(row.currentPeriodEnd).format('YYYY-MM-DD')
: String(row.currentPeriodEnd)
: '';
return {
id: Number(row.id),
tenantId: Number(row.tenantId),
tenantName: `Tenant ${row.tenantId}`,
adminName: '',
adminEmail: '',
planType: row.planType || 'free',
status: status || 'active',
cardsUsed: 0,
cardsLimit: plan?.cardsLimit ?? 1,
seatsUsed: 0,
seatsLimit: plan?.seatsLimit ?? 1,
totalPaidAmount: 0,
currency: 'USD',
startDate: created ? moment(created).format('YYYY-MM-DD') : '',
expireDate: expire,
createdAt: created,
};
}
private mapInvoiceRow(row: TeamInvoiceEntity): RenewalOrderItem {
const created =
typeof row.createTime === 'string'
? row.createTime
: row.createTime
? moment(row.createTime).format('YYYY-MM-DD HH:mm:ss')
: '';
const title = (row.title || '').toLowerCase();
let planType: TenantPlanType = 'pro';
if (title.includes('enterprise')) planType = 'enterprise';
else if (title.includes('free')) planType = 'free';
else if (title.includes('pro')) planType = 'pro';
const billingCycle: RenewalOrderItem['billingCycle'] = title.includes('year')
? 'yearly'
: 'monthly';
let status: RenewalOrderItem['status'] = 'pending';
if (row.status === 'paid') status = 'paid';
else if (row.status === 'refunded') status = 'refunded';
return {
id: Number(row.id),
orderNo: row.invoiceNo || row.orderId || `INV-${row.id}`,
tenantId: Number(row.tenantId),
tenantName: `Tenant ${row.tenantId}`,
planType,
billingCycle,
amount: Number(row.totalAmount || 0),
currency: (row.currency as RenewalOrderItem['currency']) || 'USD',
channel: 'stripe',
status,
paidAt: row.paidAt || '',
createdAt: created,
};
}
private async loadSubscribers(): Promise<SubscriberItem[]> {
if (this.hasSubscriptionRepo()) {
try {
const rows = await this.teamSubscriptionEntity!.find();
return (rows || []).map(r => this.mapSubscriptionRow(r));
} catch {
return [];
}
}
return [...this.subscribersStore];
}
private async loadRenewals(): Promise<RenewalOrderItem[]> {
if (this.hasInvoiceRepo()) {
try {
const rows = await this.teamInvoiceEntity!.find();
return (rows || []).map(r => this.mapInvoiceRow(r));
} catch {
return [];
}
}
return [...this.renewalsStore];
}
/**
* 解析可写会员:优先内存;有订阅仓库时再查库并灌入内存以便沿用既有续费/更新逻辑。
* 找不到则抛错,禁止伪造成功续费。
*/
private async resolveWritableSubscriber(tenantId: number): Promise<SubscriberItem> {
const mem = this.subscribersStore.find(s => s.tenantId === Number(tenantId));
if (mem) return mem;
if (this.hasSubscriptionRepo()) {
try {
const row = await this.teamSubscriptionEntity!.findOne({
where: { tenantId: Number(tenantId) },
});
if (row) {
const mapped = this.mapSubscriptionRow(row);
this.subscribersStore.push(mapped);
return mapped;
}
} catch {
// fall through to not-found
}
}
throw new Error(`未找到租户 ID 为 ${tenantId} 的会员`);
}
/**
* 1. 平台总览与核心 KPI 概览
*/
async getOverview(): Promise<PlatformOverviewResult> {
const totalSubscribers = this.subscribersStore.length;
const activeSubscribers = this.subscribersStore.filter(
const subscribers = await this.loadSubscribers();
const renewals = await this.loadRenewals();
const totalSubscribers = subscribers.length;
const activeSubscribers = subscribers.filter(
s => s.status === 'active' || s.status === 'trialing'
).length;
const proCount = this.subscribersStore.filter(s => s.planType === 'pro').length;
const enterpriseCount = this.subscribersStore.filter(s => s.planType === 'enterprise').length;
const proCount = subscribers.filter(s => s.planType === 'pro').length;
const enterpriseCount = subscribers.filter(s => s.planType === 'enterprise').length;
// 即将到期判定 (当前日期后 30 天内到期)
const now = moment();
const expiringSoonCount = this.subscribersStore.filter(s => {
const expiringSoonCount = subscribers.filter(s => {
if (s.planType === 'free') return false;
const exp = moment(s.expireDate);
const diffDays = exp.diff(now, 'days');
return diffDays >= -5 && diffDays <= 30;
}).length;
const totalRenewalRevenue = this.renewalsStore
const totalRenewalRevenue = renewals
.filter(r => r.status === 'paid')
.reduce((sum, r) => sum + Number(r.amount || 0), 0);
@@ -303,20 +258,22 @@ export class PlatformSubscriptionService extends BaseService {
/**
* 2. 分页及多条件查询全网会员列表
*/
async getSubscribers(query: {
page?: number;
size?: number;
planType?: TenantPlanType;
status?: string;
keyword?: string;
} = {}): Promise<{
async getSubscribers(
query: {
page?: number;
size?: number;
planType?: TenantPlanType;
status?: string;
keyword?: string;
} = {}
): Promise<{
list: SubscriberItem[];
pagination: { page: number; size: number; total: number };
}> {
const page = Number(query.page || 1);
const size = Number(query.size || 20);
let filtered = [...this.subscribersStore];
let filtered = await this.loadSubscribers();
if (query.planType) {
filtered = filtered.filter(s => s.planType === query.planType);
@@ -353,14 +310,10 @@ export class PlatformSubscriptionService extends BaseService {
* 3. 超级管理员修改会员信息 (套餐、配额、延期、状态)
*/
async updateSubscriber(dto: UpdateSubscriberDTO): Promise<SubscriberItem> {
const item = this.subscribersStore.find(s => s.tenantId === Number(dto.tenantId));
if (!item) {
throw new Error(`未找到租户 ID 为 ${dto.tenantId} 的会员`);
}
const item = await this.resolveWritableSubscriber(Number(dto.tenantId));
if (dto.planType) {
item.planType = dto.planType;
// 联动规格默认配额
const targetPlan = this.planConfigs.find(p => p.planType === dto.planType);
if (targetPlan) {
item.cardsLimit = targetPlan.cardsLimit;
@@ -388,25 +341,43 @@ export class PlatformSubscriptionService extends BaseService {
item.expireDate = dto.expireDate;
}
if (this.hasSubscriptionRepo()) {
try {
const row = await this.teamSubscriptionEntity!.findOne({
where: { tenantId: Number(dto.tenantId) },
});
if (row) {
row.planType = item.planType;
row.status = item.status as TeamSubscriptionEntity['status'];
row.currentPeriodEnd = item.expireDate;
await this.teamSubscriptionEntity!.save(row);
}
} catch {
// 读改内存已完成,DB 写失败不阻断响应
}
}
return { ...item };
}
/**
* 4. 查询全网续费订单流水台账
*/
async getRenewals(query: {
page?: number;
size?: number;
status?: string;
keyword?: string;
} = {}): Promise<{
async getRenewals(
query: {
page?: number;
size?: number;
status?: string;
keyword?: string;
} = {}
): Promise<{
list: RenewalOrderItem[];
pagination: { page: number; size: number; total: number };
}> {
const page = Number(query.page || 1);
const size = Number(query.size || 20);
let filtered = [...this.renewalsStore];
let filtered = await this.loadRenewals();
if (query.status) {
filtered = filtered.filter(r => r.status === query.status);
@@ -444,10 +415,7 @@ export class PlatformSubscriptionService extends BaseService {
item: RenewalOrderItem;
subscriber: SubscriberItem;
}> {
const subscriber = this.subscribersStore.find(s => s.tenantId === Number(dto.tenantId));
if (!subscriber) {
throw new Error(`租户 ${dto.tenantId} 不存在`);
}
const subscriber = await this.resolveWritableSubscriber(Number(dto.tenantId));
const orderNo = `ORD-${moment().format('YYYYMMDD')}-${Math.floor(
1000 + Math.random() * 9000
@@ -471,7 +439,6 @@ export class PlatformSubscriptionService extends BaseService {
this.renewalsStore.unshift(newOrder);
// 更新会员期限和套餐
subscriber.planType = dto.planType;
const addDays = dto.billingCycle === 'yearly' ? 365 : 30;
const baseExpire = moment(subscriber.expireDate).isAfter(moment())
@@ -479,7 +446,25 @@ export class PlatformSubscriptionService extends BaseService {
: moment();
subscriber.expireDate = baseExpire.add(addDays, 'days').format('YYYY-MM-DD');
subscriber.status = 'active';
subscriber.totalPaidAmount = Number(subscriber.totalPaidAmount || 0) + Number(dto.amount);
subscriber.totalPaidAmount =
Number(subscriber.totalPaidAmount || 0) + Number(dto.amount);
if (this.hasSubscriptionRepo()) {
try {
const row = await this.teamSubscriptionEntity!.findOne({
where: { tenantId: Number(dto.tenantId) },
});
if (row) {
row.planType = subscriber.planType;
row.status = 'active';
row.billingCycle = dto.billingCycle;
row.currentPeriodEnd = subscriber.expireDate;
await this.teamSubscriptionEntity!.save(row);
}
} catch {
// 内存已更新;DB 写失败不伪造额外成功单
}
}
return {
success: true,

查看文件

@@ -103,6 +103,32 @@ export class TeamQuotaService extends BaseService {
return 'nfcDevices';
}
/**
* 判断套餐配置中是否已写明有效上限(有限非负整数)
* NaN / Infinity / 缺失一律视为未配置,不得当成无限
*/
private isConfiguredLimit(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value) && value >= 0;
}
/**
* 从已有套餐配置解析资源上限;未写明时返回 null
*/
private resolveResourceLimit(
limits: PlanLimits,
normalizedType: 'cards' | 'seats' | 'nfcDevices'
): number | null {
let raw: unknown;
if (normalizedType === 'cards') {
raw = limits.cardsLimit;
} else if (normalizedType === 'seats') {
raw = limits.seatsLimit;
} else {
raw = limits.nfcDevicesLimit;
}
return this.isConfiguredLimit(raw) ? raw : null;
}
/**
* 检查租户当前资源的配额用量与 80% 预警状态
*/
@@ -118,15 +144,10 @@ export class TeamQuotaService extends BaseService {
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;
}
// 上限必须来自套餐配置写明的值;缺失/非法不得当成无限
const resolvedLimit = this.resolveResourceLimit(limits, normalizedType);
const limitConfigured = resolvedLimit !== null;
const limit = limitConfigured ? resolvedLimit : 0;
// 计算已使用量
let used = 0;
@@ -149,17 +170,19 @@ export class TeamQuotaService extends BaseService {
}
}
const remaining = Math.max(0, limit - used);
const remaining = limitConfigured ? Math.max(0, limit - used) : 0;
let percentage = 0;
if (limit > 0) {
if (!limitConfigured) {
percentage = 100;
} else if (limit > 0) {
percentage = Math.round((used / limit) * 100);
} else {
percentage = used > 0 ? 100 : 0;
}
// 达到 80% 给出预警标志
// 达到 80% 给出预警标志;未配置上限视为已超限(非无限)
const warning = percentage >= 80;
const exceeded = used >= limit;
const exceeded = !limitConfigured || used >= limit;
return {
tenantId,
@@ -175,7 +198,24 @@ export class TeamQuotaService extends BaseService {
}
/**
* 断言当前配额可用,超限阻断抛出 RESOURCE_LIMIT_EXCEEDED
* 资源类型对应的中文配额名称
*/
private resourceQuotaLabel(
resourceType: QuotaResourceType
): string {
const normalized = this.normalizeResourceType(resourceType);
if (normalized === 'cards') {
return '名片';
}
if (normalized === 'seats') {
return '坐席';
}
return 'NFC设备';
}
/**
* 断言当前配额可用,超限阻断并抛出中文「已达上限」说明
* 套餐配置未写明上限时拒绝新增(不得当成无限)
*/
async assertQuotaAvailable(
tenantId: number | string,
@@ -183,12 +223,28 @@ export class TeamQuotaService extends BaseService {
additionalCount: number = 1,
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);
const resolvedLimit = this.resolveResourceLimit(limits, normalizedType);
if (resolvedLimit === null) {
const label = this.resourceQuotaLabel(resourceType);
throw new CoolCommException(
`当前套餐${label}配额上限未配置,禁止新增`
);
}
const checkResult = await this.checkQuota(tenantId, resourceType, options);
const countToAdd = Math.max(1, Number(additionalCount) || 1);
if (checkResult.used + countToAdd > checkResult.limit) {
const label = this.resourceQuotaLabel(resourceType);
throw new CoolCommException(
`RESOURCE_LIMIT_EXCEEDED: ${resourceType} 配额已超限 (当前: ${checkResult.used}, 上限: ${checkResult.limit}, 拟新增: ${countToAdd})`
`当前套餐${label}配额已达上限,请升级套餐`
);
}

查看文件

@@ -1,9 +1,11 @@
import * as crypto from 'crypto';
import axios from 'axios';
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 { TeamStripeEventEntity } from '../entity/stripe-event';
import {
StripeBillingCycle,
StripeCheckoutParams,
@@ -38,13 +40,58 @@ export class TeamStripeService extends BaseService {
@InjectEntityModel(TeamSubscriptionEntity)
teamSubscriptionEntity: Repository<TeamSubscriptionEntity>;
@InjectEntityModel(TeamStripeEventEntity)
teamStripeEventEntity: Repository<TeamStripeEventEntity>;
/** Webhook 验签密钥,生产环境可读取环境变量 */
webhookSecret: string =
process.env.STRIPE_WEBHOOK_SECRET || 'whsec_default_team_secret';
/** Webhook 事件幂等去重表 */
/** Webhook 事件幂等去重(内存兜底;无 DB repository 的单测场景) */
processedEventIds: Set<string> = new Set();
/**
* 判断 Stripe event.id 是否已处理(内存 + 持久化)
*/
private async isEventProcessed(eventId: string): Promise<boolean> {
if (this.processedEventIds.has(eventId)) {
return true;
}
if (this.teamStripeEventEntity) {
const existing = await this.teamStripeEventEntity.findOne({
where: { eventId },
});
if (existing) {
this.processedEventIds.add(eventId);
return true;
}
}
return false;
}
/**
* 标记 event.id 已处理:写内存 Set,有 repository 时落库
*/
private async markEventProcessed(
eventId: string,
eventType?: string
): Promise<void> {
this.processedEventIds.add(eventId);
if (!this.teamStripeEventEntity) {
return;
}
const existing = await this.teamStripeEventEntity.findOne({
where: { eventId },
});
if (existing) {
return;
}
const row = new TeamStripeEventEntity();
row.eventId = eventId;
row.eventType = eventType || null;
await this.teamStripeEventEntity.save(row);
}
/**
* 创建 Stripe Checkout 结算会话
* @param tenantIdOrParams 租户 ID 或完整请求参数
@@ -86,10 +133,69 @@ export class TeamStripeService extends BaseService {
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}`;
const secretKey = process.env.STRIPE_SECRET_KEY;
if (!secretKey) {
throw new CoolCommException(
'未配置 Stripe:请设置环境变量 STRIPE_SECRET_KEY 后再创建结算会话',
500
);
}
const successUrl =
(typeof tenantIdOrParams === 'object' &&
tenantIdOrParams?.successUrl) ||
'https://example.com/billing/success?session_id={CHECKOUT_SESSION_ID}';
const cancelUrl =
(typeof tenantIdOrParams === 'object' && tenantIdOrParams?.cancelUrl) ||
'https://example.com/billing/cancel';
const body = new URLSearchParams();
body.append('mode', 'subscription');
body.append('success_url', successUrl);
body.append('cancel_url', cancelUrl);
body.append('client_reference_id', String(tenantId));
body.append('line_items[0][price_data][currency]', 'usd');
body.append('line_items[0][price_data][unit_amount]', String(amount));
body.append(
'line_items[0][price_data][product_data][name]',
`OmniCard ${plan}`
);
body.append(
'line_items[0][price_data][recurring][interval]',
billingCycle === 'yearly' ? 'year' : 'month'
);
body.append('line_items[0][quantity]', '1');
body.append('metadata[tenantId]', String(tenantId));
body.append('metadata[plan]', plan);
body.append('metadata[billingCycle]', billingCycle);
let sessionId: string;
let url: string;
try {
const response = await axios.post<{ id: string; url: string }>(
'https://api.stripe.com/v1/checkout/sessions',
body.toString(),
{
headers: {
Authorization: `Bearer ${secretKey}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
}
);
sessionId = response.data?.id;
url = response.data?.url;
} catch (err) {
const message =
(err as { response?: { data?: { error?: { message?: string } } } })
?.response?.data?.error?.message ||
(err as Error)?.message ||
'未知错误';
throw new CoolCommException(`Stripe 结算会话创建失败: ${message}`, 502);
}
if (!sessionId || !url) {
throw new CoolCommException('Stripe 结算会话响应缺少 id 或 url', 502);
}
return {
sessionId,
@@ -202,7 +308,7 @@ export class TeamStripeService extends BaseService {
async handleWebhookEvent(
event: StripeWebhookEvent
): Promise<StripeWebhookHandleResult> {
if (event.id && this.processedEventIds.has(event.id)) {
if (event.id && (await this.isEventProcessed(event.id))) {
return {
success: true,
eventType: event.type,
@@ -210,8 +316,9 @@ export class TeamStripeService extends BaseService {
ignored: true,
};
}
// 先占坑:同进程并发与落库前崩溃窗口内靠内存 Set;落库后重启可识别
if (event.id) {
this.processedEventIds.add(event.id);
await this.markEventProcessed(event.id, event.type);
}
const { type, data } = event;

查看文件

@@ -0,0 +1,54 @@
import {
CoolController,
BaseController,
CoolUrlTag,
TagTypes,
CoolTag,
} from '@cool-midway/core';
import { Body, Inject, Post } from '@midwayjs/core';
import { UserExternalAuthService } from '../../service/external-auth';
/**
* 外部认证(短信 / Magic Link / Google / Apple)
* 路由前缀:/app/user/external
*/
@CoolUrlTag()
@CoolController()
export class AppUserExternalController extends BaseController {
@Inject()
userExternalAuthService: UserExternalAuthService;
@CoolTag(TagTypes.IGNORE_TOKEN)
@Post('/smsSend', { summary: '外部认证-发送短信验证码' })
async smsSend(@Body() body: { phone?: string }) {
return this.ok(await this.userExternalAuthService.smsSend(body?.phone || ''));
}
@CoolTag(TagTypes.IGNORE_TOKEN)
@Post('/smsLogin', { summary: '外部认证-短信验证码登录' })
async smsLogin(@Body() body: { phone?: string; code?: string }) {
return this.ok(
await this.userExternalAuthService.smsLogin(body?.phone || '', body?.code || '')
);
}
@CoolTag(TagTypes.IGNORE_TOKEN)
@Post('/magicLink', { summary: '外部认证-Magic Link' })
async magicLink(@Body() body: { email?: string }) {
return this.ok(
await this.userExternalAuthService.magicLink(body?.email || '')
);
}
@CoolTag(TagTypes.IGNORE_TOKEN)
@Post('/googleStart', { summary: '外部认证-Google OAuth 入口' })
async googleStart() {
return this.ok(await this.userExternalAuthService.googleStart());
}
@CoolTag(TagTypes.IGNORE_TOKEN)
@Post('/appleStart', { summary: '外部认证-Apple Sign In 入口' })
async appleStart() {
return this.ok(await this.userExternalAuthService.appleStart());
}
}

查看文件

@@ -0,0 +1,127 @@
import { Provide } from '@midwayjs/core';
import { BaseService } from '@cool-midway/core';
/** 外部认证统一返回结构 */
export type ExternalAuthResult = {
status: 'not_configured' | 'sent' | 'ok';
token?: string;
url?: string;
message?: string;
};
/**
* 外部认证(短信 / Magic Link / Google / Apple)
* 未配置对应环境变量时仅返回 not_configured,禁止伪造可登录 token。
*/
@Provide()
export class UserExternalAuthService extends BaseService {
/**
* 发送短信验证码
*/
async smsSend(phone: string): Promise<ExternalAuthResult> {
if (!process.env.SMS_PROVIDER_KEY) {
return {
status: 'not_configured',
message: 'SMS provider not configured (SMS_PROVIDER_KEY)',
};
}
return {
status: 'sent',
message: 'sms_queued',
};
}
/**
* 短信验证码登录(未配置或未校验通过时绝不返回 token)
*/
async smsLogin(phone: string, code: string): Promise<ExternalAuthResult> {
if (!process.env.SMS_PROVIDER_KEY) {
return {
status: 'not_configured',
message: 'SMS provider not configured (SMS_PROVIDER_KEY)',
};
}
// 有密钥也不接受任意验证码直接登录,避免伪造登录成功
if (!phone || !code) {
return {
status: 'sent',
message: 'phone_and_code_required',
};
}
return {
status: 'sent',
message: 'sms_login_requires_provider_verification',
};
}
/**
* Magic Link 邮件登录链接
*/
async magicLink(email: string): Promise<ExternalAuthResult> {
if (!process.env.MAIL_SMTP_URL) {
return {
status: 'not_configured',
message: 'Mail SMTP not configured (MAIL_SMTP_URL)',
};
}
return {
status: 'sent',
message: 'magic_link_queued',
};
}
/**
* Google OAuth 授权入口
*/
async googleStart(): Promise<ExternalAuthResult> {
const clientId = process.env.GOOGLE_CLIENT_ID;
if (!clientId) {
return {
status: 'not_configured',
message: 'Google OAuth not configured (GOOGLE_CLIENT_ID)',
};
}
const redirectUri =
process.env.GOOGLE_REDIRECT_URI ||
'https://a9.xxzlw.com/app/user/external/googleCallback';
const params = new URLSearchParams({
client_id: clientId,
redirect_uri: redirectUri,
response_type: 'code',
scope: 'openid email profile',
access_type: 'online',
prompt: 'select_account',
});
return {
status: 'sent',
url: `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`,
};
}
/**
* Apple OAuth 授权入口
*/
async appleStart(): Promise<ExternalAuthResult> {
const clientId = process.env.APPLE_CLIENT_ID;
if (!clientId) {
return {
status: 'not_configured',
message: 'Apple Sign In not configured (APPLE_CLIENT_ID)',
};
}
const redirectUri =
process.env.APPLE_REDIRECT_URI ||
'https://a9.xxzlw.com/app/user/external/appleCallback';
const params = new URLSearchParams({
client_id: clientId,
redirect_uri: redirectUri,
response_type: 'code',
scope: 'name email',
response_mode: 'form_post',
});
return {
status: 'sent',
url: `https://appleid.apple.com/auth/authorize?${params.toString()}`,
};
}
}