接收回調
Node.js SDK 支援事件回撥、同步回撥和 URL 驗證,處理簽名、隨機數、防重放、事件去重和分派。控制台配置見回撥,協議見回撥概述。
可直接執行的 Fastify、Express、NestJS 示例及“建立地址 → 啟動 → 驗證”流程見 示例指南。
| 要做什麼 | 看哪裡 |
|---|---|
| 第一次接入回撥 | 接入順序、建立處理器 |
| 多例項部署,用 Redis 防重放和去重 | 共享儲存 |
| 掛到 Express、NestJS、Fastify、Koa、Next.js 上 | Express、Fastify、Koa、Next.js 與 Web 標準 Request |
| 處理事件、交給佇列 | 事件處理 |
| 在訊息傳送前稽核、拒絕或修改 | 同步回撥 |
| 簽名失敗、返回 503 等問題 | 驗證、簽名與排查 |
| 對回撥做單元測試 | 本地測試 |
接入順序
- 在控制台建立回撥地址,記錄 endpoint ID 與簽名金鑰;接收 URL 必須由 IM 服務端訪問得到。
- 在下方選擇框架介面卡,以原始請求體建立並掛載處理器。多例項部署先配置共享儲存。
- 啟動接收服務,在控制台執行 URL 驗證;處理器自動回應 challenge。
- 啟用所需事件訂閱或同步鉤子,觸發對應業務動作,檢查處理器日誌和業務結果。
URL 驗證只證明地址和驗籤流程可用。事件回撥與同步回撥需要各自配置並觸發;真實事件的業務去重也需要單獨驗證。
建立處理器
單程序測試或單例項部署可以顯式使用記憶體儲存:
import {
CallbackHandler, MemoryNonceStore, MemoryDedupStore, allow, reject,
} from '@deeprespond/im-server-sdk/callback';
const handler = new CallbackHandler({
secrets: {
[process.env.IM_CALLBACK_ENDPOINT_ID!]: [process.env.IM_CALLBACK_SECRET!],
},
nonceStore: new MemoryNonceStore(),
dedupStore: new MemoryDedupStore(),
});
handler.on('user.created', async (event, data, ctx) => {
console.log(event.id, data.username);
// 业务数据库操作可以使用 ctx.signal。
});
handler.onHook('message.before_send', async (req, ctx) => {
if (typeof req.body.text === 'string' && req.body.text.includes('spam')) return reject('spam', '消息不符合发送规则');
return allow();
});secrets 的鍵是回撥地址的 endpoint ID,值是金鑰陣列,不能傳單個字串。輪換期間同時提供新舊金鑰。也可用 async (endpointId, { signal }) => readonly string[] 動態讀取;找不到返回空陣列。取金鑰失敗或超時返回 503。
| 選項 | 預設值 | 說明 |
|---|---|---|
secrets | 必填 | endpoint ID 對應的金鑰陣列,或金鑰查詢函式 |
nonceStore、dedupStore | 必填 | 隨機數儲存、事件去重儲存 |
queue | 無 | 持久佇列,事件收到後交給 enqueue,見事件處理 |
hookFallback | null | 同步回撥失敗時返回 503;可顯式設定 allow() 或 reject() 等結果 |
maxConcurrentHooks | 100 | 同時執行的同步回撥處理函式上限,包含超時後仍在執行的函式 |
claimTtlMs | 30000 | 事件處理的佔用時限(毫秒) |
maxBodyBytes | 2097152 | 最大請求體位元組數,預設 2 MiB |
logger | error、warn 輸出到 console | 日誌級別和 sink |
共享儲存
多程序、叢集、Serverless 或多臺伺服器部署時使用共享 Redis;記憶體儲存隨程序退出丟失,也不能跨例項防重放和去重。
import { Redis } from 'ioredis';
import { CallbackHandler } from '@deeprespond/im-server-sdk/callback';
import { ioredisStores } from '@deeprespond/im-server-sdk/redis';
const redis = new Redis(process.env.REDIS_URL!);
const { nonceStore, dedupStore } = ioredisStores(redis, {
keyPrefix: 'my-service:im:',
retentionMs: 7 * 24 * 60 * 60 * 1000,
});
const handler = new CallbackHandler({
secrets: { [process.env.IM_CALLBACK_ENDPOINT_ID!]: [process.env.IM_CALLBACK_SECRET!] },
nonceStore,
dedupStore,
});使用 node-redis 時呼叫 nodeRedisStores(redis, options),先連線 Redis。預設 keyPrefix 為 drim:,事件去重記錄預設保留 7 天。所有接收相同回撥的例項使用相同儲存和字首,Redis 連線由業務應用管理。
Express
import express from 'express';
import { expressHandler } from '@deeprespond/im-server-sdk/callback/express';
const app = express();
app.post('/im/callback', expressHandler(handler));
app.use(express.json());
app.listen(3000);適用於 Express 4、5。回撥路由放在 JSON 解析中介軟體之前,介面卡直接讀取原始位元組。如果先使用 express.raw({ type: 'application/json', limit: '2mb' }),也可將原始 Buffer 交給介面卡。已經解析成物件且未保留原文的請求無法驗籤;不要 JSON.stringify 後再驗籤。
NestJS
使用 Express 平臺,以 rawBody: true 建立應用,保留原始請求體:
import { NestFactory } from '@nestjs/core';
import type { NestExpressApplication } from '@nestjs/platform-express';
import { expressHandler } from '@deeprespond/im-server-sdk/callback/express';
// AppModule 为业务应用已有的 NestJS 模块。
const app = await NestFactory.create<NestExpressApplication>(AppModule, { rawBody: true });
app.useBodyParser('json', { limit: '2mb' });
app.use('/im/callback', expressHandler(handler));
await app.listen(3000);Fastify
import Fastify from 'fastify';
import { fastifyCallback } from '@deeprespond/im-server-sdk/callback/fastify';
const app = Fastify();
await app.register(fastifyCallback(handler), { path: '/im/callback' });
await app.listen({ port: 3000 });適用於 Fastify 5,外掛在自己的路由作用域中儲存原始位元組,不改變其他路由的 JSON 解析。
Koa
import Koa from 'koa';
import { koaHandler, type KoaContextLike } from '@deeprespond/im-server-sdk/callback/koa';
const app = new Koa();
const receive = koaHandler(handler);
app.use(async (ctx, next) => {
if (ctx.path === '/im/callback') await receive(ctx as KoaContextLike);
else await next();
});
app.listen(3000);適用於 Koa 2、3。將此路由放在 body parser 之前;如已使用 koa-bodyparser 或 @koa/bodyparser,需要保留 ctx.request.rawBody 並將 jsonLimit 設為 2mb。
Next.js 與 Web 標準 Request
介面卡使用 Request 和 Response,但必須在 Node.js 執行時執行。Next.js App Router 的 app/im/callback/route.ts:
import { fetchHandler } from '@deeprespond/im-server-sdk/callback/fetch';
import { handler } from '@/lib/im-callback'; // 导出上文创建并注册好的处理器。
export const runtime = 'nodejs';
export const POST = fetchHandler(handler);不要預先呼叫 request.json() 消耗請求體。Serverless 的多個例項使用共享儲存;該介面卡也可接入使用 Node.js 的 Hono 等框架。
原生 node:http 與自定義框架
import { createServer } from 'node:http';
import { nodeHandler } from '@deeprespond/im-server-sdk/callback/node';
const receive = nodeHandler(handler);
createServer((req, res) => {
if (req.url === '/im/callback') {
void receive(req, res).catch(() => { res.statusCode = 500; res.end(); });
} else {
res.statusCode = 404;
res.end();
}
}).listen(3000);自定義框架可以呼叫 await handler.handle({ headers, body }),body 為原始 Uint8Array 或 ArrayBuffer;把返回的 status、headers、body 原樣寫入 HTTP 響應。
事件處理
handler.on(type, fn) 按事件名推斷 data 的型別,fn 引數是 (event, data, ctx)。同一事件只能註冊一次,事件 ID 為 event.id。已成功處理的事件不重複分派;整批中部分失敗時成功項仍保留去重記錄,重投時只處理未完成項。
處理函式丟擲錯誤時返回 503,服務端按投遞規則重試。需要指定等待時間時,丟擲 new RetryLater(seconds),從 callback 子路徑匯入。未知事件可用 handler.onUnknown((event, ctx) => ...) 處理;具體事件結構見事件回撥。
長任務可以傳入 queue: { async enqueue(event, ctx) { /* 写入持久队列 */ } }。enqueue 成功表示已可靠接收,失敗或超時返回 503。配置 queue 後不能再用 on、onAny、onUnknown 註冊事件處理函式;同步回撥仍可用 onHook。佇列消費者仍應按事件 ID 保證業務操作冪等。
SDK 去重不能使業務寫入和去重提交成為同一個事務。程序在業務寫入後、去重提交前退出時,事件可能再次處理,因此業務資料庫也應以事件 ID 防止重複副作用。
同步回撥
handler.onHook(name, fn) 的引數為 (req, ctx)。支援 message.before_send、friend.before_add、group.before_join、chatroom.message_before_send;返回值必須使用 SDK 的結果函式建立:
| 結果 | 用途 |
|---|---|
allow() | 放行 |
reject(reason?, message?) | 拒絕;reason 為 1~64 個小寫字母、數字或 _.-,message 為最多 256 個字元的單行提示 |
modify({ body?, ext? }) | 替換訊息正文或擴充套件欄位,適用範圍見同步回撥 |
partial([{ username, reason?, message? }]) | 部分拒絕,僅用於 group.before_join 的邀請和建群目標 |
ctx.timeoutMs 是服務端給出的等待時間,ctx.deadline 是 SDK 預留 50 毫秒後的截止時刻,ctx.signal 到時觸發。將 signal 傳給自己的資料庫或 fetch 呼叫,避免超時後任務仍長期佔用資源。未註冊、超時、拋錯或返回不合法結果時,預設返回 503,服務端按配置的失敗策略處理;hookFallback 是業務顯式選擇的替代結果。
不要在同步回撥中執行耗時的同步計算。它會阻塞 Node.js 事件迴圈,使截止計時器也無法及時觸發;將計算移到工作執行緒,或在時限內只完成必要的判斷。
驗證、簽名與排查
處理器自動響應 URL 驗證,業務無需單獨註冊驗證函式。簽名檢查使用原始請求體,校驗時間戳及隨機數;演算法和金鑰輪換見回撥安全。
常見接入問題:
- 簽名失敗:核對 endpoint ID、金鑰陣列和原始位元組是否被中介軟體改動。
- 時間戳失效:核對服務端和接收端的系統時鐘。
- 請求體超過限制:同時檢查 SDK、框架和反向代理的請求體上限。
- 返回 503:檢視金鑰查詢、Redis、處理函式異常和同步回撥時限。
| 響應 | 含義 |
|---|---|
| URL 驗證 200 | 返回帶 challenge 的驗證結果 |
| 事件 204 | 本批事件已接收;可能已去重,或沒有註冊相應處理函式 |
| 同步回撥 200 | 返回 SDK Verdict 編碼的 allow/reject/modify/partial 結果 |
| 400 / 401 / 413 / 503 | 請求結構、簽名/防重放、大小或處理故障,見回撥排查 |
本地測試
import assert from 'node:assert/strict';
import { CallbackHandler, MemoryNonceStore, MemoryDedupStore } from '@deeprespond/im-server-sdk/callback';
import { eventBody, signedRequest } from '@deeprespond/im-server-sdk/testing';
const body = eventBody(['user.created', { username: 'alice' }]);
const endpointId = JSON.parse(Buffer.from(body).toString('utf8')).endpoint_id as string;
const handler = new CallbackHandler({
secrets: { [endpointId]: ['test-secret'] },
nonceStore: new MemoryNonceStore(),
dedupStore: new MemoryDedupStore(),
});
let username = '';
handler.on('user.created', (_event, data) => { username = data.username; });
const response = await handler.handle(signedRequest('test-secret', endpointId, body));
assert.equal(response.status, 204);
assert.equal(username, 'alice');
await handler.close();hookBody(name, data, { timeoutMs?, test? }) 構造同步回撥請求體,verifyBody(challenge) 構造 URL 驗證請求體,再用 signedRequest 添加簽名。也可以把構造出的原始位元組發給本地框架路由,驗證中介軟體順序。
停止服務
先停止接收新請求,再 await handler.close({ timeoutMs: 5000 }) 等待在途任務;超時後通過處理函式的 ctx.signal 發出取消訊號。最後關閉由業務管理的 Redis 連線。
獨立頻道回撥
| 事件 | 強型別模型 |
|---|---|
rtc_channel.created | RTCChannelCreated |
rtc_channel.updated | RTCChannelUpdated |
rtc_channel.closing | RTCChannelClosing |
rtc_channel.closed | RTCChannelClosed |
rtc_channel.session_joined | RTCChannelSessionJoined |
rtc_channel.session_leaving | RTCChannelSessionLeaving |
rtc_channel.session_left | RTCChannelSessionLeft |
rtc_channel.member_banned | RTCChannelMemberBanned |
rtc_channel.member_unbanned | RTCChannelMemberUnbanned |
這 9 種事件和其他事件一樣由處理器分派,同樣校驗簽名、去重和處理錯誤。欄位見頻道事件;有些欄位可能沒有,除會話相關的事件外,不要假設事件中有目標使用者。頻道事件與通話(rtc.call_*)的事件名和型別是分開的;配置變化的內部廣播不會作為回調發出。
遊戲房間回撥
遊戲房間的 7 種事件和其他事件一樣由處理器分派,同樣校驗簽名、去重和處理錯誤;handler.on 按事件名推斷 data 的型別。大多數遊戲只需要處理 game_room.closed:它帶著結算結果和每個玩家的線上時長,用來發獎勵、記戰績、核對計費。
import { CallbackHandler, MemoryDedupStore, MemoryNonceStore } from '@deeprespond/im-server-sdk/callback';
const handler = new CallbackHandler({
secrets: { [process.env.IM_CALLBACK_ENDPOINT_ID!]: [process.env.IM_CALLBACK_SECRET!] },
nonceStore: new MemoryNonceStore(),
dedupStore: new MemoryDedupStore(), // 多台服务器时换成共享的存储
});
handler.on('game_room.closed', async (_event, data) => {
// data.result 是结束房间时给的结算结果;超时、全员离开等自动结束时为 null
await saveMatch(data.room_id, data.close_reason, data.result);
for (const m of data.members) await addPlayMinutes(m.username, m.billable_minutes);
});
handler.on('game_room.member_left', async (_event, data) => {
console.info(`${data.username} 离开了 ${data.room_id}:${data.reason}`);
});| 事件 | data 的型別 | 說明 |
|---|---|---|
game_room.created | GameRoomCreated | 遊戲房間建立成功(已分配到遊戲節點) |
game_room.started | GameRoomStarted | 開局(遊戲節點開始推進幀) |
game_room.member_joined | GameRoomMemberJoined | 玩家拿到座位 |
game_room.member_connected | GameRoomMemberConnected | 玩家第一次連上游戲節點;頻繁,按需訂閱 |
game_room.member_disconnected | GameRoomMemberDisconnected | 玩家與遊戲節點斷線(重連視窗內可恢復);頻繁,按需訂閱 |
game_room.member_left | GameRoomMemberLeft | 玩家離開(主動離開、超時、被移出、沒有按時連線) |
game_room.closed | GameRoomClosed | 房間結束,帶時長、結算結果和每個玩家的線上時長、計費分鐘數 |
