1
0
forked from erp-dev/erp

feat: notifier beta

This commit is contained in:
2026-04-13 12:30:33 +08:00
parent 646dbe7f18
commit 9512132bb9
50 changed files with 17830 additions and 2 deletions

437
docs/api_v2_mission_api.md Normal file
View File

@@ -0,0 +1,437 @@
# API v2 Mission 任务模块接口文档
本文档面向前端,描述 `mission` 中立任务模块的 API。
## 基本约定
- Base URL: `/api/v2`
- 认证:所有接口都需要登录。
- 人员身份:后端使用 `request.user.employee` 作为当前员工身份。
- 多商户隔离:所有任务、任务分类、参与者、回应都只允许访问当前员工所属商户的数据。
- 删除能力:不提供任务删除接口;需要结束业务时使用 `cancel` 取消任务。
- 状态字段:普通创建/更新接口不允许修改状态字段,状态变化统一走独立接口。
普通 CRUD 禁止提交这些字段:
| 字段 | 说明 |
|------|------|
| `is_urgent` | 是否紧急,使用 `set-urgent` 接口修改 |
| `is_completed` | 是否完成,由结束回应或 reopen/reject 流程维护 |
| `is_cancelled` | 是否取消,使用 `cancel` 接口修改 |
| `cancelled_by` | 取消人,由后端写入 |
| `cancelled_at` | 取消时间,由后端写入 |
| `rejected_by` | 撤销人,由后端写入 |
| `rejected_at` | 撤销时间,由后端写入 |
## content_type 说明
`content_type``content_id` 是可选的"关联业务对象"字段,用于把任务挂靠到系统中的某个具体业务单据或对象上。
### content_type 是什么
`content_type` 是一个整数,对应后端数据库 `django_content_type` 表的主键(`id`)。
该表记录了系统中所有 Django 模型的元信息,每一行代表一个模型,格式为 `app_label` + `model`
**前端不应硬编码 content_type 整数 ID**,因为这个 ID 在不同部署环境中可能不同。
正确做法:调用 `/api/v2/content-types/` 接口(见下方"查询可用 content_type"一节)获取当前环境的实际 ID。
### content_type_label 字段
响应体中的 `content_type_label` 字段是人类可读的模型标识,格式为 `"app_label.model"`,例如:
- `"printing.plateorder"` — 开版单
- `"printing.printingjob"` — 印刷任务
- `"business.purchaseorder"` — 采购单
该字段**只读**,用于前端展示或调试,不作为提交 content_type 时的值。
### 当前系统中可关联的主要业务对象
| `content_type_label` | 中文说明 |
|---|---|
| `printing.plateorder` | 开版单 |
| `printing.printingorder` | 印刷单 |
| `printing.printingjob` | 印刷任务 |
| `business.purchaseorder` | 采购单 |
| `business.presalesorder` | 预销售单 |
| `business.salesorder` | 销售单 |
| `business.prepurchaseorder` | 预采购单 |
| `business.purchasereturnorder` | 采购退货单 |
| `business.salesreturnorder` | 销售退货单 |
| `business.paymentorder` | 付款单 |
| `business.receiptorder` | 收款单 |
| `stock.transferorder` | 调拨单 |
| `shipment.shipment` | 发货单 |
| `stateflow.process` | 工艺流程实例 |
> 上述列表是"有意义的"业务关联对象;系统本身不限制可关联的模型类型,技术上任何有效 ContentType ID 都被接受。
### 查询可用 content_type
- URL: `/api/v2/content-types/`
- Method: `GET`
响应示例:
```json
[
{"id": 7, "app_label": "business", "model": "paymentorder", "label": "business.paymentorder", "name": "付款单"},
{"id": 8, "app_label": "business", "model": "presalesorder", "label": "business.presalesorder", "name": "预销售单"},
...
]
```
前端应在运行时调用此接口获取 `id`,不应硬编码,因为不同部署环境的 ID 可能不同。
## 数据结构
### MissionCategory
```json
{
"id": 1,
"merchant": 10,
"name": "通用",
"created_at": "2026-04-10T12:00:00+08:00",
"updated_at": "2026-04-10T12:00:00+08:00"
}
```
### Mission
```json
{
"id": 1,
"merchant": 10,
"description": "跟进客户问题",
"category": 1,
"category_name": "通用",
"is_urgent": false,
"is_completed": false,
"is_cancelled": false,
"cancelled_at": null,
"creator": {
"id": 20,
"name": "张三",
"merchant_id": 10
},
"cancelled_by": null,
"participants": [
{
"id": 21,
"name": "李四",
"merchant_id": 10
}
],
"content_type": null,
"content_type_label": null,
"content_id": null,
"has_ending_reply": false,
"can_reply": true,
"created_at": "2026-04-10T12:00:00+08:00",
"updated_at": "2026-04-10T12:00:00+08:00"
}
```
### MissionReply
```json
{
"id": 100,
"mission": 1,
"merchant": 10,
"responder": {
"id": 20,
"name": "张三",
"merchant_id": 10
},
"content": "已处理",
"replied_at": "2026-04-10T12:10:00+08:00",
"ends_task": true,
"is_rejected": false,
"rejected_by": null,
"rejected_at": null,
"created_at": "2026-04-10T12:10:00+08:00",
"updated_at": "2026-04-10T12:10:00+08:00"
}
```
## 任务分类列表
- URL: `/api/v2/mission-categories/`
- Method: `GET`
响应:`MissionCategory[]`
## 创建任务分类
- URL: `/api/v2/mission-categories/`
- Method: `POST`
请求参数:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `name` | string | 是 | 分类名称,同商户下唯一 |
请求示例:
```json
{
"name": "售后"
}
```
成功响应:`201 Created`,返回 `MissionCategory`
## 任务分类详情
- URL: `/api/v2/mission-categories/<category_id>/`
- Method: `GET`
成功响应:`MissionCategory`
## 更新任务分类
- URL: `/api/v2/mission-categories/<category_id>/`
- Method: `PATCH`
允许更新:
| 参数 | 类型 | 说明 |
|------|------|------|
| `name` | string | 分类名称,同商户下唯一 |
成功响应:`MissionCategory`
## 删除任务分类
- URL: `/api/v2/mission-categories/<category_id>/`
- Method: `DELETE`
说明:
- 如果该分类已经被任务使用,则删除会被拒绝,返回 `400`
成功响应:`204 No Content`
## 任务列表
- URL: `/api/v2/missions/`
- Method: `GET`
查询参数:
| 参数 | 类型 | 说明 |
|------|------|------|
| `category` | int | 任务分类 ID |
| `is_urgent` | boolean | `true` / `false` |
| `is_completed` | boolean | `true` / `false` |
| `is_cancelled` | boolean | `true` / `false` |
| `content_type` | int | Django ContentType ID |
| `content_id` | int | 关联业务对象 ID |
响应:`Mission[]`
## 创建任务
- URL: `/api/v2/missions/`
- Method: `POST`
请求参数:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `description` | string | 是 | 任务描述 |
| `category` | int | 否 | 任务分类 ID不传时默认使用当前商户下名称为“通用”的分类不存在则自动创建 |
| `content_type` | int/null | 否 | Django ContentType ID必须与 `content_id` 同时提供或同时省略 |
| `content_id` | int/null | 否 | 关联业务对象 ID必须与 `content_type` 同时提供或同时省略 |
| `participant_ids` | int[] | 否 | 参与者员工 ID 列表,必须属于当前商户 |
说明:
- `creator``merchant` 由当前登录用户的 employee 自动写入
- `category_name` 为只读字段,由后端根据分类表返回
- `is_urgent``is_completed``is_cancelled` 均按默认值创建,不接受请求参数
- 若关联对象存在 `merchant_id` 字段,后端会校验它必须属于当前商户
请求示例:
```json
{
"description": "跟进客户问题",
"category": 1,
"participant_ids": [21, 22]
}
```
成功响应:`201 Created`,返回 `Mission`
## 任务详情
- URL: `/api/v2/missions/<mission_id>/`
- Method: `GET`
成功响应:`Mission`
跨商户访问返回 `404`
## 更新任务
- URL: `/api/v2/missions/<mission_id>/`
- Method: `PATCH`
允许更新:
| 参数 | 类型 | 说明 |
|------|------|------|
| `description` | string | 任务描述 |
| `category` | int | 任务分类 ID |
| `content_type` | int/null | 关联对象类型;必须与 `content_id` 同时提供 |
| `content_id` | int/null | 关联对象 ID必须与 `content_type` 同时提供 |
| `participant_ids` | int[] | 重置参与者列表 |
禁止更新状态字段,见“基本约定”
成功响应:`Mission`
## 删除任务
- URL: `/api/v2/missions/<mission_id>/`
- Method: `DELETE`
当前不提供删除能力,固定返回:
```json
{
"detail": "Mission 删除接口未提供,请使用 cancel 接口取消任务"
}
```
HTTP 状态码:`405 Method Not Allowed`
## 获取任务回应列表
- URL: `/api/v2/missions/<mission_id>/replies/`
- Method: `GET`
查询参数:
| 参数 | 类型 | 说明 |
|------|------|------|
| `ends_task` | boolean | `true` / `false`,筛选是否为结束回应 |
| `is_rejected` | boolean | `true` / `false`,筛选是否已被撤销 |
响应:`MissionReply[]`,按 `replied_at` 升序排列
## 创建任务回应
- URL: `/api/v2/missions/<mission_id>/replies/`
- Method: `POST`
请求参数:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `content` | string | 是 | 回应内容 |
| `ends_task` | boolean | 否 | 是否结束任务,默认 `false` |
说明:
- `responder` 使用当前登录用户的 employee
- 如果 `ends_task=true`,后端会同步设置 `Mission.is_completed=true`
- 已取消任务、或已有有效结束回应的任务不允许继续回应
成功响应:`201 Created`,返回 `MissionReply`
## 重新打开任务
- URL: `/api/v2/missions/<mission_id>/reopen/`
- Method: `POST`
- 额外权限:`mission.reopen_mission`
请求体可为空:
```json
{}
```
前置条件:
- 当前用户必须具备 `mission.reopen_mission`
- 任务未取消
- 任务已完成
- 任务存在有效的 `ends_task=true` 回应
执行效果:
- `Mission.is_completed=false`
- 相关结束回应被标记为 `ends_task=false`
- 相关结束回应记录 `is_rejected=true``rejected_by=当前员工``rejected_at=当前时间`
成功响应:`Mission`
无权限响应:`403 Forbidden`
## 取消任务
- URL: `/api/v2/missions/<mission_id>/cancel/`
- Method: `POST`
请求体可为空:
```json
{}
```
执行效果:
- `Mission.is_cancelled=true`
- `Mission.cancelled_by=当前员工`
- `Mission.cancelled_at=当前时间`
- 不强行修改 `Mission.is_completed`
成功响应:`Mission`
## 设置紧急状态
- URL: `/api/v2/missions/<mission_id>/set-urgent/`
- Method: `POST`
请求参数:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `is_urgent` | boolean | 是 | 新的紧急状态 |
请求示例:
```json
{
"is_urgent": true
}
```
成功响应:`Mission`
## 撤销任务回应
- URL: `/api/v2/mission-replies/<reply_id>/reject/`
- Method: `POST`
- 额外权限:`mission.reject_mission_reply`
请求体可为空:
```json
{}
```
执行效果:
- 当前回应标记为 `is_rejected=true`
- 当前回应的 `rejected_by``rejected_at` 由后端写入
- 如果当前回应原本是唯一有效的结束回应,则对应任务会被重新置为未完成
成功响应:`MissionReply`
无权限响应:`403 Forbidden`

