feat: 同步管理端、用户端与后端业务,并接通线上 Web 接口

补齐平台订阅、外部登录、CRM 同步与用户端页面;Nginx 转发用户端接口,登录页使用 Google 与 Apple 标志。

Co-authored-by: Cursor <cursoragent@cursor.com>
这个提交包含在:
wikiboss
2026-09-23 18:01:47 +08:00
父节点 8ea9c25386
当前提交 62e0ae4435
共有 227 个文件被更改,包括 21855 次插入 和 5264 次删除

查看文件

@@ -56,6 +56,25 @@ describe('AdminDeepSeekController 多租户 BYOK 与 Token 用量控制台 (TDD)
expect(service.isConfigured()).toBe(true);
expect(service.getConfig().apiKey).toBe('sk-platform-master-key-999999');
});
it('租户 BYOK 经 setTenantConfig 写穿,无 repository 时内存 Map 兜底仍可被管理端读取', async () => {
await service.setTenantConfig(555, {
apiKey: 'sk-tenant-555-byok-key-abcdef',
model: 'deepseek-reasoner',
});
(controller as any).ctx = {
admin: { username: 'tenant_admin_555', tenantId: 555 },
};
const res: any = await controller.config({});
expect(res.code).toBe(1000);
expect(res.data.isConfigured).toBe(true);
expect(res.data.model).toBe('deepseek-reasoner');
expect(res.data.apiKey).toBe('sk-t****cdef');
// 脱敏回显不得包含完整密钥
expect(JSON.stringify(res)).not.toContain('sk-tenant-555-byok-key-abcdef');
});
});
describe('2. 租户 Token 消耗统计与用量查询 (POST /usage)', () => {

查看文件

@@ -1,5 +1,5 @@
import { DeepSeekService } from '../../../src/modules/ai/service/deepseek';
import { DeepSeekConfig } from '../../../src/comm/types/omni';
import { AiDeepSeekConfigEntity } from '../../../src/modules/ai/entity/deepseek-config';
describe('T31 DeepSeek 大模型集成与适配服务 (deepseek.ts)', () => {
let service: DeepSeekService;
@@ -48,6 +48,81 @@ describe('T31 DeepSeek 大模型集成与适配服务 (deepseek.ts)', () => {
expect(cfg.temperature).toBe(0.2);
expect(cfg.baseUrl).toBe('https://api.deepseek.com'); // 保留默认值
});
it('1.4 无 repository 时 setTenantConfig 仅写内存 Map 兜底', async () => {
expect((service as any).deepSeekConfigEntity).toBeUndefined();
await service.setTenantConfig(42, {
apiKey: 'sk-map-only-fallback-key',
model: 'deepseek-reasoner',
baseUrl: 'https://api.deepseek.com',
});
expect(service.isConfigured(42)).toBe(true);
expect(service.getTenantConfig(42).apiKey).toBe('sk-map-only-fallback-key');
expect(service.getTenantConfig(42).model).toBe('deepseek-reasoner');
// 不污染平台公共池
expect(service.isConfigured()).toBe(false);
});
it('1.5 有 mock repository 时 setTenantConfig 落库且 resolve 优先读库', async () => {
const store: AiDeepSeekConfigEntity[] = [];
const mockRepo = {
find: jest.fn(async () => [...store]),
findOne: jest.fn(async ({ where }: { where: { tenantId: number } }) => {
return store.find((r) => r.tenantId === where.tenantId) || null;
}),
save: jest.fn(async (row: AiDeepSeekConfigEntity) => {
const idx = store.findIndex((r) => r.tenantId === row.tenantId);
if (idx >= 0) {
store[idx] = row;
} else {
store.push(row);
}
return row;
}),
};
(service as any).deepSeekConfigEntity = mockRepo;
await service.setTenantConfig(1001, {
apiKey: 'sk-persisted-tenant-key',
model: 'deepseek-chat',
baseUrl: 'https://api.deepseek.com',
});
expect(mockRepo.save).toHaveBeenCalled();
expect(store).toHaveLength(1);
expect(store[0].tenantId).toBe(1001);
expect(store[0].apiKey).toBe('sk-persisted-tenant-key');
expect(store[0].enabled).toBe(true);
// 清空内存 Map,验证 resolveTenantConfig 从 DB 读回
(service as any).tenantConfigs.clear();
const fromDb = await service.resolveTenantConfig(1001);
expect(fromDb.apiKey).toBe('sk-persisted-tenant-key');
expect(service.getTenantConfig(1001).apiKey).toBe('sk-persisted-tenant-key');
});
it('1.6 DB 抛错时静默回退内存 Map,不中断业务', async () => {
const mockRepo = {
find: jest.fn(async () => {
throw new Error('db down');
}),
findOne: jest.fn(async () => {
throw new Error('db down');
}),
save: jest.fn(async () => {
throw new Error('db down');
}),
};
(service as any).deepSeekConfigEntity = mockRepo;
await expect(
service.setTenantConfig(9, { apiKey: 'sk-survive-db-outage' })
).resolves.toMatchObject({ apiKey: 'sk-survive-db-outage' });
expect(service.getTenantConfig(9).apiKey).toBe('sk-survive-db-outage');
});
});
describe('2. 提示词动态拼装 (Prompt Builders)', () => {
@@ -230,4 +305,73 @@ describe('T31 DeepSeek 大模型集成与适配服务 (deepseek.ts)', () => {
expect(res.message).toContain('Authentication Fails');
});
});
describe('5. Token 用量仅进程内存,不得冒充账单/落库 (R29)', () => {
/** 内存用量标记:storage:'memory' 或 persisted:false 均可 */
function assertMemoryOnlyUsage(result: {
storage?: string;
persisted?: boolean;
totalTokens: number;
promptTokens: number;
completionTokens: number;
callCount: number;
}) {
const markedMemory =
result.storage === 'memory' || result.persisted === false;
expect(markedMemory).toBe(true);
// 不得伪装成已落库账单用量
expect(result.storage).not.toBe('db');
expect(result.storage).not.toBe('persisted');
expect(result.persisted).not.toBe(true);
}
it('5.1 无任何记录时 getTenantTokenUsage 返回全 0,并标明内存存储', () => {
const usage = service.getTenantTokenUsage(9001);
expect(usage.totalTokens).toBe(0);
expect(usage.promptTokens).toBe(0);
expect(usage.completionTokens).toBe(0);
expect(usage.callCount).toBe(0);
assertMemoryOnlyUsage(usage);
// 禁止造示例/占位用量
expect(usage.totalTokens).not.toBeGreaterThan(0);
});
it('5.2 recordTokenUsage 返回值须带 storage:memory(或 persisted:false)', () => {
const recorded = service.recordTokenUsage(9002, {
promptTokens: 10,
completionTokens: 5,
totalTokens: 15,
});
expect(recorded.totalTokens).toBe(15);
expect(recorded.promptTokens).toBe(10);
expect(recorded.completionTokens).toBe(5);
expect(recorded.callCount).toBe(1);
assertMemoryOnlyUsage(recorded);
});
it('5.3 累计后 getTenantTokenUsage 仍标明内存,数值仅反映进程内账本', () => {
service.recordTokenUsage(9003, { totalTokens: 100 });
service.recordTokenUsage(9003, {
promptTokens: 20,
completionTokens: 30,
totalTokens: 50,
});
const usage = service.getTenantTokenUsage(9003);
expect(usage.totalTokens).toBe(150);
expect(usage.promptTokens).toBe(20);
expect(usage.completionTokens).toBe(30);
expect(usage.callCount).toBe(2);
assertMemoryOnlyUsage(usage);
});
it('5.4 不同租户用量隔离;未写入租户仍为 0 且标明内存', () => {
service.recordTokenUsage(9004, { totalTokens: 77 });
const other = service.getTenantTokenUsage(9005);
expect(other.totalTokens).toBe(0);
expect(other.callCount).toBe(0);
assertMemoryOnlyUsage(other);
assertMemoryOnlyUsage(service.getTenantTokenUsage(9004));
});
});
});

