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:
1 parent
9d3bb13ecf
commit
cf0a0d4d31
21 files changed
+1439
-272
No files matched your search
+1
-1
@@ -38,7 +38,7 @@
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"sequelize": "^6.37.5",
|
||||
"sequelize-typescript": "^2.1.6",
|
||||
"undici": "^8.12.2",
|
||||
"undici": "^8.12.3",
|
||||
"uuid": "^10.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Controller, Get, Post, Inject, Query, Param } from "@midwayjs/core";
|
||||
import { Context } from "@midwayjs/koa";
|
||||
import { ElectricGatewayService } from "../service/electric_gateway.service";
|
||||
import { IccGatewaySyncService } from "../service/icc_gateway_sync.service";
|
||||
|
||||
@Controller("/api/electric-gateways")
|
||||
export class ElectricGatewayController {
|
||||
@Inject() ctx!: Context;
|
||||
@Inject() electricGatewayService!: ElectricGatewayService;
|
||||
@Inject() iccGatewaySyncService!: IccGatewaySyncService;
|
||||
|
||||
@Get("/") async list(@Query() query: any) { return this.electricGatewayService.list(query, this.ctx); }
|
||||
@Get("/:id") async detail(@Param("id") id: string) { return this.electricGatewayService.detail(Number(id), this.ctx); }
|
||||
@Post("/sync") async sync() {
|
||||
const result = await this.iccGatewaySyncService.syncFromIcc(this.ctx);
|
||||
return { code: 200, message: "网关设备同步完成", data: result };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { IsOptional, IsString, IsInt, MaxLength, Min, Max } from "class-validator";
|
||||
|
||||
export class ElectricGatewayQueryDTO {
|
||||
@IsOptional() @IsInt() @Min(1) page?: number = 1;
|
||||
@IsOptional() @IsInt() @Min(1) @Max(200) pageSize?: number = 10;
|
||||
@IsOptional() @IsString() @MaxLength(64) keyword?: string;
|
||||
@IsOptional() @IsInt() customerId?: number;
|
||||
@IsOptional() @IsInt() equipId?: number;
|
||||
@IsOptional() @IsInt() @Min(0) @Max(1) isOnline?: number;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Column, Model, Table, DataType, CreatedAt, ForeignKey, BelongsTo } from "sequelize-typescript";
|
||||
import Customer from "./customer.entity";
|
||||
import CustomerEquipment from "./customer_equipment.entity";
|
||||
|
||||
@Table({
|
||||
tableName: "electric_gateway",
|
||||
comment: "智电改造网关设备表(ICC 同步)",
|
||||
timestamps: false,
|
||||
indexes: [
|
||||
{ name: "idx_gateway_customer", fields: ["customer_id"] },
|
||||
{ name: "idx_gateway_equip", fields: ["equip_id"] },
|
||||
{ name: "idx_gateway_imei", fields: ["icc_imei"] }
|
||||
]
|
||||
})
|
||||
export default class ElectricGateway extends Model {
|
||||
@Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true })
|
||||
id!: number;
|
||||
|
||||
@Column({ type: DataType.STRING(64), unique: true, field: "icc_imei", comment: "设备唯一标识码(imei)" })
|
||||
iccImei!: string;
|
||||
|
||||
@Column({ type: DataType.STRING(64), unique: true, field: "device_code", comment: "设备编码" })
|
||||
deviceCode!: string;
|
||||
|
||||
@Column({ type: DataType.STRING(128), field: "device_name", comment: "设备名称" })
|
||||
deviceName!: string;
|
||||
|
||||
@ForeignKey(() => Customer)
|
||||
@Column({ type: DataType.BIGINT, allowNull: false, field: "customer_id", comment: "客户 ID(由 orgCode→customer.icc_code 解析)" })
|
||||
customerId!: number;
|
||||
|
||||
@ForeignKey(() => CustomerEquipment)
|
||||
@Column({ type: DataType.BIGINT, field: "equip_id", comment: "客户设备 ID(→ customer_equipment,可手动关联)" })
|
||||
equipId!: number;
|
||||
|
||||
@Column({ type: DataType.STRING(64), field: "org_code", comment: "ICC 组织编码" })
|
||||
orgCode!: string;
|
||||
|
||||
@Column({ type: DataType.STRING(128), field: "org_name", comment: "ICC 组织名称" })
|
||||
orgName!: string;
|
||||
|
||||
@Column({ type: DataType.STRING(64), field: "manufacturer_code", comment: "厂商编码" })
|
||||
manufacturerCode!: string;
|
||||
|
||||
@Column({ type: DataType.STRING(128), field: "manufacturer_name", comment: "厂商名称" })
|
||||
manufacturerName!: string;
|
||||
|
||||
@Column({ type: DataType.STRING(32), field: "type_code", comment: "设备类型编码" })
|
||||
typeCode!: string;
|
||||
|
||||
@Column({ type: DataType.STRING(64), field: "type_name", comment: "设备类型名称" })
|
||||
typeName!: string;
|
||||
|
||||
@Column({ type: DataType.STRING(64), field: "product_model", comment: "产品型号" })
|
||||
productModel!: string;
|
||||
|
||||
@Column({ type: DataType.SMALLINT, allowNull: false, defaultValue: 1, field: "is_online", comment: "在线状态:1在线 0离线" })
|
||||
isOnline!: number;
|
||||
|
||||
@Column({ type: DataType.DATE, field: "synced_at", comment: "ICC 同步时间" })
|
||||
syncedAt!: Date;
|
||||
|
||||
@BelongsTo(() => Customer, { foreignKey: "customerId", as: "customer" })
|
||||
customer!: Customer;
|
||||
|
||||
@BelongsTo(() => CustomerEquipment, { foreignKey: "equipId", as: "equipment" })
|
||||
equipment!: CustomerEquipment;
|
||||
|
||||
@CreatedAt
|
||||
created_at!: Date;
|
||||
}
|
||||
@@ -29,6 +29,7 @@ const PERMISSION_MAP: Record<string, string> = {
|
||||
"/api/customer-equipments": "device:list",
|
||||
"/api/smart-devices": "device:model",
|
||||
"/api/electric-lines": "device:list",
|
||||
"/api/electric-gateways": "device:list",
|
||||
"/api/survey-orders": "project:survey",
|
||||
"/api/project-plans": "project:plan",
|
||||
"/api/contracts": "project:contract",
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { Provide } from "@midwayjs/core";
|
||||
import { InjectRepository } from "@midwayjs/sequelize";
|
||||
import { Repository } from "sequelize-typescript";
|
||||
import { Op } from "sequelize";
|
||||
import { Context } from "@midwayjs/koa";
|
||||
import ElectricGateway from "../entity/electric_gateway.entity";
|
||||
import Customer from "../entity/customer.entity";
|
||||
import CustomerEquipment from "../entity/customer_equipment.entity";
|
||||
import SysUserRole from "../entity/sysUserRole.entity";
|
||||
import SysRole from "../entity/sysRole.entity";
|
||||
|
||||
@Provide()
|
||||
export class ElectricGatewayService {
|
||||
@InjectRepository(ElectricGateway)
|
||||
gatewayModel!: Repository<ElectricGateway>;
|
||||
|
||||
@InjectRepository(Customer)
|
||||
customerModel!: Repository<Customer>;
|
||||
|
||||
@InjectRepository(CustomerEquipment)
|
||||
equipmentModel!: Repository<CustomerEquipment>;
|
||||
|
||||
@InjectRepository(SysUserRole)
|
||||
userRoleModel!: Repository<SysUserRole>;
|
||||
|
||||
@InjectRepository(SysRole)
|
||||
roleModel!: Repository<SysRole>;
|
||||
|
||||
private async isAdmin(userId: number): Promise<boolean> {
|
||||
const userRoles = await this.userRoleModel.findAll({ where: { userId }, include: [this.roleModel] });
|
||||
return userRoles.some((ur) => (ur as any).role?.roleCode === "ADMIN");
|
||||
}
|
||||
|
||||
async list(query: any, ctx: Context): Promise<any> {
|
||||
const page = Number(query.page) || 1;
|
||||
const pageSize = Number(query.pageSize) || 10;
|
||||
const userId = ctx.state.user.userId;
|
||||
const where: any = {};
|
||||
|
||||
if (query.keyword) {
|
||||
where[Op.or] = [
|
||||
{ deviceName: { [Op.like]: `%${query.keyword}%` } },
|
||||
{ deviceCode: { [Op.like]: `%${query.keyword}%` } },
|
||||
{ iccImei: { [Op.like]: `%${query.keyword}%` } }
|
||||
];
|
||||
}
|
||||
if (query.customerId !== undefined && query.customerId !== "") where.customerId = Number(query.customerId);
|
||||
if (query.equipId !== undefined && query.equipId !== "") where.equipId = Number(query.equipId);
|
||||
if (query.isOnline !== undefined && query.isOnline !== "" && query.isOnline !== null) where.isOnline = Number(query.isOnline);
|
||||
|
||||
// 非管理员按客户归属自动限定数据范围
|
||||
if (!(await this.isAdmin(userId))) {
|
||||
const myCustomers = await this.customerModel.findAll({ where: { salesmanId: userId }, attributes: ["id"] });
|
||||
const ids = myCustomers.map((c) => c.id);
|
||||
if (ids.length === 0) {
|
||||
return { code: 200, message: "获取网关台账列表成功", data: { list: [], total: 0, page: Number(page), pageSize: Number(pageSize) } };
|
||||
}
|
||||
if (where.customerId !== undefined) {
|
||||
if (!ids.includes(where.customerId)) {
|
||||
return { code: 200, message: "获取网关台账列表成功", data: { list: [], total: 0, page: Number(page), pageSize: Number(pageSize) } };
|
||||
}
|
||||
} else {
|
||||
where.customerId = { [Op.in]: ids };
|
||||
}
|
||||
}
|
||||
|
||||
const { count, rows } = await this.gatewayModel.findAndCountAll({
|
||||
where,
|
||||
limit: pageSize,
|
||||
offset: (page - 1) * pageSize,
|
||||
order: [["created_at", "DESC"], ["id", "DESC"]],
|
||||
distinct: true
|
||||
});
|
||||
|
||||
const list = await this.decorate(rows);
|
||||
return { code: 200, message: "获取网关台账列表成功", data: { list, total: count, page: Number(page), pageSize: Number(pageSize) } };
|
||||
}
|
||||
|
||||
async detail(id: number, ctx: Context): Promise<any> {
|
||||
const record = await this.gatewayModel.findByPk(id);
|
||||
if (!record) return { code: 404, message: "网关设备不存在", data: null };
|
||||
|
||||
if (!(await this.isAdmin(ctx.state.user.userId))) {
|
||||
const customer = await this.customerModel.findByPk(record.customerId);
|
||||
if (!customer || customer.salesmanId !== ctx.state.user.userId) {
|
||||
return { code: 403, message: "无权限访问该网关设备", data: null };
|
||||
}
|
||||
}
|
||||
|
||||
const decorated = await this.decorate([record]);
|
||||
return { code: 200, message: "获取网关设备台账详情成功", data: decorated[0] };
|
||||
}
|
||||
|
||||
/** 补全客户/设备展示信息,避免 N+1。 */
|
||||
private async decorate(rows: ElectricGateway[]): Promise<any[]> {
|
||||
if (rows.length === 0) return rows as any;
|
||||
|
||||
const customerIds = [...new Set(rows.map((r) => r.customerId).filter(Boolean))];
|
||||
const equipIds = [...new Set(rows.map((r) => r.equipId).filter(Boolean))];
|
||||
|
||||
const [customers, equips] = await Promise.all([
|
||||
customerIds.length
|
||||
? this.customerModel.findAll({
|
||||
where: { id: { [Op.in]: customerIds } },
|
||||
attributes: ["id", "customerNo", "customerName"]
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
equipIds.length
|
||||
? this.equipmentModel.findAll({
|
||||
where: { id: { [Op.in]: equipIds } },
|
||||
attributes: ["id", "equipCode", "equipName"]
|
||||
})
|
||||
: Promise.resolve([])
|
||||
]);
|
||||
|
||||
const customerMap = new Map(customers.map((c) => [c.id, c]));
|
||||
const equipMap = new Map(equips.map((e) => [e.id, e]));
|
||||
|
||||
return rows.map((row) => {
|
||||
const json: any = row.toJSON();
|
||||
const customer: any = customerMap.get(row.customerId);
|
||||
const equip: any = row.equipId ? equipMap.get(row.equipId) : undefined;
|
||||
json.customerNo = customer?.customerNo || "";
|
||||
json.customerName = customer?.customerName || "";
|
||||
json.equipCode = equip?.equipCode || "";
|
||||
json.equipName = equip?.equipName || "";
|
||||
return json;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { Provide } from "@midwayjs/core";
|
||||
import { InjectRepository } from "@midwayjs/sequelize";
|
||||
import { Repository } from "sequelize-typescript";
|
||||
import { Op } from "sequelize";
|
||||
import { Context } from "@midwayjs/koa";
|
||||
import ElectricGateway from "../entity/electric_gateway.entity";
|
||||
import IccSyncLog from "../entity/icc_sync_log.entity";
|
||||
import Customer from "../entity/customer.entity";
|
||||
import { getIccAccessToken, iccFetch } from "../task/icc_customer_auth";
|
||||
import { ICC_GATEWAY_ENDPOINT, mapIccGatewayRecord, parseGatewayPage, IccGatewayRawRecord } from "../task/icc_gateway_mapper";
|
||||
|
||||
export interface IccGatewaySyncResult {
|
||||
/** ICC 拉取到的原始设备条数 */
|
||||
pulled: number;
|
||||
/** 成功落库的设备条数(新增+更新) */
|
||||
synced: number;
|
||||
/** 新增的设备条数 */
|
||||
inserted: number;
|
||||
/** 更新的设备条数 */
|
||||
updated: number;
|
||||
/** 跳过的条数(缺 imei/deviceCode / orgCode 匹配不到客户) */
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
const SYNC_TYPE = "electric_gateway";
|
||||
/** 分页拉取参数(ICC 设备分页接口约定,短页判定阈值) */
|
||||
const PAGE_SIZE = 50;
|
||||
/** 最大分页数保护,防止异常接口导致无限拉取 */
|
||||
const MAX_PAGES = 100;
|
||||
/** ICC_VERSION 缺省值(与 icc_customer_auth 保持一致) */
|
||||
const DEFAULT_VERSION = "1.0.0";
|
||||
/** HTTP 超时 60s(项目约定) */
|
||||
const HTTP_TIMEOUT_MS = 60000;
|
||||
|
||||
/**
|
||||
* ICC 智电改造网关设备拉取同步:
|
||||
* 1) 复用 icc_customer_auth 的真实 OAuth2 密码模式鉴权(getIccAccessToken / iccFetch),
|
||||
* 从 .env 读取 ICC_BASE_URL / ICC_VERSION
|
||||
* 2) POST 分页请求 ICC 设备列表(/device/subsystem/page,pageNum/pageSize=50,全量拉取)
|
||||
* 3) 预取 orgCode→customer.id 映射(customer.icc_code),避免 N+1 查询客户
|
||||
* 4) 按 imei/deviceCode 幂等 upsert 写入 electric_gateway,更新 synced_at
|
||||
* 5) 每次同步写一条 icc_sync_log;拉取失败仅记录 error,不抛业务错误
|
||||
*/
|
||||
@Provide()
|
||||
export class IccGatewaySyncService {
|
||||
@InjectRepository(ElectricGateway)
|
||||
gatewayModel!: Repository<ElectricGateway>;
|
||||
|
||||
@InjectRepository(IccSyncLog)
|
||||
syncLogModel!: Repository<IccSyncLog>;
|
||||
|
||||
@InjectRepository(Customer)
|
||||
customerModel!: Repository<Customer>;
|
||||
|
||||
async syncFromIcc(ctx?: Context): Promise<IccGatewaySyncResult> {
|
||||
const startTime = new Date();
|
||||
const baseUrl = process.env.ICC_BASE_URL?.trim();
|
||||
|
||||
const result: IccGatewaySyncResult = { pulled: 0, synced: 0, inserted: 0, updated: 0, skipped: 0 };
|
||||
|
||||
// 基础配置缺失视为拉取失败:记录日志,不抛业务错误
|
||||
if (!baseUrl) {
|
||||
await this.writeLog(startTime, 0, SYNC_TYPE, 0, "未配置 ICC_BASE_URL,请检查 server/.env");
|
||||
return result;
|
||||
}
|
||||
|
||||
try {
|
||||
const token = await getIccAccessToken();
|
||||
const url = `${baseUrl.replace(/\/$/, "")}${ICC_GATEWAY_ENDPOINT.replace("{version}", process.env.ICC_VERSION?.trim() || DEFAULT_VERSION)}`;
|
||||
|
||||
const allRaw: IccGatewayRawRecord[] = [];
|
||||
let pageNum = 1;
|
||||
|
||||
while (pageNum <= MAX_PAGES) {
|
||||
const res = await iccFetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
Authorization: `bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ pageNum, pageSize: PAGE_SIZE }), // 不传 typeCode,全量拉取
|
||||
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS)
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`ICC 网关设备接口返回异常:HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
const payload: any = await res.json();
|
||||
const page = parseGatewayPage(payload);
|
||||
allRaw.push(...page.list);
|
||||
result.pulled += page.list.length;
|
||||
|
||||
// 短页判定:本页不满 pageSize 视为无下一页;另有最大页数保护
|
||||
if (page.list.length < PAGE_SIZE) break;
|
||||
pageNum++;
|
||||
}
|
||||
|
||||
const { inserted, updated, skipped } = await this.batchUpsert(allRaw);
|
||||
result.inserted = inserted;
|
||||
result.updated = updated;
|
||||
result.synced = inserted + updated;
|
||||
result.skipped = skipped;
|
||||
|
||||
await this.writeLog(startTime, 1, SYNC_TYPE, result.synced, null);
|
||||
return result;
|
||||
} catch (err: any) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
await this.writeLog(startTime, 0, SYNC_TYPE, result.pulled, message);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/** 按 imei/deviceCode 幂等 upsert(幂等),customer_id 解析失败的行跳过。 */
|
||||
private async batchUpsert(records: IccGatewayRawRecord[]): Promise<{ inserted: number; updated: number; skipped: number }> {
|
||||
let inserted = 0;
|
||||
let updated = 0;
|
||||
let skipped = 0;
|
||||
|
||||
if (records.length === 0) return { inserted, updated, skipped };
|
||||
|
||||
// 预取 orgCode → customer.id 映射,避免 N+1 查询客户
|
||||
const orgCodes = [...new Set(records.map((r) => mapIccGatewayRecord(r).orgCode).filter(Boolean))];
|
||||
const customers = orgCodes.length
|
||||
? await this.customerModel.findAll({ where: { iccCode: { [Op.in]: orgCodes } }, attributes: ["id", "iccCode"] })
|
||||
: [];
|
||||
const customerIdByOrgCode = new Map(customers.map((c) => [c.iccCode, c.id]));
|
||||
|
||||
const now = new Date();
|
||||
|
||||
for (const raw of records) {
|
||||
const mapped = mapIccGatewayRecord(raw);
|
||||
// imei 与 deviceCode 都为空则无同步匹配键,跳过
|
||||
if (!mapped.imei && !mapped.deviceCode) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
// customer_id NOT NULL:orgCode 必须能解析到客户,否则跳过
|
||||
if (!mapped.orgCode) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
const customerId = customerIdByOrgCode.get(mapped.orgCode);
|
||||
if (!customerId) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
iccImei: mapped.imei || null,
|
||||
deviceCode: mapped.deviceCode || null,
|
||||
deviceName: mapped.deviceName || null,
|
||||
customerId,
|
||||
orgCode: mapped.orgCode || null,
|
||||
orgName: mapped.orgName || null,
|
||||
manufacturerCode: mapped.manufacturerCode || null,
|
||||
manufacturerName: mapped.manufacturerName || null,
|
||||
typeCode: mapped.typeCode || null,
|
||||
typeName: mapped.typeName || null,
|
||||
productModel: mapped.productModel || null,
|
||||
isOnline: mapped.isOnline,
|
||||
syncedAt: now
|
||||
} as any;
|
||||
|
||||
// 幂等 upsert:按非空键匹配(仅包含非空键)
|
||||
const matchWhere: any[] = [];
|
||||
if (mapped.imei) matchWhere.push({ iccImei: mapped.imei });
|
||||
if (mapped.deviceCode) matchWhere.push({ deviceCode: mapped.deviceCode });
|
||||
|
||||
const existing = await this.gatewayModel.findOne({ where: { [Op.or]: matchWhere } });
|
||||
if (existing) {
|
||||
await existing.update({ ...payload, syncedAt: now });
|
||||
updated++;
|
||||
} else {
|
||||
await this.gatewayModel.create(payload);
|
||||
inserted++;
|
||||
}
|
||||
}
|
||||
|
||||
return { inserted, updated, skipped };
|
||||
}
|
||||
|
||||
private async writeLog(startTime: Date, status: number, syncType: string, recordCount: number, errorMessage: string | null): Promise<void> {
|
||||
try {
|
||||
await this.syncLogModel.create({
|
||||
syncType,
|
||||
startTime,
|
||||
endTime: new Date(),
|
||||
recordCount,
|
||||
status,
|
||||
errorMessage
|
||||
} as any);
|
||||
} catch (err) {
|
||||
console.error("[ICC同步] 同步日志写入失败:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { LogService } from "./log.service";
|
||||
import { NoticeRuleService } from "./notice_rule.service";
|
||||
import { IccLineSyncService } from "./icc_line_sync.service";
|
||||
import { IccCustomerSyncService } from "./icc_customer_sync.service";
|
||||
import { IccGatewaySyncService } from "./icc_gateway_sync.service";
|
||||
import { BuiltinJob, JobHandlerContext, JobRuntime, getBuiltinJob } from "../task/job_registry";
|
||||
|
||||
/** 任务执行结果(一次尝试) */
|
||||
@@ -459,6 +460,12 @@ export class JobSchedulerService {
|
||||
const iccCustomerService = (await ctx.requestContext.getAsync(IccCustomerSyncService)) as IccCustomerSyncService;
|
||||
const result = await iccCustomerService.syncFromIcc();
|
||||
return `拉取 ${result.pulled} 条,新增 ${result.inserted},更新 ${result.updated},跳过 ${result.skipped}`;
|
||||
},
|
||||
syncIccGateways: async () => {
|
||||
const ctx = this.app.createAnonymousContext();
|
||||
const iccGatewayService = (await ctx.requestContext.getAsync(IccGatewaySyncService)) as IccGatewaySyncService;
|
||||
const result = await iccGatewayService.syncFromIcc();
|
||||
return `拉取 ${result.pulled} 条,新增 ${result.inserted},更新 ${result.updated},跳过 ${result.skipped}`;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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: [] };
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
+4
-4
@@ -4537,10 +4537,10 @@ undici-types@~6.21.0:
|
||||
resolved "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz"
|
||||
integrity sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==
|
||||
|
||||
undici@^8.12.2:
|
||||
version "8.12.2"
|
||||
resolved "https://registry.yarnpkg.com/undici/-/undici-8.12.2.tgz#e869237430adcc5de6bca798c94edd7db9dac5ac"
|
||||
integrity sha512-XtuDWVdqTLr3NAxm2ly9/hU6kUsJOgGh1RASzMBbsw6aWCOaQzHdW+QQDfdERjfJ2V5vL5pRPa4RetO9UfivDg==
|
||||
undici@^8.12.3:
|
||||
version "8.12.3"
|
||||
resolved "https://registry.yarnpkg.com/undici/-/undici-8.12.3.tgz#e76a91feee9c275ad0f43bfc0eb632b6124d3bb6"
|
||||
integrity sha512-+fdndUTwBP86cVjihTp21hZHo7d6oj1c/47XPA3lL8mM0XHs+C4EkcjmgRsFnFk2f/UONGDy9ItBItmXsLOQHw==
|
||||
|
||||
universalify@^0.1.0:
|
||||
version "0.1.2"
|
||||
|
||||
Reference in new issue
Block a user