diff --git a/.trae/specs/add-icc-event-alarm/checklist.md b/.trae/specs/add-icc-event-alarm/checklist.md new file mode 100644 index 0000000..a299bb7 --- /dev/null +++ b/.trae/specs/add-icc-event-alarm/checklist.md @@ -0,0 +1,15 @@ +# Checklist + +- [x] 数据库迁移 20261024_icc_event_alarm.sql 可执行,device_alarm/alarm_dispatch 结构与 spec 一致(字段、索引、唯一键),db_init.sql 同步 +- [x] 设计文档(表结构新增及变更.md / db_design.html)中 device_alarm、alarm_dispatch 字段已更新 +- [x] device_alarm.entity.ts / alarm_dispatch.entity.ts 与迁移字段一一对应,后端 tsc 通过 +- [x] 回调接口 POST /api/icc-event/callback:token 校验失败返回 401;合法 alarm 事件按 alarmCode 幂等 upsert;匹配 electric_line 填充 line_id/line_name/customer_id;未匹配记录 line_id 为空;返回 {"success":true,"code":"0"} +- [x] ICC 订阅注册(POST subscribe/mqinfo)可手动触发,配置了 monitor URL 与 name/magic;保活定时任务 icc_event_alarm_keepalive 已注册(cron */30 * * * *,sys_job 种子) +- [x] RBAC PERMISSION_MAP 与菜单/角色种子就位:设备报警监控(alarm:list)、报警派单台账与创建(alarm-dispatch:list / create) +- [x] GET /api/device-alarms 列表+统计+筛选只返回匹配线路的报警;POST /api/device-alarms/:id/dispatch 生成唯一派单号、同报警未完成派单拒绝重复创建、校验电工在岗审核通过 +- [x] 报警派单创建后向电工发送 sys_notice 站内通知(receiver_type=worker) +- [x] GET /api/alarm-dispatches 台账分页列表正常(联出电工姓名/客户名称) +- [x] 前端设备报警监控页面:统计卡、筛选、报警表格、行内「发起报警派单」按钮与电工选择弹窗齐全,样式对齐项目勘察页 +- [x] 前端 30 秒轮询刷新;新出现 grade=1 报警时报警灯闪烁 + Web Audio 提示音,且去重不重复响 +- [x] 前端报警派单台账页面与路由就位,vue-tsc 通过 +- [x] 后端 tsc + 前端 vue-tsc 全绿;模拟回调端到端验证通过(入库→页面展示→报警灯/声音→发起派单→站内通知→台账可见) diff --git a/.trae/specs/add-icc-event-alarm/spec.md b/.trae/specs/add-icc-event-alarm/spec.md new file mode 100644 index 0000000..9b60927 --- /dev/null +++ b/.trae/specs/add-icc-event-alarm/spec.md @@ -0,0 +1,20 @@ +# Checklist + +* [x] 生产库 electricity\_data 现状已核对(有无表/字段/分区/数据),迁移方案据此确定 + +* [x] 迁移 `20261024_icc_analog_data.sql` 已执行到生产库:字段集(id/line\_id/line\_code/imei/line\_name/customer\_id/analog\_type/analog\_name/analog\_unit/analog\_value/collect\_time,**无 equip\_id**)正确,唯一键 (line\_code, analog\_type, collect\_time)、按月分区(2026\_10\~2027\_03)、索引(customer/line/time)就绪 + +* [x] db\_init.sql 中 electricity\_data 基线定义与迁移一致 + +* [x] 后端实体 `electricity_data.entity.ts` 字段与迁移一致,映射 `icc_analog_mapper.ts` 按实际返回字段正确映射(line\_code/imei/analog\_type/analog\_name/analog\_unit/analog\_value) + +* [x] 同步服务 `icc_analog_sync.service.ts` 复用 OAuth2 鉴权,先读已同步线路 line\_code,分批 deviceCodes(≤50)POST 模拟量接口 pageSize=500 短页停止,collect\_time 用同步时间,按 (line\_code, analog\_type, collect\_time) 幂等 upsert,写 icc\_sync\_log(sync\_type=electricity\_data),失败仅记日志不抛 + +* [x] `POST /api/electricity-data/sync` 接口可调用并返回 {pulled, inserted, updated, skipped, synced},RBAC 路由 `/api/electricity-data` → device:list 生效 + +* [x] 定时任务 `icc_analog_sync`(每 5 分钟 `*/5 * * * *`)已注册:job\_registry BUILTIN\_JOBS + JobRuntime.syncIccAnalogs + db\_seed.sql sys\_job 种子幂等 + +* [x] 设计文档「表结构新增及变更.md」已补充 electricity\_data 新表结构及同步服务/定时任务说明 + +* [x] 验证通过:tsc 编译无错误;真实同步拉取到模拟量数据入库(74 行/快照,analog\_name/analog\_unit 填充正确);幂等重跑正常(二次快照 148 行);无前端改动 + diff --git a/.trae/specs/add-icc-event-alarm/tasks.md b/.trae/specs/add-icc-event-alarm/tasks.md new file mode 100644 index 0000000..850d0e7 --- /dev/null +++ b/.trae/specs/add-icc-event-alarm/tasks.md @@ -0,0 +1,55 @@ +# Tasks + +- [x] Task 1: 数据库迁移与设计文档同步 + - [x] 1.1 新建 database/migrations/20261024_icc_event_alarm.sql:DROP 重建 device_alarm(按 ICC 事件结构:alarm_code UNIQUE、uuid、line_id/line_code/line_name/customer_id、node_code/device_code/device_name、org_code/org_name、alarm_type/alarm_grade/alarm_stat/is_event、alarm_time TIMESTAMPTZ、alarm_value NUMERIC(14,4) 可空、synced_at;索引 idx_alarm_time/idx_alarm_line/idx_alarm_customer/idx_alarm_grade)与 alarm_dispatch(dispatch_no UNIQUE、alarm_id→device_alarm、line_id/line_code/line_name、customer_id→customer、alarm_time/alarm_grade 快照、dispatcher、worker_id、status 0已派发 1已处理、fault_desc、created_at/completed_at;索引 idx_alarm_disp_status/idx_alarm_disp_worker/idx_alarm_disp_alarm) + - [x] 1.2 同步 database/db_init.sql 中对应两表 DDL(与迁移保持一致) + - [x] 1.3 更新 系统设计/表结构新增及变更.md(device_alarm/alarm_dispatch 小节)与 db_design.html 中相关表结构(字段级) + - [x] 1.4 执行迁移到生产库验证(表为空,安全重建;用 server/node_modules/pg 脚本) + +- [x] Task 2: 后端实体 + - [x] 2.1 server/src/entity/device_alarm.entity.ts(字段与迁移一致,line_id/customer_id 建 FK/BelongsTo 可选) + - [x] 2.2 server/src/entity/alarm_dispatch.entity.ts(含 BelongsTo device_alarm、Customer;worker_id 仅存 ID) + - [x] 2.3 `npx tsc --noEmit` 通过 + +- [x] Task 3: ICC 事件订阅服务与回调接收 + - [x] 3.1 server/src/service/icc_event_subscribe.service.ts:复用 getIccAccessToken/iccFetch(icc_customer_auth.ts),实现 registerSubscribe()(POST subscribe/mqinfo,category=alarm、订阅全部类型/权限、monitor=配置的回调 URL 带 token、subsystem name/magic=配置默认 `222.243.55.42_7001_alarm`)与 ensureSubscribe() + - [x] 3.2 server/src/controller/icc_event.controller.ts:POST /api/icc-event/callback(无鉴权,校验 URL token 与配置一致,解析通用事件格式,仅处理 category=alarm;按 alarmCode upsert 到 device_alarm,匹配 electric_line.line_code 填充 line_id/line_name/customer_id;返回 {"success":true,"code":"0"});另提供受 RBAC 保护的 POST /api/icc-event/subscribe(手动触发注册)与 GET /api/icc-event/subscribe-status + - [x] 3.3 幂等处理:同 uuid/alarmCode 重复推送不重复计数;alarm_stat 变化更新原记录 + - [x] 3.4 回调 token 与 monitor URL 从配置读取(config.default.ts 加 ICC_EVENT 配置项) + +- [x] Task 4: 保活定时任务、RBAC 与种子 + - [x] 4.1 server/src/task/icc_event_keepalive.task.ts(defaultCron `*/30 * * * *`,调用 ensureSubscribe),注册到 job_registry.ts 与 job_scheduler.service.ts(syncIccEvents) + - [x] 4.2 rbac.middleware.ts PERMISSION_MAP 新增:`/api/icc-event/subscribe: device:list`、`/api/device-alarms: alarm:list`、`/api/alarm-dispatches: alarm-dispatch:list`(create 权限 `alarm-dispatch:create`) + - [x] 4.3 database/db_seed.sql:新增 sys_job(icc_event_alarm_keepalive,cron `*/30 * * * *`)、sys_menu(设备监控→设备报警监控 /api/device/alarm;运营派单→报警派单台账 /api/dispatch/alarm)与 sys_role_menu(ADMIN 角色)、sys_permission 相关种子(若权限为表驱动则同步) + +- [x] Task 5: 报警查询与报警派单后端接口 + - [x] 5.1 server/src/controller/device_alarm.controller.ts:GET /api/device-alarms(分页列表+统计:今日/严重/未派单;筛选客户/等级/关键词;只展示 line_id 非空的记录)+ POST /api/device-alarms/:id/dispatch(创建 alarm_dispatch:生成唯一 dispatch_no、快照报警/线路/客户、dispatcher=当前用户 realName、worker_id 校验在岗+审核通过;同一报警已有未完成派单则拒绝) + - [x] 5.2 派单后通过 NoticeService.deliverToReceivers(参照 survey_order.service 的派单通知埋点)向 worker 发站内通知(notice_type=alarm_dispatch,receiver_type=worker) + - [x] 5.3 server/src/controller/alarm_dispatch.controller.ts:GET /api/alarm-dispatches(台账分页列表,筛选状态/关键词,联出电工姓名/客户名称) + - [x] 5.4 `npx tsc --noEmit` 通过 + +- [x] Task 6: 前端-设备报警监控页面 + - [x] 6.1 client/src/api/device.ts 或新增 api/alarm.ts:deviceAlarmApi.getList/dispatch、alarmDispatchApi.getList、iccEventApi.subscribe/status 类型与封装 + - [x] 6.2 client/src/views/device/alarm/index.vue(样式对齐 views/dispatch/survey/index.vue):统计卡(今日/严重/未派单)+ 筛选区(客户/等级/关键词)+ 报警表格(报警时间/线路名称/线路编码/客户/类型/等级 tag/状态)+ 行内「发起报警派单」按钮 + 派单弹窗(共享电工下拉,取 worker 在岗审核通过列表) + - [x] 6.3 严重报警提示:页面挂载轮询(30s 刷新列表);轮询发现新 grade=1 报警时右上角报警灯闪烁(全局按钮/角标)并播放提示音(Web Audio 蜂鸣 3 次);按「已提示的报警 id 集合」去重 + - [x] 6.4 路由注册(client/src/router)+ 菜单由后端 sys_menu 动态渲染(自动生效) + +- [x] Task 7: 前端-报警派单台账页面 + - [x] 7.1 client/src/views/dispatch/alarm/index.vue:台账列表(派单号/报警时间/线路/客户/指派电工/状态 tag/创建时间)+ 筛选(状态/关键词) + - [x] 7.2 路由注册(运营派单分组下) + - [x] 7.3 `npm run check`(vue-tsc)通过 + +- [x] Task 8: 验证 + - [x] 8.1 后端 `npx tsc --noEmit`、前端 `npm run check` 全绿 + - [x] 8.2 接口自测:手工构造 ICC 通用事件 JSON POST 回调接口验证入库/幂等/token 校验/未匹配线路落空记录;subscribe 注册接口调用成功 + - [x] 8.3 浏览器端到端:登录进入「设备报警监控」,触发一条模拟回调入库后页面可见;严重报警出现报警灯+声音;发起报警派单→选择电工→生成派单→电工收到站内通知;「报警派单台账」显示该派单 + - [x] 8.4 若生产订阅真实生效受限(ICC 无法访问回调地址),以模拟回调验证全链路并在交付说明中注明 + +# Task Dependencies +- Task 2 依赖 Task 1(表结构确定) +- Task 3 依赖 Task 2(实体) +- Task 4 依赖 Task 3(订阅服务) +- Task 5 依赖 Task 2(实体)与 Task 3(回调入库可测) +- Task 6 依赖 Task 5(接口) +- Task 7 依赖 Task 5.3(台账接口) +- Task 8 依赖全部 diff --git a/client/src/api/alarm.ts b/client/src/api/alarm.ts new file mode 100644 index 0000000..00a5c4e --- /dev/null +++ b/client/src/api/alarm.ts @@ -0,0 +1,136 @@ +import request from '@/utils/request'; + +// ==================== 设备报警 ==================== + +export interface DeviceAlarmRow { + id: number; + alarmCode?: string; + uuid?: string; + lineId?: number; + lineCode?: string; + lineName?: string; + customerId?: number; + nodeCode?: string; + deviceCode?: string; + deviceName?: string; + orgCode?: string; + orgName?: string; + alarmType?: number | string; + // 报警等级:1 严重 / 2 一般 / 3 提示 + alarmGrade?: number; + alarmStat?: number; + // 是否事件(1 事件 / 0 报警) + isEvent?: number; + alarmTime?: string; + alarmValue?: string; + syncedAt?: string; + // 联表补全字段(存在性不确定,使用时防御性取用) + customerName?: string; +} + +export interface DeviceAlarmSummary { + todayCount: number; + gradeOneCount: number; + undispatchedCount: number; +} + +export interface DeviceAlarmListResult { + list: DeviceAlarmRow[]; + total: number; + page: number; + pageSize: number; + summary?: DeviceAlarmSummary; +} + +export interface DeviceAlarmQuery { + page?: number; + pageSize?: number; + customerId?: number; + alarmGrade?: number; + keyword?: string; +} + +export interface DeviceAlarmDispatchParams { + workerId: number; + faultDesc?: string; +} + +export interface DeviceAlarmDispatchResult { + id?: number; + alarmId?: number; + dispatchNo?: string; + workerId?: number; + workerName?: string; + status?: number; + faultDesc?: string; + dispatchedAt?: string; +} + +export const deviceAlarmApi = { + getList: (params: DeviceAlarmQuery) => + request.get('/device-alarms', { params }), + + dispatch: (id: number, body: DeviceAlarmDispatchParams) => + request.post(`/device-alarms/${id}/dispatch`, body), +}; + +// ==================== 报警派单台账(Task7 使用) ==================== + +export interface AlarmDispatchRow { + id: number; + alarmId?: number; + dispatchNo?: string; + lineId?: number; + lineCode?: string; + lineName?: string; + customerId?: number; + customerName?: string; + alarmTime?: string; + // 报警等级:1 严重 / 2 一般 / 3 提示 + alarmGrade?: number; + faultDesc?: string; + dispatcher?: string; + workerId?: number; + workerName?: string; + // 状态:0 已派发 / 1 已处理 + status?: number; + dispatchedAt?: string; + createdAt?: string; + completedAt?: string; +} + +export interface AlarmDispatchQuery { + page?: number; + pageSize?: number; + status?: number | string; + keyword?: string; +} + +export interface AlarmDispatchListResult { + list: AlarmDispatchRow[]; + total: number; + page: number; + pageSize: number; +} + +export const alarmDispatchApi = { + getList: (params: AlarmDispatchQuery) => + request.get('/alarm-dispatches', { params }), +}; + +// ==================== ICC 事件订阅(本页订阅状态提示,可选) ==================== + +export interface IccEventStatus { + name?: string; + monitorUrl?: string; + magic?: string; + status?: string; + registerResult?: string; + registeredAt?: string; + lastError?: string; +} + +export const iccEventApi = { + subscribe: () => request.post('/icc-event/subscribe'), + status: () => request.get('/icc-event/subscribe-status'), +}; diff --git a/client/src/router/index.ts b/client/src/router/index.ts index 29ed647..a4a631a 100644 --- a/client/src/router/index.ts +++ b/client/src/router/index.ts @@ -175,6 +175,12 @@ const routes: RouteRecordRaw[] = [ component: () => import("@/views/dispatch/maintenance/index.vue"), meta: { requiresAuth: true, title: "维保派单", permission: "dispatch:maintenance" } }, + { + path: "dispatch/alarm", + name: "AlarmDispatchLedger", + component: () => import("@/views/dispatch/alarm/index.vue"), + meta: { requiresAuth: true, title: "报警派单台账", permission: "alarm-dispatch:list" } + }, { path: "device", name: "Device", @@ -199,6 +205,12 @@ const routes: RouteRecordRaw[] = [ component: () => import("@/views/device/gateway/index.vue"), meta: { requiresAuth: true, title: "智电改造网关台账", permission: "device:list" } }, + { + path: "device/alarm", + name: "DeviceAlarm", + component: () => import("@/views/device/alarm/index.vue"), + meta: { requiresAuth: true, title: "设备报警监控", permission: "device:list" } + }, { path: "base", name: "Base", diff --git a/client/src/views/device/alarm/index.vue b/client/src/views/device/alarm/index.vue new file mode 100644 index 0000000..d0ccb52 --- /dev/null +++ b/client/src/views/device/alarm/index.vue @@ -0,0 +1,1054 @@ + + + + diff --git a/client/src/views/dispatch/alarm/index.vue b/client/src/views/dispatch/alarm/index.vue new file mode 100644 index 0000000..a6be8d7 --- /dev/null +++ b/client/src/views/dispatch/alarm/index.vue @@ -0,0 +1,511 @@ + + + + diff --git a/database/db_init.sql b/database/db_init.sql index df28009..c6eeb80 100644 --- a/database/db_init.sql +++ b/database/db_init.sql @@ -921,101 +921,58 @@ COMMENT ON COLUMN fault_type_detail.remark IS '备注'; COMMENT ON COLUMN fault_type_detail.created_at IS '创建时间'; CREATE INDEX idx_ftd_type ON fault_type_detail (fault_type_id); --- 27. 设备报警信息表(ICC 拉取) +-- 27. 设备报警信息表(ICC 事件订阅) +DROP TABLE IF EXISTS alarm_dispatch CASCADE; DROP TABLE IF EXISTS device_alarm CASCADE; CREATE TABLE device_alarm ( - id BIGSERIAL PRIMARY KEY, - alarm_code VARCHAR(128) NOT NULL UNIQUE, - line_id BIGINT, - equip_id BIGINT, - icc_device_code VARCHAR(128), - line_name VARCHAR(128), - customer_id BIGINT, - building_id BIGINT, - floor_no VARCHAR(16), - location VARCHAR(256), - alarm_date DATE, - fault_type VARCHAR(128), - alarm_value NUMERIC(14,2), - history_count INT DEFAULT 0, - synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + id BIGSERIAL PRIMARY KEY, + alarm_code VARCHAR(128) NOT NULL UNIQUE, -- ICC 报警编码(幂等键) + uuid VARCHAR(128), -- ICC 事件 uuid + line_id BIGINT, -- 智电线路 ID(→ electric_line,nodeCode/deviceCode 匹配 line_code) + line_code VARCHAR(64), -- 线路编码(electric_line.line_code) + line_name VARCHAR(128), -- 线路名称 + customer_id BIGINT, -- 客户 ID(由 line_id 联出) + node_code VARCHAR(64), -- 探测器节点编码(info.nodeCode) + device_code VARCHAR(64), -- 设备编码(info.deviceCode) + device_name VARCHAR(128), -- 设备名称(info.deviceName) + org_code VARCHAR(64), -- 组织编码(info.orgCode) + org_name VARCHAR(128), -- 组织名称(info.orgName) + alarm_type INT, -- 报警类型(info.alarmType) + alarm_grade SMALLINT, -- 报警等级 1/2/3(1 最严重) + alarm_stat SMALLINT, -- 报警状态(info.alarmStat) + is_event BOOLEAN DEFAULT FALSE, -- 是否事件(info.isEvent) + alarm_time TIMESTAMPTZ, -- 报警发生时间(info.alarmDate 秒级时间戳) + alarm_value NUMERIC(14,4), -- 报警数值(部分事件带值,可空) + synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -- 入库时间 ); -COMMENT ON TABLE device_alarm IS '设备报警信息表(ICC 拉取)'; -COMMENT ON COLUMN device_alarm.id IS '自增主键'; -COMMENT ON COLUMN device_alarm.alarm_code IS '报警编码(唯一)'; -COMMENT ON COLUMN device_alarm.line_id IS '线路 ID'; -COMMENT ON COLUMN device_alarm.equip_id IS '客户设备 ID'; -COMMENT ON COLUMN device_alarm.icc_device_code IS 'ICC 设备编号'; -COMMENT ON COLUMN device_alarm.line_name IS '线路名称'; -COMMENT ON COLUMN device_alarm.customer_id IS '客户 ID'; -COMMENT ON COLUMN device_alarm.building_id IS '所属建筑 ID'; -COMMENT ON COLUMN device_alarm.floor_no IS '所在楼层'; -COMMENT ON COLUMN device_alarm.location IS '所在位置'; -COMMENT ON COLUMN device_alarm.alarm_date IS '报警日期'; -COMMENT ON COLUMN device_alarm.fault_type IS 'ICC 预警类型'; -COMMENT ON COLUMN device_alarm.alarm_value IS '报警数值'; -COMMENT ON COLUMN device_alarm.history_count IS '历史出现次数'; -COMMENT ON COLUMN device_alarm.synced_at IS 'ICC 同步时间'; -CREATE INDEX idx_alarm_customer ON device_alarm (customer_id); -CREATE INDEX idx_alarm_date ON device_alarm (alarm_date); +COMMENT ON TABLE device_alarm IS '设备报警信息表(ICC 事件订阅)'; +CREATE INDEX idx_alarm_time ON device_alarm (alarm_time DESC); CREATE INDEX idx_alarm_line ON device_alarm (line_id); +CREATE INDEX idx_alarm_customer ON device_alarm (customer_id); +CREATE INDEX idx_alarm_grade ON device_alarm (alarm_grade); -- 28. 报警派单信息表 -DROP TABLE IF EXISTS alarm_dispatch CASCADE; CREATE TABLE alarm_dispatch ( - id BIGSERIAL PRIMARY KEY, - dispatch_no VARCHAR(64) NOT NULL UNIQUE, - alarm_id BIGINT, - line_id BIGINT, - equip_id BIGINT, - icc_device_code VARCHAR(128), - line_name VARCHAR(128), - customer_id BIGINT NOT NULL REFERENCES customer(id), - contact_person VARCHAR(64), - contact_phone VARCHAR(20), - building_id BIGINT, - floor_no VARCHAR(16), - location VARCHAR(256), - fault_type_id BIGINT REFERENCES fault_type(id), - fault_desc TEXT, - dispatcher VARCHAR(64), - fault_reason TEXT, - process_flow TEXT, - worker_id BIGINT, - status SMALLINT NOT NULL DEFAULT 0, - process_photos JSONB, - rating SMALLINT, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - completed_at TIMESTAMPTZ + id BIGSERIAL PRIMARY KEY, + dispatch_no VARCHAR(64) NOT NULL UNIQUE, -- 报警派单号(如 AD20261024001,生成规则由后端实现) + alarm_id BIGINT, -- 报警 ID(→ device_alarm) + line_id BIGINT, -- 线路 ID + line_code VARCHAR(64), -- 线路编码 + line_name VARCHAR(128), -- 线路名称 + customer_id BIGINT REFERENCES customer(id), -- 客户 ID + alarm_time TIMESTAMPTZ, -- 报警时间(快照) + alarm_grade SMALLINT, -- 报警等级(快照) + fault_desc TEXT, -- 故障事项(报警类型描述) + dispatcher VARCHAR(64), -- 派发人(当前登录用户 realName) + worker_id BIGINT, -- 共享电工 ID(→ worker) + status SMALLINT NOT NULL DEFAULT 0, -- 状态:0已派发 1已处理 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + completed_at TIMESTAMPTZ ); -COMMENT ON TABLE alarm_dispatch IS '报警派单信息表'; -COMMENT ON COLUMN alarm_dispatch.id IS '自增主键'; -COMMENT ON COLUMN alarm_dispatch.dispatch_no IS '报警派单号(唯一)'; -COMMENT ON COLUMN alarm_dispatch.alarm_id IS '报警 ID(→ device_alarm)'; -COMMENT ON COLUMN alarm_dispatch.line_id IS '线路 ID'; -COMMENT ON COLUMN alarm_dispatch.equip_id IS '客户设备 ID'; -COMMENT ON COLUMN alarm_dispatch.icc_device_code IS 'ICC 设备编号'; -COMMENT ON COLUMN alarm_dispatch.line_name IS '线路名称'; -COMMENT ON COLUMN alarm_dispatch.customer_id IS '客户 ID(→ customer)'; -COMMENT ON COLUMN alarm_dispatch.contact_person IS '联系人'; -COMMENT ON COLUMN alarm_dispatch.contact_phone IS '电话'; -COMMENT ON COLUMN alarm_dispatch.building_id IS '建筑 ID'; -COMMENT ON COLUMN alarm_dispatch.floor_no IS '所在楼层'; -COMMENT ON COLUMN alarm_dispatch.location IS '所在位置'; -COMMENT ON COLUMN alarm_dispatch.fault_type_id IS '故障类型 ID(→ fault_type)'; -COMMENT ON COLUMN alarm_dispatch.fault_desc IS '故障事项(类型+数值)'; -COMMENT ON COLUMN alarm_dispatch.dispatcher IS '派发人'; -COMMENT ON COLUMN alarm_dispatch.fault_reason IS '故障原因(来自明细)'; -COMMENT ON COLUMN alarm_dispatch.process_flow IS '处理流程(来自明细)'; -COMMENT ON COLUMN alarm_dispatch.worker_id IS '共享电工 ID'; -COMMENT ON COLUMN alarm_dispatch.status IS '状态:0已派发 1已处理'; -COMMENT ON COLUMN alarm_dispatch.process_photos IS '报警处理照片(JSONB 多张)'; -COMMENT ON COLUMN alarm_dispatch.rating IS '派单处理评分'; -COMMENT ON COLUMN alarm_dispatch.created_at IS '创建时间'; -COMMENT ON COLUMN alarm_dispatch.completed_at IS '完成时间'; -CREATE INDEX idx_alarm_disp_customer ON alarm_dispatch (customer_id); -CREATE INDEX idx_alarm_disp_worker ON alarm_dispatch (worker_id); -CREATE INDEX idx_alarm_disp_status ON alarm_dispatch (status); +COMMENT ON TABLE alarm_dispatch IS '报警派单信息表'; +CREATE INDEX idx_alarm_disp_status ON alarm_dispatch (status); +CREATE INDEX idx_alarm_disp_worker ON alarm_dispatch (worker_id); +CREATE INDEX idx_alarm_disp_alarm ON alarm_dispatch (alarm_id); -- ============================================================ -- 八、报修域(2 张表) diff --git a/database/db_seed.sql b/database/db_seed.sql index 8197c0d..ad11aa7 100644 --- a/database/db_seed.sql +++ b/database/db_seed.sql @@ -538,6 +538,44 @@ ON CONFLICT (job_key) DO UPDATE SET job_name = EXCLUDED.job_name, remark = EXCLUDED.remark; +-- ------------------------------------------------------------ +-- 12. ICC 事件订阅保活任务种子(sys_job,按 job_key 幂等;默认每 30 分钟) +-- ------------------------------------------------------------ +INSERT INTO sys_job (job_key, job_name, cron_expression, params, retry_times, retry_interval, status, remark) VALUES +('icc_event_alarm_keepalive', 'ICC事件订阅保活', '*/30 * * * *', '{}'::jsonb, 1, 300, 1, + '向 ICC 平台重新注册报警事件回调订阅(同名覆盖保活,默认每 30 分钟)') +ON CONFLICT (job_key) DO UPDATE SET + cron_expression = EXCLUDED.cron_expression, + job_name = EXCLUDED.job_name, + remark = EXCLUDED.remark; + +-- ------------------------------------------------------------ +-- 12a. 报警域菜单(设备监控下「设备报警监控」/ 运营派单下「报警派单台账」,id:63-64) +-- 63 挂设备监控 parent_id=4(sort 4,排在网关台账之后) +-- 64 挂运营派单 parent_id=3(sort 6,排在维保派单之后) +-- ------------------------------------------------------------ +INSERT INTO sys_menu (id, parent_id, menu_name, menu_path, component, icon, sort_order, menu_type, permission, visible) VALUES +(63, 4, '设备报警监控', '/device/alarm', 'device/alarm/index', 'AlertOutlined', 4, 2, 'alarm:list', 1), +(64, 3, '报警派单台账', '/dispatch/alarm', 'dispatch/alarm/index', 'ScheduleOutlined', 6, 2, 'alarm-dispatch:list', 1) +ON CONFLICT (id) DO UPDATE SET + parent_id = EXCLUDED.parent_id, + menu_name = EXCLUDED.menu_name, + menu_path = EXCLUDED.menu_path, + component = EXCLUDED.component, + icon = EXCLUDED.icon, + sort_order = EXCLUDED.sort_order, + menu_type = EXCLUDED.menu_type, + permission = EXCLUDED.permission, + visible = EXCLUDED.visible; + +-- 角色-菜单绑定:ADMIN(role_id=1)拥有 63-64 +INSERT INTO sys_role_menu (role_id, menu_id) VALUES +(1, 63), (1, 64) +ON CONFLICT (role_id, menu_id) DO NOTHING; + +-- 重置 sys_menu 自增序列(覆盖到 64) +SELECT setval('sys_menu_id_seq', GREATEST((SELECT MAX(id) FROM sys_menu), 64), true); + COMMIT; -- ============================================================ diff --git a/database/migrations/20261024_icc_event_alarm.sql b/database/migrations/20261024_icc_event_alarm.sql new file mode 100644 index 0000000..1f90ad4 --- /dev/null +++ b/database/migrations/20261024_icc_event_alarm.sql @@ -0,0 +1,52 @@ +-- ICC 报警事件订阅:按 ICC 平台事件订阅推送的通用事件格式重建 device_alarm / alarm_dispatch +-- 两表原为旧设计(equip_id/building_id/floor_no/location/history_count/fault_type/alarm_date 等与 ICC 事件结构不符),现为空表,DROP 重建 +DROP TABLE IF EXISTS alarm_dispatch CASCADE; +DROP TABLE IF EXISTS device_alarm CASCADE; +CREATE TABLE device_alarm ( + id BIGSERIAL PRIMARY KEY, + alarm_code VARCHAR(128) NOT NULL UNIQUE, -- ICC 报警编码(幂等键) + uuid VARCHAR(128), -- ICC 事件 uuid + line_id BIGINT, -- 智电线路 ID(→ electric_line,nodeCode/deviceCode 匹配 line_code) + line_code VARCHAR(64), -- 线路编码(electric_line.line_code) + line_name VARCHAR(128), -- 线路名称 + customer_id BIGINT, -- 客户 ID(由 line_id 联出) + node_code VARCHAR(64), -- 探测器节点编码(info.nodeCode) + device_code VARCHAR(64), -- 设备编码(info.deviceCode) + device_name VARCHAR(128), -- 设备名称(info.deviceName) + org_code VARCHAR(64), -- 组织编码(info.orgCode) + org_name VARCHAR(128), -- 组织名称(info.orgName) + alarm_type INT, -- 报警类型(info.alarmType) + alarm_grade SMALLINT, -- 报警等级 1/2/3(1 最严重) + alarm_stat SMALLINT, -- 报警状态(info.alarmStat) + is_event BOOLEAN DEFAULT FALSE, -- 是否事件(info.isEvent) + alarm_time TIMESTAMPTZ, -- 报警发生时间(info.alarmDate 秒级时间戳) + alarm_value NUMERIC(14,4), -- 报警数值(部分事件带值,可空) + synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -- 入库时间 +); +COMMENT ON TABLE device_alarm IS '设备报警信息表(ICC 事件订阅)'; +CREATE INDEX idx_alarm_time ON device_alarm (alarm_time DESC); +CREATE INDEX idx_alarm_line ON device_alarm (line_id); +CREATE INDEX idx_alarm_customer ON device_alarm (customer_id); +CREATE INDEX idx_alarm_grade ON device_alarm (alarm_grade); + +CREATE TABLE alarm_dispatch ( + id BIGSERIAL PRIMARY KEY, + dispatch_no VARCHAR(64) NOT NULL UNIQUE, -- 报警派单号(如 AD20261024001,生成规则由后端实现) + alarm_id BIGINT, -- 报警 ID(→ device_alarm) + line_id BIGINT, -- 线路 ID + line_code VARCHAR(64), -- 线路编码 + line_name VARCHAR(128), -- 线路名称 + customer_id BIGINT REFERENCES customer(id), -- 客户 ID + alarm_time TIMESTAMPTZ, -- 报警时间(快照) + alarm_grade SMALLINT, -- 报警等级(快照) + fault_desc TEXT, -- 故障事项(报警类型描述) + dispatcher VARCHAR(64), -- 派发人(当前登录用户 realName) + worker_id BIGINT, -- 共享电工 ID(→ worker) + status SMALLINT NOT NULL DEFAULT 0, -- 状态:0已派发 1已处理 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + completed_at TIMESTAMPTZ +); +COMMENT ON TABLE alarm_dispatch IS '报警派单信息表'; +CREATE INDEX idx_alarm_disp_status ON alarm_dispatch (status); +CREATE INDEX idx_alarm_disp_worker ON alarm_dispatch (worker_id); +CREATE INDEX idx_alarm_disp_alarm ON alarm_dispatch (alarm_id); diff --git a/server/src/config/config.default.ts b/server/src/config/config.default.ts index c8eabd8..c9a4b50 100644 --- a/server/src/config/config.default.ts +++ b/server/src/config/config.default.ts @@ -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", diff --git a/server/src/controller/alarm_dispatch.controller.ts b/server/src/controller/alarm_dispatch.controller.ts new file mode 100644 index 0000000..643edca --- /dev/null +++ b/server/src/controller/alarm_dispatch.controller.ts @@ -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; + + @InjectRepository(Worker) + workerRepo!: Repository; + + @InjectRepository(Customer) + customerRepo!: Repository; + + /** 报警派单台账分页列表(联出电工姓名/客户名称) */ + @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([]), + customerIds.length ? this.customerRepo.findAll({ where: { id: { [Op.in]: customerIds } }, attributes: ["id", "customerName"] }) : Promise.resolve([]) + ]); + 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 } + }; + } +} diff --git a/server/src/controller/device_alarm.controller.ts b/server/src/controller/device_alarm.controller.ts new file mode 100644 index 0000000..7416f66 --- /dev/null +++ b/server/src/controller/device_alarm.controller.ts @@ -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; + + @InjectRepository(AlarmDispatch) + dispatchRepo!: Repository; + + @InjectRepository(Worker) + workerRepo!: Repository; + + @InjectRepository(Customer) + customerRepo!: Repository; + + @InjectRepository(SysUser) + userRepo!: Repository; + + /** 报警分页列表(仅 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 { + 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 }); + } +} diff --git a/server/src/controller/icc_event.controller.ts b/server/src/controller/icc_event.controller.ts new file mode 100644 index 0000000..dd5b737 --- /dev/null +++ b/server/src/controller/icc_event.controller.ts @@ -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() + }; + } +} diff --git a/server/src/entity/alarm_dispatch.entity.ts b/server/src/entity/alarm_dispatch.entity.ts new file mode 100644 index 0000000..c31a91c --- /dev/null +++ b/server/src/entity/alarm_dispatch.entity.ts @@ -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; +} diff --git a/server/src/entity/device_alarm.entity.ts b/server/src/entity/device_alarm.entity.ts new file mode 100644 index 0000000..f641ff8 --- /dev/null +++ b/server/src/entity/device_alarm.entity.ts @@ -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; +} diff --git a/server/src/middleware/auth.middleware.ts b/server/src/middleware/auth.middleware.ts index d55a911..4062ea7 100644 --- a/server/src/middleware/auth.middleware.ts +++ b/server/src/middleware/auth.middleware.ts @@ -5,8 +5,8 @@ import { TokenUtil } from '../util/token'; @Provide() export class AuthMiddleware implements IMiddleware { - // 中间件匹配范围:全局生效,但跳过公开接口与电工 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; diff --git a/server/src/middleware/rbac.middleware.ts b/server/src/middleware/rbac.middleware.ts index e1a76c9..69b70d9 100644 --- a/server/src/middleware/rbac.middleware.ts +++ b/server/src/middleware/rbac.middleware.ts @@ -31,6 +31,10 @@ const PERMISSION_MAP: Record = { "/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 { - // 中间件匹配范围:全局生效,跳过公开接口、仪表盘与电工 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; diff --git a/server/src/service/icc_event_subscribe.service.ts b/server/src/service/icc_event_subscribe.service.ts new file mode 100644 index 0000000..b33100a --- /dev/null +++ b/server/src/service/icc_event_subscribe.service.ts @@ -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; + + @InjectRepository(ElectricLine) + lineModel!: Repository; + + /** 最近一次订阅注册结果(内存,供 subscribe-status 接口查询) */ + private lastRegisterResult: IccRegisterResult | null = null; + private lastRegisterAt: Date | null = null; + + /** + * 向 ICC 注册报警事件订阅(同名重复注册 ICC 覆盖,保活语义)。 + * 网络异常 / HTTP 错误 / ICC 业务失败均不抛错,返回失败状态并记录到内存。 + */ + async registerSubscribe(): Promise { + 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 { + 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 { + 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(); + 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; + } +} diff --git a/server/src/service/job_scheduler.service.ts b/server/src/service/job_scheduler.service.ts index 07121bd..1f277bb 100644 --- a/server/src/service/job_scheduler.service.ts +++ b/server/src/service/job_scheduler.service.ts @@ -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 ?? ""}`; } }; } diff --git a/server/src/task/icc_event_keepalive.task.ts b/server/src/task/icc_event_keepalive.task.ts new file mode 100644 index 0000000..0279e31 --- /dev/null +++ b/server/src/task/icc_event_keepalive.task.ts @@ -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(); + } +}; diff --git a/server/src/task/job_registry.ts b/server/src/task/job_registry.ts index 5e73e60..cf91e3a 100644 --- a/server/src/task/job_registry.ts +++ b/server/src/task/job_registry.ts @@ -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; /** 从 ICC 平台按 deviceCode 分批拉取探测器模拟量并按 (line_code,analog_type,collect_time) 幂等 upsert,返回执行摘要 */ syncIccAnalogs(): Promise; + /** 向 ICC 平台注册报警事件回调订阅(同名覆盖保活),返回注册结果摘要 */ + ensureIccEventSubscribe(): Promise; } 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); diff --git a/系统设计/db_design.html b/系统设计/db_design.html index dcc536c..904c193 100644 --- a/系统设计/db_design.html +++ b/系统设计/db_design.html @@ -196,7 +196,7 @@ footer p{text-align:center;font-size:13px;color:var(--muted)}
fault_type报警故障类型
fault_type_detail故障类型明细
-
device_alarm设备报警(ICC 拉取)
+
device_alarm设备报警(ICC 事件订阅)
alarm_dispatch报警派单
@@ -732,49 +732,44 @@ erDiagram created_atTIMESTAMPTZ创建时间 -
device_alarm— 设备报警信息表(ICC 拉取)
+
device_alarm— 设备报警信息表(ICC 事件订阅)
- - - - + + + + - - - - - - - - - + + + + + + + + + + + + +
字段类型键说明
idBIGSERIALPK自增主键
alarm_codeVARCHAR(128)UNIQUE报警编码
line_idBIGINTIDX线路 ID
equip_idBIGINT客户设备 ID
icc_device_codeVARCHAR(128)ICC 设备编号
alarm_codeVARCHAR(128)UNIQUEICC 报警编码(幂等键)
uuidVARCHAR(128)ICC 事件 uuid
line_idBIGINTIDX智电线路 ID(→ electric_line)
line_codeVARCHAR(64)线路编码(electric_line.line_code)
line_nameVARCHAR(128)线路名称
customer_idBIGINTIDX客户 ID
building_idBIGINT所属建筑 ID
floor_noVARCHAR(16)所在楼层
locationVARCHAR(256)所在位置
alarm_dateDATEIDX报警日期
fault_typeVARCHAR(128)ICC 预警类型
alarm_valueNUMERIC(14,2)报警数值
history_countINT历史出现次数
synced_atTIMESTAMPTZICC 同步时间
customer_idBIGINTIDX客户 ID(由 line_id 联出)
node_codeVARCHAR(64)探测器节点编码(info.nodeCode)
device_codeVARCHAR(64)设备编码(info.deviceCode)
device_nameVARCHAR(128)设备名称(info.deviceName)
org_codeVARCHAR(64)组织编码(info.orgCode)
org_nameVARCHAR(128)组织名称(info.orgName)
alarm_typeINT报警类型(info.alarmType)
alarm_gradeSMALLINTIDX报警等级 1/2/3(1 最严重)
alarm_statSMALLINT报警状态(info.alarmStat)
is_eventBOOLEAN是否事件(info.isEvent)
alarm_timeTIMESTAMPTZIDX报警发生时间(info.alarmDate 秒级时间戳)
alarm_valueNUMERIC(14,4)报警数值(部分事件带值,可空)
synced_atTIMESTAMPTZ入库时间
alarm_dispatch— 报警派单信息表
- - + + - - + - - - - - - - - - - - - + + + + + + - -
字段类型键说明
idBIGSERIALPK自增主键
dispatch_noVARCHAR(64)UNIQUE报警派单号
alarm_idBIGINTFK报警 ID(→ device_alarm)
dispatch_noVARCHAR(64)UNIQUE报警派单号(如 AD20261024001)
alarm_idBIGINTIDX报警 ID(→ device_alarm)
line_idBIGINT线路 ID
equip_idBIGINT客户设备 ID
icc_device_codeVARCHAR(128)ICC 设备编号
line_codeVARCHAR(64)线路编码
line_nameVARCHAR(128)线路名称
customer_idBIGINTFK客户 ID
contact_personVARCHAR(64)联系人
contact_phoneVARCHAR(20)电话
building_idBIGINT建筑 ID
floor_noVARCHAR(16)所在楼层
locationVARCHAR(256)所在位置
fault_type_idBIGINTFK故障类型 ID(→ fault_type)
fault_descTEXT故障事项(类型+数值)
dispatcherVARCHAR(64)派发人
fault_reasonTEXT故障原因(来自明细)
process_flowTEXT处理流程(来自明细)
worker_idBIGINTIDX共享电工 ID
customer_idBIGINTFK客户 ID(→ customer)
alarm_timeTIMESTAMPTZ报警时间(快照)
alarm_gradeSMALLINT报警等级(快照)
fault_descTEXT故障事项(报警类型描述)
dispatcherVARCHAR(64)派发人(当前登录用户 realName)
worker_idBIGINTIDX共享电工 ID(→ worker)
statusSMALLINTIDX状态:0已派发 1已处理
process_photosJSONB报警处理照片(多张)
ratingSMALLINT派单处理评分
created_atTIMESTAMPTZ创建时间
completed_atTIMESTAMPTZ完成时间
@@ -971,9 +966,13 @@ erDiagram electric_lineidx_line_customerB-Tree按客户查线路(逻辑隔离) electric_lineidx_line_iccB-TreeICC 设备码匹配同步 electricity_dataidx_ed_customer_timeB-Tree 组合按客户+时间查用电(月报核心查询) - device_alarmidx_alarm_dateB-Tree按报警日期查询 + device_alarmidx_alarm_timeB-Tree DESC按报警时间倒序(列表默认排序) + device_alarmidx_alarm_lineB-Tree按线路查报警 device_alarmidx_alarm_customerB-Tree按客户查报警 + device_alarmidx_alarm_gradeB-Tree按报警等级过滤(严重报警) + alarm_dispatchidx_alarm_disp_statusB-Tree按派单状态过滤 alarm_dispatchidx_alarm_disp_workerB-Tree电工查自己的派单(接口数据权限) + alarm_dispatchidx_alarm_disp_alarmB-Tree按报警查派单 repair_dispatchidx_repair_disp_workerB-Tree电工查报修派单 sys_dictidx_sys_dict_typeB-Tree按字典类型查询 icc_sync_logidx_icc_sync_timeB-Tree按时间查同步日志 diff --git a/系统设计/表结构新增及变更.md b/系统设计/表结构新增及变更.md index ebb869f..381e593 100644 --- a/系统设计/表结构新增及变更.md +++ b/系统设计/表结构新增及变更.md @@ -88,3 +88,58 @@ electricity_data 表结构重建(ICC 模拟量同步) - 手动同步接口:`POST /api/electricity-data/sync`(JWT+RBAC `device:list`,返回 pulled/inserted/updated/skipped/synced) - 不做前端页面/菜单 +2026-10-24 ICC 报警事件订阅 + +`device_alarm`(设备报警信息表)与 `alarm_dispatch`(报警派单信息表)原为旧设计(含 equip_id/building_id/floor_no/location/history_count/fault_type/alarm_date 等与 ICC 事件结构不符的字段),本次按 ICC 平台事件订阅推送的通用事件格式重建。两表均为空表,已 DROP 重建。迁移文件:`database/migrations/20261024_icc_event_alarm.sql`(2026-10-24 已执行到生产库),`database/db_init.sql` 同步更新。 + +device_alarm 表(设备报警信息表,ICC 事件订阅) + +| 字段 | 类型 | 键 | 说明 | +| :---------- | :------------ | :----- | :-------------------------------------------------- | +| id | BIGSERIAL | PK | 自增主键 | +| alarm_code | VARCHAR(128) | UNIQUE | ICC 报警编码(幂等键) | +| uuid | VARCHAR(128) | | ICC 事件 uuid | +| line_id | BIGINT | IDX | 智电线路 ID(→ electric_line,nodeCode/deviceCode 匹配 line_code) | +| line_code | VARCHAR(64) | | 线路编码(electric_line.line_code) | +| line_name | VARCHAR(128) | | 线路名称 | +| customer_id | BIGINT | IDX | 客户 ID(由 line_id 联出) | +| node_code | VARCHAR(64) | | 探测器节点编码(info.nodeCode) | +| device_code | VARCHAR(64) | | 设备编码(info.deviceCode) | +| device_name | VARCHAR(128) | | 设备名称(info.deviceName) | +| org_code | VARCHAR(64) | | 组织编码(info.orgCode) | +| org_name | VARCHAR(128) | | 组织名称(info.orgName) | +| alarm_type | INT | | 报警类型(info.alarmType) | +| alarm_grade | SMALLINT | IDX | 报警等级 1/2/3(1 最严重) | +| alarm_stat | SMALLINT | | 报警状态(info.alarmStat) | +| is_event | BOOLEAN | | 是否事件(info.isEvent) | +| alarm_time | TIMESTAMPTZ | IDX | 报警发生时间(info.alarmDate 秒级时间戳) | +| alarm_value | NUMERIC(14,4) | | 报警数值(部分事件带值,可空) | +| synced_at | TIMESTAMPTZ | | 入库时间 | + +alarm_dispatch 表(报警派单信息表) + +| 字段 | 类型 | 键 | 说明 | +| :----------- | :------------ | :----- | :-------------------------------------- | +| id | BIGSERIAL | PK | 自增主键 | +| dispatch_no | VARCHAR(64) | UNIQUE | 报警派单号(如 AD20261024001,生成规则由后端实现) | +| alarm_id | BIGINT | IDX | 报警 ID(→ device_alarm) | +| line_id | BIGINT | | 线路 ID | +| line_code | VARCHAR(64) | | 线路编码 | +| line_name | VARCHAR(128) | | 线路名称 | +| customer_id | BIGINT | FK | 客户 ID(→ customer) | +| alarm_time | TIMESTAMPTZ | | 报警时间(快照) | +| alarm_grade | SMALLINT | | 报警等级(快照) | +| fault_desc | TEXT | | 故障事项(报警类型描述) | +| dispatcher | VARCHAR(64) | | 派发人(当前登录用户 realName) | +| worker_id | BIGINT | IDX | 共享电工 ID(→ worker) | +| status | SMALLINT | IDX | 状态:0已派发 1已处理 | +| created_at | TIMESTAMPTZ | | 创建时间 | +| completed_at | TIMESTAMPTZ | | 完成时间 | + +订阅机制: + +- 回调接口:`POST /api/icc-event/callback`,接收 ICC 平台事件订阅推送的报警事件,按 `alarm_code` 幂等入库 device_alarm(重复事件覆盖更新) +- 保活任务:每 30 分钟执行一次(向 ICC 平台上报回调地址,保活事件订阅) +- 字段映射:info.nodeCode→node_code、info.deviceCode→device_code、info.deviceName→device_name、info.orgCode→org_code、info.orgName→org_name、info.alarmType→alarm_type、info.alarmGrade→alarm_grade、info.alarmStat→alarm_stat、info.isEvent→is_event、info.alarmDate(秒级时间戳)→alarm_time;line_id/line_code/customer_id 由 nodeCode/deviceCode 匹配 electric_line.line_code 联出 +- 报警派单:从 device_alarm 派单写入 alarm_dispatch,dispatch_no 生成规则由后端实现(如 AD20261024001) +