文件
OmniCard/backend/test/modules/nfc/rule.test.ts
wikiboss 6c3d5d323a refactor(arch): 落地平台架构演进第一二阶段整改,消除OOM与越权风险并支持高并发缓冲与AI多租户BYOK
- [P0安全底座] 多租户数据闭环:公开留资与雷达足迹自动逆向追溯绑定名片 tenantId 与归属销售 userId
- [P0性能稳定] 消除内存全量加载:公海池分批带条件扫描回收,雷达国家分布改用 SQL 原生 GROUP BY 聚合查询彻底消除 OOM 隐患
- [P0商业变现] 商业化配额门禁切面:在 Cool-Admin modifyBefore 生命周期织入 assertQuotaAvailable 校验,阻断超额名片创建
- [P0环境解耦] 客户端解耦本地硬编码:request.uts 支持 api_base_url 本地覆盖,生产数据库名修正为 crmmpdata
- [P1高并发削峰] 雷达足迹批量缓冲引擎:引入 CrmRadarBufferService 环形缓冲队列与阈值/定时批量 Bulk Insert 落盘
- [P2中枢租户化] AI 智能中枢多租户 BYOK 与 Token 成本风控:支持企业自带 Key 隔离、Token 消耗账本、用量配额门禁与名片人设个性化联动
- [跨端规范] 对齐 Uni-app x 移动微官网原生首屏与底部原生 tabBar 规范
- [测试驱动] 全工程 116 个测试套件,1,175 个单元与集成测试用例全部 100% 绿色通过,管理后台 Vite 打包无错误
2026-09-21 11:14:10 +08:00

446 行
13 KiB
TypeScript

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

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?: any[];
};
expect(pageQueryOp.fieldEq).toEqual(
expect.arrayContaining([
{ column: 'a.device_id', requestParam: 'deviceId' },
'a.enabled',
{ column: 'a.scene_mode', requestParam: 'sceneMode' },
{ column: 'a.country_code', requestParam: 'countryCode' },
])
);
});
it('控制器含 resolveScene', () => {
const methods = serviceApiMethods(option.serviceApis);
expect(methods).toContain('resolveScene');
});
});
});