Webhook

采集时主动推

企业在管理端「达人数据库 → 数据同步」创建端点后,Blogger 新增或更新会进入端点缓冲队列, 平台按配置把标准 Blogger JSON 推到客户 HTTP/HTTPS 接口。

触发流程

  1. Blogger 由采集、导入或手动保存产生新增/更新。
  2. 后端查找同企业下启用的 SyncEndpoint。
  3. 按端点 platformFilter 判断是否命中当前平台。
  4. 命中后进入端点级缓冲队列。
  5. 达到 batchSize 或 200ms 软超时后推送。
  6. 写入 SyncEndpointRun,便于审计与失败重推。
主动推不依赖企业 dataMode。LOCAL 和 CLOUD 都可以配置 Webhook。

签名

每个同步端点创建时会返回一次性明文 WEBHOOK_SECRET。后端后续不再返回明文, 客户接收端必须自行保存。

Content-Type: application/json
X-ZS-Signature: sha256=<hex>
X-ZS-Timestamp: <unix seconds>

签名内容固定为 raw body bytes 加换行和时间戳:

signContent = rawRequestBody + "\n" + X-ZS-Timestamp
signature = HMAC_SHA256(WEBHOOK_SECRET, signContent)

接收端建议在验签后校验时间戳与当前时间相差不超过 300 秒。

Payload

bloggers 永远是数组。即使 batchSize = 1,也不要按单对象解析。
采集回传只承诺平台公开数据或企业已授权字段;联系方式不作为采集回传字段。
{
  "bloggers": [
    {
      "id": "blg_xxx",
      "platform": "PGY",
      "platformBloggerId": "5fa1...",
      "nickname": "示例达人",
      "avatar": null,
      "url": "https://...",
      "gender": null,
      "location": "上海",
      "category": "美妆",
      "fansCount": 12345,
      "interactRate": 350,
      "priceJson": {
        "imageText": 5000,
        "shortVideo": 12000
      },
      "tags": ["美妆", "护肤"],
      "remark": null,
      "source": "SCRAPE",
      "rawData": { "platformOriginal": true },
      "createdAt": "2026-05-01T10:00:00.000Z",
      "updatedAt": "2026-05-12T08:30:00.000Z"
    }
  ],
  "endpointId": "se_xxx",
  "runId": "ser_xxx",
  "attempt": 1
}

对外 Blogger 契约不暴露 organizationIdownerUserId

重试与重推

单次请求失败会按端点 retryCount 重试。最终失败后,failedBloggerIds 会记录本批失败的 Blogger id。

管理端「立即重推」会读取失败 run 的 failedBloggerIds,生成一条新的 run,attempt = 上一次 attempt + 1

Node 接收端骨架

app.use('/zs-webhook', express.raw({ type: 'application/json' }));

app.post('/zs-webhook', async (req, res) => {
  const rawBody = req.body;
  const timestamp = req.header('X-ZS-Timestamp') ?? '';
  const signature = req.header('X-ZS-Signature') ?? '';

  const expected = crypto
    .createHmac('sha256', process.env.WEBHOOK_SECRET)
    .update(Buffer.concat([rawBody, Buffer.from('\n' + timestamp)]))
    .digest('hex');

  if (signature !== 'sha256=' + expected) {
    return res.status(401).end();
  }

  const payload = JSON.parse(rawBody.toString('utf8'));
  for (const blogger of payload.bloggers) {
    await upsertBlogger(blogger);
  }

  res.json({ ok: true });
});

Java 接收端骨架

@RestController
public class ZsWebhookController {
  @PostMapping(
      value = "/zs-webhook",
      consumes = MediaType.APPLICATION_JSON_VALUE
  )
  public ResponseEntity<Map<String, Boolean>> receive(
      @RequestBody byte[] rawBody,
      @RequestHeader("X-ZS-Timestamp") String timestamp,
      @RequestHeader("X-ZS-Signature") String signature
  ) throws Exception {
    String expected = "sha256=" + hmacSha256Hex(
        System.getenv("WEBHOOK_SECRET"),
        rawBody,
        timestamp
    );

    if (!MessageDigest.isEqual(
        expected.getBytes(StandardCharsets.UTF_8),
        signature.getBytes(StandardCharsets.UTF_8)
    )) {
      return ResponseEntity.status(401).build();
    }

    long skew = Math.abs(Instant.now().getEpochSecond() - Long.parseLong(timestamp));
    if (skew > 300) {
      return ResponseEntity.status(401).build();
    }

    JsonNode payload = new ObjectMapper().readTree(rawBody);
    for (JsonNode blogger : payload.get("bloggers")) {
      upsertBlogger(blogger);
    }

    return ResponseEntity.ok(Map.of("ok", true));
  }

  private String hmacSha256Hex(String secret, byte[] rawBody, String timestamp) throws Exception {
    Mac mac = Mac.getInstance("HmacSHA256");
    mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
    mac.update(rawBody);
    mac.update((byte)'\n');
    byte[] digest = mac.doFinal(timestamp.getBytes(StandardCharsets.UTF_8));
    return HexFormat.of().formatHex(digest);
  }
}