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