1
0
forked from erp-dev/erp

feat: message-api for mission via wecomm agent

This commit is contained in:
2026-05-14 09:30:38 +08:00
parent 9359013b4c
commit f409f2e6ee
38 changed files with 2085 additions and 49 deletions

View File

@@ -541,30 +541,44 @@ class MissionV2APITest(TestCase):
[item["name"] for item in list_resp.data], [item["name"] for item in list_resp.data],
["通用", "跟进"], ["通用", "跟进"],
) )
self.assertEqual(list_resp.data[0]["payload_processor"], "")
create_resp = self.client.post( create_resp = self.client.post(
"/api/v2/mission-categories/", "/api/v2/mission-categories/",
{"name": "售后"}, {"name": "售后", "payload_processor": "structured_description_v1"},
format="json", format="json",
) )
self.assertEqual(create_resp.status_code, 201) self.assertEqual(create_resp.status_code, 201)
category_id = create_resp.data["id"] 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}/") detail_resp = self.client.get(f"/api/v2/mission-categories/{category_id}/")
self.assertEqual(detail_resp.status_code, 200) self.assertEqual(detail_resp.status_code, 200)
self.assertEqual(detail_resp.data["name"], "售后") self.assertEqual(detail_resp.data["name"], "售后")
self.assertEqual(detail_resp.data["payload_processor"], "structured_description_v1")
patch_resp = self.client.patch( patch_resp = self.client.patch(
f"/api/v2/mission-categories/{category_id}/", f"/api/v2/mission-categories/{category_id}/",
{"name": "售后跟进"}, {"name": "售后跟进", "payload_processor": ""},
format="json", format="json",
) )
self.assertEqual(patch_resp.status_code, 200) self.assertEqual(patch_resp.status_code, 200)
self.assertEqual(patch_resp.data["name"], "售后跟进") 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}/") delete_resp = self.client.delete(f"/api/v2/mission-categories/{category_id}/")
self.assertEqual(delete_resp.status_code, 204) 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): def test_delete_used_mission_category_is_rejected(self):
mission = self._create_mission() mission = self._create_mission()

View File

@@ -108,6 +108,11 @@ class MissionUrgentSerializer(serializers.Serializer):
class MissionCategoryWriteSerializer(serializers.Serializer): class MissionCategoryWriteSerializer(serializers.Serializer):
name = serializers.CharField(allow_blank=False, max_length=50) 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): def validate_name(self, value):
value = value.strip() value = value.strip()
@@ -251,6 +256,7 @@ class MissionCategorySerializer(serializers.ModelSerializer):
"id", "id",
"merchant", "merchant",
"name", "name",
"payload_processor",
"created_at", "created_at",
"updated_at", "updated_at",
] ]
@@ -402,6 +408,7 @@ class MissionCategoryListCreateView(APIView):
category = mission_models.MissionCategory.objects.create( category = mission_models.MissionCategory.objects.create(
merchant=employee.merchant, merchant=employee.merchant,
name=serializer.validated_data["name"], name=serializer.validated_data["name"],
payload_processor=serializer.validated_data.get("payload_processor", ""),
) )
except IntegrityError: except IntegrityError:
return Response({"name": ["分类名称已存在"]}, status=status.HTTP_400_BAD_REQUEST) return Response({"name": ["分类名称已存在"]}, status=status.HTTP_400_BAD_REQUEST)
@@ -428,10 +435,16 @@ class MissionCategoryDetailView(APIView):
context={"employee": employee, "instance": category}, context={"employee": employee, "instance": category},
) )
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
update_fields = []
if "name" in serializer.validated_data: if "name" in serializer.validated_data:
category.name = serializer.validated_data["name"] 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: try:
category.save(update_fields=["name", "updated_at"]) category.save(update_fields=[*update_fields, "updated_at"])
except IntegrityError: except IntegrityError:
return Response({"name": ["分类名称已存在"]}, status=status.HTTP_400_BAD_REQUEST) return Response({"name": ["分类名称已存在"]}, status=status.HTTP_400_BAD_REQUEST)
return Response(MissionCategorySerializer(category).data) return Response(MissionCategorySerializer(category).data)

