Files
andyesfly 6cf6a3d5b9 feat: 新增可配置通知中心,支持扫描式/埋点式通知规则与手动群发
新增通知规则实体、扫描任务、API与管理界面,重构站内通知支持用户/电工双类型,移除硬编码的证照到期提醒,新增工单派单、电工审核结果等通知触发场景,调整菜单与数据库结构适配新通知体系。
2026-10-06 11:53:02 +08:00

172 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 可配置消息通知中心 · 实施计划
## Context(为什么做)
现状(代码核对结论):
- 后端通知是**硬编码单点**:`notice.service.ts` 只支持「证照到期」一种,标题模板写死,**接收人写死为 ADMIN 角色**;由 `@midwayjs/cron` 的 `cert_expire.task.ts` 每日 08:00 触发。
- **Web 管理端**消息中心已完整(铃铛 + 未读角标 + 抽屉 + 60s 轮询 + 已读),见 [layouts/index.vue](file:///d:/MyProject/smart_grid_iot/smart_grid_iot/client/src/layouts/index.vue#L334-L380)。
- **管理端 Flutter App**:顶栏铃铛 [workbench_tab.dart](file:///d:/MyProject/smart_grid_iot/smart_grid_iot_manage_app_flutter/lib/features/workbench/presentation/workbench_tab.dart#L139) 传了 `trailingIcon` 但没传 `onTrailingTap`,点击无反应;无 notice API、无消息中心页。
- **电工端 Flutter App**:三处铃铛只弹 `showAppToast('暂无新通知')`;后端 `/api/worker-app/notices` 是**实时计算的证书临期清单**,不落库、无已读/未读数。
目标:建立「可配置 + 两端统一」的站内消息中心——**规则模板自动触发 + 手动群发**,接收对象覆盖**管理端用户与电工**,配置在 **Web 后台**,管理端/电工端 Flutter 各自拥有消息中心页。
### 已确认决策(来自需求澄清,不得擅自更改)
1. 形态:**站内消息中心**(不做离线/厂商推送)。
2. 能力:**规则模板自动触发** 与 **手动群发** 两者都要;手动群发**仅立即发送**(不做草稿/定时)。
3. 接收对象:**管理端用户 + 电工端用户**;**完全靠配置**决定,代码不内置默认接收人。
4. 数据模型:**改造现有 `sys_notice` 增加接收人类型,两端统一**。
5. 事件捕获:**混合式** = 扫描式规则(时间/状态类)+ 业务代码埋点(一次性状态变更)。
6. 配置入口:**Web 后台**。
7. 首批事件域:**电工域 / 客户域 / 工单域**(项目域、验收、项目计划下期扩)。
8. 两个 App 消息中心:**独立二级页**(go_router,复用 Cupertino 右滑转场),未读红点 + 60s 轮询(对齐 Web)。
---
## 架构总览
```
业务事件 ──┬─ 扫描式:内置任务 notice_scan ─→ NoticeRuleService.executeScan()
│ (复用自研调度器,可在「定时任务」页改 cron/启停/立即执行)
└─ 埋点式:业务 service 直接调 NoticeService.deliver()
↓
NoticeService.deliver() ← 接收人解析(sys_user / worker)+ 幂等写入
↓
sys_notice(receiver_type=user|worker)
↓
┌──────────────┬───────────────────┬──────────────────┐
Web 消息中心 管理端 App 消息中心 电工端 App 消息中心
(已存在,改造) (新建页面) (新建页面)
```
**复用现有资产**(不重复造):
- 自研调度器 [job_scheduler.service.ts](file:///d:/MyProject/smart_grid_iot/smart_grid_iot/server/src/service/job_scheduler.service.ts#L416-L428) 与任务注册表 [job_registry.ts](file:///d:/MyProject/smart_grid_iot/smart_grid_iot/server/src/task/job_registry.ts#L22-L55):通过扩展 `JobRuntime` 注入"执行通知规则"能力(写法照搬 `cleanOperationLogs` 用 `app.createAnonymousContext()` 取 service)。
- RBAC 路径映射 [rbac.middleware.ts](file:///d:/MyProject/smart_grid_iot/smart_grid_iot/server/src/middleware/rbac.middleware.ts#L10-L36)、菜单/权限迁移写法 [20261002_job_management.sql](file:///d:/MyProject/smart_grid_iot/smart_grid_iot/database/migrations/20261002_job_management.sql#L87-L108)。
- Web 列表页骨架 [dispatch/price/index.vue](file:///d:/MyProject/smart_grid_iot/smart_grid_iot/client/src/views/dispatch/price/index.vue)。
- Flutter 两端 `go_router` + `XPageShell`/`XTopBar`/`XCard` + Riverpod。
---
## 一、数据库(迁移 `database/migrations/20261004_notice_config.sql`)
### 1) 改造 `sys_notice`
```sql
ALTER TABLE sys_notice ADD COLUMN IF NOT EXISTS receiver_type VARCHAR(16) NOT NULL DEFAULT 'user'; -- user|worker
ALTER TABLE sys_notice ADD COLUMN IF NOT EXISTS rule_id BIGINT; -- → sys_notice_rule.id(扫描式来源)
ALTER TABLE sys_notice ADD COLUMN IF NOT EXISTS batch_id BIGINT; -- → sys_notice_batch.id(手动群发来源)
DROP INDEX IF EXISTS uk_sys_notice_biz;
CREATE UNIQUE INDEX IF NOT EXISTS uk_sys_notice_deliver
ON sys_notice (notice_type, biz_type, biz_id, receiver_type, receiver_id); -- 幂等键
CREATE INDEX IF NOT EXISTS idx_sys_notice_receiver_type ON sys_notice (receiver_type, receiver_id, is_read);
```
### 2) 新增 `sys_notice_rule`(规则模板 + 接收对象)
| 列 | 类型 | 说明 |
|---|---|---|
| id | BIGSERIAL PK | |
| rule_key | VARCHAR(64) UNIQUE | 规则键,如 `cert_expire` / `worker_audit_result` / `contract_expire` / `visit_plan` / `dispatch_assign` / `order_timeout` |
| rule_name | VARCHAR(64) | 规则名称 |
| event_key | VARCHAR(64) | 事件注册表 key |
| trigger_mode | SMALLINT | 1 扫描式 2 埋点式 |
| enabled | SMALLINT DEFAULT 1 | 启停 |
| advance_days | INT NULL | 提前天数(扫描式用) |
| title_tpl | VARCHAR(128) | 标题模板,支持 `{变量}` |
| content_tpl | VARCHAR(512) | 正文模板,支持 `{变量}` |
| receiver_config | JSONB | `{"users":{"roles":["ADMIN"],"ids":[]},"workers":{"mode":"ids\|all\|status","ids":[],"auditStatus":1,"status":1}}` |
| remark / created_at / updated_at | | |
### 3) 新增 `sys_notice_batch`(手动群发批次/发送记录)
`id`、`title`、`content`、`receiver_config JSONB`、`receiver_count INT`、`sent_by BIGINT(→sys_user.id)`、`sent_at TIMESTAMPTZ`。
### 4) 菜单与权限
新增「通知管理」菜单(`menu_type=2`,路径 `/system/notice`,权限 `notice:rule`)+ 按钮权限 `notice:batch`(群发);绑定 ADMIN 角色(沿用 `ON CONFLICT` 幂等写法);同步 `database/db_seed.sql` 菜单段与序列。
---
## 二、后端(`server/src`)
### 新增文件
- `entity/sys_notice_rule.entity.ts`、`entity/sys_notice_batch.entity.ts`
- `task/notice_event_registry.ts`:**事件 provider 注册表**(`eventKey → { name, variables, scan(advanceDays) }`),扫描式事件在此实现数据扫描,返回 `{ bizType, bizId, vars }[]`。
- `task/notice_scan.task.ts`:`BuiltinJob`(`jobKey='notice_scan'`,默认 cron `0 0 8 * * *`),handler 调用 `ctx.runtime.runNoticeRules()`。
- `service/notice_rule.service.ts`:规则 CRUD;`executeScan()` 遍历 `enabled && trigger_mode=1` 的规则 → provider 扫描 → 解析接收人 → 模板渲染 → 调 `NoticeService.deliver()`;提供 `runForEvent(eventKey, biz...)` 供埋点式复用。
- `service/notice_batch.service.ts`:手动群发(解析接收人 → 逐人写入 `sys_notice`,`notice_type='manual'`、`biz_type='manual_batch'`、`biz_id=batchId` → 写批次记录)。
- `controller/notice_rule.controller.ts`:`/api/notice-rules`(list/detail/create/update/delete/enable/事件字典/立即执行)。
- `controller/notice_batch.controller.ts`:`/api/notice-batches`(list/create 群发)。
- `dto/notice_rule.dto.ts`、`dto/notice_batch.dto.ts`(class-validator)。
### 修改文件
- `entity/sys_notice.entity.ts`:加 `receiverType/ruleId/batchId`,索引改为 `uk_sys_notice_deliver`。
- `task/job_registry.ts`:`BUILTIN_JOBS` 注册 `notice_scan`;`JobRuntime` 增加 `runNoticeRules(): Promise<string>`。
- `service/job_scheduler.service.ts`:`buildRuntime()` 增加 `runNoticeRules`(匿名上下文取 `NoticeRuleService`,照搬 L417-428 的 `LogService` 写法)。
- `service/notice.service.ts`:抽出**统一投递** `deliver({...})`(幂等 ON CONFLICT)、**接收人解析**(roles→sys_user,workers→worker);`listMy/unreadCount/markRead/markAllRead` 增加 `receiverType` 维度(管理端固定 `user`;同时新增 worker 侧方法)。
- `controller/notice.controller.ts`:管理端固定 `receiver_type='user'`。
- `controller/worker_app.controller.ts` + `service/worker_app.service.ts`:`listNotices` **改为读 `sys_notice`**(`receiver_type='worker' AND receiver_id=workerId`),替换现有实时计算;新增 `/notices/unread-count`、`/notices/:id/read`、`/notices/read-all`。
- `middleware/rbac.middleware.ts`:新增 `/api/notice-rules → notice:rule`、`/api/notice-batches → notice:batch`(`/api/notice*` 注意前缀匹配顺序,`-rules/-batches` 需在 `/api/notices` 之前不冲突——二者路径不同,无冲突)。
### 扫描式事件 provider(首批)
| event_key | 数据源 | 条件 |
|---|---|---|
| `cert_expire` | `worker_cert.expireDate` | `expire_date <= today + advance_days`(含已过期) |
| `contract_expire` | `contract.endDate` | 同上 |
| `visit_plan` | `customer_visit` | `status=0` 且计划日期已到/超期 |
| `order_timeout` | `repair_dispatch`/`survey_order`/`install_order`/`maintenance_order` | 派单后 `status` 滞留超 N 天未接单 |
> 现有 `cert_expire.task.ts`(`@midwayjs/cron`)**迁移进 `notice_scan`** 后删除,消除两套调度并存(这也是 `add-scheduled-job-management` spec Assumption 6 预留的后续动作)。
### 埋点式事件(在业务 service 内调用 `NoticeService.deliver()`,**必须 try/catch 包裹,通知失败不得阻断业务**)
| event_key | 埋点位置 |
|---|---|
| `worker_audit_result` | [worker.service.ts](file:///d:/MyProject/smart_grid_iot/smart_grid_iot/server/src/service/worker.service.ts#L348-L418) 审核通过/驳回 → 通知电工本人 |
| `dispatch_assign` | `repair_dispatch.service.ts`、`survey_order.service.ts`、`install_order.service.ts`、`maintenance.service.ts` 派单/指派 → 通知电工 |
| `order_status` | 上述工单状态流转(按需) |
---
## 三、Web 管理端(`client/src`)
- 新增 `api/noticeRule.ts`、`api/noticeBatch.ts`;`api/notice.ts` 按需加类型筛选。
- 新增 `views/system/notice/index.vue`:Tabs「通知规则」(列表 + 编辑弹窗:事件、启停、提前天数、标题/正文模板、接收对象选择器)「手动群发」(标题/正文 + 接收对象 + 立即发送)「发送记录」。
- `router/index.ts`:注册 `/system/notice`,`meta.permission='notice:rule'`(侧边栏由后端 `sys_menu` 动态生成,故必须配合迁移菜单)。
- `layouts/index.vue`:`handleNoticeClick` 按 `noticeType/bizType` 映射跳转业务详情(管理端可跳电工登记页;无映射则仅标记已读)。
## 四、Flutter 管理端 App(`smart_grid_iot_manage_app_flutter`)
- 新增 `features/notice/data/notice_api.dart`、`features/notice/presentation/notice_center_page.dart`(列表 + 未读高亮 + 全部已读 + 下拉刷新 + 分页)。
- `core/providers.dart`:新增 `noticeUnreadCountProvider`(60s 轮询,登录态下生效)。
- `app/router.dart`:注册 `/notice`(复用现有 Cupertino 右滑转场)。
- `shared/widgets/x_top_bar.dart`:给 `trailing` 增加未读红点角标能力。
- `workbench_tab.dart`:`onTrailingTap` → `context.push('/notice')`;其余 tab 顶栏统一挂红点。
## 五、Flutter 电工端 App(`smart_grid_iot_app_flutter`)
- 同上新建 `features/notice/**` 与 `/notice` 路由、`noticeUnreadCountProvider`。
- `order_tab.dart`/`earnings_tab.dart`/`profile_tab.dart` 三处铃铛:`onTrailingTap` 改为 `context.push('/notice')` + 红点。
- 后端 `listNotices` 改为读 `sys_notice` 后,页面展示统一的消息中心(含证照到期、审核结果、派单通知)。
---
## 六、验证(端到端)
1. **后端**:`server` 下 `yarn dev` 无编译错误;`/api/notice-rules` CRUD、`/api/notice-batches` 群发返回 `{code:200}`;在「定时任务」页对 `notice_scan` 点「立即执行」。
2. **幂等**:连续执行 `notice_scan` 两次,`sys_notice` 不新增重复(唯一索引 `uk_sys_notice_deliver` 生效)。
3. **接收人解析**:配置规则接收对象为「角色 ADMIN + 指定电工」,确认管理端用户与电工各自收到且 `receiver_type` 正确。
4. **埋点**:审核通过一个电工 → 该电工消息中心立即可见;且审核接口在通知写入失败时仍成功(断点/模拟异常验证)。
5. **Web**:登录 ADMIN → 通知管理页配置规则 + 群发;顶栏铃铛角标与已读正常。
6. **管理端 App**:登录 → workbench 铃铛红点 → 消息中心 → 标记已读 → 红点消失。
7. **电工端 App**:登录电工账号 → 铃铛红点 → 消息中心 → 已读;审核通过/驳回后收到消息。
8. **数据库**:核对 `sys_notice` 新旧索引替换、缓存数据保留。
---
## 七、分期与边界
- **本期**:电工域 / 客户域 / 工单域(扫描式 + 埋点式)+ 手动群发 + Web 配置页 + 两端 App 消息中心。
- **下期**:项目域 / 验收 / 项目计划节点(仅需注册 provider 或在 service 埋点,框架不变)。
- **不做**:离线/厂商推送(极光、个推、FCM)、消息撤销、已读回执、群发定时/草稿、发送失败重试、消息模板富文本。
## 八、假设与风险(如需调整请指出)
1. 管理端接口固定 `receiver_type='user'`,电工端固定 `'worker'`;两者 id 空间不同,靠 `receiver_type` 区分。
2. 埋点会改动多个业务 service;一律 `try/catch` 且不参与业务事务提交(通知失败只记日志)。
3. `sys_notice` 唯一索引需先 `DROP` 旧索引再建新索引,历史 cert 通知数据保留。
4. 扫描式事件存在调度间隔延迟(默认每日 08:00,可在「定时任务」页按需调整)。
5. `cert_expire.task.ts` 迁入 `notice_scan` 后删除;`@midwayjs/cron` 依赖是否保留取决于是否还有其它注解任务(`demo_heartbeat`/`log_clean` 已是注册表任务)。
6. Flutter 两端新增页面的视觉细节(空态、时间格式)沿用各自现有组件与 `format_util`,不引入新设计。