迭代方案确认
This commit is contained in:
271
docs/7月迭代/独立方案/历史方案/前端共性方案-历史稿.md
Normal file
271
docs/7月迭代/独立方案/历史方案/前端共性方案-历史稿.md
Normal file
@@ -0,0 +1,271 @@
|
||||
# 7月迭代前端技术方案
|
||||
|
||||
> 历史状态:共性前端来源稿;本地审批交互已废弃,最终以前端标准章节和各独立方案为准。
|
||||
> 当前方案:[标准评审稿](../../7月迭代技术方案-标准评审稿.md)。
|
||||
> 适用端:后台管理端、代理端、C 端 H5/公众号
|
||||
> 最后更新:2026-07-14
|
||||
> 说明:当前仓库不包含前端源码,本文定义页面、交互和接口契约;实际目录、状态库和组件名称由前端仓库现状映射,禁止据此凭空更换前端技术栈。
|
||||
|
||||
---
|
||||
|
||||
## 一、目标与边界
|
||||
|
||||
本方案解决七月迭代中跨需求的前端共性问题:
|
||||
|
||||
- 审批任务、退款和充值使用同一套动态审批展示。
|
||||
- Excel 导入、批量订购和导出使用统一的异步任务交互。
|
||||
- 站内消息统一未读数、列表、已读和业务跳转。
|
||||
- 配置类页面不让用户直接编辑 JSON 或依赖前端自行校验业务规则。
|
||||
- 金额、状态、时间、权限和错误展示使用统一口径。
|
||||
|
||||
本文不指定 Vue、React、Pinia、Redux 或具体 UI 组件库。实现时必须优先复用前端仓库现有的请求封装、权限指令、上传组件、表格和轮询 Hook。
|
||||
|
||||
---
|
||||
|
||||
## 二、系统上下文
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
AdminUser[平台/代理后台用户] --> AdminWeb[后台管理前端]
|
||||
Customer[C端客户] --> ClientWeb[C端 H5/公众号]
|
||||
AdminWeb -->|/api/admin/*| API[Junhong API]
|
||||
ClientWeb -->|/api/c/v1/*| API
|
||||
API --> DB[(PostgreSQL)]
|
||||
API --> Redis[(Redis)]
|
||||
API --> Queue[Asynq]
|
||||
Queue --> Worker[Worker]
|
||||
Worker --> APIData[业务数据/对象存储/Gateway]
|
||||
AdminWeb -->|轮询未读数、任务状态| API
|
||||
```
|
||||
|
||||
前端只根据 API 返回的权限、状态和可操作项渲染,不自行推导“谁能审批”“是否允许扣款”或“当前流程下一步是谁”。
|
||||
|
||||
---
|
||||
|
||||
## 三、模块边界
|
||||
|
||||
建议在现有前端目录结构中映射以下模块,不要求创建新的全局架构:
|
||||
|
||||
| 模块 | 负责内容 | 不负责内容 |
|
||||
|------|----------|------------|
|
||||
| Approval | 待办列表、流程详情、流程定义配置、审批动作 | 退款或充值的业务表单 |
|
||||
| Notification | 未读数、通知列表、标记已读、业务跳转 | 审批状态计算 |
|
||||
| AsyncTask | 导入、批量订购、导出的轮询与结果展示 | 各业务文件解析 |
|
||||
| Refund | 退款申请、编辑、重新提交、业务处理状态 | 动态审批节点渲染的内部规则 |
|
||||
| Recharge | 代理在线充值、员工线下代充值、重新提交 | 钱包入账和审批人判断 |
|
||||
| SystemConfig | 配置表单和版本刷新 | 直接编辑任意 JSON |
|
||||
|
||||
全局状态只保留跨页面共享的数据:登录用户、菜单/按钮权限、通知未读数。列表数据、详情数据和表单草稿默认留在页面或模块级状态,避免把服务端状态复制到全局 Store 后长期失真。
|
||||
|
||||
---
|
||||
|
||||
## 四、接口与类型约定
|
||||
|
||||
### 4.1 路径前缀
|
||||
|
||||
- 后台管理:`/api/admin/*`
|
||||
- C 端:`/api/c/v1/*`
|
||||
- 回调接口不由前端调用。
|
||||
|
||||
专项文档出现省略 `/api` 的路径时,以本节和真实路由注册为准,并应在评审前修正。
|
||||
|
||||
### 4.2 枚举和显示
|
||||
|
||||
- 生命周期状态使用后端返回的 `status` 做逻辑判断,使用 `status_name` 做中文展示。
|
||||
- 前端不得维护另一份与后端重复的中文状态映射;只有颜色、图标等纯展示映射可以留在前端。
|
||||
- 金额 API 统一使用“分”,输入组件展示“元”,提交前做整数转换,禁止浮点数直接乘除后提交。
|
||||
- 时间统一使用后端 ISO 8601 值,展示层按现有项目时区和格式化工具处理。
|
||||
|
||||
### 4.3 请求幂等
|
||||
|
||||
审批等敏感写操作由前端生成 `request_id`。一次用户操作从首次提交到网络重试必须复用同一个值;用户明确重新发起操作时才生成新值。
|
||||
|
||||
按钮提交后进入 loading 并禁止重复点击。前端防重只是体验控制,后端仍必须执行状态条件更新和唯一约束。
|
||||
|
||||
---
|
||||
|
||||
## 五、统一异步任务交互
|
||||
|
||||
适用:设备批量分配、批量订购、导出任务。
|
||||
|
||||
不适用于临期状态:临期列表、详情和首页数量由接口实时 SQL 计算;每日任务只生成 15/7/3 天通知,前端不轮询或维护临期快照。
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Editing: 填写参数/选择文件
|
||||
Editing --> Submitting: 提交
|
||||
Submitting --> Processing: 创建任务成功
|
||||
Submitting --> Editing: 参数或上传失败
|
||||
Processing --> Processing: 轮询进度
|
||||
Processing --> Completed: 全部或部分完成
|
||||
Processing --> Failed: 任务失败
|
||||
Processing --> Cancelled: 用户取消且后端确认
|
||||
Completed --> [*]
|
||||
Failed --> Editing: 修正后重新提交
|
||||
Cancelled --> [*]
|
||||
```
|
||||
|
||||
### 5.1 轮询规则
|
||||
|
||||
- 创建成功后立即请求一次详情,再按 2 秒、3 秒、5 秒逐步退避,最大间隔 10 秒。
|
||||
- 页面不可见时暂停轮询,恢复可见时立即刷新。
|
||||
- 达到终态、离开页面或组件销毁时停止轮询。
|
||||
- 连续网络失败不把业务任务标记为失败,展示“状态获取失败,点击重试”。
|
||||
- 服务端返回 `retry_after_seconds` 时优先采用服务端建议。
|
||||
|
||||
### 5.2 结果展示
|
||||
|
||||
- 必须同时展示总数、成功数、失败数和任务状态。
|
||||
- 部分成功不能只弹一个成功 Toast;失败明细要留在页面,并支持下载或复制,具体能力按专项方案。
|
||||
- 导出任务完成后展示下载按钮和链接过期时间;链接过期时重新获取任务详情,不重新创建导出任务。
|
||||
|
||||
### 5.3 Excel 模板
|
||||
|
||||
- 设备批量分配、批量订购等 Excel 模板由前端作为静态资源维护,后端不提供模板下载 API。
|
||||
- 模板文件名包含版本号,下载入口与对应上传表单放在同一页面。
|
||||
- 后端仍必须严格校验表头和内容,不能因为模板由前端提供就信任文件结构。
|
||||
- 模板字段变更需要前后端同批发布,并保留对用户本地旧模板的可理解错误提示。
|
||||
|
||||
---
|
||||
|
||||
## 六、统一审批交互
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Loading
|
||||
Loading --> ReadOnly: 当前用户无待处理资格
|
||||
Loading --> Actionable: 当前用户是待处理审批人
|
||||
Actionable --> EditingAction: 打开通过/驳回/退回弹框
|
||||
EditingAction --> Uploading: 上传附件
|
||||
Uploading --> EditingAction: 上传成功或失败后返回
|
||||
EditingAction --> Submitting: 意见和附件校验通过
|
||||
Submitting --> EditingAction: 请求失败且任务仍可操作
|
||||
Submitting --> ReadOnly: 操作成功或状态已被他人改变
|
||||
ReadOnly --> [*]
|
||||
```
|
||||
|
||||
### 6.1 页面建议
|
||||
|
||||
| 页面 | 建议路由 | 主要能力 |
|
||||
|------|----------|----------|
|
||||
| 待我审批 | `/approvals/tasks` | 状态、业务类型、提交人、当前节点、提交时间筛选 |
|
||||
| 审批详情 | `/approvals/instances/:instance_id` | 业务快照、业务资料、动态节点时间线、审批人和意见、当前可操作按钮 |
|
||||
| 流程定义 | `/settings/approval-flows` | 草稿、发布版本、停用、业务绑定 |
|
||||
| 流程定义编辑 | `/settings/approval-flows/:id` | 串行节点配置、角色/账号选择、或签/会签、发布前校验 |
|
||||
|
||||
第一版只支持串行节点,前端使用“有序节点列表编辑器”,不建设拖拽 DAG/BPMN 设计器。节点可上移、下移、增加和删除;开始、结束节点由系统生成且不可删除。
|
||||
|
||||
### 6.2 动态详情
|
||||
|
||||
审批详情按接口返回的 `tasks[]` 和 `assignees[]` 渲染,禁止写死“部门领导”和“财务”两个字段。
|
||||
|
||||
详情首屏必须先渲染 `business_snapshot`:业务标题、业务单号、提交人、审批关键字段和业务资料。业务资料来自发起时快照,审批附件来自某位审批人的操作记录,页面用两个区域展示;审批人不需要跳转到实时业务页才能知道正在审批什么。
|
||||
|
||||
操作按钮显示条件同时满足:
|
||||
|
||||
- 流程状态为审批中。
|
||||
- 当前任务状态为待审批。
|
||||
- 当前账号对应的审批人状态为待处理。
|
||||
- 接口返回相应操作权限。
|
||||
|
||||
操作成功后重新请求审批实例和关联业务详情,不做乐观状态推进。
|
||||
|
||||
审批详情可返回 `action_form.fields`。前端只渲染后端声明的受控字段类型;退款金额和操作密码均由流程节点配置决定,且只有当前动作会完成该节点时才出现。金额展示元、提交分;操作密码不写入全局 Store、本地存储或重试缓存,请求结束立即清空。节点没有动作字段时不显示额外表单,禁止根据“财务审核”等节点名称自行添加输入项。
|
||||
|
||||
每个通过、驳回、退回弹框统一包含“审批意见”和“附件”:
|
||||
|
||||
- 通过意见可选;驳回、退回意见必填,最多 1000 字。
|
||||
- 意见使用普通多行文本框,不提供富文本编辑器。
|
||||
- 附件最多 5 个、单个默认不超过 20MB,允许图片、PDF、Word、Excel;打开动作弹框时先生成 `request_id`,申请 `purpose=approval_attachment` 且绑定该 `request_id` 的上传凭证,上传完成后提交 `file_key + file_name`。前端校验只用于体验,最终以后端对象元数据和上传归属校验为准。
|
||||
- 文件仍在上传时禁止提交审批;删除尚未提交的附件只影响本地表单,不调用删除历史审批附件。
|
||||
- 网络重试复用原 `request_id`、意见和附件集合;操作成功后清空本地表单。
|
||||
- 时间线按审批人展示各自意见和附件。审批附件和业务资料分别通过审批实例权限校验接口换取短期 URL,不直接拼对象存储地址。
|
||||
|
||||
### 6.3 业务处理中的展示
|
||||
|
||||
审批通过与退款到账、钱包入账不是同一时刻。退款和充值详情必须同时展示:
|
||||
|
||||
- 审批状态。
|
||||
- 业务处理状态。
|
||||
- 业务处理失败原因和“系统重试中/联系管理员”的提示;非代理钱包退款审批通过后显示“待人工退款”,仅财务确认权限可见确认完成入口。
|
||||
|
||||
不能仅根据业务单原状态显示“待审批”。
|
||||
|
||||
### 6.4 存量历史记录
|
||||
|
||||
业务详情接口增加 `approval_source`:
|
||||
|
||||
- `none`:当前业务不需要审批,例如代理在线充值;隐藏审批区域。
|
||||
- `workflow`:存在通用审批实例,展示动态时间线和 `available_actions`。
|
||||
- `legacy`:发布前已经结束的历史记录,没有完整流程实例;只读展示原业务状态、旧审批摘要和审计信息,不生成虚假节点。
|
||||
|
||||
如果一条仍需审批的退款或员工线下充值返回 `approval_instance_id=null`,前端按数据迁移异常展示并禁止任何审批操作,不能退回旧业务单审批接口。
|
||||
|
||||
---
|
||||
|
||||
## 七、页面与需求映射
|
||||
|
||||
| 需求 | 端 | 页面/入口 | 关键交互 |
|
||||
|------|----|-----------|----------|
|
||||
| 01 | 后台 | 资产详情复机操作 | 界面不变,展示后端真实结果 |
|
||||
| 02 | 后台 + C端 | 卡/设备列表实名策略;C端流程页 | 单条/批量设置,C端按接口策略跳转 |
|
||||
| 03/07/11/12/13 | 后台 | 原有列表和详情 | 新筛选、新字段、动态审批摘要 |
|
||||
| 05 | 后台 | 套餐分配弹框、已分配列表 | 生效条件覆盖和修改提示 |
|
||||
| 08 | 后台 | 设备管理批量操作 | “批量分配代理”和“批量分配套餐系列”两个入口,上传、任务轮询、失败明细 |
|
||||
| 09 | 后台 + C端 | 系统配置;支付页 | 后台配置支付方式,C端隐藏并由后端兜底拦截 |
|
||||
| 10 | 后台 | 卡/设备资产详情 | 手动设置或取消当前卡限速,单位 `kbps`,不提供套餐限速配置 |
|
||||
| 14 | 后台 | 各业务列表导出 | 字段选择、权限过滤、统一导出任务 |
|
||||
| 15 | C端 + 后台 | 当前套餐卡片、后台代购 | 当前套餐旁“续费”复用购买流程;下架套餐仅在合法续费入口展示 |
|
||||
| 16 | - | 已移出 7 月迭代 | 不建设分销码、代理申请、提现材料和相关审批页面 |
|
||||
| 17 | 后台/代理端 | 代理信用、钱包详情 | 元/分转换、欠款状态、额度权限 |
|
||||
| 18/20/21 | 后台 | 待办、退款、充值详情 | 动态审批时间线、处理状态、退回重提 |
|
||||
| 19 | 后台 | 批量订购 | 参数确认、上传、逐行结果和金额汇总 |
|
||||
| 22 | 后台 + 代理端 + C端 | 临期列表、首页提醒 | 15/7/3 天分级、续费跳转、到期后自动消失 |
|
||||
|
||||
---
|
||||
|
||||
## 八、站内消息
|
||||
|
||||
- 登录后和进入后台布局时立即获取未读数,之后每 30 秒轮询。
|
||||
- 页面不可见时暂停,恢复时立即刷新。
|
||||
- 铃铛数字超过 99 显示 `99+`。
|
||||
- 通知点击只按受控的 `ref_type + ref_id` 路由表跳转,不接受后端返回任意 URL。
|
||||
- 标记已读失败不阻止查看业务详情,但需要在下次轮询时恢复真实未读状态。
|
||||
- 通知正文按纯文本展示;若未来支持富文本,必须使用受控模板和统一净化,不直接渲染任意 HTML。
|
||||
|
||||
---
|
||||
|
||||
## 九、权限与敏感数据
|
||||
|
||||
- 菜单和按钮根据登录返回的权限控制可见性,但后端权限校验是最终依据。
|
||||
- 审批人资格由任务接口返回,前端不根据角色名称推导。
|
||||
- 导出字段由后端返回允许字段集合;前端只能在允许集合中选择。
|
||||
- 退款凭证、充值凭证、身份证、营业执照和审批附件使用现有对象存储上传流程,不提交本地路径或长期公开 URL。审批附件额外提交清理后的原文件名作为展示快照。
|
||||
- 列表和详情对无权限与不存在统一展示,避免通过前端文案泄露资源存在性。
|
||||
|
||||
---
|
||||
|
||||
## 十、停机发布
|
||||
|
||||
1. 发布前进入维护模式,前端统一展示维护页并停止提交写请求。
|
||||
2. 维护窗口内执行数据库迁移,同时发布 API、Worker/Relay 和前端静态资源。
|
||||
3. 初始化并启用退款、充值流程定义和业务绑定,执行存量待审批单回填。
|
||||
4. 前端只调用任务级审批 API,不保留退款或线下充值的业务单级通过/驳回按钮,也不保留线下充值“确认入账”按钮。
|
||||
5. 人工验证登录、菜单权限、流程发起、存量历史展示、待办、审批详情、退回重提和业务处理状态。
|
||||
6. 验证通过后解除维护模式;失败则在开放访问前回滚整套应用版本。
|
||||
|
||||
前端必须容忍新增响应字段且不依赖字段顺序,但本次不要求支持旧后端与新前端或新后端与旧前端交叉运行。
|
||||
|
||||
---
|
||||
|
||||
## 十一、待前端仓库确认
|
||||
|
||||
以下内容不影响当前接口和交互评审,但实施前必须在前端仓库确认:
|
||||
|
||||
- 后台、代理端和 C 端分别使用的框架版本与目录结构。
|
||||
- 现有权限指令、请求封装、上传组件和导出 Hook 的真实名称。
|
||||
- 是否已有通用任务轮询组件和流程时间线组件。
|
||||
- 实际菜单路由和按钮权限编码。
|
||||
- 表格是否支持服务端返回的动态导出字段配置。
|
||||
|
||||
确认后只补充实现映射,不改变本文已经评审通过的业务状态和 API 契约。
|
||||
1174
docs/7月迭代/独立方案/历史方案/本地通用审批流-已废弃方案.md
Normal file
1174
docs/7月迭代/独立方案/历史方案/本地通用审批流-已废弃方案.md
Normal file
File diff suppressed because it is too large
Load Diff
383
docs/7月迭代/独立方案/历史方案/站内消息-初版.md
Normal file
383
docs/7月迭代/独立方案/历史方案/站内消息-初版.md
Normal file
@@ -0,0 +1,383 @@
|
||||
# 基础设施:站内消息(Notification)
|
||||
|
||||
> 历史状态:初版方案,已被站内通知详细方案替代。
|
||||
> 替代方案:[站内通知详细方案](../新增需求/03-站内通知详细方案.md) 和 [标准评审稿](../../7月迭代技术方案-标准评审稿.md)。
|
||||
> 被依赖:需求 18/20/21(审批流通知)、需求 22(临期提醒)
|
||||
> Phase 2 扩展:企业微信推送、短信通知(预留插拔接口)
|
||||
|
||||
---
|
||||
|
||||
## 一、设计原则
|
||||
|
||||
- **业务代码不直接写通知表**:统一通过通知发布器或领域事件异步处理
|
||||
- **关键领域事件先写 Outbox**:审批、退款、充值等事务内事件由 Outbox Relay 投递 Asynq,禁止事务提交后直接入队
|
||||
- **通知消费必须幂等**:使用稳定的 `event_id` 和接收人唯一约束,Asynq 重试不得重复生成站内消息
|
||||
- **不过度抽象**:不用 interface,用具体的 `NotificationPublisher`(后续加渠道 = 在 handler 里加代码)
|
||||
- **前端轮询**:30秒一次 `/api/admin/notifications/unread-count`,不用 WebSocket
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Biz as 业务事务
|
||||
participant Outbox as Outbox
|
||||
participant Relay as Relay
|
||||
participant Worker as Notification Worker
|
||||
participant DB as tb_notification
|
||||
participant Web as 后台前端
|
||||
|
||||
Biz->>Outbox: 同事务写领域事件
|
||||
Relay->>Outbox: 拉取待投递事件
|
||||
Relay->>Worker: 至少一次投递
|
||||
Worker->>DB: 按 event_id + recipient 幂等插入
|
||||
Web->>DB: 经 API 轮询未读数/通知列表
|
||||
Web->>DB: 经 API 标记已读
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、数据库
|
||||
|
||||
```sql
|
||||
-- 迁移文件:YYYYMMDD_create_tb_notification.sql
|
||||
CREATE TABLE tb_notification (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
event_id VARCHAR(64) NOT NULL, -- 领域事件ID或调用方请求ID,用于幂等
|
||||
recipient_id BIGINT NOT NULL, -- 用户ID(admin user 或 agent user)
|
||||
recipient_type VARCHAR(20) NOT NULL DEFAULT 'admin', -- admin | agent
|
||||
type VARCHAR(50) NOT NULL, -- 通知类型(见常量定义)
|
||||
title VARCHAR(200) NOT NULL, -- 标题
|
||||
body TEXT NOT NULL DEFAULT '', -- 纯文本正文
|
||||
ref_type VARCHAR(50), -- 关联业务类型 approval | recharge | refund | iot_card | device
|
||||
ref_id BIGINT, -- 关联业务ID
|
||||
is_read BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
read_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ -- 过期自动不展示(可选,临期提醒用)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_notification_recipient
|
||||
ON tb_notification (recipient_id, recipient_type, is_read, created_at DESC);
|
||||
CREATE INDEX idx_notification_created ON tb_notification (created_at DESC);
|
||||
CREATE UNIQUE INDEX uq_notification_event_recipient
|
||||
ON tb_notification (event_id, recipient_id, recipient_type);
|
||||
|
||||
COMMENT ON TABLE tb_notification IS '站内消息通知表';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、常量定义
|
||||
|
||||
```go
|
||||
// pkg/constants/notification.go
|
||||
|
||||
// 通知类型
|
||||
const (
|
||||
NotifyTypeApprovalPending = "approval.pending" // 待我审批
|
||||
NotifyTypeApprovalDone = "approval.done" // 审批完成(申请人收)
|
||||
NotifyTypeApprovalRejected = "approval.rejected" // 审批驳回(申请人收,流程终止)
|
||||
NotifyTypeApprovalReturned = "approval.returned" // 审批退回(申请人收,可修改后重新提交)
|
||||
NotifyTypePackageExpiring = "package.expiring" // 套餐临期
|
||||
NotifyTypeSystemAlert = "system.alert" // 系统通知
|
||||
)
|
||||
|
||||
// 通知接收者类型
|
||||
const (
|
||||
NotifyRecipientAdmin = "admin" // 平台用户
|
||||
NotifyRecipientAgent = "agent" // 代理用户(预留)
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、Model
|
||||
|
||||
```go
|
||||
// internal/model/notification.go
|
||||
|
||||
// Notification 站内消息模型
|
||||
type Notification struct {
|
||||
ID uint `gorm:"column:id;primaryKey" json:"id"`
|
||||
EventID string `gorm:"column:event_id;type:varchar(64);not null;uniqueIndex:uq_notification_event_recipient,priority:1" json:"event_id"`
|
||||
RecipientID uint `gorm:"column:recipient_id;not null;index;uniqueIndex:uq_notification_event_recipient,priority:2" json:"recipient_id"`
|
||||
RecipientType string `gorm:"column:recipient_type;type:varchar(20);not null;default:'admin';uniqueIndex:uq_notification_event_recipient,priority:3" json:"recipient_type"`
|
||||
Type string `gorm:"column:type;type:varchar(50);not null" json:"type"`
|
||||
Title string `gorm:"column:title;type:varchar(200);not null" json:"title"`
|
||||
Body string `gorm:"column:body;type:text;not null;default:''" json:"body"`
|
||||
RefType *string `gorm:"column:ref_type;type:varchar(50)" json:"ref_type,omitempty"`
|
||||
RefID *uint `gorm:"column:ref_id" json:"ref_id,omitempty"`
|
||||
IsRead bool `gorm:"column:is_read;not null;default:false" json:"is_read"`
|
||||
ReadAt *time.Time `gorm:"column:read_at" json:"read_at,omitempty"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;not null" json:"created_at"`
|
||||
ExpiresAt *time.Time `gorm:"column:expires_at" json:"expires_at,omitempty"`
|
||||
}
|
||||
|
||||
func (Notification) TableName() string { return "tb_notification" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、发布侧:NotificationPublisher
|
||||
|
||||
非事务性、允许调用方直接发起的通知使用发布器异步入队。审批等领域事件不直接调用该发布器,而由 `TaskCreated`、`ProcessApproved` 等事件处理器转换为通知载荷。
|
||||
|
||||
领域事件通知使用领域事件自身的 `event_id`;非领域事件调用方使用稳定的业务请求 ID。网络重试或 Asynq 重试时禁止重新生成 ID。
|
||||
|
||||
```go
|
||||
// internal/infrastructure/messaging/notification_publisher.go
|
||||
|
||||
// SendPayload 发送通知的参数
|
||||
type SendPayload struct {
|
||||
EventID string // 领域事件ID或调用方请求ID,同一次重试必须保持不变
|
||||
RecipientIDs []uint // 接收人ID列表
|
||||
RecipientType string // admin | agent
|
||||
Type string // 通知类型常量
|
||||
Title string // 标题
|
||||
Body string // 正文
|
||||
RefType string // 关联业务类型(可选)
|
||||
RefID uint // 关联业务ID(可选)
|
||||
ExpiresAt *time.Time // 过期时间(可选)
|
||||
}
|
||||
|
||||
// NotificationPublisher 通知发布器(非接口,简单具体实现)
|
||||
type NotificationPublisher struct {
|
||||
queueClient *queue.Client
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// Publish 发布通知(异步)
|
||||
// 返回错误供调用方或 Asynq Handler 决定重试,禁止吞掉关键通知错误。
|
||||
func (p *NotificationPublisher) Publish(ctx context.Context, payload SendPayload) error {
|
||||
if payload.EventID == "" {
|
||||
return errors.New(errors.CodeInvalidParam, "通知事件ID不能为空")
|
||||
}
|
||||
if err := p.queueClient.EnqueueTask(ctx, constants.TaskTypeNotification, payload); err != nil {
|
||||
p.logger.Error("通知入队失败", zap.Error(err), zap.String("type", payload.Type))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
审批事件处理器调用示例(节点激活后通知候选审批人):
|
||||
|
||||
```go
|
||||
return h.notifyPublisher.Publish(ctx, notification.SendPayload{
|
||||
EventID: event.EventID,
|
||||
RecipientIDs: []uint{nextApproverID},
|
||||
RecipientType: constants.NotifyRecipientAdmin,
|
||||
Type: constants.NotifyTypeApprovalPending,
|
||||
Title: "您有一条待审批记录",
|
||||
Body: fmt.Sprintf("代理「%s」提交了充值申请,请及时审批。", shopName),
|
||||
RefType: "approval",
|
||||
RefID: processInstanceID,
|
||||
})
|
||||
```
|
||||
|
||||
审批事务中只写 `TaskCreated` Outbox 事件。即使 Redis/Asynq 暂时不可用,事件仍保留在数据库,由 Relay 重试;通知处理失败则由 Asynq 重试当前任务。
|
||||
|
||||
---
|
||||
|
||||
## 六、消费侧:Asynq Task Handler
|
||||
|
||||
```go
|
||||
// internal/task/notification_handler.go
|
||||
|
||||
// HandleNotification 处理通知发送任务
|
||||
func (h *NotificationHandler) HandleNotification(ctx context.Context, t *asynq.Task) error {
|
||||
var payload notification.SendPayload
|
||||
if err := sonic.Unmarshal(t.Payload(), &payload); err != nil {
|
||||
return fmt.Errorf("反序列化通知载荷失败: %w", err)
|
||||
}
|
||||
|
||||
// 批量写入 tb_notification
|
||||
records := make([]model.Notification, 0, len(payload.RecipientIDs))
|
||||
for _, uid := range payload.RecipientIDs {
|
||||
ref_type := (*string)(nil)
|
||||
ref_id := (*uint)(nil)
|
||||
if payload.RefType != "" {
|
||||
ref_type = &payload.RefType
|
||||
}
|
||||
if payload.RefID != 0 {
|
||||
ref_id = &payload.RefID
|
||||
}
|
||||
records = append(records, model.Notification{
|
||||
EventID: payload.EventID,
|
||||
RecipientID: uid,
|
||||
RecipientType: payload.RecipientType,
|
||||
Type: payload.Type,
|
||||
Title: payload.Title,
|
||||
Body: payload.Body,
|
||||
RefType: ref_type,
|
||||
RefID: ref_id,
|
||||
ExpiresAt: payload.ExpiresAt,
|
||||
})
|
||||
}
|
||||
|
||||
if err := h.db.WithContext(ctx).
|
||||
Clauses(clause.OnConflict{DoNothing: true}).
|
||||
CreateInBatches(records, 100).Error; err != nil {
|
||||
return fmt.Errorf("批量写入通知失败: %w", err)
|
||||
}
|
||||
|
||||
// Phase 2:在此处加企微/短信调用
|
||||
// for _, sender := range h.extraSenders { sender.Send(ctx, payload) }
|
||||
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、API 设计
|
||||
|
||||
### 7.1 未读数量(前端轮询用,30秒一次)
|
||||
|
||||
```
|
||||
GET /api/admin/notifications/unread-count
|
||||
```
|
||||
|
||||
响应:
|
||||
```json
|
||||
{ "code": 0, "data": { "count": 5 } }
|
||||
```
|
||||
|
||||
### 7.2 通知列表
|
||||
|
||||
```
|
||||
GET /api/admin/notifications?is_read=false&type=approval.pending&page=1&page_size=20
|
||||
```
|
||||
|
||||
请求参数:
|
||||
```go
|
||||
type NotificationListRequest struct {
|
||||
IsRead *bool `query:"is_read" description:"是否已读(不传=全部)"`
|
||||
Type string `query:"type" description:"通知类型过滤(可选)"`
|
||||
Page int `query:"page" description:"页码"`
|
||||
PageSize int `query:"page_size" description:"每页数量(最大50)"`
|
||||
}
|
||||
```
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"list": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "approval.pending",
|
||||
"title": "您有一条待审批记录",
|
||||
"body": "代理「XX店」提交了充值申请,请及时审批。",
|
||||
"ref_type": "approval",
|
||||
"ref_id": 42,
|
||||
"is_read": false,
|
||||
"created_at": "2026-07-11T10:00:00Z"
|
||||
}
|
||||
],
|
||||
"total": 3,
|
||||
"page": 1,
|
||||
"page_size": 20
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7.3 标记已读
|
||||
|
||||
```
|
||||
PUT /api/admin/notifications/{id}/read
|
||||
```
|
||||
|
||||
### 7.4 全部标记已读
|
||||
|
||||
```
|
||||
PUT /api/admin/notifications/read-all
|
||||
```
|
||||
|
||||
可传 `type` 过滤(只把某类型全部已读):
|
||||
```json
|
||||
{ "type": "approval.pending" }
|
||||
```
|
||||
|
||||
### DTO
|
||||
|
||||
```go
|
||||
// internal/model/dto/notification_dto.go
|
||||
|
||||
type NotificationListRequest struct {
|
||||
IsRead *bool `query:"is_read"`
|
||||
Type string `query:"type"`
|
||||
Page int `query:"page" default:"1"`
|
||||
PageSize int `query:"page_size" default:"20"`
|
||||
}
|
||||
|
||||
type NotificationItem struct {
|
||||
ID uint `json:"id"`
|
||||
Type string `json:"type" description:"通知类型"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
RefType *string `json:"ref_type,omitempty"`
|
||||
RefID *uint `json:"ref_id,omitempty"`
|
||||
IsRead bool `json:"is_read"`
|
||||
ReadAt *time.Time `json:"read_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type UnreadCountResponse struct {
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
type ReadAllRequest struct {
|
||||
Type string `json:"type" description:"通知类型(为空则全部已读)"`
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、前端对接
|
||||
|
||||
### 顶部导航栏铃铛
|
||||
|
||||
```
|
||||
组件挂载 → 轮询 GET /api/admin/notifications/unread-count(30秒一次)
|
||||
→ count > 0 时铃铛显示红点 + 数字
|
||||
→ 点击铃铛 → 弹出通知抽屉 or 跳转 /notifications 页面
|
||||
→ 打开时调 GET /api/admin/notifications?is_read=false
|
||||
→ 点击某条通知 → PUT /api/admin/notifications/{id}/read → 根据 ref_type+ref_id 跳转对应业务页
|
||||
```
|
||||
|
||||
### 跳转逻辑(ref_type)
|
||||
|
||||
| ref_type | 跳转页面 |
|
||||
|----------|---------|
|
||||
| `approval` | `/approvals/instances/{ref_id}` 审批详情 |
|
||||
| `recharge` | `/agent-recharges/{ref_id}` 充值单详情 |
|
||||
| `refund` | `/refunds/{ref_id}` 退款单详情 |
|
||||
| `iot_card` | `/iot-cards/{ref_id}` IoT卡详情(临期提醒) |
|
||||
| `device` | `/devices/{ref_id}` 设备详情(临期提醒) |
|
||||
|
||||
### 通知列表页(/notifications)
|
||||
|
||||
筛选:通知类型(下拉)、已读状态
|
||||
操作:全部已读按钮
|
||||
列表字段:类型、标题、时间、已读状态
|
||||
点击行:跳转关联业务详情
|
||||
|
||||
前端页面不可见时暂停未读数轮询,恢复可见时立即刷新。通知正文按纯文本渲染;未来需要富文本时使用受控模板和统一净化,禁止直接渲染业务方提交的 HTML。
|
||||
|
||||
---
|
||||
|
||||
## 九、Phase 2 扩展预留
|
||||
|
||||
当需要接入企微通知时,只需在 `HandleNotification` 里追加:
|
||||
|
||||
```go
|
||||
// 企微通知(Phase 2)
|
||||
if h.wecomClient != nil {
|
||||
for _, uid := range payload.RecipientIDs {
|
||||
wecomOpenID := h.userStore.GetWecomOpenID(ctx, uid)
|
||||
h.wecomClient.SendMessage(wecomOpenID, payload.Title, payload.Body)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
业务代码零修改,Handler 里加一段即可。
|
||||
Reference in New Issue
Block a user