View File

@@ -0,0 +1,336 @@
# Mission 模块内部设计说明
本文档面向后端维护者,不作为对外 API 文档。
## 模块定位
`mission` 是一个中立任务及跟进模块,用于承载系统内任意业务对象上的任务、参与者和回应。
命名使用 `Mission`,不使用 `Task`,主要原因是避免和 Celery task 以及 Python/业务语义中的 task 混淆。
## 当前模型
核心模型:
- `Mission`
- `MissionParticipant`
- `MissionReply`
### Mission
职责:
- 表示一个任务。
- 可选关联任意业务对象。
- 保存任务状态。
- 保存创建人、取消人、所属商户。
关键设计:
- `merchant` 是必填外键,用于严格多商户隔离。
- `creator` 指向 `basic_info.Employee`,不直接关联 `auth.User`
- `cancelled_by` 指向 `basic_info.Employee`,可空。
- `content_type + content_id` 都可空,用 Django `GenericForeignKey` 表示可选业务关联。
- `has_ending_reply` 是计算属性,只统计 `ends_task=True``is_rejected=False` 的回应。
- `can_reply` 是计算属性:任务未取消,且不存在有效结束回应。
自定义权限:
- `mission.reopen_mission`
- `mission.reject_mission_reply`
### MissionParticipant
职责:
- 表示任务参与者。
关键设计:
- 通过中间表维护参与者,而不是裸 ManyToMany。
- `merchant` 是冗余必填字段,用于隔离和后续高频查询。
- `(mission, employee)` 有唯一约束,避免同一员工重复加入同一任务。
### MissionReply
职责:
- 表示任务回应。
关键设计:
- `responder` 指向 `basic_info.Employee`,不可空。
- `ends_task=True` 表示这条回应触发结束任务。
- `is_rejected/rejected_by/rejected_at` 记录回应撤销行为。
- `merchant` 是冗余必填字段,用于隔离和后续高频查询。
## 多商户隔离
当前策略是“显式 merchant 冗余 + service/API 双层限制”。
模型层:
- `Mission.merchant`
- `MissionParticipant.merchant`
- `MissionReply.merchant`
API 层:
- 所有 mission 查询都限制为 `merchant=request.user.employee.merchant`
- 所有 reply 操作都限制为当前商户。
Service 层:
- 校验操作员工属于任务所属商户。
- 创建/更新关联业务对象时,如果目标对象存在 `merchant_id` 字段,则要求目标对象商户与任务商户一致。
- 设置参与者时,所有参与者必须属于任务所属商户。
说明:
- `GenericForeignKey` 本身没有数据库级外键约束,因此 service 层需要负责对象存在性和商户一致性校验。
- `MissionParticipant.merchant``MissionReply.merchant` 是有意冗余字段,后续代码应通过 service 创建和维护,避免绕过造成数据不一致。
## Service 层现状
当前 service 函数位于 `mission/services.py`
公开业务函数:
- `create_mission(...)`
- `update_mission(...)`
- `set_mission_participants(...)`
- `create_mission_reply(...)`
- `reopen_mission(...)`
- `reject_reply(...)`
- `cancel_mission(...)`
- `set_mission_urgent(...)`
内部辅助函数:
- `_assert_employee_belongs_to_mission(...)`
- `_validate_content_object_merchant(...)`
### create_mission
创建任务。
约定:
- `creator` 必须是 `Employee`
- `merchant` 来自 `creator.merchant`
- `is_urgent/is_completed/is_cancelled` 不接受外部参数,按模型默认值创建。
- 可选设置 `content_type/content_id`
- 可选设置参与者列表。
- 成功提交事务后发送 `mission_created` 信号。
### update_mission
普通任务更新。
允许更新:
- `description`
- `category`
- `content_type/content_id`
- `participant_ids`
不负责更新状态字段。
### set_mission_participants
重置任务参与者。
约定:
- 参与者 ID 会去重。
- 所有参与者必须属于任务所属商户。
- 未在新列表中的旧参与者会被删除。
### create_mission_reply
创建任务回应。
约定:
- `responder` 必须属于任务所属商户。
- 任务必须 `can_reply=True`
- 如果 `ends_task=True`,同步设置 `Mission.is_completed=True`
- 成功提交事务后发送 `mission_replied` 信号。
-`ends_task=True` 且任务从未完成变为完成时,同时发送 `mission_completed` 信号。
### reopen_mission
重新打开任务。
API 层需要先校验权限:
- `mission.reopen_mission`
Service 层前置条件:
- 操作员工属于任务所属商户。
- 任务未取消。
- 任务已完成。
- 存在有效的 `ends_task=True, is_rejected=False` 回应。
执行效果:
- `Mission.is_completed=False`
- 相关结束回应设置为 `ends_task=False`
- 相关结束回应记录 `is_rejected=True``rejected_by=reopened_by``rejected_at=now`
- 成功提交事务后发送 `mission_reopened` 信号。
- 被 reopen 撤销的结束回应会发送 `mission_reply_rejected` 信号,`reason="reopen"`
### reject_reply
撤销任务回应。
API 层需要先校验权限:
- `mission.reject_mission_reply`
Service 层前置条件:
- 操作员工属于任务所属商户。
- 任务未取消。
- 回应未被撤销。
执行效果:
- `MissionReply.ends_task=False`
- `MissionReply.is_rejected=True`
- `MissionReply.rejected_by=rejected_by`
- `MissionReply.rejected_at=now`
- 如果被撤销的是结束回应,且任务没有其他有效结束回应,则同步 `Mission.is_completed=False`
- 成功提交事务后发送 `mission_reply_rejected` 信号,`reason="reject_reply"`
### cancel_mission
取消任务。
约定:
- 操作员工必须属于任务所属商户。
- 已取消任务不能重复取消。
- 只设置 `is_cancelled/cancelled_by/cancelled_at`
- 不强行修改 `is_completed`,展示层应让取消状态优先于完成状态。
- 成功提交事务后发送 `mission_cancelled` 信号。
### set_mission_urgent
设置紧急状态。
约定:
- 操作员工必须属于任务所属商户。
- `is_urgent` 不允许通过普通 CRUD 更新,只能通过独立状态接口更新。
## 领域信号
信号定义位于 `mission/signals.py`,空 handler 接入口位于 `mission/handlers.py`,注册逻辑位于 `mission/apps.py`
发送原则:
- 只从 service 层发送,不从 model `post_save` 自动发送。
- 使用 `transaction.on_commit(...)`,确保事务提交成功后 handler 才运行。
- signal 发送异常只记录日志,不反向影响主业务流程。
当前信号:
| 信号 | sender | 触发时机 | 主要 payload |
|------|--------|----------|--------------|
| `mission_created` | `Mission` | 任务创建成功 | `instance`, `created_by` |
| `mission_replied` | `MissionReply` | 任务回应创建成功 | `instance`, `mission`, `responder` |
| `mission_completed` | `Mission` | 结束回应使任务变为完成 | `instance`, `completed_by`, `reply` |
| `mission_reply_rejected` | `MissionReply` | 回应被撤销 | `instance`, `mission`, `rejected_by`, `reason` |
| `mission_reopened` | `Mission` | 任务被 reopen | `instance`, `reopened_by`, `rejected_reply_ids` |
| `mission_cancelled` | `Mission` | 任务被取消 | `instance`, `cancelled_by` |
`mission_reply_rejected.reason` 当前取值:
- `reject_reply`:显式调用 `reject_reply(...)` 撤销回应。
- `reopen``reopen_mission(...)` 内部撤销结束回应。
## API 层现状
API 位于 `api_v2/views/mission.py`,路由位于 `api_v2/urls.py`
对外接口文档见:
- `docs/api_v2_mission_api.md`
当前 API 风格沿用 `api_v2` 现有结构:显式 `APIView + path(...)`,未引入 ViewSet/Router。
普通 CRUD
- `GET /api/v2/mission-categories/`
- `POST /api/v2/mission-categories/`
- `GET /api/v2/mission-categories/<category_id>/`
- `PATCH /api/v2/mission-categories/<category_id>/`
- `DELETE /api/v2/mission-categories/<category_id>/`
- `GET /api/v2/missions/`
- `POST /api/v2/missions/`
- `GET /api/v2/missions/<mission_id>/`
- `PATCH /api/v2/missions/<mission_id>/`
- `DELETE /api/v2/missions/<mission_id>/` 返回 405不提供删除能力
状态和 service 对应接口:
- `POST /api/v2/missions/<mission_id>/replies/`
- `POST /api/v2/missions/<mission_id>/reopen/`
- `POST /api/v2/missions/<mission_id>/cancel/`
- `POST /api/v2/missions/<mission_id>/set-urgent/`
- `POST /api/v2/mission-replies/<reply_id>/reject/`
## 当前测试
测试文件:
- `mission/tests.py`
- `api_v2/test_mission_api.py`
覆盖重点:
- 参与者便捷方法。
- 创建结束回应时任务自动完成。
- reopen 撤销结束回应。
- cancel 记录取消人和取消时间。
- reject reply 撤销回应并按需要恢复任务未完成。
- 普通 CRUD 禁止状态字段。
- 多商户隔离。
- reopen/reject 权限校验。
## 当前覆盖率
最近一次统计命令:
```bash
docker compose exec -T -e DB_HOST=postgres -e DB_PORT=5432 web \
uv run coverage run --source=mission manage.py test --keepdb --noinput \
api_v2.test_mission_api mission
docker compose exec -T web uv run coverage report -m
```
统计结果:
| 范围 | 覆盖率 |
|------|--------|
| `mission` 总体 | 96% |
| `mission/models.py` | 95% |
| `mission/services.py` | 99% |
| `mission/admin.py` | 100% |
| `mission/tests.py` | 100% |
说明:
- 当前按 `--source=mission` 统计,只计算 `mission` 模块本身,不包含 `api_v2/views/mission.py`
- 当前 `mission/services.py` 覆盖率为 99%,剩余未覆盖为 signal 发送异常保护分支。
- `mission` 总体剩余未覆盖主要来自迁移回填函数分支和空的 `mission/views.py`
## 已知边界
- 当前没有 Mission 的前端分页;列表直接返回数组。若任务量增大,应补分页。
- 当前 `MissionCategory` 已独立成表Mission 对外返回 `category=id``category_name`
- 当前没有删除能力,取消是唯一业务关闭入口之一。
- 当前 `GenericForeignKey` 只做对象存在性与可选商户一致性校验,不做目标对象的复杂业务权限校验。

