- [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 打包无错误
412 行
13 KiB
TypeScript
412 行
13 KiB
TypeScript
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import { getMetadataArgsStorage } from 'typeorm';
|
|
import { CONTROLLER_KEY, getClassMetadata } from '@midwayjs/core';
|
|
import { BaseController, CoolCommException, CurdOption } from '@cool-midway/core';
|
|
import { BaseEntity } from '../../../src/comm/entity/base';
|
|
import { NfcDeviceEntity } from '../../../src/modules/nfc/entity/device';
|
|
import { NfcDeviceService } from '../../../src/modules/nfc/service/device';
|
|
import {
|
|
AdminNfcDeviceController,
|
|
adminNfcDeviceCurdOption,
|
|
} from '../../../src/modules/nfc/controller/admin/device';
|
|
import type {
|
|
NFCMaterial,
|
|
NFCSceneMode,
|
|
NFCStatus,
|
|
} from '../../../src/comm/types/omni';
|
|
|
|
/**
|
|
* 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 元数据未就绪`);
|
|
}
|
|
|
|
interface DeviceFindOptions {
|
|
where?: Partial<
|
|
Pick<NfcDeviceEntity, 'id' | 'uid' | 'status' | 'material' | 'cardId' | 'sceneMode'>
|
|
>;
|
|
order?: Partial<
|
|
Record<'id' | 'updateTime' | 'uid', 'ASC' | 'DESC' | 'asc' | 'desc'>
|
|
>;
|
|
}
|
|
|
|
/**
|
|
* 内存 Repository,覆盖 add / page 所需能力
|
|
*/
|
|
class MemoryDeviceRepo {
|
|
private data: NfcDeviceEntity[] = [];
|
|
private currentId = 1;
|
|
|
|
constructor(initialData: Array<Partial<NfcDeviceEntity>> = []) {
|
|
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<NfcDeviceEntity>): NfcDeviceEntity {
|
|
const entity = new NfcDeviceEntity();
|
|
Object.assign(entity, {
|
|
id: item.id || this.currentId++,
|
|
createTime: item.createTime || new Date(),
|
|
updateTime: item.updateTime || new Date(),
|
|
material: item.material || 'pvc',
|
|
status: item.status || 'unbound',
|
|
sceneMode: item.sceneMode || 'business',
|
|
...item,
|
|
});
|
|
return entity;
|
|
}
|
|
|
|
async find(options?: DeviceFindOptions): Promise<NfcDeviceEntity[]> {
|
|
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;
|
|
});
|
|
});
|
|
}
|
|
|
|
if (options?.order) {
|
|
const orderKeys = Object.keys(options.order) as Array<
|
|
keyof NonNullable<DeviceFindOptions['order']>
|
|
>;
|
|
result.sort((a, b) => {
|
|
for (const key of orderKeys) {
|
|
const dir = (options.order?.[key] || 'ASC').toUpperCase();
|
|
if (a[key] < b[key]) return dir === 'ASC' ? -1 : 1;
|
|
if (a[key] > b[key]) return dir === 'ASC' ? 1 : -1;
|
|
}
|
|
return 0;
|
|
});
|
|
}
|
|
|
|
return result.map(item => ({ ...item } as NfcDeviceEntity));
|
|
}
|
|
|
|
async findOneBy(
|
|
where: Partial<Pick<NfcDeviceEntity, 'id' | 'uid'>>
|
|
): Promise<NfcDeviceEntity | null> {
|
|
const found = this.data.find(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 found ? ({ ...found } as NfcDeviceEntity) : null;
|
|
}
|
|
|
|
async save(
|
|
entityOrEntities:
|
|
| Partial<NfcDeviceEntity>
|
|
| Array<Partial<NfcDeviceEntity>>
|
|
): Promise<NfcDeviceEntity | NfcDeviceEntity[]> {
|
|
if (Array.isArray(entityOrEntities)) {
|
|
const savedList: NfcDeviceEntity[] = [];
|
|
for (const item of entityOrEntities) {
|
|
savedList.push((await this.save(item)) as NfcDeviceEntity);
|
|
}
|
|
return savedList;
|
|
}
|
|
|
|
const item = entityOrEntities;
|
|
if (item.id) {
|
|
const index = this.data.findIndex(d => d.id === item.id);
|
|
if (index !== -1) {
|
|
this.data[index] = {
|
|
...this.data[index],
|
|
...item,
|
|
updateTime: new Date(),
|
|
} as NfcDeviceEntity;
|
|
return { ...this.data[index] } as NfcDeviceEntity;
|
|
}
|
|
}
|
|
|
|
const newEntity = this.createEntity(item);
|
|
this.data.push(newEntity);
|
|
return { ...newEntity } as NfcDeviceEntity;
|
|
}
|
|
|
|
getSnapshot(): NfcDeviceEntity[] {
|
|
return [...this.data];
|
|
}
|
|
}
|
|
|
|
function createService(
|
|
seed: Array<Partial<NfcDeviceEntity>> = []
|
|
): NfcDeviceService {
|
|
const service = new NfcDeviceService();
|
|
service.nfcDeviceEntity = new MemoryDeviceRepo(
|
|
seed
|
|
) as unknown as NfcDeviceService['nfcDeviceEntity'];
|
|
return service;
|
|
}
|
|
|
|
function seedDevices(): Array<Partial<NfcDeviceEntity>> {
|
|
return [
|
|
{
|
|
id: 1,
|
|
uid: '04:7A:B2:8F:1C:6D:80',
|
|
batchNo: 'BATCH-2026-01',
|
|
material: 'pvc',
|
|
status: 'active',
|
|
sceneMode: 'business',
|
|
cardId: 100,
|
|
},
|
|
{
|
|
id: 2,
|
|
uid: '04:11:22:33:44:55:66',
|
|
batchNo: 'BATCH-2026-02',
|
|
material: 'metal',
|
|
status: 'unbound',
|
|
sceneMode: 'social',
|
|
},
|
|
];
|
|
}
|
|
|
|
describe('T21 NFC 设备台账 (nfc_device)', () => {
|
|
describe('1. 实体 NfcDeviceEntity 表结构与索引', () => {
|
|
const metadata = getMetadataArgsStorage();
|
|
|
|
it('应继承 comm/entity/base BaseEntity,表名为 nfc_device', () => {
|
|
const entity = new NfcDeviceEntity();
|
|
expect(entity).toBeInstanceOf(BaseEntity);
|
|
|
|
const table = metadata.tables.find(t => t.target === NfcDeviceEntity);
|
|
expect(table).toBeDefined();
|
|
expect(table?.name).toBe('nfc_device');
|
|
});
|
|
|
|
it('必须声明 uid / card_id / batch_no / material / status / scene_mode / last_scanned_at', () => {
|
|
const columns = metadata.columns.filter(
|
|
c => c.target === NfcDeviceEntity
|
|
);
|
|
const expected: Record<string, string> = {
|
|
uid: 'uid',
|
|
cardId: 'card_id',
|
|
batchNo: 'batch_no',
|
|
material: 'material',
|
|
status: 'status',
|
|
sceneMode: 'scene_mode',
|
|
lastScannedAt: 'last_scanned_at',
|
|
};
|
|
|
|
for (const [prop, colName] of Object.entries(expected)) {
|
|
const col = columns.find(c => c.propertyName === prop);
|
|
expect(col).toBeDefined();
|
|
expect(col?.options.name || col?.propertyName).toBe(colName);
|
|
}
|
|
});
|
|
|
|
it('uid 必须唯一索引,cardId / status 必须建索引', () => {
|
|
const indexes = metadata.indices.filter(
|
|
i => i.target === NfcDeviceEntity
|
|
);
|
|
const uidIndex = indexes.find(
|
|
i => Array.isArray(i.columns) && i.columns.includes('uid')
|
|
);
|
|
expect(uidIndex).toBeDefined();
|
|
expect(uidIndex?.unique).toBe(true);
|
|
|
|
const hasCardIdIndex = indexes.some(
|
|
i => Array.isArray(i.columns) && i.columns.includes('cardId')
|
|
);
|
|
const hasStatusIndex = indexes.some(
|
|
i => Array.isArray(i.columns) && i.columns.includes('status')
|
|
);
|
|
expect(hasCardIdIndex).toBe(true);
|
|
expect(hasStatusIndex).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('2. Service add / page 内存仓库', () => {
|
|
it('add 未传 status 时应默认 unbound', async () => {
|
|
const service = createService();
|
|
const created = await service.add({
|
|
uid: '04:AA:BB:CC:DD:EE:FF',
|
|
batchNo: 'BATCH-NEW',
|
|
material: 'wood' as NFCMaterial,
|
|
});
|
|
|
|
expect(created.id).toBeDefined();
|
|
const saved = await service.nfcDeviceEntity.findOneBy({
|
|
uid: '04:AA:BB:CC:DD:EE:FF',
|
|
});
|
|
expect(saved?.status).toBe('unbound');
|
|
expect(saved?.batchNo).toBe('BATCH-NEW');
|
|
expect(saved?.material).toBe('wood');
|
|
});
|
|
|
|
it('add 传入 status 时应保留原值', async () => {
|
|
const service = createService();
|
|
await service.add({
|
|
uid: '04:11:00:22:33:44:55',
|
|
batchNo: 'BATCH-ACTIVE',
|
|
status: 'active' as NFCStatus,
|
|
sceneMode: 'business' as NFCSceneMode,
|
|
});
|
|
const saved = await service.nfcDeviceEntity.findOneBy({
|
|
uid: '04:11:00:22:33:44:55',
|
|
});
|
|
expect(saved?.status).toBe('active');
|
|
});
|
|
|
|
it('uid 重复时应抛 CoolCommException', async () => {
|
|
const service = createService(seedDevices());
|
|
await expect(
|
|
service.add({
|
|
uid: '04:7A:B2:8F:1C:6D:80',
|
|
batchNo: 'BATCH-DUP',
|
|
})
|
|
).rejects.toThrow(CoolCommException);
|
|
});
|
|
|
|
it('page 应按内存仓库返回分页数据,并按 updateTime 倒序', async () => {
|
|
const service = createService(seedDevices());
|
|
const result = await service.page({ page: 1, size: 10 });
|
|
|
|
expect(result.list.length).toBe(2);
|
|
expect(result.pagination.total).toBe(2);
|
|
expect(result.pagination.page).toBe(1);
|
|
expect(result.pagination.size).toBe(10);
|
|
});
|
|
|
|
it('page 应支持 status / material / cardId / sceneMode 精确筛选', async () => {
|
|
const service = createService(seedDevices());
|
|
const byStatus = await service.page({ status: 'unbound', page: 1, size: 10 });
|
|
expect(byStatus.list).toHaveLength(1);
|
|
expect(byStatus.list[0].uid).toBe('04:11:22:33:44:55:66');
|
|
|
|
const byMaterial = await service.page({
|
|
material: 'pvc',
|
|
page: 1,
|
|
size: 10,
|
|
});
|
|
expect(byMaterial.list).toHaveLength(1);
|
|
expect(byMaterial.list[0].batchNo).toBe('BATCH-2026-01');
|
|
|
|
const byCard = await service.page({ cardId: 100, page: 1, size: 10 });
|
|
expect(byCard.list).toHaveLength(1);
|
|
expect(byCard.list[0].id).toBe(1);
|
|
|
|
const byScene = await service.page({
|
|
sceneMode: 'social',
|
|
page: 1,
|
|
size: 10,
|
|
});
|
|
expect(byScene.list).toHaveLength(1);
|
|
expect(byScene.list[0].sceneMode).toBe('social');
|
|
});
|
|
|
|
it('page 应支持 uid / batchNo 关键字模糊检索', async () => {
|
|
const service = createService(seedDevices());
|
|
const byUid = await service.page({ keyWord: '04:7A', page: 1, size: 10 });
|
|
expect(byUid.list).toHaveLength(1);
|
|
expect(byUid.list[0].uid).toBe('04:7A:B2:8F:1C:6D:80');
|
|
|
|
const byBatch = await service.page({
|
|
keyWord: '2026-02',
|
|
page: 1,
|
|
size: 10,
|
|
});
|
|
expect(byBatch.list).toHaveLength(1);
|
|
expect(byBatch.list[0].batchNo).toBe('BATCH-2026-02');
|
|
});
|
|
});
|
|
|
|
describe('3. 后台控制器装饰器 /admin/nfc/device', () => {
|
|
const controllerFile = path.join(
|
|
__dirname,
|
|
'../../../src/modules/nfc/controller/admin/device.ts'
|
|
);
|
|
|
|
let meta: CoolControllerMeta;
|
|
let option: CurdOption;
|
|
|
|
beforeAll(async () => {
|
|
meta = await waitCoolMeta(AdminNfcDeviceController);
|
|
option = meta.curdOption as CurdOption;
|
|
});
|
|
|
|
it('文件路径应符合 /admin/nfc/device 自动路由约定', () => {
|
|
expect(fs.existsSync(controllerFile)).toBe(true);
|
|
expect(controllerFile.replace(/\\/g, '/')).toMatch(
|
|
/modules\/nfc\/controller\/admin\/device\.ts$/
|
|
);
|
|
});
|
|
|
|
it('路由前缀应为 /admin/nfc/device', () => {
|
|
expect(meta.prefix).toBe('/admin/nfc/device');
|
|
});
|
|
|
|
it('控制器应继承 BaseController 并绑定实体与服务', () => {
|
|
expect(AdminNfcDeviceController.prototype).toBeInstanceOf(
|
|
BaseController
|
|
);
|
|
expect(option.entity).toBe(NfcDeviceEntity);
|
|
expect(option.service).toBe(NfcDeviceService);
|
|
expect(adminNfcDeviceCurdOption.entity).toBe(NfcDeviceEntity);
|
|
expect(adminNfcDeviceCurdOption.service).toBe(NfcDeviceService);
|
|
});
|
|
|
|
it('装饰器 api 必须包含 add/delete/update/info/list/page', () => {
|
|
expect(option.api).toEqual([
|
|
'add',
|
|
'delete',
|
|
'update',
|
|
'info',
|
|
'list',
|
|
'page',
|
|
]);
|
|
expect(option.api).toContain('page');
|
|
});
|
|
|
|
it('pageQueryOp 应支持 uid/batchNo 模糊检索,status/material/cardId/sceneMode 精确筛选,并按 updateTime 倒序', () => {
|
|
const pageQueryOp = option.pageQueryOp as {
|
|
keyWordLikeFields?: string[];
|
|
fieldEq?: string[];
|
|
addOrderBy?: Record<string, string>;
|
|
};
|
|
expect(pageQueryOp.keyWordLikeFields).toEqual(
|
|
expect.arrayContaining(['a.uid', 'a.batch_no'])
|
|
);
|
|
expect(pageQueryOp.fieldEq).toEqual(
|
|
expect.arrayContaining([
|
|
'a.status',
|
|
'a.material',
|
|
{ column: 'a.card_id', requestParam: 'cardId' },
|
|
{ column: 'a.scene_mode', requestParam: 'sceneMode' },
|
|
])
|
|
);
|
|
expect(pageQueryOp.addOrderBy).toEqual({ update_time: 'desc' });
|
|
});
|
|
});
|
|
});
|