diff --git a/api_v2/test_mission_api.py b/api_v2/test_mission_api.py index 0ba9602..48e6da6 100644 --- a/api_v2/test_mission_api.py +++ b/api_v2/test_mission_api.py @@ -541,30 +541,44 @@ class MissionV2APITest(TestCase): [item["name"] for item in list_resp.data], ["通用", "跟进"], ) + self.assertEqual(list_resp.data[0]["payload_processor"], "") create_resp = self.client.post( "/api/v2/mission-categories/", - {"name": "售后"}, + {"name": "售后", "payload_processor": "structured_description_v1"}, format="json", ) self.assertEqual(create_resp.status_code, 201) category_id = create_resp.data["id"] + self.assertEqual(create_resp.data["payload_processor"], "structured_description_v1") detail_resp = self.client.get(f"/api/v2/mission-categories/{category_id}/") self.assertEqual(detail_resp.status_code, 200) self.assertEqual(detail_resp.data["name"], "售后") + self.assertEqual(detail_resp.data["payload_processor"], "structured_description_v1") patch_resp = self.client.patch( f"/api/v2/mission-categories/{category_id}/", - {"name": "售后跟进"}, + {"name": "售后跟进", "payload_processor": ""}, format="json", ) self.assertEqual(patch_resp.status_code, 200) self.assertEqual(patch_resp.data["name"], "售后跟进") + self.assertEqual(patch_resp.data["payload_processor"], "") delete_resp = self.client.delete(f"/api/v2/mission-categories/{category_id}/") self.assertEqual(delete_resp.status_code, 204) + def test_mission_category_rejects_unknown_payload_processor(self): + resp = self.client.post( + "/api/v2/mission-categories/", + {"name": "售后", "payload_processor": "unknown_processor"}, + format="json", + ) + + self.assertEqual(resp.status_code, 400) + self.assertIn("payload_processor", resp.data) + def test_delete_used_mission_category_is_rejected(self): mission = self._create_mission() diff --git a/api_v2/views/mission.py b/api_v2/views/mission.py index 9653470..73b441b 100644 --- a/api_v2/views/mission.py +++ b/api_v2/views/mission.py @@ -108,6 +108,11 @@ class MissionUrgentSerializer(serializers.Serializer): class MissionCategoryWriteSerializer(serializers.Serializer): name = serializers.CharField(allow_blank=False, max_length=50) + payload_processor = serializers.ChoiceField( + choices=mission_models.MissionPayloadProcessorEnum.choices, + required=False, + allow_blank=True, + ) def validate_name(self, value): value = value.strip() @@ -251,6 +256,7 @@ class MissionCategorySerializer(serializers.ModelSerializer): "id", "merchant", "name", + "payload_processor", "created_at", "updated_at", ] @@ -402,6 +408,7 @@ class MissionCategoryListCreateView(APIView): category = mission_models.MissionCategory.objects.create( merchant=employee.merchant, name=serializer.validated_data["name"], + payload_processor=serializer.validated_data.get("payload_processor", ""), ) except IntegrityError: return Response({"name": ["分类名称已存在"]}, status=status.HTTP_400_BAD_REQUEST) @@ -428,10 +435,16 @@ class MissionCategoryDetailView(APIView): context={"employee": employee, "instance": category}, ) serializer.is_valid(raise_exception=True) + update_fields = [] if "name" in serializer.validated_data: category.name = serializer.validated_data["name"] + update_fields.append("name") + if "payload_processor" in serializer.validated_data: + category.payload_processor = serializer.validated_data["payload_processor"] + update_fields.append("payload_processor") + if update_fields: try: - category.save(update_fields=["name", "updated_at"]) + category.save(update_fields=[*update_fields, "updated_at"]) except IntegrityError: return Response({"name": ["分类名称已存在"]}, status=status.HTTP_400_BAD_REQUEST) return Response(MissionCategorySerializer(category).data) diff --git a/docs/MESSAGE_API.md b/docs/MESSAGE_API.md new file mode 100644 index 0000000..c8e7910 --- /dev/null +++ b/docs/MESSAGE_API.md @@ -0,0 +1,165 @@ +# 消息发送 API + +这组 API 用于对外主动发送企业微信应用消息。 + +当前提供两个接口: + +- `POST /api/message/send`:发送文本消息 +- `POST /api/message/send/news`:发送单篇图文消息,可同时投递到多个 `agent_id` + +## 鉴权 + +仅这组消息发送 API 需要固定 `Authorization` 请求头。 + +服务端读取环境变量: + +- `MESSAGE_API_AUTHORIZATION` + +默认值: + +- `hophopkk` + +请求示例: + +```http +Authorization: hophopkk +Content-Type: application/json +``` + +鉴权失败时返回: + +```json +{ + "detail": "Invalid Authorization header" +} +``` + +HTTP 状态码:`401 Unauthorized` + +## 发送文本消息 + +### 请求 + +`POST /api/message/send` + +```json +{ + "agent_id": 1000007, + "content": "库存盘点将在 18:00 开始", + "user_ids": ["zhangsan", "lisi"] +} +``` + +字段说明: + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `agent_id` | integer | 是 | 企业微信应用 ID | +| `content` | string | 是 | 文本内容,1-2048 字节 | +| `user_ids` | string[] | 否 | 接收用户 UserID 列表;为空时发送给应用可见范围内全部成员 | + +### 成功响应 + +```json +{ + "errcode": 0, + "errmsg": "ok" +} +``` + +### curl 示例 + +```bash +curl -X POST 'http://localhost:8198/api/message/send' \ + -H 'Authorization: hophopkk' \ + -H 'Content-Type: application/json' \ + -d '{ + "agent_id": 1000007, + "content": "库存盘点将在 18:00 开始", + "user_ids": ["zhangsan", "lisi"] + }' +``` + +## 发送图文消息 + +### 请求 + +`POST /api/message/send/news` + +```json +{ + "agent_ids": [1000007, 1000008], + "title": "销售日报", + "description": "点击查看今日各区域销售汇总", + "url": "https://example.com/reports/daily-sales", + "image_url": "https://example.com/static/daily-sales-cover.png", + "user_ids": ["zhangsan"] +} +``` + +字段说明: + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `agent_ids` | integer[] | 是 | 目标应用 ID 列表,至少 1 个 | +| `title` | string | 是 | 图文标题,1-128 字符 | +| `description` | string | 是 | 图文描述,1-512 字符 | +| `url` | string | 是 | 点击跳转链接 | +| `image_url` | string | 是 | 封面图片 URL,直接映射到企业微信 `picurl` | +| `user_ids` | string[] | 否 | 接收用户 UserID 列表;为空时发送给应用可见范围内全部成员 | + +### 成功响应 + +至少一个 `agent_id` 发送成功时返回 `200`,并带每个应用的发送结果: + +```json +{ + "results": [ + { + "agent_id": 1000007, + "ok": true, + "response": { + "errcode": 0, + "errmsg": "ok" + }, + "error": null + }, + { + "agent_id": 1000008, + "ok": false, + "response": null, + "error": "agent not found" + } + ] +} +``` + +如果所有 `agent_id` 都失败,则返回 `502 Bad Gateway`: + +```json +{ + "detail": "All agent sends failed" +} +``` + +### curl 示例 + +```bash +curl -X POST 'http://localhost:8198/api/message/send/news' \ + -H 'Authorization: hophopkk' \ + -H 'Content-Type: application/json' \ + -d '{ + "agent_ids": [1000007, 1000008], + "title": "销售日报", + "description": "点击查看今日各区域销售汇总", + "url": "https://example.com/reports/daily-sales", + "image_url": "https://example.com/static/daily-sales-cover.png", + "user_ids": ["zhangsan"] + }' +``` + +## 发布建议 + +- 如果要对外给第三方系统调用,至少同步交付这份文档和环境变量名 `MESSAGE_API_AUTHORIZATION` +- 当前是固定密钥模式,适合内网或受控系统对接,不适合开放互联网暴露 +- 如果后续要接入更多对外方,建议升级为签名、时间戳或短期 token 模式 \ No newline at end of file diff --git a/docs/WECOM_APP_INTEGRATION_GUIDE.md b/docs/WECOM_APP_INTEGRATION_GUIDE.md new file mode 100644 index 0000000..1be55ca --- /dev/null +++ b/docs/WECOM_APP_INTEGRATION_GUIDE.md @@ -0,0 +1,412 @@ +# 企业微信应用接入说明 + +本文档面向企业微信自建应用开发组,说明当前 AI Agent 的接入方式、适合的消息入口、菜单和提问引导建议、能力边界,以及消息处理侧需要注意的事项。 + +## 1. 目标 + +当前 Agent 不是通用对话机器人,而是一个“受约束的业务查询助手”。 + +它适合处理以下两类问题: + +1. MES / 出货 / 运输车辆等业务数据查询。 +2. 指定客户的财务记录查询。 + +它不适合承担以下职责: + +1. 开放式闲聊。 +2. 多轮澄清式对话。 +3. 写入、审批、修改业务数据。 +4. 复杂统计分析和自定义报表。 + +因此,企业微信侧的入口设计应尽量采用“菜单驱动 + 明确提问”的方式,而不是把它当成一个无限制聊天窗口。 + +## 2. 当前调用入口 + +当前服务对外提供的 Agent 入口为: + +- `POST /agent` + +健康检查: + +- `GET /health` + +当前请求体: + +```json +{ + "message": "查询华东未出货出货单", + "merchant_id": 1 +} +``` + +当前响应体: + +```json +{ + "reply": "目前查询到华东区域没有未出货的出货单(结果为0条)。" +} +``` + +说明: + +- `message` 为发送给 Agent 的自然语言问题。 +- `merchant_id` 目前只对商户隔离型业务 API 生效。 +- 财务查询当前不依赖 `merchant_id`。 +- 返回值是自然语言文本,面向企业微信 markdown 消息渲染。 + +## 3. 当前能力范围 + +### 3.1 商户业务查询 + +当前已接入以下业务能力: + +1. 查询 MES 设备列表。 +2. 查询指定日期范围内的 MES 生产安排。 +3. 查询运输车辆详情。 +4. 查询未进入送货单的出货单。 + +这些能力依赖: + +- `merchant_id` + +因此企业微信应用侧如果要接这类查询,建议在进入 Agent 前就明确当前商户身份,或由调用方稳定注入 `merchant_id`。 + +### 3.2 财务查询 + +当前已接入以下财务记录类型: + +1. 销售记录 +2. 销退记录 +3. 收款记录 +4. 退款记录 + +财务查询支持: + +- 单选一种记录类型 +- 多选多种记录类型 +- 宽泛的“财务记录”查询 + +财务查询还能区分: + +1. 真实资金变动 +2. 挂账 / 冲减 / 欠款调整 + +## 4. 当前明确不支持的能力 + +企业微信应用开发组应优先在入口侧理解这些边界,因为这些能力不应引导给 Agent。 + +### 4.1 不支持写操作 + +当前不支持: + +1. 写入财务数据 +2. 修改财务数据 +3. 删除财务数据 +4. 审批财务数据 +5. 纠正财务数据 +6. 写入业务单据 + +如果用户发起这类请求,推荐在企业微信应用侧直接拦截,或允许 Agent 返回标准拒绝说明。 + +### 4.2 不支持复杂统计 + +当前财务接口只支持“查记录”,不支持复杂统计分析。 + +允许的上限只有: + +1. 对返回记录做直接计数。 +2. 对返回记录做直接累计。 + +当前不支持: + +1. 按季度汇总 +2. 按月汇总 +3. 按年汇总 +4. 环比 +5. 同比 +6. 趋势分析 +7. 占比分析 +8. 分组统计 +9. 筛选后再累计 +10. 小计、分类汇总、派生统计口径 + +如果企业微信应用已经能判断是这类诉求,建议不要直接把这类问题送给 Agent。 + +## 5. 企业微信消息渲染约束 + +当前 Agent 的回复是按企业微信应用消息中的 markdown 消息来约束的。 + +已知约束如下: + +1. 企业微信 markdown 只支持 markdown 子集。 +2. `content` 最长不超过 `2048` 字节,UTF-8 编码。 +3. 当前 Agent 会尽量把回复压缩在约 `1200` 字节以内。 +4. 不应使用表格。 +5. 不应使用 fenced code block。 +6. 不应使用复杂嵌套列表。 +7. 不应依赖复杂 HTML 排版。 + +当前更适合的展示形式: + +1. 一段简洁结论。 +2. 2 到 5 条短列表。 +3. 必要时附少量关键字段。 + +因此,企业微信应用侧不应期待 Agent 返回: + +1. 表格型结果 +2. 长篇报告 +3. 大批量明细完整铺开 + +## 6. 入口设计建议 + +### 6.1 推荐采用单入口调用,多菜单引导 + +当前技术上只需要一个 Agent 接口入口: + +- `POST /agent` + +但在企业微信应用层,建议不要只给一个“自由提问”入口。更合适的是: + +1. 菜单项负责限定业务范围。 +2. 菜单点击后,用明确提示语引导用户输入必要字段。 +3. 应用层把整理后的自然语言问题发送给 `/agent`。 + +换句话说,推荐是“多个菜单入口,共用一个 Agent API”。 + +### 6.2 推荐菜单分组 + +建议至少拆成两大类: + +1. 生产/物流类查询 +2. 财务类查询 + +财务类再细分为: + +1. 查询客户销售记录 +2. 查询客户销退记录 +3. 查询客户收款记录 +4. 查询客户退款记录 +5. 查询客户全部财务记录 + +生产/物流类可拆为: + +1. 查询 MES 设备 +2. 查询生产安排 +3. 查询车辆信息 +4. 查询未出货出货单 + +## 7. 提问引导建议 + +### 7.1 总原则 + +企业微信侧不应引导用户输入过于自由的问题,而应尽量引导输入“Agent 已支持的字段”。 + +推荐原则: + +1. 一次只问一类问题。 +2. 提示词里直接说明需要提供什么。 +3. 对关键参数给示例。 +4. 不要让用户猜系统支持哪些写法。 + +### 7.2 财务类推荐引导文案 + +#### 查询客户销售记录 + +推荐引导: + +```text +请输入客户名称,例如:查询杭州某客户的销售记录 +``` + +#### 查询客户销退记录 + +推荐引导: + +```text +请输入客户名称,例如:查询杭州某客户的销退记录 +``` + +#### 查询客户收款记录 + +推荐引导: + +```text +请输入客户名称,例如:查询杭州某客户的收款记录 +``` + +#### 查询客户退款记录 + +推荐引导: + +```text +请输入客户名称,例如:查询杭州某客户的退款记录 +``` + +#### 查询客户全部财务记录 + +推荐引导: + +```text +请输入客户名称,例如:查询杭州某客户的财务记录 +``` + +#### 查询挂账或冲减 + +推荐引导: + +```text +请输入客户名称,并明确说明挂账或冲减,例如:查询杭州某客户的收款挂账调整记录 +``` + +### 7.3 生产/物流类推荐引导文案 + +#### 查询 MES 设备 + +推荐引导: + +```text +请输入查询需求,例如:查询当前商户的 MES 设备 +``` + +#### 查询生产安排 + +推荐引导: + +```text +请输入日期范围,例如:查询 2026-05-01 到 2026-05-07 的生产安排 +``` + +#### 查询车辆信息 + +推荐引导: + +```text +请输入车牌号,例如:查询车牌 粤A12345 的车辆信息 +``` + +#### 查询未出货出货单 + +推荐引导: + +```text +请输入地区,例如:查询华东未出货出货单 +``` + +## 8. 建议的应用层预处理 + +企业微信应用侧建议做以下最小预处理: + +1. 注入 `merchant_id`,如果当前入口属于商户业务场景。 +2. 保留用户原始问题,不要做过度改写。 +3. 可以在菜单点击后,补一个简短上下文前缀,例如: + - `当前是财务查询场景:查询杭州某客户的销退记录` + - `当前是生产安排查询场景:查询 2026-05-01 到 2026-05-07 的生产安排` +4. 不要在应用层生成复杂长提示词,避免和 Agent 提示词互相冲突。 + +## 9. 建议的应用层拦截规则 + +以下问题建议在企业微信应用层直接拦截,或者至少标记为“超出当前 Agent 支持范围”: + +1. `帮我新增一条财务记录` +2. `把这个客户的退款改掉` +3. `审批这笔收款` +4. `按季度汇总这个客户的销售和回款` +5. `统计今年每个月的销退趋势` +6. `按地区筛选后累计某客户收款` + +推荐返回说明: + +```text +当前入口只支持记录查询,不支持写入、修改、审批或复杂统计分析。 +``` + +## 10. 建议的调用方式 + +### 10.1 财务查询示例 + +```json +{ + "message": "查询杭州某客户的销退记录" +} +``` + +### 10.2 商户业务查询示例 + +```json +{ + "message": "查询华东未出货出货单", + "merchant_id": 1 +} +``` + +### 10.3 财务能力说明类示例 + +```json +{ + "message": "你可以查询哪些财务数据?是否可以多选?" +} +``` + +期望回答方向: + +1. 支持销售、销退、收款、退款。 +2. 可以单选,也可以多选。 +3. 如果问“财务记录”,可以查全部四类。 + +## 11. 推荐的消息处理流程 + +推荐流程如下: + +1. 用户点击菜单。 +2. 企业微信应用展示该菜单对应的引导语。 +3. 用户输入问题。 +4. 应用层判断是否需要补 `merchant_id`。 +5. 应用层判断是否属于明显超范围请求。 +6. 如果在支持范围内,则调用 `/agent`。 +7. 将 `reply` 直接作为企业微信 markdown 消息发送。 + +## 12. 入口选择建议 + +如果开发组需要判断“应该做几个入口”,建议如下: + +### 方案 A:一个统一输入入口 + +优点: + +1. 技术实现最简单。 +2. 前端交互最少。 + +缺点: + +1. 用户容易提超范围问题。 +2. 参数缺失率会更高。 +3. 结果稳定性较弱。 + +### 方案 B:按业务域拆菜单,共用一个 Agent API + +优点: + +1. 更符合当前 Agent 的能力边界。 +2. 更容易做提问引导。 +3. 回复稳定性更高。 +4. 更适合企业微信菜单场景。 + +缺点: + +1. 菜单设计需要更多前期整理。 + +当前更推荐: + +- 采用方案 B。 + +## 13. 当前接入结论 + +对于企业微信应用开发组,当前最合适的接入方式不是“开放聊天入口”,而是: + +1. 用菜单限制问题范围。 +2. 用引导语约束用户输入。 +3. 用一个统一的 `/agent` 入口承接调用。 +4. 在应用层提前拦截写操作和复杂统计类请求。 + +这样能最大程度发挥当前 Agent 的查询能力,同时避免把不支持的能力暴露成错误体验。 \ No newline at end of file diff --git a/docs/api_v2_mission_api.md b/docs/api_v2_mission_api.md index 1553384..3a8583d 100644 --- a/docs/api_v2_mission_api.md +++ b/docs/api_v2_mission_api.md @@ -279,6 +279,17 @@ 响应:`MissionCategory[]` +`MissionCategory` 当前包含以下字段: + +| 字段 | 类型 | 说明 | +|------|------|------| +| `id` | int | 分类 ID | +| `merchant` | int | 所属商户 ID | +| `name` | string | 分类名称 | +| `payload_processor` | string | payload 增强器标识,未启用时为空字符串 | +| `created_at` | datetime | 创建时间 | +| `updated_at` | datetime | 更新时间 | + ## 创建任务分类 - URL: `/api/v2/mission-categories/` @@ -289,12 +300,14 @@ | 参数 | 类型 | 必填 | 说明 | |------|------|------|------| | `name` | string | 是 | 分类名称,同商户下唯一 | +| `payload_processor` | string | 否 | payload 增强器标识;当前可选值:`structured_description_v1` | 请求示例: ```json { - "name": "售后" + "name": "售后", + "payload_processor": "structured_description_v1" } ``` @@ -317,6 +330,7 @@ | 参数 | 类型 | 说明 | |------|------|------| | `name` | string | 分类名称,同商户下唯一 | +| `payload_processor` | string | payload 增强器标识;传空字符串表示清空 | 成功响应:`MissionCategory` diff --git a/docs/notifier_admin_guide.md b/docs/notifier_admin_guide.md index 266625d..ef38905 100644 --- a/docs/notifier_admin_guide.md +++ b/docs/notifier_admin_guide.md @@ -14,6 +14,22 @@ - `Notifier` 是“发信工具” - `NotifierRoute` 是“分发规则” +## 1.1 当前文档适用范围 + +这份文档只描述“当前已经确认可由后台管理人员自行配置”的 `Notifier` 能力。 + +截至目前,已经稳定确认、并且适合由后台人员自行配置的渠道有: + +- `wecom_webhook` +- `message_api` + +注意: + +- `wecom_webhook` 和 `message_api` 的配置方式不同,不要混用字段。 +- `message_api` 适合发企业微信应用消息,可以发文本,也可以发单篇图文。 +- 当前 ERP 只是 `message_api` 的调用方,不直接管理企业微信凭据。 +- 当前 `message_api` 默认仍由开发组提供模板文件,管理人员主要负责选择正确的 `template_key` 和填写 `config`。 + ## 2. 当前已支持的 mission 事件 目前 `mission` 模块已接入以下事件: @@ -28,6 +44,92 @@ 所有这些事件都支持通过 `NotifierRoute` 进行分类路由。 +## 2.1 任务分类上的 payload 增强器 + +除 `Notifier` 和 `NotifierRoute` 以外,部分任务分类还可以额外配置 `payload 增强器`。 + +它的作用不是决定“发给谁”,而是在发送通知前,先对任务描述做一次固定规则的加工,再把结果交给模板使用。 + +当前已提供的增强器: + +- `structured_description_v1` + +当前配套可直接使用的 `message_api` 模板: + +- `mission_structured_description_text` +- `mission_structured_description_news` + +适用场景: + +- 某些模板不直接消费整段 `任务描述` +- 而是希望从 `任务描述` 中拆出标题、正文、链接这类结构化字段 + +当前 `structured_description_v1` 的处理规则: + +1. 忽略任务描述第一行 +2. 如果某一行以 `款式图:` 开头,则提取该行后面的图片地址为封面图字段,并且该行不再参与标题/正文内容 +3. 如果最后一行里包含 `http://` 或 `https://` 链接,则提取为跳转链接字段,并且该行不再参与正文拆分 +4. 之后按第一个空行拆分:空行前为标题,空行后为正文 +5. 如果没有空行,则剩余内容全部作为标题,正文为空 + +模板可使用的新增字段: + +- `parsed_description_title` +- `parsed_description_body` +- `parsed_description_url` +- `parsed_description_image_url` + +如果你希望直接复用开发组已经准备好的模板,推荐: + +- `channel = message_api` +- `template_key = mission_structured_description_text` + +这个模板会把上面三个字段组织成一条可直接发送的文本消息。 + +如果你希望发送单篇图文消息,可以使用: + +- `channel = message_api` +- `template_key = mission_structured_description_news` + +这个模板除了依赖增强器产出的字段外,还要求在 `Notifier.config` 中填写: + +```json +{ + "agent_ids": [1000007], + "image_url": "https://cdn.example.com/covers/mission-news.png" +} +``` + +可选地也可以填写一个兜底跳转地址: + +```json +{ + "agent_ids": [1000007], + "image_url": "https://cdn.example.com/covers/mission-news.png", + "url": "https://erp.example.com/missions/fallback" +} +``` + +说明: + +- 优先使用任务描述里解析出的链接作为 `news.url` +- 优先使用任务描述里 `款式图:` 解析出的图片地址作为 `news.image_url` +- 如果解析不出链接,则回退到 `Notifier.config.url` +- 如果解析不出图片,则回退到 `Notifier.config.image_url` +- 如果 `Notifier.config.image_url` 也为空,则回退到系统级默认空值图 `MESSAGE_API_DEFAULT_NEWS_IMAGE_URL` + +当前默认值是: + +- `https://via.placeholder.com/640x360.png?text=No+Image` + +如果你们后续有自己的线上空值图,建议在环境变量里覆盖这个默认值,而不是继续依赖外部占位图服务。 + +补充说明: + +- 该增强器是否启用,由任务分类决定 +- 同一个模板可以被多个分类复用,但只有启用了增强器的分类才会得到这些解析字段 +- `mission_id`、`category_name` 等原始审计字段仍然会照常传递 + ## 3. 当前支持的分类路由能力 路由匹配规则如下: @@ -58,6 +160,8 @@ 在 `Notifier` 详情页中,也可以直接通过 inline 管理该通知器下的路由。 +如果需要启用上面的 `payload 增强器`,还需要进入 `任务分类` 管理页,在具体分类上选择对应增强器。 + ## 5. Notifier 字段说明 ### 5.1 merchant @@ -82,9 +186,16 @@ 通知渠道。 -当前固定选: +当前可选: - `wecom_webhook` +- `message_api` + +补充说明: + +- 如果你在后台将来看到新的 channel 选项,不代表它已经进入“可自行配置”的稳定状态。 +- 当前管理人员应只配置已经明确说明过的 channel。 +- 如果要发送企业微信应用消息,请选 `message_api`,不要继续选 `wecom_webhook`。 ### 5.4 template_key @@ -97,9 +208,17 @@ 例如: -- `template_key = mission_completed` +- `wecom_webhook` 渠道下:`template_key = mission_completed` - 对应模板文件:`notifier/templates/notifier/events/mission_completed.md` +- `message_api` 渠道下:`template_key = mission_completed` +- 对应模板文件:`notifier/templates/notifier/events/mission_completed.json` + +可以把它理解为: + +- `.md` 模板:最后会渲染成一段文字 +- `.json` 模板:最后会渲染成一组“结构化消息字段” + ### 5.5 is_enabled 是否启用通知器本体。 @@ -121,6 +240,49 @@ } ``` +说明: + +- `key`:企业微信机器人 webhook key +- `msgtype`:当前建议使用 `markdown` +- `timeout_seconds`:请求超时时间,通常保持默认即可 + +特别提醒: + +- 当前不要自行在 `config` 中增加诸如 `corp_id`、`agent_id`、`secret`、`to_user`、`to_party`、`to_tag` 等字段,除非开发组已经单独通知并提供正式说明。 +- 这些字段不属于当前已经确认可交付给管理人员配置的范围。 + +当前 `message_api` 渠道建议配置: + +文本消息场景: + +```json +{ + "agent_id": 1000007, + "timeout_seconds": 10 +} +``` + +图文消息场景: + +```json +{ + "agent_ids": [1000007, 1000008], + "timeout_seconds": 10 +} +``` + +说明: + +- `agent_id`:企业微信应用 ID,适用于文本消息 +- `agent_ids`:企业微信应用 ID 列表,适用于图文消息 +- `timeout_seconds`:请求超时时间,通常保持默认即可 + +特别提醒: + +- `message_api` 当前不要求管理人员填写任何企业微信系统级配置。 +- 对 ERP 来说,只需要知道 `message_api` 的访问地址和固定 `Authorization`。 +- `message_api` 下应优先使用开发组已提供好的 `template_key`,不要自行猜测 JSON 字段名。 + ### 5.7 description 备注说明,非必填。 @@ -249,6 +411,64 @@ 系统会分别发送到两个群。 +### 8.4 示例四:任务创建时发企业微信应用文本消息 + +适用于: + +- 想发给某一个企业微信应用 +- 内容以一段任务提醒文字为主 + +`Notifier` + +- `name`: `任务创建通知-企业微信应用` +- `channel`: `message_api` +- `template_key`: `mission_created` +- `config`: + +```json +{ + "agent_id": 1000007, + "timeout_seconds": 10 +} +``` + +- `is_enabled`: 勾选 + +`NotifierRoute` + +- `event_key`: `mission.created` +- `mission_category`: 留空 或 选择具体分类 +- `is_enabled`: 勾选 + +### 8.5 示例五:任务创建时发企业微信应用图文消息 + +适用于: + +- 想同时发到多个企业微信应用 +- 希望用户在企业微信里看到标题、摘要、点击链接、封面图 + +`Notifier` + +- `name`: `任务创建图文通知-企业微信应用` +- `channel`: `message_api` +- `template_key`: `test_message_news` +- `config`: + +```json +{ + "agent_ids": [1000007, 1000008], + "timeout_seconds": 10 +} +``` + +- `is_enabled`: 勾选 + +`NotifierRoute` + +- `event_key`: `mission.created` +- `mission_category`: 留空 或 选择具体分类 +- `is_enabled`: 勾选 + ## 9. 模板如何对应 当前系统已内置以下模板: @@ -260,10 +480,88 @@ - `mission_reply_rejected` - `mission_reopened` - `mission_cancelled` +- `test_message_news` 管理人员通常只需要填 `template_key`,不需要改代码。 如果后续要新增模板内容或调整文案,需要由开发人员修改模板文件。 +补充说明: + +- 当前这套 admin 配置说明默认基于“模板文件”模式。 +- `wecom_webhook` 使用 `.md` 模板文件。 +- `message_api` 使用 `.json` 模板文件。 +- 在新的正式说明发布前,管理人员不要自行推断未文档化的模板字段。 + +### 9.1 `message_api` 的 JSON 模板到底长什么样 + +这部分是为了帮助管理人员“看懂模板的大致样子”,不是要求你在后台手工编写模板。 + +可以把 JSON 模板理解为: + +- 它不是程序代码 +- 它更像一张“字段清单” +- 系统会把里面的变量替换成真正的任务内容 + +#### 文本消息模板示例 + +例如 `mission_created.json` 大致会渲染成: + +```json +{ + "msgtype": "text", + "content": "任务已创建\n任务ID:123\n创建人:张三\n分类:售后\n紧急:否\n参与人:李四、王五\n说明:请跟进客户退货" +} +``` + +用更容易理解的话说: + +- `msgtype = text`:表示这是一条文本消息 +- `content`:表示真正发出去的文字内容 + +这类模板适合: + +- 直接提醒 +- 内容以文字为主 +- 不需要点击封面图和链接 + +#### 图文消息模板示例 + +例如 `test_message_news.json` 大致会渲染成: + +```json +{ + "msgtype": "news", + "title": "任务 123 通知", + "description": "请跟进客户退货", + "url": "https://example.com/missions/123", + "image_url": "https://example.com/static/mission-cover.png" +} +``` + +用更容易理解的话说: + +- `msgtype = news`:表示这是一条单篇图文消息 +- `title`:企业微信里显示的标题 +- `description`:企业微信里显示的摘要 +- `url`:用户点击后打开的链接 +- `image_url`:封面图地址 + +这类模板适合: + +- 需要点击查看详情 +- 需要更像“卡片消息”的展示 +- 想同时发到多个企业微信应用 + +### 9.2 管理人员最需要记住什么 + +对于 `message_api`,管理人员通常只要记住下面几件事: + +1. 文本消息用 `agent_id` +2. 图文消息用 `agent_ids` +3. `template_key` 要和开发组给出的模板名一致 +4. 不要自己修改 JSON 字段名 +5. 如果不确定是文本还是图文,先问开发组,不要猜 + ## 10. 未回复提醒的 admin 配置要点 `mission.unreplied` 和其它事件不同,它不是在某个瞬时动作发生时触发,而是由后台每分钟扫描一次“仍未回复的任务”后触发。 @@ -352,6 +650,22 @@ 6. `config.key` 是否填写正确 7. Celery worker 是否已启动 +### 11.3 为什么 `message_api` 没有更多系统配置项 + +原因是: + +1. 当前 ERP 只是 `message_api` 的调用方。 +2. 企业微信真正的凭据管理和发送细节不属于 ERP 负责。 +3. 因此后台管理人员只需要配置通知器自己的参数,例如 `agent_id`、`agent_ids`、`template_key`。 + +系统级配置由部署环境统一提供,例如: + +1. `MESSAGE_API_BASE_URL` +2. `MESSAGE_API_AUTHORIZATION` +3. `MESSAGE_API_DEFAULT_NEWS_IMAGE_URL` + +如果后续 `message_api` 的接口契约扩展,开发组会补充新的配置说明。 + ### 11.2 为什么某个任务分类没有走专门群 常见原因: diff --git a/docs/notifier_module_design.md b/docs/notifier_module_design.md index cc7571d..c2497d3 100644 --- a/docs/notifier_module_design.md +++ b/docs/notifier_module_design.md @@ -25,6 +25,7 @@ - 独立 app:`notifier` - Celery task 化投递 - 企业微信 webhook 渠道 +- `message_api` 渠道(ERP 作为调用方) - 模板化内容渲染 - Admin 可配置 - `NotifierRoute` 事件路由 @@ -33,7 +34,6 @@ 本阶段仍未做: - 旧模块静态通知逻辑迁移 -- 外部 API - 通知投递明细表 - 数据库级别审计 @@ -151,7 +151,26 @@ `template_key` 如果写错,会在渲染阶段报错并记录日志。 后续可在 admin 或 model clean 中增强校验。 -## 12. 当前结论 +## 12. `message_api` 渠道边界 + +`message_api` 的定位是: + +- ERP / notifier 只负责渲染结构化消息模板并调用内部 `message_api` +- ERP 不直接管理企业微信 `corp_id`、`secret`、`access_token` +- ERP 不直接调用企业微信官方 API + +当前实现方式: + +- `message_api` channel 使用 `.json` 模板 +- backend 会校验模板渲染结果是否符合 text/news 结构 +- 之后由 notifier 作为 HTTP client 调用 `MESSAGE_API_BASE_URL` + +这意味着: + +- `agent_id` / `agent_ids` 仍然属于 channel 级配置 +- 企业微信系统级凭据属于 `message_api` 服务自身,不属于 ERP 配置 + +## 13. 当前结论 当前 `notifier` 已具备: diff --git a/env.example b/env.example index aa39ca9..4c03056 100644 --- a/env.example +++ b/env.example @@ -71,6 +71,14 @@ TENCENTCLOUD_TIIA_QPS=10 ############################ # 为空时发送会报错:WeCom webhook key 未配置 WECOM_WEBHOOK_KEY= +MESSAGE_API_AUTHORIZATION=hophopkk +# 按你的部署环境填写 message_api 服务地址。 +# 如果当前 Django 运行在 Docker 容器内,不要直接写 localhost, +# 应填写可从容器内访问到的服务地址,例如 http://message-api:8198 +MESSAGE_API_BASE_URL= +# message_api 图文消息封面图兜底地址。 +# 当任务描述里没有“款式图:...”,且 Notifier.config.image_url 也为空时使用。 +MESSAGE_API_DEFAULT_NEWS_IMAGE_URL=https://via.placeholder.com/640x360.png?text=No+Image SPEAK_ENDPOINT=http://8.148.215.233:9004/speak # PrintingJob 状态推进通知的“跟进地址”模板;为空则消息里省略“跟进地址”字段 diff --git a/flower-app_latest.tar.gz b/flower-app_latest.tar.gz index 76cfc05..8443872 100644 Binary files a/flower-app_latest.tar.gz and b/flower-app_latest.tar.gz differ diff --git a/flower/settings.py b/flower/settings.py index a206a2e..33e0d70 100644 --- a/flower/settings.py +++ b/flower/settings.py @@ -84,6 +84,12 @@ TENCENTCLOUD_TIIA_QPS = env.int('TENCENTCLOUD_TIIA_QPS', default=10) # Tencent # 使用:https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxxx WECOM_WEBHOOK_BASE_URL = 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send' WECOM_WEBHOOK_KEY = env('WECOM_WEBHOOK_KEY', default='') +MESSAGE_API_AUTHORIZATION = env('MESSAGE_API_AUTHORIZATION', default='hophopkk') +MESSAGE_API_BASE_URL = env('MESSAGE_API_BASE_URL', default='https://www.ruicaiyinhua.online') +MESSAGE_API_DEFAULT_NEWS_IMAGE_URL = env( + 'MESSAGE_API_DEFAULT_NEWS_IMAGE_URL', + default='https://via.placeholder.com/640x360.png?text=No+Image', +) SPEAK_ENDPOINT = env('SPEAK_ENDPOINT', default='http://8.148.215.233:9004/speak') PRINTING_ORDER_CREATED_SPEECH_ENABLED = False AGENT_ACCESS_KEY = env('AGENT_ACCESS_KEY', default='') diff --git a/flower/settings_test.py b/flower/settings_test.py new file mode 100644 index 0000000..59a76db --- /dev/null +++ b/flower/settings_test.py @@ -0,0 +1,43 @@ +from .settings import * + + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": ":memory:", + } +} + +CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + "LOCATION": "notifier-tests", + } +} + +STORAGES = { + "default": { + "BACKEND": "django.core.files.storage.FileSystemStorage", + }, + "staticfiles": { + "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage", + }, +} + +DEFAULT_FILE_STORAGE = "django.core.files.storage.FileSystemStorage" +STATICFILES_STORAGE = "django.contrib.staticfiles.storage.StaticFilesStorage" + +PASSWORD_HASHERS = [ + "django.contrib.auth.hashers.MD5PasswordHasher", +] + + +class DisableMigrations(dict): + def __contains__(self, item): + return True + + def __getitem__(self, item): + return None + + +MIGRATION_MODULES = DisableMigrations() \ No newline at end of file diff --git a/mission/admin.py b/mission/admin.py index 4a7ba19..52f6551 100644 --- a/mission/admin.py +++ b/mission/admin.py @@ -5,8 +5,8 @@ from mission.models import Mission, MissionCategory, MissionParticipant, Mission @admin.register(MissionCategory) class MissionCategoryAdmin(admin.ModelAdmin): - list_display = ["id", "merchant", "name", "created_at"] - list_filter = ["merchant", "created_at"] + list_display = ["id", "merchant", "name", "payload_processor", "created_at"] + list_filter = ["merchant", "payload_processor", "created_at"] search_fields = ["name"] readonly_fields = ["created_at", "updated_at"] diff --git a/mission/handlers.py b/mission/handlers.py index 4d98e35..37596bd 100644 --- a/mission/handlers.py +++ b/mission/handlers.py @@ -1,5 +1,6 @@ import logging +from mission.payload_processors import apply_mission_payload_processor from notifier.models import NotificationEventKeyEnum from notifier.services import enqueue_notification_event @@ -26,6 +27,7 @@ def _build_mission_payload(mission) -> dict: "participant_names_display": "、".join(participant_names) if participant_names else "无", "content_type": content_type or "", "content_id": mission.content_id or "", + "payload_processor": getattr(mission.category, "payload_processor", "") or "", } @@ -58,11 +60,15 @@ def _enqueue(*, event_key: str, merchant_id: int, payload: dict) -> None: def on_mission_created(sender, instance, created_by=None, **kwargs): - payload = { - **_build_mission_payload(instance), - "created_by_id": getattr(created_by, "id", None), - "created_by_name": getattr(created_by, "name", ""), - } + payload = apply_mission_payload_processor( + mission=instance, + event_key=NotificationEventKeyEnum.MISSION_CREATED, + payload={ + **_build_mission_payload(instance), + "created_by_id": getattr(created_by, "id", None), + "created_by_name": getattr(created_by, "name", ""), + }, + ) _enqueue( event_key=NotificationEventKeyEnum.MISSION_CREATED, merchant_id=instance.merchant_id, @@ -72,12 +78,16 @@ def on_mission_created(sender, instance, created_by=None, **kwargs): def on_mission_replied(sender, instance, mission=None, responder=None, **kwargs): mission = mission or instance.mission - payload = { - **_build_mission_payload(mission), - **_build_reply_payload(instance), - "responder_id": getattr(responder, "id", None), - "responder_name": getattr(responder, "name", ""), - } + payload = apply_mission_payload_processor( + mission=mission, + event_key=NotificationEventKeyEnum.MISSION_REPLIED, + payload={ + **_build_mission_payload(mission), + **_build_reply_payload(instance), + "responder_id": getattr(responder, "id", None), + "responder_name": getattr(responder, "name", ""), + }, + ) _enqueue( event_key=NotificationEventKeyEnum.MISSION_REPLIED, merchant_id=mission.merchant_id, @@ -93,6 +103,11 @@ def on_mission_completed(sender, instance, completed_by=None, reply=None, **kwar } if reply is not None: payload.update(_build_reply_payload(reply)) + payload = apply_mission_payload_processor( + mission=instance, + event_key=NotificationEventKeyEnum.MISSION_COMPLETED, + payload=payload, + ) _enqueue( event_key=NotificationEventKeyEnum.MISSION_COMPLETED, merchant_id=instance.merchant_id, @@ -102,13 +117,17 @@ def on_mission_completed(sender, instance, completed_by=None, reply=None, **kwar def on_mission_reply_rejected(sender, instance, mission=None, rejected_by=None, reason=None, **kwargs): mission = mission or instance.mission - payload = { - **_build_mission_payload(mission), - **_build_reply_payload(instance), - "rejected_by_id": getattr(rejected_by, "id", None), - "rejected_by_name": getattr(rejected_by, "name", ""), - "reason": reason or "", - } + payload = apply_mission_payload_processor( + mission=mission, + event_key=NotificationEventKeyEnum.MISSION_REPLY_REJECTED, + payload={ + **_build_mission_payload(mission), + **_build_reply_payload(instance), + "rejected_by_id": getattr(rejected_by, "id", None), + "rejected_by_name": getattr(rejected_by, "name", ""), + "reason": reason or "", + }, + ) _enqueue( event_key=NotificationEventKeyEnum.MISSION_REPLY_REJECTED, merchant_id=mission.merchant_id, @@ -117,13 +136,17 @@ def on_mission_reply_rejected(sender, instance, mission=None, rejected_by=None, def on_mission_reopened(sender, instance, reopened_by=None, rejected_reply_ids=None, **kwargs): - payload = { - **_build_mission_payload(instance), - "reopened_by_id": getattr(reopened_by, "id", None), - "reopened_by_name": getattr(reopened_by, "name", ""), - "rejected_reply_ids": rejected_reply_ids or [], - "rejected_reply_ids_display": ", ".join(str(reply_id) for reply_id in (rejected_reply_ids or [])) or "无", - } + payload = apply_mission_payload_processor( + mission=instance, + event_key=NotificationEventKeyEnum.MISSION_REOPENED, + payload={ + **_build_mission_payload(instance), + "reopened_by_id": getattr(reopened_by, "id", None), + "reopened_by_name": getattr(reopened_by, "name", ""), + "rejected_reply_ids": rejected_reply_ids or [], + "rejected_reply_ids_display": ", ".join(str(reply_id) for reply_id in (rejected_reply_ids or [])) or "无", + }, + ) _enqueue( event_key=NotificationEventKeyEnum.MISSION_REOPENED, merchant_id=instance.merchant_id, @@ -132,12 +155,16 @@ def on_mission_reopened(sender, instance, reopened_by=None, rejected_reply_ids=N def on_mission_cancelled(sender, instance, cancelled_by=None, **kwargs): - payload = { - **_build_mission_payload(instance), - "cancelled_by_id": getattr(cancelled_by, "id", None), - "cancelled_by_name": getattr(cancelled_by, "name", ""), - "cancelled_at": instance.cancelled_at.isoformat() if instance.cancelled_at else "", - } + payload = apply_mission_payload_processor( + mission=instance, + event_key=NotificationEventKeyEnum.MISSION_CANCELLED, + payload={ + **_build_mission_payload(instance), + "cancelled_by_id": getattr(cancelled_by, "id", None), + "cancelled_by_name": getattr(cancelled_by, "name", ""), + "cancelled_at": instance.cancelled_at.isoformat() if instance.cancelled_at else "", + }, + ) _enqueue( event_key=NotificationEventKeyEnum.MISSION_CANCELLED, merchant_id=instance.merchant_id, diff --git a/mission/migrations/0007_missioncategory_payload_processor.py b/mission/migrations/0007_missioncategory_payload_processor.py new file mode 100644 index 0000000..a80c2bf --- /dev/null +++ b/mission/migrations/0007_missioncategory_payload_processor.py @@ -0,0 +1,22 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("mission", "0006_mission_and_reply_extra"), + ] + + operations = [ + migrations.AddField( + model_name="missioncategory", + name="payload_processor", + field=models.CharField( + blank=True, + choices=[("structured_description_v1", "结构化描述增强(v1)")], + default="", + max_length=50, + verbose_name="payload 增强器", + ), + ), + ] \ No newline at end of file diff --git a/mission/models.py b/mission/models.py index f5bacf7..7e5d55e 100644 --- a/mission/models.py +++ b/mission/models.py @@ -6,6 +6,10 @@ from django.utils import timezone from flower.common import ModelBase +class MissionPayloadProcessorEnum(models.TextChoices): + STRUCTURED_DESCRIPTION_V1 = "structured_description_v1", "结构化描述增强(v1)" + + class MissionCategory(ModelBase): """任务分类字典,按商户隔离。""" @@ -17,6 +21,13 @@ class MissionCategory(ModelBase): verbose_name="所属商户", ) name = models.CharField(max_length=50, verbose_name="分类名称") + payload_processor = models.CharField( + max_length=50, + blank=True, + default="", + choices=MissionPayloadProcessorEnum.choices, + verbose_name="payload 增强器", + ) def __str__(self): return self.name diff --git a/mission/payload_processors.py b/mission/payload_processors.py new file mode 100644 index 0000000..de6daa6 --- /dev/null +++ b/mission/payload_processors.py @@ -0,0 +1,86 @@ +import re + +from mission.models import MissionPayloadProcessorEnum + + +STRUCTURED_DESCRIPTION_IMAGE_PREFIX = "款式图:" + + +def _extract_labeled_image_url(lines: list[str]) -> tuple[list[str], str]: + kept_lines = [] + image_url = "" + for line in lines: + stripped_line = line.strip() + if stripped_line.startswith(STRUCTURED_DESCRIPTION_IMAGE_PREFIX): + candidate = stripped_line.removeprefix(STRUCTURED_DESCRIPTION_IMAGE_PREFIX).strip() + if candidate: + image_url = candidate + continue + kept_lines.append(line) + return kept_lines, image_url + + +def _split_structured_description(description: str) -> tuple[str, str, str, str]: + lines = [line.strip() for line in (description or "").splitlines()] + if not lines: + return "", "", "", "" + + body_lines = lines[1:] if len(lines) > 1 else [] + while body_lines and not body_lines[0]: + body_lines.pop(0) + + body_lines, image_url = _extract_labeled_image_url(body_lines) + + url = "" + if body_lines: + last_line = body_lines[-1] + match = re.search(r"https?://\S+", last_line) + if match is not None: + url = match.group(0) + body_lines = body_lines[:-1] + + while body_lines and not body_lines[-1]: + body_lines.pop() + + split_index = next((index for index, line in enumerate(body_lines) if not line), None) + if split_index is None: + title_lines = body_lines + description_lines = [] + else: + title_lines = body_lines[:split_index] + description_lines = body_lines[split_index + 1 :] + while description_lines and not description_lines[0]: + description_lines.pop(0) + + title = "\n".join(title_lines).strip() + parsed_description = "\n".join(description_lines).strip() + return title, parsed_description, url, image_url + + +def enhance_structured_description_v1(*, mission, payload: dict, event_key: str) -> dict: + title, parsed_description, url, image_url = _split_structured_description(mission.description) + return { + **payload, + "parsed_description_title": title, + "parsed_description_body": parsed_description, + "parsed_description_url": url, + "parsed_description_image_url": image_url, + } + + +PAYLOAD_PROCESSORS = { + MissionPayloadProcessorEnum.STRUCTURED_DESCRIPTION_V1: enhance_structured_description_v1, +} + + +def apply_mission_payload_processor(*, mission, payload: dict, event_key: str) -> dict: + processor_key = getattr(mission.category, "payload_processor", "") or "" + if not processor_key: + return payload + + processor = PAYLOAD_PROCESSORS.get(processor_key) + if processor is None: + return payload + + enhanced_payload = processor(mission=mission, payload=payload, event_key=event_key) + return enhanced_payload if isinstance(enhanced_payload, dict) else payload \ No newline at end of file diff --git a/mission/tests.py b/mission/tests.py index 3a75d16..2dd8615 100644 --- a/mission/tests.py +++ b/mission/tests.py @@ -1,5 +1,6 @@ from django.test import TestCase from django.contrib.contenttypes.models import ContentType +from types import SimpleNamespace from unittest.mock import patch from django.utils import timezone @@ -23,6 +24,9 @@ from mission.signals import ( mission_replied, mission_reply_rejected, ) +from mission.payload_processors import _split_structured_description +from notifier.models import NotificationEventKeyEnum, Notifier, NotifierChannelEnum, NotifierRoute +from notifier.services import dispatch_notification_event class MissionModelTestCase(TestCase): @@ -546,6 +550,26 @@ class MissionModelTestCase(TestCase): self.assertEqual(received, [(Mission, self.mission.id, self.creator.id)]) + def test_split_structured_description_v1(self): + title, body, url, image_url = _split_structured_description( + "系统首行\n标题一\n款式图:https://images.yuwen.cloud/abc.jpg\n标题二\n\n正文一\n正文二\n手机端链接: https://example.com/detail" + ) + + self.assertEqual(title, "标题一\n标题二") + self.assertEqual(body, "正文一\n正文二") + self.assertEqual(url, "https://example.com/detail") + self.assertEqual(image_url, "https://images.yuwen.cloud/abc.jpg") + + def test_split_structured_description_v1_without_blank_line_uses_title_only(self): + title, body, url, image_url = _split_structured_description( + "系统首行\n标题一\n款式图:https://images.yuwen.cloud/abc.jpg\n标题二\n手机端链接: https://example.com/detail" + ) + + self.assertEqual(title, "标题一\n标题二") + self.assertEqual(body, "") + self.assertEqual(url, "https://example.com/detail") + self.assertEqual(image_url, "https://images.yuwen.cloud/abc.jpg") + @patch("mission.handlers.enqueue_notification_event") def test_mission_created_handler_enqueues_notifier_event(self, mock_enqueue): with self.captureOnCommitCallbacks(execute=True): @@ -556,6 +580,28 @@ class MissionModelTestCase(TestCase): self.assertEqual(mock_enqueue.call_args.kwargs["merchant_id"], self.merchant.id) self.assertEqual(mock_enqueue.call_args.kwargs["payload"]["mission_id"], mission.id) + @patch("mission.handlers.enqueue_notification_event") + def test_mission_created_handler_applies_category_payload_processor(self, mock_enqueue): + self.default_category.payload_processor = "structured_description_v1" + self.default_category.save(update_fields=["payload_processor", "updated_at"]) + + with self.captureOnCommitCallbacks(execute=True): + mission = create_mission( + creator=self.creator, + description=( + "系统首行\n标题一\n款式图:https://images.yuwen.cloud/abc.jpg\n标题二\n\n正文一\n正文二\n" + "手机端链接: https://example.com/detail" + ), + ) + + payload = mock_enqueue.call_args.kwargs["payload"] + self.assertEqual(payload["mission_id"], mission.id) + self.assertEqual(payload["payload_processor"], "structured_description_v1") + self.assertEqual(payload["parsed_description_title"], "标题一\n标题二") + self.assertEqual(payload["parsed_description_body"], "正文一\n正文二") + self.assertEqual(payload["parsed_description_url"], "https://example.com/detail") + self.assertEqual(payload["parsed_description_image_url"], "https://images.yuwen.cloud/abc.jpg") + @patch("mission.handlers.enqueue_notification_event") def test_create_ending_reply_handler_enqueues_replied_and_completed_notifications(self, mock_enqueue): with self.captureOnCommitCallbacks(execute=True): @@ -614,3 +660,67 @@ class MissionModelTestCase(TestCase): mock_enqueue.assert_called_once() self.assertEqual(mock_enqueue.call_args.kwargs["event_key"], "mission.cancelled") self.assertEqual(mock_enqueue.call_args.kwargs["payload"]["mission_id"], self.mission.id) + + @patch("notifier.backends.send_message_api_news_to_agents") + @patch("notifier.tasks.dispatch_notification_event_task.delay") + def test_mission_created_can_flow_to_message_api_news_with_structured_description_template( + self, + mock_delay, + mock_send_news, + ): + self.default_category.payload_processor = "structured_description_v1" + self.default_category.save(update_fields=["payload_processor", "updated_at"]) + notifier = Notifier.objects.create( + merchant=self.merchant, + name="结构化描述图文通知", + channel=NotifierChannelEnum.MESSAGE_API, + template_key="mission_structured_description_news", + config={ + "agent_ids": [1000007], + "image_url": "https://cdn.example.com/covers/mission-news.png", + }, + ) + NotifierRoute.objects.create( + merchant=self.merchant, + notifier=notifier, + event_key=NotificationEventKeyEnum.MISSION_CREATED, + mission_category=self.default_category, + ) + mock_send_news.return_value = [ + { + "agent_id": 1000007, + "ok": True, + "response": {"errcode": 0, "errmsg": "ok"}, + "error": None, + } + ] + + def inline_delay(*, event_key, merchant_id, payload): + dispatch_notification_event( + event_key=event_key, + merchant_id=merchant_id, + payload=payload, + ) + return SimpleNamespace(id="task-inline-1") + + mock_delay.side_effect = inline_delay + + with self.captureOnCommitCallbacks(execute=True): + create_mission( + creator=self.creator, + category=self.default_category, + description=( + "系统首行\n标题一\n款式图:https://images.yuwen.cloud/abc.jpg\n标题二\n\n正文一\n正文二\n" + "手机端链接: https://example.com/detail" + ), + ) + + mock_send_news.assert_called_once() + self.assertEqual(mock_send_news.call_args.kwargs["agent_ids"], [1000007]) + self.assertEqual(mock_send_news.call_args.kwargs["title"], "标题一\n标题二") + self.assertEqual(mock_send_news.call_args.kwargs["description"], "正文一\n正文二") + self.assertEqual(mock_send_news.call_args.kwargs["url"], "https://example.com/detail") + self.assertEqual( + mock_send_news.call_args.kwargs["image_url"], + "https://images.yuwen.cloud/abc.jpg", + ) diff --git a/notifier/backends.py b/notifier/backends.py index 0c10990..433df7f 100644 --- a/notifier/backends.py +++ b/notifier/backends.py @@ -1,7 +1,14 @@ import logging +import json from api_v1.utils.wecom_webhook import send_wecom_webhook_message from notifier.models import NotifierChannelEnum +from notifier.message_api import send_message_api_news_to_agents, send_message_api_text_message +from notifier.serializers import ( + MessageNewsRequestSerializer, + MessageTemplatePayloadSerializer, + MessageTextRequestSerializer, +) logger = logging.getLogger(__name__) @@ -17,6 +24,9 @@ class WeComWebhookNotifierBackend(BaseNotifierBackend): channel = NotifierChannelEnum.WECOM_WEBHOOK def notify(self, *, notifier, content: str, context: dict) -> dict: + content = (content or "").strip() + if not content: + raise ValueError("企业微信机器人消息 content 不能为空") msgtype = str(notifier.get_config_value("msgtype", "markdown") or "markdown").strip().lower() timeout_seconds = float(notifier.get_config_value("timeout_seconds", 10.0) or 10.0) key = str(notifier.get_config_value("key", "") or "").strip() @@ -36,3 +46,89 @@ class WeComWebhookNotifierBackend(BaseNotifierBackend): } logger.info("[notifier.backends] wecom webhook sent: notifier_id=%s result=%s", notifier.id, result) return result + + +class MessageAPINotifierBackend(BaseNotifierBackend): + channel = NotifierChannelEnum.MESSAGE_API + + def _load_rendered_payload(self, *, content: str) -> dict: + content = (content or "").strip() + if not content: + raise ValueError("消息发送 API 模板渲染结果不能为空") + try: + payload = json.loads(content) + except json.JSONDecodeError as exc: + raise ValueError(f"消息发送 API 模板渲染结果不是合法 JSON: {exc}") from exc + if not isinstance(payload, dict): + raise ValueError("消息发送 API 模板渲染结果必须是 JSON 对象") + + serializer = MessageTemplatePayloadSerializer(data=payload) + serializer.is_valid(raise_exception=True) + return serializer.validated_data + + def notify(self, *, notifier, content: str, context: dict) -> dict: + rendered_payload = self._load_rendered_payload(content=content) + msgtype = rendered_payload["msgtype"] + + if msgtype == "text": + payload = { + "agent_id": rendered_payload.get("agent_id", notifier.get_config_value("agent_id")), + "content": rendered_payload["content"], + } + resolved_user_ids = rendered_payload.get("user_ids") or notifier.get_config_value("user_ids") + if resolved_user_ids: + payload["user_ids"] = resolved_user_ids + serializer = MessageTextRequestSerializer(data=payload) + serializer.is_valid(raise_exception=True) + data = serializer.validated_data + response = send_message_api_text_message( + agent_id=data["agent_id"], + content=data["content"], + user_ids=data.get("user_ids"), + timeout_seconds=float(notifier.get_config_value("timeout_seconds", 10.0) or 10.0), + ) + result = { + "channel": self.channel, + "msgtype": "text", + "agent_id": data["agent_id"], + "errcode": response.errcode, + "errmsg": response.errmsg, + } + logger.info("[notifier.backends] message api text sent: notifier_id=%s result=%s", notifier.id, result) + return result + + payload = { + "agent_ids": rendered_payload.get("agent_ids", notifier.get_config_value("agent_ids")), + "title": rendered_payload["title"], + "description": rendered_payload["description"], + "url": rendered_payload["url"], + "image_url": rendered_payload["image_url"], + } + resolved_user_ids = rendered_payload.get("user_ids") or notifier.get_config_value("user_ids") + if resolved_user_ids: + payload["user_ids"] = resolved_user_ids + serializer = MessageNewsRequestSerializer(data=payload) + serializer.is_valid(raise_exception=True) + data = serializer.validated_data + results = send_message_api_news_to_agents( + agent_ids=data["agent_ids"], + title=data["title"], + description=data["description"], + url=data["url"], + image_url=data["image_url"], + user_ids=data.get("user_ids"), + timeout_seconds=float(notifier.get_config_value("timeout_seconds", 10.0) or 10.0), + ) + if not any(item.get("ok") for item in results): + raise RuntimeError("All agent sends failed") + + result = { + "channel": self.channel, + "msgtype": "news", + "agent_ids": data["agent_ids"], + "ok_count": sum(1 for item in results if item.get("ok")), + "total_count": len(results), + "results": results, + } + logger.info("[notifier.backends] message api news sent: notifier_id=%s result=%s", notifier.id, result) + return result diff --git a/notifier/message_api.py b/notifier/message_api.py new file mode 100644 index 0000000..d7ad496 --- /dev/null +++ b/notifier/message_api.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass +from urllib.parse import urljoin +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + +from django.conf import settings + + +@dataclass(frozen=True) +class MessageAPIResponse: + errcode: int + errmsg: str + raw: dict + + @property + def ok(self) -> bool: + return int(self.errcode) == 0 + + +def _request_json( + *, + url: str, + method: str = "GET", + payload: dict | None = None, + timeout_seconds: float = 10.0, + headers: dict | None = None, +) -> dict: + data = None + request_headers = dict(headers or {}) + if payload is not None: + data = json.dumps(payload, ensure_ascii=False).encode("utf-8") + request_headers.setdefault("Content-Type", "application/json") + + req = Request(url=url, data=data, headers=request_headers, method=method) + try: + with urlopen(req, timeout=float(timeout_seconds)) as resp: + body = resp.read().decode("utf-8", errors="replace") + except HTTPError as exc: + body = "" + try: + body = exc.read().decode("utf-8", errors="replace") + except Exception: + pass + raise RuntimeError(f"Message API HTTPError: status={exc.code}, body={body}") from exc + except URLError as exc: + raise RuntimeError(f"Message API URLError: {exc}") from exc + + try: + return json.loads(body) if body else {} + except Exception as exc: + raise RuntimeError(f"Message API 响应不是合法 JSON: {body}") from exc + + +def _get_message_api_base_url() -> str: + base_url = str(getattr(settings, "MESSAGE_API_BASE_URL", "") or "").strip().rstrip("/") + if not base_url: + raise ValueError("MESSAGE_API_BASE_URL 未配置") + return base_url + + +def _get_message_api_authorization() -> str: + authorization = str(getattr(settings, "MESSAGE_API_AUTHORIZATION", "") or "").strip() + if not authorization: + raise ValueError("MESSAGE_API_AUTHORIZATION 未配置") + return authorization + + +def _build_url(path: str) -> str: + return urljoin(f"{_get_message_api_base_url()}/", path.lstrip("/")) + + +def _post_message_api(*, path: str, payload: dict, timeout_seconds: float) -> dict: + raw = _request_json( + url=_build_url(path), + method="POST", + payload=payload, + timeout_seconds=timeout_seconds, + headers={ + "Authorization": _get_message_api_authorization(), + "Content-Type": "application/json", + }, + ) + return raw + + +def send_message_api_text_message( + *, + agent_id: int, + content: str, + user_ids: list[str] | None = None, + timeout_seconds: float = 10.0, +) -> MessageAPIResponse: + payload = { + "agent_id": int(agent_id), + "content": str(content or "").strip(), + } + if user_ids: + payload["user_ids"] = user_ids + + raw = _post_message_api(path="/api/message/send", payload=payload, timeout_seconds=timeout_seconds) + return MessageAPIResponse( + errcode=int(raw.get("errcode") or 0), + errmsg=str(raw.get("errmsg") or ""), + raw=raw, + ) + + +def send_message_api_news_to_agents( + *, + agent_ids: list[int], + title: str, + description: str, + url: str, + image_url: str, + user_ids: list[str] | None = None, + timeout_seconds: float = 10.0, +) -> list[dict]: + payload = { + "agent_ids": [int(agent_id) for agent_id in agent_ids], + "title": title, + "description": description, + "url": url, + "image_url": image_url, + } + if user_ids: + payload["user_ids"] = user_ids + + raw = _post_message_api(path="/api/message/send/news", payload=payload, timeout_seconds=timeout_seconds) + return raw.get("results") or [] \ No newline at end of file diff --git a/notifier/migrations/0003_alter_notifier_channel_alter_notifierroute_event_key.py b/notifier/migrations/0003_alter_notifier_channel_alter_notifierroute_event_key.py new file mode 100644 index 0000000..a0db199 --- /dev/null +++ b/notifier/migrations/0003_alter_notifier_channel_alter_notifierroute_event_key.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.8 on 2026-05-13 12:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('notifier', '0002_notifierroute_refactor'), + ] + + operations = [ + migrations.AlterField( + model_name='notifier', + name='channel', + field=models.CharField(choices=[('wecom_webhook', '企业微信机器人'), ('message_api', '消息发送 API')], default='wecom_webhook', max_length=50, verbose_name='通知渠道'), + ), + migrations.AlterField( + model_name='notifierroute', + name='event_key', + field=models.CharField(choices=[('mission.created', '任务已创建'), ('mission.replied', '任务有新回应'), ('mission.completed', '任务已完成'), ('mission.unreplied', '任务未回复提醒'), ('mission.reply_rejected', '任务回应已撤销'), ('mission.reopened', '任务已重新打开'), ('mission.cancelled', '任务已取消')], db_index=True, max_length=100, verbose_name='事件标识'), + ), + ] diff --git a/notifier/models.py b/notifier/models.py index 65b7ac6..4931ee5 100644 --- a/notifier/models.py +++ b/notifier/models.py @@ -7,6 +7,7 @@ from flower.common import ModelBase class NotifierChannelEnum(models.TextChoices): WECOM_WEBHOOK = "wecom_webhook", "企业微信机器人" + MESSAGE_API = "message_api", "消息发送 API" class NotificationEventKeyEnum(models.TextChoices): @@ -40,7 +41,8 @@ class Notifier(ModelBase): description = models.TextField(blank=True, null=True, verbose_name="备注描述") def get_template_name(self) -> str: - return f"notifier/events/{self.template_key}.md" + suffix = "json" if self.channel == NotifierChannelEnum.MESSAGE_API else "md" + return f"notifier/events/{self.template_key}.{suffix}" def get_config_value(self, key: str, default=None): config = self.config or {} diff --git a/notifier/registry.py b/notifier/registry.py index 2a77487..398a4a2 100644 --- a/notifier/registry.py +++ b/notifier/registry.py @@ -1,9 +1,10 @@ -from notifier.backends import WeComWebhookNotifierBackend +from notifier.backends import MessageAPINotifierBackend, WeComWebhookNotifierBackend from notifier.models import NotifierChannelEnum BACKEND_REGISTRY = { NotifierChannelEnum.WECOM_WEBHOOK: WeComWebhookNotifierBackend, + NotifierChannelEnum.MESSAGE_API: MessageAPINotifierBackend, } diff --git a/notifier/serializers.py b/notifier/serializers.py new file mode 100644 index 0000000..ce9a6b6 --- /dev/null +++ b/notifier/serializers.py @@ -0,0 +1,99 @@ +from rest_framework import serializers + + +def _normalize_user_ids(values: list[str] | None) -> list[str]: + normalized = [] + for value in values or []: + item = str(value or "").strip() + if not item: + raise serializers.ValidationError("user_ids 中不能包含空值") + normalized.append(item) + return normalized + + +class MessageTextRequestSerializer(serializers.Serializer): + agent_id = serializers.IntegerField(min_value=1) + content = serializers.CharField(allow_blank=False, trim_whitespace=True) + user_ids = serializers.ListField( + child=serializers.CharField(allow_blank=False, trim_whitespace=True), + required=False, + allow_empty=True, + ) + + def validate_content(self, value: str) -> str: + content = (value or "").strip() + if not content: + raise serializers.ValidationError("content 不能为空") + if len(content.encode("utf-8")) > 2048: + raise serializers.ValidationError("content 不能超过 2048 字节") + return content + + def validate_user_ids(self, value): + return _normalize_user_ids(value) + + +class MessageNewsRequestSerializer(serializers.Serializer): + agent_ids = serializers.ListField( + child=serializers.IntegerField(min_value=1), + allow_empty=False, + ) + title = serializers.CharField(allow_blank=False, trim_whitespace=True, max_length=128) + description = serializers.CharField(allow_blank=False, trim_whitespace=True, max_length=512) + url = serializers.URLField(allow_blank=False) + image_url = serializers.URLField(allow_blank=False) + user_ids = serializers.ListField( + child=serializers.CharField(allow_blank=False, trim_whitespace=True), + required=False, + allow_empty=True, + ) + + def validate_agent_ids(self, value): + return list(dict.fromkeys(value or [])) + + def validate_user_ids(self, value): + return _normalize_user_ids(value) + + +class MessageTemplatePayloadSerializer(serializers.Serializer): + msgtype = serializers.ChoiceField(choices=["text", "news"]) + agent_id = serializers.IntegerField(min_value=1, required=False) + agent_ids = serializers.ListField( + child=serializers.IntegerField(min_value=1), + required=False, + allow_empty=False, + ) + content = serializers.CharField(required=False, allow_blank=False, trim_whitespace=True) + title = serializers.CharField(required=False, allow_blank=False, trim_whitespace=True, max_length=128) + description = serializers.CharField(required=False, allow_blank=False, trim_whitespace=True, max_length=512) + url = serializers.URLField(required=False) + image_url = serializers.URLField(required=False) + user_ids = serializers.ListField( + child=serializers.CharField(allow_blank=False, trim_whitespace=True), + required=False, + allow_empty=True, + ) + + def validate_user_ids(self, value): + return _normalize_user_ids(value) + + def validate(self, attrs): + msgtype = attrs["msgtype"] + if msgtype == "text": + if not attrs.get("content"): + raise serializers.ValidationError({"content": "text 类型必须提供 content"}) + if "agent_ids" in attrs: + raise serializers.ValidationError({"agent_ids": "text 类型不支持 agent_ids"}) + return attrs + + required = ["title", "description", "url", "image_url"] + errors = {} + for field in required: + if not attrs.get(field): + errors[field] = f"news 类型必须提供 {field}" + if "agent_id" in attrs: + errors["agent_id"] = "news 类型不支持 agent_id" + if "content" in attrs: + errors["content"] = "news 类型不支持 content" + if errors: + raise serializers.ValidationError(errors) + return attrs \ No newline at end of file diff --git a/notifier/services.py b/notifier/services.py index fddc081..178ee1f 100644 --- a/notifier/services.py +++ b/notifier/services.py @@ -1,5 +1,6 @@ import logging +from django.conf import settings from django.db.models import Case, IntegerField, Q, Value, When from django.template.loader import render_to_string @@ -10,7 +11,15 @@ logger = logging.getLogger(__name__) def render_notification_content(*, notifier: Notifier, payload: dict) -> str: - content = render_to_string(notifier.get_template_name(), payload or {}).strip() + context = { + **(payload or {}), + "notifier_config": notifier.config or {}, + "notifier_config_url": notifier.get_config_value("url", "") or "", + "notifier_config_image_url": notifier.get_config_value("image_url", "") or "", + "default_news_image_url": getattr(settings, "MESSAGE_API_DEFAULT_NEWS_IMAGE_URL", "") or "", + "notifier": notifier, + } + content = render_to_string(notifier.get_template_name(), context).strip() if not content: raise ValueError("通知模板渲染结果不能为空") return content diff --git a/notifier/tasks.py b/notifier/tasks.py index b7225e6..e5cf50d 100644 --- a/notifier/tasks.py +++ b/notifier/tasks.py @@ -7,6 +7,7 @@ from django.db.models import Exists, F, OuterRef from django.utils import timezone from mission import models as mission_models +from mission.payload_processors import apply_mission_payload_processor from mission import services as mission_services from notifier.models import NotificationEventKeyEnum from notifier.services import dispatch_notification_event @@ -20,7 +21,7 @@ def _build_unreplied_mission_payload(*, mission: mission_models.Mission, notifie ) content_type = getattr(mission.content_type, "model", None) next_count = mission.unreplied_notify_sent_count + 1 - return { + payload = { "mission_id": mission.id, "merchant_id": mission.merchant_id, "description": mission.description, @@ -35,11 +36,17 @@ def _build_unreplied_mission_payload(*, mission: mission_models.Mission, notifie "participant_names_display": "、".join(participant_names) if participant_names else "无", "content_type": content_type or "", "content_id": mission.content_id or "", + "payload_processor": getattr(mission.category, "payload_processor", "") or "", "unreplied_notify_interval_minutes": mission.unreplied_notify_interval_minutes, "unreplied_notify_max_count": mission.unreplied_notify_max_count, "unreplied_notify_sent_count": next_count, "unreplied_last_notified_at": notified_at.isoformat() if notified_at else "", } + return apply_mission_payload_processor( + mission=mission, + event_key=NotificationEventKeyEnum.MISSION_UNREPLIED, + payload=payload, + ) def _get_due_unreplied_mission_ids(*, limit: int) -> list[int]: diff --git a/notifier/templates/notifier/events/mission_cancelled.json b/notifier/templates/notifier/events/mission_cancelled.json new file mode 100644 index 0000000..5e279ce --- /dev/null +++ b/notifier/templates/notifier/events/mission_cancelled.json @@ -0,0 +1,4 @@ +{ + "msgtype": "text", + "content": "任务已取消\n任务ID:{{ mission_id }}\n分类:{{ category_name|escapejs }}\n取消人:{{ cancelled_by_name|default:creator_name|escapejs }}\n参与人:{{ participant_names_display|escapejs }}\n说明:{{ description|escapejs }}" +} \ No newline at end of file diff --git a/notifier/templates/notifier/events/mission_completed.json b/notifier/templates/notifier/events/mission_completed.json new file mode 100644 index 0000000..b8786fd --- /dev/null +++ b/notifier/templates/notifier/events/mission_completed.json @@ -0,0 +1,4 @@ +{ + "msgtype": "text", + "content": "任务已完成\n任务ID:{{ mission_id }}\n分类:{{ category_name|escapejs }}\n完成人:{{ completed_by_name|default:responder_name|default:creator_name|escapejs }}\n参与人:{{ participant_names_display|escapejs }}\n说明:{{ description|escapejs }}" +} \ No newline at end of file diff --git a/notifier/templates/notifier/events/mission_created.json b/notifier/templates/notifier/events/mission_created.json new file mode 100644 index 0000000..165fb13 --- /dev/null +++ b/notifier/templates/notifier/events/mission_created.json @@ -0,0 +1,4 @@ +{ + "msgtype": "text", + "content": "任务已创建\n任务ID:{{ mission_id }}\n创建人:{{ created_by_name|default:creator_name|escapejs }}\n分类:{{ category_name|escapejs }}\n紧急:{% if is_urgent %}是{% else %}否{% endif %}\n参与人:{{ participant_names_display|escapejs }}\n说明:{{ description|escapejs }}" +} \ No newline at end of file diff --git a/notifier/templates/notifier/events/mission_reopened.json b/notifier/templates/notifier/events/mission_reopened.json new file mode 100644 index 0000000..fbca261 --- /dev/null +++ b/notifier/templates/notifier/events/mission_reopened.json @@ -0,0 +1,4 @@ +{ + "msgtype": "text", + "content": "任务已重新打开\n任务ID:{{ mission_id }}\n分类:{{ category_name|escapejs }}\n操作人:{{ reopened_by_name|escapejs }}\n撤销回应:{{ rejected_reply_ids_display|escapejs }}\n说明:{{ description|escapejs }}" +} \ No newline at end of file diff --git a/notifier/templates/notifier/events/mission_replied.json b/notifier/templates/notifier/events/mission_replied.json new file mode 100644 index 0000000..d797003 --- /dev/null +++ b/notifier/templates/notifier/events/mission_replied.json @@ -0,0 +1,4 @@ +{ + "msgtype": "text", + "content": "任务有新回应\n任务ID:{{ mission_id }}\n分类:{{ category_name|escapejs }}\n回应人:{{ responder_name|escapejs }}\n参与人:{{ participant_names_display|escapejs }}\n说明:{{ reply_content_short|default:description|escapejs }}" +} \ No newline at end of file diff --git a/notifier/templates/notifier/events/mission_reply_rejected.json b/notifier/templates/notifier/events/mission_reply_rejected.json new file mode 100644 index 0000000..12c3575 --- /dev/null +++ b/notifier/templates/notifier/events/mission_reply_rejected.json @@ -0,0 +1,4 @@ +{ + "msgtype": "text", + "content": "任务回应已撤销\n任务ID:{{ mission_id }}\n分类:{{ category_name|escapejs }}\n操作人:{{ rejected_by_name|escapejs }}\n原因:{{ reason|escapejs }}\n回应:{{ reply_content_short|escapejs }}" +} \ No newline at end of file diff --git a/notifier/templates/notifier/events/mission_structured_description_news.json b/notifier/templates/notifier/events/mission_structured_description_news.json new file mode 100644 index 0000000..8934eee --- /dev/null +++ b/notifier/templates/notifier/events/mission_structured_description_news.json @@ -0,0 +1,7 @@ +{ + "msgtype": "news", + "title": "{{ parsed_description_title|default:description|truncatechars:128|escapejs }}", + "description": "{{ parsed_description_body|default:parsed_description_title|default:description|truncatechars:512|escapejs }}", + "url": "{{ parsed_description_url|default:notifier_config_url|escapejs }}", + "image_url": "{{ parsed_description_image_url|default:notifier_config_image_url|default:default_news_image_url|escapejs }}" +} \ No newline at end of file diff --git a/notifier/templates/notifier/events/mission_structured_description_text.json b/notifier/templates/notifier/events/mission_structured_description_text.json new file mode 100644 index 0000000..f6256c5 --- /dev/null +++ b/notifier/templates/notifier/events/mission_structured_description_text.json @@ -0,0 +1,4 @@ +{ + "msgtype": "text", + "content": "任务通知\n任务ID:{{ mission_id }}\n分类:{{ category_name|escapejs }}\n发起人:{{ creator_name|escapejs }}\n标题:{{ parsed_description_title|default:description|escapejs }}{% if parsed_description_body %}\n\n正文:{{ parsed_description_body|escapejs }}{% endif %}{% if parsed_description_url %}\n\n链接:{{ parsed_description_url|escapejs }}{% endif %}" +} \ No newline at end of file diff --git a/notifier/templates/notifier/events/mission_unreplied.json b/notifier/templates/notifier/events/mission_unreplied.json new file mode 100644 index 0000000..4c4bf80 --- /dev/null +++ b/notifier/templates/notifier/events/mission_unreplied.json @@ -0,0 +1,4 @@ +{ + "msgtype": "text", + "content": "任务未回复提醒\n任务ID:{{ mission_id }}\n分类:{{ category_name|escapejs }}\n提醒间隔:{{ unreplied_notify_interval_minutes }} 分钟\n提醒次数:{{ unreplied_notify_sent_count }}/{{ unreplied_notify_max_count }}\n参与人:{{ participant_names_display|escapejs }}\n说明:{{ description|escapejs }}" +} \ No newline at end of file diff --git a/notifier/templates/notifier/events/test_message_invalid.json b/notifier/templates/notifier/events/test_message_invalid.json new file mode 100644 index 0000000..7619728 --- /dev/null +++ b/notifier/templates/notifier/events/test_message_invalid.json @@ -0,0 +1,3 @@ +{ + "msgtype": "text" +} \ No newline at end of file diff --git a/notifier/templates/notifier/events/test_message_news.json b/notifier/templates/notifier/events/test_message_news.json new file mode 100644 index 0000000..28973ca --- /dev/null +++ b/notifier/templates/notifier/events/test_message_news.json @@ -0,0 +1,7 @@ +{ + "msgtype": "news", + "title": "任务 {{ mission_id }} 通知", + "description": "{{ description|escapejs }}", + "url": "https://example.com/missions/{{ mission_id }}", + "image_url": "https://example.com/static/mission-cover.png" +} \ No newline at end of file diff --git a/notifier/tests.py b/notifier/tests.py index 11dc5ed..ef1f337 100644 --- a/notifier/tests.py +++ b/notifier/tests.py @@ -1,4 +1,5 @@ -from unittest.mock import patch +import json +from unittest.mock import MagicMock, patch from django.test import TestCase from django.utils import timezone @@ -52,6 +53,103 @@ class NotifierServiceTestCase(TestCase): self.assertIn("任务ID:12", content) self.assertIn("创建人:张三", content) + def test_render_notification_content_with_structured_description_template(self): + notifier = Notifier.objects.create( + merchant=self.merchant, + name="结构化描述通知", + channel=NotifierChannelEnum.MESSAGE_API, + template_key="mission_structured_description_text", + config={"agent_id": 1000007}, + ) + + content = render_notification_content( + notifier=notifier, + payload={ + "mission_id": 12, + "creator_name": "张三", + "category_name": "通用", + "description": "原始描述", + "parsed_description_title": "标题一\n标题二", + "parsed_description_body": "正文一\n正文二", + "parsed_description_url": "https://example.com/detail", + "parsed_description_image_url": "https://images.yuwen.cloud/abc.jpg", + }, + ) + + rendered_payload = json.loads(content) + self.assertEqual(rendered_payload["msgtype"], "text") + self.assertIn("标题:标题一\n标题二", rendered_payload["content"]) + self.assertIn("正文:正文一\n正文二", rendered_payload["content"]) + self.assertIn("链接:https://example.com/detail", rendered_payload["content"]) + + def test_render_notification_content_with_structured_description_news_template(self): + notifier = Notifier.objects.create( + merchant=self.merchant, + name="结构化描述图文通知", + channel=NotifierChannelEnum.MESSAGE_API, + template_key="mission_structured_description_news", + config={ + "agent_ids": [1000007], + "image_url": "https://cdn.example.com/covers/mission-news.png", + "url": "https://erp.example.com/missions/fallback", + }, + ) + + content = render_notification_content( + notifier=notifier, + payload={ + "mission_id": 12, + "creator_name": "张三", + "category_name": "通用", + "description": "原始描述", + "parsed_description_title": "标题一\n标题二", + "parsed_description_body": "正文一\n正文二", + "parsed_description_url": "https://example.com/detail", + "parsed_description_image_url": "https://images.yuwen.cloud/abc.jpg", + }, + ) + + rendered_payload = json.loads(content) + self.assertEqual(rendered_payload["msgtype"], "news") + self.assertEqual(rendered_payload["title"], "标题一\n标题二") + self.assertEqual(rendered_payload["description"], "正文一\n正文二") + self.assertEqual(rendered_payload["url"], "https://example.com/detail") + self.assertEqual(rendered_payload["image_url"], "https://images.yuwen.cloud/abc.jpg") + + def test_render_notification_content_with_structured_description_news_template_falls_back_to_default_image(self): + notifier = Notifier.objects.create( + merchant=self.merchant, + name="结构化描述图文通知-默认图", + channel=NotifierChannelEnum.MESSAGE_API, + template_key="mission_structured_description_news", + config={ + "agent_ids": [1000007], + "url": "https://erp.example.com/missions/fallback", + }, + ) + + with self.settings(MESSAGE_API_DEFAULT_NEWS_IMAGE_URL="https://via.placeholder.com/640x360.png?text=No+Image"): + content = render_notification_content( + notifier=notifier, + payload={ + "mission_id": 12, + "creator_name": "张三", + "category_name": "通用", + "description": "原始描述", + "parsed_description_title": "标题一\n标题二", + "parsed_description_body": "正文一\n正文二", + "parsed_description_url": "", + "parsed_description_image_url": "", + }, + ) + + rendered_payload = json.loads(content) + self.assertEqual(rendered_payload["url"], "https://erp.example.com/missions/fallback") + self.assertEqual( + rendered_payload["image_url"], + "https://via.placeholder.com/640x360.png?text=No+Image", + ) + @patch("notifier.backends.send_wecom_webhook_message") def test_send_notification_with_notifier_uses_wecom_backend(self, mock_send): mock_send.return_value.ok = True @@ -252,3 +350,262 @@ class NotifierServiceTestCase(TestCase): self.assertEqual(result["sent_count"], 0) mock_dispatch.assert_not_called() + + @patch("notifier.tasks.dispatch_notification_event") + def test_notify_unreplied_missions_task_applies_category_payload_processor(self, mock_dispatch): + mock_dispatch.return_value = [{"status": "sent"}] + self.general_category.payload_processor = "structured_description_v1" + self.general_category.save(update_fields=["payload_processor", "updated_at"]) + notifier = Notifier.objects.create( + merchant=self.merchant, + name="未回复提醒通知器", + channel=NotifierChannelEnum.WECOM_WEBHOOK, + template_key="mission_unreplied", + config={"key": "unreplied-key"}, + ) + NotifierRoute.objects.create( + merchant=self.merchant, + notifier=notifier, + event_key=NotificationEventKeyEnum.MISSION_UNREPLIED, + ) + mission = Mission.objects.create( + merchant=self.merchant, + category=self.general_category, + creator=self.creator, + description=( + "系统首行\n标题一\n款式图:https://images.yuwen.cloud/abc.jpg\n标题二\n\n正文一\n正文二\n" + "手机端链接: https://example.com/detail" + ), + notify_if_unreplied=True, + unreplied_notify_interval_minutes=1, + ) + mission.created_at = timezone.now() - timezone.timedelta(minutes=3) + mission.save(update_fields=["created_at", "updated_at"]) + + result = notify_unreplied_missions_task.run(limit=10) + + self.assertEqual(result["sent_count"], 1) + payload = mock_dispatch.call_args.kwargs["payload"] + self.assertEqual(payload["payload_processor"], "structured_description_v1") + self.assertEqual(payload["parsed_description_title"], "标题一\n标题二") + self.assertEqual(payload["parsed_description_body"], "正文一\n正文二") + self.assertEqual(payload["parsed_description_url"], "https://example.com/detail") + self.assertEqual(payload["parsed_description_image_url"], "https://images.yuwen.cloud/abc.jpg") + + @patch("notifier.backends.send_message_api_text_message") + def test_send_notification_with_notifier_uses_message_api_text_backend(self, mock_send): + mock_send.return_value.errcode = 0 + mock_send.return_value.errmsg = "ok" + mock_send.return_value.raw = {"errcode": 0, "errmsg": "ok"} + + notifier = Notifier.objects.create( + merchant=self.merchant, + name="任务创建消息 API 通知", + channel=NotifierChannelEnum.MESSAGE_API, + template_key="mission_created", + config={"agent_id": 1000007}, + ) + + result = send_notification_with_notifier( + notifier=notifier, + payload={ + "mission_id": 12, + "created_by_name": "张三", + "creator_name": "张三", + "category_name": "通用", + "is_urgent": True, + "participant_names_display": "无", + "description": "检查打印质量", + }, + ) + + self.assertEqual(result["status"], "sent") + self.assertEqual(result["channel"], NotifierChannelEnum.MESSAGE_API) + self.assertEqual(result["msgtype"], "text") + self.assertEqual(mock_send.call_count, 1) + self.assertEqual(mock_send.call_args.kwargs["agent_id"], 1000007) + self.assertEqual(mock_send.call_args.kwargs["user_ids"], None) + self.assertEqual(mock_send.call_args.kwargs["timeout_seconds"], 10.0) + self.assertIn("任务已创建", mock_send.call_args.kwargs["content"]) + + @patch("notifier.backends.send_message_api_text_message") + def test_send_notification_with_notifier_uses_structured_description_template(self, mock_send): + mock_send.return_value.errcode = 0 + mock_send.return_value.errmsg = "ok" + mock_send.return_value.raw = {"errcode": 0, "errmsg": "ok"} + + notifier = Notifier.objects.create( + merchant=self.merchant, + name="结构化描述消息 API 通知", + channel=NotifierChannelEnum.MESSAGE_API, + template_key="mission_structured_description_text", + config={"agent_id": 1000007}, + ) + + result = send_notification_with_notifier( + notifier=notifier, + payload={ + "mission_id": 12, + "creator_name": "张三", + "category_name": "通用", + "description": "原始描述", + "parsed_description_title": "标题一\n标题二", + "parsed_description_body": "正文一\n正文二", + "parsed_description_url": "https://example.com/detail", + "parsed_description_image_url": "https://images.yuwen.cloud/abc.jpg", + }, + ) + + self.assertEqual(result["status"], "sent") + self.assertEqual(result["channel"], NotifierChannelEnum.MESSAGE_API) + self.assertEqual(result["msgtype"], "text") + self.assertEqual(mock_send.call_count, 1) + self.assertIn("标题:标题一\n标题二", mock_send.call_args.kwargs["content"]) + self.assertIn("正文:正文一\n正文二", mock_send.call_args.kwargs["content"]) + self.assertIn("链接:https://example.com/detail", mock_send.call_args.kwargs["content"]) + + @patch("notifier.backends.send_message_api_news_to_agents") + def test_send_notification_with_notifier_uses_structured_description_news_template(self, mock_send): + mock_send.return_value = [ + { + "agent_id": 1000007, + "ok": True, + "response": {"errcode": 0, "errmsg": "ok"}, + "error": None, + } + ] + notifier = Notifier.objects.create( + merchant=self.merchant, + name="结构化描述图文消息 API 通知", + channel=NotifierChannelEnum.MESSAGE_API, + template_key="mission_structured_description_news", + config={ + "agent_ids": [1000007], + "image_url": "https://cdn.example.com/covers/mission-news.png", + }, + ) + + result = send_notification_with_notifier( + notifier=notifier, + payload={ + "mission_id": 12, + "description": "原始描述", + "parsed_description_title": "标题一\n标题二", + "parsed_description_body": "正文一\n正文二", + "parsed_description_url": "https://example.com/detail", + "parsed_description_image_url": "https://images.yuwen.cloud/abc.jpg", + }, + ) + + self.assertEqual(result["status"], "sent") + self.assertEqual(result["msgtype"], "news") + mock_send.assert_called_once() + self.assertEqual(mock_send.call_args.kwargs["agent_ids"], [1000007]) + self.assertEqual(mock_send.call_args.kwargs["title"], "标题一\n标题二") + self.assertEqual(mock_send.call_args.kwargs["description"], "正文一\n正文二") + self.assertEqual(mock_send.call_args.kwargs["url"], "https://example.com/detail") + self.assertEqual( + mock_send.call_args.kwargs["image_url"], + "https://images.yuwen.cloud/abc.jpg", + ) + + @patch("notifier.backends.send_message_api_news_to_agents") + def test_send_notification_with_notifier_uses_message_api_news_backend(self, mock_send): + mock_send.return_value = [ + { + "agent_id": 1000007, + "ok": True, + "response": {"errcode": 0, "errmsg": "ok"}, + "error": None, + } + ] + notifier = Notifier.objects.create( + merchant=self.merchant, + name="任务创建图文通知", + channel=NotifierChannelEnum.MESSAGE_API, + template_key="test_message_news", + config={"agent_ids": [1000007]}, + ) + + result = send_notification_with_notifier( + notifier=notifier, + payload={ + "mission_id": 12, + "description": "检查打印质量", + "category_name": "通用", + }, + ) + + self.assertEqual(result["status"], "sent") + self.assertEqual(result["msgtype"], "news") + self.assertEqual(result["ok_count"], 1) + mock_send.assert_called_once() + + def test_send_notification_with_notifier_rejects_invalid_message_api_template(self): + notifier = Notifier.objects.create( + merchant=self.merchant, + name="非法消息模板", + channel=NotifierChannelEnum.MESSAGE_API, + template_key="test_message_invalid", + config={"agent_id": 1000007}, + ) + + with self.assertRaisesMessage(Exception, "content"): + send_notification_with_notifier( + notifier=notifier, + payload={"mission_id": 12}, + ) +class MessageAPIServiceTestCase(TestCase): + @patch("notifier.message_api.urlopen") + def test_send_message_api_text_message_calls_message_api_endpoint(self, mock_urlopen): + from notifier.message_api import send_message_api_text_message + + response_obj = MagicMock() + response_obj.read.return_value = b'{"errcode":0,"errmsg":"ok"}' + response_obj.__enter__.return_value = response_obj + response_obj.__exit__.return_value = False + + mock_urlopen.return_value = response_obj + + with self.settings( + MESSAGE_API_BASE_URL="http://message-api.internal:8198", + MESSAGE_API_AUTHORIZATION="secret-1", + ): + response = send_message_api_text_message(agent_id=1000007, content="hello") + + self.assertTrue(response.ok) + self.assertEqual(response.errcode, 0) + self.assertEqual(mock_urlopen.call_count, 1) + request = mock_urlopen.call_args.args[0] + self.assertEqual(request.full_url, "http://message-api.internal:8198/api/message/send") + self.assertEqual(request.get_method(), "POST") + self.assertEqual(request.get_header("Authorization"), "secret-1") + + @patch("notifier.message_api.urlopen") + def test_send_message_api_news_to_agents_calls_message_api_endpoint(self, mock_urlopen): + from notifier.message_api import send_message_api_news_to_agents + + response_obj = MagicMock() + response_obj.read.return_value = ( + b'{"results":[{"agent_id":1000007,"ok":true,"response":{"errcode":0,"errmsg":"ok"},"error":null}]}' + ) + response_obj.__enter__.return_value = response_obj + response_obj.__exit__.return_value = False + mock_urlopen.return_value = response_obj + + with self.settings( + MESSAGE_API_BASE_URL="http://message-api.internal:8198", + MESSAGE_API_AUTHORIZATION="secret-1", + ): + results = send_message_api_news_to_agents( + agent_ids=[1000007], + title="销售日报", + description="点击查看今日各区域销售汇总", + url="https://example.com/reports/daily-sales", + image_url="https://example.com/static/daily-sales-cover.png", + ) + + self.assertEqual(len(results), 1) + request = mock_urlopen.call_args.args[0] + self.assertEqual(request.full_url, "http://message-api.internal:8198/api/message/send/news") + self.assertEqual(request.get_header("Authorization"), "secret-1") diff --git a/notifier/views.py b/notifier/views.py deleted file mode 100644 index 91ea44a..0000000 --- a/notifier/views.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.shortcuts import render - -# Create your views here.