165
docs/MESSAGE_API.md Normal file
View File

@@ -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 模式

View File

@@ -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 的查询能力,同时避免把不支持的能力暴露成错误体验。

View File

@@ -279,6 +279,17 @@
响应:`MissionCategory[]` 响应:`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/` - URL: `/api/v2/mission-categories/`
@@ -289,12 +300,14 @@
| 参数 | 类型 | 必填 | 说明 | | 参数 | 类型 | 必填 | 说明 |
|------|------|------|------| |------|------|------|------|
| `name` | string | 是 | 分类名称,同商户下唯一 | | `name` | string | 是 | 分类名称,同商户下唯一 |
| `payload_processor` | string | 否 | payload 增强器标识;当前可选值:`structured_description_v1` |
请求示例: 请求示例:
```json ```json
{ {
"name": "售后" "name": "售后",
"payload_processor": "structured_description_v1"
} }
``` ```
@@ -317,6 +330,7 @@
| 参数 | 类型 | 说明 | | 参数 | 类型 | 说明 |
|------|------|------| |------|------|------|
| `name` | string | 分类名称,同商户下唯一 | | `name` | string | 分类名称,同商户下唯一 |
| `payload_processor` | string | payload 增强器标识;传空字符串表示清空 |
成功响应:`MissionCategory` 成功响应:`MissionCategory`

View File

