forked from erp-dev/erp
feat: message-api for mission via wecomm agent
This commit is contained in:
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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
165
docs/MESSAGE_API.md
Normal 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 模式
|
||||||
412
docs/WECOM_APP_INTEGRATION_GUIDE.md
Normal file
412
docs/WECOM_APP_INTEGRATION_GUIDE.md
Normal 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 的查询能力,同时避免把不支持的能力暴露成错误体验。
|
||||||
@@ -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`
|
||||||
|
|
||||||
|
|||||||
@@ -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任务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 配置要点
|
## 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 为什么某个任务分类没有走专门群
|
||||||
|
|
||||||
常见原因:
|
常见原因:
|
||||||
|
|||||||
@@ -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` 已具备:
|
||||||
|
|
||||||
|
|||||||
@@ -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.
@@ -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
43
flower/settings_test.py
Normal 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()
|
||||||
@@ -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"]
|
||||||
|
|
||||||
|
|||||||
@@ -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 = apply_mission_payload_processor(
|
||||||
|
mission=instance,
|
||||||
|
event_key=NotificationEventKeyEnum.MISSION_CREATED,
|
||||||
payload={
|
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 = apply_mission_payload_processor(
|
||||||
|
mission=mission,
|
||||||
|
event_key=NotificationEventKeyEnum.MISSION_REPLIED,
|
||||||
payload={
|
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 = apply_mission_payload_processor(
|
||||||
|
mission=mission,
|
||||||
|
event_key=NotificationEventKeyEnum.MISSION_REPLY_REJECTED,
|
||||||
payload={
|
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 = apply_mission_payload_processor(
|
||||||
|
mission=instance,
|
||||||
|
event_key=NotificationEventKeyEnum.MISSION_REOPENED,
|
||||||
payload={
|
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 = apply_mission_payload_processor(
|
||||||
|
mission=instance,
|
||||||
|
event_key=NotificationEventKeyEnum.MISSION_CANCELLED,
|
||||||
payload={
|
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,
|
||||||
|
|||||||
22
mission/migrations/0007_missioncategory_payload_processor.py
Normal file
22
mission/migrations/0007_missioncategory_payload_processor.py
Normal 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 增强器",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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
|
||||||
|
|||||||
86
mission/payload_processors.py
Normal file
86
mission/payload_processors.py
Normal 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
|
||||||
110
mission/tests.py
110
mission/tests.py
@@ -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",
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
132
notifier/message_api.py
Normal 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 []
|
||||||
@@ -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='事件标识'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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
99
notifier/serializers.py
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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]:
|
||||||
|
|||||||
@@ -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 }}"
|
||||||
|
}
|
||||||
@@ -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 }}"
|
||||||
|
}
|
||||||
4
notifier/templates/notifier/events/mission_created.json
Normal file
4
notifier/templates/notifier/events/mission_created.json
Normal 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 }}"
|
||||||
|
}
|
||||||
4
notifier/templates/notifier/events/mission_reopened.json
Normal file
4
notifier/templates/notifier/events/mission_reopened.json
Normal 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 }}"
|
||||||
|
}
|
||||||
4
notifier/templates/notifier/events/mission_replied.json
Normal file
4
notifier/templates/notifier/events/mission_replied.json
Normal 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 }}"
|
||||||
|
}
|
||||||
@@ -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 }}"
|
||||||
|
}
|
||||||
@@ -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 }}"
|
||||||
|
}
|
||||||
@@ -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 %}"
|
||||||
|
}
|
||||||
@@ -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 }}"
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"msgtype": "text"
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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("任务ID:12", content)
|
self.assertIn("任务ID:12", 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")
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
from django.shortcuts import render
|
|
||||||
|
|
||||||
# Create your views here.
|
|
||||||
Reference in New Issue
Block a user