merge: 合并 xtzd/main 的报警监控、ICC 网关/模拟量/报警订阅与菜单修复提交
冲突解决:auth.middleware.ts 与 rbac.middleware.ts 的 ignore 白名单取并集,同时保留 /api/customer-app/*(小程序客户鉴权)与 /api/icc-event/callback(ICC 事件回调 URL token 校验)。
This commit is contained in:
commit
6aea23c070
72 files changed
+6256
-639
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": {
|
||||
|
||||
@@ -118,6 +118,14 @@ export default {
|
||||
keyPrefix: "customer-visit"
|
||||
},
|
||||
|
||||
// ICC 事件订阅配置(回调地址需 ICC 服务器可访问;订阅保活每 30 分钟向 ICC 重新注册)
|
||||
iccEvent: {
|
||||
monitorUrl: "http://222.243.55.42:7001/api/icc-event/callback", // 回调地址(ICC 服务器需可访问)
|
||||
callbackToken: "xtzd_icc_event_2026", // 回调 URL 固定 token
|
||||
subscriberName: "222.243.55.42_7001_alarm", // 订阅者唯一名
|
||||
subscriberMagic: "222.243.55.42_7001" // IP_端口
|
||||
},
|
||||
|
||||
cors: {
|
||||
origin: "*",
|
||||
allowMethods: "GET,HEAD,PUT,POST,DELETE,PATCH,OPTIONS",
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Controller, Get, Inject, Query } from "@midwayjs/core";
|
||||
import { Context } from "@midwayjs/koa";
|
||||
import { InjectRepository } from "@midwayjs/sequelize";
|
||||
import { Repository } from "sequelize-typescript";
|
||||
import { Op } from "sequelize";
|
||||
import AlarmDispatch from "../entity/alarm_dispatch.entity";
|
||||
import Worker from "../entity/worker.entity";
|
||||
import Customer from "../entity/customer.entity";
|
||||
|
||||
@Controller("/api/alarm-dispatches")
|
||||
export class AlarmDispatchController {
|
||||
@Inject() ctx!: Context;
|
||||
|
||||
@InjectRepository(AlarmDispatch)
|
||||
dispatchRepo!: Repository<AlarmDispatch>;
|
||||
|
||||
@InjectRepository(Worker)
|
||||
workerRepo!: Repository<Worker>;
|
||||
|
||||
@InjectRepository(Customer)
|
||||
customerRepo!: Repository<Customer>;
|
||||
|
||||
/** 报警派单台账分页列表(联出电工姓名/客户名称) */
|
||||
@Get("/") async list(@Query() query: any) {
|
||||
const page = Math.max(Number(query.page) || 1, 1);
|
||||
const rawPageSize = Number(query.pageSize) || 10;
|
||||
const pageSize = Math.min(Math.max(rawPageSize, 1), 200);
|
||||
|
||||
const where: any = {};
|
||||
if (query.status !== undefined && query.status !== "") where.status = Number(query.status);
|
||||
if (query.keyword) {
|
||||
const kw = String(query.keyword).trim();
|
||||
const ors: any[] = [
|
||||
{ dispatchNo: { [Op.like]: `%${kw}%` } },
|
||||
{ lineName: { [Op.like]: `%${kw}%` } },
|
||||
{ lineCode: { [Op.like]: `%${kw}%` } }
|
||||
];
|
||||
// 关键字命中电工姓名时联出匹配的电工 ID 参与过滤
|
||||
const workers = await this.workerRepo.findAll({ where: { workerName: { [Op.like]: `%${kw}%` } }, attributes: ["id"] });
|
||||
if (workers.length) ors.push({ workerId: { [Op.in]: workers.map((w) => w.id) } });
|
||||
where[Op.or] = ors;
|
||||
}
|
||||
|
||||
const { count, rows } = await this.dispatchRepo.findAndCountAll({
|
||||
where,
|
||||
limit: pageSize,
|
||||
offset: (page - 1) * pageSize,
|
||||
order: [["createdAt", "DESC"]]
|
||||
});
|
||||
|
||||
// 联出电工姓名与客户名称(二次查询组装)
|
||||
const workerIds = [...new Set(rows.map((r) => Number(r.workerId)).filter(Boolean))];
|
||||
const customerIds = [...new Set(rows.map((r) => Number(r.customerId)).filter(Boolean))];
|
||||
const [workers, customers] = await Promise.all([
|
||||
workerIds.length ? this.workerRepo.findAll({ where: { id: { [Op.in]: workerIds } }, attributes: ["id", "workerName"] }) : Promise.resolve<Worker[]>([]),
|
||||
customerIds.length ? this.customerRepo.findAll({ where: { id: { [Op.in]: customerIds } }, attributes: ["id", "customerName"] }) : Promise.resolve<Customer[]>([])
|
||||
]);
|
||||
const workerMap = new Map(workers.map((w) => [Number(w.id), w.workerName]));
|
||||
const customerMap = new Map(customers.map((c) => [Number(c.id), c.customerName]));
|
||||
const list = rows.map((r) => ({
|
||||
...r.toJSON(),
|
||||
workerName: workerMap.get(Number(r.workerId)) || "",
|
||||
customerName: customerMap.get(Number(r.customerId)) || ""
|
||||
}));
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: "获取报警派单台账成功",
|
||||
data: { list, total: count, page, pageSize }
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { Controller, Get, Post, Inject, Query, Body, Param } from "@midwayjs/core";
|
||||
import { Context } from "@midwayjs/koa";
|
||||
import { InjectRepository } from "@midwayjs/sequelize";
|
||||
import { Repository } from "sequelize-typescript";
|
||||
import { Op, UniqueConstraintError } from "sequelize";
|
||||
import DeviceAlarm from "../entity/device_alarm.entity";
|
||||
import AlarmDispatch from "../entity/alarm_dispatch.entity";
|
||||
import Worker from "../entity/worker.entity";
|
||||
import Customer from "../entity/customer.entity";
|
||||
import SysUser from "../entity/sysUser.entity";
|
||||
import { NoticeService } from "../service/notice.service";
|
||||
|
||||
@Controller("/api/device-alarms")
|
||||
export class DeviceAlarmController {
|
||||
@Inject() ctx!: Context;
|
||||
@Inject() noticeService!: NoticeService;
|
||||
|
||||
@InjectRepository(DeviceAlarm)
|
||||
alarmRepo!: Repository<DeviceAlarm>;
|
||||
|
||||
@InjectRepository(AlarmDispatch)
|
||||
dispatchRepo!: Repository<AlarmDispatch>;
|
||||
|
||||
@InjectRepository(Worker)
|
||||
workerRepo!: Repository<Worker>;
|
||||
|
||||
@InjectRepository(Customer)
|
||||
customerRepo!: Repository<Customer>;
|
||||
|
||||
@InjectRepository(SysUser)
|
||||
userRepo!: Repository<SysUser>;
|
||||
|
||||
/** 报警分页列表(仅 line_id 非空的记录)+ 统计 */
|
||||
@Get("/") async list(@Query() query: any) {
|
||||
const page = Math.max(Number(query.page) || 1, 1);
|
||||
const rawPageSize = Number(query.pageSize) || 10;
|
||||
const pageSize = Math.min(Math.max(rawPageSize, 1), 200);
|
||||
|
||||
const where: any = { lineId: { [Op.ne]: null } };
|
||||
if (query.customerId !== undefined && query.customerId !== "") where.customerId = Number(query.customerId);
|
||||
if (query.alarmGrade !== undefined && query.alarmGrade !== "") where.alarmGrade = Number(query.alarmGrade);
|
||||
if (query.keyword) {
|
||||
where[Op.or] = [
|
||||
{ lineName: { [Op.like]: `%${query.keyword}%` } },
|
||||
{ lineCode: { [Op.like]: `%${query.keyword}%` } },
|
||||
{ deviceName: { [Op.like]: `%${query.keyword}%` } }
|
||||
];
|
||||
}
|
||||
|
||||
const { count, rows } = await this.alarmRepo.findAndCountAll({
|
||||
where,
|
||||
limit: pageSize,
|
||||
offset: (page - 1) * pageSize,
|
||||
order: [["alarmTime", "DESC"]]
|
||||
});
|
||||
|
||||
// 统计:今日报警数(alarm_time >= 当天0点)/ 严重报警数(grade=1)/ 未派单数(无 status=0 派单)
|
||||
const d = new Date(new Date().getTime() + 8 * 60 * 60 * 1000);
|
||||
const today = `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`;
|
||||
const todayStart = new Date(`${today}T00:00:00+08:00`);
|
||||
// 统计:今日报警数(alarm_time >= 当天0点)/ 严重报警数(grade=1)
|
||||
const [todayCount, gradeOneCount] = await Promise.all([
|
||||
this.alarmRepo.count({ where: { lineId: { [Op.ne]: null }, alarmTime: { [Op.gte]: todayStart } } }),
|
||||
this.alarmRepo.count({ where: { lineId: { [Op.ne]: null }, alarmGrade: 1 } })
|
||||
]);
|
||||
// 活跃严重报警 id 集合(grade=1 且无已完成派单,按报警时间倒序取最近 100 条,供前端全局报警灯/声音触发,不受分页筛选影响)
|
||||
const activeSevereIds = await this.getActiveSevereIds();
|
||||
const dispatchedAlarms = await this.dispatchRepo.findAll({ where: { status: 0 }, attributes: ["alarmId"], raw: true });
|
||||
const dispatchedIds = [...new Set(dispatchedAlarms.map((r: any) => Number(r.alarmId)))];
|
||||
const undispatchedCount = await this.alarmRepo.count({
|
||||
where: {
|
||||
lineId: { [Op.ne]: null },
|
||||
id: dispatchedIds.length ? { [Op.notIn]: dispatchedIds } : { [Op.ne]: null }
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: "获取报警列表成功",
|
||||
data: {
|
||||
list: rows.map((r) => r.toJSON()),
|
||||
total: count,
|
||||
page,
|
||||
pageSize,
|
||||
summary: { todayCount, gradeOneCount, undispatchedCount, activeSevereIds }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** 活跃严重报警 id 轻量接口(供全局悬浮警灯轮询,rbac 前缀权限 alarm:list 已覆盖) */
|
||||
@Get("/active-severe") async activeSevere() {
|
||||
const activeSevereIds = await this.getActiveSevereIds();
|
||||
return {
|
||||
code: 200,
|
||||
message: "获取活跃严重报警成功",
|
||||
data: { activeSevereIds }
|
||||
};
|
||||
}
|
||||
|
||||
// 活跃严重报警 id 集合:grade=1、lineId 非空、且无 status=1 已完成派单的报警,
|
||||
// 按报警时间倒序取最近 100 条,供前端全局报警灯/声音触发,不受分页筛选影响
|
||||
private async getActiveSevereIds(): Promise<number[]> {
|
||||
const completedAlarms = await this.dispatchRepo.findAll({ where: { status: 1 }, attributes: ["alarmId"], raw: true });
|
||||
const completedAlarmIds = [...new Set(completedAlarms.map((r: any) => Number(r.alarmId)))];
|
||||
const activeSevereAlarms = await this.alarmRepo.findAll({
|
||||
where: {
|
||||
lineId: { [Op.ne]: null },
|
||||
alarmGrade: 1,
|
||||
id: completedAlarmIds.length ? { [Op.notIn]: completedAlarmIds } : { [Op.ne]: null }
|
||||
},
|
||||
attributes: ["id"],
|
||||
order: [["alarmTime", "DESC"]],
|
||||
limit: 100,
|
||||
raw: true
|
||||
});
|
||||
return activeSevereAlarms.map((r: any) => Number(r.id));
|
||||
}
|
||||
|
||||
/** 发起派单:校验报警/未完成派单/电工,生成派单号,写入派单并给电工发站内通知 */
|
||||
@Post("/:id/dispatch") async dispatch(@Param("id") id: string, @Body() body: any) {
|
||||
const alarmId = Number(id);
|
||||
if (!alarmId || Number.isNaN(alarmId)) return { code: 400, message: "缺少有效的报警 ID", data: null };
|
||||
|
||||
const alarm = await this.alarmRepo.findByPk(alarmId);
|
||||
if (!alarm) return { code: 404, message: "报警记录不存在", data: null };
|
||||
if (!alarm.lineId) return { code: 400, message: "该报警未关联线路,无法派单", data: null };
|
||||
|
||||
const existing = await this.dispatchRepo.findOne({ where: { alarmId, status: 0 } });
|
||||
if (existing) return { code: 400, message: "该报警已有未完成的派单", data: null };
|
||||
|
||||
const workerId = Number(body.workerId);
|
||||
if (!workerId || Number.isNaN(workerId)) return { code: 400, message: "缺少有效的 workerId 参数", data: null };
|
||||
const worker = await this.workerRepo.findByPk(workerId);
|
||||
if (!worker) return { code: 400, message: "共享电工不存在", data: null };
|
||||
if (Number(worker.status) !== 1) return { code: 400, message: "该电工不在岗,无法派单", data: null };
|
||||
if (Number(worker.auditStatus) !== 1) return { code: 400, message: "该电工审核未通过,无法派单", data: null };
|
||||
|
||||
// 派发人:当前登录用户 realName(缺失时回退 username)
|
||||
const loginUserId = (this.ctx.state.user as any)?.userId;
|
||||
const loginUser = loginUserId ? await this.userRepo.findByPk(loginUserId, { attributes: ["realName", "username"] }) : null;
|
||||
const dispatcher = loginUser?.realName || (loginUser as any)?.username || "未知";
|
||||
|
||||
// 客户名称(通知文案用,查不到时回退客户 ID)
|
||||
let customerName = `#${alarm.customerId}`;
|
||||
if (alarm.customerId) {
|
||||
const customer = await this.customerRepo.findByPk(alarm.customerId, { attributes: ["customerName"] });
|
||||
if (customer) customerName = customer.customerName;
|
||||
}
|
||||
|
||||
const payload: any = {
|
||||
alarmId: alarm.id,
|
||||
lineId: alarm.lineId,
|
||||
lineCode: alarm.lineCode,
|
||||
lineName: alarm.lineName,
|
||||
customerId: alarm.customerId,
|
||||
alarmTime: alarm.alarmTime,
|
||||
alarmGrade: alarm.alarmGrade,
|
||||
faultDesc: body.faultDesc || "",
|
||||
dispatcher,
|
||||
workerId: worker.id,
|
||||
status: 0
|
||||
};
|
||||
|
||||
// 派单号生成 + 写入(唯一冲突时重试一次)
|
||||
let dispatch: AlarmDispatch;
|
||||
try {
|
||||
payload.dispatchNo = await this.generateDispatchNo();
|
||||
dispatch = await this.dispatchRepo.create(payload);
|
||||
} catch (err: any) {
|
||||
if (err instanceof UniqueConstraintError) {
|
||||
payload.dispatchNo = await this.generateDispatchNo();
|
||||
dispatch = await this.dispatchRepo.create(payload);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// 派单站内通知(通知失败不得阻断派单)
|
||||
try {
|
||||
await this.noticeService.deliverToReceivers(
|
||||
{
|
||||
noticeType: "alarm_dispatch",
|
||||
title: `新报警派单:${alarm.lineName}`,
|
||||
content: `线路:${alarm.lineName}\n客户:${customerName}\n报警时间:${this.formatTime(alarm.alarmTime)}\n报警等级:${alarm.alarmGrade}级`,
|
||||
bizType: "alarm_dispatch",
|
||||
bizId: Number(dispatch.id)
|
||||
},
|
||||
{ workers: { mode: "ids", ids: [worker.id] } }
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("[notice] 报警派单通知失败:", err);
|
||||
}
|
||||
|
||||
return { code: 200, message: "报警派单成功", data: dispatch.toJSON() };
|
||||
}
|
||||
|
||||
// 派单号:AD + 东八区 yyyyMMdd + 3 位当日序号(当天已创建数量 + 1,冲突则递增重试)
|
||||
private async generateDispatchNo(): Promise<string> {
|
||||
const d = new Date(new Date().getTime() + 8 * 60 * 60 * 1000);
|
||||
const yyyymmdd = `${d.getUTCFullYear()}${String(d.getUTCMonth() + 1).padStart(2, "0")}${String(d.getUTCDate()).padStart(2, "0")}`;
|
||||
const prefix = `AD${yyyymmdd}`;
|
||||
const dayStart = new Date(`${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}T00:00:00+08:00`);
|
||||
const count = await this.dispatchRepo.count({ where: { createdAt: { [Op.gte]: dayStart } } });
|
||||
for (let i = 1; i <= 100; i++) {
|
||||
const no = `${prefix}${String(count + i).padStart(3, "0")}`;
|
||||
const exists = await this.dispatchRepo.findOne({ where: { dispatchNo: no }, attributes: ["id"] });
|
||||
if (!exists) return no;
|
||||
}
|
||||
throw new Error("派单号生成冲突,请重试");
|
||||
}
|
||||
|
||||
private formatTime(t?: Date): string {
|
||||
if (!t) return "-";
|
||||
return new Date(t).toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
}
|
||||
@@ -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,54 @@
|
||||
import { Controller, Post, Get, Query, Inject } from "@midwayjs/core";
|
||||
import { Context } from "@midwayjs/koa";
|
||||
import { InjectRepository } from "@midwayjs/sequelize";
|
||||
import { Repository } from "sequelize-typescript";
|
||||
import { IccAnalogSyncService } from "../service/icc_analog_sync.service";
|
||||
import ElectricityData from "../entity/electricity_data.entity";
|
||||
|
||||
@Controller("/api/electricity-data")
|
||||
export class ElectricityDataController {
|
||||
@Inject() ctx!: Context;
|
||||
@Inject() iccAnalogSyncService!: IccAnalogSyncService;
|
||||
@InjectRepository(ElectricityData)
|
||||
electricityDataRepo!: Repository<ElectricityData>;
|
||||
|
||||
@Post("/sync") async sync() {
|
||||
const result = await this.iccAnalogSyncService.syncFromIcc(this.ctx);
|
||||
return { code: 200, message: "模拟量数据同步完成", data: result };
|
||||
}
|
||||
|
||||
/** 查询某线路最近一次同步的模拟量快照(取最大 collect_time 的全部模拟量行) */
|
||||
@Get("/latest") async latest(@Query("lineId") lineId?: string) {
|
||||
const id = Number(lineId);
|
||||
if (!id || Number.isNaN(id)) {
|
||||
return { code: 400, message: "缺少有效的 lineId 参数" };
|
||||
}
|
||||
const latest = await this.electricityDataRepo.findOne({
|
||||
where: { lineId: id },
|
||||
order: [["collectTime", "DESC"]],
|
||||
attributes: ["lineCode", "lineName", "imei", "customerId", "collectTime"],
|
||||
});
|
||||
if (!latest) {
|
||||
return { code: 200, message: "该线路暂无模拟量数据", data: { lineId: id, hasData: false, collectTime: null, list: [] } };
|
||||
}
|
||||
const rows = await this.electricityDataRepo.findAll({
|
||||
where: { lineId: id, collectTime: latest.getDataValue("collectTime") as Date },
|
||||
order: [["analogType", "ASC"]],
|
||||
attributes: ["analogType", "analogName", "analogUnit", "analogValue"],
|
||||
});
|
||||
return {
|
||||
code: 200,
|
||||
message: "ok",
|
||||
data: {
|
||||
lineId: id,
|
||||
lineCode: latest.getDataValue("lineCode"),
|
||||
lineName: latest.getDataValue("lineName"),
|
||||
imei: latest.getDataValue("imei"),
|
||||
customerId: latest.getDataValue("customerId"),
|
||||
collectTime: latest.getDataValue("collectTime"),
|
||||
hasData: true,
|
||||
list: rows,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Controller, Post, Get, Inject, Body, Query, Config } from "@midwayjs/core";
|
||||
import { Context } from "@midwayjs/koa";
|
||||
import { IccEventSubscribeService } from "../service/icc_event_subscribe.service";
|
||||
|
||||
/**
|
||||
* ICC 事件订阅相关接口:
|
||||
* - POST /api/icc-event/callback ICC 报警事件回调(不走登录鉴权/RBAC,靠 URL token 校验)
|
||||
* - POST /api/icc-event/subscribe 手动触发订阅注册/保活
|
||||
* - GET /api/icc-event/subscribe-status 查询订阅配置与最近一次注册结果
|
||||
*/
|
||||
@Controller("/api/icc-event")
|
||||
export class IccEventController {
|
||||
@Inject()
|
||||
ctx!: Context;
|
||||
|
||||
@Inject()
|
||||
iccEventSubscribeService!: IccEventSubscribeService;
|
||||
|
||||
@Config("iccEvent")
|
||||
iccEventConfig!: { monitorUrl: string; callbackToken: string; subscriberName: string; subscriberMagic: string };
|
||||
|
||||
/** ICC 回调入口:仅校验 URL token,校验通过后解析入库报警事件 */
|
||||
@Post("/callback")
|
||||
async callback(@Query() query: any, @Body() body: any) {
|
||||
if (query.token !== this.iccEventConfig.callbackToken) {
|
||||
this.ctx.status = 401;
|
||||
return { code: 401, message: "invalid token" };
|
||||
}
|
||||
await this.iccEventSubscribeService.handleCallback(body);
|
||||
// ICC 文档要求的返回体
|
||||
return { success: true, code: "0", errMsg: "" };
|
||||
}
|
||||
|
||||
/** 手动触发订阅注册(保活:ICC 同名覆盖) */
|
||||
@Post("/subscribe")
|
||||
async subscribe() {
|
||||
const result = await this.iccEventSubscribeService.ensureSubscribe();
|
||||
return {
|
||||
code: result.success ? 200 : 500,
|
||||
message: result.success ? "ICC 事件订阅成功" : `ICC 事件订阅失败:${result.errMsg ?? ""}`,
|
||||
data: result
|
||||
};
|
||||
}
|
||||
|
||||
/** 查询订阅配置与最近一次注册结果 */
|
||||
@Get("/subscribe-status")
|
||||
async status() {
|
||||
return {
|
||||
code: 200,
|
||||
message: "获取 ICC 事件订阅状态成功",
|
||||
data: await this.iccEventSubscribeService.getSubscribeStatus()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -150,6 +150,12 @@ export class WorkerAppController {
|
||||
return this.workerAppService.completeOrder(this.currentWorkerId(), source, Number(id));
|
||||
}
|
||||
|
||||
// 报警派单完成确认
|
||||
@Post("/alarm-dispatches/:id/complete")
|
||||
async completeAlarmDispatch(@Param("id") id: string) {
|
||||
return this.workerAppService.completeAlarmDispatch(this.currentWorkerId(), Number(id));
|
||||
}
|
||||
|
||||
// 勘察明细维护(仅勘察单):全量替换,含建筑/楼层/房间与现场照片
|
||||
@Put("/orders/:source/:id/details")
|
||||
async saveDetails(@Param("source") source: string, @Param("id") id: string, @Body() body: any) {
|
||||
|
||||
@@ -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,62 @@
|
||||
import { Column, Model, Table, DataType, BelongsTo } from "sequelize-typescript";
|
||||
import DeviceAlarm from "./device_alarm.entity";
|
||||
|
||||
@Table({
|
||||
tableName: "alarm_dispatch",
|
||||
comment: "报警派单信息表",
|
||||
timestamps: false,
|
||||
indexes: [
|
||||
{ name: "idx_alarm_disp_status", fields: ["status"] },
|
||||
{ name: "idx_alarm_disp_worker", fields: ["worker_id"] },
|
||||
{ name: "idx_alarm_disp_alarm", fields: ["alarm_id"] }
|
||||
]
|
||||
})
|
||||
export default class AlarmDispatch extends Model {
|
||||
@Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true })
|
||||
id!: number;
|
||||
|
||||
@Column({ type: DataType.STRING(64), allowNull: false, unique: true, field: "dispatch_no", comment: "报警派单号(唯一)" })
|
||||
dispatchNo!: string;
|
||||
|
||||
@Column({ type: DataType.BIGINT, field: "alarm_id", comment: "报警 ID(→ device_alarm)" })
|
||||
alarmId!: number;
|
||||
|
||||
@Column({ type: DataType.BIGINT, field: "line_id", comment: "线路 ID" })
|
||||
lineId!: number;
|
||||
|
||||
@Column({ type: DataType.STRING(64), field: "line_code", comment: "线路编码" })
|
||||
lineCode!: string;
|
||||
|
||||
@Column({ type: DataType.STRING(128), field: "line_name", comment: "线路名称" })
|
||||
lineName!: string;
|
||||
|
||||
@Column({ type: DataType.BIGINT, field: "customer_id", comment: "客户 ID(→ customer)" })
|
||||
customerId!: number;
|
||||
|
||||
@Column({ type: DataType.DATE, field: "alarm_time", comment: "报警时间(快照)" })
|
||||
alarmTime!: Date;
|
||||
|
||||
@Column({ type: DataType.SMALLINT, field: "alarm_grade", comment: "报警等级(快照)" })
|
||||
alarmGrade!: number;
|
||||
|
||||
@Column({ type: DataType.TEXT, field: "fault_desc", comment: "故障事项(报警类型描述)" })
|
||||
faultDesc!: string;
|
||||
|
||||
@Column({ type: DataType.STRING(64), comment: "派发人(当前登录用户 realName)" })
|
||||
dispatcher!: string;
|
||||
|
||||
@Column({ type: DataType.BIGINT, field: "worker_id", comment: "共享电工 ID(→ worker)" })
|
||||
workerId!: number;
|
||||
|
||||
@Column({ type: DataType.SMALLINT, allowNull: false, defaultValue: 0, comment: "状态:0已派发 1已处理" })
|
||||
status!: number;
|
||||
|
||||
@Column({ type: DataType.DATE, field: "created_at", defaultValue: DataType.NOW, comment: "创建时间" })
|
||||
createdAt!: Date;
|
||||
|
||||
@Column({ type: DataType.DATE, field: "completed_at", comment: "完成时间" })
|
||||
completedAt!: Date;
|
||||
|
||||
@BelongsTo(() => DeviceAlarm, { foreignKey: "alarmId", as: "alarm" })
|
||||
alarm!: DeviceAlarm;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Column, Model, Table, DataType } from "sequelize-typescript";
|
||||
|
||||
@Table({
|
||||
tableName: "device_alarm",
|
||||
comment: "设备报警信息表(ICC 事件订阅)",
|
||||
timestamps: false,
|
||||
indexes: [
|
||||
{ name: "idx_alarm_time", fields: [{ name: "alarm_time", order: "DESC" }] },
|
||||
{ name: "idx_alarm_line", fields: ["line_id"] },
|
||||
{ name: "idx_alarm_customer", fields: ["customer_id"] },
|
||||
{ name: "idx_alarm_grade", fields: ["alarm_grade"] }
|
||||
]
|
||||
})
|
||||
export default class DeviceAlarm extends Model {
|
||||
@Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true })
|
||||
id!: number;
|
||||
|
||||
@Column({ type: DataType.STRING(128), allowNull: false, unique: true, field: "alarm_code", comment: "ICC 报警编码(幂等键)" })
|
||||
alarmCode!: string;
|
||||
|
||||
@Column({ type: DataType.STRING(128), comment: "ICC 事件 uuid" })
|
||||
uuid!: string;
|
||||
|
||||
@Column({ type: DataType.BIGINT, field: "line_id", comment: "智电线路 ID(→ electric_line,nodeCode/deviceCode 匹配 line_code)" })
|
||||
lineId!: number;
|
||||
|
||||
@Column({ type: DataType.STRING(64), field: "line_code", comment: "线路编码(electric_line.line_code)" })
|
||||
lineCode!: string;
|
||||
|
||||
@Column({ type: DataType.STRING(128), field: "line_name", comment: "线路名称" })
|
||||
lineName!: string;
|
||||
|
||||
@Column({ type: DataType.BIGINT, field: "customer_id", comment: "客户 ID(由 line_id 联出)" })
|
||||
customerId!: number;
|
||||
|
||||
@Column({ type: DataType.STRING(64), field: "node_code", comment: "探测器节点编码(info.nodeCode)" })
|
||||
nodeCode!: string;
|
||||
|
||||
@Column({ type: DataType.STRING(64), field: "device_code", comment: "设备编码(info.deviceCode)" })
|
||||
deviceCode!: string;
|
||||
|
||||
@Column({ type: DataType.STRING(128), field: "device_name", comment: "设备名称(info.deviceName)" })
|
||||
deviceName!: string;
|
||||
|
||||
@Column({ type: DataType.STRING(64), field: "org_code", comment: "组织编码(info.orgCode)" })
|
||||
orgCode!: string;
|
||||
|
||||
@Column({ type: DataType.STRING(128), field: "org_name", comment: "组织名称(info.orgName)" })
|
||||
orgName!: string;
|
||||
|
||||
@Column({ type: DataType.INTEGER, field: "alarm_type", comment: "报警类型(info.alarmType)" })
|
||||
alarmType!: number;
|
||||
|
||||
@Column({ type: DataType.SMALLINT, field: "alarm_grade", comment: "报警等级 1/2/3(1 最严重)" })
|
||||
alarmGrade!: number;
|
||||
|
||||
@Column({ type: DataType.SMALLINT, field: "alarm_stat", comment: "报警状态(info.alarmStat)" })
|
||||
alarmStat!: number;
|
||||
|
||||
@Column({ type: DataType.BOOLEAN, field: "is_event", comment: "是否事件(info.isEvent)" })
|
||||
isEvent!: boolean;
|
||||
|
||||
@Column({ type: DataType.DATE, field: "alarm_time", comment: "报警发生时间(info.alarmDate 秒级时间戳)" })
|
||||
alarmTime!: Date;
|
||||
|
||||
@Column({ type: DataType.DECIMAL(14, 4), field: "alarm_value", comment: "报警数值(部分事件带值,可空)" })
|
||||
alarmValue!: number;
|
||||
|
||||
@Column({ type: DataType.DATE, allowNull: false, field: "synced_at", comment: "入库时间" })
|
||||
syncedAt!: Date;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Column, Model, Table, DataType, CreatedAt, ForeignKey, BelongsTo } from "sequelize-typescript";
|
||||
import CustomerEquipment from "./customer_equipment.entity";
|
||||
import ElectricGateway from "./electric_gateway.entity";
|
||||
import Customer from "./customer.entity";
|
||||
import CustomerBuilding from "./customer_building.entity";
|
||||
|
||||
@Table({
|
||||
tableName: "electric_line",
|
||||
@@ -10,41 +10,60 @@ import CustomerBuilding from "./customer_building.entity";
|
||||
indexes: [
|
||||
{ name: "idx_line_customer", fields: ["customer_id"] },
|
||||
{ name: "idx_line_equip", fields: ["equip_id"] },
|
||||
{ name: "idx_line_icc", fields: ["icc_device_code"] }
|
||||
{ name: "idx_line_gateway", fields: ["gateway_id"] },
|
||||
{ name: "idx_line_imei", fields: ["imei"] }
|
||||
]
|
||||
})
|
||||
export default class ElectricLine extends Model {
|
||||
@Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true })
|
||||
id!: number;
|
||||
|
||||
@Column({ type: DataType.STRING(64), allowNull: false, unique: true, field: "line_code", comment: "线路编码(唯一)" })
|
||||
@Column({ type: DataType.STRING(64), allowNull: false, unique: true, field: "line_code", comment: "探测器编码(detectorCode)唯一幂等键" })
|
||||
lineCode!: string;
|
||||
|
||||
@ForeignKey(() => CustomerEquipment)
|
||||
@Column({ type: DataType.BIGINT, allowNull: false, field: "equip_id", comment: "客户设备 ID(→ customer_equipment)" })
|
||||
equipId!: number;
|
||||
|
||||
@Column({ type: DataType.STRING(128), field: "icc_device_code", comment: "ICC 智电设备编号" })
|
||||
iccDeviceCode!: string;
|
||||
|
||||
@Column({ type: DataType.STRING(128), field: "line_name", comment: "线路名称(ICC 同步)" })
|
||||
@Column({ type: DataType.STRING(128), field: "line_name", comment: "探测器名称(detectorName)" })
|
||||
lineName!: string;
|
||||
|
||||
@Column({ type: DataType.STRING(64), field: "imei", comment: "探测器 IMEI" })
|
||||
imei!: string;
|
||||
|
||||
@ForeignKey(() => CustomerEquipment)
|
||||
@Column({ type: DataType.BIGINT, field: "equip_id", comment: "客户设备 ID(→ customer_equipment,同步不填充)" })
|
||||
equipId!: number;
|
||||
|
||||
@ForeignKey(() => ElectricGateway)
|
||||
@Column({ type: DataType.BIGINT, field: "gateway_id", comment: "网关 ID(→ electric_gateway,由 parentCode↔device_code 匹配)" })
|
||||
gatewayId!: number;
|
||||
|
||||
@ForeignKey(() => Customer)
|
||||
@Column({ type: DataType.BIGINT, allowNull: false, field: "customer_id", comment: "客户 ID(逻辑隔离键)" })
|
||||
@Column({ type: DataType.BIGINT, allowNull: false, field: "customer_id", comment: "客户 ID(由 orgCode→customer.icc_code 解析)" })
|
||||
customerId!: number;
|
||||
|
||||
@ForeignKey(() => CustomerBuilding)
|
||||
@Column({ type: DataType.BIGINT, field: "building_id", comment: "所属建筑 ID(→ customer_building)" })
|
||||
buildingId!: number;
|
||||
@Column({ type: DataType.STRING(32), field: "type_code", comment: "探测器类型编码(typeCode)" })
|
||||
typeCode!: string;
|
||||
|
||||
@Column({ type: DataType.STRING(16), field: "floor_no", comment: "所在楼层" })
|
||||
floorNo!: string;
|
||||
@Column({ type: DataType.STRING(64), field: "type_name", comment: "探测器类型名称(typeName)" })
|
||||
typeName!: string;
|
||||
|
||||
@Column({ type: DataType.STRING(256), comment: "所在位置" })
|
||||
location!: string;
|
||||
@Column({ type: DataType.STRING(64), field: "fire_system_code", comment: "消防系统编码(fireSystemCode)" })
|
||||
fireSystemCode!: string;
|
||||
|
||||
@Column({ type: DataType.SMALLINT, allowNull: false, defaultValue: 1, comment: "状态:1在线 0离线" })
|
||||
@Column({ type: DataType.STRING(128), field: "fire_system_name", comment: "消防系统名称(fireSystemName)" })
|
||||
fireSystemName!: string;
|
||||
|
||||
@Column({ type: DataType.STRING(64), field: "part_unit_loop_code", comment: "部件/单元/回路编码(partUnitLoopCode)" })
|
||||
partUnitLoopCode!: string;
|
||||
|
||||
@Column({ type: DataType.STRING(64), field: "channel_no", comment: "通道号(channelNo)" })
|
||||
channelNo!: string;
|
||||
|
||||
@Column({ type: DataType.STRING(64), field: "channel_sn", comment: "通道序列号(channelSn)" })
|
||||
channelSn!: string;
|
||||
|
||||
@Column({ type: DataType.STRING(16), field: "produce_date", comment: "生产日期(produceDate)" })
|
||||
produceDate!: string;
|
||||
|
||||
@Column({ type: DataType.SMALLINT, allowNull: false, defaultValue: 1, field: "status", comment: "上报状态:1在线 0离线(detectorStatus 归一)" })
|
||||
status!: number;
|
||||
|
||||
@Column({ type: DataType.DATE, field: "synced_at", comment: "ICC 同步时间" })
|
||||
@@ -53,12 +72,12 @@ export default class ElectricLine extends Model {
|
||||
@BelongsTo(() => CustomerEquipment, { foreignKey: "equipId", as: "equipment" })
|
||||
equipment!: CustomerEquipment;
|
||||
|
||||
@BelongsTo(() => ElectricGateway, { foreignKey: "gatewayId", as: "gateway" })
|
||||
gateway!: ElectricGateway;
|
||||
|
||||
@BelongsTo(() => Customer, { foreignKey: "customerId", as: "customer" })
|
||||
customer!: Customer;
|
||||
|
||||
@BelongsTo(() => CustomerBuilding, { foreignKey: "buildingId", as: "building" })
|
||||
building!: CustomerBuilding;
|
||||
|
||||
@CreatedAt
|
||||
created_at!: Date;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Column, Model, Table, DataType, ForeignKey, BelongsTo } from "sequelize-typescript";
|
||||
import ElectricLine from "./electric_line.entity";
|
||||
import Customer from "./customer.entity";
|
||||
|
||||
@Table({
|
||||
tableName: "electricity_data",
|
||||
comment: "线路用电信息表(ICC 模拟量同步,按月分区时序数据)",
|
||||
timestamps: false,
|
||||
indexes: [
|
||||
{ name: "idx_ed_customer", fields: ["customer_id"] },
|
||||
{ name: "idx_ed_line", fields: ["line_id"] },
|
||||
{ name: "idx_ed_time", fields: ["collect_time"] }
|
||||
]
|
||||
})
|
||||
export default class ElectricityData extends Model {
|
||||
@Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true })
|
||||
id!: number;
|
||||
|
||||
@ForeignKey(() => ElectricLine)
|
||||
@Column({ type: DataType.BIGINT, allowNull: false, field: "line_id", comment: "线路 ID(→ electric_line)" })
|
||||
lineId!: number;
|
||||
|
||||
@Column({ type: DataType.STRING(64), allowNull: false, field: "line_code", comment: "线路编码(ICC deviceCode,冗余 + 幂等键)" })
|
||||
lineCode!: string;
|
||||
|
||||
@Column({ type: DataType.STRING(64), field: "imei", comment: "设备唯一标识码(ICC uniqueCode)" })
|
||||
imei!: string;
|
||||
|
||||
@Column({ type: DataType.STRING(128), field: "line_name", comment: "线路名称(冗余,加速展示)" })
|
||||
lineName!: string;
|
||||
|
||||
@ForeignKey(() => Customer)
|
||||
@Column({ type: DataType.BIGINT, allowNull: false, field: "customer_id", comment: "客户 ID(逻辑隔离键)" })
|
||||
customerId!: number;
|
||||
|
||||
@Column({ type: DataType.STRING(64), field: "analog_type", comment: "模拟量类型(ICC analogType)" })
|
||||
analogType!: string;
|
||||
|
||||
@Column({ type: DataType.STRING(128), field: "analog_name", comment: "模拟量名称(ICC analogName,如:剩余电流)" })
|
||||
analogName!: string;
|
||||
|
||||
@Column({ type: DataType.STRING(32), field: "analog_unit", comment: "模拟量单位(ICC analogUnit,如:mA)" })
|
||||
analogUnit!: string;
|
||||
|
||||
@Column({ type: DataType.DECIMAL(14, 4), field: "analog_value", comment: "模拟量值(ICC analogValue)" })
|
||||
analogValue!: number;
|
||||
|
||||
@Column({ type: DataType.DATE, allowNull: false, field: "collect_time", comment: "采集时间(同步时间,分区键)" })
|
||||
collectTime!: Date;
|
||||
|
||||
@BelongsTo(() => ElectricLine, { foreignKey: "lineId", as: "line" })
|
||||
line!: ElectricLine;
|
||||
|
||||
@BelongsTo(() => Customer, { foreignKey: "customerId", as: "customer" })
|
||||
customer!: Customer;
|
||||
}
|
||||
@@ -5,8 +5,8 @@ import { TokenUtil } from '../util/token';
|
||||
|
||||
@Provide()
|
||||
export class AuthMiddleware implements IMiddleware<Context, NextFunction> {
|
||||
// 中间件匹配范围:全局生效,但跳过公开接口、电工 APP 与小程序客户接口(各自走独立鉴权)
|
||||
ignore = ['/api/auth/*', '/api/health', '/api/worker-app/*', '/api/customer-app/*'];
|
||||
// 中间件匹配范围:全局生效,但跳过公开接口、ICC 事件回调、电工 APP 与小程序客户接口(各自走独立鉴权)
|
||||
ignore = ['/api/auth/*', '/api/health', '/api/worker-app/*', '/api/customer-app/*', '/api/icc-event/callback'];
|
||||
|
||||
@Inject()
|
||||
tokenUtil!: TokenUtil;
|
||||
|
||||
@@ -29,6 +29,12 @@ 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/electricity-data": "device:list",
|
||||
"/api/icc-event/subscribe": "device:list",
|
||||
"/api/icc-event/subscribe-status": "device:list",
|
||||
"/api/device-alarms": "alarm:list",
|
||||
"/api/alarm-dispatches": "alarm-dispatch:list",
|
||||
"/api/survey-orders": "project:survey",
|
||||
"/api/project-plans": "project:plan",
|
||||
"/api/contracts": "project:contract",
|
||||
@@ -48,10 +54,10 @@ const ADMIN_ROLE_CODE = "ADMIN";
|
||||
|
||||
@Provide()
|
||||
export class RbacMiddleware implements IMiddleware<Context, NextFunction> {
|
||||
// 中间件匹配范围:全局生效,跳过公开接口、仪表盘与电工 APP 接口
|
||||
// 中间件匹配范围:全局生效,跳过公开接口、ICC 事件回调(URL token 校验)、仪表盘、电工 APP 与小程序客户接口
|
||||
// /api/workers/options 与 /api/workers/recommend 放行:业务员在「项目勘察派单」页
|
||||
// 仍可调用电工下拉与推荐接口,不受 dispatch:worker 权限限制(本次需求有意设计)
|
||||
ignore = ["/api/auth/*", "/api/health", "/api/dashboard/*", "/api/worker-app/*", "/api/customer-app/*", "/api/workers/options", "/api/workers/recommend"];
|
||||
ignore = ["/api/auth/*", "/api/health", "/api/dashboard/*", "/api/worker-app/*", "/api/customer-app/*", "/api/workers/options", "/api/workers/recommend", "/api/icc-event/callback"];
|
||||
|
||||
@Inject()
|
||||
permissionService!: PermissionService;
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,9 @@ import { Repository } from "sequelize-typescript";
|
||||
import { Op } from "sequelize";
|
||||
import { Context } from "@midwayjs/koa";
|
||||
import ElectricLine from "../entity/electric_line.entity";
|
||||
import ElectricGateway from "../entity/electric_gateway.entity";
|
||||
import Customer from "../entity/customer.entity";
|
||||
import CustomerEquipment from "../entity/customer_equipment.entity";
|
||||
import CustomerBuilding from "../entity/customer_building.entity";
|
||||
import SysUserRole from "../entity/sysUserRole.entity";
|
||||
import SysRole from "../entity/sysRole.entity";
|
||||
|
||||
@@ -21,8 +21,8 @@ export class ElectricLineService {
|
||||
@InjectRepository(CustomerEquipment)
|
||||
equipmentModel!: Repository<CustomerEquipment>;
|
||||
|
||||
@InjectRepository(CustomerBuilding)
|
||||
buildingModel!: Repository<CustomerBuilding>;
|
||||
@InjectRepository(ElectricGateway)
|
||||
gatewayModel!: Repository<ElectricGateway>;
|
||||
|
||||
@InjectRepository(SysUserRole)
|
||||
userRoleModel!: Repository<SysUserRole>;
|
||||
@@ -44,12 +44,12 @@ export class ElectricLineService {
|
||||
if (query.keyword) {
|
||||
where[Op.or] = [
|
||||
{ lineName: { [Op.like]: `%${query.keyword}%` } },
|
||||
{ lineCode: { [Op.like]: `%${query.keyword}%` } }
|
||||
{ lineCode: { [Op.like]: `%${query.keyword}%` } },
|
||||
{ imei: { [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.buildingId !== undefined && query.buildingId !== "") where.buildingId = Number(query.buildingId);
|
||||
if (query.status !== undefined && query.status !== "" && query.status !== null) where.status = Number(query.status);
|
||||
|
||||
// 非管理员按客户归属自动限定数据范围
|
||||
@@ -95,15 +95,15 @@ export class ElectricLineService {
|
||||
return { code: 200, message: "获取线路台账详情成功", data: decorated[0] };
|
||||
}
|
||||
|
||||
/** 补全客户/设备/建筑展示信息,避免 N+1。 */
|
||||
/** 补全客户/设备/网关展示信息,避免 N+1。 */
|
||||
private async decorate(rows: ElectricLine[]): 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 buildingIds = [...new Set(rows.map((r) => r.buildingId).filter(Boolean))];
|
||||
const gatewayIds = [...new Set(rows.map((r) => r.gatewayId).filter(Boolean))];
|
||||
|
||||
const [customers, equips, buildings] = await Promise.all([
|
||||
const [customers, equips, gateways] = await Promise.all([
|
||||
customerIds.length
|
||||
? this.customerModel.findAll({
|
||||
where: { id: { [Op.in]: customerIds } },
|
||||
@@ -116,28 +116,29 @@ export class ElectricLineService {
|
||||
attributes: ["id", "equipCode", "equipName"]
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
buildingIds.length
|
||||
? this.buildingModel.findAll({
|
||||
where: { id: { [Op.in]: buildingIds } },
|
||||
attributes: ["id", "buildingName"]
|
||||
gatewayIds.length
|
||||
? this.gatewayModel.findAll({
|
||||
where: { id: { [Op.in]: gatewayIds } },
|
||||
attributes: ["id", "deviceCode", "deviceName"]
|
||||
})
|
||||
: Promise.resolve([])
|
||||
]);
|
||||
|
||||
const customerMap = new Map(customers.map((c) => [c.id, c]));
|
||||
const equipMap = new Map(equips.map((e) => [e.id, e]));
|
||||
const buildingMap = new Map(buildings.map((b) => [b.id, b]));
|
||||
const gatewayMap = new Map(gateways.map((g) => [g.id, g]));
|
||||
|
||||
return rows.map((row) => {
|
||||
const json: any = row.toJSON();
|
||||
const customer: any = customerMap.get(row.customerId);
|
||||
const equip: any = equipMap.get(row.equipId);
|
||||
const building: any = row.buildingId ? buildingMap.get(row.buildingId) : undefined;
|
||||
const equip: any = row.equipId ? equipMap.get(row.equipId) : undefined;
|
||||
const gateway: any = row.gatewayId ? gatewayMap.get(row.gatewayId) : undefined;
|
||||
json.customerNo = customer?.customerNo || "";
|
||||
json.customerName = customer?.customerName || "";
|
||||
json.equipCode = equip?.equipCode || "";
|
||||
json.equipName = equip?.equipName || "";
|
||||
json.buildingName = building?.buildingName || "";
|
||||
json.gatewayCode = gateway?.deviceCode || "";
|
||||
json.gatewayName = gateway?.deviceName || "";
|
||||
return json;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
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<ElectricityData>;
|
||||
|
||||
@InjectRepository(ElectricLine)
|
||||
lineModel!: Repository<ElectricLine>;
|
||||
|
||||
@InjectRepository(IccSyncLog)
|
||||
syncLogModel!: Repository<IccSyncLog>;
|
||||
|
||||
async syncFromIcc(ctx?: Context): Promise<IccAnalogSyncResult> {
|
||||
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<string, LineMatchInfo>();
|
||||
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<string, LineMatchInfo>,
|
||||
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<void> {
|
||||
try {
|
||||
await this.syncLogModel.create({
|
||||
syncType,
|
||||
startTime,
|
||||
endTime: new Date(),
|
||||
recordCount,
|
||||
status,
|
||||
errorMessage
|
||||
} as any);
|
||||
} catch (err) {
|
||||
console.error("[ICC同步] 同步日志写入失败:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
import { Provide, Inject, Config } from "@midwayjs/core";
|
||||
import { Context } from "@midwayjs/koa";
|
||||
import { InjectRepository } from "@midwayjs/sequelize";
|
||||
import { Repository } from "sequelize-typescript";
|
||||
import DeviceAlarm from "../entity/device_alarm.entity";
|
||||
import ElectricLine from "../entity/electric_line.entity";
|
||||
import { getIccAccessToken, iccFetch } from "../task/icc_customer_auth";
|
||||
|
||||
/** 订阅注册结果 */
|
||||
export interface IccRegisterResult {
|
||||
success: boolean;
|
||||
code?: string;
|
||||
errMsg?: string;
|
||||
raw?: any;
|
||||
}
|
||||
|
||||
/** 回调事件处理统计 */
|
||||
export interface IccEventHandleResult {
|
||||
/** 收到的通用事件总数 */
|
||||
received: number;
|
||||
/** 进入处理(category=alarm 且有 info)的事件数 */
|
||||
processed: number;
|
||||
/** 新增报警数 */
|
||||
inserted: number;
|
||||
/** 更新报警数 */
|
||||
updated: number;
|
||||
/** 未匹配到本系统线路的报警数 */
|
||||
unmatched: number;
|
||||
}
|
||||
|
||||
/** 线路预取信息(回填 lineId/lineCode/lineName/customerId,避免 N+1) */
|
||||
interface LineMatchInfo {
|
||||
lineId: number;
|
||||
lineName?: string;
|
||||
customerId: number;
|
||||
}
|
||||
|
||||
/** ICC 事件订阅接口路径(version 固定 1.0.0,与 ICC 事件订阅文档一致) */
|
||||
const ICC_EVENT_SUBSCRIBE_ENDPOINT = "/evo-apigw/evo-event/1.0.0/subscribe/mqinfo";
|
||||
/** 查询订阅列表接口路径(version 固定 1.0.0) */
|
||||
const ICC_EVENT_LIST_ENDPOINT = "/evo-apigw/evo-event/1.0.0/subscribe/subscribe-list";
|
||||
/** HTTP 超时 60s(项目约定) */
|
||||
const HTTP_TIMEOUT_MS = 60000;
|
||||
|
||||
/** 秒级时间戳转 Date(< 1e12 视为秒级 *1000;已毫秒级则原样);非法返回 null */
|
||||
function secondsToDate(value: unknown): Date | null {
|
||||
if (value === undefined || value === null || String(value).trim() === "") return null;
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n) || n <= 0) return null;
|
||||
return new Date(n < 1e12 ? n * 1000 : n);
|
||||
}
|
||||
|
||||
/** 数值解析:空 / null / 非法统一归一为 null */
|
||||
function toInt(value: unknown): number | null {
|
||||
if (value === undefined || value === null || String(value).trim() === "") return null;
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
/** 字符串 trim;空串归一为 null */
|
||||
function toOptionalString(value: unknown): string | null {
|
||||
if (value === undefined || value === null) return null;
|
||||
const s = String(value).trim();
|
||||
return s === "" ? null : s;
|
||||
}
|
||||
|
||||
/** 布尔解析:兼容 boolean / 0|1 / "true"|"false" / "1"|"0";空值归一为 null */
|
||||
function toBool(value: unknown): boolean | null {
|
||||
if (value === undefined || value === null || String(value).trim() === "") return null;
|
||||
if (typeof value === "boolean") return value;
|
||||
const s = String(value).trim().toLowerCase();
|
||||
if (s === "1" || s === "true" || s === "yes") return true;
|
||||
if (s === "0" || s === "false" || s === "no") return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* ICC 事件订阅服务:
|
||||
* 1) registerSubscribe:向 ICC 事件订阅接口注册报警事件回调(monitor=monitorUrl?token=callbackToken,
|
||||
* 子系统 name/magic 取自配置 iccEvent),同名重复注册 ICC 会自动覆盖(保活语义)
|
||||
* 2) ensureSubscribe:保活入口,直接 registerSubscribe 同名覆盖(ICC 同名覆盖即保活),
|
||||
* 由每 30 分钟定时任务与手动接口调用
|
||||
* 3) handleCallback:处理 ICC 回调推送的通用事件,仅入库 category=alarm 的报警事件,
|
||||
* 按 alarm_code 幂等 upsert,uuid 相同重复推送跳过,按 nodeCode/deviceCode 匹配本系统线路
|
||||
*/
|
||||
@Provide()
|
||||
export class IccEventSubscribeService {
|
||||
@Inject()
|
||||
ctx!: Context;
|
||||
|
||||
@Config("iccEvent")
|
||||
iccEventConfig!: { monitorUrl: string; callbackToken: string; subscriberName: string; subscriberMagic: string };
|
||||
|
||||
@InjectRepository(DeviceAlarm)
|
||||
alarmModel!: Repository<DeviceAlarm>;
|
||||
|
||||
@InjectRepository(ElectricLine)
|
||||
lineModel!: Repository<ElectricLine>;
|
||||
|
||||
/** 最近一次订阅注册结果(内存,供 subscribe-status 接口查询) */
|
||||
private lastRegisterResult: IccRegisterResult | null = null;
|
||||
private lastRegisterAt: Date | null = null;
|
||||
|
||||
/**
|
||||
* 向 ICC 注册报警事件订阅(同名重复注册 ICC 覆盖,保活语义)。
|
||||
* 网络异常 / HTTP 错误 / ICC 业务失败均不抛错,返回失败状态并记录到内存。
|
||||
*/
|
||||
async registerSubscribe(): Promise<IccRegisterResult> {
|
||||
const baseUrl = process.env.ICC_BASE_URL?.trim();
|
||||
if (!baseUrl) {
|
||||
return this.remember({ success: false, code: "500", errMsg: "未配置 ICC_BASE_URL,请检查 server/.env" });
|
||||
}
|
||||
|
||||
try {
|
||||
const token = await getIccAccessToken();
|
||||
const url = `${baseUrl.replace(/\/$/, "")}${ICC_EVENT_SUBSCRIBE_ENDPOINT}`;
|
||||
const body = {
|
||||
param: {
|
||||
monitors: [
|
||||
{
|
||||
monitor: `${this.iccEventConfig.monitorUrl}?token=${this.iccEventConfig.callbackToken}`,
|
||||
monitorType: "url",
|
||||
events: [{ category: "alarm", subscribeAll: 1, domainSubscribe: 2, eventType: 2 }]
|
||||
}
|
||||
],
|
||||
subsystem: {
|
||||
subsystemType: 0,
|
||||
name: this.iccEventConfig.subscriberName,
|
||||
magic: this.iccEventConfig.subscriberMagic
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const res = await iccFetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
Authorization: `bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS)
|
||||
});
|
||||
|
||||
const payload: any = await res.json().catch(() => null);
|
||||
if (!res.ok) {
|
||||
return this.remember({ success: false, code: String(res.status), errMsg: `ICC 订阅接口 HTTP ${res.status}`, raw: payload });
|
||||
}
|
||||
|
||||
const success = payload?.success === true || String(payload?.code ?? "") === "0";
|
||||
if (!success) {
|
||||
return this.remember({ success: false, code: String(payload?.code ?? ""), errMsg: String(payload?.errMsg ?? JSON.stringify(payload)), raw: payload });
|
||||
}
|
||||
return this.remember({ success: true, code: "0", errMsg: "", raw: payload });
|
||||
} catch (err: any) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return this.remember({ success: false, code: "500", errMsg: message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保活入口:ICC 同名订阅会覆盖,因此直接 registerSubscribe 即可(保活语义)。
|
||||
* 返回与 registerSubscribe 相同的注册结果。
|
||||
*/
|
||||
async ensureSubscribe(): Promise<IccRegisterResult> {
|
||||
return this.registerSubscribe();
|
||||
}
|
||||
|
||||
/** 查询订阅状态:配置 + 最近一次注册结果(内存) */
|
||||
async getSubscribeStatus(): Promise<{
|
||||
config: { monitorUrl: string; subscriberName: string; subscriberMagic: string };
|
||||
lastRegisterResult: IccRegisterResult | null;
|
||||
lastRegisterAt: Date | null;
|
||||
}> {
|
||||
return {
|
||||
config: {
|
||||
monitorUrl: this.iccEventConfig.monitorUrl,
|
||||
subscriberName: this.iccEventConfig.subscriberName,
|
||||
subscriberMagic: this.iccEventConfig.subscriberMagic
|
||||
},
|
||||
lastRegisterResult: this.lastRegisterResult,
|
||||
lastRegisterAt: this.lastRegisterAt
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 ICC 回调推送的通用事件:仅处理 category=alarm 且有 info 的事件。
|
||||
* - 按 info.nodeCode || info.deviceCode 匹配 electric_line.line_code,命中回填 lineId/lineCode/lineName/customerId
|
||||
* - uuid 相同(同一事件重复推送)跳过
|
||||
* - 按 alarm_code 幂等 upsert:存在则更新 alarmStat/isEvent/alarmTime/syncedAt 等,不存在则新增
|
||||
*/
|
||||
async handleCallback(body: any): Promise<IccEventHandleResult> {
|
||||
const result: IccEventHandleResult = { received: 0, processed: 0, inserted: 0, updated: 0, unmatched: 0 };
|
||||
|
||||
const events = this.extractEvents(body);
|
||||
result.received = events.length;
|
||||
if (events.length === 0) return result;
|
||||
|
||||
// 预取本系统已同步线路 → line_code → {lineId,lineName,customerId} 映射,避免 N+1
|
||||
const lines = await this.lineModel.findAll({
|
||||
attributes: ["id", "lineCode", "lineName", "customerId"]
|
||||
});
|
||||
const lineMap = new Map<string, LineMatchInfo>();
|
||||
for (const line of lines) {
|
||||
if (!line.lineCode) continue;
|
||||
lineMap.set(line.lineCode, {
|
||||
lineId: line.id,
|
||||
lineName: line.lineName || undefined,
|
||||
customerId: line.customerId
|
||||
});
|
||||
}
|
||||
|
||||
for (const event of events) {
|
||||
// 通用事件格式:仅处理 category=alarm 且有 info 对象的事件
|
||||
if (!event || event.category !== "alarm") continue;
|
||||
const info = event.info;
|
||||
if (!info || typeof info !== "object") continue;
|
||||
result.processed++;
|
||||
|
||||
const alarmCode = toOptionalString(info.alarmCode);
|
||||
if (!alarmCode) continue;
|
||||
|
||||
// uuid 去重:同一 uuid 重复推送直接跳过(uuid 非唯一列,按存在性判断)
|
||||
const uuid = toOptionalString(event.uuid ?? info.uuid);
|
||||
if (uuid) {
|
||||
const byUuid = await this.alarmModel.findOne({ where: { uuid } });
|
||||
if (byUuid) continue;
|
||||
}
|
||||
|
||||
// 按 nodeCode || deviceCode 匹配本系统线路(line_code)
|
||||
const lineFields: { lineId: number | null; lineCode: string | null; lineName: string | null; customerId: number | null } = {
|
||||
lineId: null,
|
||||
lineCode: null,
|
||||
lineName: null,
|
||||
customerId: null
|
||||
};
|
||||
const matchKey = toOptionalString(info.nodeCode) || toOptionalString(info.deviceCode);
|
||||
if (matchKey) {
|
||||
const lineInfo = lineMap.get(matchKey);
|
||||
if (lineInfo) {
|
||||
lineFields.lineId = lineInfo.lineId;
|
||||
lineFields.lineCode = matchKey;
|
||||
lineFields.lineName = lineInfo.lineName || null;
|
||||
lineFields.customerId = lineInfo.customerId;
|
||||
} else {
|
||||
result.unmatched++;
|
||||
}
|
||||
} else {
|
||||
result.unmatched++;
|
||||
}
|
||||
|
||||
const base = {
|
||||
uuid,
|
||||
nodeCode: toOptionalString(info.nodeCode),
|
||||
deviceCode: toOptionalString(info.deviceCode),
|
||||
deviceName: toOptionalString(info.deviceName),
|
||||
orgCode: toOptionalString(info.orgCode),
|
||||
orgName: toOptionalString(info.orgName),
|
||||
alarmType: toInt(info.alarmType),
|
||||
alarmGrade: toInt(info.alarmGrade),
|
||||
alarmStat: toInt(info.alarmStat),
|
||||
isEvent: toBool(info.isEvent),
|
||||
alarmValue: toInt(info.alarmValue),
|
||||
alarmTime: secondsToDate(info.alarmDate)
|
||||
};
|
||||
|
||||
// 按 alarm_code 幂等 upsert
|
||||
const existing = await this.alarmModel.findOne({ where: { alarmCode } });
|
||||
if (existing) {
|
||||
await existing.update({
|
||||
...base,
|
||||
...lineFields,
|
||||
syncedAt: new Date()
|
||||
} as any);
|
||||
result.updated++;
|
||||
} else {
|
||||
await this.alarmModel.create({
|
||||
alarmCode,
|
||||
...base,
|
||||
...lineFields,
|
||||
syncedAt: new Date()
|
||||
} as any);
|
||||
result.inserted++;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 从回调 body 提取通用事件数组(兼容 data.events / events / data 数组 / body 数组 / body 单条事件 五种形态) */
|
||||
private extractEvents(body: any): any[] {
|
||||
try {
|
||||
if (!body || typeof body !== "object") return [];
|
||||
if (Array.isArray(body)) return body;
|
||||
if (Array.isArray(body.events)) return body.events;
|
||||
const data = body.data;
|
||||
if (data && Array.isArray(data.events)) return data.events;
|
||||
if (Array.isArray(data)) return data;
|
||||
// 兼容 ICC 直接推送单条通用事件对象(body 本身即事件)
|
||||
if (typeof body.category === "string" && body.info && typeof body.info === "object") return [body];
|
||||
return [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** 记录注册结果到内存并返回 */
|
||||
private remember(result: IccRegisterResult): IccRegisterResult {
|
||||
this.lastRegisterResult = result;
|
||||
this.lastRegisterAt = new Date();
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,31 +6,41 @@ import { Context } from "@midwayjs/koa";
|
||||
import ElectricLine from "../entity/electric_line.entity";
|
||||
import IccSyncLog from "../entity/icc_sync_log.entity";
|
||||
import Customer from "../entity/customer.entity";
|
||||
import CustomerEquipment from "../entity/customer_equipment.entity";
|
||||
import CustomerBuilding from "../entity/customer_building.entity";
|
||||
import { ICC_LINE_ENDPOINT, mapIccLineRecord, IccLineRawRecord } from "../task/icc_line_mapper";
|
||||
import ElectricGateway from "../entity/electric_gateway.entity";
|
||||
import { getIccAccessToken, iccFetch } from "../task/icc_customer_auth";
|
||||
import { ICC_LINE_ENDPOINT, mapIccLineRecord, parseLinePage, IccLineRawRecord } from "../task/icc_line_mapper";
|
||||
|
||||
export interface IccLineSyncResult {
|
||||
/** ICC 拉取到的原始条数 */
|
||||
/** ICC 拉取到的原始探测器条数 */
|
||||
pulled: number;
|
||||
/** 解析落库的线路条数(成功 upsert,含新增/更新) */
|
||||
/** 成功落库的线路条数(新增+更新) */
|
||||
synced: number;
|
||||
/** 新增的线路条数 */
|
||||
inserted: number;
|
||||
/** 更新的线路条数 */
|
||||
updated: number;
|
||||
/** 因缺少合法 line_code 或无法匹配客户/设备而跳过的条数 */
|
||||
/** 跳过的条数(缺 lineCode / orgCode 匹配不到客户) */
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
const SYNC_TYPE = "electric_line";
|
||||
/** 分页拉取参数(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) 从 .env 读取 ICC_BASE_URL / ICC_TOKEN 占位配置
|
||||
* 2) 请求 ICC 线路列表(路径与字段映射集中在 icc_line_mapper)
|
||||
* 3) 按 line_code 幂等 upsert 写入 electric_line,更新 synced_at
|
||||
* 4) 每次同步写一条 icc_sync_log;ICC 拉取失败仅记录 error,不抛业务错误
|
||||
* ICC 智电改造线路(消防探测器)拉取同步:
|
||||
* 1) 复用 icc_customer_auth 的真实 OAuth2 密码模式鉴权(getIccAccessToken / iccFetch),
|
||||
* 从 .env 读取 ICC_BASE_URL / ICC_VERSION
|
||||
* 2) POST 分页请求 ICC 探测器列表(/detector/subsystem/page,pageNum/pageSize=50,全量拉取)
|
||||
* 3) 预取 orgCode→customer.id 映射(customer.icc_code)与 parentCode→gateway.id 映射(electric_gateway.device_code),避免 N+1 查询
|
||||
* 4) 按 line_code(detectorCode)幂等 upsert 写入 electric_line,更新 synced_at
|
||||
* 5) 每次同步写一条 icc_sync_log;拉取失败仅记录 error,不抛业务错误
|
||||
*/
|
||||
@Provide()
|
||||
export class IccLineSyncService {
|
||||
@@ -43,52 +53,55 @@ export class IccLineSyncService {
|
||||
@InjectRepository(Customer)
|
||||
customerModel!: Repository<Customer>;
|
||||
|
||||
@InjectRepository(CustomerEquipment)
|
||||
equipmentModel!: Repository<CustomerEquipment>;
|
||||
|
||||
@InjectRepository(CustomerBuilding)
|
||||
buildingModel!: Repository<CustomerBuilding>;
|
||||
@InjectRepository(ElectricGateway)
|
||||
gatewayModel!: Repository<ElectricGateway>;
|
||||
|
||||
async syncFromIcc(ctx?: Context): Promise<IccLineSyncResult> {
|
||||
const startTime = new Date();
|
||||
const baseUrl = process.env.ICC_BASE_URL?.trim();
|
||||
const token = process.env.ICC_TOKEN?.trim();
|
||||
|
||||
const result: IccLineSyncResult = { 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 url = `${baseUrl.replace(/\/$/, "")}${ICC_LINE_ENDPOINT}`;
|
||||
const headers: Record<string, string> = { Accept: "application/json" };
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
const token = await getIccAccessToken();
|
||||
const url = `${baseUrl.replace(/\/$/, "")}${ICC_LINE_ENDPOINT.replace("{version}", process.env.ICC_VERSION?.trim() || DEFAULT_VERSION)}`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: "GET",
|
||||
headers,
|
||||
signal: AbortSignal.timeout(60000) // 按项目约定 HTTP 超时 60s
|
||||
});
|
||||
const allRaw: IccLineRawRecord[] = [];
|
||||
let pageNum = 1;
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`ICC 线路接口返回异常:HTTP ${res.status}`);
|
||||
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 }), // 不传额外筛选条件,全量拉取
|
||||
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS)
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`ICC 探测器接口返回异常:HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
const payload: any = await res.json();
|
||||
const page = parseLinePage(payload);
|
||||
allRaw.push(...page.list);
|
||||
result.pulled += page.list.length;
|
||||
|
||||
// 短页判定:本页不满 pageSize 视为无下一页;另有最大页数保护
|
||||
if (page.list.length < PAGE_SIZE) break;
|
||||
pageNum++;
|
||||
}
|
||||
|
||||
const payload: any = await res.json();
|
||||
// 兼容缓存工具包裹(data/list 字段)返回格式
|
||||
const rawList: IccLineRawRecord[] = Array.isArray(payload)
|
||||
? payload
|
||||
: Array.isArray(payload.data)
|
||||
? payload.data
|
||||
: Array.isArray(payload.list)
|
||||
? payload.list
|
||||
: [];
|
||||
result.pulled = rawList.length;
|
||||
|
||||
const { inserted, updated, skipped } = await this.batchUpsert(rawList);
|
||||
const { inserted, updated, skipped } = await this.batchUpsert(allRaw);
|
||||
result.inserted = inserted;
|
||||
result.updated = updated;
|
||||
result.synced = inserted + updated;
|
||||
@@ -97,15 +110,13 @@ export class IccLineSyncService {
|
||||
await this.writeLog(startTime, 1, SYNC_TYPE, result.synced, null);
|
||||
return result;
|
||||
} catch (err: any) {
|
||||
const message = err?.code === "ERR_MODULE_NOT_FOUND" || err?.message
|
||||
? String(err?.message || err)
|
||||
: String(err);
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
await this.writeLog(startTime, 0, SYNC_TYPE, result.pulled, message);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/** 批量按 line_code upsert(幂等),并做客户/设备/建筑外键反查。 */
|
||||
/** 按 line_code(detectorCode)幂等 upsert,customer_id 解析失败的行跳过。 */
|
||||
private async batchUpsert(records: IccLineRawRecord[]): Promise<{ inserted: number; updated: number; skipped: number }> {
|
||||
let inserted = 0;
|
||||
let updated = 0;
|
||||
@@ -113,71 +124,66 @@ export class IccLineSyncService {
|
||||
|
||||
if (records.length === 0) return { inserted, updated, skipped };
|
||||
|
||||
// 预取外键映射,避免 N+1
|
||||
const equipCodes = [...new Set(records.map((r) => mapIccLineRecord(r).equipCode).filter(Boolean))];
|
||||
const customerNos = [...new Set(records.map((r) => mapIccLineRecord(r).customerNo).filter(Boolean))];
|
||||
const buildingNames = [...new Set(records.map((r) => mapIccLineRecord(r).buildingName).filter(Boolean))];
|
||||
// 预取 orgCode → customer.id 映射,避免 N+1 查询客户
|
||||
const orgCodes = [...new Set(records.map((r) => mapIccLineRecord(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 [equips, customers, buildings] = await Promise.all([
|
||||
equipCodes.length
|
||||
? this.equipmentModel.findAll({ where: { equipCode: { [Op.in]: equipCodes } }, attributes: ["id", "equipCode", "customerId", "buildingId"] })
|
||||
: Promise.resolve([]),
|
||||
customerNos.length
|
||||
? this.customerModel.findAll({ where: { customerNo: { [Op.in]: customerNos } }, attributes: ["id", "customerNo"] })
|
||||
: Promise.resolve([]),
|
||||
buildingNames.length
|
||||
? this.buildingModel.findAll({ where: { buildingName: { [Op.in]: buildingNames } }, attributes: ["id", "buildingName", "customerId"] })
|
||||
: Promise.resolve([])
|
||||
]);
|
||||
|
||||
const equipByCode = new Map(equips.map((e) => [e.equipCode, e]));
|
||||
const customerByNo = new Map(customers.map((c) => [c.customerNo, c]));
|
||||
const buildingByName = new Map(buildings.map((b) => [b.buildingName, b]));
|
||||
// 预取 parentCode → gateway.id 映射(electric_gateway.device_code),避免 N+1 查询网关
|
||||
const parentCodes = [...new Set(records.map((r) => mapIccLineRecord(r).parentCode).filter(Boolean))];
|
||||
const gateways = parentCodes.length
|
||||
? await this.gatewayModel.findAll({ where: { deviceCode: { [Op.in]: parentCodes } }, attributes: ["id", "deviceCode"] })
|
||||
: [];
|
||||
const gatewayIdByParentCode = new Map(gateways.map((g) => [g.deviceCode, g.id]));
|
||||
|
||||
const now = new Date();
|
||||
|
||||
for (const raw of records) {
|
||||
const mapped = mapIccLineRecord(raw);
|
||||
// line_code(detectorCode)为空则无同步匹配键,跳过
|
||||
if (!mapped.lineCode) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const equip = mapped.equipCode ? equipByCode.get(mapped.equipCode) : undefined;
|
||||
// equip_id 与 customer_id 为 NOT NULL,无法解析时跳过该行
|
||||
if (!equip) {
|
||||
// customer_id NOT NULL:orgCode 必须能解析到客户,否则跳过
|
||||
if (!mapped.orgCode) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
const customerId = customerIdByOrgCode.get(mapped.orgCode);
|
||||
if (!customerId) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
let customerId: number = equip.customerId;
|
||||
if (mapped.customerNo) {
|
||||
const byNo = customerByNo.get(mapped.customerNo);
|
||||
if (byNo) customerId = byNo.id;
|
||||
}
|
||||
|
||||
let buildingId: number | null = equip.buildingId || null;
|
||||
if (mapped.buildingName) {
|
||||
const building = buildingByName.get(mapped.buildingName);
|
||||
if (building) buildingId = building.id;
|
||||
}
|
||||
// gateway_id 可空:parentCode 匹配不到 electric_gateway.device_code 时置空
|
||||
const gatewayId = mapped.parentCode ? (gatewayIdByParentCode.get(mapped.parentCode) ?? null) : null;
|
||||
|
||||
const payload = {
|
||||
lineCode: mapped.lineCode,
|
||||
equipId: equip.id,
|
||||
customerId,
|
||||
buildingId,
|
||||
iccDeviceCode: mapped.iccDeviceCode || null,
|
||||
lineName: mapped.lineName || null,
|
||||
floorNo: mapped.floorNo || null,
|
||||
location: mapped.location || null,
|
||||
imei: mapped.imei || null,
|
||||
// equipId 同步不填充
|
||||
gatewayId,
|
||||
customerId,
|
||||
typeCode: mapped.typeCode || null,
|
||||
typeName: mapped.typeName || null,
|
||||
fireSystemCode: mapped.fireSystemCode || null,
|
||||
fireSystemName: mapped.fireSystemName || null,
|
||||
partUnitLoopCode: mapped.partUnitLoopCode || null,
|
||||
channelNo: mapped.channelNo || null,
|
||||
channelSn: mapped.channelSn || null,
|
||||
produceDate: mapped.produceDate || null,
|
||||
status: mapped.status,
|
||||
syncedAt: now
|
||||
} as any;
|
||||
|
||||
// 幂等 upsert:按 line_code 唯一键匹配
|
||||
const existing = await this.lineModel.findOne({ where: { lineCode: mapped.lineCode } });
|
||||
if (existing) {
|
||||
await existing.update(payload);
|
||||
await existing.update({ ...payload, syncedAt: now });
|
||||
updated++;
|
||||
} else {
|
||||
await this.lineModel.create(payload);
|
||||
@@ -202,4 +208,4 @@ export class IccLineSyncService {
|
||||
console.error("[ICC同步] 同步日志写入失败:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,9 @@ 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 { IccAnalogSyncService } from "./icc_analog_sync.service";
|
||||
import { IccEventSubscribeService } from "./icc_event_subscribe.service";
|
||||
import { BuiltinJob, JobHandlerContext, JobRuntime, getBuiltinJob } from "../task/job_registry";
|
||||
|
||||
/** 任务执行结果(一次尝试) */
|
||||
@@ -459,6 +462,24 @@ 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}`;
|
||||
},
|
||||
syncIccAnalogs: async () => {
|
||||
const ctx = this.app.createAnonymousContext();
|
||||
const iccAnalogService = (await ctx.requestContext.getAsync(IccAnalogSyncService)) as IccAnalogSyncService;
|
||||
const result = await iccAnalogService.syncFromIcc();
|
||||
return `拉取 ${result.pulled} 条,新增 ${result.inserted},更新 ${result.updated},跳过 ${result.skipped}`;
|
||||
},
|
||||
ensureIccEventSubscribe: async () => {
|
||||
const ctx = this.app.createAnonymousContext();
|
||||
const iccEventService = (await ctx.requestContext.getAsync(IccEventSubscribeService)) as IccEventSubscribeService;
|
||||
const result = await iccEventService.ensureSubscribe();
|
||||
return result.success ? `ICC 事件订阅成功(code=${result.code ?? "0"})` : `ICC 事件订阅失败:${result.errMsg ?? ""}`;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -43,13 +43,13 @@ export class MenuService {
|
||||
}
|
||||
|
||||
const menu = await this.menuModel.create({
|
||||
parent_id: parentId,
|
||||
menu_name: menuName,
|
||||
menu_path: menuPath,
|
||||
parentId,
|
||||
menuName,
|
||||
menuPath,
|
||||
component,
|
||||
icon,
|
||||
sort_order: sortOrder,
|
||||
menu_type: menuType,
|
||||
sortOrder,
|
||||
menuType,
|
||||
permission,
|
||||
visible,
|
||||
});
|
||||
@@ -73,13 +73,13 @@ export class MenuService {
|
||||
}
|
||||
|
||||
const updateData: any = {};
|
||||
if (data.parentId !== undefined) updateData.parent_id = data.parentId;
|
||||
if (data.menuName !== undefined) updateData.menu_name = data.menuName;
|
||||
if (data.menuPath !== undefined) updateData.menu_path = data.menuPath;
|
||||
if (data.parentId !== undefined) updateData.parentId = data.parentId;
|
||||
if (data.menuName !== undefined) updateData.menuName = data.menuName;
|
||||
if (data.menuPath !== undefined) updateData.menuPath = data.menuPath;
|
||||
if (data.component !== undefined) updateData.component = data.component;
|
||||
if (data.icon !== undefined) updateData.icon = data.icon;
|
||||
if (data.sortOrder !== undefined) updateData.sort_order = data.sortOrder;
|
||||
if (data.menuType !== undefined) updateData.menu_type = data.menuType;
|
||||
if (data.sortOrder !== undefined) updateData.sortOrder = data.sortOrder;
|
||||
if (data.menuType !== undefined) updateData.menuType = data.menuType;
|
||||
if (data.permission !== undefined) updateData.permission = data.permission;
|
||||
if (data.visible !== undefined) updateData.visible = data.visible;
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import WorkerCert from "../entity/worker_cert.entity";
|
||||
import SysNotice from "../entity/sys_notice.entity";
|
||||
import RepairRequest from "../entity/repair_request.entity";
|
||||
import RepairDispatch from "../entity/repair_dispatch.entity";
|
||||
import AlarmDispatch from "../entity/alarm_dispatch.entity";
|
||||
import InstallOrder from "../entity/install_order.entity";
|
||||
import MaintenanceOrder from "../entity/maintenance_order.entity";
|
||||
import SurveyOrder from "../entity/survey_order.entity";
|
||||
@@ -63,6 +64,9 @@ export class WorkerAppService {
|
||||
@InjectRepository(RepairDispatch)
|
||||
dispatchModel!: Repository<RepairDispatch>;
|
||||
|
||||
@InjectRepository(AlarmDispatch)
|
||||
alarmDispatchModel!: Repository<AlarmDispatch>;
|
||||
|
||||
@InjectRepository(InstallOrder)
|
||||
installModel!: Repository<InstallOrder>;
|
||||
|
||||
@@ -503,6 +507,19 @@ export class WorkerAppService {
|
||||
return { code: 200, message: "已完成" };
|
||||
}
|
||||
|
||||
// 报警派单完成确认:校验归属(不属于当前电工返回 403),幂等(已处理不重复更新)
|
||||
async completeAlarmDispatch(workerId: number, id: number): Promise<any> {
|
||||
const dispatch = await this.alarmDispatchModel.findByPk(id);
|
||||
if (!dispatch) return { code: 404, message: "派单不存在" };
|
||||
if (Number(dispatch.workerId) !== Number(workerId)) return { code: 403, message: "无权操作该派单" };
|
||||
if (Number(dispatch.status) === 1) return { code: 200, message: "该派单已完成" };
|
||||
|
||||
await this.sequelize.transaction(async t => {
|
||||
await dispatch.update({ status: 1, completedAt: new Date() }, { transaction: t });
|
||||
});
|
||||
return { code: 200, message: "报警派单完成确认成功" };
|
||||
}
|
||||
|
||||
// ================= 收益汇总 =================
|
||||
|
||||
// 收益汇总:累计/可提现/本月/近 7 日趋势/最近 20 条流水
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* ICC 探测器模拟量数据字段映射(集中配置,便于按 ICC 平台实际返回结构调整)。
|
||||
*
|
||||
* ICC 返回的原始字段名 → 本系统 electricity_data 字段。接入真实 ICC 平台时
|
||||
* 仅需调整本文件与 fetch 路径即可,业务层无需改动。纯映射逻辑,无 SQL 无 DOM。
|
||||
*/
|
||||
export interface IccAnalogRawRecord {
|
||||
[field: string]: any;
|
||||
}
|
||||
|
||||
/** 模拟量分页列表接口路径({version} 由调用方按 ICC_VERSION 替换) */
|
||||
export const ICC_ANALOG_ENDPOINT = "/evo-apigw/evo-fdbu/{version}/analog/subsystem/page";
|
||||
|
||||
/** 原始记录关键字段映射:本系统字段 → ICC 返回字段名 */
|
||||
export const ICC_ANALOG_FIELDS = {
|
||||
deviceCode: "deviceCode", // 探测器编码(→ electric_line.line_code 幂等键)
|
||||
uniqueCode: "uniqueCode", // 设备唯一标识码(→ electricity_data.imei)
|
||||
analogType: "analogType", // 模拟量类型
|
||||
analogName: "analogName", // 模拟量名称(→ electricity_data.analog_name)
|
||||
analogUnit: "analogUnit", // 模拟量单位(→ electricity_data.analog_unit)
|
||||
analogValue: "analogValue", // 模拟量值
|
||||
reportTime: "reportTime" // 上报/采集时间(yyyy-MM-dd HH:mm:ss,接口文档声明字段,保留映射)
|
||||
} as const;
|
||||
|
||||
/** 字符串 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 原始模拟量记录映射为本系统 electricity_data 落库字段。
|
||||
* analogValue 用 Number 解析,空值/非法值统一归一为 null(可空落库)。
|
||||
*/
|
||||
export function mapIccAnalogRecord(raw: IccAnalogRawRecord): {
|
||||
deviceCode: string;
|
||||
uniqueCode?: string;
|
||||
analogType?: string;
|
||||
analogName?: string;
|
||||
analogUnit?: string;
|
||||
analogValue: number | null;
|
||||
reportTime?: string;
|
||||
} {
|
||||
const f = ICC_ANALOG_FIELDS;
|
||||
// 匹配键保持空串语义,由调用方判跳
|
||||
const deviceCode = String(raw[f.deviceCode] ?? "").trim();
|
||||
|
||||
// 数值解析:空 / null / 非数值统一返回 null
|
||||
let analogValue: number | null = null;
|
||||
const rawValue = raw[f.analogValue];
|
||||
if (rawValue !== undefined && rawValue !== null && String(rawValue).trim() !== "") {
|
||||
const num = Number(rawValue);
|
||||
analogValue = Number.isFinite(num) ? num : null;
|
||||
}
|
||||
|
||||
return {
|
||||
deviceCode,
|
||||
uniqueCode: toOptionalString(raw[f.uniqueCode]),
|
||||
analogType: toOptionalString(raw[f.analogType]),
|
||||
analogName: toOptionalString(raw[f.analogName]),
|
||||
analogUnit: toOptionalString(raw[f.analogUnit]),
|
||||
analogValue,
|
||||
// reportTime 保留映射(接口文档仍声明该字段;当前采集时间以同步时间为准,见同步服务)
|
||||
reportTime: toOptionalString(raw[f.reportTime])
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析模拟量分页响应(兼容三种形态):
|
||||
* - payload 为数组 → 直接作为列表
|
||||
* - payload.data.pageData 为数组 → 取 pageData
|
||||
* - payload.data.list 为数组 → 取 list
|
||||
* - 结构不匹配或解析异常 → 返回空列表
|
||||
*/
|
||||
export function parseAnalogPage(payload: any): {
|
||||
list: IccAnalogRawRecord[];
|
||||
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,18 @@
|
||||
import type { BuiltinJob } from "./job_registry";
|
||||
|
||||
/**
|
||||
* 模拟量数据同步任务:从 ICC 平台按 deviceCode 分批拉取探测器最新模拟量,
|
||||
* 以同步时间作采集时间写入 electricity_data(按 line_code/analog_type/collect_time 幂等),
|
||||
* 写 icc_sync_log。默认每 5 分钟执行一次。
|
||||
*/
|
||||
export const iccAnalogSyncJob: BuiltinJob = {
|
||||
jobKey: "icc_analog_sync",
|
||||
jobName: "模拟量数据同步",
|
||||
description: "拉取 ICC 平台探测器最新模拟量,以同步时间作采集时间写入 electricity_data(写 icc_sync_log)",
|
||||
defaultCron: "*/5 * * * *",
|
||||
defaultParams: {},
|
||||
paramFields: [],
|
||||
handler: async (_params, ctx) => {
|
||||
return ctx.runtime.syncIccAnalogs();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { BuiltinJob } from "./job_registry";
|
||||
|
||||
/**
|
||||
* ICC 事件订阅保活任务:每 30 分钟向 ICC 平台重新注册报警事件订阅(同名覆盖即保活)。
|
||||
* 失败仅记录日志返回摘要,不抛业务错误(参照 icc_analog_sync 容错约定)。
|
||||
*/
|
||||
export const iccEventAlarmKeepaliveJob: BuiltinJob = {
|
||||
jobKey: "icc_event_alarm_keepalive",
|
||||
jobName: "ICC事件订阅保活",
|
||||
description: "向 ICC 平台重新注册报警事件回调订阅(同名覆盖保活,默认每 30 分钟)",
|
||||
defaultCron: "*/30 * * * *",
|
||||
defaultParams: {},
|
||||
paramFields: [],
|
||||
handler: async (_params, ctx) => {
|
||||
return ctx.runtime.ensureIccEventSubscribe();
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -1,51 +1,109 @@
|
||||
/**
|
||||
* ICC 线路数据字段映射(集中配置,便于按 ICC 平台实际返回结构调整)
|
||||
* ICC 智电改造线路(消防探测器)数据字段映射(集中配置,便于按 ICC 平台实际返回结构调整)。
|
||||
*
|
||||
* ICC 返回的原始字段名 → 本系统 electric_line 字段名。当前为占位映射,
|
||||
* 接入真实 ICC 平台时仅需调整本文件与 fetch 路径即可,业务层无需改动。
|
||||
* ICC 返回的原始字段名 → 本系统 electric_line 字段。接入真实 ICC 平台时
|
||||
* 仅需调整本文件与 fetch 路径即可,业务层无需改动。纯映射逻辑,无 SQL 无 DOM。
|
||||
*/
|
||||
export interface IccLineRawRecord {
|
||||
[field: string]: any;
|
||||
}
|
||||
|
||||
/** 原始记录关键字段名(ICC 平台约定,占位) */
|
||||
/** 探测器分页列表接口路径({version} 由调用方按 ICC_VERSION 替换) */
|
||||
export const ICC_LINE_ENDPOINT = "/evo-apigw/evo-fdbu/{version}/detector/subsystem/page";
|
||||
|
||||
/** 原始记录关键字段映射:本系统字段 → ICC 返回字段名 */
|
||||
export const ICC_LINE_FIELDS = {
|
||||
lineCode: "lineCode", // ICC 线路编码(唯一键)
|
||||
iccDeviceCode: "deviceCode", // ICC 智电设备编号
|
||||
equipCode: "equipCode", // 关联的客户设备编码(用于反查 equip_id)
|
||||
lineName: "lineName", // 线路名称
|
||||
customerNo: "customerNo", // 关联客户编号(用于反查 customer_id)
|
||||
buildingName: "buildingName", // 建筑名称(占位,反查 building_id)
|
||||
floorNo: "floorNo", // 所在楼层
|
||||
location: "location", // 所在位置
|
||||
status: "status" // 1在线 0离线
|
||||
lineCode: "detectorCode", // 探测器编码(唯一幂等键)
|
||||
lineName: "detectorName", // 探测器名称
|
||||
imei: "imei", // 探测器 IMEI
|
||||
orgCode: "orgCode", // 所属组织编码(→ customer.icc_code 反查客户,不落库)
|
||||
parentCode: "parentCode", // 上级网关编码(→ electric_gateway.device_code 反查网关,不落库)
|
||||
typeCode: "typeCode", // 探测器类型编码
|
||||
typeName: "typeName", // 探测器类型名称
|
||||
fireSystemCode: "fireSystemCode", // 消防系统编码
|
||||
fireSystemName: "fireSystemName", // 消防系统名称
|
||||
partUnitLoopCode: "partUnitLoopCode", // 部件/单元/回路编码
|
||||
channelNo: "channelNo", // 通道号
|
||||
channelSn: "channelSn", // 通道序列号
|
||||
produceDate: "produceDate", // 生产日期
|
||||
detectorStatus: "detectorStatus" // 上报状态 01=在线 02=离线
|
||||
} as const;
|
||||
|
||||
/** 渲染列表路径与 token 鉴权头(占位,接入真实平台时配置 .env)。 */
|
||||
export const ICC_LINE_ENDPOINT = "/api/grid/lines";
|
||||
/** 离线标记集合:命中即归一化为离线(0),其余(含空)均视为在线(1) */
|
||||
export const OFFLINE_MARKERS = new Set(["02", "0", "false", "off", "离线"]);
|
||||
|
||||
/** 将 ICC 原始记录映射为本系统 electric_line 落库字段(不含联表外键)。 */
|
||||
/** 字符串 trim;null / undefined / 空串统一归一为 undefined。 */
|
||||
export function toOptionalString(value: unknown): string | undefined {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
const s = String(value).trim();
|
||||
return s === "" ? undefined : s;
|
||||
}
|
||||
|
||||
/** 将 ICC 原始探测器记录映射为本系统 electric_line 落库字段(不含联表外键)。 */
|
||||
export function mapIccLineRecord(raw: IccLineRawRecord): {
|
||||
lineCode: string;
|
||||
iccDeviceCode: string;
|
||||
equipCode?: string;
|
||||
lineName?: string;
|
||||
customerNo?: string;
|
||||
buildingName?: string;
|
||||
floorNo?: string;
|
||||
location?: string;
|
||||
imei?: string;
|
||||
orgCode?: string;
|
||||
parentCode?: string;
|
||||
typeCode?: string;
|
||||
typeName?: string;
|
||||
fireSystemCode?: string;
|
||||
fireSystemName?: string;
|
||||
partUnitLoopCode?: string;
|
||||
channelNo?: string;
|
||||
channelSn?: string;
|
||||
produceDate?: string;
|
||||
status: number;
|
||||
} {
|
||||
const f = ICC_LINE_FIELDS;
|
||||
return {
|
||||
lineCode: String(raw[f.lineCode] ?? "").trim(),
|
||||
iccDeviceCode: String(raw[f.iccDeviceCode] ?? "").trim(),
|
||||
equipCode: raw[f.equipCode] ? String(raw[f.equipCode]).trim() : undefined,
|
||||
lineName: raw[f.lineName] ? String(raw[f.lineName]).trim() : undefined,
|
||||
customerNo: raw[f.customerNo] ? String(raw[f.customerNo]).trim() : undefined,
|
||||
buildingName: raw[f.buildingName] ? String(raw[f.buildingName]).trim() : undefined,
|
||||
floorNo: raw[f.floorNo] ? String(raw[f.floorNo]).trim() : undefined,
|
||||
location: raw[f.location] ? String(raw[f.location]).trim() : undefined,
|
||||
status: Number(raw[f.status]) === 0 ? 0 : 1
|
||||
const result: Record<string, any> = {
|
||||
// 匹配键保持空串语义,由调用方判跳
|
||||
lineCode: String(raw[f.lineCode] ?? "").trim()
|
||||
};
|
||||
}
|
||||
// 可选字符串字段
|
||||
for (const key of ["lineName", "imei", "orgCode", "parentCode", "typeCode", "typeName", "fireSystemCode", "fireSystemName", "partUnitLoopCode", "channelNo", "channelSn", "produceDate"] as const) {
|
||||
result[key] = toOptionalString(raw[f[key]]);
|
||||
}
|
||||
// 上报状态归一化:离线标记集合 → 0,其余(含空)→ 1
|
||||
const statusRaw = String(raw[f.detectorStatus] ?? "").trim();
|
||||
result.status = OFFLINE_MARKERS.has(statusRaw) ? 0 : 1;
|
||||
return result as any;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析探测器分页响应(兼容三种形态):
|
||||
* - payload 为数组 → 直接作为列表
|
||||
* - payload.data.pageData 为数组 → 取 pageData
|
||||
* - payload.data.list 为数组 → 取 list
|
||||
* - 结构不匹配或解析异常 → 返回空列表
|
||||
*/
|
||||
export function parseLinePage(payload: any): {
|
||||
list: IccLineRawRecord[];
|
||||
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: [] };
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,9 @@ 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";
|
||||
import { iccAnalogSyncJob } from "./icc_analog_sync.task";
|
||||
import { iccEventAlarmKeepaliveJob } from "./icc_event_keepalive.task";
|
||||
|
||||
export type JobParamType = "number" | "string";
|
||||
|
||||
@@ -35,6 +38,12 @@ export interface JobRuntime {
|
||||
syncIccLines(): Promise<string>;
|
||||
/** 从 ICC 平台拉取 org_base 组织并按 iccCode 幂等新增/变更客户,返回执行摘要 */
|
||||
syncIccCustomers(): Promise<string>;
|
||||
/** 从 ICC 平台拉取智电改造网关设备并按 imei/deviceCode 幂等 upsert,返回执行摘要 */
|
||||
syncIccGateways(): Promise<string>;
|
||||
/** 从 ICC 平台按 deviceCode 分批拉取探测器模拟量并按 (line_code,analog_type,collect_time) 幂等 upsert,返回执行摘要 */
|
||||
syncIccAnalogs(): Promise<string>;
|
||||
/** 向 ICC 平台注册报警事件回调订阅(同名覆盖保活),返回注册结果摘要 */
|
||||
ensureIccEventSubscribe(): Promise<string>;
|
||||
}
|
||||
|
||||
export interface JobHandlerContext {
|
||||
@@ -61,7 +70,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, iccAnalogSyncJob, iccEventAlarmKeepaliveJob];
|
||||
|
||||
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