# 可配置消息通知中心 · 实施计划 ## 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`。 - `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`,不引入新设计。