@@ -14,6 +14,22 @@
- `Notifier` 是“发信工具” - `Notifier` 是“发信工具”
- `NotifierRoute` 是“分发规则” - `NotifierRoute` 是“分发规则”
## 1.1 当前文档适用范围
这份文档只描述“当前已经确认可由后台管理人员自行配置”的 `Notifier` 能力。
截至目前,已经稳定确认、并且适合由后台人员自行配置的渠道有:
- `wecom_webhook`
- `message_api`
注意:
- `wecom_webhook``message_api` 的配置方式不同,不要混用字段。
- `message_api` 适合发企业微信应用消息,可以发文本,也可以发单篇图文。
- 当前 ERP 只是 `message_api` 的调用方,不直接管理企业微信凭据。
- 当前 `message_api` 默认仍由开发组提供模板文件,管理人员主要负责选择正确的 `template_key` 和填写 `config`
## 2. 当前已支持的 mission 事件 ## 2. 当前已支持的 mission 事件
目前 `mission` 模块已接入以下事件: 目前 `mission` 模块已接入以下事件:
@@ -28,6 +44,92 @@
所有这些事件都支持通过 `NotifierRoute` 进行分类路由。 所有这些事件都支持通过 `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. 当前支持的分类路由能力 ## 3. 当前支持的分类路由能力
路由匹配规则如下: 路由匹配规则如下:
@@ -58,6 +160,8 @@
`Notifier` 详情页中,也可以直接通过 inline 管理该通知器下的路由。 `Notifier` 详情页中,也可以直接通过 inline 管理该通知器下的路由。
如果需要启用上面的 `payload 增强器`,还需要进入 `任务分类` 管理页,在具体分类上选择对应增强器。
## 5. Notifier 字段说明 ## 5. Notifier 字段说明
### 5.1 merchant ### 5.1 merchant
@@ -82,9 +186,16 @@
通知渠道。 通知渠道。
当前固定选: 当前选:
- `wecom_webhook` - `wecom_webhook`
- `message_api`
补充说明:
- 如果你在后台将来看到新的 channel 选项,不代表它已经进入“可自行配置”的稳定状态。
- 当前管理人员应只配置已经明确说明过的 channel。
- 如果要发送企业微信应用消息,请选 `message_api`,不要继续选 `wecom_webhook`
### 5.4 template_key ### 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` - 对应模板文件:`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 ### 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 ### 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. 模板如何对应 ## 9. 模板如何对应
当前系统已内置以下模板: 当前系统已内置以下模板:
@@ -260,10 +480,88 @@
- `mission_reply_rejected` - `mission_reply_rejected`
- `mission_reopened` - `mission_reopened`
- `mission_cancelled` - `mission_cancelled`
- `test_message_news`
管理人员通常只需要填 `template_key`,不需要改代码。 管理人员通常只需要填 `template_key`,不需要改代码。
如果后续要新增模板内容或调整文案,需要由开发人员修改模板文件。 如果后续要新增模板内容或调整文案,需要由开发人员修改模板文件。
补充说明:
- 当前这套 admin 配置说明默认基于“模板文件”模式。
- `wecom_webhook` 使用 `.md` 模板文件。
- `message_api` 使用 `.json` 模板文件。
- 在新的正式说明发布前,管理人员不要自行推断未文档化的模板字段。
### 9.1 `message_api` 的 JSON 模板到底长什么样
这部分是为了帮助管理人员“看懂模板的大致样子”,不是要求你在后台手工编写模板。
可以把 JSON 模板理解为:
- 它不是程序代码
- 它更像一张“字段清单”
- 系统会把里面的变量替换成真正的任务内容
#### 文本消息模板示例
例如 `mission_created.json` 大致会渲染成:
```json
{
"msgtype": "text",
"content": "任务已创建\n任务ID123\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 配置要点 ## 10. 未回复提醒的 admin 配置要点
`mission.unreplied` 和其它事件不同,它不是在某个瞬时动作发生时触发,而是由后台每分钟扫描一次“仍未回复的任务”后触发。 `mission.unreplied` 和其它事件不同,它不是在某个瞬时动作发生时触发,而是由后台每分钟扫描一次“仍未回复的任务”后触发。
@@ -352,6 +650,22 @@
6. `config.key` 是否填写正确 6. `config.key` 是否填写正确
7. Celery worker 是否已启动 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 为什么某个任务分类没有走专门群 ### 11.2 为什么某个任务分类没有走专门群
常见原因: 常见原因:

View File

@@ -25,6 +25,7 @@
- 独立 app`notifier` - 独立 app`notifier`
- Celery task 化投递 - Celery task 化投递
- 企业微信 webhook 渠道 - 企业微信 webhook 渠道
- `message_api` 渠道ERP 作为调用方)
- 模板化内容渲染 - 模板化内容渲染
- Admin 可配置 - Admin 可配置
- `NotifierRoute` 事件路由 - `NotifierRoute` 事件路由
@@ -33,7 +34,6 @@
本阶段仍未做: 本阶段仍未做:
- 旧模块静态通知逻辑迁移 - 旧模块静态通知逻辑迁移
- 外部 API
- 通知投递明细表 - 通知投递明细表
- 数据库级别审计 - 数据库级别审计
@@ -151,7 +151,26 @@
`template_key` 如果写错,会在渲染阶段报错并记录日志。 `template_key` 如果写错,会在渲染阶段报错并记录日志。
后续可在 admin 或 model clean 中增强校验。 后续可在 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` 已具备: 当前 `notifier` 已具备:

View File

@@ -71,6 +71,14 @@ TENCENTCLOUD_TIIA_QPS=10
############################ ############################
# 为空时发送会报错WeCom webhook key 未配置 # 为空时发送会报错WeCom webhook key 未配置
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 SPEAK_ENDPOINT=http://8.148.215.233:9004/speak
# PrintingJob 状态推进通知的“跟进地址”模板;为空则消息里省略“跟进地址”字段 # PrintingJob 状态推进通知的“跟进地址”模板;为空则消息里省略“跟进地址”字段

Binary file not shown.

View File

@@ -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 # 使用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_BASE_URL = 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send'
WECOM_WEBHOOK_KEY = env('WECOM_WEBHOOK_KEY', default='') 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') SPEAK_ENDPOINT = env('SPEAK_ENDPOINT', default='http://8.148.215.233:9004/speak')
PRINTING_ORDER_CREATED_SPEECH_ENABLED = False PRINTING_ORDER_CREATED_SPEECH_ENABLED = False
AGENT_ACCESS_KEY = env('AGENT_ACCESS_KEY', default='') AGENT_ACCESS_KEY = env('AGENT_ACCESS_KEY', default='')

43
flower/settings_test.py Normal file
View File

@@ -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()

View File

@@ -5,8 +5,8 @@ from mission.models import Mission, MissionCategory, MissionParticipant, Mission
@admin.register(MissionCategory) @admin.register(MissionCategory)
class MissionCategoryAdmin(admin.ModelAdmin): class MissionCategoryAdmin(admin.ModelAdmin):
list_display = ["id", "merchant", "name", "created_at"] list_display = ["id", "merchant", "name", "payload_processor", "created_at"]
list_filter = ["merchant", "created_at"] list_filter = ["merchant", "payload_processor", "created_at"]
search_fields = ["name"] search_fields = ["name"]
readonly_fields = ["created_at", "updated_at"] readonly_fields = ["created_at", "updated_at"]

View File

@@ -1,5 +1,6 @@
import logging import logging
from mission.payload_processors import apply_mission_payload_processor
from notifier.models import NotificationEventKeyEnum from notifier.models import NotificationEventKeyEnum
from notifier.services import enqueue_notification_event 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 "", "participant_names_display": "".join(participant_names) if participant_names else "",
"content_type": content_type or "", "content_type": content_type or "",
"content_id": mission.content_id 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): def on_mission_created(sender, instance, created_by=None, **kwargs):
payload = { payload = apply_mission_payload_processor(
mission=instance,
event_key=NotificationEventKeyEnum.MISSION_CREATED,
payload={
**_build_mission_payload(instance), **_build_mission_payload(instance),
"created_by_id": getattr(created_by, "id", None), "created_by_id": getattr(created_by, "id", None),
"created_by_name": getattr(created_by, "name", ""), "created_by_name": getattr(created_by, "name", ""),
} },
)
_enqueue( _enqueue(
event_key=NotificationEventKeyEnum.MISSION_CREATED, event_key=NotificationEventKeyEnum.MISSION_CREATED,
merchant_id=instance.merchant_id, 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): def on_mission_replied(sender, instance, mission=None, responder=None, **kwargs):
mission = mission or instance.mission mission = mission or instance.mission
payload = { payload = apply_mission_payload_processor(
mission=mission,
event_key=NotificationEventKeyEnum.MISSION_REPLIED,
payload={
**_build_mission_payload(mission), **_build_mission_payload(mission),
**_build_reply_payload(instance), **_build_reply_payload(instance),
"responder_id": getattr(responder, "id", None), "responder_id": getattr(responder, "id", None),
"responder_name": getattr(responder, "name", ""), "responder_name": getattr(responder, "name", ""),
} },
)
_enqueue( _enqueue(
event_key=NotificationEventKeyEnum.MISSION_REPLIED, event_key=NotificationEventKeyEnum.MISSION_REPLIED,
merchant_id=mission.merchant_id, 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: if reply is not None:
payload.update(_build_reply_payload(reply)) payload.update(_build_reply_payload(reply))
payload = apply_mission_payload_processor(
mission=instance,
event_key=NotificationEventKeyEnum.MISSION_COMPLETED,
payload=payload,
)
_enqueue( _enqueue(
event_key=NotificationEventKeyEnum.MISSION_COMPLETED, event_key=NotificationEventKeyEnum.MISSION_COMPLETED,
merchant_id=instance.merchant_id, 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): def on_mission_reply_rejected(sender, instance, mission=None, rejected_by=None, reason=None, **kwargs):
mission = mission or instance.mission mission = mission or instance.mission
payload = { payload = apply_mission_payload_processor(
mission=mission,
event_key=NotificationEventKeyEnum.MISSION_REPLY_REJECTED,
payload={
**_build_mission_payload(mission), **_build_mission_payload(mission),
**_build_reply_payload(instance), **_build_reply_payload(instance),
"rejected_by_id": getattr(rejected_by, "id", None), "rejected_by_id": getattr(rejected_by, "id", None),
"rejected_by_name": getattr(rejected_by, "name", ""), "rejected_by_name": getattr(rejected_by, "name", ""),
"reason": reason or "", "reason": reason or "",
} },
)
_enqueue( _enqueue(
event_key=NotificationEventKeyEnum.MISSION_REPLY_REJECTED, event_key=NotificationEventKeyEnum.MISSION_REPLY_REJECTED,
merchant_id=mission.merchant_id, 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): def on_mission_reopened(sender, instance, reopened_by=None, rejected_reply_ids=None, **kwargs):
payload = { payload = apply_mission_payload_processor(
mission=instance,
event_key=NotificationEventKeyEnum.MISSION_REOPENED,
payload={
**_build_mission_payload(instance), **_build_mission_payload(instance),
"reopened_by_id": getattr(reopened_by, "id", None), "reopened_by_id": getattr(reopened_by, "id", None),
"reopened_by_name": getattr(reopened_by, "name", ""), "reopened_by_name": getattr(reopened_by, "name", ""),
"rejected_reply_ids": rejected_reply_ids or [], "rejected_reply_ids": rejected_reply_ids or [],
"rejected_reply_ids_display": ", ".join(str(reply_id) for reply_id in (rejected_reply_ids or [])) or "", "rejected_reply_ids_display": ", ".join(str(reply_id) for reply_id in (rejected_reply_ids or [])) or "",
} },
)
_enqueue( _enqueue(
event_key=NotificationEventKeyEnum.MISSION_REOPENED, event_key=NotificationEventKeyEnum.MISSION_REOPENED,
merchant_id=instance.merchant_id, 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): def on_mission_cancelled(sender, instance, cancelled_by=None, **kwargs):
payload = { payload = apply_mission_payload_processor(
mission=instance,
event_key=NotificationEventKeyEnum.MISSION_CANCELLED,
payload={
**_build_mission_payload(instance), **_build_mission_payload(instance),
"cancelled_by_id": getattr(cancelled_by, "id", None), "cancelled_by_id": getattr(cancelled_by, "id", None),
"cancelled_by_name": getattr(cancelled_by, "name", ""), "cancelled_by_name": getattr(cancelled_by, "name", ""),
"cancelled_at": instance.cancelled_at.isoformat() if instance.cancelled_at else "", "cancelled_at": instance.cancelled_at.isoformat() if instance.cancelled_at else "",
} },
)
_enqueue( _enqueue(
event_key=NotificationEventKeyEnum.MISSION_CANCELLED, event_key=NotificationEventKeyEnum.MISSION_CANCELLED,
merchant_id=instance.merchant_id, merchant_id=instance.merchant_id,

View File

@@ -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 增强器",
),
),
]

View File

@@ -6,6 +6,10 @@ from django.utils import timezone
from flower.common import ModelBase from flower.common import ModelBase
class MissionPayloadProcessorEnum(models.TextChoices):
STRUCTURED_DESCRIPTION_V1 = "structured_description_v1", "结构化描述增强(v1)"
class MissionCategory(ModelBase): class MissionCategory(ModelBase):
"""任务分类字典,按商户隔离。""" """任务分类字典,按商户隔离。"""
@@ -17,6 +21,13 @@ class MissionCategory(ModelBase):
verbose_name="所属商户", verbose_name="所属商户",
) )
name = models.CharField(max_length=50, 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): def __str__(self):
return self.name return self.name

View File

@@ -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

View File

@@ -1,5 +1,6 @@
from django.test import TestCase from django.test import TestCase
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from types import SimpleNamespace
from unittest.mock import patch from unittest.mock import patch
from django.utils import timezone from django.utils import timezone
@@ -23,6 +24,9 @@ from mission.signals import (
mission_replied, mission_replied,
mission_reply_rejected, 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): class MissionModelTestCase(TestCase):
@@ -546,6 +550,26 @@ class MissionModelTestCase(TestCase):
self.assertEqual(received, [(Mission, self.mission.id, self.creator.id)]) 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") @patch("mission.handlers.enqueue_notification_event")
def test_mission_created_handler_enqueues_notifier_event(self, mock_enqueue): def test_mission_created_handler_enqueues_notifier_event(self, mock_enqueue):
with self.captureOnCommitCallbacks(execute=True): 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["merchant_id"], self.merchant.id)
self.assertEqual(mock_enqueue.call_args.kwargs["payload"]["mission_id"], mission.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") @patch("mission.handlers.enqueue_notification_event")
def test_create_ending_reply_handler_enqueues_replied_and_completed_notifications(self, mock_enqueue): def test_create_ending_reply_handler_enqueues_replied_and_completed_notifications(self, mock_enqueue):
with self.captureOnCommitCallbacks(execute=True): with self.captureOnCommitCallbacks(execute=True):
@@ -614,3 +660,67 @@ class MissionModelTestCase(TestCase):
mock_enqueue.assert_called_once() mock_enqueue.assert_called_once()
self.assertEqual(mock_enqueue.call_args.kwargs["event_key"], "mission.cancelled") self.assertEqual(mock_enqueue.call_args.kwargs["event_key"], "mission.cancelled")
self.assertEqual(mock_enqueue.call_args.kwargs["payload"]["mission_id"], self.mission.id) 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",
)

View File

@@ -1,7 +1,14 @@
import logging import logging
import json
from api_v1.utils.wecom_webhook import send_wecom_webhook_message from api_v1.utils.wecom_webhook import send_wecom_webhook_message
from notifier.models import NotifierChannelEnum 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__) logger = logging.getLogger(__name__)
@@ -17,6 +24,9 @@ class WeComWebhookNotifierBackend(BaseNotifierBackend):
channel = NotifierChannelEnum.WECOM_WEBHOOK channel = NotifierChannelEnum.WECOM_WEBHOOK
def notify(self, *, notifier, content: str, context: dict) -> dict: 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() 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) timeout_seconds = float(notifier.get_config_value("timeout_seconds", 10.0) or 10.0)
key = str(notifier.get_config_value("key", "") or "").strip() 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) logger.info("[notifier.backends] wecom webhook sent: notifier_id=%s result=%s", notifier.id, result)
return 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

132
notifier/message_api.py Normal file
View File

@@ -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 []

View File

@@ -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='事件标识'),
),
]

View File

@@ -7,6 +7,7 @@ from flower.common import ModelBase
class NotifierChannelEnum(models.TextChoices): class NotifierChannelEnum(models.TextChoices):
WECOM_WEBHOOK = "wecom_webhook", "企业微信机器人" WECOM_WEBHOOK = "wecom_webhook", "企业微信机器人"
MESSAGE_API = "message_api", "消息发送 API"
class NotificationEventKeyEnum(models.TextChoices): class NotificationEventKeyEnum(models.TextChoices):
@@ -40,7 +41,8 @@ class Notifier(ModelBase):
description = models.TextField(blank=True, null=True, verbose_name="备注描述") description = models.TextField(blank=True, null=True, verbose_name="备注描述")
def get_template_name(self) -> str: 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): def get_config_value(self, key: str, default=None):
config = self.config or {} config = self.config or {}

View File

@@ -1,9 +1,10 @@
from notifier.backends import WeComWebhookNotifierBackend from notifier.backends import MessageAPINotifierBackend, WeComWebhookNotifierBackend
from notifier.models import NotifierChannelEnum from notifier.models import NotifierChannelEnum
BACKEND_REGISTRY = { BACKEND_REGISTRY = {
NotifierChannelEnum.WECOM_WEBHOOK: WeComWebhookNotifierBackend, NotifierChannelEnum.WECOM_WEBHOOK: WeComWebhookNotifierBackend,
NotifierChannelEnum.MESSAGE_API: MessageAPINotifierBackend,
} }

99
notifier/serializers.py Normal file
View File

@@ -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

View File

@@ -1,5 +1,6 @@
import logging import logging
from django.conf import settings
from django.db.models import Case, IntegerField, Q, Value, When from django.db.models import Case, IntegerField, Q, Value, When
from django.template.loader import render_to_string 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: 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: if not content:
raise ValueError("通知模板渲染结果不能为空") raise ValueError("通知模板渲染结果不能为空")
return content return content

View File

@@ -7,6 +7,7 @@ from django.db.models import Exists, F, OuterRef
from django.utils import timezone from django.utils import timezone
from mission import models as mission_models from mission import models as mission_models
from mission.payload_processors import apply_mission_payload_processor
from mission import services as mission_services from mission import services as mission_services
from notifier.models import NotificationEventKeyEnum from notifier.models import NotificationEventKeyEnum
from notifier.services import dispatch_notification_event 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) content_type = getattr(mission.content_type, "model", None)
next_count = mission.unreplied_notify_sent_count + 1 next_count = mission.unreplied_notify_sent_count + 1
return { payload = {
"mission_id": mission.id, "mission_id": mission.id,
"merchant_id": mission.merchant_id, "merchant_id": mission.merchant_id,
"description": mission.description, "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 "", "participant_names_display": "".join(participant_names) if participant_names else "",
"content_type": content_type or "", "content_type": content_type or "",
"content_id": mission.content_id 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_interval_minutes": mission.unreplied_notify_interval_minutes,
"unreplied_notify_max_count": mission.unreplied_notify_max_count, "unreplied_notify_max_count": mission.unreplied_notify_max_count,
"unreplied_notify_sent_count": next_count, "unreplied_notify_sent_count": next_count,
"unreplied_last_notified_at": notified_at.isoformat() if notified_at else "", "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]: def _get_due_unreplied_mission_ids(*, limit: int) -> list[int]:

View File

@@ -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 }}"
}

View File

@@ -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 }}"
}

View File

@@ -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 }}"
}

View File

@@ -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 }}"
}

View File

@@ -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 }}"
}

View File

@@ -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 }}"
}

View File

@@ -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 }}"
}

View File

@@ -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 %}"
}

View File

@@ -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 }}"
}

View File

@@ -0,0 +1,3 @@
{
"msgtype": "text"
}

View File

@@ -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"
}

View File

@@ -1,4 +1,5 @@
from unittest.mock import patch import json
from unittest.mock import MagicMock, patch
from django.test import TestCase from django.test import TestCase
from django.utils import timezone from django.utils import timezone
@@ -52,6 +53,103 @@ class NotifierServiceTestCase(TestCase):
self.assertIn("任务ID12", content) self.assertIn("任务ID12", content)
self.assertIn("创建人:张三", 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") @patch("notifier.backends.send_wecom_webhook_message")
def test_send_notification_with_notifier_uses_wecom_backend(self, mock_send): def test_send_notification_with_notifier_uses_wecom_backend(self, mock_send):
mock_send.return_value.ok = True mock_send.return_value.ok = True
@@ -252,3 +350,262 @@ class NotifierServiceTestCase(TestCase):
self.assertEqual(result["sent_count"], 0) self.assertEqual(result["sent_count"], 0)
mock_dispatch.assert_not_called() 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")

View File

@@ -1,3 +0,0 @@
from django.shortcuts import render
# Create your views here.