From 8c003b70cd127258929ce697c764a4f14814c868 Mon Sep 17 00:00:00 2001 From: wikiboss Date: Mon, 21 Sep 2026 02:17:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(round-16):=20AI=E6=84=8F=E5=9B=BE=E6=83=85?= =?UTF-8?q?=E7=BB=AA=E5=88=86=E6=9E=90/=E5=B1=95=E4=BC=9A=E5=90=8D?= =?UTF-8?q?=E7=89=87=E5=8F=8C=E5=90=91=E4=BA=92=E6=8D=A2/=E9=9B=B7?= =?UTF-8?q?=E8=BE=BE=E7=88=AC=E8=99=AB=E6=B8=85=E6=B4=97/=E7=A6=BB?= =?UTF-8?q?=E7=BA=BF=E5=A4=9A=E5=AA=92=E4=BD=93=E7=BC=93=E5=AD=98=20(103?= =?UTF-8?q?=E5=A5=97=E4=BB=B61101=E6=B5=8B=E8=AF=95=E5=85=A8=E7=BB=BF)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../modules/ai/service/sentiment-intent.ts | 151 ++++++++++++++++++ .../modules/card/service/mutual-exchange.ts | 127 +++++++++++++++ backend/src/modules/crm/service/bot-filter.ts | 102 ++++++++++++ .../test/modules/ai/sentiment-intent.test.ts | 70 ++++++++ .../test/modules/card/mutual-exchange.test.ts | 63 ++++++++ backend/test/modules/crm/bot-filter.test.ts | 77 +++++++++ backend/test/mpuniapp/media-cache.test.ts | 76 +++++++++ mpuniapp/common/media-cache.uts | 139 ++++++++++++++++ 8 files changed, 805 insertions(+) create mode 100644 backend/src/modules/ai/service/sentiment-intent.ts create mode 100644 backend/src/modules/card/service/mutual-exchange.ts create mode 100644 backend/src/modules/crm/service/bot-filter.ts create mode 100644 backend/test/modules/ai/sentiment-intent.test.ts create mode 100644 backend/test/modules/card/mutual-exchange.test.ts create mode 100644 backend/test/modules/crm/bot-filter.test.ts create mode 100644 backend/test/mpuniapp/media-cache.test.ts create mode 100644 mpuniapp/common/media-cache.uts diff --git a/backend/src/modules/ai/service/sentiment-intent.ts b/backend/src/modules/ai/service/sentiment-intent.ts new file mode 100644 index 0000000..f68723d --- /dev/null +++ b/backend/src/modules/ai/service/sentiment-intent.ts @@ -0,0 +1,151 @@ +import { Provide } from '@midwayjs/core'; +import { BaseService } from '@cool-midway/core'; + +export type CommercialIntent = 'PURCHASE_INTENT' | 'PARTNER_INQUIRY' | 'TECH_SUPPORT' | 'GENERAL_CHAT'; +export type SentimentPolarity = 'positive' | 'neutral' | 'negative'; + +export interface IntentAnalysisResult { + primaryIntent: CommercialIntent; + confidence: number; + matchedKeywords: string[]; +} + +export interface SentimentAnalysisResult { + polarity: SentimentPolarity; + score: number; // -1.0 到 1.0 +} + +export interface ConversationAnalysisResult { + intent: IntentAnalysisResult; + sentiment: SentimentAnalysisResult; + recommendedLeadScoreBoost: number; +} + +const PURCHASE_KEYWORDS = ['buy', 'purchase', 'price', 'pricing', 'quote', 'quotation', 'cost', 'moq', 'wholesale', 'order', 'contract', '订购', '采购', '价格', '报价']; +const PARTNER_KEYWORDS = ['partner', 'partnership', 'reseller', 'distributor', 'agency', 'agent', 'cooperate', '代理', '分销', '合作']; +const TECH_KEYWORDS = ['api', 'sdk', 'tech', 'integration', 'developer', 'spec', 'chip', 'ntag', '接口', '开发']; + +const POSITIVE_KEYWORDS = ['great', 'excellent', 'love', 'perfect', 'awesome', 'good', 'impressive', 'forward to', 'pleasure', '太棒', '满意', '喜欢', '期待']; +const NEGATIVE_KEYWORDS = ['bad', 'expensive', 'too high', 'slow', 'poor', 'disappointed', 'terrible', 'not happy', '差', '贵', '不满意', '太慢']; + +/** + * 识别对话核心商务意图 + */ +export function analyzeConversationIntent(messages: string[]): IntentAnalysisResult { + const fullText = messages.join(' ').toLowerCase(); + + let purchaseHits = 0; + let partnerHits = 0; + let techHits = 0; + const matchedKeywords: string[] = []; + + for (const kw of PURCHASE_KEYWORDS) { + if (fullText.includes(kw)) { + purchaseHits++; + matchedKeywords.push(kw); + } + } + for (const kw of PARTNER_KEYWORDS) { + if (fullText.includes(kw)) { + partnerHits++; + matchedKeywords.push(kw); + } + } + for (const kw of TECH_KEYWORDS) { + if (fullText.includes(kw)) { + techHits++; + matchedKeywords.push(kw); + } + } + + if (purchaseHits > 0 && purchaseHits >= partnerHits && purchaseHits >= techHits) { + return { + primaryIntent: 'PURCHASE_INTENT', + confidence: Math.min(1.0, 0.6 + purchaseHits * 0.15), + matchedKeywords, + }; + } + + if (partnerHits > 0 && partnerHits >= techHits) { + return { + primaryIntent: 'PARTNER_INQUIRY', + confidence: Math.min(1.0, 0.6 + partnerHits * 0.15), + matchedKeywords, + }; + } + + if (techHits > 0) { + return { + primaryIntent: 'TECH_SUPPORT', + confidence: Math.min(1.0, 0.6 + techHits * 0.15), + matchedKeywords, + }; + } + + return { + primaryIntent: 'GENERAL_CHAT', + confidence: 0.5, + matchedKeywords: [], + }; +} + +/** + * 客商情绪极性分析 + */ +export function analyzeConversationSentiment(messages: string[]): SentimentAnalysisResult { + const fullText = messages.join(' ').toLowerCase(); + + let posCount = 0; + let negCount = 0; + + for (const kw of POSITIVE_KEYWORDS) { + if (fullText.includes(kw)) posCount++; + } + for (const kw of NEGATIVE_KEYWORDS) { + if (fullText.includes(kw)) negCount++; + } + + if (posCount > negCount) { + return { + polarity: 'positive', + score: Math.min(1.0, 0.4 + (posCount - negCount) * 0.3), + }; + } + + if (negCount > posCount) { + return { + polarity: 'negative', + score: Math.max(-1.0, -0.4 - (negCount - posCount) * 0.3), + }; + } + + return { + polarity: 'neutral', + score: 0.0, + }; +} + +/** + * AI 客商意图与商业情绪分析引擎服务 + */ +@Provide() +export class AiSentimentIntentAnalyzer extends BaseService { + async evaluateConversationSession( + messages: string[] + ): Promise { + const intent = analyzeConversationIntent(messages); + const sentiment = analyzeConversationSentiment(messages); + + let boost = 0; + if (intent.primaryIntent === 'PURCHASE_INTENT') boost += 20; + if (intent.primaryIntent === 'PARTNER_INQUIRY') boost += 15; + if (sentiment.polarity === 'positive') boost += 10; + if (sentiment.polarity === 'negative') boost -= 10; + + return { + intent, + sentiment, + recommendedLeadScoreBoost: Math.max(0, boost), + }; + } +} diff --git a/backend/src/modules/card/service/mutual-exchange.ts b/backend/src/modules/card/service/mutual-exchange.ts new file mode 100644 index 0000000..269e260 --- /dev/null +++ b/backend/src/modules/card/service/mutual-exchange.ts @@ -0,0 +1,127 @@ +import { Provide } from '@midwayjs/core'; +import { BaseService } from '@cool-midway/core'; + +export type MutualExchangeStatus = 'REQUESTED' | 'ACCEPTED' | 'REJECTED' | 'EXPIRED'; + +export interface MutualExchangeInvitation { + id: string; + senderCardId: number; + recipientCardId: number; + status: MutualExchangeStatus; + exchangeCode: string; + notes?: string; + createdAt: number; + acceptedAt?: number; +} + +export interface MutualExchangeSyncAction { + targetCardId: number; + sourceCardId: number; + action: 'CREATE_CRM_LEAD'; +} + +export interface AcceptExchangeResult { + invitation: MutualExchangeInvitation; + syncActions: MutualExchangeSyncAction[]; +} + +/** + * 发起名片双向互换邀约 + */ +export function createMutualExchangeInvitation( + senderCardId: number, + recipientCardId: number, + notes?: string +): MutualExchangeInvitation { + if (senderCardId === recipientCardId) { + throw new Error('不能与自己互换名片'); + } + + const exchangeCode = String(Math.floor(100000 + Math.random() * 900000)); + const id = `xchg_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`; + + return { + id, + senderCardId, + recipientCardId, + status: 'REQUESTED', + exchangeCode, + notes, + createdAt: Date.now(), + }; +} + +/** + * 接收方确认互换名片 + */ +export function acceptMutualExchangeInvitation( + invitation: MutualExchangeInvitation, + actingCardId: number +): AcceptExchangeResult { + if (invitation.recipientCardId !== actingCardId) { + throw new Error('无权操作此名片互换邀约'); + } + + if (invitation.status !== 'REQUESTED') { + throw new Error(`当前互换邀约状态为 ${invitation.status},不可重复确认`); + } + + invitation.status = 'ACCEPTED'; + invitation.acceptedAt = Date.now(); + + const syncActions: MutualExchangeSyncAction[] = [ + { + targetCardId: invitation.senderCardId, + sourceCardId: invitation.recipientCardId, + action: 'CREATE_CRM_LEAD', + }, + { + targetCardId: invitation.recipientCardId, + sourceCardId: invitation.senderCardId, + action: 'CREATE_CRM_LEAD', + }, + ]; + + return { + invitation, + syncActions, + }; +} + +/** + * 展会名片双向互换服务 + */ +@Provide() +export class CardMutualExchangeService extends BaseService { + private readonly invitations = new Map(); + + async initiateExchange(params: { + senderCardId: number; + recipientCardId: number; + notes?: string; + }): Promise { + const invite = createMutualExchangeInvitation( + params.senderCardId, + params.recipientCardId, + params.notes + ); + this.invitations.set(invite.id, invite); + return invite; + } + + async confirmExchange( + invitationId: string, + actingCardId: number + ): Promise<{ status: MutualExchangeStatus; isDualSynced: boolean }> { + const invite = this.invitations.get(invitationId); + if (!invite) { + throw new Error('未找到该名片互换邀约'); + } + + const res = acceptMutualExchangeInvitation(invite, actingCardId); + return { + status: res.invitation.status, + isDualSynced: res.syncActions.length === 2, + }; + } +} diff --git a/backend/src/modules/crm/service/bot-filter.ts b/backend/src/modules/crm/service/bot-filter.ts new file mode 100644 index 0000000..b12c099 --- /dev/null +++ b/backend/src/modules/crm/service/bot-filter.ts @@ -0,0 +1,102 @@ +import { Provide } from '@midwayjs/core'; +import { BaseService } from '@cool-midway/core'; + +export type BotClassification = 'SEARCH_CRAWLER' | 'SOCIAL_PREVIEW' | 'SCRIPTER'; + +export interface BotDetectResult { + isBot: boolean; + botType?: BotClassification; + botName?: string; +} + +export interface TrafficFilterResult { + isBot: boolean; + category: 'HUMAN' | 'CRAWLER' | 'SUSPICIOUS_SPAM'; + shouldRecordLead: boolean; + reason?: string; +} + +const SEARCH_BOTS = ['googlebot', 'bingbot', 'baiduspider', 'yandexbot', 'duckduckbot', 'sogou']; +const SOCIAL_BOTS = ['whatsapp', 'twitterbot', 'facebookexternalhit', 'linkedinbot', 'telegrambot', 'slackbot']; +const SCRIPT_BOTS = ['curl', 'wget', 'python-requests', 'postman', 'httpclient', 'scrapy', 'axios']; + +/** + * 基于 User-Agent 识别机器人与爬虫 + */ +export function detectBotUserAgent(userAgent?: string): BotDetectResult { + if (!userAgent || typeof userAgent !== 'string') { + return { isBot: false }; + } + + const ua = userAgent.toLowerCase(); + + for (const bot of SEARCH_BOTS) { + if (ua.includes(bot)) { + return { isBot: true, botType: 'SEARCH_CRAWLER', botName: bot }; + } + } + + for (const bot of SOCIAL_BOTS) { + if (ua.includes(bot)) { + return { isBot: true, botType: 'SOCIAL_PREVIEW', botName: bot }; + } + } + + for (const bot of SCRIPT_BOTS) { + if (ua.includes(bot)) { + return { isBot: true, botType: 'SCRIPTER', botName: bot }; + } + } + + return { isBot: false }; +} + +/** + * 检测 1 秒内的频次是否异常超高 + */ +export function isRateLimitSuspicious(timestamps: number[], thresholdPerSec = 10): boolean { + if (!timestamps || timestamps.length < thresholdPerSec) { + return false; + } + const sorted = [...timestamps].sort((a, b) => a - b); + const now = sorted[sorted.length - 1]; + const countInLastSecond = sorted.filter(t => now - t <= 1000).length; + return countInLastSecond >= thresholdPerSec; +} + +/** + * 访客雷达异常流量与爬虫清洗过滤服务 + */ +@Provide() +export class RadarBotFilterService extends BaseService { + async filterIncomingTraffic(input: { + ip: string; + userAgent: string; + recentTimestamps?: number[]; + }): Promise { + const botCheck = detectBotUserAgent(input.userAgent); + if (botCheck.isBot) { + return { + isBot: true, + category: 'CRAWLER', + shouldRecordLead: false, + reason: `检测到 ${botCheck.botType}: ${botCheck.botName}`, + }; + } + + if (input.recentTimestamps && isRateLimitSuspicious(input.recentTimestamps)) { + return { + isBot: true, + category: 'SUSPICIOUS_SPAM', + shouldRecordLead: false, + reason: '1秒内突发异常高频请求,疑似自动化刷量攻击', + }; + } + + return { + isBot: false, + category: 'HUMAN', + shouldRecordLead: true, + }; + } +} diff --git a/backend/test/modules/ai/sentiment-intent.test.ts b/backend/test/modules/ai/sentiment-intent.test.ts new file mode 100644 index 0000000..ac64b5d --- /dev/null +++ b/backend/test/modules/ai/sentiment-intent.test.ts @@ -0,0 +1,70 @@ +import { + AiSentimentIntentAnalyzer, + analyzeConversationIntent, + analyzeConversationSentiment, + ConversationAnalysisResult, +} from '../../../src/modules/ai/service/sentiment-intent'; + +describe('Point 61 (P1): AI 对话客商意图与情绪极性分析引擎', () => { + let analyzer: AiSentimentIntentAnalyzer; + + beforeEach(() => { + analyzer = new AiSentimentIntentAnalyzer(); + }); + + describe('1. analyzeConversationIntent (出海商务意图识别)', () => { + it('包含价格、报价、起订量或购买关键词应识别为 PURCHASE_INTENT', () => { + const msgs = [ + 'Hello, what is the MOQ for 500 NFC metal cards?', + 'Can you provide a wholesale quotation and delivery time to Dubai?', + ]; + const intent = analyzeConversationIntent(msgs); + expect(intent.primaryIntent).toBe('PURCHASE_INTENT'); + expect(intent.confidence).toBeGreaterThanOrEqual(0.8); + }); + + it('包含代理、渠道分销或合作伙伴意向应识别为 PARTNER_INQUIRY', () => { + const msgs = [ + 'We are a leading distributor in Saudi Arabia.', + 'Interested in becoming your regional partner and reseller.', + ]; + const intent = analyzeConversationIntent(msgs); + expect(intent.primaryIntent).toBe('PARTNER_INQUIRY'); + }); + + it('普通闲聊应识别为 GENERAL_CHAT', () => { + const msgs = ['Hi there', 'Just browsing your digital card showcase']; + const intent = analyzeConversationIntent(msgs); + expect(intent.primaryIntent).toBe('GENERAL_CHAT'); + }); + }); + + describe('2. analyzeConversationSentiment (客商商业情绪极性分析)', () => { + it('热情积极、赞美或明确赞同应识别为 positive', () => { + const msgs = ['Great product! Exactly what our international sales team needs.']; + const sentiment = analyzeConversationSentiment(msgs); + expect(sentiment.polarity).toBe('positive'); + expect(sentiment.score).toBeGreaterThan(0.5); + }); + + it('抱怨、价格过贵或批评应识别为 negative', () => { + const msgs = ['Too expensive, not happy with the slow response.', 'Bad experience.']; + const sentiment = analyzeConversationSentiment(msgs); + expect(sentiment.polarity).toBe('negative'); + expect(sentiment.score).toBeLessThan(0); + }); + }); + + describe('3. AiSentimentIntentAnalyzer 综合画像报告与 CRM 加分', () => { + it('高价值积极意向对话应输出线索加权建议 (+25~35分)', async () => { + const result = await analyzer.evaluateConversationSession([ + 'We want to purchase 1,000 smart cards for our Dubai exhibition.', + 'Looking forward to signing the contract next week.', + ]); + + expect(result.intent.primaryIntent).toBe('PURCHASE_INTENT'); + expect(result.sentiment.polarity).toBe('positive'); + expect(result.recommendedLeadScoreBoost).toBeGreaterThanOrEqual(25); + }); + }); +}); diff --git a/backend/test/modules/card/mutual-exchange.test.ts b/backend/test/modules/card/mutual-exchange.test.ts new file mode 100644 index 0000000..15ce88a --- /dev/null +++ b/backend/test/modules/card/mutual-exchange.test.ts @@ -0,0 +1,63 @@ +import { + CardMutualExchangeService, + createMutualExchangeInvitation, + acceptMutualExchangeInvitation, + MutualExchangeInvitation, +} from '../../../src/modules/card/service/mutual-exchange'; + +describe('Point 62 (P1): 展会名片双向互换与 CRM 互加好友验证', () => { + let service: CardMutualExchangeService; + + beforeEach(() => { + service = new CardMutualExchangeService(); + }); + + describe('1. 互换邀约发起与校验 (createMutualExchangeInvitation)', () => { + it('应成功生成包含发起方名片与接收方名片的有效邀约 (REQUESTED)', () => { + const invite = createMutualExchangeInvitation(101, 202); + + expect(invite.id).toBeDefined(); + expect(invite.senderCardId).toBe(101); + expect(invite.recipientCardId).toBe(202); + expect(invite.status).toBe('REQUESTED'); + expect(invite.exchangeCode).toHaveLength(6); // 6位简易换卡数字码 + }); + + it('自己与自己互换应安全拒绝', () => { + expect(() => createMutualExchangeInvitation(101, 101)).toThrow(/不能与自己互换/); + }); + }); + + describe('2. 接收方确认与状态流转 (acceptMutualExchangeInvitation)', () => { + it('接收方确认后状态应流转为 ACCEPTED,并生成双向入库同步指令', () => { + const invite = createMutualExchangeInvitation(101, 202); + const result = acceptMutualExchangeInvitation(invite, 202); + + expect(result.invitation.status).toBe('ACCEPTED'); + expect(result.syncActions.length).toBe(2); + expect(result.syncActions[0].targetCardId).toBe(101); // 同步到 101 的 CRM + expect(result.syncActions[1].targetCardId).toBe(202); // 同步到 202 的 CRM + }); + + it('非指定接收方尝试确认时应被阻断', () => { + const invite = createMutualExchangeInvitation(101, 202); + expect(() => acceptMutualExchangeInvitation(invite, 999)).toThrow(/无权操作/); + }); + }); + + describe('3. CardMutualExchangeService 完整流程执行', () => { + it('应完整贯通发起、接受与双向 CRM 线索生成流程', async () => { + const invite = await service.initiateExchange({ + senderCardId: 301, + recipientCardId: 402, + notes: 'Met at CES 2026 Eureka Park', + }); + + expect(invite.status).toBe('REQUESTED'); + + const outcome = await service.confirmExchange(invite.id, 402); + expect(outcome.status).toBe('ACCEPTED'); + expect(outcome.isDualSynced).toBe(true); + }); + }); +}); diff --git a/backend/test/modules/crm/bot-filter.test.ts b/backend/test/modules/crm/bot-filter.test.ts new file mode 100644 index 0000000..10ee066 --- /dev/null +++ b/backend/test/modules/crm/bot-filter.test.ts @@ -0,0 +1,77 @@ +import { + RadarBotFilterService, + detectBotUserAgent, + isRateLimitSuspicious, + TrafficFilterResult, +} from '../../../src/modules/crm/service/bot-filter'; + +describe('Point 63 (P2): 访客雷达异常流量与出海社交抓取机器爬虫清洗过滤', () => { + let service: RadarBotFilterService; + + beforeEach(() => { + service = new RadarBotFilterService(); + }); + + describe('1. detectBotUserAgent (爬虫与社交卡片抓取器识别)', () => { + it('应精准识别 Googlebot、Bingbot 与主流搜索引擎爬虫', () => { + const googleUa = 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)'; + const res = detectBotUserAgent(googleUa); + expect(res.isBot).toBe(true); + expect(res.botType).toBe('SEARCH_CRAWLER'); + }); + + it('应精准识别 WhatsApp 与 Twitter/LinkedIn 社交预览卡片抓取器', () => { + const waUa = 'WhatsApp/2.21.12.21 A'; + const waRes = detectBotUserAgent(waUa); + expect(waRes.isBot).toBe(true); + expect(waRes.botType).toBe('SOCIAL_PREVIEW'); + + const twUa = 'Twitterbot/1.0'; + const twRes = detectBotUserAgent(twUa); + expect(twRes.isBot).toBe(true); + expect(twRes.botType).toBe('SOCIAL_PREVIEW'); + }); + + it('应拦截 curl、Python Requests、Postman 等自动化脚本工具', () => { + expect(detectBotUserAgent('python-requests/2.28.1').isBot).toBe(true); + expect(detectBotUserAgent('curl/7.68.0').isBot).toBe(true); + }); + + it('真实手机移动端浏览器访问应判定为正常用户 (isBot = false)', () => { + const mobileUa = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1'; + const res = detectBotUserAgent(mobileUa); + expect(res.isBot).toBe(false); + expect(res.botType).toBeUndefined(); + }); + }); + + describe('2. isRateLimitSuspicious (超高频空刷异常检测)', () => { + it('1秒内发生超过 10 次访问请求应判定为异常刷量', () => { + const timestamps = Array(12).fill(Date.now()); + expect(isRateLimitSuspicious(timestamps)).toBe(true); + }); + + it('正常人类浏览间隔应判定为合法流量', () => { + const timestamps = [Date.now() - 5000, Date.now() - 2000, Date.now()]; + expect(isRateLimitSuspicious(timestamps)).toBe(false); + }); + }); + + describe('3. RadarBotFilterService 综合过滤清洗', () => { + it('应标记机器人流量并决定是否计入 CRM 真实意向得分', async () => { + const botTraffic = await service.filterIncomingTraffic({ + ip: '66.249.66.1', + userAgent: 'Googlebot/2.1', + }); + expect(botTraffic.shouldRecordLead).toBe(false); + expect(botTraffic.category).toBe('CRAWLER'); + + const humanTraffic = await service.filterIncomingTraffic({ + ip: '94.200.12.34', + userAgent: 'Mozilla/5.0 (Linux; Android 14) Chrome/120.0 Mobile Safari/537.36', + }); + expect(humanTraffic.shouldRecordLead).toBe(true); + expect(humanTraffic.category).toBe('HUMAN'); + }); + }); +}); diff --git a/backend/test/mpuniapp/media-cache.test.ts b/backend/test/mpuniapp/media-cache.test.ts new file mode 100644 index 0000000..6dfb91f --- /dev/null +++ b/backend/test/mpuniapp/media-cache.test.ts @@ -0,0 +1,76 @@ +/** + * OmniCard AI (CRMmp) - 移动端多媒体离线缓存持久化策略单元测试 + * 严格遵循 TDD 规范 + */ + +describe('Point 64 (P2): 移动端名片离线多媒体缓存与本地持久化策略 (Media Cache)', () => { + let cacheMod: any; + let storageMap: Record; + + beforeEach(() => { + storageMap = {}; + + (global as any).uni = { + getStorageSync: jest.fn((key: string) => storageMap[key] || ''), + setStorageSync: jest.fn((key: string, val: string) => { + storageMap[key] = String(val); + }), + removeStorageSync: jest.fn((key: string) => { + delete storageMap[key]; + }), + }; + + // @ts-ignore + cacheMod = require('../../../mpuniapp/common/media-cache.uts'); + cacheMod.clearAllMediaCache(); + }); + + afterEach(() => { + delete (global as any).uni; + }); + + describe('1. 缓存存储与命中获取 (saveMediaToCache & getCachedMedia)', () => { + const remoteUrl = 'https://cdn.omnicard.ai/avatars/alex.png'; + const localPath = '_doc/cache/avatars/alex.png'; + + it('写入缓存后应能成功命中并返回本地文件路径', () => { + cacheMod.saveMediaToCache(remoteUrl, localPath, 24); // 24小时有效 + const hit = cacheMod.getCachedMedia(remoteUrl); + + expect(hit).toBeDefined(); + expect(hit.localPath).toBe(localPath); + expect(hit.isExpired).toBe(false); + }); + + it('未缓存的 URL 查询应返回 null', () => { + const miss = cacheMod.getCachedMedia('https://cdn.omnicard.ai/not-cached.png'); + expect(miss).toBeNull(); + }); + }); + + describe('2. 缓存过期淘汰机制 (pruneExpiredMediaCache)', () => { + it('已过期的缓存条目应被自动清理淘汰', () => { + const oldUrl = 'https://cdn.omnicard.ai/old.png'; + cacheMod.saveMediaToCache(oldUrl, '_doc/old.png', -1); // 传入负数表示已过期 + + const beforePrune = cacheMod.getCachedMedia(oldUrl); + expect(beforePrune.isExpired).toBe(true); + + const prunedCount = cacheMod.pruneExpiredMediaCache(); + expect(prunedCount).toBe(1); + + // 清理后再次获取应为 null + expect(cacheMod.getCachedMedia(oldUrl)).toBeNull(); + }); + }); + + describe('3. 缓存容量上限保护', () => { + it('应提供当前缓存条目数量统计', () => { + cacheMod.saveMediaToCache('https://cdn.omnicard.ai/1.png', '_doc/1.png'); + cacheMod.saveMediaToCache('https://cdn.omnicard.ai/2.png', '_doc/2.png'); + + const stats = cacheMod.getMediaCacheStats(); + expect(stats.totalEntries).toBe(2); + }); + }); +}); diff --git a/mpuniapp/common/media-cache.uts b/mpuniapp/common/media-cache.uts new file mode 100644 index 0000000..86aa243 --- /dev/null +++ b/mpuniapp/common/media-cache.uts @@ -0,0 +1,139 @@ +/** + * OmniCard AI (CRMmp) - 移动端名片离线多媒体缓存与本地持久化策略 + * 严格遵循 AGENTS.md 规范与 uts 跨端强类型语法 + */ + +export interface MediaCacheEntry { + url: string; + localPath: string; + cachedAt: number; + expireAt: number; + sizeBytes?: number; +} + +export interface CachedMediaResult { + localPath: string; + isExpired: boolean; +} + +export interface MediaCacheStats { + totalEntries: number; + oldestEntryAt?: number; +} + +const STORAGE_CACHE_KEY = 'omni_media_cache_index'; + +/** + * 获取底层全部缓存索引映射 + */ +function getIndexMap(): Record { + try { + const raw = uni.getStorageSync(STORAGE_CACHE_KEY); + if (!raw) return {}; + return JSON.parse(String(raw)) || {}; + } catch (e) { + return {}; + } +} + +/** + * 保存索引映射 + */ +function saveIndexMap(map: Record): void { + try { + uni.setStorageSync(STORAGE_CACHE_KEY, JSON.stringify(map)); + } catch (e) {} +} + +/** + * 获取指定多媒体 URL 的本地缓存 + */ +export function getCachedMedia(url?: string): CachedMediaResult | null { + if (!url || typeof url !== 'string') return null; + + const map = getIndexMap(); + const entry = map[url]; + if (!entry) return null; + + const now = Date.now(); + const isExpired = now > entry.expireAt; + + return { + localPath: entry.localPath, + isExpired, + }; +} + +/** + * 存入多媒体缓存索引 + */ +export function saveMediaToCache( + url: string, + localPath: string, + expireHours: number = 72 +): void { + if (!url || !localPath) return; + + const map = getIndexMap(); + const now = Date.now(); + const expireAt = now + expireHours * 3600 * 1000; + + map[url] = { + url, + localPath, + cachedAt: now, + expireAt, + }; + + saveIndexMap(map); +} + +/** + * 定期/按需淘汰清理过期缓存条目 + */ +export function pruneExpiredMediaCache(): number { + const map = getIndexMap(); + const now = Date.now(); + let prunedCount = 0; + + const newMap: Record = {}; + for (const [key, entry] of Object.entries(map)) { + if (now > entry.expireAt) { + prunedCount++; + } else { + newMap[key] = entry; + } + } + + saveIndexMap(newMap); + return prunedCount; +} + +/** + * 获取多媒体缓存状态统计 + */ +export function getMediaCacheStats(): MediaCacheStats { + const map = getIndexMap(); + const entries = Object.values(map); + + let oldest: number | undefined = undefined; + for (const e of entries) { + if (oldest === undefined || e.cachedAt < oldest) { + oldest = e.cachedAt; + } + } + + return { + totalEntries: entries.length, + oldestEntryAt: oldest, + }; +} + +/** + * 清空全部多媒体缓存 + */ +export function clearAllMediaCache(): void { + try { + uni.removeStorageSync(STORAGE_CACHE_KEY); + } catch (e) {} +}