feat: 完成全系统30个功能点开发与E2E全链路验收 (8轮调度32个子代理闭环)
- 模块一: 出海案例作品集、博客动态发布、人名发音语音条、RTL阿语多语言切换 - 模块二: AI顾问人设配置、行业术语库润色、RAG切片沙盒调试、流式会话与意向留资卡片 - 模块三: 全球访客雷达大屏、多维加权意向算分、外部CRM对接推送、团队公海池流转 - 模块四: 移动端NFC安全挂失、NFC动态路由规则配置、动态防伪验签、展会制卡Zip批量导出 - 模块五: 组织架构与名片分配、移动端个人中心设置、离职交接与历史线索迁移、安全审计日志 - 模块六: Stripe国际信用卡订阅Webhook、独立二级域名与SSL检测、坐席配额预警、多币种发票 - 模块七: 纸质名片OCR解析入库、国际商务日程RFC 5545 ICS日历、微官网离线SWR预加载、展会实时排行榜大屏 - 模块八: 全球出海SEO OpenGraph元标签注入与社交爬虫探测、30点端到端全链路业务流集成测试套件
这个提交包含在:
@@ -0,0 +1,445 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { getMetadataArgsStorage } from 'typeorm';
|
||||
import { CONTROLLER_KEY, getClassMetadata } from '@midwayjs/core';
|
||||
import { BaseController, CurdOption } from '@cool-midway/core';
|
||||
import { BaseEntity, transformerJson } from '../../../src/comm/entity/base';
|
||||
import { NFCSceneRule } from '../../../src/comm/types/omni';
|
||||
import { NfcRuleEntity } from '../../../src/modules/nfc/entity/rule';
|
||||
import { NfcRuleService } from '../../../src/modules/nfc/service/rule';
|
||||
import {
|
||||
AdminNfcRuleController,
|
||||
adminNfcRuleCurdOption,
|
||||
} from '../../../src/modules/nfc/controller/admin/rule';
|
||||
|
||||
/**
|
||||
* CoolController 元数据(脚本路径解析为异步)
|
||||
*/
|
||||
interface CoolControllerMeta {
|
||||
prefix?: string;
|
||||
curdOption?: CurdOption;
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待 CoolController 将 CRUD 配置写入类元数据
|
||||
*/
|
||||
async function waitCoolMeta(
|
||||
target: new (...args: never[]) => unknown
|
||||
): Promise<CoolControllerMeta> {
|
||||
const started = Date.now();
|
||||
while (Date.now() - started < 2000) {
|
||||
const meta = getClassMetadata(CONTROLLER_KEY, target) as
|
||||
| CoolControllerMeta
|
||||
| undefined;
|
||||
if (meta?.curdOption) {
|
||||
return meta;
|
||||
}
|
||||
await new Promise<void>(resolve => setImmediate(resolve));
|
||||
}
|
||||
throw new Error(`${target.name} 的 CoolController 元数据未就绪`);
|
||||
}
|
||||
|
||||
function serviceApiMethods(apis: CurdOption['serviceApis'] = []): string[] {
|
||||
return apis.map(api => (typeof api === 'string' ? api : api.method));
|
||||
}
|
||||
|
||||
interface RuleFindOptions {
|
||||
where?: Partial<
|
||||
Pick<NfcRuleEntity, 'id' | 'tenantId' | 'deviceId' | 'enabled'>
|
||||
>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 内存 Repository,覆盖 resolveScene 查询
|
||||
*/
|
||||
class MemoryRuleRepo {
|
||||
private data: NfcRuleEntity[] = [];
|
||||
private currentId = 1;
|
||||
|
||||
constructor(initialData: Array<Partial<NfcRuleEntity>> = []) {
|
||||
this.data = initialData.map(item => this.createEntity(item));
|
||||
if (this.data.length > 0) {
|
||||
this.currentId = Math.max(...this.data.map(d => Number(d.id) || 0)) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
private createEntity(item: Partial<NfcRuleEntity>): NfcRuleEntity {
|
||||
const entity = new NfcRuleEntity();
|
||||
Object.assign(entity, {
|
||||
id: item.id || this.currentId++,
|
||||
createTime: item.createTime || new Date(),
|
||||
updateTime: new Date(),
|
||||
...item,
|
||||
});
|
||||
return entity;
|
||||
}
|
||||
|
||||
async find(options?: RuleFindOptions): Promise<NfcRuleEntity[]> {
|
||||
let result = [...this.data];
|
||||
if (options?.where) {
|
||||
const where = options.where;
|
||||
result = result.filter(item => {
|
||||
const keys = Object.keys(where) as Array<keyof typeof where>;
|
||||
return keys.every(key => {
|
||||
const expected = where[key];
|
||||
return expected === undefined || item[key] === expected;
|
||||
});
|
||||
});
|
||||
}
|
||||
return result.map(item => ({ ...item } as NfcRuleEntity));
|
||||
}
|
||||
}
|
||||
|
||||
function createService(
|
||||
seed: Array<Partial<NfcRuleEntity>> = []
|
||||
): NfcRuleService {
|
||||
const service = new NfcRuleService();
|
||||
service.nfcRuleEntity = new MemoryRuleRepo(
|
||||
seed
|
||||
) as unknown as NfcRuleService['nfcRuleEntity'];
|
||||
return service;
|
||||
}
|
||||
|
||||
/** 2026-09-21 周一 10:00(本地) */
|
||||
function mondayAt10(): Date {
|
||||
return new Date(2026, 8, 21, 10, 0, 0);
|
||||
}
|
||||
|
||||
/** 2026-09-26 周六 22:00(本地) */
|
||||
function saturdayAt22(): Date {
|
||||
return new Date(2026, 8, 26, 22, 0, 0);
|
||||
}
|
||||
|
||||
function seedPrdRules(): Array<Partial<NfcRuleEntity>> {
|
||||
return [
|
||||
{
|
||||
id: 1,
|
||||
tenantId: 10,
|
||||
deviceId: null,
|
||||
name: '工作日展会名片',
|
||||
sceneMode: 'business',
|
||||
targetCardId: 101,
|
||||
enabled: true,
|
||||
startTime: '09:00',
|
||||
endTime: '18:00',
|
||||
weekdays: [1, 2, 3, 4, 5],
|
||||
countryCode: null,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
tenantId: 10,
|
||||
deviceId: null,
|
||||
name: '晚间周末社交名片',
|
||||
sceneMode: 'social',
|
||||
targetCardId: 202,
|
||||
enabled: true,
|
||||
startTime: '18:00',
|
||||
endTime: '09:00',
|
||||
weekdays: [0, 5, 6],
|
||||
countryCode: null,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
describe('T23 NFC 场景路由规则 (nfc_rule)', () => {
|
||||
describe('1. 实体 NfcRuleEntity 表结构与列名', () => {
|
||||
const metadata = getMetadataArgsStorage();
|
||||
|
||||
it('应继承 comm/entity/base BaseEntity,表名为 nfc_rule', () => {
|
||||
const entity = new NfcRuleEntity();
|
||||
expect(entity).toBeInstanceOf(BaseEntity);
|
||||
|
||||
const table = metadata.tables.find(t => t.target === NfcRuleEntity);
|
||||
expect(table).toBeDefined();
|
||||
expect(table?.name).toBe('nfc_rule');
|
||||
});
|
||||
|
||||
it('实例应满足 NFCSceneRule 契约', () => {
|
||||
const rule = new NfcRuleEntity();
|
||||
rule.id = 1;
|
||||
rule.tenantId = 10;
|
||||
rule.deviceId = 88;
|
||||
rule.name = '展会';
|
||||
rule.sceneMode = 'business';
|
||||
rule.targetCardId = 101;
|
||||
rule.enabled = true;
|
||||
rule.startTime = '09:00';
|
||||
rule.endTime = '18:00';
|
||||
rule.weekdays = [1, 2, 3, 4, 5];
|
||||
rule.countryCode = 'AE';
|
||||
|
||||
const contract: NFCSceneRule = rule;
|
||||
expect(contract.sceneMode).toBe('business');
|
||||
expect(contract.targetCardId).toBe(101);
|
||||
expect(contract.weekdays).toEqual([1, 2, 3, 4, 5]);
|
||||
});
|
||||
|
||||
it('属性必须映射为下划线列名', () => {
|
||||
const columns = metadata.columns.filter(c => c.target === NfcRuleEntity);
|
||||
const expected: Record<string, string> = {
|
||||
deviceId: 'device_id',
|
||||
name: 'name',
|
||||
sceneMode: 'scene_mode',
|
||||
targetCardId: 'target_card_id',
|
||||
enabled: 'enabled',
|
||||
startTime: 'start_time',
|
||||
endTime: 'end_time',
|
||||
weekdays: 'weekdays',
|
||||
countryCode: 'country_code',
|
||||
};
|
||||
|
||||
for (const [prop, dbCol] of Object.entries(expected)) {
|
||||
const col = columns.find(c => c.propertyName === prop);
|
||||
expect(col).toBeDefined();
|
||||
expect(col?.options.name || col?.propertyName).toBe(dbCol);
|
||||
}
|
||||
});
|
||||
|
||||
it('deviceId / startTime / endTime / weekdays / countryCode 必须可空', () => {
|
||||
const columns = metadata.columns.filter(c => c.target === NfcRuleEntity);
|
||||
for (const prop of [
|
||||
'deviceId',
|
||||
'startTime',
|
||||
'endTime',
|
||||
'weekdays',
|
||||
'countryCode',
|
||||
] as const) {
|
||||
const col = columns.find(c => c.propertyName === prop);
|
||||
expect(col).toBeDefined();
|
||||
expect(col?.options.nullable).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('startTime / endTime 为 varchar,countryCode 长度为 8,weekdays 使用 transformerJson', () => {
|
||||
const columns = metadata.columns.filter(c => c.target === NfcRuleEntity);
|
||||
const startCol = columns.find(c => c.propertyName === 'startTime');
|
||||
const endCol = columns.find(c => c.propertyName === 'endTime');
|
||||
const countryCol = columns.find(c => c.propertyName === 'countryCode');
|
||||
const weekdaysCol = columns.find(c => c.propertyName === 'weekdays');
|
||||
|
||||
expect(startCol?.options.type).toBe('varchar');
|
||||
expect(endCol?.options.type).toBe('varchar');
|
||||
expect(countryCol?.options.length).toBe(8);
|
||||
expect(weekdaysCol?.options.transformer).toBe(transformerJson);
|
||||
});
|
||||
});
|
||||
|
||||
describe('2. Service resolveScene 时段 / 星期 / 优先级', () => {
|
||||
it('10:00 周一应命中 business 展会名片', async () => {
|
||||
const now = mondayAt10();
|
||||
expect(now.getDay()).toBe(1);
|
||||
expect(now.getHours()).toBe(10);
|
||||
|
||||
const service = createService(seedPrdRules());
|
||||
const hit = await service.resolveScene({
|
||||
deviceId: 88,
|
||||
tenantId: 10,
|
||||
now,
|
||||
});
|
||||
|
||||
expect(hit).toEqual({
|
||||
targetCardId: 101,
|
||||
sceneMode: 'business',
|
||||
});
|
||||
});
|
||||
|
||||
it('22:00 周六应命中 social 社交名片(含跨午夜)', async () => {
|
||||
const now = saturdayAt22();
|
||||
expect(now.getDay()).toBe(6);
|
||||
expect(now.getHours()).toBe(22);
|
||||
|
||||
const service = createService(seedPrdRules());
|
||||
const hit = await service.resolveScene({
|
||||
deviceId: 88,
|
||||
tenantId: 10,
|
||||
now,
|
||||
});
|
||||
|
||||
expect(hit).toEqual({
|
||||
targetCardId: 202,
|
||||
sceneMode: 'social',
|
||||
});
|
||||
});
|
||||
|
||||
it('设备级规则应覆盖同条件租户级规则', async () => {
|
||||
const service = createService([
|
||||
...seedPrdRules(),
|
||||
{
|
||||
id: 3,
|
||||
tenantId: 10,
|
||||
deviceId: 88,
|
||||
name: '设备专属社交',
|
||||
sceneMode: 'social',
|
||||
targetCardId: 303,
|
||||
enabled: true,
|
||||
startTime: '09:00',
|
||||
endTime: '18:00',
|
||||
weekdays: [1, 2, 3, 4, 5],
|
||||
countryCode: null,
|
||||
},
|
||||
]);
|
||||
|
||||
const hit = await service.resolveScene({
|
||||
deviceId: 88,
|
||||
tenantId: 10,
|
||||
now: mondayAt10(),
|
||||
});
|
||||
|
||||
expect(hit).toEqual({
|
||||
targetCardId: 303,
|
||||
sceneMode: 'social',
|
||||
});
|
||||
|
||||
const otherDevice = await service.resolveScene({
|
||||
deviceId: 99,
|
||||
tenantId: 10,
|
||||
now: mondayAt10(),
|
||||
});
|
||||
expect(otherDevice).toEqual({
|
||||
targetCardId: 101,
|
||||
sceneMode: 'business',
|
||||
});
|
||||
});
|
||||
|
||||
it('应按国家 AE 路由且不区分大小写', async () => {
|
||||
const service = createService([
|
||||
...seedPrdRules(),
|
||||
{
|
||||
id: 4,
|
||||
tenantId: 10,
|
||||
deviceId: null,
|
||||
name: '阿联酋展会',
|
||||
sceneMode: 'business',
|
||||
targetCardId: 404,
|
||||
enabled: true,
|
||||
startTime: null,
|
||||
endTime: null,
|
||||
weekdays: null,
|
||||
countryCode: 'AE',
|
||||
},
|
||||
]);
|
||||
|
||||
const ae = await service.resolveScene({
|
||||
deviceId: 88,
|
||||
tenantId: 10,
|
||||
now: mondayAt10(),
|
||||
countryCode: 'ae',
|
||||
});
|
||||
expect(ae).toEqual({
|
||||
targetCardId: 404,
|
||||
sceneMode: 'business',
|
||||
});
|
||||
|
||||
const us = await service.resolveScene({
|
||||
deviceId: 88,
|
||||
tenantId: 10,
|
||||
now: mondayAt10(),
|
||||
countryCode: 'US',
|
||||
});
|
||||
expect(us).toEqual({
|
||||
targetCardId: 101,
|
||||
sceneMode: 'business',
|
||||
});
|
||||
});
|
||||
|
||||
it('禁用规则不命中', async () => {
|
||||
const service = createService([
|
||||
{
|
||||
id: 5,
|
||||
tenantId: 10,
|
||||
deviceId: null,
|
||||
name: '已停用展会',
|
||||
sceneMode: 'business',
|
||||
targetCardId: 505,
|
||||
enabled: false,
|
||||
startTime: '09:00',
|
||||
endTime: '18:00',
|
||||
weekdays: [1, 2, 3, 4, 5],
|
||||
countryCode: null,
|
||||
},
|
||||
]);
|
||||
|
||||
const hit = await service.resolveScene({
|
||||
deviceId: 88,
|
||||
tenantId: 10,
|
||||
now: mondayAt10(),
|
||||
});
|
||||
expect(hit).toBeNull();
|
||||
});
|
||||
|
||||
it('resolveScene 应兼容 serviceApis 单对象入参', async () => {
|
||||
const service = createService(seedPrdRules());
|
||||
const hit = await service.resolveScene({
|
||||
deviceId: 88,
|
||||
tenantId: 10,
|
||||
now: mondayAt10(),
|
||||
countryCode: undefined,
|
||||
});
|
||||
expect(hit?.sceneMode).toBe('business');
|
||||
});
|
||||
});
|
||||
|
||||
describe('3. 后台控制器 /admin/nfc/rule', () => {
|
||||
const controllerFile = path.join(
|
||||
__dirname,
|
||||
'../../../src/modules/nfc/controller/admin/rule.ts'
|
||||
);
|
||||
|
||||
let meta: CoolControllerMeta;
|
||||
let option: CurdOption;
|
||||
|
||||
beforeAll(async () => {
|
||||
meta = await waitCoolMeta(AdminNfcRuleController);
|
||||
option = meta.curdOption as CurdOption;
|
||||
});
|
||||
|
||||
it('文件路径应符合 /admin/nfc/rule 自动路由约定', () => {
|
||||
expect(fs.existsSync(controllerFile)).toBe(true);
|
||||
expect(controllerFile.replace(/\\/g, '/')).toMatch(
|
||||
/modules\/nfc\/controller\/admin\/rule\.ts$/
|
||||
);
|
||||
});
|
||||
|
||||
it('路由前缀应为 /admin/nfc/rule', () => {
|
||||
expect(meta.prefix).toBe('/admin/nfc/rule');
|
||||
});
|
||||
|
||||
it('控制器应继承 BaseController 并导出 adminNfcRuleCurdOption', () => {
|
||||
expect(AdminNfcRuleController.prototype).toBeInstanceOf(BaseController);
|
||||
expect(option.entity).toBe(NfcRuleEntity);
|
||||
expect(option.service).toBe(NfcRuleService);
|
||||
expect(adminNfcRuleCurdOption.entity).toBe(NfcRuleEntity);
|
||||
expect(adminNfcRuleCurdOption.service).toBe(NfcRuleService);
|
||||
});
|
||||
|
||||
it('装饰器 api 必须包含 add/delete/update/info/list/page', () => {
|
||||
expect(option.api).toEqual([
|
||||
'add',
|
||||
'delete',
|
||||
'update',
|
||||
'info',
|
||||
'list',
|
||||
'page',
|
||||
]);
|
||||
});
|
||||
|
||||
it('pageQueryOp.fieldEq 应支持 deviceId/enabled/sceneMode/countryCode', () => {
|
||||
const pageQueryOp = option.pageQueryOp as {
|
||||
fieldEq?: string[];
|
||||
};
|
||||
expect(pageQueryOp.fieldEq).toEqual(
|
||||
expect.arrayContaining([
|
||||
'a.deviceId',
|
||||
'a.enabled',
|
||||
'a.sceneMode',
|
||||
'a.countryCode',
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('控制器含 resolveScene', () => {
|
||||
const methods = serviceApiMethods(option.serviceApis);
|
||||
expect(methods).toContain('resolveScene');
|
||||
});
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户