diff --git a/backend/jest.config.js b/backend/jest.config.js index 442aff4..dbe2082 100644 --- a/backend/jest.config.js +++ b/backend/jest.config.js @@ -4,4 +4,9 @@ module.exports = { testMatch: ['/test/**/*.test.ts'], testPathIgnorePatterns: ['/test/fixtures'], coveragePathIgnorePatterns: ['/test/'], + moduleFileExtensions: ['ts', 'uts', 'js', 'json'], + transform: { + '^.+\\.ts$': 'ts-jest', + '^.+\\.uts$': '/test/mpuniapp/uts-transformer.js', + }, }; \ No newline at end of file diff --git a/backend/test/mpuniapp/request.test.ts b/backend/test/mpuniapp/request.test.ts new file mode 100644 index 0000000..9b8cd99 --- /dev/null +++ b/backend/test/mpuniapp/request.test.ts @@ -0,0 +1,312 @@ +/** + * OmniCard AI (CRMmp) - mpuniapp 网络拦截器与 API 客户端基础契约测试 (TDD Red -> Green) + * + * 严格按照 AGENTS.md 3.3、4.3 规范与 PRD 设计编写。 + */ + +describe('OmniCard AI 用户端 mpuniapp 网络拦截器与 API 契约测试', () => { + let storageMap: Map; + let toastCalls: Array<{ title: string; icon?: string }>; + let reLaunchCalls: Array<{ url: string }>; + let lastRequestOptions: any; + let mockRequestResponse: { + statusCode?: number; + data?: any; + failError?: any; + }; + + beforeEach(() => { + storageMap = new Map(); + toastCalls = []; + reLaunchCalls = []; + lastRequestOptions = null; + mockRequestResponse = { + statusCode: 200, + data: { code: 1000, message: 'success', data: { ok: true } }, + }; + + // 构造全局 uni 运行时 Mock 环境 + (global as any).uni = { + getStorageSync: (key: string) => storageMap.get(key) || '', + setStorageSync: (key: string, val: string) => storageMap.set(key, val), + removeStorageSync: (key: string) => storageMap.delete(key), + showToast: (options: { title: string; icon?: string }) => { + toastCalls.push(options); + }, + reLaunch: (options: { url: string }) => { + reLaunchCalls.push(options); + }, + request: (options: any) => { + lastRequestOptions = options; + setTimeout(() => { + if (mockRequestResponse.failError) { + options.fail?.(mockRequestResponse.failError); + } else { + options.success?.({ + statusCode: mockRequestResponse.statusCode ?? 200, + data: mockRequestResponse.data, + header: {}, + }); + } + options.complete?.(); + }, 0); + return { + abort: jest.fn(), + }; + }, + }; + }); + + afterEach(() => { + delete (global as any).uni; + }); + + describe('1. 基础配置与请求拦截器 (request.uts)', () => { + it('1.1 默认配置、baseUrl 拼接与超时时长 (10000ms)', async () => { + // @ts-ignore + const { request, getBaseUrl, setBaseUrl, getRequestTimeout, setRequestTimeout } = require('../../../mpuniapp/common/request.uts'); + + expect(getRequestTimeout()).toBe(10000); + expect(getBaseUrl()).toBeDefined(); + + setBaseUrl('https://api.omnicard.ai/api'); + setRequestTimeout(8000); + expect(getBaseUrl()).toBe('https://api.omnicard.ai/api'); + expect(getRequestTimeout()).toBe(8000); + + // 发起相对路径请求 + await request({ + url: '/test/endpoint', + method: 'GET', + }); + + expect(lastRequestOptions).toBeDefined(); + expect(lastRequestOptions.url).toBe('https://api.omnicard.ai/api/test/endpoint'); + expect(lastRequestOptions.timeout).toBe(8000); + }); + + it('1.2 自动注入 Authorization (Token) 与 x-tenant-id 请求头', async () => { + // @ts-ignore + const { request } = require('../../../mpuniapp/common/request.uts'); + + storageMap.set('token', 'mock_jwt_token_999'); + storageMap.set('tenant_id', 'tenant_omni_88'); + + await request({ + url: '/card/info', + method: 'GET', + }); + + expect(lastRequestOptions.header).toBeDefined(); + expect(lastRequestOptions.header['Authorization']).toBe('Bearer mock_jwt_token_999'); + expect(lastRequestOptions.header['x-tenant-id']).toBe('tenant_omni_88'); + }); + + it('1.3 允许显式自定义 Header 覆盖或扩展', async () => { + // @ts-ignore + const { request } = require('../../../mpuniapp/common/request.uts'); + + storageMap.set('token', 'default_token'); + + await request({ + url: '/custom/header', + method: 'POST', + header: { + Authorization: 'CustomToken override', + 'x-custom-trace': 'trace_123', + }, + }); + + expect(lastRequestOptions.header['Authorization']).toBe('CustomToken override'); + expect(lastRequestOptions.header['x-custom-trace']).toBe('trace_123'); + }); + }); + + describe('2. 统一响应解包与错误拦截', () => { + it('2.1 成功响应 (code === 1000) 统一自动解包返回 data 核心数据', async () => { + // @ts-ignore + const { request } = require('../../../mpuniapp/common/request.uts'); + + mockRequestResponse.data = { + code: 1000, + message: 'success', + data: { + cardId: 'card_1001', + name: 'Alex Rivera', + }, + }; + + const res = await request({ + url: '/card/info', + method: 'GET', + }); + + expect(res).toEqual({ + cardId: 'card_1001', + name: 'Alex Rivera', + }); + expect(toastCalls).toHaveLength(0); + }); + + it('2.2 业务失败 (code !== 1000) 自动弹出 uni.showToast 且 Promise reject', async () => { + // @ts-ignore + const { request } = require('../../../mpuniapp/common/request.uts'); + + mockRequestResponse.data = { + code: 1002, + message: '指定微官网名片不存在或已下架', + data: null, + }; + + await expect( + request({ + url: '/card/portal/info', + method: 'GET', + }) + ).rejects.toThrow('指定微官网名片不存在或已下架'); + + expect(toastCalls).toHaveLength(1); + expect(toastCalls[0].title).toBe('指定微官网名片不存在或已下架'); + expect(toastCalls[0].icon).toBe('none'); + }); + + it('2.3 认证失效 (code === 401 或 HTTP 401) 自动跳转登录页、清除 Token 并弹出提示', async () => { + // @ts-ignore + const { request } = require('../../../mpuniapp/common/request.uts'); + + storageMap.set('token', 'expired_token'); + + mockRequestResponse.data = { + code: 401, + message: '登录状态已失效,请重新登录', + }; + + await expect( + request({ + url: '/card/my-cards', + method: 'GET', + }) + ).rejects.toThrow('登录状态已失效,请重新登录'); + + expect(storageMap.has('token')).toBe(false); + expect(reLaunchCalls).toHaveLength(1); + expect(reLaunchCalls[0].url).toBe('/pages/common/login'); + expect(toastCalls).toHaveLength(1); + expect(toastCalls[0].title).toBe('登录状态已失效,请重新登录'); + }); + + it('2.4 网络请求异常 (fail 回调) 提示网络异常并 Promise reject', async () => { + // @ts-ignore + const { request } = require('../../../mpuniapp/common/request.uts'); + + mockRequestResponse.failError = { + errMsg: 'request:fail timeout', + }; + + await expect( + request({ + url: '/timeout/test', + method: 'GET', + }) + ).rejects.toThrow(); + + expect(toastCalls).toHaveLength(1); + expect(toastCalls[0].title).toContain('网络连接异常'); + }); + }); + + describe('3. 核心 API 方法契约 (api.uts)', () => { + it('3.1 fetchPortalCard 能够按 ID 或 Slug 正确请求微官网聚合详情', async () => { + // @ts-ignore + const { fetchPortalCard } = require('../../../mpuniapp/common/api.uts'); + + mockRequestResponse.data = { + code: 1000, + message: 'success', + data: { + id: 1001, + slug: 'alex-rivera', + name: 'Alex Rivera', + title: 'VP of Global Business', + company: 'OmniCard AI Global', + bentoBlocks: [], + }, + }; + + const result = await fetchPortalCard('alex-rivera'); + + expect(lastRequestOptions.method).toBe('GET'); + expect(lastRequestOptions.url).toContain('/card/portal/info'); + expect(lastRequestOptions.data).toEqual({ id: 'alex-rivera' }); + expect(result.name).toBe('Alex Rivera'); + expect(result.slug).toBe('alex-rivera'); + }); + + it('3.2 submitLead 能够提交留资/互换名片数据并返回潜客模型', async () => { + // @ts-ignore + const { submitLead } = require('../../../mpuniapp/common/api.uts'); + + const leadPayload = { + cardId: 1001, + name: 'Tariq Mansour', + company: 'Dubai Energy Holding', + phone: '+971-50-1234567', + email: 'tariq@dubaienergy.ae', + source: 'nfc', + notes: 'Interested in enterprise solution', + }; + + mockRequestResponse.data = { + code: 1000, + message: 'success', + data: { + id: 'lead_9921', + ...leadPayload, + status: 'new', + intentScore: 85, + }, + }; + + const lead = await submitLead(leadPayload); + + expect(lastRequestOptions.method).toBe('POST'); + expect(lastRequestOptions.url).toContain('/crm/lead/submit'); + expect(lastRequestOptions.data).toEqual(leadPayload); + expect(lead.id).toBe('lead_9921'); + expect(lead.status).toBe('new'); + }); + + it('3.3 recordRadarTrack 能够发送访客轨迹数据并记录', async () => { + // @ts-ignore + const { recordRadarTrack } = require('../../../mpuniapp/common/api.uts'); + + const trackPayload = { + cardId: 1001, + visitorId: 'vis_de_4432', + touchType: 'qr', + stayDurationMs: 65000, + scrollDepth: 80, + viewedBlocks: ['hero', 'case_1'], + }; + + mockRequestResponse.data = { + code: 1000, + message: 'success', + data: { + id: 'track_881', + ...trackPayload, + country: 'Germany', + city: 'Frankfurt', + }, + }; + + const track = await recordRadarTrack(trackPayload); + + expect(lastRequestOptions.method).toBe('POST'); + expect(lastRequestOptions.url).toContain('/crm/radar/track'); + expect(lastRequestOptions.data).toEqual(trackPayload); + expect(track.id).toBe('track_881'); + expect(track.country).toBe('Germany'); + }); + }); +}); diff --git a/backend/test/mpuniapp/uts-transformer.js b/backend/test/mpuniapp/uts-transformer.js new file mode 100644 index 0000000..e4cf333 --- /dev/null +++ b/backend/test/mpuniapp/uts-transformer.js @@ -0,0 +1,17 @@ +const ts = require('typescript'); + +module.exports = { + process(sourceText, sourcePath) { + const result = ts.transpileModule(sourceText, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020, + esModuleInterop: true, + }, + fileName: sourcePath, + }); + return { + code: result.outputText, + }; + }, +}; diff --git a/backend/typings/uts.d.ts b/backend/typings/uts.d.ts new file mode 100644 index 0000000..b48e87b --- /dev/null +++ b/backend/typings/uts.d.ts @@ -0,0 +1,4 @@ +declare module '*.uts' { + const content: any; + export = content; +} diff --git a/mpuniapp/common/api.uts b/mpuniapp/common/api.uts new file mode 100644 index 0000000..ecb1c4a --- /dev/null +++ b/mpuniapp/common/api.uts @@ -0,0 +1,45 @@ +/** + * OmniCard AI (CRMmp) - 用户端面向微官网与名片的核心 API 客户端 + * 严格遵循 AGENTS.md 规范与 uts 强类型约束 + */ + +import { + CardPortalDetail, + LeadEntity, + RadarTrack, + SubmitLeadParams, + RecordRadarTrackParams, +} from './types.uts'; +import { httpGet, httpPost } from './request.uts'; + +/** + * 获取微官网与名片公开聚合详情 (包含 Hero、Bento 块、多语言与品牌 VI) + * + * @param cardIdOrSlug 名片主键 ID 或自定义短链标识 + */ +export function fetchPortalCard(cardIdOrSlug: string): Promise { + return httpGet('/card/portal/info', { + id: cardIdOrSlug, + }); +} + +/** + * 提交无摩擦潜客留资 / 互换名片数据 + * + * @param leadData 潜客留资信息 + */ +export function submitLead(leadData: SubmitLeadParams): Promise { + return httpPost('/crm/lead/submit', leadData); +} + +/** + * 记录访客实时雷达轨迹足迹 (停留时长、滚动深度、浏览块等) + * + * @param trackData 轨迹行为数据 + */ +export function recordRadarTrack(trackData: RecordRadarTrackParams): Promise { + return httpPost('/crm/radar/track', trackData); +} + +export * from './types.uts'; +export * from './request.uts'; diff --git a/mpuniapp/common/request.uts b/mpuniapp/common/request.uts new file mode 100644 index 0000000..dfed1e7 --- /dev/null +++ b/mpuniapp/common/request.uts @@ -0,0 +1,207 @@ +/** + * OmniCard AI (CRMmp) - 用户端网络请求底层封装与统一拦截器 + * 严格遵循 AGENTS.md 规范与 uts 强类型规范,一套代码兼顾 App 与 Web/H5 + */ + +import { ApiResponse } from './types.uts'; + +let currentBaseUrl: string = 'http://127.0.0.1:9000/api'; +// #ifdef H5 +currentBaseUrl = '/api'; +// #endif + +let currentTimeout: number = 10000; + +export function getBaseUrl(): string { + return currentBaseUrl; +} + +export function setBaseUrl(url: string): void { + currentBaseUrl = url; +} + +export function getRequestTimeout(): number { + return currentTimeout; +} + +export function setRequestTimeout(timeout: number): void { + currentTimeout = timeout; +} + +export function getAuthToken(): string { + try { + const val = uni.getStorageSync('token'); + return val != null ? String(val) : ''; + } catch (e) { + return ''; + } +} + +export function setAuthToken(token: string): void { + try { + uni.setStorageSync('token', token); + } catch (e) {} +} + +export function clearAuthToken(): void { + try { + uni.removeStorageSync('token'); + } catch (e) {} +} + +export function getTenantId(): string { + try { + const val = uni.getStorageSync('tenant_id'); + return val != null ? String(val) : ''; + } catch (e) { + return ''; + } +} + +export function setTenantId(tenantId: string): void { + try { + uni.setStorageSync('tenant_id', tenantId); + } catch (e) {} +} + +export type RequestMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; + +export interface RequestConfig { + url: string; + method?: RequestMethod; + data?: any; + header?: Record; + timeout?: number; +} + +/** + * 构建完整的请求 URL + */ +function buildFullUrl(url: string): string { + if (url.startsWith('http://') || url.startsWith('https://')) { + return url; + } + const base = getBaseUrl().replace(/\/+$/, ''); + const path = url.startsWith('/') ? url : `/${url}`; + return `${base}${path}`; +} + +/** + * 统一处理未授权 / 401 状态 + */ +function handleUnauthorized(message: string): void { + clearAuthToken(); + uni.showToast({ + title: message || '登录状态已失效,请重新登录', + icon: 'none', + }); + uni.reLaunch({ + url: '/pages/common/login', + }); +} + +/** + * 网络请求底层统一封装 + */ +export function request(config: RequestConfig): Promise { + return new Promise((resolve, reject) => { + const method = config.method || 'GET'; + const fullUrl = buildFullUrl(config.url); + const timeout = config.timeout != null ? config.timeout : getRequestTimeout(); + + // 默认请求头配置 + const headers: Record = { + 'content-type': 'application/json', + }; + + // 自动挂载 Authorization 与 x-tenant-id + const token = getAuthToken(); + if (token) { + headers['Authorization'] = token.startsWith('Bearer ') ? token : `Bearer ${token}`; + } + + const tenantId = getTenantId(); + if (tenantId) { + headers['x-tenant-id'] = tenantId; + } + + // 显式传入的 header 具有更高优先级 + if (config.header) { + Object.assign(headers, config.header); + } + + uni.request({ + url: fullUrl, + method: method as any, + data: config.data, + header: headers, + timeout: timeout, + success: (res: any) => { + const statusCode = res.statusCode != null ? res.statusCode : 200; + const resBody = res.data; + + // 1. 处理 HTTP 401 或业务 code 401 + if (statusCode === 401 || (resBody != null && resBody.code === 401)) { + const msg = (resBody && resBody.message) ? resBody.message : '登录状态已失效,请重新登录'; + handleUnauthorized(msg); + reject(new Error(msg)); + return; + } + + // 2. 处理非 200 的 HTTP 异常状态 + if (statusCode < 200 || statusCode >= 300) { + const errorMsg = (resBody && resBody.message) ? resBody.message : `网络请求错误 (${statusCode})`; + uni.showToast({ + title: errorMsg, + icon: 'none', + }); + reject(new Error(errorMsg)); + return; + } + + // 3. 处理 Cool-Admin 标准业务状态 (code === 1000 为成功) + if (resBody != null && typeof resBody === 'object' && 'code' in resBody) { + const apiRes = resBody as ApiResponse; + if (apiRes.code === 1000) { + resolve(apiRes.data !== undefined ? apiRes.data : (apiRes as unknown as T)); + } else { + const failMsg = apiRes.message || '请求处理失败'; + uni.showToast({ + title: failMsg, + icon: 'none', + }); + reject(new Error(failMsg)); + } + return; + } + + // 4. 其它直接返回的数据格式 + resolve(resBody as T); + }, + fail: (err: any) => { + const errMsg = (err && err.errMsg) ? err.errMsg : '网络连接异常,请重试'; + uni.showToast({ + title: '网络连接异常,请重试', + icon: 'none', + }); + reject(new Error(errMsg)); + }, + }); + }); +} + +export function httpGet(url: string, data?: any, header?: Record): Promise { + return request({ url, method: 'GET', data, header }); +} + +export function httpPost(url: string, data?: any, header?: Record): Promise { + return request({ url, method: 'POST', data, header }); +} + +export function httpPut(url: string, data?: any, header?: Record): Promise { + return request({ url, method: 'PUT', data, header }); +} + +export function httpDelete(url: string, data?: any, header?: Record): Promise { + return request({ url, method: 'DELETE', data, header }); +} diff --git a/mpuniapp/common/types.uts b/mpuniapp/common/types.uts new file mode 100644 index 0000000..f390aad --- /dev/null +++ b/mpuniapp/common/types.uts @@ -0,0 +1,119 @@ +/** + * OmniCard AI (CRMmp) - 用户端跨端核心数据契约与 TypeScript / UTS 类型体系 + * 严格遵循 AGENTS.md 规范与出海 SaaS 契约,与 backend / admin 镜像对齐 + */ + +export type CardStatus = 'draft' | 'published' | 'archived'; + +export type BentoBlockType = 'hero' | 'case' | 'article' | 'social' | 'video' | 'form'; + +export type BentoColSpan = 1 | 2; + +export type BentoRowSpan = 1 | 2; + +export interface BentoBlockPublic { + id: number | string; + cardId: number | string; + type: BentoBlockType; + colSpan: BentoColSpan; + rowSpan: BentoRowSpan; + sortOrder: number; + title?: string; + content?: string; + mediaUrl?: string; + linkUrl?: string; + metaJson?: string | Record; +} + +export interface BrandKit { + logoUrl: string; + primaryColor: string; +} + +export interface CardPortalDetail { + id: number | string; + slug?: string; + name: string; + title: string; + company: string; + slogan?: string; + avatarUrl?: string; + phone?: string; + email?: string; + whatsapp?: string; + linkedin?: string; + timezone?: string; + audioPronunciationUrl?: string; + themeCode?: string; + status: CardStatus; + bentoBlocks: BentoBlockPublic[]; + brandKit?: BrandKit; + cachedAt?: number; +} + +export type LeadStatus = 'new' | 'contacted' | 'hot' | 'won' | 'archived'; + +export type LeadSource = 'nfc' | 'qr' | 'link' | 'ai_chat'; + +export interface LeadEntity { + id: string | number; + tenantId?: string | number; + cardId: string | number; + visitorId?: string; + name: string; + company?: string; + phone?: string; + email?: string; + status: LeadStatus; + intentScore: number; + source: LeadSource; + notes?: string; +} + +export interface SubmitLeadParams { + cardId: string | number; + name: string; + company?: string; + phone?: string; + email?: string; + source?: string; + notes?: string; + visitorId?: string; + [key: string]: any; +} + +export type TouchType = 'nfc' | 'qr' | 'link'; + +export interface RadarTrack { + id: string | number; + tenantId?: string | number; + cardId: string | number; + visitorId: string; + ip?: string; + country?: string; + city?: string; + touchType: TouchType; + stayDurationMs: number; + scrollDepth: number; + viewedBlocks: string[]; + createdAt?: string | Date; +} + +export interface RecordRadarTrackParams { + cardId: string | number; + visitorId: string; + touchType?: string; + stayDurationMs?: number; + scrollDepth?: number; + viewedBlocks?: string[]; + ip?: string; + country?: string; + city?: string; + [key: string]: any; +} + +export interface ApiResponse { + code: number; + message: string; + data: T; +}