feat(alarm): ICC 报警事件订阅与设备报警监控/报警派单 - 重建 device_alarm 表(迁移20261024_icc_event_alarm.sql:alarm_code 唯一、uuid、line 关联、客户、等级、事件标记、alarm_time 等 19 字段与 4 索引)并新建 alarm_dispatch 派单表(15 字段);订阅服务 IccEventSubscribeService 复用 OAuth2 鉴权向 ICC 注册报警事件回调(POST subscribe/mqinfo,monitor 带 token、同名覆盖保活),每 30 分钟保活任务(sys_job 种子);回调 POST /api/icc-event/callback 无鉴权仅 URL token 校验,兼容单条/数组事件解析、按 alarmCode 幂等 upsert、匹配 electric_line.line_code 回填线路客户;新增 GET /api/device-alarms(列表+今日/严重/未派单统计+客户/等级/关键词筛选)与 POST /api/device-alarms/:id/dispatch(AD 派单号、同报警重复派单拦截、电工在岗审核校验、NoticeService 站内通知);GET /api/alarm-dispatches 台账(联电工/客户名);前端设备报警监控页(统计卡/筛选/表格/发起报警派单弹窗/30 秒轮询+严重报警红灯闪烁与 Web Audio 蜂鸣/订阅状态弹窗)与报警派单台账页+路由;菜单 63/64、role_menu、sys_job 种子;同步 db_init/db_seed 与设计文档;端到端验证通过

Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com
This commit is contained in:
wkandClaude Sonnet 4.6 noreply@anthropic.com authored and trae-solo-agent committed 2026-10-24 16:59:02 +08:00
1 parent c8a4330f3b
commit 44fc9a1d54
24 files changed
+2825 -126

No files matched your search

+8
View File
@@ -112,6 +112,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,184 @@
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`);
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 } })
]);
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 }
}
};
}
/** 发起派单:校验报警/未完成派单/电工,生成派单号,写入派单并给电工发站内通知 */
@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,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()
};
}
}
@@ -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;
}
+71
View File
@@ -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;
}
+2 -2
View File
@@ -5,8 +5,8 @@ import { TokenUtil } from '../util/token';
@Provide()
export class AuthMiddleware implements IMiddleware<Context, NextFunction> {
// 中间件匹配范围:全局生效,但跳过公开接口与电工 APP 接口(电工 APP 走独立鉴权)
ignore = ['/api/auth/*', '/api/health', '/api/worker-app/*'];
// 中间件匹配范围:全局生效,但跳过公开接口、ICC 事件回调与电工 APP 接口(电工 APP 走独立鉴权)
ignore = ['/api/auth/*', '/api/health', '/api/worker-app/*', '/api/icc-event/callback'];
@Inject()
tokenUtil!: TokenUtil;
+6 -2
View File
@@ -31,6 +31,10 @@ const PERMISSION_MAP: Record<string, string> = {
"/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",
@@ -50,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/workers/options", "/api/workers/recommend"];
ignore = ["/api/auth/*", "/api/health", "/api/dashboard/*", "/api/worker-app/*", "/api/workers/options", "/api/workers/recommend", "/api/icc-event/callback"];
@Inject()
permissionService!: PermissionService;
@@ -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;
}
}
@@ -12,6 +12,7 @@ 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";
/** 任务执行结果(一次尝试) */
@@ -473,6 +474,12 @@ export class JobSchedulerService {
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 ?? ""}`;
}
};
}
@@ -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();
}
};
+4 -1
View File
@@ -5,6 +5,7 @@ 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";
@@ -41,6 +42,8 @@ export interface JobRuntime {
syncIccGateways(): Promise<string>;
/** 从 ICC 平台按 deviceCode 分批拉取探测器模拟量并按 (line_code,analog_type,collect_time) 幂等 upsert,返回执行摘要 */
syncIccAnalogs(): Promise<string>;
/** 向 ICC 平台注册报警事件回调订阅(同名覆盖保活),返回注册结果摘要 */
ensureIccEventSubscribe(): Promise<string>;
}
export interface JobHandlerContext {
@@ -67,7 +70,7 @@ export interface BuiltinJob {
handler: JobHandler;
}
export const BUILTIN_JOBS: BuiltinJob[] = [demoHeartbeatJob, logCleanJob, noticeScanJob, iccLineSyncJob, iccCustomerSyncJob, iccGatewaySyncJob, iccAnalogSyncJob];
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);