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; @InjectRepository(IccSyncLog) syncLogModel!: Repository; @InjectRepository(Customer) customerModel!: Repository; async syncFromIcc(ctx?: Context): Promise { 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 { try { await this.syncLogModel.create({ syncType, startTime, endTime: new Date(), recordCount, status, errorMessage } as any); } catch (err) { console.error("[ICC同步] 同步日志写入失败:", err); } } }