Webhook
采集时主动推
企业在管理端「达人数据库 → 数据同步」创建端点后,Blogger 新增或更新会进入端点缓冲队列, 平台按配置把标准 Blogger JSON 推到客户 HTTP/HTTPS 接口。
触发流程
- Blogger 由采集、导入或手动保存产生新增/更新。
- 后端查找同企业下启用的 SyncEndpoint。
- 按端点
platformFilter判断是否命中当前平台。 - 命中后进入端点级缓冲队列。
- 达到
batchSize或 200ms 软超时后推送。 - 写入
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 契约不暴露 organizationId 和 ownerUserId。
重试与重推
单次请求失败会按端点 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);
}
}