查看文件

@@ -0,0 +1,220 @@
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');
});
});
});

查看文件

@@ -1,5 +1,6 @@
import * as fs from 'fs';
import * as path from 'path';
import axios from 'axios';
import { CONTROLLER_KEY, getClassMetadata } from '@midwayjs/core';
import { BaseController, CurdOption } from '@cool-midway/core';
import { AiKnowledgeService } from '../../../src/modules/ai/service/knowledge';
@@ -160,6 +161,125 @@ describe('Point 07: AI 知识库分块切片与召回沙盒 (modules/ai knowledg
});
});
describe('2.1 retrieve 统一召回入口 (keyword / embedding)', () => {
const mockChunks: KnowledgeChunk[] = [
{
index: 0,
content: 'OmniCard 提供 NFC 智能实体卡,碰一碰即可传输联系方式与微官网。',
length: 37,
},
{
index: 1,
content: '雷达系统支持智能 CRM 访客轨迹追踪、停留时长与意向度自动评分。',
length: 33,
},
{
index: 2,
content: 'AI 知识库提供文档分块切片预览与 RAG 检索增强沙盒调试能力。',
length: 34,
},
{
index: 3,
content: 'Bento Grid 便当盒美学布局,响应式适配全球多语言及 RTL 界面。',
length: 34,
},
];
const envSnapshot = {
key: process.env.EMBEDDING_API_KEY,
url: process.env.EMBEDDING_BASE_URL,
};
afterEach(() => {
jest.restoreAllMocks();
if (envSnapshot.key === undefined) {
delete process.env.EMBEDDING_API_KEY;
} else {
process.env.EMBEDDING_API_KEY = envSnapshot.key;
}
if (envSnapshot.url === undefined) {
delete process.env.EMBEDDING_BASE_URL;
} else {
process.env.EMBEDDING_BASE_URL = envSnapshot.url;
}
});
it('无 EMBEDDING_API_KEY 时 mode 为 keyword,排序与 score 与 simulateRetrieval 一致', async () => {
delete process.env.EMBEDDING_API_KEY;
delete process.env.EMBEDDING_BASE_URL;
const query = 'NFC 实体卡 碰一碰';
const baseline = service.simulateRetrieval(query, mockChunks, 3);
const results = await service.retrieve(query, mockChunks, { topK: 3 });
expect(results).toHaveLength(baseline.length);
expect(results.every(item => item.mode === 'keyword')).toBe(true);
expect(results.map(item => item.index)).toEqual(
baseline.map(item => item.index)
);
expect(results.map(item => item.score)).toEqual(
baseline.map(item => item.score)
);
// 词重叠分数不得伪装成 embedding 相似度字段
results.forEach(item => {
expect(item).not.toHaveProperty('similarity');
expect(item).not.toHaveProperty('cosine');
});
});
it('有 EMBEDDING_API_KEY 但无 EMBEDDING_BASE_URL 时应抛明确错误,不静默回退', async () => {
process.env.EMBEDDING_API_KEY = 'test-embedding-key';
delete process.env.EMBEDDING_BASE_URL;
await expect(
service.retrieve('NFC', mockChunks, { topK: 2 })
).rejects.toThrow(/EMBEDDING_BASE_URL/);
});
it('有 key 与 URL 时应调用 axios,并按向量相似度排序返回 mode=embedding', async () => {
process.env.EMBEDDING_API_KEY = 'test-embedding-key';
process.env.EMBEDDING_BASE_URL = 'https://example.test/v1/embeddings';
const postSpy = jest.spyOn(axios, 'post').mockResolvedValueOnce({
data: {
data: [
{ index: 0, embedding: [1, 0, 0] }, // query
{ index: 1, embedding: [0.1, 0.9, 0] }, // chunk0 — 低相似
{ index: 2, embedding: [0.99, 0.01, 0] }, // chunk1 — 高相似
{ index: 3, embedding: [0, 1, 0] }, // chunk2
{ index: 4, embedding: [0.2, 0.8, 0] }, // chunk3
],
},
});
const results = await service.retrieve('访客雷达 CRM', mockChunks, {
topK: 2,
});
expect(postSpy).toHaveBeenCalledTimes(1);
expect(postSpy.mock.calls[0][0]).toBe(
'https://example.test/v1/embeddings'
);
expect(results).toHaveLength(2);
expect(results.every(item => item.mode === 'embedding')).toBe(true);
// chunk1 向量最接近 query,应排第一
expect(results[0].index).toBe(1);
expect(results[0].score).toBeGreaterThan(results[1].score ?? 0);
expect(results[0].score).toBeLessThanOrEqual(1);
});
it('embedding 请求失败时应抛错,不回退 keyword', async () => {
process.env.EMBEDDING_API_KEY = 'test-embedding-key';
process.env.EMBEDDING_BASE_URL = 'https://example.test/v1/embeddings';
jest.spyOn(axios, 'post').mockRejectedValueOnce(new Error('network down'));
await expect(
service.retrieve('NFC', mockChunks, { topK: 1 })
).rejects.toThrow(/network down|embedding|EMBEDDING/i);
});
});
describe('3. AdminAiKnowledgeController 与 /admin/ai/knowledge/sandbox-test 接口', () => {
const controllerFile = path.join(
__dirname,