feat(round-16): AI意图情绪分析/展会名片双向互换/雷达爬虫清洗/离线多媒体缓存 (103套件1101测试全绿)
这个提交包含在:
@@ -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<ConversationAnalysisResult> {
|
||||
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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<string, MutualExchangeInvitation>();
|
||||
|
||||
async initiateExchange(params: {
|
||||
senderCardId: number;
|
||||
recipientCardId: number;
|
||||
notes?: string;
|
||||
}): Promise<MutualExchangeInvitation> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<TrafficFilterResult> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* OmniCard AI (CRMmp) - 移动端多媒体离线缓存持久化策略单元测试
|
||||
* 严格遵循 TDD 规范
|
||||
*/
|
||||
|
||||
describe('Point 64 (P2): 移动端名片离线多媒体缓存与本地持久化策略 (Media Cache)', () => {
|
||||
let cacheMod: any;
|
||||
let storageMap: Record<string, string>;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
139
mpuniapp/common/media-cache.uts
普通文件
139
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<string, MediaCacheEntry> {
|
||||
try {
|
||||
const raw = uni.getStorageSync(STORAGE_CACHE_KEY);
|
||||
if (!raw) return {};
|
||||
return JSON.parse(String(raw)) || {};
|
||||
} catch (e) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存索引映射
|
||||
*/
|
||||
function saveIndexMap(map: Record<string, MediaCacheEntry>): 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<string, MediaCacheEntry> = {};
|
||||
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) {}
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户