feat: 完成全系统30个功能点开发与E2E全链路验收 (8轮调度32个子代理闭环)

- 模块一: 出海案例作品集、博客动态发布、人名发音语音条、RTL阿语多语言切换
- 模块二: AI顾问人设配置、行业术语库润色、RAG切片沙盒调试、流式会话与意向留资卡片
- 模块三: 全球访客雷达大屏、多维加权意向算分、外部CRM对接推送、团队公海池流转
- 模块四: 移动端NFC安全挂失、NFC动态路由规则配置、动态防伪验签、展会制卡Zip批量导出
- 模块五: 组织架构与名片分配、移动端个人中心设置、离职交接与历史线索迁移、安全审计日志
- 模块六: Stripe国际信用卡订阅Webhook、独立二级域名与SSL检测、坐席配额预警、多币种发票
- 模块七: 纸质名片OCR解析入库、国际商务日程RFC 5545 ICS日历、微官网离线SWR预加载、展会实时排行榜大屏
- 模块八: 全球出海SEO OpenGraph元标签注入与社交爬虫探测、30点端到端全链路业务流集成测试套件
这个提交包含在:
wikiboss
2026-09-20 23:47:47 +08:00
父节点 acd10ffb59
当前提交 eec259a3a7
共有 276 个文件被更改,包括 54906 次插入 和 145 次删除

查看文件

@@ -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;
}
}