import { Provide } from "@midwayjs/core"; import { InjectRepository } from "@midwayjs/sequelize"; import { Repository } from "sequelize-typescript"; import { Context } from "@midwayjs/koa"; import ElectricityData from "../entity/electricity_data.entity"; import ElectricLine from "../entity/electric_line.entity"; import IccSyncLog from "../entity/icc_sync_log.entity"; import { getIccAccessToken, iccFetch } from "../task/icc_customer_auth"; import { ICC_ANALOG_ENDPOINT, mapIccAnalogRecord, parseAnalogPage, IccAnalogRawRecord } from "../task/icc_analog_mapper"; export interface IccAnalogSyncResult { /** ICC 拉取到的原始模拟量条数 */ pulled: number; /** 成功落库的模拟量条数(新增+更新) */ synced: number; /** 新增的模拟量条数 */ inserted: number; /** 更新的模拟量条数 */ updated: number; /** 跳过的条数(线路未同步等) */ skipped: number; } const SYNC_TYPE = "electricity_data"; /** 模拟量接口每页上限(短页判定阈值,ICC 接口约定) */ const PAGE_SIZE = 500; /** deviceCodes 每批最多 50 个(ICC 模拟量接口约定) */ const DEVICE_BATCH = 50; /** 最大分页数保护,防止异常接口导致无限拉取 */ const MAX_PAGES = 100; /** ICC_VERSION 缺省值(与 icc_customer_auth 保持一致) */ const DEFAULT_VERSION = "1.0.0"; /** HTTP 超时 60s(项目约定) */ const HTTP_TIMEOUT_MS = 60000; /** 线路预取信息(用于模拟量落库时回填 lineId / lineName / customerId,避免 N+1) */ interface LineMatchInfo { lineId: number; lineName?: string; customerId: number; } /** * ICC 探测器模拟量数据同步(默认每 5 分钟执行一次): * 1) 复用 icc_customer_auth 的真实 OAuth2 密码模式鉴权(getIccAccessToken / iccFetch), * 从 .env 读取 ICC_BASE_URL / ICC_VERSION * 2) 先读本系统已同步的 electric_line(line_code = ICC deviceCode),把 line_code 每批 ≤50 * 作为 deviceCodes 分批 POST 模拟量接口(/analog/subsystem/page,pageSize=500 短页停止) * 3) 以同步时间作采集时间,按唯一键 (line_code, analog_type, collect_time) 幂等 upsert * 写入 electricity_data(同一次同步内重复则覆盖更新) * 4) 每次同步写一条 icc_sync_log(sync_type=electricity_data);拉取失败仅记录 error,不抛业务错误 */ @Provide() export class IccAnalogSyncService { @InjectRepository(ElectricityData) electricityModel!: Repository; @InjectRepository(ElectricLine) lineModel!: Repository; @InjectRepository(IccSyncLog) syncLogModel!: Repository; async syncFromIcc(ctx?: Context): Promise { const startTime = new Date(); // 采集时间 = 同步拉取时间(ICC 最新模拟量接口实际不返回 reportTime,经确认以同步时间作采集时间/分区键) const collectTime = startTime; const baseUrl = process.env.ICC_BASE_URL?.trim(); const result: IccAnalogSyncResult = { 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 { // 预取本系统已同步线路 → line_code → {lineId,lineName,customerId} 映射,避免 N+1 查询 const lines = await this.lineModel.findAll({ attributes: ["id", "lineCode", "lineName", "customerId"] }); const lineMap = new Map(); for (const line of lines) { if (!line.lineCode) continue; lineMap.set(line.lineCode, { lineId: line.id, lineName: line.lineName || undefined, customerId: line.customerId }); } // 本系统尚未同步任何线路(无 deviceCode 可查),直接写成功日志返回 if (lineMap.size === 0) { await this.writeLog(startTime, 1, SYNC_TYPE, 0, null); return result; } const token = await getIccAccessToken(); const url = `${baseUrl.replace(/\/$/, "")}${ICC_ANALOG_ENDPOINT.replace("{version}", process.env.ICC_VERSION?.trim() || DEFAULT_VERSION)}`; // lineCodes 按 ≤DEVICE_BATCH 分批,逐批拉取模拟量 const allRaw: IccAnalogRawRecord[] = []; const lineCodes = [...lineMap.keys()]; for (let i = 0; i < lineCodes.length; i += DEVICE_BATCH) { const batch = lineCodes.slice(i, i + DEVICE_BATCH); 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, deviceCodes: batch }), signal: AbortSignal.timeout(HTTP_TIMEOUT_MS) }); if (!res.ok) { throw new Error(`ICC 模拟量接口返回异常:HTTP ${res.status}`); } const payload: any = await res.json(); const page = parseAnalogPage(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, lineMap, collectTime); 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; } } /** 按 (line_code, analog_type, collect_time) 幂等 upsert(collectTime 为本次同步时间);线路未同步的行跳过。 */ private async batchUpsert( records: IccAnalogRawRecord[], lineMap: Map, collectTime: Date ): Promise<{ inserted: number; updated: number; skipped: number }> { let inserted = 0; let updated = 0; let skipped = 0; if (records.length === 0) return { inserted, updated, skipped }; for (const raw of records) { const mapped = mapIccAnalogRecord(raw); // deviceCode 必须在本系统已同步的线路中(line_code = ICC deviceCode),否则跳过 const lineInfo = lineMap.get(mapped.deviceCode); if (!lineInfo) { skipped++; continue; } const payload = { lineId: lineInfo.lineId, lineCode: mapped.deviceCode, imei: mapped.uniqueCode || null, lineName: lineInfo.lineName || null, customerId: lineInfo.customerId, analogType: mapped.analogType || null, analogName: mapped.analogName || null, analogUnit: mapped.analogUnit || null, analogValue: mapped.analogValue, collectTime } as any; // 幂等 upsert:唯一键 (line_code, analog_type, collect_time)(collectTime 为本次同步时间), // 同一次同步内同一 device+type 重复出现(如跨页)则覆盖更新 const existing = await this.electricityModel.findOne({ where: { lineCode: mapped.deviceCode, analogType: mapped.analogType || null, collectTime } }); if (existing) { await existing.update({ analogValue: mapped.analogValue, imei: mapped.uniqueCode || null, lineName: lineInfo.lineName || null, analogName: mapped.analogName || null, analogUnit: mapped.analogUnit || null } as any); updated++; } else { await this.electricityModel.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); } } }