# CRM Webhook Worker

更新时间：2026-06-30

`apps/worker` 是后端异步任务进程，当前 CRM 任务支持客资首绑 webhook 推送和租户后台学生批量跟进推送。API 只负责在客资首绑或学生运营动作时写入 `crm_webhook_queue`；worker 负责消费到期任务、签名、发送、失败重试和写入 `crm_webhook_log`。

## 运行命令

本地单次消费一批任务：

```bash
npm --workspace @tiku-saas/worker run crm:once
```

或使用根脚本测试：

```bash
npm run test:worker:crm
```

生产常驻：

```bash
npm --workspace @tiku-saas/worker run start
```

建议 API 和 worker 作为两个独立进程部署，共用同一个 `DATABASE_URL`。

## 配置

环境变量：

```text
WORKER_CRM_BATCH_SIZE=20
WORKER_CRM_POLL_INTERVAL_MS=10000
WORKER_CRM_MAX_ATTEMPTS=5
WORKER_CRM_BACKOFF_SECONDS=5,30,120,600,1800
WORKER_CRM_REQUEST_TIMEOUT_MS=10000
WORKER_CRM_ALLOW_INSECURE_LOCALHOST=false
```

租户后台配置仍走：

```text
PUT /api/crm/config
GET /api/crm/config
GET /api/crm/queue
GET /api/crm/dead-letters
GET /api/crm/queue/logs
POST /api/crm/queue/action
POST /api/tenant-admin/students/crm-push
```

密钥必须写入 `app_private.tenant_secrets`，公共表 `crm_config.secret_ref` 只保留引用，例如：

```text
app_private.tenant_secrets:crm:webhook
```

## Provider

worker 根据队列 payload 的 `provider` 或 webhook URL 自动判断：

| provider | URL 特征 | 行为 |
| --- | --- | --- |
| `generic` | 默认 | POST `{ event: "lead.created", data: ... }` 或 `{ event: "student.crm_push", data: ... }` |
| `dingtalk` | `dingtalk.com` | markdown 消息；按钉钉自定义机器人安全设置做 timestamp/sign |
| `feishu` | `feishu.cn` / `larksuite.com` | interactive card；按飞书自定义机器人签名做 timestamp/sign |
| `wecom` | `qyapi.weixin.qq.com` / `work.weixin.qq.com` | 企业微信群机器人 markdown 消息 |

官方参考：

- 钉钉开放平台：[自定义机器人安全设置](https://open.dingtalk.com/document/robots/customize-robot-security-settings)，`timestamp + "\n" + secret` 做 HMAC-SHA256 后 Base64。
- 飞书开放平台：[自定义机器人签名校验](https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot)，使用 timestamp 和 secret 生成签名。
- 企业微信开发者文档：[群机器人配置说明](https://developer.work.weixin.qq.com/document/path/91770)，群机器人 webhook 使用 `msgtype` 消息体。

## 队列状态

`crm_webhook_queue.status`：

```text
pending -> processing -> sent
pending -> processing -> retrying -> processing -> sent
pending -> processing -> retrying -> failed
pending -> discarded
```

说明：

- `pending` / `retrying`：等待 worker 消费。
- `processing`：worker 已抢占任务。
- `sent`：目标 webhook 返回 2xx。
- `failed`：超过最大重试次数。
- `discarded`：租户 CRM 未启用或 URL 缺失，任务不会继续重试。

每次尝试都会写入 `crm_webhook_log`，日志只记录 provider、目标 host、请求体和响应摘要，不写入 webhook secret。

## 死信运营

CRM 失败运营入口已经落地，租户后台只通过后端 API 处理失败任务：

```text
GET  /api/crm/dead-letters?status=failed&limit=20
GET  /api/crm/queue/logs?queueId=<crmQueueId>
POST /api/crm/queue/action
```

`POST /api/crm/queue/action` 当前支持：

```json
{ "queueId": "<crmQueueId>", "action": "retry", "reason": "确认 webhook 已恢复" }
```

```json
{ "queueId": "<crmQueueId>", "action": "ignore", "reason": "租户确认不再推送" }
```

安全边界：

- 读取失败池、查看日志和重试/忽略都要求租户内 `crm:write` 权限。
- `retry` 会把 `failed/discarded/retrying/pending` 任务重置为 `pending`，清空错误并记录操作者。
- `ignore` 只允许处理 `failed/discarded` 任务，会标记为 `discarded` 并记录原因。
- API 会递归脱敏 payload、日志和审计响应中的 secret、token、authorization、password 等敏感字段。
- 所有动作都会写入 `crm_webhook_log` 和租户审计日志，前端不能直接改 `crm_webhook_queue`。

## 安全边界

- 前端不能直接写 `crm_webhook_queue`。
- 前端不能持有 CRM webhook secret。
- 学生批量推送必须走 `POST /api/tenant-admin/students/crm-push`，后端会校验 `crm:write`、`students:read`、`students:followups:write`、学生范围、班级范围和租户成员指派人。
- 学生推送 payload 只包含 `avatarPreset` 默认头像口径，不写入、不回显微信/QQ/旧库头像 URL。
- 生产环境 webhook 必须使用 HTTPS。
- `WORKER_CRM_ALLOW_INSECURE_LOCALHOST=true` 只用于本地 fake webhook 测试。
- worker 使用数据库服务端连接读取 `app_private.tenant_secrets`，不要把 secret 复制到公共表。

## 后续增强

- 钉钉/飞书/企微富卡片模板。
- 外部失败告警升级。
- 更细销售转化看板。