View File

@@ -0,0 +1,293 @@
# Notifier 管理人员配置说明
本文档面向后台管理人员,说明如何在 Django Admin 中配置 `Notifier`,让系统在指定业务事件发生时自动发送通知。
## 1. Notifier 是什么
`Notifier` 可以理解为一条“通知规则”。
当某个业务事件发生时,系统会根据以下条件查找可用的通知器:
- 商户一致
- `event_key` 一致
- `is_enabled=True`
找到后,系统会自动:
1. 使用对应模板渲染消息内容
2. 按配置的渠道发送
3. 记录发送日志
当前已支持的渠道:
- 企业微信机器人 `wecom_webhook`
## 2. 当前已支持的事件
目前 `mission` 模块已接入以下事件:
- `mission.created`:任务创建
- `mission.replied`:任务有新回应
- `mission.completed`:任务完成
- `mission.reply_rejected`:任务回应被撤销
- `mission.reopened`:任务被重新打开
- `mission.cancelled`:任务被取消
如果要让某个事件发送通知,只需要在后台新增对应 `event_key``Notifier`
## 3. 在哪里配置
进入 Django Admin 后,找到:
- `通知器`
然后点击“新增”即可。
## 4. 字段说明
创建 `Notifier` 时,需要填写以下字段。
### 4.1 merchant
所属商户。
通知器只会匹配当前商户下发生的事件。
不同商户如果都需要通知,需要分别创建各自的 `Notifier`
### 4.2 name
通知器名称,仅用于后台识别和管理。
建议命名方式:
- `任务创建通知-生产群`
- `任务完成通知-老板群`
- `任务取消通知-客服群`
同一商户下名称不能重复。
### 4.3 event_key
要监听的业务事件标识。
这是最核心的绑定字段。
它决定这条 `Notifier` 绑定到哪个业务信号。
例如:
- `mission.completed` 表示“任务完成时发送”
- `mission.cancelled` 表示“任务取消时发送”
### 4.4 channel
通知渠道。
当前固定选:
- `wecom_webhook`
### 4.5 template_key
消息模板标识。
系统会根据这个字段去固定目录查找模板文件。
当前模板目录为:
`notifier/templates/notifier/events/`
例如:
- `template_key = mission_completed`
- 对应模板文件:`notifier/templates/notifier/events/mission_completed.md`
### 4.6 is_enabled
是否启用。
- 勾选:该 `Notifier` 生效
- 不勾选:该 `Notifier` 不会参与匹配和发送
### 4.7 config
渠道配置,使用 JSON 格式填写。
当前企业微信机器人建议配置如下:
```json
{
"key": "你的企业微信机器人key",
"msgtype": "markdown",
"timeout_seconds": 10
}
```
字段说明:
- `key`:企业微信机器人 webhook key
- `msgtype`:消息类型,建议用 `markdown`
- `timeout_seconds`:请求超时时间,单位秒
### 4.8 description
备注说明,非必填。
建议写清楚这条通知器的用途,例如:
- `用于生产部任务完成通知`
- `用于客服查看任务取消`
## 5. 配置步骤
以“任务完成时发送企业微信通知”为例:
1. 进入 Admin 的 `通知器`
2. 点击“新增”
3. 选择 `merchant`
4. 填写 `name`
5. 选择 `event_key = mission.completed`
6. 选择 `channel = wecom_webhook`
7. 填写 `template_key = mission_completed`
8.`config` 中填写 webhook 参数
9. 勾选 `is_enabled`
10. 保存
保存后,只要该商户下发生“任务完成”事件,系统就会自动尝试发送通知。
## 6. 推荐配置示例
### 6.1 示例一:任务创建通知
适合发到内部任务协作群。
字段建议:
- `name`: `任务创建通知-协作群`
- `event_key`: `mission.created`
- `channel`: `wecom_webhook`
- `template_key`: `mission_created`
- `is_enabled`: 勾选
`config` 示例:
```json
{
"key": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"msgtype": "markdown",
"timeout_seconds": 10
}
```
### 6.2 示例二:任务完成通知
适合发到管理群或老板群。
字段建议:
- `name`: `任务完成通知-管理群`
- `event_key`: `mission.completed`
- `channel`: `wecom_webhook`
- `template_key`: `mission_completed`
- `is_enabled`: 勾选
### 6.3 示例三:任务取消通知
适合发到客服或跟单群。
字段建议:
- `name`: `任务取消通知-客服群`
- `event_key`: `mission.cancelled`
- `channel`: `wecom_webhook`
- `template_key`: `mission_cancelled`
- `is_enabled`: 勾选
## 7. 一个事件是否可以绑定多个 Notifier
可以。
例如同一个商户下,`mission.completed` 可以同时配置:
- 一条发到生产群
- 一条发到老板群
- 一条发到客服群
只要它们满足:
- `merchant` 相同
- `event_key` 相同
- `is_enabled=True`
系统就会逐条发送。
## 8. 模板如何对应
当前系统已内置以下模板:
- `mission_created`
- `mission_replied`
- `mission_completed`
- `mission_reply_rejected`
- `mission_reopened`
- `mission_cancelled`
管理人员通常只需要填 `template_key`,不需要改代码。
如果后续要新增模板内容或调整文案,需要由开发人员修改模板文件。
## 9. 如何停用某条通知
如果暂时不想让某条通知继续发送,不需要删除,只需要:
1. 打开该 `Notifier`
2. 取消勾选 `is_enabled`
3. 保存
这样最安全,也方便后续恢复。
## 10. 常见问题
### 10.1 为什么事件发生了,但没有收到通知
请依次检查:
1. `Notifier` 是否已勾选 `is_enabled`
2. `merchant` 是否配置正确
3. `event_key` 是否选对
4. `template_key` 是否与现有模板匹配
5. `config.key` 是否填写正确
6. Celery worker 是否已启动
### 10.2 为什么同一个事件发了多次
通常是因为配置了多条相同 `merchant + event_key` 的启用状态通知器。
这不一定是错误,也可能是有意发往多个群。
如果不希望多发,请检查是否存在重复配置。
### 10.3 是否建议删除 Notifier
一般不建议优先删除,建议先停用:
- 更安全
- 方便回滚
- 方便排查历史配置
## 11. 管理建议
建议按下面的方式维护:
- 名称中写清楚用途和群目标
- 先停用再删除
- 一个事件先配一条验证,确认无误后再扩展到多个群
- `description` 中注明负责人或用途
## 12. 给管理人员的最简操作结论
如果你只想快速配置一条通知,记住这 5 个关键点就够了:
1. 选对 `merchant`
2. 选对 `event_key`
3.`channel = wecom_webhook`
4. 填对 `template_key`
5.`config` 填正确的企业微信机器人 `key`
这样保存后,对应事件发生时就会自动发送。

