feat(gateway): 新增智电改造网关台账功能(ICC消防物联设备同步) - 新建electric_gateway表(迁移20261024_icc_gateway.sql并同步db_seed:自增id、icc_imei/device_code双唯一、orgCode经customer.icc_code解析customer_id、equip_id可空、is_online/synced_at等);同步服务IccGatewaySyncService复用OAuth2真实鉴权分页拉取消防物联设备(POST /device/subsystem/page)并按imei幂等upsert写icc_sync_log;新增GET /api/electric-gateways列表/详情与POST /sync手动同步(RBAC device:list);注册icc_gateway_sync每10分钟定时任务(菜单62/role_menu/sys_job种子);前端网关台账页(搜索/筛选/同步按钮/在线状态tag/分页)与路由;server补装undici依赖

Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com
This commit is contained in:
wkandClaude Sonnet 4.6 noreply@anthropic.com authored and trae-solo-agent committed 2026-10-24 12:37:35 +08:00
1 parent 9d3bb13ecf
commit cf0a0d4d31
21 files changed
+1439 -272

No files matched your search

+103
View File
@@ -0,0 +1,103 @@
/**
* ICC 网关设备数据字段映射(集中配置,便于按 ICC 平台实际返回结构调整)。
*
* ICC 返回的原始字段名 → 本系统 electric_gateway 字段。接入真实 ICC 平台时
* 仅需调整本文件与 fetch 路径即可,业务层无需改动。纯映射逻辑,无 SQL 无 DOM。
*/
export interface IccGatewayRawRecord {
[field: string]: any;
}
/** 设备分页列表接口路径({version} 由调用方按 ICC_VERSION 替换) */
export const ICC_GATEWAY_ENDPOINT = "/evo-apigw/evo-fdbu/{version}/device/subsystem/page";
/** 原始记录关键字段映射:本系统字段 → ICC 返回字段名 */
export const ICC_GATEWAY_FIELDS = {
imei: "imei", // 设备唯一标识码(imei)
deviceCode: "deviceCode", // 设备编码
deviceName: "deviceName", // 设备名称
orgCode: "orgCode", // 所属组织编码(→ customer.icc_code 反查客户)
orgName: "orgName", // 所属组织名称
manufacturerCode: "manufacturerCode", // 厂商编码
manufacturerName: "manufacturerName", // 厂商名称
typeCode: "typeCode", // 设备类型编码
typeName: "typeName", // 设备类型名称
productModel: "productModel", // 产品型号
isOnline: "isOnline" // 在线状态
} as const;
/** 离线标记集合:命中即归一化为离线(0),其余(含空)均视为在线(1) */
const OFFLINE_MARKERS = new Set(["0", "02", "false", "no", "off", "离线"]);
/** 字符串 trim;null / undefined / 空串统一归一为 undefined。 */
function toOptionalString(value: unknown): string | undefined {
if (value === undefined || value === null) return undefined;
const s = String(value).trim();
return s === "" ? undefined : s;
}
/** 将 ICC 原始设备记录映射为本系统 electric_gateway 落库字段(不含联表外键)。 */
export function mapIccGatewayRecord(raw: IccGatewayRawRecord): {
imei: string;
deviceCode: string;
deviceName?: string;
orgCode?: string;
orgName?: string;
manufacturerCode?: string;
manufacturerName?: string;
typeCode?: string;
typeName?: string;
productModel?: string;
isOnline: number;
} {
const result: Record<string, any> = {
// 匹配键保持空串语义,由调用方判跳
imei: String(raw[ICC_GATEWAY_FIELDS.imei] ?? "").trim(),
deviceCode: String(raw[ICC_GATEWAY_FIELDS.deviceCode] ?? "").trim()
};
// 可选字符串字段
for (const key of ["deviceName", "orgCode", "orgName", "manufacturerCode", "manufacturerName", "typeCode", "typeName", "productModel"] as const) {
result[key] = toOptionalString(raw[ICC_GATEWAY_FIELDS[key]]);
}
// 在线状态归一化:离线标记集合 → 0,其余(含空)→ 1
const onlineRaw = String(raw[ICC_GATEWAY_FIELDS.isOnline] ?? "").trim();
result.isOnline = OFFLINE_MARKERS.has(onlineRaw) ? 0 : 1;
return result as any;
}
/**
* 解析设备分页响应(兼容三种形态):
* - payload 为数组 → 直接作为列表
* - payload.data.pageData 为数组 → 取 pageData
* - payload.data.list 为数组 → 取 list
* - 结构不匹配或解析异常 → 返回空列表
*/
export function parseGatewayPage(payload: any): {
list: IccGatewayRawRecord[];
nextPage?: number;
totalCount?: number;
} {
try {
if (Array.isArray(payload)) {
return { list: payload };
}
const page = payload?.data;
if (page && Array.isArray(page.pageData)) {
return {
list: page.pageData,
nextPage: Number(page.nextPage) > 0 ? Number(page.nextPage) : undefined,
totalCount: Number(page.totalCount) > 0 ? Number(page.totalCount) : undefined
};
}
if (page && Array.isArray(page.list)) {
return {
list: page.list,
nextPage: Number(page.nextPage) > 0 ? Number(page.nextPage) : undefined,
totalCount: Number(page.totalCount) > 0 ? Number(page.totalCount) : undefined
};
}
return { list: [] };
} catch {
return { list: [] };
}
}
+17
View File
@@ -0,0 +1,17 @@
import type { BuiltinJob } from "./job_registry";
/**
* 智电网关同步任务:从 ICC 平台拉取智电改造网关设备并按 imei/deviceCode 幂等 upsert,
* 写 icc_sync_log。默认每 10 分钟执行一次。
*/
export const iccGatewaySyncJob: BuiltinJob = {
jobKey: "icc_gateway_sync",
jobName: "智电网关同步",
description: "从 ICC 平台拉取智电改造网关设备并按 imei/deviceCode 幂等 upsert(写 icc_sync_log)",
defaultCron: "*/10 * * * *",
defaultParams: {},
paramFields: [],
handler: async (_params, ctx) => {
return ctx.runtime.syncIccGateways();
}
};
+4 -1
View File
@@ -3,6 +3,7 @@ import { logCleanJob } from "./log_clean.task";
import { noticeScanJob } from "./notice_scan.task";
import { iccLineSyncJob } from "./icc_line_sync.task";
import { iccCustomerSyncJob } from "./icc_customer_sync.task";
import { iccGatewaySyncJob } from "./icc_gateway_sync.task";
export type JobParamType = "number" | "string";
@@ -35,6 +36,8 @@ export interface JobRuntime {
syncIccLines(): Promise<string>;
/** 从 ICC 平台拉取 org_base 组织并按 iccCode 幂等新增/变更客户,返回执行摘要 */
syncIccCustomers(): Promise<string>;
/** 从 ICC 平台拉取智电改造网关设备并按 imei/deviceCode 幂等 upsert,返回执行摘要 */
syncIccGateways(): Promise<string>;
}
export interface JobHandlerContext {
@@ -61,7 +64,7 @@ export interface BuiltinJob {
handler: JobHandler;
}
export const BUILTIN_JOBS: BuiltinJob[] = [demoHeartbeatJob, logCleanJob, noticeScanJob, iccLineSyncJob, iccCustomerSyncJob];
export const BUILTIN_JOBS: BuiltinJob[] = [demoHeartbeatJob, logCleanJob, noticeScanJob, iccLineSyncJob, iccCustomerSyncJob, iccGatewaySyncJob];
export function getBuiltinJob(jobKey: string): BuiltinJob | undefined {
return BUILTIN_JOBS.find(job => job.jobKey === jobKey);