feat(round-11): 弱网离线队列/跨国会议一键预约/团队多语言名片批量向导/NFC硬件出厂封印 (83套件984测试全绿)

这个提交包含在:
wikiboss
2026-09-21 02:03:13 +08:00
父节点 f46b24a177
当前提交 76f0dc17c0
共有 22 个文件被更改,包括 1488 次插入 和 12 次删除

查看文件

@@ -0,0 +1,212 @@
import { Init, Provide } from '@midwayjs/core';
import { BaseService } from '@cool-midway/core';
import { InjectEntityModel } from '@midwayjs/typeorm';
import { Repository } from 'typeorm';
import { CardLocaleEntity } from '../entity/locale';
import { CardLocaleFields } from './locale';
export interface BatchLocaleMemberCard {
cardId: number;
baseFields: CardLocaleFields;
avatar?: string;
}
export interface BatchLocaleGenerateParams {
members: BatchLocaleMemberCard[];
targetLocales: string[];
localeTransformer?: (
locale: string,
base: CardLocaleFields
) => CardLocaleFields | Promise<CardLocaleFields>;
}
export interface BatchLocaleResultItem {
cardId: number;
locale: string;
success: boolean;
error?: string;
}
export interface BatchLocaleReport {
totalTasks: number;
successTasks: number;
failedTasks: number;
completionRate: number;
errors: Array<{ cardId: number; locale: string; message: string }>;
results: BatchLocaleResultItem[];
}
export interface LocaleCoverageItem {
locale: string;
coveredCount: number;
coverageRate: number;
missingCardIds: number[];
}
export interface TeamLocaleCoverageReport {
teamMemberCount: number;
locales: Record<string, LocaleCoverageItem>;
}
/**
* 校验多语言文案字段规范
*/
export function validateLocaleFields(fields: CardLocaleFields): {
valid: boolean;
errors: string[];
} {
const errors: string[] = [];
const name = fields?.name != null ? String(fields.name).trim() : '';
if (!name) {
errors.push('姓名不能为空');
} else if (name.length > 64) {
errors.push('姓名长度不能超过64字符');
}
if (fields?.title && String(fields.title).length > 128) {
errors.push('职位长度不能超过128字符');
}
if (fields?.company && String(fields.company).length > 128) {
errors.push('公司名称长度不能超过128字符');
}
if (fields?.slogan && String(fields.slogan).length > 255) {
errors.push('个人标语长度不能超过255字符');
}
return {
valid: errors.length === 0,
errors,
};
}
/**
* 出海团队多语言名片批量生成向导与多语种字段镜像校验服务
*/
@Provide()
export class CardBatchLocaleService extends BaseService {
@InjectEntityModel(CardLocaleEntity)
cardLocaleEntity: Repository<CardLocaleEntity>;
@Init()
async init() {
await super.init();
if (this.cardLocaleEntity) {
this.setEntity(this.cardLocaleEntity);
}
}
/**
* 批量为团队成员生成目标语言分支
*/
async batchGenerateMemberLocales(
params: BatchLocaleGenerateParams
): Promise<BatchLocaleReport> {
const { members, targetLocales, localeTransformer } = params;
const totalTasks = members.length * targetLocales.length;
let successTasks = 0;
let failedTasks = 0;
const errors: Array<{ cardId: number; locale: string; message: string }> = [];
const results: BatchLocaleResultItem[] = [];
for (const member of members) {
for (const locale of targetLocales) {
try {
const transformed = localeTransformer
? await localeTransformer(locale, member.baseFields)
: member.baseFields;
const validation = validateLocaleFields(transformed);
if (!validation.valid) {
throw new Error(validation.errors.join('; '));
}
let existing = await this.cardLocaleEntity.findOneBy({
cardId: member.cardId,
locale,
});
if (existing) {
existing.name = transformed.name;
existing.title = transformed.title;
existing.company = transformed.company;
existing.slogan = transformed.slogan;
await this.cardLocaleEntity.save(existing);
} else {
const created = new CardLocaleEntity();
created.cardId = member.cardId;
created.locale = locale;
created.name = transformed.name;
created.title = transformed.title;
created.company = transformed.company;
created.slogan = transformed.slogan;
await this.cardLocaleEntity.save(created);
}
successTasks++;
results.push({ cardId: member.cardId, locale, success: true });
} catch (err: any) {
failedTasks++;
const msg = err?.message || '生成失败';
errors.push({ cardId: member.cardId, locale, message: msg });
results.push({ cardId: member.cardId, locale, success: false, error: msg });
}
}
}
const completionRate = totalTasks > 0 ? Number((successTasks / totalTasks).toFixed(2)) : 1;
return {
totalTasks,
successTasks,
failedTasks,
completionRate,
errors,
results,
};
}
/**
* 诊断团队名片在各语言下的覆盖率与就绪度
*/
async inspectTeamLocaleCoverage(
cardIds: number[],
targetLocales: string[]
): Promise<TeamLocaleCoverageReport> {
const teamMemberCount = cardIds.length;
const locales: Record<string, LocaleCoverageItem> = {};
for (const loc of targetLocales) {
const missingCardIds: number[] = [];
let coveredCount = 0;
for (const cid of cardIds) {
const found = await this.cardLocaleEntity.findOneBy({
cardId: cid,
locale: loc,
});
if (found) {
coveredCount++;
} else {
missingCardIds.push(cid);
}
}
const rate = teamMemberCount > 0 ? Number((coveredCount / teamMemberCount).toFixed(2)) : 0;
locales[loc] = {
locale: loc,
coveredCount,
coverageRate: rate,
missingCardIds,
};
}
return {
teamMemberCount,
locales,
};
}
}