View File

@@ -0,0 +1,229 @@
# Notifier 模块实现说明
本文档面向后端维护者,描述 `notifier` 模块当前的实现决策、已落地范围与后续扩展注意事项。
## 1. 背景与目标
项目原有的通知能力主要以企业微信机器人为主,并且配置集中在 `settings.py` 中,属于静态配置方案。
新的 `mission` 模块希望从一开始就采用:
- 独立 Django app
- task 化投递
- 后台可配置
- signal 与 notifier 动态绑定
- 为后续增加其它通知渠道预留统一接口
因此本次新增独立模块 `notifier`,并先将 `mission` 的新信号通知接入该模块。
## 2. 当前范围
本次实现属于 Phase 1范围有意收敛
- 已新增独立 app`notifier`
- 已支持后台配置 `Notifier`
- 已支持按 `event_key` + `merchant` 动态匹配通知器
- 已支持 Celery task 化派发
- 已支持模板化内容渲染
- 已支持第一种渠道:企业微信机器人 webhook
- 已接入 `mission` 的 6 个业务信号
本次**没有**做的内容:
- 没有改造旧模块的静态通知逻辑
- 没有引入通知订阅/端点拆表
- 没有引入通知投递明细表(如 `NotificationDelivery`
- 没有做数据库级别审计
- 没有提供对外 API
## 3. 核心模型
当前模型只有一个主模型:`notifier.Notifier`
字段职责如下:
- `merchant`: 多商户隔离
- `name`: 通知器名称,仅要求在同商户内唯一
- `event_key`: 事件标识,用于和业务 signal 对接
- `channel`: 通知渠道,当前仅实现 `wecom_webhook`
- `template_key`: 模板标识,对应固定目录中的模板文件
- `is_enabled`: 启用/停用
- `config`: 渠道配置,当前主要存放企业微信 webhook key、msgtype、timeout 等
- `description`: 备注
当前将 `event_key` 直接放在 `Notifier` 上,而没有拆成“事件订阅 + 通知端点”两层,原因是现阶段追求低复杂度、可快速上线。
后续如果一个通知端点需要订阅多个事件,或者一个事件需要更复杂的启停/优先级/路由策略,再考虑拆模。
## 4. 目录结构
关键文件如下:
- `notifier/models.py`
- `notifier/admin.py`
- `notifier/services.py`
- `notifier/tasks.py`
- `notifier/backends.py`
- `notifier/registry.py`
- `notifier/templates/notifier/events/`
模板固定目录为:
`notifier/templates/notifier/events/`
当前已提供的模板:
- `mission_created.md`
- `mission_replied.md`
- `mission_completed.md`
- `mission_reply_rejected.md`
- `mission_reopened.md`
- `mission_cancelled.md`
`template_key` 与模板文件名一一对应,例如:
- `template_key="mission_created"`
- 模板路径 `notifier/events/mission_created.md`
## 5. 调用链路
当前通知链路为:
1. `mission.services` 在事务提交后发送业务 signal
2. `mission.handlers` 监听 signal
3. handler 将业务对象整理为纯字典 payload
4. handler 调用 `notifier.services.enqueue_notification_event(...)`
5. notifier 通过 Celery task 异步执行投递
6. task 内部调用 `dispatch_notification_event(...)`
7.`merchant_id + event_key + is_enabled=True` 查询匹配的 `Notifier`
8. 逐个渲染模板并调用对应 backend 的 `notify(...)`
9. 写详细日志
这里有两个关键约束:
- handler 只做 payload 组装和入队,不做实际发送
- task 层才做真正的通知投递
这样可以保持业务事务与外部通知解耦。
## 6. 渠道抽象
当前 backend 接口约定为:
- `BaseNotifierBackend.notify(notifier, content, context) -> dict`
当前已实现:
- `WeComWebhookNotifierBackend`
其复用了现有工具:
- `api_v1.utils.wecom_webhook.send_wecom_webhook_message`
这样做的原因:
- 避免重复实现 webhook 发送逻辑
- 保持旧工具可复用
- 新模块只负责“编排”和“动态配置”
## 7. Mission 已接入事件
当前 `mission` 已接入以下事件:
- `mission.created`
- `mission.replied`
- `mission.completed`
- `mission.reply_rejected`
- `mission.reopened`
- `mission.cancelled`
对应 handler 在:
- `mission/handlers.py`
当前 handler 不再只是打日志,而是会构造 payload 并投递到 notifier task。
## 8. Admin 配置方式
`Notifier` 已接入 Django Admin可进行
- 添加
- 编辑
- 删除
- 启用/停用
当前推荐的使用方式:
1. 在 admin 中新建 `Notifier`
2. 选择所属商户
3. 选择 `event_key`
4. 选择 `channel=wecom_webhook`
5. 填写 `template_key`
6.`config` 中填写 webhook key 等参数
7. 启用 `is_enabled`
当前 `config` 示例:
```json
{
"key": "企业微信机器人key",
"msgtype": "markdown",
"timeout_seconds": 10
}
```
## 9. 日志策略
本阶段没有引入数据库投递明细表,因此发送明细主要依赖日志。
当前日志覆盖以下节点:
- 任务入队
- backend 发送成功
- 单个 notifier 发送成功
- 单个 notifier 发送失败
- 某事件无匹配 notifier
这满足当前“先可用、后增强”的目标,也符合“暂不做数据库级审计”的约束。
## 10. 当前风险与注意点
### 10.1 配置合法性主要依赖管理规范
当前 `config` 是自由 JSON没有做更强的结构化校验。
优点是灵活,缺点是后台录入错误会在发送时才暴露。
### 10.2 模板标识依赖文件存在
`template_key` 对应的模板文件如果不存在,会在发送阶段报错并记录日志。
这在当前阶段是可接受的,但后续可以考虑在 admin 或 model clean 中增加校验。
### 10.3 目前仍是“单对象订阅”模型
一个 `Notifier` 对应一个 `event_key`
如果后续出现“一个群同时订阅多个事件”的强需求,可以考虑抽象出 Subscription 层。
### 10.4 旧通知逻辑尚未迁移
当前仅 `mission` 新通知走 `notifier`
`printing``shipment` 等旧逻辑仍保留原来的静态方式,不应在本次改动中混改。
## 11. 后续建议
按优先级建议如下:
1. 在 admin 使用中观察 `config``template_key` 是否已足够稳定
2. 若 notifier 数量增多,再决定是否拆分“通知端点”与“事件订阅”
3. 若需要追踪投递历史,再增加 `NotificationDelivery`
4. 当 mission 通知稳定后,再考虑逐步迁移新业务节点到 notifier
## 12. 当前结论
当前方案已经满足:
- 独立模块
- admin 配置
- task 化通知
- 模板化内容
- 动态 signal -> notifier 路由
- 后续可扩展到多渠道
同时复杂度仍控制在较低水平,适合作为第一阶段正式实现。

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

View File

@@ -0,0 +1,146 @@
@startuml
title 中立 mission 及跟进模块类图
skinparam shadowing false
skinparam class {
BackgroundColor #F8FBFF
BorderColor #4C6A92
ArrowColor #4C6A92
FontName Noto Sans CJK SC
}
skinparam note {
BackgroundColor #FFF7E6
BorderColor #B7791F
FontName Noto Sans CJK SC
}
skinparam defaultFontName Noto Sans CJK SC
class "Mission\n任务" as Mission {
+id: BigAutoField
+merchant: Merchant
+description: Text
+category: CharField
+created_at: DateTime
+is_urgent: Boolean = false
+is_completed: Boolean = false
+is_cancelled: Boolean = false
+cancelled_at: DateTime? = null
+content_type: ContentType? = null
+content_id: PositiveBigInteger? = null
+content_object: GenericForeignKey?
+creator: Employee
+cancelled_by: Employee? = null
--
+participants: QuerySet<Employee>
+has_ending_reply: Boolean <<computed>>
+can_reply: Boolean <<computed>>
+get_participants(): QuerySet<Employee>
+filter_participants(...): QuerySet<Employee>
+reopen(): Mission
}
class "MissionParticipant\n任务参与者" as MissionParticipant {
+id: BigAutoField
+merchant: Merchant
+mission: Mission
+employee: Employee
+created_at: DateTime
}
class "MissionReply\n任务回应" as MissionReply {
+id: BigAutoField
+merchant: Merchant
+mission: Mission
+responder: Employee
+content: Text
+replied_at: DateTime
+ends_task: Boolean = false
+is_rejected: Boolean = false
+rejected_by: Employee? = null
+rejected_at: DateTime? = null
}
class "Merchant\nbasic_info.Merchant" as Merchant {
+id: BigAutoField
+name: CharField
}
class "Employee\nbasic_info.Employee" as Employee {
+id: BigAutoField
+name: CharField
+merchant: Merchant
}
class "ContentType\ndjango.contrib.contenttypes" as ContentType {
+id: AutoField
+app_label: CharField
+model: CharField
}
class "Any Business Model\n任意业务模型" as AnyBusinessModel {
+id: ...
}
Mission "1" o-- "0..*" MissionParticipant : participants
Mission "0..*" --> "1" Merchant : merchant
MissionParticipant "0..*" --> "1" Merchant : merchant
MissionReply "0..*" --> "1" Merchant : merchant
MissionParticipant "0..*" --> "1" Employee : employee
Mission "1" --> "1" Employee : creator
Mission "0..*" --> "0..1" Employee : cancelled_by
Mission "1" o-- "0..*" MissionReply : replies
MissionReply "0..*" --> "1" Employee : responder
MissionReply "0..*" --> "0..1" Employee : rejected_by
Mission "0..*" --> "0..1" ContentType : content_type
Mission ..> AnyBusinessModel : content_object\nGenericForeignKey
note right of Mission
content_type 与 content_id 均可为空:
- 为空:任务不绑定具体业务对象
- 非空:任务可关联系统中任意业务模型
所有人物字段均关联 basic_info.Employee
不直接关联 request.user / auth.User。
Mission / MissionParticipant / MissionReply
都冗余保存 merchant用于严格多商户隔离。
end note
note bottom of Mission
参与者通过 MissionParticipant 中间表维护。
Mission 模型应提供便捷方法用于获取和筛选参与者,
但关系本身不直接写成裸 ManyToMany 字段,
方便后续扩展参与者状态、角色、加入时间等信息。
end note
note right of MissionReply
当任意关联回应 ends_task = true 时:
- Mission.has_ending_reply = true
- Mission.can_reply = false
- 后续不允许继续创建 MissionReply
创建 ends_task = true 的回应时,
本模块 service 同步设置:
Mission.is_completed = true
end note
note left of Mission
is_cancelled 默认为 false。
取消任务时写入:
- cancelled_by
- cancelled_at
end note
note bottom of MissionReply
reopen 独立业务函数的前置条件:
- Mission.is_cancelled = false
- Mission.is_completed = true
- 能查询到 ends_task = true 的 MissionReply
reopen 执行效果:
- Mission.is_completed = false
- 对 ends_task = true 的回应设置 ends_task = false
- 同时记录该回应 is_rejected = true、
rejected_by = reopened_by、rejected_at = now
end note
@enduml

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

View File

@@ -0,0 +1,155 @@
@startuml
title Notifier 模块一期类图
skinparam shadowing false
skinparam class {
BackgroundColor #F8FBFF
BorderColor #4C6A92
ArrowColor #4C6A92
FontName Noto Sans CJK SC
}
skinparam note {
BackgroundColor #FFF7E6
BorderColor #B7791F
FontName Noto Sans CJK SC
}
skinparam defaultFontName Noto Sans CJK SC
class "Notifier\n通知器配置" as Notifier {
+id: BigAutoField
+merchant: Merchant
+name: CharField
+event_key: CharField
+channel: CharField
+template_key: CharField
+is_enabled: Boolean = true
+config: JSON
+description: Text?
+created_at: DateTime
+updated_at: DateTime
}
class "Merchant\nbasic_info.Merchant" as Merchant {
+id: BigAutoField
+name: CharField
}
class "Mission Signals\n领域信号" as MissionSignals <<service>> {
+mission_created
+mission_replied
+mission_completed
+mission_reply_rejected
+mission_reopened
+mission_cancelled
}
class "MissionHandlers\n信号处理器" as MissionHandlers <<service>> {
+on_mission_created(...)
+on_mission_replied(...)
+on_mission_completed(...)
+on_mission_reply_rejected(...)
+on_mission_reopened(...)
+on_mission_cancelled(...)
}
class "NotifierDispatcher\n通知分发器" as NotifierDispatcher <<service>> {
+dispatch(event_key, merchant_id, payload)
}
class "TemplateRenderer\n模板渲染器" as TemplateRenderer <<service>> {
+render(template_key, payload): dict
}
abstract class "BaseNotifier\n渠道后端抽象" as BaseNotifier {
+channel: str
+notify(endpoint, event_key, payload, rendered): dict
}
class "WeComWebhookNotifier\n企业微信通知器" as WeComWebhookNotifier {
+channel = \"wecom_webhook\"
+notify(endpoint, event_key, payload, rendered): dict
}
class "EmailNotifier\n邮件通知器" as EmailNotifier {
+channel = \"email\"
+notify(endpoint, event_key, payload, rendered): dict
}
class "NotifierRegistry\n通知器注册表" as NotifierRegistry <<service>> {
+get(channel): BaseNotifier
}
class "NotifierTask\n异步任务入口" as NotifierTask <<task>> {
+dispatch_notification_event(...)
}
MissionSignals ..> MissionHandlers : connect
MissionHandlers ..> NotifierTask : on_commit + delay
NotifierTask ..> NotifierDispatcher : dispatch(...)
NotifierDispatcher --> "0..*" Notifier : query by\nmerchant + event_key + is_enabled
NotifierDispatcher ..> TemplateRenderer : render(...)
NotifierDispatcher ..> NotifierRegistry : get(channel)
NotifierRegistry ..> BaseNotifier
BaseNotifier <|-- WeComWebhookNotifier
BaseNotifier <|-- EmailNotifier
Notifier "0..*" --> "1" Merchant : merchant
note right of Notifier
一期简化设计:
- 不拆 Endpoint / Subscription
- 直接把 event_key 绑定在 Notifier 对象上
- 直接把 template_key 配置在 Notifier 对象上
适合通过 Django Admin 做:
- 添加 / 删除
- 启用 / 禁用
- 编辑渠道参数
end note
note bottom of Notifier
config 为 JSON
企业微信示例可包含:
- webhook_key
- mentioned_list
- mentioned_mobile_list
同一商户下可配置多个 Notifier
同一个 event_key 可命中多个 Notifier。
end note
note right of TemplateRenderer
模板文件不落数据库。
Notifier 仅保存 template_key。
模板集中存放在固定目录,例如:
notifier/templates/
end note
note left of MissionHandlers
handler 不直接拼企业微信内容,
也不直接读取 settings.py 中的静态 key。
handler 只负责把领域事件
转成 event_key + payload
再交给 notifier 模块。
end note
note bottom of NotifierDispatcher
一期暂不引入 NotificationDelivery 表。
发送结果通过日志记录即可,
不做数据库级发送审计。
风险:
- 无法在数据库中重放 / 检索历史发送记录
- 排查依赖日志系统
end note
note left of Notifier
设计取舍风险:
把 template_key 配到 Notifier 对象上,
会让“同一渠道目标用于多个事件”时产生配置重复。
但这能显著降低一期复杂度,
且更贴合 Admin 直接维护。
end note
@enduml