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

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` 只做对象存在性与可选商户一致性校验,不做目标对象的复杂业务权限校验。