查看文件

@@ -239,6 +239,94 @@ export function parseIcsEvent(ics: string): Partial<MeetingEventOptions> {
return result;
}
/**
* 日历预约集合契约
*/
export interface CalendarInviteBundle {
googleUrl: string;
outlookUrl: string;
office365Url: string;
icsContent: string;
}
/**
* 生成 Google Calendar 一键预约链接
*/
export function generateGoogleCalendarUrl(meeting: MeetingEventOptions): string {
const summary = meeting.summary || meeting.title || '';
const dtStart = formatIcsDate(meeting.startTime || meeting.start || new Date());
const dtEnd = formatIcsDate(meeting.endTime || meeting.end || new Date());
let details = meeting.description || '';
if (meeting.organizer) {
const orgEmail = typeof meeting.organizer === 'string' ? meeting.organizer : meeting.organizer.email;
if (orgEmail) details += `\nOrganizer: ${orgEmail}`;
}
if (meeting.attendees && meeting.attendees.length > 0) {
details += `\nAttendees: ` + meeting.attendees.map(a => a.email).join(', ');
}
const params = new URLSearchParams({
action: 'TEMPLATE',
text: summary,
dates: `${dtStart}/${dtEnd}`,
details: details.trim(),
location: meeting.location || '',
});
return `https://calendar.google.com/calendar/render?${params.toString()}`;
}
/**
* 格式化 ISO 8601 日期字符串用于 Outlook
*/
function toIsoDate(date: Date | string | number): string {
const d = date instanceof Date ? date : new Date(date);
return d.toISOString();
}
/**
* 生成 Outlook Live (个人版) 一键预约链接
*/
export function generateOutlookCalendarUrl(meeting: MeetingEventOptions): string {
const summary = meeting.summary || meeting.title || '';
const startDt = toIsoDate(meeting.startTime || meeting.start || new Date());
const endDt = toIsoDate(meeting.endTime || meeting.end || new Date());
const params = new URLSearchParams({
path: '/calendar/action/compose',
rru: 'addevent',
subject: summary,
startdt: startDt,
enddt: endDt,
body: meeting.description || '',
location: meeting.location || '',
});
return `https://outlook.live.com/calendar/0/deeplink/compose?${params.toString()}`;
}
/**
* 生成 Office 365 (商业企业版) 一键预约链接
*/
export function generateOffice365CalendarUrl(meeting: MeetingEventOptions): string {
const summary = meeting.summary || meeting.title || '';
const startDt = toIsoDate(meeting.startTime || meeting.start || new Date());
const endDt = toIsoDate(meeting.endTime || meeting.end || new Date());
const params = new URLSearchParams({
path: '/calendar/action/compose',
rru: 'addevent',
subject: summary,
startdt: startDt,
enddt: endDt,
body: meeting.description || '',
location: meeting.location || '',
});
return `https://outlook.office.com/calendar/0/deeplink/compose?${params.toString()}`;
}
/**
* OmniCard 国际商务日程预约与 ICS 日历订阅服务
*/
@@ -285,4 +373,16 @@ export class CardCalendarService extends BaseService {
parseIcsEvent(ics: string): Partial<MeetingEventOptions> {
return parseIcsEvent(ics);
}
/**
* 综合生成 Google, Outlook, Office 365 链接与 RFC 5545 ICS 字符串包
*/
generateCalendarInviteLinks(meeting: MeetingEventOptions): CalendarInviteBundle {
return {
googleUrl: generateGoogleCalendarUrl(meeting),
outlookUrl: generateOutlookCalendarUrl(meeting),
office365Url: generateOffice365CalendarUrl(meeting),
icsContent: generateIcsEvent(meeting),
};
}
}

