feat: 完成全系统30个功能点开发与E2E全链路验收 (8轮调度32个子代理闭环)
- 模块一: 出海案例作品集、博客动态发布、人名发音语音条、RTL阿语多语言切换 - 模块二: AI顾问人设配置、行业术语库润色、RAG切片沙盒调试、流式会话与意向留资卡片 - 模块三: 全球访客雷达大屏、多维加权意向算分、外部CRM对接推送、团队公海池流转 - 模块四: 移动端NFC安全挂失、NFC动态路由规则配置、动态防伪验签、展会制卡Zip批量导出 - 模块五: 组织架构与名片分配、移动端个人中心设置、离职交接与历史线索迁移、安全审计日志 - 模块六: Stripe国际信用卡订阅Webhook、独立二级域名与SSL检测、坐席配额预警、多币种发票 - 模块七: 纸质名片OCR解析入库、国际商务日程RFC 5545 ICS日历、微官网离线SWR预加载、展会实时排行榜大屏 - 模块八: 全球出海SEO OpenGraph元标签注入与社交爬虫探测、30点端到端全链路业务流集成测试套件
这个提交包含在:
@@ -0,0 +1,14 @@
|
||||
import { ModuleConfig } from '@cool-midway/core';
|
||||
|
||||
/**
|
||||
* NFC 实体卡硬件生命周期模块配置
|
||||
*/
|
||||
export default () => {
|
||||
return {
|
||||
name: 'NFC 硬件舰队模块',
|
||||
description: '实体卡设备台账、绑定激活、场景路由与挂失冻结',
|
||||
middlewares: [],
|
||||
globalMiddlewares: [],
|
||||
order: 30,
|
||||
} as ModuleConfig;
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import { CoolController, BaseController } from '@cool-midway/core';
|
||||
import { NfcBindService } from '../../service/bind';
|
||||
|
||||
/**
|
||||
* 后台 NFC 绑定激活标准配置(不开 CRUD)
|
||||
*/
|
||||
export const adminNfcBindCurdOption = {
|
||||
api: [] as Array<
|
||||
'add' | 'delete' | 'update' | 'info' | 'list' | 'page'
|
||||
>,
|
||||
service: NfcBindService,
|
||||
serviceApis: [
|
||||
{ method: 'bind', summary: '绑定名片并激活设备' },
|
||||
{ method: 'activate', summary: '激活已绑定名片的设备' },
|
||||
{ method: 'switchCard', summary: '更换已激活设备绑定的名片' },
|
||||
{ method: 'unbind', summary: '解绑设备名片' },
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* NFC 模块-后台绑定激活
|
||||
* 路由前缀:/admin/nfc/bind
|
||||
*/
|
||||
@CoolController(adminNfcBindCurdOption)
|
||||
export class AdminNfcBindController extends BaseController {}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Body, Inject, Post } from '@midwayjs/core';
|
||||
import { Context } from '@midwayjs/koa';
|
||||
import { CoolController, BaseController } from '@cool-midway/core';
|
||||
import { NfcDeviceEntity } from '../../entity/device';
|
||||
import { NfcDeviceService } from '../../service/device';
|
||||
import {
|
||||
NfcBatchExportDeviceItem,
|
||||
NfcBatchExportService,
|
||||
} from '../../service/batch-export';
|
||||
|
||||
/**
|
||||
* 后台 NFC 设备台账标准 CRUD 配置
|
||||
*/
|
||||
export const adminNfcDeviceCurdOption = {
|
||||
api: ['add', 'delete', 'update', 'info', 'list', 'page'] as Array<
|
||||
'add' | 'delete' | 'update' | 'info' | 'list' | 'page'
|
||||
>,
|
||||
entity: NfcDeviceEntity,
|
||||
service: NfcDeviceService,
|
||||
pageQueryOp: {
|
||||
keyWordLikeFields: ['a.uid', 'a.batchNo'],
|
||||
fieldEq: ['a.status', 'a.material', 'a.cardId', 'a.sceneMode'],
|
||||
addOrderBy: { updateTime: 'desc' as const },
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* NFC 模块-后台设备台账
|
||||
* 路由前缀:/admin/nfc/device
|
||||
*/
|
||||
@CoolController(adminNfcDeviceCurdOption)
|
||||
export class AdminNfcDeviceController extends BaseController {
|
||||
@Inject()
|
||||
ctx: Context;
|
||||
|
||||
@Inject()
|
||||
nfcBatchExportService: NfcBatchExportService;
|
||||
|
||||
/**
|
||||
* 展会批量制卡二维码与离线数据包 Zip 导出
|
||||
*/
|
||||
@Post('/batch-export-zip', {
|
||||
summary: '展会批量制卡二维码与离线数据包 Zip 导出',
|
||||
})
|
||||
async batchExportZip(
|
||||
@Body('batchNo') batchNo: string,
|
||||
@Body('devices') devices: NfcBatchExportDeviceItem[]
|
||||
) {
|
||||
const { zipBuffer, totalCount } =
|
||||
await this.nfcBatchExportService.generateBatchPack(batchNo, devices);
|
||||
|
||||
this.ctx.set('Content-Type', 'application/zip');
|
||||
this.ctx.set(
|
||||
'Content-Disposition',
|
||||
`attachment; filename="${encodeURIComponent(batchNo)}.zip"`
|
||||
);
|
||||
this.ctx.set('X-Total-Count', String(totalCount));
|
||||
this.ctx.body = zipBuffer;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { CoolController, BaseController } from '@cool-midway/core';
|
||||
import { NfcRuleEntity } from '../../entity/rule';
|
||||
import { NfcRuleService } from '../../service/rule';
|
||||
|
||||
/**
|
||||
* 后台 NFC 场景路由规则标准 CRUD 配置
|
||||
*/
|
||||
export const adminNfcRuleCurdOption = {
|
||||
api: ['add', 'delete', 'update', 'info', 'list', 'page'] as Array<
|
||||
'add' | 'delete' | 'update' | 'info' | 'list' | 'page'
|
||||
>,
|
||||
entity: NfcRuleEntity,
|
||||
service: NfcRuleService,
|
||||
pageQueryOp: {
|
||||
fieldEq: ['a.deviceId', 'a.enabled', 'a.sceneMode', 'a.countryCode'],
|
||||
},
|
||||
serviceApis: [
|
||||
{ method: 'resolveScene', summary: '解析当前触卡场景路由' },
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* NFC 模块-后台场景路由规则
|
||||
* 路由前缀:/admin/nfc/rule
|
||||
*/
|
||||
@CoolController(adminNfcRuleCurdOption)
|
||||
export class AdminNfcRuleController extends BaseController {}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { CoolController, BaseController } from '@cool-midway/core';
|
||||
import { NfcDeviceEntity } from '../../entity/device';
|
||||
import { NfcSecurityService } from '../../service/security';
|
||||
|
||||
/**
|
||||
* 后台 NFC 挂失冻结配置(不开标准 CRUD)
|
||||
*/
|
||||
export const adminNfcSecurityCurdOption = {
|
||||
api: [] as Array<
|
||||
'add' | 'delete' | 'update' | 'info' | 'list' | 'page'
|
||||
>,
|
||||
entity: NfcDeviceEntity,
|
||||
service: NfcSecurityService,
|
||||
serviceApis: [
|
||||
{ method: 'freeze', summary: '一键挂失冻结' },
|
||||
{ method: 'unfreeze', summary: '解冻恢复' },
|
||||
{ method: 'reset', summary: '注销重置' },
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* NFC 模块-后台挂失冻结
|
||||
* 路由前缀:/admin/nfc/security
|
||||
*/
|
||||
@CoolController(adminNfcSecurityCurdOption)
|
||||
export class AdminNfcSecurityController extends BaseController {}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { BaseEntity } from '../../../comm/entity/base';
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
import {
|
||||
NFCMaterial,
|
||||
NFCRecord,
|
||||
NFCSceneMode,
|
||||
NFCStatus,
|
||||
} from '../../../comm/types/omni';
|
||||
|
||||
/**
|
||||
* NFC 实体卡设备台账
|
||||
* 表名: nfc_device
|
||||
*/
|
||||
@Entity('nfc_device')
|
||||
export class NfcDeviceEntity extends BaseEntity implements NFCRecord {
|
||||
@Index({ unique: true })
|
||||
@Column({
|
||||
name: 'uid',
|
||||
comment: '硬件唯一识别芯片 UID',
|
||||
length: 64,
|
||||
})
|
||||
uid: string;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
name: 'card_id',
|
||||
comment: '当前绑定的名片 ID',
|
||||
type: 'bigint',
|
||||
nullable: true,
|
||||
})
|
||||
cardId?: number | null;
|
||||
|
||||
@Column({
|
||||
name: 'batch_no',
|
||||
comment: '硬件制造/下发批次号',
|
||||
length: 64,
|
||||
})
|
||||
batchNo: string;
|
||||
|
||||
@Column({
|
||||
name: 'material',
|
||||
comment: '物理材质 wood/metal/pvc',
|
||||
length: 16,
|
||||
default: 'pvc',
|
||||
})
|
||||
material: NFCMaterial;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
name: 'status',
|
||||
comment: '设备状态 active/suspended/unbound',
|
||||
length: 16,
|
||||
default: 'unbound',
|
||||
})
|
||||
status: NFCStatus;
|
||||
|
||||
@Column({
|
||||
name: 'scene_mode',
|
||||
comment: '场景模式 business/social',
|
||||
length: 16,
|
||||
default: 'business',
|
||||
})
|
||||
sceneMode: NFCSceneMode;
|
||||
|
||||
@Column({
|
||||
name: 'last_scanned_at',
|
||||
comment: '最后一次被触碰扫描时间',
|
||||
type: 'varchar',
|
||||
length: 32,
|
||||
nullable: true,
|
||||
})
|
||||
lastScannedAt?: string | Date | null;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { BaseEntity, transformerJson } from '../../../comm/entity/base';
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
import { NFCSceneMode, NFCSceneRule } from '../../../comm/types/omni';
|
||||
|
||||
/**
|
||||
* NFC 场景路由规则
|
||||
* 表名: nfc_rule
|
||||
* deviceId 为空时对租户下全部设备生效
|
||||
*/
|
||||
@Entity('nfc_rule')
|
||||
export class NfcRuleEntity extends BaseEntity implements NFCSceneRule {
|
||||
@Index()
|
||||
@Column({
|
||||
name: 'device_id',
|
||||
comment: '绑定设备;空则对租户下全部设备生效',
|
||||
type: 'bigint',
|
||||
nullable: true,
|
||||
})
|
||||
deviceId?: number | null;
|
||||
|
||||
@Column({
|
||||
name: 'name',
|
||||
comment: '规则名称',
|
||||
length: 100,
|
||||
})
|
||||
name: string;
|
||||
|
||||
@Column({
|
||||
name: 'scene_mode',
|
||||
comment: '命中后的场景模式 business/social',
|
||||
length: 16,
|
||||
default: 'business',
|
||||
})
|
||||
sceneMode: NFCSceneMode;
|
||||
|
||||
@Column({
|
||||
name: 'target_card_id',
|
||||
comment: '命中后导向的名片 ID',
|
||||
type: 'bigint',
|
||||
})
|
||||
targetCardId: number;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
name: 'enabled',
|
||||
comment: '是否启用',
|
||||
default: true,
|
||||
})
|
||||
enabled: boolean;
|
||||
|
||||
@Column({
|
||||
name: 'start_time',
|
||||
comment: '生效开始时刻 HH:mm',
|
||||
type: 'varchar',
|
||||
length: 8,
|
||||
nullable: true,
|
||||
})
|
||||
startTime?: string | null;
|
||||
|
||||
@Column({
|
||||
name: 'end_time',
|
||||
comment: '生效结束时刻 HH:mm',
|
||||
type: 'varchar',
|
||||
length: 8,
|
||||
nullable: true,
|
||||
})
|
||||
endTime?: string | null;
|
||||
|
||||
@Column({
|
||||
name: 'weekdays',
|
||||
comment: '生效星期 0-6(周日=0)',
|
||||
type: 'json',
|
||||
nullable: true,
|
||||
transformer: transformerJson,
|
||||
})
|
||||
weekdays?: number[] | null;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
name: 'country_code',
|
||||
comment: '地理国家码 ISO 3166-1 alpha-2',
|
||||
length: 8,
|
||||
nullable: true,
|
||||
})
|
||||
countryCode?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import { Provide } from '@midwayjs/core';
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
/**
|
||||
* 安全审计日志结构
|
||||
*/
|
||||
export interface NfcSecurityAuditLog {
|
||||
timestamp: number;
|
||||
uid: string;
|
||||
cardId: number;
|
||||
reason: string;
|
||||
token?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验签返回结果契约
|
||||
*/
|
||||
export interface NfcVerifyResult {
|
||||
valid: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认有效时钟窗口:5 分钟 (300,000 毫秒)
|
||||
*/
|
||||
const DEFAULT_MAX_AGE_MS = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* 实体 NFC 卡片动态防伪验签服务
|
||||
* 职责:
|
||||
* 1. HMAC-SHA256 动态验签 Token 生成
|
||||
* 2. 恒定时间防篡改校验 (防时序侧信道攻击)
|
||||
* 3. 动态时间戳时钟偏差与超时拦截
|
||||
* 4. 防重放拦截 (Replay Protection)
|
||||
* 5. 恶意行为安全审计日志留痕
|
||||
*/
|
||||
@Provide()
|
||||
export class NfcAntiTamperService {
|
||||
/**
|
||||
* 已消费 Token 缓存,防止重放攻击 (token -> 过期时间戳)
|
||||
*/
|
||||
private readonly usedTokens = new Map<string, number>();
|
||||
|
||||
/**
|
||||
* 安全审计日志队列 (内存循环留痕,上限 1000 条)
|
||||
*/
|
||||
private readonly auditLogs: NfcSecurityAuditLog[] = [];
|
||||
private readonly maxAuditLogSize = 1000;
|
||||
|
||||
/**
|
||||
* 生成设备动态防伪验签 Token
|
||||
* 格式: `${timestamp}.${hmac_sha256_hex}`
|
||||
*/
|
||||
generateDeviceToken(
|
||||
uid: string,
|
||||
cardId: number,
|
||||
secret: string,
|
||||
timestamp?: number
|
||||
): string {
|
||||
if (!uid || typeof uid !== 'string' || !uid.trim()) {
|
||||
throw new Error('缺少必要参数: uid');
|
||||
}
|
||||
if (
|
||||
cardId == null ||
|
||||
typeof cardId !== 'number' ||
|
||||
cardId <= 0 ||
|
||||
!Number.isFinite(cardId)
|
||||
) {
|
||||
throw new Error('缺少必要参数: cardId 必须为合法正整数');
|
||||
}
|
||||
if (!secret || typeof secret !== 'string') {
|
||||
throw new Error('缺少必要参数: secret');
|
||||
}
|
||||
|
||||
const ts = timestamp ?? Date.now();
|
||||
const payload = `${uid.trim()}:${cardId}:${ts}`;
|
||||
const signature = crypto
|
||||
.createHmac('sha256', secret)
|
||||
.update(payload)
|
||||
.digest('hex');
|
||||
|
||||
return `${ts}.${signature}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验设备动态防伪验签 Token
|
||||
*/
|
||||
verifyDeviceToken(
|
||||
uid: string,
|
||||
cardId: number,
|
||||
token: string,
|
||||
secret: string,
|
||||
maxAgeMs = DEFAULT_MAX_AGE_MS
|
||||
): NfcVerifyResult {
|
||||
// 1. 空参及参数合法性校验
|
||||
if (
|
||||
!uid ||
|
||||
typeof uid !== 'string' ||
|
||||
!uid.trim() ||
|
||||
cardId == null ||
|
||||
typeof cardId !== 'number' ||
|
||||
cardId <= 0 ||
|
||||
!Number.isFinite(cardId) ||
|
||||
!token ||
|
||||
typeof token !== 'string' ||
|
||||
!secret ||
|
||||
typeof secret !== 'string'
|
||||
) {
|
||||
const reason = '缺少必要参数或参数不合法';
|
||||
this.recordAuditLog(
|
||||
typeof uid === 'string' ? uid : '',
|
||||
typeof cardId === 'number' ? cardId : 0,
|
||||
reason,
|
||||
typeof token === 'string' ? token : undefined
|
||||
);
|
||||
return { valid: false, reason };
|
||||
}
|
||||
|
||||
// 2. Token 结构与格式解析
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 2) {
|
||||
const reason = 'Token 格式无效,缺少必要段落';
|
||||
this.recordAuditLog(uid, cardId, reason, token);
|
||||
return { valid: false, reason };
|
||||
}
|
||||
|
||||
const [tsStr, signature] = parts;
|
||||
const ts = Number(tsStr);
|
||||
if (!Number.isSafeInteger(ts) || ts <= 0) {
|
||||
const reason = 'Token 格式无效,时间戳非法';
|
||||
this.recordAuditLog(uid, cardId, reason, token);
|
||||
return { valid: false, reason };
|
||||
}
|
||||
|
||||
if (!/^[0-9a-f]{64}$/i.test(signature)) {
|
||||
const reason = 'Token 格式无效,签名非有效 SHA256 十六进制串';
|
||||
this.recordAuditLog(uid, cardId, reason, token);
|
||||
return { valid: false, reason };
|
||||
}
|
||||
|
||||
// 3. 时钟容差与过期校验
|
||||
const now = Date.now();
|
||||
if (ts - now > maxAgeMs) {
|
||||
const reason = 'Token 时间戳异常(未来时钟偏差过大)';
|
||||
this.recordAuditLog(uid, cardId, reason, token);
|
||||
return { valid: false, reason };
|
||||
}
|
||||
|
||||
if (now - ts > maxAgeMs) {
|
||||
const reason = 'Token 已过期 (Token has expired)';
|
||||
this.recordAuditLog(uid, cardId, reason, token);
|
||||
return { valid: false, reason };
|
||||
}
|
||||
|
||||
// 4. 防重放校验 (已消费令牌不可二次验签)
|
||||
this.cleanExpiredTokens(now);
|
||||
if (this.usedTokens.has(token)) {
|
||||
const reason = 'Token 已被使用,拦截重放攻击 (Replay attack detected)';
|
||||
this.recordAuditLog(uid, cardId, reason, token);
|
||||
return { valid: false, reason };
|
||||
}
|
||||
|
||||
// 5. 预期签名比对 (防参数与密钥篡改,恒定时间比对)
|
||||
const expectedPayload = `${uid.trim()}:${cardId}:${ts}`;
|
||||
const expectedSig = crypto
|
||||
.createHmac('sha256', secret)
|
||||
.update(expectedPayload)
|
||||
.digest('hex');
|
||||
|
||||
const sigBuf = Buffer.from(signature.toLowerCase(), 'hex');
|
||||
const expectedSigBuf = Buffer.from(expectedSig.toLowerCase(), 'hex');
|
||||
|
||||
if (
|
||||
sigBuf.length !== expectedSigBuf.length ||
|
||||
!crypto.timingSafeEqual(sigBuf, expectedSigBuf)
|
||||
) {
|
||||
const reason = '签名无效,检测到恶意参数篡改 (Invalid signature / Tampered)';
|
||||
this.recordAuditLog(uid, cardId, reason, token);
|
||||
return { valid: false, reason };
|
||||
}
|
||||
|
||||
// 6. 验签通过,标记 Token 已消费 (有效期至 ts + maxAgeMs)
|
||||
this.usedTokens.set(token, ts + maxAgeMs);
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录安全审计日志
|
||||
*/
|
||||
private recordAuditLog(
|
||||
uid: string,
|
||||
cardId: number,
|
||||
reason: string,
|
||||
token?: string
|
||||
): void {
|
||||
const log: NfcSecurityAuditLog = {
|
||||
timestamp: Date.now(),
|
||||
uid,
|
||||
cardId,
|
||||
reason,
|
||||
token,
|
||||
};
|
||||
if (this.auditLogs.length >= this.maxAuditLogSize) {
|
||||
this.auditLogs.shift();
|
||||
}
|
||||
this.auditLogs.push(log);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理已超期的防重放缓存
|
||||
*/
|
||||
private cleanExpiredTokens(now: number): void {
|
||||
for (const [t, expireTime] of this.usedTokens.entries()) {
|
||||
if (expireTime <= now) {
|
||||
this.usedTokens.delete(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取安全审计日志列表
|
||||
*/
|
||||
getAuditLogs(): NfcSecurityAuditLog[] {
|
||||
return [...this.auditLogs];
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空安全审计日志 (供单测/运维重置使用)
|
||||
*/
|
||||
clearAuditLogs(): void {
|
||||
this.auditLogs.length = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空防重放缓存 (供单测/维护使用)
|
||||
*/
|
||||
clearUsedTokens(): void {
|
||||
this.usedTokens.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import * as crypto from 'crypto';
|
||||
import { Provide } from '@midwayjs/core';
|
||||
import { BaseService, CoolCommException } from '@cool-midway/core';
|
||||
import * as QRCode from 'qrcode';
|
||||
import { buildCardPublicUrl } from '../../../comm/qrcode/qrcode';
|
||||
|
||||
const AdmZip = require('adm-zip');
|
||||
|
||||
/**
|
||||
* 离线数据包清单文件名常量
|
||||
*/
|
||||
export const BATCH_EXPORT_MANIFEST_FILE = 'manifest.csv';
|
||||
export const BATCH_EXPORT_QR_DIR = 'qrcodes';
|
||||
|
||||
/**
|
||||
* 单张设备批次制卡入参
|
||||
*/
|
||||
export interface NfcBatchExportDeviceItem {
|
||||
uid: string;
|
||||
slug: string;
|
||||
cardTitle: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验批次号命名规范
|
||||
*/
|
||||
export function validateBatchNo(batchNo: string): string {
|
||||
if (!batchNo || !batchNo.trim()) {
|
||||
throw new CoolCommException('批次号不能为空');
|
||||
}
|
||||
const clean = batchNo.trim();
|
||||
const regex = /^[a-zA-Z0-9_-]{2,64}$/;
|
||||
if (!regex.test(clean)) {
|
||||
throw new CoolCommException(
|
||||
'批次号命名不规范(仅支持英文字母、数字、下划线及中划线,长度2-64位)'
|
||||
);
|
||||
}
|
||||
return clean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成出厂防伪码(确定性 16 位高强度大写十六进制签名)
|
||||
*/
|
||||
export function generateSecurityCode(batchNo: string, uid: string): string {
|
||||
return crypto
|
||||
.createHmac('sha256', 'omnicard-nfc-salt')
|
||||
.update(`${batchNo}:${uid}`)
|
||||
.digest('hex')
|
||||
.slice(0, 16)
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* CSV 单元格转义
|
||||
*/
|
||||
function escapeCsv(val: string): string {
|
||||
const s = String(val ?? '');
|
||||
if (
|
||||
s.includes(',') ||
|
||||
s.includes('"') ||
|
||||
s.includes('\n') ||
|
||||
s.includes('\r')
|
||||
) {
|
||||
return `"${s.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* NFC 展会批量制卡与离线数据包导出服务
|
||||
*/
|
||||
@Provide()
|
||||
export class NfcBatchExportService extends BaseService {
|
||||
/**
|
||||
* 生成展会批量制卡二维码离线数据包 ZIP Buffer
|
||||
*/
|
||||
async generateBatchPack(
|
||||
batchNo: string,
|
||||
devices: NfcBatchExportDeviceItem[]
|
||||
): Promise<{ zipBuffer: Buffer; totalCount: number }> {
|
||||
const cleanBatchNo = validateBatchNo(batchNo);
|
||||
|
||||
if (!devices || !Array.isArray(devices) || devices.length === 0) {
|
||||
throw new CoolCommException('导出设备列表不能为空');
|
||||
}
|
||||
|
||||
const zip = new AdmZip();
|
||||
const csvRows: string[] = [
|
||||
'批次号,UID,短链,微官网标题,出厂防伪码,二维码图片路径',
|
||||
];
|
||||
|
||||
for (const item of devices) {
|
||||
const uid = String(item.uid || '').trim();
|
||||
if (!uid) {
|
||||
throw new CoolCommException('设备 UID 不能为空');
|
||||
}
|
||||
|
||||
const slug = String(item.slug || '').trim();
|
||||
const cardTitle = String(item.cardTitle || '').trim();
|
||||
const shortUrl = slug.startsWith('http')
|
||||
? slug
|
||||
: buildCardPublicUrl(slug);
|
||||
const securityCode = generateSecurityCode(cleanBatchNo, uid);
|
||||
const safeUid = uid.replace(/[:\/\\*?"<>|]/g, '_');
|
||||
const qrPath = `${BATCH_EXPORT_QR_DIR}/${safeUid}.png`;
|
||||
|
||||
// 1. 生成高清二维码 PNG Buffer (容错率 H)
|
||||
const qrBuffer = await QRCode.toBuffer(shortUrl, {
|
||||
type: 'png',
|
||||
width: 512,
|
||||
margin: 2,
|
||||
errorCorrectionLevel: 'H',
|
||||
});
|
||||
|
||||
// 2. 将二维码图片写入 ZIP 包(支持目录层级与平铺解析)
|
||||
zip.addFile(qrPath, qrBuffer);
|
||||
zip.addFile(`${safeUid}.png`, qrBuffer);
|
||||
|
||||
// 3. 记录清单行
|
||||
csvRows.push(
|
||||
[
|
||||
escapeCsv(cleanBatchNo),
|
||||
escapeCsv(uid),
|
||||
escapeCsv(shortUrl),
|
||||
escapeCsv(cardTitle),
|
||||
escapeCsv(securityCode),
|
||||
escapeCsv(qrPath),
|
||||
].join(',')
|
||||
);
|
||||
}
|
||||
|
||||
// 4. 生成带 UTF-8 BOM 的 CSV 清单以防止各类办公软件中文乱码
|
||||
const csvBuffer = Buffer.from('\uFEFF' + csvRows.join('\r\n'), 'utf-8');
|
||||
zip.addFile(BATCH_EXPORT_MANIFEST_FILE, csvBuffer);
|
||||
zip.addFile(`${cleanBatchNo}.csv`, csvBuffer);
|
||||
|
||||
// 5. 流式安全输出 ZIP Buffer
|
||||
const zipBuffer = zip.toBuffer();
|
||||
return {
|
||||
zipBuffer,
|
||||
totalCount: devices.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { Init, Provide } from '@midwayjs/core';
|
||||
import { BaseService, CoolCommException } from '@cool-midway/core';
|
||||
import { InjectEntityModel } from '@midwayjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { NfcDeviceEntity } from '../entity/device';
|
||||
|
||||
/**
|
||||
* 管理后台 serviceApis:绑定 / 换绑
|
||||
*/
|
||||
export interface NfcBindParams {
|
||||
uid: string;
|
||||
cardId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理后台 serviceApis:仅需芯片 UID
|
||||
*/
|
||||
export interface NfcUidParams {
|
||||
uid: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* NFC 绑定激活服务
|
||||
* 硬件只写 UID/短链,后台随时换绑名片,无需重烧
|
||||
*/
|
||||
@Provide()
|
||||
export class NfcBindService extends BaseService {
|
||||
@InjectEntityModel(NfcDeviceEntity)
|
||||
nfcDeviceEntity: Repository<NfcDeviceEntity>;
|
||||
|
||||
@Init()
|
||||
async init() {
|
||||
await super.init();
|
||||
this.setEntity(this.nfcDeviceEntity);
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定名片并激活
|
||||
* 兼容管理后台 serviceApis:POST body 为单参数对象 { uid, cardId }
|
||||
*/
|
||||
async bind(params: NfcBindParams): Promise<NfcDeviceEntity> {
|
||||
const device = await this.requireDevice(params?.uid);
|
||||
if (device.status === 'suspended') {
|
||||
throw new CoolCommException('已挂失不可绑定');
|
||||
}
|
||||
const cardId = this.requireCardId(params?.cardId);
|
||||
device.cardId = cardId;
|
||||
device.status = 'active';
|
||||
return (await this.nfcDeviceEntity.save(device)) as NfcDeviceEntity;
|
||||
}
|
||||
|
||||
/**
|
||||
* 激活已绑定名片的 unbound 设备
|
||||
* 兼容管理后台 serviceApis:POST body 为单参数对象 { uid }
|
||||
*/
|
||||
async activate(params: NfcUidParams): Promise<NfcDeviceEntity> {
|
||||
const device = await this.requireDevice(params?.uid);
|
||||
if (device.status === 'suspended') {
|
||||
throw new CoolCommException('已挂失不可激活');
|
||||
}
|
||||
if (device.cardId == null || !Number(device.cardId)) {
|
||||
throw new CoolCommException('未绑定名片,无法激活');
|
||||
}
|
||||
device.status = 'active';
|
||||
return (await this.nfcDeviceEntity.save(device)) as NfcDeviceEntity;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更换已激活设备绑定的名片,不改 UID
|
||||
* 兼容管理后台 serviceApis:POST body 为单参数对象 { uid, cardId }
|
||||
*/
|
||||
async switchCard(params: NfcBindParams): Promise<NfcDeviceEntity> {
|
||||
const device = await this.requireDevice(params?.uid);
|
||||
if (device.status !== 'active') {
|
||||
throw new CoolCommException(
|
||||
device.status === 'suspended'
|
||||
? '已挂失不可换绑,请先解冻'
|
||||
: '仅已激活设备可换绑'
|
||||
);
|
||||
}
|
||||
const cardId = this.requireCardId(params?.cardId);
|
||||
device.cardId = cardId;
|
||||
return (await this.nfcDeviceEntity.save(device)) as NfcDeviceEntity;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解绑名片,状态回到 unbound
|
||||
* 兼容管理后台 serviceApis:POST body 为单参数对象 { uid }
|
||||
*/
|
||||
async unbind(params: NfcUidParams): Promise<NfcDeviceEntity> {
|
||||
const device = await this.requireDevice(params?.uid);
|
||||
if (device.status === 'suspended') {
|
||||
throw new CoolCommException('已挂失不可解绑,请先解冻');
|
||||
}
|
||||
device.cardId = null;
|
||||
device.status = 'unbound';
|
||||
return (await this.nfcDeviceEntity.save(device)) as NfcDeviceEntity;
|
||||
}
|
||||
|
||||
private async requireDevice(uid?: string): Promise<NfcDeviceEntity> {
|
||||
const chipUid = String(uid || '').trim();
|
||||
if (!chipUid) {
|
||||
throw new CoolCommException('设备 UID 不能为空');
|
||||
}
|
||||
const device = await this.nfcDeviceEntity.findOneBy({ uid: chipUid });
|
||||
if (!device) {
|
||||
throw new CoolCommException('设备未在台账中');
|
||||
}
|
||||
return device;
|
||||
}
|
||||
|
||||
private requireCardId(cardId?: number): number {
|
||||
const id = Number(cardId);
|
||||
if (!id) {
|
||||
throw new CoolCommException('名片 ID 不能为空');
|
||||
}
|
||||
return id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { Init, Provide } from '@midwayjs/core';
|
||||
import { BaseService, CoolCommException } from '@cool-midway/core';
|
||||
import { InjectEntityModel } from '@midwayjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { NfcDeviceEntity } from '../entity/device';
|
||||
import type {
|
||||
NFCMaterial,
|
||||
NFCSceneMode,
|
||||
NFCStatus,
|
||||
} from '../../../comm/types/omni';
|
||||
|
||||
/**
|
||||
* NFC 设备新增/修改载荷
|
||||
*/
|
||||
export interface NfcDeviceParam {
|
||||
id?: number;
|
||||
uid?: string;
|
||||
batchNo?: string;
|
||||
material?: NFCMaterial;
|
||||
status?: NFCStatus;
|
||||
cardId?: number | null;
|
||||
sceneMode?: NFCSceneMode;
|
||||
lastScannedAt?: string | Date | null;
|
||||
tenantId?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* NFC 设备分页查询
|
||||
*/
|
||||
export interface NfcDevicePageQuery {
|
||||
keyWord?: string;
|
||||
status?: NFCStatus;
|
||||
material?: NFCMaterial;
|
||||
cardId?: number;
|
||||
sceneMode?: NFCSceneMode;
|
||||
page?: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页结果
|
||||
*/
|
||||
export interface NfcDevicePageResult {
|
||||
list: NfcDeviceEntity[];
|
||||
pagination: {
|
||||
page: number;
|
||||
size: number;
|
||||
total: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* NFC 实体卡设备台账服务
|
||||
* 标准 CRUD 复用 BaseService;仅在新增时补默认状态并校验 UID 唯一
|
||||
*/
|
||||
@Provide()
|
||||
export class NfcDeviceService extends BaseService {
|
||||
@InjectEntityModel(NfcDeviceEntity)
|
||||
nfcDeviceEntity: Repository<NfcDeviceEntity>;
|
||||
|
||||
@Init()
|
||||
async init() {
|
||||
await super.init();
|
||||
this.setEntity(this.nfcDeviceEntity);
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增设备:生产复用 BaseService;单测走内存仓库
|
||||
*/
|
||||
async add(param: NfcDeviceParam): Promise<{ id: number }> {
|
||||
if (this.service) {
|
||||
return (await super.add(param)) as { id: number };
|
||||
}
|
||||
const payload: NfcDeviceParam = { ...param };
|
||||
delete payload.id;
|
||||
await this.modifyBefore(payload, 'add');
|
||||
const saved = (await this.nfcDeviceEntity.save(
|
||||
payload as NfcDeviceEntity
|
||||
)) as NfcDeviceEntity;
|
||||
await this.modifyAfter(saved, 'add');
|
||||
return { id: Number(saved.id) };
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询:生产走 BaseService;单测走内存仓库
|
||||
*/
|
||||
async page(
|
||||
query: NfcDevicePageQuery = {},
|
||||
option?: unknown,
|
||||
connectionName?: unknown
|
||||
): Promise<NfcDevicePageResult> {
|
||||
if (this.service) {
|
||||
return (await super.page(query, option, connectionName)) as NfcDevicePageResult;
|
||||
}
|
||||
return this.pageFromMemory(query);
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增/修改前:补默认状态,校验 UID 唯一
|
||||
*/
|
||||
async modifyBefore(
|
||||
data: NfcDeviceParam | number | string | number[],
|
||||
type: 'delete' | 'update' | 'add'
|
||||
): Promise<void> {
|
||||
if (type === 'delete' || typeof data !== 'object' || data === null) {
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'add' && !data.status) {
|
||||
data.status = 'unbound';
|
||||
}
|
||||
|
||||
const uid = String(data.uid || '').trim();
|
||||
if (!uid) {
|
||||
if (type === 'add') {
|
||||
throw new CoolCommException('NFC 设备 UID 不能为空');
|
||||
}
|
||||
return;
|
||||
}
|
||||
data.uid = uid;
|
||||
|
||||
const existing = await this.nfcDeviceEntity.findOneBy({ uid });
|
||||
if (existing && Number(existing.id) !== Number(data.id || 0)) {
|
||||
throw new CoolCommException('NFC 设备 UID 已存在');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 内存仓库分页(仅单测无 ORM Service 时使用)
|
||||
*/
|
||||
private async pageFromMemory(
|
||||
query: NfcDevicePageQuery
|
||||
): Promise<NfcDevicePageResult> {
|
||||
const page = Number(query.page || 1);
|
||||
const size = Number(query.size || 10);
|
||||
let list = await this.nfcDeviceEntity.find({
|
||||
order: { updateTime: 'DESC', id: 'DESC' },
|
||||
});
|
||||
|
||||
if (query.status) {
|
||||
list = list.filter(item => item.status === query.status);
|
||||
}
|
||||
if (query.material) {
|
||||
list = list.filter(item => item.material === query.material);
|
||||
}
|
||||
if (query.cardId !== undefined) {
|
||||
list = list.filter(item => Number(item.cardId) === Number(query.cardId));
|
||||
}
|
||||
if (query.sceneMode) {
|
||||
list = list.filter(item => item.sceneMode === query.sceneMode);
|
||||
}
|
||||
if (query.keyWord) {
|
||||
const keyword = query.keyWord.toLowerCase();
|
||||
list = list.filter(
|
||||
item =>
|
||||
String(item.uid || '')
|
||||
.toLowerCase()
|
||||
.includes(keyword) ||
|
||||
String(item.batchNo || '')
|
||||
.toLowerCase()
|
||||
.includes(keyword)
|
||||
);
|
||||
}
|
||||
|
||||
const total = list.length;
|
||||
const start = (page - 1) * size;
|
||||
return {
|
||||
list: list.slice(start, start + size),
|
||||
pagination: { page, size, total },
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { Init, Provide } from '@midwayjs/core';
|
||||
import { BaseService } from '@cool-midway/core';
|
||||
import { InjectEntityModel } from '@midwayjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { NFCSceneMode } from '../../../comm/types/omni';
|
||||
import { NfcRuleEntity } from '../entity/rule';
|
||||
|
||||
/**
|
||||
* 场景路由解析入参(兼容管理后台 serviceApis 单对象)
|
||||
*/
|
||||
export interface ResolveSceneParams {
|
||||
deviceId?: string | number | null;
|
||||
tenantId: string | number;
|
||||
now?: Date | string;
|
||||
countryCode?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 场景路由解析结果
|
||||
*/
|
||||
export interface ResolveSceneResult {
|
||||
targetCardId: string | number;
|
||||
sceneMode: NFCSceneMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* NFC 场景路由规则服务
|
||||
*/
|
||||
@Provide()
|
||||
export class NfcRuleService extends BaseService {
|
||||
@InjectEntityModel(NfcRuleEntity)
|
||||
nfcRuleEntity: Repository<NfcRuleEntity>;
|
||||
|
||||
@Init()
|
||||
async init() {
|
||||
await super.init();
|
||||
this.setEntity(this.nfcRuleEntity);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按时段 / 星期 / 国家解析触卡导向名片
|
||||
* 设备级规则优先于租户级;同级时带国家码的规则更具体
|
||||
* 兼容管理后台 serviceApis:POST body 为单参数对象
|
||||
*/
|
||||
async resolveScene(
|
||||
params: ResolveSceneParams
|
||||
): Promise<ResolveSceneResult | null> {
|
||||
const tenantId = Number(params?.tenantId);
|
||||
if (!tenantId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = this.toDate(params.now);
|
||||
const rules = await this.nfcRuleEntity.find({
|
||||
where: { tenantId, enabled: true },
|
||||
});
|
||||
|
||||
const matched = rules.filter(rule =>
|
||||
this.matchesRule(rule, params.deviceId, now, params.countryCode)
|
||||
);
|
||||
if (matched.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
matched.sort(
|
||||
(a, b) =>
|
||||
this.ruleScore(b, params.deviceId) - this.ruleScore(a, params.deviceId)
|
||||
);
|
||||
const winner = matched[0];
|
||||
return {
|
||||
targetCardId: winner.targetCardId,
|
||||
sceneMode: winner.sceneMode,
|
||||
};
|
||||
}
|
||||
|
||||
private toDate(now?: Date | string): Date {
|
||||
if (now instanceof Date) {
|
||||
return now;
|
||||
}
|
||||
if (typeof now === 'string' && now.trim()) {
|
||||
const parsed = new Date(now);
|
||||
if (!Number.isNaN(parsed.getTime())) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return new Date();
|
||||
}
|
||||
|
||||
private matchesRule(
|
||||
rule: NfcRuleEntity,
|
||||
deviceId: string | number | null | undefined,
|
||||
now: Date,
|
||||
countryCode?: string | null
|
||||
): boolean {
|
||||
if (rule.enabled !== true) {
|
||||
return false;
|
||||
}
|
||||
if (!this.matchesDevice(rule.deviceId, deviceId)) {
|
||||
return false;
|
||||
}
|
||||
if (!this.matchesWeekdays(rule.weekdays, now)) {
|
||||
return false;
|
||||
}
|
||||
if (!this.matchesTime(rule.startTime, rule.endTime, now)) {
|
||||
return false;
|
||||
}
|
||||
return this.matchesCountry(rule.countryCode, countryCode);
|
||||
}
|
||||
|
||||
private isBlank(value: string | number | null | undefined): boolean {
|
||||
return value === null || value === undefined || value === '';
|
||||
}
|
||||
|
||||
private matchesDevice(
|
||||
ruleDeviceId: string | number | null | undefined,
|
||||
deviceId: string | number | null | undefined
|
||||
): boolean {
|
||||
if (this.isBlank(ruleDeviceId)) {
|
||||
return true;
|
||||
}
|
||||
if (this.isBlank(deviceId)) {
|
||||
return false;
|
||||
}
|
||||
return String(ruleDeviceId) === String(deviceId);
|
||||
}
|
||||
|
||||
private matchesWeekdays(
|
||||
weekdays: number[] | null | undefined,
|
||||
now: Date
|
||||
): boolean {
|
||||
if (!weekdays || weekdays.length === 0) {
|
||||
return true;
|
||||
}
|
||||
return weekdays.includes(now.getDay());
|
||||
}
|
||||
|
||||
private matchesTime(
|
||||
startTime: string | null | undefined,
|
||||
endTime: string | null | undefined,
|
||||
now: Date
|
||||
): boolean {
|
||||
const start = (startTime || '').trim();
|
||||
const end = (endTime || '').trim();
|
||||
if (!start && !end) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const current = this.formatHm(now);
|
||||
if (start && end) {
|
||||
if (start <= end) {
|
||||
return current >= start && current <= end;
|
||||
}
|
||||
return current >= start || current <= end;
|
||||
}
|
||||
if (start) {
|
||||
return current >= start;
|
||||
}
|
||||
return current <= end;
|
||||
}
|
||||
|
||||
private formatHm(now: Date): string {
|
||||
const hours = String(now.getHours()).padStart(2, '0');
|
||||
const minutes = String(now.getMinutes()).padStart(2, '0');
|
||||
return `${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
private matchesCountry(
|
||||
ruleCountry: string | null | undefined,
|
||||
countryCode?: string | null
|
||||
): boolean {
|
||||
const expected = (ruleCountry || '').trim();
|
||||
if (!expected) {
|
||||
return true;
|
||||
}
|
||||
const actual = (countryCode || '').trim();
|
||||
if (!actual) {
|
||||
return false;
|
||||
}
|
||||
return expected.toLowerCase() === actual.toLowerCase();
|
||||
}
|
||||
|
||||
private ruleScore(
|
||||
rule: NfcRuleEntity,
|
||||
deviceId: string | number | null | undefined
|
||||
): number {
|
||||
let score = 0;
|
||||
if (
|
||||
!this.isBlank(rule.deviceId) &&
|
||||
!this.isBlank(deviceId) &&
|
||||
String(rule.deviceId) === String(deviceId)
|
||||
) {
|
||||
score += 2;
|
||||
}
|
||||
if ((rule.countryCode || '').trim()) {
|
||||
score += 1;
|
||||
}
|
||||
return score;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Init, Provide } from '@midwayjs/core';
|
||||
import { BaseService, CoolCommException } from '@cool-midway/core';
|
||||
import { InjectEntityModel } from '@midwayjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { NfcDeviceEntity } from '../entity/device';
|
||||
|
||||
/**
|
||||
* 管理后台 serviceApis:按 uid 或数字 id 定位设备
|
||||
*/
|
||||
export interface NfcSecurityQuery {
|
||||
uid?: string;
|
||||
id?: number | string;
|
||||
}
|
||||
|
||||
/**
|
||||
* NFC 挂失冻结服务
|
||||
* 一键挂失防滥用;可重置/解冻。保留绑定关系,不解绑。
|
||||
*/
|
||||
@Provide()
|
||||
export class NfcSecurityService extends BaseService {
|
||||
@InjectEntityModel(NfcDeviceEntity)
|
||||
nfcDeviceEntity: Repository<NfcDeviceEntity>;
|
||||
|
||||
@Init()
|
||||
async init() {
|
||||
await super.init();
|
||||
this.setEntity(this.nfcDeviceEntity);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 uid 或数字 id 查找设备
|
||||
*/
|
||||
private async findDevice(params: NfcSecurityQuery): Promise<NfcDeviceEntity> {
|
||||
const uid = params?.uid != null ? String(params.uid).trim() : '';
|
||||
const rawId = params?.id;
|
||||
const id = rawId != null && rawId !== '' ? Number(rawId) : 0;
|
||||
|
||||
if (!uid && !id) {
|
||||
throw new CoolCommException('缺少设备标识');
|
||||
}
|
||||
|
||||
const device = uid
|
||||
? await this.nfcDeviceEntity.findOneBy({ uid })
|
||||
: await this.nfcDeviceEntity.findOneBy({ id });
|
||||
|
||||
if (!device) {
|
||||
throw new CoolCommException('NFC 设备不存在');
|
||||
}
|
||||
return device;
|
||||
}
|
||||
|
||||
private hasCardId(device: NfcDeviceEntity): boolean {
|
||||
return device.cardId != null && Number(device.cardId) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 一键挂失冻结:status → suspended,保留 cardId
|
||||
* 已是 suspended 可幂等
|
||||
* 兼容管理后台 serviceApis:POST body 为单参数对象 { uid } | { id }
|
||||
*/
|
||||
async freeze(params: NfcSecurityQuery): Promise<NfcDeviceEntity> {
|
||||
const device = await this.findDevice(params);
|
||||
if (device.status === 'suspended') {
|
||||
return device;
|
||||
}
|
||||
device.status = 'suspended';
|
||||
return (await this.nfcDeviceEntity.save(device)) as NfcDeviceEntity;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解冻:suspended → active(有 cardId)或 unbound(无 cardId)
|
||||
* 非 suspended 抛异常
|
||||
* 兼容管理后台 serviceApis:POST body 为单参数对象 { uid } | { id }
|
||||
*/
|
||||
async unfreeze(params: NfcSecurityQuery): Promise<NfcDeviceEntity> {
|
||||
const device = await this.findDevice(params);
|
||||
if (device.status !== 'suspended') {
|
||||
throw new CoolCommException('设备未处于挂失冻结状态');
|
||||
}
|
||||
device.status = this.hasCardId(device) ? 'active' : 'unbound';
|
||||
return (await this.nfcDeviceEntity.save(device)) as NfcDeviceEntity;
|
||||
}
|
||||
|
||||
/**
|
||||
* 注销重置:清空 cardId,status → unbound,sceneMode 回 business
|
||||
* 兼容管理后台 serviceApis:POST body 为单参数对象 { uid } | { id }
|
||||
*/
|
||||
async reset(params: NfcSecurityQuery): Promise<NfcDeviceEntity> {
|
||||
const device = await this.findDevice(params);
|
||||
device.cardId = null;
|
||||
device.status = 'unbound';
|
||||
device.sceneMode = 'business';
|
||||
return (await this.nfcDeviceEntity.save(device)) as NfcDeviceEntity;
|
||||
}
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户