forked from erp-dev/erp
9.8 KiB
9.8 KiB
Mission 模块内部设计说明
本文档面向后端维护者,不作为对外 API 文档。
模块定位
mission 是一个中立任务及跟进模块,用于承载系统内任意业务对象上的任务、参与者和回应。
命名使用 Mission,不使用 Task,主要原因是避免和 Celery task 以及 Python/业务语义中的 task 混淆。
当前模型
核心模型:
MissionMissionParticipantMissionReply
Mission
职责:
- 表示一个任务。
- 可选关联任意业务对象。
- 保存任务状态。
- 保存创建人、取消人、所属商户。
关键设计:
merchant是必填外键,用于严格多商户隔离。creator指向basic_info.Employee,不直接关联auth.User。cancelled_by指向basic_info.Employee,可空。content_type + content_id都可空,用 DjangoGenericForeignKey表示可选业务关联。has_ending_reply是计算属性,只统计ends_task=True且is_rejected=False的回应。can_reply是计算属性:任务未取消,且不存在有效结束回应。
自定义权限:
mission.reopen_missionmission.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.merchantMissionParticipant.merchantMissionReply.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
普通任务更新。
允许更新:
descriptioncategorycontent_type/content_idparticipant_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=FalseMissionReply.is_rejected=TrueMissionReply.rejected_by=rejected_byMissionReply.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.pyapi_v2/test_mission_api.py
覆盖重点:
- 参与者便捷方法。
- 创建结束回应时任务自动完成。
- reopen 撤销结束回应。
- cancel 记录取消人和取消时间。
- reject reply 撤销回应并按需要恢复任务未完成。
- 普通 CRUD 禁止状态字段。
- 多商户隔离。
- reopen/reject 权限校验。
当前覆盖率
最近一次统计命令:
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只做对象存在性与可选商户一致性校验,不做目标对象的复杂业务权限校验。