查看文件

@@ -90,7 +90,8 @@ export class NfcAntiTamperService {
cardId: number,
token: string,
secret: string,
maxAgeMs = DEFAULT_MAX_AGE_MS
maxAgeMs = DEFAULT_MAX_AGE_MS,
maxFutureDriftMs = 30000
): NfcVerifyResult {
// 1. 空参及参数合法性校验
if (
@@ -140,8 +141,8 @@ export class NfcAntiTamperService {
// 3. 时钟容差与过期校验
const now = Date.now();
if (ts - now > maxAgeMs) {
const reason = 'Token 时间戳异常(未来时钟偏差过大)';
if (ts - now > maxFutureDriftMs) {
const reason = 'Token 时间戳异常(未来时钟偏差过大 clock drift detected)';
this.recordAuditLog(uid, cardId, reason, token);
return { valid: false, reason };
}
@@ -238,3 +239,98 @@ export class NfcAntiTamperService {
this.usedTokens.clear();
}
}
/**
* NFC 卡片离线碰一碰数据包结构
*/
export interface OfflineCardPayload {
uid: string;
cardId: number;
batchNo: string;
seal: string;
token: string;
}
/**
* 生成 NFC 硬件物理出厂数字封印 (Offline Anti-Tamper Seal)
* 基于 UID + 批次号 + 工厂母密钥生成
*/
export function generateCardOfflineSeal(
uid: string,
batchNo: string,
factoryKey: string
): string {
if (!uid || !batchNo || !factoryKey) {
throw new Error('缺少生成硬件数字封印的必要参数');
}
const payload = `SEAL:${uid.trim()}:${batchNo.trim()}`;
return crypto
.createHmac('sha256', factoryKey)
.update(payload)
.digest('hex');
}
/**
* 恒定时间校验卡片出厂数字封印
*/
export function verifyCardOfflineSeal(
uid: string,
batchNo: string,
seal: string,
factoryKey: string
): boolean {
if (!uid || !batchNo || !seal || !factoryKey) {
return false;
}
const expected = generateCardOfflineSeal(uid, batchNo, factoryKey);
const bufA = Buffer.from(seal.toLowerCase(), 'hex');
const bufB = Buffer.from(expected.toLowerCase(), 'hex');
if (bufA.length !== bufB.length) return false;
return crypto.timingSafeEqual(bufA, bufB);
}
/**
* 离线快速核验碰一碰数据包 (无网络时手机端或离线设备快速验真)
*/
export function verifyOfflineCardPayload(
payload: OfflineCardPayload,
secret: string,
factoryKey: string
): { valid: boolean; isAuthenticNfc: boolean; reason?: string } {
// 1. 验证出厂硬件封印
const isSealValid = verifyCardOfflineSeal(
payload.uid,
payload.batchNo,
payload.seal,
factoryKey
);
if (!isSealValid) {
return {
valid: false,
isAuthenticNfc: false,
reason: '硬件出厂封印校验失败,疑似伪造假卡',
};
}
// 2. 校验防伪 Token
const service = new NfcAntiTamperService();
const tokenRes = service.verifyDeviceToken(
payload.uid,
payload.cardId,
payload.token,
secret
);
if (!tokenRes.valid) {
return {
valid: false,
isAuthenticNfc: false,
reason: tokenRes.reason,
};
}
return {
valid: true,
isAuthenticNfc: true,
};
}

查看文件

@@ -0,0 +1,77 @@
import {
CardCalendarService,
generateGoogleCalendarUrl,
generateOutlookCalendarUrl,
generateOffice365CalendarUrl,
MeetingEventOptions,
} from '../../src/modules/card/service/calendar';
describe('Point 42: 出海潜客跨国会议 Google Calendar / Outlook 一键预约同步', () => {
let calendarService: CardCalendarService;
const mockMeeting: MeetingEventOptions = {
summary: 'OmniCard Global Expansion Demo (Dubai GITEX)',
description: 'Discussing AI Business Cards & Regional CRM Deployment for Middle East.\nMeeting Link: https://meet.google.com/xyz-abc-def',
location: 'Dubai World Trade Centre, Hall 4 & Google Meet',
startTime: new Date(Date.UTC(2026, 9, 15, 10, 0, 0)),
endTime: new Date(Date.UTC(2026, 9, 15, 11, 0, 0)),
};
beforeEach(() => {
calendarService = new CardCalendarService();
});
describe('1. Google Calendar Web 预约链接生成 (generateGoogleCalendarUrl)', () => {
it('应生成符合 Google Calendar 规范的模板 URL', () => {
const url = generateGoogleCalendarUrl(mockMeeting);
expect(url).toContain('calendar.google.com/calendar/render?action=TEMPLATE');
expect(decodeURIComponent(url.replace(/\+/g, ' '))).toContain('text=OmniCard Global Expansion Demo (Dubai GITEX)');
expect(url).toContain('dates=20261015T100000Z%2F20261015T110000Z');
expect(decodeURIComponent(url.replace(/\+/g, ' '))).toContain('location=Dubai World Trade Centre');
expect(url).toContain('details=');
});
it('支持包含组织者与参会人 email 添加至 details 备注中', () => {
const meetingWithEmails: MeetingEventOptions = {
...mockMeeting,
organizer: { name: 'Sarah Lynn', email: 'sarah@omnicard.ai' },
attendees: [{ name: 'Ahmed', email: 'ahmed@emirates.ae' }],
};
const url = generateGoogleCalendarUrl(meetingWithEmails);
expect(decodeURIComponent(url)).toContain('sarah@omnicard.ai');
expect(decodeURIComponent(url)).toContain('ahmed@emirates.ae');
});
});
describe('2. Outlook / Office 365 预约链接生成', () => {
it('应生成 Outlook Live 深度一键日程链接', () => {
const url = generateOutlookCalendarUrl(mockMeeting);
expect(url).toContain('outlook.live.com/calendar/0/deeplink/compose');
expect(url).toContain('rru=addevent');
expect(decodeURIComponent(url.replace(/\+/g, ' '))).toContain('subject=OmniCard Global Expansion Demo (Dubai GITEX)');
expect(url).toContain('startdt=2026-10-15T10%3A00%3A00.000Z');
expect(url).toContain('enddt=2026-10-15T11%3A00%3A00.000Z');
});
it('应生成 Office 365 商业版企业日历链接', () => {
const url = generateOffice365CalendarUrl(mockMeeting);
expect(url).toContain('outlook.office.com/calendar/0/deeplink/compose');
expect(url).toContain('rru=addevent');
expect(url).toContain('subject=');
});
});
describe('3. CardCalendarService 综合生成日历集合包 (generateCalendarInviteLinks)', () => {
it('应统一返回 Google、Outlook、Office365 链接与标准 RFC 5545 ICS 字符串', () => {
const invite = calendarService.generateCalendarInviteLinks(mockMeeting);
expect(invite.googleUrl).toBeDefined();
expect(invite.outlookUrl).toBeDefined();
expect(invite.office365Url).toBeDefined();
expect(invite.icsContent).toContain('BEGIN:VCALENDAR');
expect(invite.icsContent).toContain('BEGIN:VEVENT');
expect(invite.icsContent).toContain('SUMMARY:OmniCard Global Expansion Demo');
expect(invite.icsContent).toContain('END:VCALENDAR');
});
});
});

查看文件

@@ -0,0 +1,191 @@
import {
CardBatchLocaleService,
BatchLocaleMemberCard,
validateLocaleFields,
} from '../../../src/modules/card/service/batch-locale';
import { CardLocaleEntity } from '../../../src/modules/card/entity/locale';
describe('Point 43: 出海团队多语言名片批量生成向导与多语种字段镜像校验', () => {
let service: CardBatchLocaleService;
let mockRepository: any;
let savedRecords: CardLocaleEntity[];
beforeEach(() => {
savedRecords = [];
mockRepository = {
findOneBy: jest.fn(async ({ cardId, locale }: any) => {
return savedRecords.find(r => r.cardId === cardId && r.locale === locale) || null;
}),
find: jest.fn(async ({ where }: any) => {
if (where && where.cardId) {
return savedRecords.filter(r => r.cardId === where.cardId);
}
return savedRecords;
}),
save: jest.fn(async (entity: any) => {
const existingIdx = savedRecords.findIndex(
r => r.cardId === entity.cardId && r.locale === entity.locale
);
if (existingIdx >= 0) {
savedRecords[existingIdx] = { ...savedRecords[existingIdx], ...entity };
return savedRecords[existingIdx];
}
const created = { id: savedRecords.length + 1, ...entity };
savedRecords.push(created);
return created;
}),
};
service = new CardBatchLocaleService();
service.cardLocaleEntity = mockRepository;
});
describe('1. validateLocaleFields (多语言字段规范与合法性校验)', () => {
it('正常字段应校验通过', () => {
const res = validateLocaleFields({
name: 'Sarah Chen',
title: 'VP of Global BD',
company: 'OmniCard AI',
slogan: 'Connect the world with intelligent cards',
});
expect(res.valid).toBe(true);
expect(res.errors).toHaveLength(0);
});
it('姓名为空或超长应精准拦截', () => {
const emptyRes = validateLocaleFields({ name: ' ' });
expect(emptyRes.valid).toBe(false);
expect(emptyRes.errors).toContain('姓名不能为空');
const longRes = validateLocaleFields({ name: 'A'.repeat(65) });
expect(longRes.valid).toBe(false);
expect(longRes.errors).toContain('姓名长度不能超过64字符');
});
it('职位、公司与标语超长时应拦截', () => {
const res = validateLocaleFields({
name: 'Test',
title: 'T'.repeat(129),
company: 'C'.repeat(129),
slogan: 'S'.repeat(256),
});
expect(res.valid).toBe(false);
expect(res.errors.length).toBeGreaterThanOrEqual(3);
});
});
describe('2. batchGenerateMemberLocales (团队成员多语言名片批量生成向导)', () => {
const mockMembers: BatchLocaleMemberCard[] = [
{
cardId: 101,
baseFields: {
name: '张三',
title: '出海销售总监',
company: '深圳数字智能出海科技',
slogan: '智能连接全球',
},
},
{
cardId: 102,
baseFields: {
name: '李四',
title: '中东区域代表',
company: '深圳数字智能出海科技',
slogan: '专注海湾六国',
},
},
];
it('应成功为团队成员批量生成指定目标语言分支 (en, ar)', async () => {
const result = await service.batchGenerateMemberLocales({
members: mockMembers,
targetLocales: ['en', 'ar'],
});
expect(result.totalTasks).toBe(4); // 2 members * 2 locales
expect(result.successTasks).toBe(4);
expect(result.failedTasks).toBe(0);
expect(result.completionRate).toBe(1.0);
expect(savedRecords.length).toBe(4);
// 验证保存的记录属性
const en101 = savedRecords.find(r => r.cardId === 101 && r.locale === 'en');
expect(en101).toBeDefined();
expect(en101?.name).toBe('张三');
const ar102 = savedRecords.find(r => r.cardId === 102 && r.locale === 'ar');
expect(ar102).toBeDefined();
expect(ar102?.name).toBe('李四');
});
it('支持传入语言自定义词典或转换映射处理器 (localeTransformer)', async () => {
const result = await service.batchGenerateMemberLocales({
members: [mockMembers[0]],
targetLocales: ['en'],
localeTransformer: (locale, base) => {
if (locale === 'en') {
return {
name: 'Alex Zhang',
title: 'Global Sales Director',
company: 'OmniCard Tech Co., Ltd.',
slogan: 'Connecting globally with AI cards',
};
}
return base;
},
});
expect(result.successTasks).toBe(1);
const enCard = savedRecords.find(r => r.cardId === 101 && r.locale === 'en');
expect(enCard?.name).toBe('Alex Zhang');
expect(enCard?.title).toBe('Global Sales Director');
});
it('遇到非法非法参数时应记录错误且不阻断其它任务', async () => {
const invalidMembers: BatchLocaleMemberCard[] = [
{
cardId: 999,
baseFields: { name: '' }, // 姓名为空导致失败
},
mockMembers[0],
];
const result = await service.batchGenerateMemberLocales({
members: invalidMembers,
targetLocales: ['en'],
});
expect(result.totalTasks).toBe(2);
expect(result.successTasks).toBe(1);
expect(result.failedTasks).toBe(1);
expect(result.errors.length).toBe(1);
expect(result.errors[0].cardId).toBe(999);
expect(result.completionRate).toBe(0.5);
});
});
describe('3. inspectTeamLocaleCoverage (出海团队语言覆盖率与就绪度诊断)', () => {
it('应精准统计团队名片各语言的覆盖率与缺失名单', async () => {
savedRecords.push(
{ id: 1, cardId: 101, locale: 'en', name: 'Alex' } as CardLocaleEntity,
{ id: 2, cardId: 101, locale: 'ar', name: 'Alex' } as CardLocaleEntity,
{ id: 3, cardId: 102, locale: 'en', name: 'John' } as CardLocaleEntity
);
const coverage = await service.inspectTeamLocaleCoverage(
[101, 102],
['en', 'ar', 'es']
);
expect(coverage.teamMemberCount).toBe(2);
expect(coverage.locales['en'].coverageRate).toBe(1.0); // 101, 102 都有 en
expect(coverage.locales['en'].missingCardIds).toHaveLength(0);
expect(coverage.locales['ar'].coverageRate).toBe(0.5); // 仅 101 有
expect(coverage.locales['ar'].missingCardIds).toEqual([102]);
expect(coverage.locales['es'].coverageRate).toBe(0.0); // 都没有
expect(coverage.locales['es'].missingCardIds).toEqual([101, 102]);
});
});
});

查看文件

@@ -0,0 +1,106 @@
import {
NfcAntiTamperService,
generateCardOfflineSeal,
verifyCardOfflineSeal,
OfflineCardPayload,
verifyOfflineCardPayload,
} from '../../../src/modules/nfc/service/anti-tamper';
describe('Point 44: NFC 实体卡离线数字防伪防篡改签章与硬件出厂封印', () => {
let service: NfcAntiTamperService;
const mockUid = '04A1B2C3D4E5F6';
const mockCardId = 2002;
const mockSecret = 'nfc-secret-key-gitex-2026-dubai-omnicard';
const mockFactoryKey = 'factory-master-key-secure-hardware-seal';
const mockBatchNo = 'BATCH-202609-UAE-001';
beforeEach(() => {
service = new NfcAntiTamperService();
});
describe('1. 硬件物理出厂封印 (generateCardOfflineSeal & verifyCardOfflineSeal)', () => {
it('应基于 UID、批次号与工厂母密钥生成不可逆 SHA-256 硬件封印哈希', () => {
const seal = generateCardOfflineSeal(mockUid, mockBatchNo, mockFactoryKey);
expect(typeof seal).toBe('string');
expect(seal).toHaveLength(64);
expect(/^[0-9a-f]{64}$/i.test(seal)).toBe(true);
// 验证封印通过
const isSealValid = verifyCardOfflineSeal(mockUid, mockBatchNo, seal, mockFactoryKey);
expect(isSealValid).toBe(true);
});
it('篡改 UID、批次号或封印哈希时应严密拒绝', () => {
const seal = generateCardOfflineSeal(mockUid, mockBatchNo, mockFactoryKey);
// 篡改 UID
expect(verifyCardOfflineSeal('04FFFFFFFFFFFF', mockBatchNo, seal, mockFactoryKey)).toBe(false);
// 篡改批次号
expect(verifyCardOfflineSeal(mockUid, 'BATCH-FAKED', seal, mockFactoryKey)).toBe(false);
// 篡改签名封印
expect(verifyCardOfflineSeal(mockUid, mockBatchNo, seal.replace('a', 'b'), mockFactoryKey)).toBe(false);
});
it('参数缺失时抛出明确保护异常', () => {
expect(() => generateCardOfflineSeal('', mockBatchNo, mockFactoryKey)).toThrow();
expect(() => generateCardOfflineSeal(mockUid, '', mockFactoryKey)).toThrow();
expect(() => generateCardOfflineSeal(mockUid, mockBatchNo, '')).toThrow();
});
});
describe('2. 离线碰一碰数据包快速验签 (verifyOfflineCardPayload)', () => {
it('合法离线数据包在无网络状态下应成功通过验签', () => {
const ts = Date.now();
const token = service.generateDeviceToken(mockUid, mockCardId, mockSecret, ts);
const seal = generateCardOfflineSeal(mockUid, mockBatchNo, mockFactoryKey);
const payload: OfflineCardPayload = {
uid: mockUid,
cardId: mockCardId,
batchNo: mockBatchNo,
seal,
token,
};
const result = verifyOfflineCardPayload(payload, mockSecret, mockFactoryKey);
expect(result.valid).toBe(true);
expect(result.isAuthenticNfc).toBe(true);
expect(result.reason).toBeUndefined();
});
it('离线防伪 Token 伪造时应准确识破', () => {
const seal = generateCardOfflineSeal(mockUid, mockBatchNo, mockFactoryKey);
const fakePayload: OfflineCardPayload = {
uid: mockUid,
cardId: mockCardId,
batchNo: mockBatchNo,
seal,
token: `${Date.now()}.0000000000000000000000000000000000000000000000000000000000000000`,
};
const result = verifyOfflineCardPayload(fakePayload, mockSecret, mockFactoryKey);
expect(result.valid).toBe(false);
expect(result.reason).toMatch(/签名|篡改|invalid/i);
});
});
describe('3. 时钟漂移与时序攻击防护 (Clock Drift Tolerance)', () => {
it('在合理漂移容差内 (30秒) 允许通过', () => {
const futureTs = Date.now() + 15 * 1000; // 稍超前 15 秒 (网络或卡内时钟微偏)
const token = service.generateDeviceToken(mockUid, mockCardId, mockSecret, futureTs);
// 设置 30 秒容差
const res = service.verifyDeviceToken(mockUid, mockCardId, token, mockSecret, 300000, 30000);
expect(res.valid).toBe(true);
});
it('严重超前于未来时间戳的令牌应被坚决拦截', () => {
const futureTs = Date.now() + 120 * 1000; // 超前 2 分钟
const token = service.generateDeviceToken(mockUid, mockCardId, mockSecret, futureTs);
const res = service.verifyDeviceToken(mockUid, mockCardId, token, mockSecret, 300000, 30000);
expect(res.valid).toBe(false);
expect(res.reason).toMatch(/未来|clock drift/i);
});
});
});

查看文件

@@ -0,0 +1,193 @@
/**
* OmniCard AI (CRMmp) - 用户端离线缓存与断网重放队列单元测试
* 严格遵循 TDD 测试驱动规范与瑞士国际主义极简原则
*/
describe('Point 41: 移动端弱网离线视觉指示器与断网重放队列 (Offline Queue)', () => {
let storageMap: Record<string, string>;
let mod: any;
beforeEach(() => {
storageMap = {};
// 模拟全局 uni-app storage 与网络 API
(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];
}),
onNetworkStatusChange: jest.fn(),
};
// @ts-ignore
mod = require('../../../mpuniapp/common/offline-queue.uts');
mod.clearOfflineQueue();
mod.setNetworkStatus(true, 'wifi');
});
afterEach(() => {
delete (global as any).uni;
});
describe('1. 网络在线/断网状态检测与切换', () => {
it('默认初始状态应为在线 (isOnline = true)', () => {
expect(mod.isOnline()).toBe(true);
const status = mod.getOfflineQueueStatus();
expect(status.isOnline).toBe(true);
expect(status.networkType).toBe('wifi');
});
it('断网时状态切换为离线并输出正确的国际化横幅文案', () => {
mod.setNetworkStatus(false, 'none');
expect(mod.isOnline()).toBe(false);
const status = mod.getOfflineQueueStatus();
expect(status.isOnline).toBe(false);
expect(status.networkType).toBe('none');
// 中文与英文横幅文案
const zhBanner = mod.formatOfflineBannerText('zh', 2);
expect(zhBanner).toContain('网络已断开');
expect(zhBanner).toContain('2 条暂存待同步');
const enBanner = mod.formatOfflineBannerText('en', 3);
expect(enBanner).toContain('Offline');
expect(enBanner).toContain('3 pending');
const arBanner = mod.formatOfflineBannerText('ar', 1);
expect(arBanner).toContain('غير متصل');
});
});
describe('2. 离线动作暂存入队 (enqueueOfflineAction)', () => {
it('应能正确保存离线留资或轨迹提交动作至本地队列', () => {
const action = {
id: 'action-001',
type: 'LEAD_SUBMIT',
url: '/card/lead/submit',
method: 'POST',
data: { name: 'Faisal Al-Otaibi', email: 'faisal@sauditech.sa' },
timestamp: Date.now(),
};
const count = mod.enqueueOfflineAction(action);
expect(count).toBe(1);
const queue = mod.getOfflineQueue();
expect(queue.length).toBe(1);
expect(queue[0].id).toBe('action-001');
expect(queue[0].type).toBe('LEAD_SUBMIT');
expect(queue[0].retryCount).toBe(0);
});
it('支持多次入队并保持先进先出 (FIFO) 顺序', () => {
mod.enqueueOfflineAction({
id: 'act-1',
type: 'RADAR_TRACK',
url: '/crm/radar/track',
timestamp: 1000,
});
mod.enqueueOfflineAction({
id: 'act-2',
type: 'LEAD_SUBMIT',
url: '/crm/lead/create',
timestamp: 2000,
});
const queue = mod.getOfflineQueue();
expect(queue.length).toBe(2);
expect(queue[0].id).toBe('act-1');
expect(queue[1].id).toBe('act-2');
});
it('自动生成缺失的 action.id 与 timestamp', () => {
mod.enqueueOfflineAction({
type: 'RADAR_TRACK',
url: '/crm/radar/ping',
});
const queue = mod.getOfflineQueue();
expect(queue.length).toBe(1);
expect(queue[0].id).toBeDefined();
expect(queue[0].id.length).toBeGreaterThan(0);
expect(queue[0].timestamp).toBeGreaterThan(0);
});
});
describe('3. 网络恢复自动重放队列 (replayOfflineQueue)', () => {
it('成功执行所有队列动作后应自动清空队列', async () => {
mod.enqueueOfflineAction({
id: 'act-success-1',
type: 'LEAD_SUBMIT',
url: '/card/lead/submit',
data: { test: 1 },
});
mod.enqueueOfflineAction({
id: 'act-success-2',
type: 'RADAR_TRACK',
url: '/crm/radar/track',
data: { test: 2 },
});
expect(mod.getOfflineQueue().length).toBe(2);
const executedIds: string[] = [];
const result = await mod.replayOfflineQueue(async (item: any) => {
executedIds.push(item.id);
return true;
});
expect(result.total).toBe(2);
expect(result.success).toBe(2);
expect(result.failed).toBe(0);
expect(executedIds).toEqual(['act-success-1', 'act-success-2']);
expect(mod.getOfflineQueue().length).toBe(0);
});
it('重放部分失败时应增加重试次数并保留失败项在队列中', async () => {
mod.enqueueOfflineAction({
id: 'act-ok',
type: 'LEAD_SUBMIT',
url: '/api/ok',
});
mod.enqueueOfflineAction({
id: 'act-fail',
type: 'LEAD_SUBMIT',
url: '/api/fail',
});
const result = await mod.replayOfflineQueue(async (item: any) => {
if (item.id === 'act-fail') {
return false;
}
return true;
});
expect(result.total).toBe(2);
expect(result.success).toBe(1);
expect(result.failed).toBe(1);
const remaining = mod.getOfflineQueue();
expect(remaining.length).toBe(1);
expect(remaining[0].id).toBe('act-fail');
expect(remaining[0].retryCount).toBe(1);
});
it('超过最大重试次数 (默认 3 次) 时应安全移除死信项,避免永久阻塞', async () => {
mod.enqueueOfflineAction({
id: 'act-dead',
type: 'DEAD_LETTER',
url: '/api/dead',
retryCount: 3, // 已达上限
});
const result = await mod.replayOfflineQueue(async () => false, 3);
expect(result.total).toBe(1);
expect(result.failed).toBe(1);
expect(mod.getOfflineQueue().length).toBe(0); // 超限后被淘汰
});
});
});