feat: 同步管理端、用户端与后端业务,并接通线上 Web 接口
补齐平台订阅、外部登录、CRM 同步与用户端页面;Nginx 转发用户端接口,登录页使用 Google 与 Apple 标志。 Co-authored-by: Cursor <cursoragent@cursor.com>
这个提交包含在:
@@ -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()}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户