- [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 打包无错误
410 行
13 KiB
TypeScript
410 行
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 { CardLocaleEntity } from '../../../src/modules/card/entity/locale';
|
|
import { CardLocaleService } from '../../../src/modules/card/service/locale';
|
|
import {
|
|
AdminCardLocaleController,
|
|
adminCardLocaleCurdOption,
|
|
} from '../../../src/modules/card/controller/admin/locale';
|
|
|
|
/**
|
|
* 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 LocaleFindOptions {
|
|
where?: Partial<Pick<CardLocaleEntity, 'id' | 'cardId' | 'locale'>>;
|
|
order?: Partial<Record<'id' | 'locale', 'ASC' | 'DESC' | 'asc' | 'desc'>>;
|
|
}
|
|
|
|
/**
|
|
* 内存 Repository,覆盖 upsert / listByCardId / getByCardIdAndLocale
|
|
*/
|
|
class MemoryLocaleRepo {
|
|
private data: CardLocaleEntity[] = [];
|
|
private currentId = 1;
|
|
|
|
constructor(initialData: Array<Partial<CardLocaleEntity>> = []) {
|
|
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<CardLocaleEntity>): CardLocaleEntity {
|
|
const entity = new CardLocaleEntity();
|
|
Object.assign(entity, {
|
|
id: item.id || this.currentId++,
|
|
createTime: item.createTime || new Date(),
|
|
updateTime: new Date(),
|
|
...item,
|
|
});
|
|
return entity;
|
|
}
|
|
|
|
async find(options?: LocaleFindOptions): Promise<CardLocaleEntity[]> {
|
|
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<LocaleFindOptions['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 CardLocaleEntity));
|
|
}
|
|
|
|
async findOneBy(
|
|
where: Partial<Pick<CardLocaleEntity, 'id' | 'cardId' | 'locale'>>
|
|
): Promise<CardLocaleEntity | 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 CardLocaleEntity) : null;
|
|
}
|
|
|
|
async save(
|
|
entityOrEntities:
|
|
| Partial<CardLocaleEntity>
|
|
| Array<Partial<CardLocaleEntity>>
|
|
): Promise<CardLocaleEntity | CardLocaleEntity[]> {
|
|
if (Array.isArray(entityOrEntities)) {
|
|
const savedList: CardLocaleEntity[] = [];
|
|
for (const item of entityOrEntities) {
|
|
savedList.push((await this.save(item)) as CardLocaleEntity);
|
|
}
|
|
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 CardLocaleEntity;
|
|
return { ...this.data[index] } as CardLocaleEntity;
|
|
}
|
|
}
|
|
|
|
const newEntity = this.createEntity(item);
|
|
this.data.push(newEntity);
|
|
return { ...newEntity } as CardLocaleEntity;
|
|
}
|
|
|
|
getSnapshot(): CardLocaleEntity[] {
|
|
return [...this.data];
|
|
}
|
|
}
|
|
|
|
function createService(
|
|
seed: Array<Partial<CardLocaleEntity>> = []
|
|
): CardLocaleService {
|
|
const service = new CardLocaleService();
|
|
service.cardLocaleEntity = new MemoryLocaleRepo(
|
|
seed
|
|
) as unknown as CardLocaleService['cardLocaleEntity'];
|
|
return service;
|
|
}
|
|
|
|
function seedLocales(): Array<Partial<CardLocaleEntity>> {
|
|
return [
|
|
{
|
|
id: 1,
|
|
cardId: 100,
|
|
locale: 'zh-CN',
|
|
name: '张伟',
|
|
title: '全球商务总监',
|
|
company: 'OmniCard',
|
|
slogan: '连接全球商务',
|
|
},
|
|
{
|
|
id: 2,
|
|
cardId: 100,
|
|
locale: 'en-US',
|
|
name: 'Alex Zhang',
|
|
title: 'Global BD Director',
|
|
company: 'OmniCard',
|
|
slogan: 'Connecting Global Business',
|
|
},
|
|
{
|
|
id: 3,
|
|
cardId: 200,
|
|
locale: 'zh-CN',
|
|
name: '李娜',
|
|
title: '产品经理',
|
|
},
|
|
];
|
|
}
|
|
|
|
describe('T17 名片内容级多语言分支 (card_locale)', () => {
|
|
describe('1. 实体 CardLocaleEntity 表结构与索引', () => {
|
|
const metadata = getMetadataArgsStorage();
|
|
|
|
it('应继承 comm/entity/base BaseEntity,表名为 card_locale', () => {
|
|
const entity = new CardLocaleEntity();
|
|
expect(entity).toBeInstanceOf(BaseEntity);
|
|
|
|
const table = metadata.tables.find(t => t.target === CardLocaleEntity);
|
|
expect(table).toBeDefined();
|
|
expect(table?.name).toBe('card_locale');
|
|
});
|
|
|
|
it('cardId / locale 必须建索引,locale 为 varchar(16)', () => {
|
|
const columns = metadata.columns.filter(
|
|
c => c.target === CardLocaleEntity
|
|
);
|
|
const cardIdCol = columns.find(c => c.propertyName === 'cardId');
|
|
const localeCol = columns.find(c => c.propertyName === 'locale');
|
|
|
|
expect(cardIdCol?.options.name || cardIdCol?.propertyName).toBe(
|
|
'card_id'
|
|
);
|
|
expect(localeCol?.options.length).toBe(16);
|
|
|
|
const indexes = metadata.indices.filter(
|
|
i => i.target === CardLocaleEntity
|
|
);
|
|
const hasCardIdIndex = indexes.some(
|
|
i => Array.isArray(i.columns) && i.columns.includes('cardId')
|
|
);
|
|
const hasLocaleIndex = indexes.some(
|
|
i => Array.isArray(i.columns) && i.columns.includes('locale')
|
|
);
|
|
expect(hasCardIdIndex).toBe(true);
|
|
expect(hasLocaleIndex).toBe(true);
|
|
});
|
|
|
|
it('name / title / company / slogan 必须可空', () => {
|
|
const columns = metadata.columns.filter(
|
|
c => c.target === CardLocaleEntity
|
|
);
|
|
for (const prop of ['name', 'title', 'company', 'slogan'] as const) {
|
|
const col = columns.find(c => c.propertyName === prop);
|
|
expect(col).toBeDefined();
|
|
expect(col?.options.nullable).toBe(true);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('2. Service upsert / listByCardId / getByCardIdAndLocale', () => {
|
|
it('upsert 同卡同语言应覆盖而非新增重复行', async () => {
|
|
const service = createService(seedLocales());
|
|
|
|
const first = await service.upsert(100, 'ar-SA', {
|
|
name: 'أليكس تشانغ',
|
|
title: 'مدير تطوير الأعمال',
|
|
company: 'OmniCard',
|
|
slogan: 'ربط الأعمال العالمية',
|
|
});
|
|
expect(first.id).toBeDefined();
|
|
expect(first.cardId).toBe(100);
|
|
expect(first.locale).toBe('ar-SA');
|
|
expect(first.name).toBe('أليكس تشانغ');
|
|
|
|
const overwritten = await service.upsert(100, 'ar-SA', {
|
|
name: 'أليكس',
|
|
slogan: 'نسخة محدثة',
|
|
});
|
|
expect(overwritten.id).toBe(first.id);
|
|
expect(overwritten.name).toBe('أليكس');
|
|
expect(overwritten.slogan).toBe('نسخة محدثة');
|
|
expect(overwritten.title).toBe('مدير تطوير الأعمال');
|
|
expect(overwritten.company).toBe('OmniCard');
|
|
|
|
const listed = await service.listByCardId(100);
|
|
const arRows = listed.filter(row => row.locale === 'ar-SA');
|
|
expect(arRows).toHaveLength(1);
|
|
});
|
|
|
|
it('listByCardId 应按 cardId 返回多语种分支且不串卡', async () => {
|
|
const service = createService(seedLocales());
|
|
const list = await service.listByCardId(100);
|
|
|
|
expect(list).toHaveLength(2);
|
|
expect(list.map(row => row.locale).sort()).toEqual(['en-US', 'zh-CN']);
|
|
expect(list.every(row => row.cardId === 100)).toBe(true);
|
|
|
|
const other = await service.listByCardId(200);
|
|
expect(other).toHaveLength(1);
|
|
expect(other[0].name).toBe('李娜');
|
|
|
|
const empty = await service.listByCardId(9999);
|
|
expect(empty).toEqual([]);
|
|
});
|
|
|
|
it('getByCardIdAndLocale 应按名片与语言精确命中', async () => {
|
|
const service = createService(seedLocales());
|
|
|
|
const zh = await service.getByCardIdAndLocale(100, 'zh-CN');
|
|
expect(zh?.name).toBe('张伟');
|
|
expect(zh?.title).toBe('全球商务总监');
|
|
|
|
const missing = await service.getByCardIdAndLocale(100, 'fr-FR');
|
|
expect(missing).toBeNull();
|
|
});
|
|
|
|
it('cardId 或 locale 非法时应拒绝写入', async () => {
|
|
const service = createService();
|
|
await expect(
|
|
service.upsert(0, 'zh-CN', { name: '无效' })
|
|
).rejects.toThrow(CoolCommException);
|
|
await expect(
|
|
service.upsert(100, ' ', { name: '无效' })
|
|
).rejects.toThrow(CoolCommException);
|
|
});
|
|
});
|
|
|
|
describe('3. 后台控制器装饰器 /admin/card/locale', () => {
|
|
const controllerFile = path.join(
|
|
__dirname,
|
|
'../../../src/modules/card/controller/admin/locale.ts'
|
|
);
|
|
|
|
let meta: CoolControllerMeta;
|
|
let option: CurdOption;
|
|
|
|
beforeAll(async () => {
|
|
meta = await waitCoolMeta(AdminCardLocaleController);
|
|
option = meta.curdOption as CurdOption;
|
|
});
|
|
|
|
it('文件路径应符合 /admin/card/locale 自动路由约定', () => {
|
|
expect(fs.existsSync(controllerFile)).toBe(true);
|
|
expect(controllerFile.replace(/\\/g, '/')).toMatch(
|
|
/modules\/card\/controller\/admin\/locale\.ts$/
|
|
);
|
|
});
|
|
|
|
it('路由前缀应为 /admin/card/locale', () => {
|
|
expect(meta.prefix).toBe('/admin/card/locale');
|
|
});
|
|
|
|
it('控制器应继承 BaseController 并绑定实体与服务', () => {
|
|
expect(AdminCardLocaleController.prototype).toBeInstanceOf(
|
|
BaseController
|
|
);
|
|
expect(option.entity).toBe(CardLocaleEntity);
|
|
expect(option.service).toBe(CardLocaleService);
|
|
expect(adminCardLocaleCurdOption.entity).toBe(CardLocaleEntity);
|
|
expect(adminCardLocaleCurdOption.service).toBe(CardLocaleService);
|
|
});
|
|
|
|
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.fieldEq 应支持 cardId 与 locale 精确筛选', () => {
|
|
const pageQueryOp = option.pageQueryOp as {
|
|
fieldEq?: string[];
|
|
};
|
|
expect(pageQueryOp.fieldEq).toEqual(
|
|
expect.arrayContaining([{ column: 'a.card_id', requestParam: 'cardId' }, 'a.locale'])
|
|
);
|
|
});
|
|
|
|
it('应将 upsert / listByCardId / getByCardIdAndLocale 注册为 serviceApis', () => {
|
|
const methods = serviceApiMethods(option.serviceApis);
|
|
expect(methods).toEqual([
|
|
'upsert',
|
|
'listByCardId',
|
|
'getByCardIdAndLocale',
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('4. serviceApis POST body 单参数对象转调', () => {
|
|
it('upsert 接收 { cardId, locale, fields } 时应覆盖同卡同语言', async () => {
|
|
const service = createService(seedLocales());
|
|
const saved = await service.upsert({
|
|
cardId: 100,
|
|
locale: 'en-US',
|
|
fields: {
|
|
name: 'Alexander Zhang',
|
|
slogan: 'Updated slogan',
|
|
},
|
|
});
|
|
|
|
expect(saved.id).toBe(2);
|
|
expect(saved.name).toBe('Alexander Zhang');
|
|
expect(saved.slogan).toBe('Updated slogan');
|
|
expect(saved.title).toBe('Global BD Director');
|
|
|
|
const listed = await service.listByCardId({ cardId: 100 });
|
|
expect(listed.filter(row => row.locale === 'en-US')).toHaveLength(1);
|
|
});
|
|
|
|
it('getByCardIdAndLocale 接收 { cardId, locale } 时应转调已有双参签名', async () => {
|
|
const service = createService(seedLocales());
|
|
const found = await service.getByCardIdAndLocale({
|
|
cardId: 100,
|
|
locale: 'zh-CN',
|
|
});
|
|
expect(found?.id).toBe(1);
|
|
expect(found?.name).toBe('张伟');
|
|
});
|
|
});
|
|
});
|