# CRM 微信推送接口调用文档

更新日期：2026-10-26。接口版本：v1。

CRM 提交客户信息后，服务将其映射为“仙宝科技”公众号的“新客户跟进通知”，持久化入队，再异步发送。CRM 无需管理公众号 AppSecret、微信 access_token、模板 ID 或微信字段名。

- 服务地址：`https://openid.xianboss.com`
- 在线文档：[CRM 推送接口](https://openid.xianboss.com/crm-api.html)
- OpenAPI 3.0.3：[下载 JSON](https://openid.xianboss.com/crm-openapi.json)，可导入 Apifox、Postman 等 API 工具。
- 请求示例文件：[`examples/crm-customer-followup.json`](crm-customer-followup.json)

## 1. 鉴权

每个调用系统使用独立的 **API Key + API Secret**，两项必须同时提供：

```http
X-API-Key: YOUR_CRM_API_KEY
X-API-Secret: YOUR_CRM_API_SECRET
```

Key 也可通过 Bearer 提供，此时仍须携带 Secret：

```http
Authorization: Bearer YOUR_CRM_API_KEY
X-API-Secret: YOUR_CRM_API_SECRET
```

请求头同时包含 `Content-Type: application/json`。密钥在单独的接入信息文件中交付，本文和公开在线文档不包含真实密钥。请在 CRM 服务端读取环境变量或安全配置，避免将业务密钥写入浏览器前端。调用系统须获授权使用 `xianbao` 公众号通道，且接收人 OpenID 必须已由管理员绑定到该系统及该通道。管理员密钥用于后台管理。

`openId` 指通知接收人，例如负责跟进的 CRM 员工或管理人员的微信身份；`customerName` 等客户字段来自 CRM 客户记录，两者不必是同一个人。CRM 可以将员工账号与其 OpenID 绑定，再按负责人发送通知。

接收人的 OpenID 必须属于目标公众号，接收人需已关注公众号并符合微信的消息接收条件。用户可在微信内打开 [OpenID 获取页面](https://openid.xianboss.com/?channel=xianbao) 后获取自己的 OpenID。

### 系统与接收人隔离

管理员在[管理后台](https://pmsg.admin.xianboss.com/admin/)为每个调用系统创建凭据、选择允许使用的通道，并添加该系统的接收人。接收人授权按“调用系统 + 公众号通道 + OpenID”精确匹配，OpenID 区分大小写。新系统未绑定接收人时不能推送；拥有相同通道权限的其他系统不能使用未分配给自己的 OpenID。

获取 OpenID 或在 CRM 内绑定员工不会自动授予 PushMsg 发送权限，仍需管理员在对应系统下添加接收人。未授权、停用或删除的接收人返回 `403 RECIPIENT_FORBIDDEN`，不会入队；重复提交也会重新检查权限。队列发送前再次检查系统、通道和接收人权限，撤销后尚未开始发送的任务会被阻止。

创建或轮换凭据时，Secret 仅返回一次，服务端保存其摘要；Key 可在后台查看。请将二者保存在调用系统服务端配置中。轮换会同时更换 Key 和 Secret，旧凭据立即失效，调用系统 ID、历史消息及幂等范围保留。旧版只有 Key 的接入必须由管理员轮换凭据并配置接收人后，改为双凭据调用。

## 2. 提交 CRM 推送

```http
POST /api/v1/crm/push
Content-Type: application/json
X-API-Key: YOUR_CRM_API_KEY
X-API-Secret: YOUR_CRM_API_SECRET
```

```json
{
  "requestId": "crm-customer-C0001-followup-20261026-001",
  "openId": "YOUR_OPENID",
  "customerName": "测试客户",
  "customerType": "潜在客户",
  "customerSource": "CRM录入",
  "customerPhone": "13800000000",
  "markedAt": "2026-10-26 11:00",
  "url": "https://crm.example.com/customers/C0001"
}
```

| 字段 | 必填 | 类型与限制 | 用途 |
| --- | --- | --- | --- |
| requestId | 是 | 非空字符串，最长 128 字符 | 同一调用方中的业务通知唯一标识，用于幂等去重 |
| openId | 是 | 非空字符串，最长 128 字符 | 通知接收人（如 CRM 负责人）在“仙宝科技”公众号下的 OpenID |
| customerName | 是 | 非空字符串，最长 20 字符 | CRM 客户记录中的客户名称 |
| customerType | 是 | 非空字符串，最长 20 字符 | 客户类型 |
| customerSource | 是 | 非空字符串，最长 20 字符 | 客户来源 |
| customerPhone | 是 | 3–17 字符，至少一个数字；只允许数字、`+`、括号、空格和短横线 | 客户联系电话 |
| markedAt | 是 | 严格格式 `yyyy-MM-dd HH:mm`，且必须是有效日期时间 | 标记时间，按北京时间解释 |
| url | 否 | 完整 HTTP/HTTPS URL，最长 2048 字符；可省略或传 null | 点击消息后的 CRM 详情地址 |

必填字段不接受全空白内容，所有字符串字段不接受控制字符。接口不自动去除空格或改写字段值。`markedAt` 不会默认取当前时间，CRM 应提供本次业务事件的固定时间，便于重试时保持请求一致。

请求只允许上述字段。额外提交 `channelId`、`templateId`、`type`、`data` 等字段会返回 `400 UNSUPPORTED_CRM_FIELD`。公众号与模板由服务端配置。

### 2.1 接收成功

首次提交返回 **HTTP 202**，表示已接收并入队：

```json
{
  "id": "11111111-2222-4333-8444-555555555555",
  "status": "PENDING",
  "duplicate": false
}
```

保存返回的 `id`，使用查询接口跟踪后续结果。HTTP 202 不表示微信已接受消息。

### 2.2 幂等与网络重试

幂等范围是“**调用方 API Key 对应的应用 + requestId**”。请求标识区分大小写。

- 同一 `requestId`、相同映射内容：返回 **HTTP 200**、原消息 `id`、当前 `status` 和 `duplicate:true`，不会创建第二条任务。
- 同一 `requestId`、不同映射内容：返回 **HTTP 409**、`IDEMPOTENCY_CONFLICT`。
- CRM 遇到网络超时、连接中断或临时服务错误时，复用**原 requestId 和相同业务内容**再次提交。不要通过换一个 requestId 的方式重试同一通知。
- 新的业务通知应使用新的 requestId，例如 `crm-customer-C0001-followup-事件编号`。

JSON 对象的键顺序不会影响去重；字段值、接收人和详情 URL 改变会影响消息内容。保持重试请求完全一致最容易接入。

## 3. 查询发送状态

```http
GET /api/v1/crm/messages/{id}
X-API-Key: YOUR_CRM_API_KEY
X-API-Secret: YOUR_CRM_API_SECRET
```

必须使用原业务调用方的密钥。其他调用方的消息按不存在处理，返回 HTTP 404。

```json
{
  "id": "11111111-2222-4333-8444-555555555555",
  "requestId": "crm-customer-C0001-followup-20261026-001",
  "status": "SENT",
  "attempts": 1,
  "maxAttempts": 5,
  "wechatMessageId": "WECHAT_MESSAGE_ID",
  "lastError": null,
  "createdAt": "2026-10-26T11:00:00",
  "updatedAt": "2026-10-26T11:00:01"
}
```

| 字段 | 说明 |
| --- | --- |
| id / requestId | 消息 ID / CRM 业务请求标识 |
| status | 下表中的队列状态 |
| attempts / maxAttempts | 当前发送尝试次数 / 自动重试上限 |
| wechatMessageId | 微信返回的消息 ID，以字符串处理；尚未成功或未返回时为 null |
| lastError | 脱敏后的错误说明，正常时为 null |
| createdAt / updatedAt | 北京时间，不含时区后缀，可能带小数秒 |

| status | 含义 | CRM 处理建议 |
| --- | --- | --- |
| PENDING | 等待发送 | 稍后查询 |
| PROCESSING | 正在处理 | 稍后查询，不另建任务 |
| RETRY | 等待自动重试 | 稍后查询，不由 CRM 创建重复任务 |
| SENT | 微信接口已接受 | 记录结果；这不代表用户已收到或阅读 |
| FAILED | 微信明确拒绝、配置错误或达到重试上限 | 查看 lastError，由管理员处理 |
| UNKNOWN | 发送结果无法确定 | 人工核实；不要自动重发或换新 requestId |

可每 2–5 秒查询一次；业务通知量较大时适当放宽查询间隔。服务仅对明确可重试的临时错误进行退避重试，最多 5 次。`UNKNOWN` 的人工重试可能重复通知，需要先确认实际发送情况。

## 4. 查看 CRM 模板配置

```http
GET /api/v1/crm/template
X-API-Key: YOUR_CRM_API_KEY
X-API-Secret: YOUR_CRM_API_SECRET
```

接口要求业务密钥具备当前通道权限。当前配置如下：

```json
{
  "channelId": "xianbao",
  "templateName": "新客户跟进通知",
  "templateId": "OpH0RvriM-Zi5WmN25X6ox0FY6rKMoStoXb_ab-jiRw",
  "fields": [
    {"field": "customerName", "templateField": "thing2", "label": "客户名称"},
    {"field": "customerType", "templateField": "thing3", "label": "客户类型"},
    {"field": "customerSource", "templateField": "thing5", "label": "客户来源"},
    {"field": "customerPhone", "templateField": "phone_number6", "label": "客户电话"},
    {"field": "markedAt", "templateField": "time8", "label": "标记时间"}
  ],
  "timeZone": "Asia/Shanghai"
}
```

CRM 发送时只传业务字段，不需要将这里的模板 ID 或微信字段名带回提交接口。

## 5. cURL 示例

将 API Key、API Secret 和已授权接收人的 OpenID 放入环境变量后执行。请将 requestId、客户信息和详情链接替换为实际业务值。

```bash
export CRM_PUSH_API_KEY='YOUR_CRM_API_KEY'
export CRM_PUSH_API_SECRET='YOUR_CRM_API_SECRET'
export CRM_RECEIVER_OPENID='YOUR_OPENID'

curl --fail-with-body 'https://openid.xianboss.com/api/v1/crm/push' \
  -H "X-API-Key: $CRM_PUSH_API_KEY" \
  -H "X-API-Secret: $CRM_PUSH_API_SECRET" \
  -H 'Content-Type: application/json' \
  --data-binary @- <<JSON
{
  "requestId": "crm-customer-C0001-followup-20261026-001",
  "openId": "$CRM_RECEIVER_OPENID",
  "customerName": "测试客户",
  "customerType": "潜在客户",
  "customerSource": "CRM录入",
  "customerPhone": "13800000000",
  "markedAt": "2026-10-26 11:00",
  "url": "https://crm.example.com/customers/C0001"
}
JSON
```

使用返回的消息 ID 查询：

```bash
curl --fail-with-body \
  'https://openid.xianboss.com/api/v1/crm/messages/11111111-2222-4333-8444-555555555555' \
  -H "X-API-Key: $CRM_PUSH_API_KEY" \
  -H "X-API-Secret: $CRM_PUSH_API_SECRET"
```

## 6. Java 17 HttpClient 示例

以下类仅使用 Java 17 标准库。环境变量 `CRM_PUSH_API_KEY`、`CRM_PUSH_API_SECRET`、`CRM_RECEIVER_OPENID` 和 `CRM_PUSH_REQUEST_ID` 必须设置；同一业务通知的再次执行应继续使用相同 requestId 与相同内容。

```java
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Set;
import java.util.regex.Pattern;

public class CrmPushExample {
    private static final String BASE = "https://openid.xianboss.com";
    private static final HttpClient HTTP = HttpClient.newBuilder()
        .connectTimeout(Duration.ofSeconds(5))
        .followRedirects(HttpClient.Redirect.NEVER).build();

    public static void main(String[] args) throws Exception {
        String key = env("CRM_PUSH_API_KEY");
        String secret = env("CRM_PUSH_API_SECRET");
        String openId = env("CRM_RECEIVER_OPENID");
        String requestId = env("CRM_PUSH_REQUEST_ID");
        String body = "{" +
            "\"requestId\":" + quote(requestId) + "," +
            "\"openId\":" + quote(openId) + "," +
            "\"customerName\":\"测试客户\"," +
            "\"customerType\":\"潜在客户\"," +
            "\"customerSource\":\"CRM录入\"," +
            "\"customerPhone\":\"13800000000\"," +
            "\"markedAt\":\"2026-10-26 11:00\"," +
            "\"url\":\"https://crm.example.com/customers/C0001\"}";

        HttpRequest request = HttpRequest.newBuilder(URI.create(BASE + "/api/v1/crm/push"))
            .timeout(Duration.ofSeconds(20)).header("X-API-Key", key).header("X-API-Secret", secret)
            .header("Content-Type", "application/json")
            .POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8)).build();
        HttpResponse<String> receipt = HTTP.send(request,
            HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
        if (receipt.statusCode() != 200 && receipt.statusCode() != 202) {
            throw new IllegalStateException("提交失败 HTTP " + receipt.statusCode() + ": " + receipt.body());
        }
        System.out.println("提交结果：" + receipt.body());
        var idMatch = Pattern.compile("\\\"id\\\"\\s*:\\s*\\\"([0-9a-fA-F-]{36})\\\"")
            .matcher(receipt.body());
        if (!idMatch.find()) throw new IllegalStateException("响应缺少消息 ID");
        String id = idMatch.group(1);
        var statusPattern = Pattern.compile("\\\"status\\\"\\s*:\\s*\\\"([A-Z_]+)\\\"");

        for (int attempt = 0; attempt < 15; attempt++) {
            HttpRequest query = HttpRequest.newBuilder(URI.create(BASE + "/api/v1/crm/messages/" + id))
                .timeout(Duration.ofSeconds(20)).header("X-API-Key", key).header("X-API-Secret", secret).GET().build();
            HttpResponse<String> result = HTTP.send(query,
                HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
            if (result.statusCode() != 200) {
                throw new IllegalStateException("查询失败 HTTP " + result.statusCode() + ": " + result.body());
            }
            var state = statusPattern.matcher(result.body());
            if (!state.find()) throw new IllegalStateException("响应缺少状态");
            if (Set.of("SENT", "FAILED", "UNKNOWN").contains(state.group(1))) {
                System.out.println("处理结果：" + result.body());
                return;
            }
            Thread.sleep(2000);
        }
        System.out.println("任务仍在队列，请保存消息 ID 后稍后查询：" + id);
    }

    private static String env(String name) {
        String value = System.getenv(name);
        if (value == null || value.isBlank()) throw new IllegalArgumentException("缺少环境变量 " + name);
        return value;
    }

    private static String quote(String value) {
        StringBuilder out = new StringBuilder("\"");
        for (char c : value.toCharArray()) {
            if (c == '"' || c == '\\') out.append('\\').append(c);
            else if (c < 0x20) out.append(String.format("\\u%04x", (int) c));
            else out.append(c);
        }
        return out.append('"').toString();
    }
}
```

保存为 `CrmPushExample.java` 后，可用 `javac -encoding UTF-8 CrmPushExample.java` 编译，再执行 `java CrmPushExample`。示例不会在网络异常后创建新的请求标识；业务系统可在持久化原 requestId 和请求内容后按前述幂等规则重试。

## 7. 错误与限制

应用返回的错误通常为：

```json
{"code":"IDEMPOTENCY_CONFLICT","message":"同一 requestId 已用于不同消息，请为新消息生成新 requestId"}
```

| HTTP 状态 | 含义与处理 |
| --- | --- |
| 400 | 请求格式、字段、时间、URL 或额外字段不正确；也可能是 `CHANNEL_DISABLED`，表示 CRM 通道已停用 |
| 401 | Key 或 Secret 缺失、不匹配、尚未配置，或调用方已停用 |
| 403 | `CHANNEL_FORBIDDEN`：无通道权限；`RECIPIENT_FORBIDDEN`：该 OpenID 未授权给当前调用系统、已停用或已删除 |
| 404 | 消息不存在、属于其他调用方，或资源不存在 |
| 409 | `IDEMPOTENCY_CONFLICT`：同一 requestId 被用于不同内容 |
| 413 | 请求体超过 64KB |
| 429 | 接口限流，请延迟后使用原 requestId 重试 |
| 500 | 服务内部错误，请保留原请求标识并稍后查询或重试 |
| 503 | 数据库暂不可用，或 `CRM_NOT_CONFIGURED` 表示 CRM 通道/模板尚未配置，需要检查服务状态 |

常见 HTTP 400 业务错误：`VALIDATION_ERROR`（必填、长度或格式不符）、`INVALID_CRM_REQUEST`（请求非 JSON 对象、字段类型错误或含控制字符）、`INVALID_CRM_TIME`（无效日期时间）、`INVALID_CRM_PHONE`（电话号码不含数字）、`UNSUPPORTED_CRM_FIELD`（传入未支持字段）、`INVALID_URL`（详情链接格式错误）。

新客户跟进消息固定使用当前配置的“新客户跟进通知”模板。消息内容应对应实际 CRM 业务事件。微信最终拒绝的消息会进入 `FAILED`，原因见 `lastError`；例如模板 ID 无效、接收人未关注或微信平台限制。不要把所有错误都当作可自动重试的错误。

Nginx 入口按来源 IP 限流；请求正文最大 64KB。调用方应在提交前保存业务通知、requestId 和发送内容，再保存返回的消息 ID，便于状态追踪与人工处理。
