补齐平台订阅、外部登录、CRM 同步与用户端页面;Nginx 转发用户端接口,登录页使用 Google 与 Apple 标志。 Co-authored-by: Cursor <cursoragent@cursor.com>
221 行
6.9 KiB
TypeScript
221 行
6.9 KiB
TypeScript
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import axios from 'axios';
|
|
import { CONTROLLER_KEY, getClassMetadata } from '@midwayjs/core';
|
|
import { BaseController, CoolCommException, CurdOption } from '@cool-midway/core';
|
|
import { AiOcrService } from '../../../src/modules/ai/service/ocr';
|
|
import {
|
|
OpenAiOcrController,
|
|
openAiOcrCurdOption,
|
|
} from '../../../src/modules/ai/controller/open/ocr';
|
|
|
|
/**
|
|
* 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));
|
|
}
|
|
|
|
describe('P06: 名片 OCR 识别 (modules/ai ocr)', () => {
|
|
const ORIGINAL_ENV = {
|
|
OCR_API_KEY: process.env.OCR_API_KEY,
|
|
OCR_BASE_URL: process.env.OCR_BASE_URL,
|
|
};
|
|
|
|
afterEach(() => {
|
|
jest.restoreAllMocks();
|
|
if (ORIGINAL_ENV.OCR_API_KEY === undefined) {
|
|
delete process.env.OCR_API_KEY;
|
|
} else {
|
|
process.env.OCR_API_KEY = ORIGINAL_ENV.OCR_API_KEY;
|
|
}
|
|
if (ORIGINAL_ENV.OCR_BASE_URL === undefined) {
|
|
delete process.env.OCR_BASE_URL;
|
|
} else {
|
|
process.env.OCR_BASE_URL = ORIGINAL_ENV.OCR_BASE_URL;
|
|
}
|
|
});
|
|
|
|
describe('1. AiOcrService.recognize 入参与配置校验', () => {
|
|
let service: AiOcrService;
|
|
|
|
beforeEach(() => {
|
|
service = new AiOcrService();
|
|
delete process.env.OCR_API_KEY;
|
|
delete process.env.OCR_BASE_URL;
|
|
});
|
|
|
|
it('imageBase64 与 imageUrl 都为空时应抛 CoolCommException', async () => {
|
|
await expect(service.recognize({})).rejects.toThrow(CoolCommException);
|
|
await expect(service.recognize({ imageBase64: '', imageUrl: '' })).rejects.toThrow(
|
|
CoolCommException
|
|
);
|
|
await expect(
|
|
service.recognize({ imageBase64: ' ', imageUrl: ' ' })
|
|
).rejects.toThrow(CoolCommException);
|
|
});
|
|
|
|
it('未配置 OCR_API_KEY 或 OCR_BASE_URL 时应抛中文未配置错误,禁止伪造识别结果', async () => {
|
|
process.env.OCR_API_KEY = 'test-key';
|
|
delete process.env.OCR_BASE_URL;
|
|
|
|
await expect(
|
|
service.recognize({ imageBase64: 'data:image/png;base64,abc' })
|
|
).rejects.toThrow(CoolCommException);
|
|
|
|
await expect(
|
|
service.recognize({ imageBase64: 'data:image/png;base64,abc' })
|
|
).rejects.toThrow(/未配置.*OCR|OCR.*未配置/);
|
|
|
|
delete process.env.OCR_API_KEY;
|
|
process.env.OCR_BASE_URL = 'https://ocr.example.test/v1/recognize';
|
|
|
|
await expect(
|
|
service.recognize({ imageUrl: 'https://cdn.example/card.jpg' })
|
|
).rejects.toThrow(/未配置.*OCR|OCR.*未配置/);
|
|
|
|
// 自审:不得在未配置时返回写死姓名/公司/电话
|
|
try {
|
|
await service.recognize({ imageBase64: 'abc' });
|
|
fail('应抛错');
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(CoolCommException);
|
|
const msg = String((err as Error).message);
|
|
expect(msg).not.toMatch(/张三|李四|示例公司|13800138000/);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('2. AiOcrService.recognize 调用远程 OCR', () => {
|
|
let service: AiOcrService;
|
|
|
|
beforeEach(() => {
|
|
service = new AiOcrService();
|
|
process.env.OCR_API_KEY = 'ocr-secret-key';
|
|
process.env.OCR_BASE_URL = 'https://ocr.example.test/v1/recognize';
|
|
});
|
|
|
|
it('配置齐全时应 axios.post 转发 body,映射 name/company/phone/email/title', async () => {
|
|
const body = {
|
|
imageBase64: 'data:image/jpeg;base64,/9j/4AAQ',
|
|
imageUrl: undefined as string | undefined,
|
|
};
|
|
|
|
const postSpy = jest.spyOn(axios, 'post').mockResolvedValueOnce({
|
|
data: {
|
|
name: 'Alex Zheng',
|
|
company: 'OmniCard',
|
|
phone: '+971501234567',
|
|
email: 'alex@omnicard.ai',
|
|
title: 'Product Lead',
|
|
extra: 'ignored',
|
|
},
|
|
});
|
|
|
|
const result = await service.recognize(body);
|
|
|
|
expect(postSpy).toHaveBeenCalledTimes(1);
|
|
expect(postSpy.mock.calls[0][0]).toBe('https://ocr.example.test/v1/recognize');
|
|
expect(postSpy.mock.calls[0][1]).toEqual(body);
|
|
expect(postSpy.mock.calls[0][2]).toMatchObject({
|
|
headers: {
|
|
Authorization: 'Bearer ocr-secret-key',
|
|
},
|
|
timeout: 15000,
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
name: 'Alex Zheng',
|
|
company: 'OmniCard',
|
|
phone: '+971501234567',
|
|
email: 'alex@omnicard.ai',
|
|
title: 'Product Lead',
|
|
});
|
|
});
|
|
|
|
it('对方 JSON 缺字段时应用空字符串填充,不得伪造默认姓名电话', async () => {
|
|
jest.spyOn(axios, 'post').mockResolvedValueOnce({
|
|
data: { name: 'Only Name' },
|
|
});
|
|
|
|
const result = await service.recognize({
|
|
imageUrl: 'https://cdn.example/card.png',
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
name: 'Only Name',
|
|
company: '',
|
|
phone: '',
|
|
email: '',
|
|
title: '',
|
|
});
|
|
expect(result.phone).toBe('');
|
|
expect(result.company).toBe('');
|
|
});
|
|
|
|
it('远程 OCR 请求失败时应抛错', async () => {
|
|
jest.spyOn(axios, 'post').mockRejectedValueOnce(new Error('OCR upstream 503'));
|
|
|
|
await expect(
|
|
service.recognize({ imageBase64: 'base64payload' })
|
|
).rejects.toThrow(/OCR upstream 503|OCR|识别/);
|
|
});
|
|
});
|
|
|
|
describe('3. OpenAiOcrController /open/ai/ocr/recognize', () => {
|
|
const controllerFile = path.join(
|
|
__dirname,
|
|
'../../../src/modules/ai/controller/open/ocr.ts'
|
|
);
|
|
|
|
let meta: CoolControllerMeta;
|
|
let option: CurdOption;
|
|
|
|
beforeAll(async () => {
|
|
meta = await waitCoolMeta(OpenAiOcrController);
|
|
option = meta.curdOption as CurdOption;
|
|
});
|
|
|
|
it('文件路径应符合 /open/ai/ocr 自动路由约定', () => {
|
|
expect(fs.existsSync(controllerFile)).toBe(true);
|
|
expect(controllerFile.replace(/\\/g, '/')).toMatch(
|
|
/modules\/ai\/controller\/open\/ocr\.ts$/
|
|
);
|
|
});
|
|
|
|
it('路由前缀应为 /open/ai/ocr,serviceApis 含 recognize', () => {
|
|
expect(meta.prefix).toBe('/open/ai/ocr');
|
|
expect(OpenAiOcrController.prototype).toBeInstanceOf(BaseController);
|
|
expect(option.service).toBe(AiOcrService);
|
|
expect(openAiOcrCurdOption.service).toBe(AiOcrService);
|
|
expect(option.api || []).toEqual([]);
|
|
const methods = serviceApiMethods(option.serviceApis);
|
|
expect(methods).toContain('recognize');
|
|
});
|
|
});
|
|
});
|