feat(round-11): 弱网离线队列/跨国会议一键预约/团队多语言名片批量向导/NFC硬件出厂封印 (83套件984测试全绿)
这个提交包含在:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
在新工单中引用
屏蔽一个用户