forked from erp-dev/erp
feat: notifier beta
This commit is contained in:
437
docs/api_v2_mission_api.md
Normal file
437
docs/api_v2_mission_api.md
Normal 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`
|
||||
336
docs/mission_module_design.md
Normal file
336
docs/mission_module_design.md
Normal 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` 只做对象存在性与可选商户一致性校验,不做目标对象的复杂业务权限校验。
|
||||
293
docs/notifier_admin_guide.md
Normal file
293
docs/notifier_admin_guide.md
Normal 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`
|
||||
|
||||
这样保存后,对应事件发生时就会自动发送。
|
||||
229
docs/notifier_module_design.md
Normal file
229
docs/notifier_module_design.md
Normal 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 路由
|
||||
- 后续可扩展到多渠道
|
||||
|
||||
同时复杂度仍控制在较低水平,适合作为第一阶段正式实现。
|
||||
BIN
docs/plant/neutral-task-module-class-diagram.png
Normal file
BIN
docs/plant/neutral-task-module-class-diagram.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 235 KiB |
146
docs/plant/neutral-task-module-class-diagram.puml
Normal file
146
docs/plant/neutral-task-module-class-diagram.puml
Normal 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
|
||||
BIN
docs/plant/notifier-module-class-diagram.png
Normal file
BIN
docs/plant/notifier-module-class-diagram.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 225 KiB |
155
docs/plant/notifier-module-class-diagram.puml
Normal file
155
docs/plant/notifier-module-class-diagram.puml
Normal 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
|
||||
Reference in New Issue
Block a user