接收回調
IM 服務向你配置的回撥地址傳送三種請求:事件回撥(含訊息抄送)、同步回撥和 URL 驗證,見事件回撥概述。callback 包處理這些請求:校驗簽名、防止重放、按事件 ID 去重,把事件和同步回撥分派給你的處理函式,並按服務端的要求返回狀態碼。你只需要寫處理函式。
| 要做什麼 | 看哪裡 |
|---|---|
| 建立處理器,掛到 Gin 等框架或雲函式上 | 建立處理器、掛到 Web 框架上 |
| 處理事件、去重、交給自己的佇列 | 處理事件回撥 |
| 在訊息傳送前稽核、拒絕或修改 | 處理同步回撥 |
| 多個例項部署,用 Redis 儲存隨機數和去重記錄 | 隨機數和去重的儲存 |
| 對回撥處理函式做單元測試 | 本地測試 |
需要先跑通完整示例時,見 Demo 的回撥配置步驟:先建立地址,再帶金鑰啟動 Demo,最後驗證地址並訂閱事件。
import "github.com/DeepRespond-AI/deeprespond-im-server-go-sdk/callback"建立處理器
package main
import (
"context"
"encoding/json"
"log"
"log/slog"
"net/http"
"os"
"strings"
"time"
"github.com/DeepRespond-AI/deeprespond-im-server-go-sdk/callback"
)
func main() {
fallback := callback.Allow() // 同步回调的处理函数出错或超时时放行
h, err := callback.NewHandler(callback.Config{
// 回调地址 ID → 密钥(控制台新建地址时显示;轮换期间新旧两个都配置,新的在前)
Secrets: callback.StaticSecrets{
os.Getenv("IM_CALLBACK_ENDPOINT_ID"): strings.Split(os.Getenv("IM_CALLBACK_SECRETS"), ","),
},
Nonces: callback.NewMemoryNonceStore(), // 多个实例部署时换成共享存储
Dedup: callback.NewMemoryDedupStore(24 * time.Hour), // 同上
Events: callback.EventHandlers{
MessageSent: func(ctx context.Context, ev *callback.Event, d *callback.MessageSent) error {
if d.Truncated || !d.ContentAvailable {
return nil // 内容被截短或已不可用:需要时按 MessageID 调用 OpenAPI 读取
}
// d.Body 与请求体中的逐字节相同,可以直接存档
log.Printf("消息 %s(会话 %s 序号 %d):%s", d.MessageID, d.ConversationID, d.Seq, d.Body)
return nil
},
UserCreated: func(ctx context.Context, ev *callback.Event, d *callback.UserCreated) error {
log.Printf("新用户 %s(%s)", d.Username, d.CreatedVia)
return nil
},
},
Hooks: callback.HookHandlers{
MessageBeforeSend: func(ctx context.Context, req *callback.MessageBeforeSend) (callback.Verdict, error) {
var body struct {
Text string `json:"text"`
}
_ = json.Unmarshal(req.Body, &body)
if strings.Contains(body.Text, "加微信") {
return callback.Reject("forbidden_word", "消息中有不允许的内容"), nil
}
return callback.Allow(), nil
},
},
HookFallback: &fallback,
Logger: slog.Default(),
})
if err != nil {
log.Fatal(err)
}
mux := http.NewServeMux()
mux.Handle("POST /im/callback", h)
log.Fatal(http.ListenAndServe(":8090", mux))
}callback.Handler 實現了 http.Handler,對每個請求依次:
- 讀取原始請求體(最多
MaxBodyBytes,預設 2 MB),簽名按原始位元組計算; - 按請求頭
X-IM-Endpoint-Id找到這個地址的金鑰,校驗簽名和時間戳(與當前時間相差不超過 5 分鐘),再核對請求體中的endpoint_id與請求頭一致,見簽名與安全; - 簽名通過後記錄隨機數
X-IM-Nonce,10 分鐘內重複的拒絕(防重放); - 按請求體中的
kind分派:URL 驗證直接回應,同步回撥交給Hooks,事件回撥逐個事件去重後交給Events(或Queue)。
配置項
| 欄位 | 型別 | 說明 |
|---|---|---|
Secrets | callback.SecretProvider | 必填。按回調地址 ID 返回這個地址的金鑰列表,見金鑰 |
Nonces | callback.NonceStore | 必填。隨機數的儲存,見隨機數和去重的儲存 |
Dedup | callback.DedupStore | 必填。事件 ID 去重的儲存 |
Events | callback.EventHandlers | 事件的處理函式(同步方式),見處理事件回撥 |
Queue | callback.EventQueue | 把事件交給你的持久佇列(非同步方式),與 Events 二選一,見非同步處理 |
Hooks | callback.HookHandlers | 同步回撥的處理函式,見處理同步回撥 |
HookFallback | *callback.Verdict | 同步回撥的處理函式超時、出錯或結果不合規時返回的結果;nil 表示返回 503,由服務端按應用設定的失敗策略處理 |
ClaimTTL | time.Duration | 處理一個事件的佔用時限,預設 30 秒;超過後同一事件可以在別的請求中重新處理 |
MaxBodyBytes | int64 | 請求體的上限,預設 2 MB,超過的返回 413 |
MaxConcurrentHooks | int | 同時執行的同步回撥處理函式的上限,預設 100;已滿時直接按 HookFallback(或 503)返回 |
Clock | func() time.Time | 當前時間,測試用,預設 time.Now |
Logger | *slog.Logger | 日誌,預設 slog.Default()。簽名校驗失敗、同步回撥的結果不合規記錯誤日誌,請據此告警 |
金鑰
每個回撥地址的金鑰不同(cbs_ 開頭),在控制台新建地址時顯示。callback.StaticSecrets 是固定的“地址 ID → 金鑰列表”;金鑰儲存在配置中心、需要隨時更新的,用 callback.SecretFunc 或自己實現 SecretProvider:
// 作为 callback.Config 的 Secrets
func secretsFromConfigCenter() callback.SecretProvider {
return callback.SecretFunc(func(ctx context.Context, endpointID string) ([]string, error) {
return loadCallbackSecrets(ctx, endpointID) // 从配置中心读取;找不到时返回空列表
})
}- 輪換金鑰:控制台輪換後,服務端在寬限期內同時用新舊兩個金鑰簽名。先把新金鑰加入這個地址的列表(新的在前),寬限期結束後去掉舊的,見輪換金鑰。
- 找不到地址的請求返回
401;SecretProvider返回錯誤時返回503,服務端稍後重發。 - 地址 ID 在全平臺唯一,多個應用共用一個接收端時,一個處理器即可:把各應用的地址都配置進去,處理函式中用
callback.RequestFrom(ctx).App(AppKey)區分應用。
掛到 Web 框架上
Handler 是標準的 http.Handler,可以直接掛到 net/http 的 ServeMux、chi 等路由上。簽名按原始請求體計算,不要在它之前加會讀取或改寫請求體的中介軟體(如解壓、把請求體讀出來記日誌後不放回的中介軟體),也不要讓閘道器或反向代理改動請求體。
Gin 用 gin.WrapH:
package main
import (
"log"
"os"
"time"
"github.com/gin-gonic/gin"
"github.com/DeepRespond-AI/deeprespond-im-server-go-sdk/callback"
)
func main() {
h, err := callback.NewHandler(callback.Config{
Secrets: callback.StaticSecrets{os.Getenv("IM_CALLBACK_ENDPOINT_ID"): {os.Getenv("IM_CALLBACK_SECRET")}},
Nonces: callback.NewMemoryNonceStore(),
Dedup: callback.NewMemoryDedupStore(24 * time.Hour),
Events: callback.EventHandlers{ /* …… */ },
})
if err != nil {
log.Fatal(err)
}
r := gin.New()
r.POST("/im/callback", gin.WrapH(h))
log.Fatal(r.Run(":8090"))
}Echo 用 e.POST("/im/callback", echo.WrapHandler(h))。
在雲函式等自己讀取請求的環境中,用與框架無關的 HandleRequest:傳入請求頭和原始請求體,得到狀態碼、響應頭和響應體。
// 云函数的入口:headers 为请求头,body 为原始请求体(如果平台给的是 base64,先解码)
func handleCallback(ctx context.Context, h *callback.Handler, headers map[string]string, body []byte) (int, map[string]string, string) {
header := http.Header{}
for k, v := range headers {
header.Set(k, v)
}
status, respHeader, respBody := h.HandleRequest(ctx, header, body)
out := map[string]string{}
for k := range respHeader {
out[k] = respHeader.Get(k)
}
return status, out, string(respBody)
}返回的狀態碼
| 情況 | 狀態碼 |
|---|---|
事件全部處理成功(含重複的、交給 Unknown 或被忽略的) | 204 |
| URL 驗證 | 200,{"challenge":"..."} |
| 同步回撥 | 200 和結果;處理函數出錯、超時、結果不合規且沒有設定 HookFallback 時 503 |
簽名、時間戳、隨機數校驗失敗,地址 ID 不在配置中,請求體中的 endpoint_id 與請求頭不同 | 401 |
請求體不是合法的回撥請求(不是 JSON、kind 不認識、事件缺少 id 或 type) | 400 |
請求體超過 MaxBodyBytes | 413 |
不是 POST | 405 |
有事件處理失敗、正被另一個請求處理、入隊失敗;儲存或 SecretProvider 出錯 | 503,服務端稍後重發 |
服務端對任何非 2xx 都按失敗重試,金鑰配置錯誤時同一批事件會重試 24 小時,持續失敗的地址會被自動停用(見投遞與重試)。簽名校驗失敗時 SDK 記錯誤日誌,日誌中寫明可能的原因(金鑰配置錯誤、請求體被改動、本機時鐘偏差),請據此告警。
處理事件回撥
callback.EventHandlers 中每種事件一個欄位,簽名為 func(ctx context.Context, ev *callback.Event, d *类型) error,d 是按事件型別解析好的 data:
func eventHandlers() callback.EventHandlers {
return callback.EventHandlers{
GroupMembersAdded: func(ctx context.Context, ev *callback.Event, d *callback.GroupMembersAdded) error {
// 不保证顺序:用版本号判断新旧,旧事件不覆盖新状态
return saveMemberCount(ctx, d.GroupID, d.MemberVersion, d.MemberCount)
},
UserStatusChanged: func(ctx context.Context, ev *callback.Event, d *callback.UserStatusChanged) error {
if ev.Test {
return nil // 控制台“测试发送”的事件,不执行业务操作
}
if d.Status == "deleted" {
return deleteLocalProfile(ctx, d.Username)
}
return nil
},
// 所有事件(含不认识的)先交给 Any,返回错误即失败;用于统一记录或转发
Any: func(ctx context.Context, ev *callback.Event) error {
slog.DebugContext(ctx, "收到事件", "id", ev.ID, "type", ev.Type, "attempt", ev.Attempt)
return nil
},
// SDK 还不认识的事件类型(服务端新增的);不设置时忽略
Unknown: func(ctx context.Context, ev *callback.Event) error {
slog.WarnContext(ctx, "SDK 还不认识的事件", "type", ev.Type, "data", string(ev.Data))
return nil
},
}
}- 處理成功返回
nil;返回錯誤時這一批返回503,服務端稍後重發整批(已處理成功的事件會被跳過)。處理函式 panic 時 SDK 恢復並按失敗處理。 - 服務端預設只等待 5 秒(地址可設 1 到 10 秒),超時算失敗。處理函式要快:寫一條記錄、更新快取;耗時的處理用非同步方式。
- 需要推遲重發時,返回
callback.RetryAfter(d, err)包裝的錯誤:響應帶上Retry-After(取一批中最長的,不超過 1 小時),服務端按它推遲重發。 - 沒有設定處理函式的事件型別直接算作成功。
callback.Event 的欄位:
| 欄位 | 說明 |
|---|---|
ID | 事件 ID:同一事件多次到達(重試、重新投遞)時不變 |
Type | 事件型別,如 message.sent |
OccurredAt | 發生的時刻 |
Attempt | 第幾次傳送,從 1 開始(重新投遞後重新計數) |
Test | 是否為控制台“測試傳送”產生的事件 |
Data | data 的原始 JSON,與請求體中的逐位元組相同 |
Raw | 這個事件在請求體中的原始 JSON |
每種事件的資料型別都嵌入 callback.EventBase:Origin(誰引起的:client、server、system、platform 等,線上狀態變化為 nil)、Truncated(data 超過 70 KB 被截短,被去掉的欄位為零值,需要時按 ID 呼叫 OpenAPI 讀取)、Raw(data 的原始 JSON,SDK 沒有解析的欄位從中讀取)。欄位名由 JSON 欄位名轉換(sender_username 為 SenderUsername),可以為 null 的欄位是指標,訊息的 Body、Ext 等自由內容是 json.RawMessage。各欄位的含義見事件。
ev.Decode() 按 Type 把 Data 解析為對應的型別(指標,如 *callback.MessageSent),不認識的型別返回 callback.ErrUnknownEventType;在 Any 或非同步方式的工作者中按型別分派時使用。callback.EventTypes 是 SDK 認識的全部事件型別。
按事件 ID 去重
投遞是“至少一次”:同一事件可能因重試、重新投遞多次到達,事件 ID 不變(同一事件發給同一應用的多個地址時 ID 也相同)。SDK 在處理每個事件之前檢查它是否已處理過:
- 已處理過的跳過,算作成功(返回
2xx,不能返回錯誤,否則服務端會一直重試); - 處理之前先佔用這個事件 ID(
ClaimTTL內有效),處理成功後記為已完成,失敗時釋放; - 同一事件正在另一個請求中處理時(前一次處理較慢、服務端超時後又重發),這一批返回
503,服務端稍後重發。
NewMemoryDedupStore(retention) 在程序記憶體中保留已完成的事件 ID(retention 為 0 時 24 小時),最多 100 萬個,超出時淘汰最早的並記警告日誌。多個例項部署時,或需要在控制台重新投遞幾天前的事件時,換成共享儲存並保留 7 天(失敗的投遞在控制台保留 7 天),見隨機數和去重的儲存。
順序與截短
- 事件不保證順序,同一批中的事件也不按發生先後排列。用版本號(
Version、MemberVersion、InfoVersion、訊息的Seq等)和OccurredAt判斷新舊,舊事件不能覆蓋新狀態。SDK 不快取、不重排事件。 - 先看
Truncated:為true時物件、陣列和較長的文本欄位被去掉了。訊息類事件再看ContentAvailable:為false時訊息已撤回、擦除或過期,Body、Ext為nil。 - 線上狀態變化、聊天室訊息抄送和撤回是盡力而為的事件,可能丟失,不能作為對賬依據。
非同步處理
量大的訊息抄送、耗時的處理,用非同步方式:設定 Queue 而不是 Events,SDK 只做校驗、去重和佔用,把事件交給你的佇列;入隊成功即把這個事件記為已完成,全部入隊後返回 2xx。
// 把事件写入数据库的待处理表(必须是持久的:入队后服务端不会再为这个事件重发)
type tableQueue struct{ db *sql.DB }
func (q tableQueue) Enqueue(ctx context.Context, ev *callback.Event) error {
_, err := q.db.ExecContext(ctx,
"INSERT INTO im_events (id, type, occurred_at, data) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE id = id",
ev.ID, ev.Type, ev.OccurredAt, []byte(ev.Raw))
return err
}
func newAsyncHandler(db *sql.DB, secrets callback.SecretProvider) (*callback.Handler, error) {
return callback.NewHandler(callback.Config{
Secrets: secrets,
Nonces: callback.NewMemoryNonceStore(),
Dedup: callback.NewMemoryDedupStore(24 * time.Hour),
Queue: tableQueue{db: db},
})
}- 入隊失敗的事件釋放佔用,這一批返回
503,服務端稍後重發。 - 之後的處理和重試由你的佇列負責(至少一次),程序記憶體中的通道不行。
- 一個處理器中
Events和Queue二選一。需要按型別分別處理的,在你的工作者中用ev.Decode()分派。
事件型別與處理函式
| 事件 | 處理函式的欄位和資料型別 | 說明 |
|---|---|---|
user.created | UserCreated | 使用者註冊(客戶端註冊、服務端或控制台建立) |
user.status_changed | UserStatusChanged | 使用者被封禁、解封或刪除 |
user.profile_changed | UserProfileChanged | 使用者修改了暱稱、頭像或自定義屬性;不帶變化的內容,需要時用 OpenAPI 讀取 |
user.mute_changed | UserMuteChanged | 設定或解除全域性禁言 |
user.session_created | UserSessionCreated | 使用者在一臺裝置上登入 |
user.sessions_revoked | UserSessionsRevoked | 登入會話被吊銷:退出、被踢、被擠下線、會話過期等 |
user.relogin_required | UserReloginRequired | 要求應用內全部使用者重新登入 |
presence.changed | PresenceChanged | 使用者的線上平臺變化:上線、下線、多了或少了一個平臺;盡力而為 |
friend.added | FriendAdded | 成為好友(雙方各一個事件) |
friend.removed | FriendRemoved | 解除好友關係(雙方各一個事件;對方賬號被刪除時 reason 為 user_deleted) |
friend.updated | FriendUpdated | 修改了好友的備註或自定義屬性 |
friend_request.received | FriendRequestReceived | 收到需要同意的好友申請 |
friend_request.declined | FriendRequestDeclined | 好友申請被拒絕 |
blacklist.changed | BlacklistChanged | 拉黑或移出黑名單 |
group.created | GroupCreated | 建群 |
group.info_changed | GroupInfoChanged | 群資料、設定、全員禁言、群主變化,群被封禁或解封 |
group.dismissed | GroupDismissed | 群被解散 |
group.members_added | GroupMembersAdded | 成員加入(一次操作一個事件) |
group.members_removed | GroupMembersRemoved | 成員退出、被移出、被拉入群黑名單或賬號被刪除 |
group.members_updated | GroupMembersUpdated | 成員的角色、群暱稱、成員屬性、禁言變化 |
group.request_created | GroupRequestCreated | 新的入群申請或邀請 |
group.request_handled | GroupRequestHandled | 入群申請或邀請被同意、拒絕、撤回或自動失效 |
message.sent | MessageSent | 訊息抄送:每寫入一條單聊、群聊訊息一個事件(群提示預設不傳送) |
message.recalled | MessageRecalled | 訊息被撤回(不受“不接收由租戶服務端引起的事件”的影響) |
message.edited | MessageEdited | 訊息被服務端編輯 |
chatroom.created | ChatroomCreated | 聊天室被建立 |
chatroom.dismissed | ChatroomDismissed | 聊天室被解散 |
chatroom.info_changed | ChatroomInfoChanged | 聊天室的資料、設定、所有者和管理員變化,被封禁或解封 |
chatroom.members_muted | ChatroomMembersMuted | 聊天室成員被禁言 |
chatroom.members_unmuted | ChatroomMembersUnmuted | 聊天室成員被解除禁言 |
chatroom.members_kicked | ChatroomMembersKicked | 聊天室成員被移出 |
chatroom.members_banned | ChatroomMembersBanned | 聊天室成員被封禁 |
chatroom.members_unbanned | ChatroomMembersUnbanned | 聊天室成員被解封 |
chatroom.allowlist_changed | ChatroomAllowlistChanged | 聊天室的白名單變化 |
chatroom.attributes_changed | ChatroomAttributesChanged | 聊天室的屬性變化 |
chatroom.message_sent | ChatroomMessageSent | 聊天室的訊息抄送:每條推送出去的訊息一個事件;盡力而為 |
chatroom.message_recalled | ChatroomMessageRecalled | 聊天室的訊息被撤回;盡力而為,不受“不接收由租戶服務端引起的事件”的影響 |
rtc.call_created | RtcCallCreated | 發起通話 |
rtc.call_answered | RtcCallAnswered | 通話接通 |
rtc.member_joined | RtcMemberJoined | 成員加入群通話 |
rtc.member_left | RtcMemberLeft | 成員離開群通話 |
rtc.call_ended | RtcCallEnded | 通話結束,帶時長 |
moderation.item_created | ModerationItemCreated | 有新的待稽核記錄(租戶佇列);不帶被稽核的內容,按 item_id 用 OpenAPI 讀取 |
moderation.item_decided | ModerationItemDecided | 稽核記錄有了結論 |
moderation.report_created | ModerationReportCreated | 使用者舉報 |
moderation.violation_recorded | ModerationViolationRecorded | 記下一次違規(可能觸發處罰) |
media.file_uploaded | MediaFileUploaded | 檔案上傳完成(企業認證材料除外) |
media.file_blocked | MediaFileBlocked | 檔案被遮蔽 |
media.file_unblocked | MediaFileUnblocked | 檔案被解除遮蔽 |
處理同步回撥
同步回撥在操作完成之前呼叫你的接收端,由你決定放行、拒絕或修改,見同步回撥。callback.HookHandlers 中每種同步回撥一個處理函式,返回 callback.Verdict:
| 處理函式 | 時機 | 可用的結果 |
|---|---|---|
MessageBeforeSend | 單聊、群聊訊息傳送和編輯之前 | Allow、Reject、Modify |
ChatroomBeforeSend | 聊天室訊息傳送之前 | Allow、Reject、Modify |
FriendBeforeAdd | 客戶端傳送好友申請、同意申請之前 | Allow、Reject |
GroupBeforeJoin | 客戶端建群、邀請、申請、通過連結入群之前 | Allow、Reject;建群和邀請另有 Partial |
func hookHandlers() callback.HookHandlers {
return callback.HookHandlers{
ChatroomBeforeSend: func(ctx context.Context, req *callback.ChatroomBeforeSend) (callback.Verdict, error) {
var body struct {
Text string `json:"text"`
}
if err := json.Unmarshal(req.Body, &body); err != nil || body.Text == "" {
return callback.Allow(), nil
}
masked := strings.ReplaceAll(body.Text, "傻", "*")
if masked == body.Text {
return callback.Allow(), nil
}
// 替换消息内容;ext 不变
newBody, err := json.Marshal(map[string]string{"text": masked})
if err != nil {
return callback.Verdict{}, err
}
return callback.Modify(newBody, drim.Field[json.RawMessage]{}), nil
},
GroupBeforeJoin: func(ctx context.Context, req *callback.GroupBeforeJoin) (callback.Verdict, error) {
if req.JoinedVia != "invite" && req.JoinedVia != "create" {
return callback.Allow(), nil
}
// 邀请和建群可以只拒绝其中一部分人
var rejected []callback.RejectedTarget
for _, username := range req.Targets {
if strings.HasPrefix(username, "guest_") {
rejected = append(rejected, callback.RejectedTarget{Username: username, Reason: "guest_not_allowed", Message: "访客不能加入群聊"})
}
}
if len(rejected) == 0 {
return callback.Allow(), nil
}
return callback.Partial(rejected...), nil
},
}
}結果的建構函式:
| 函式 | 說明 |
|---|---|
callback.Allow() | 放行 |
callback.Reject(reason, message) | 拒絕。reason 為 1 到 64 個小寫字母、數字或 _、.、-,客戶端在錯誤的 details.app_reason 中得到它;message 為給使用者看的提示,不超過 256 個字元、不能含控制字元(換行、製表符也是)。兩者都可以為空 |
callback.Modify(body, ext) | 修改訊息,只用於兩種發訊息前的回撥。body 為 nil 表示不改,否則必須是 JSON 物件;ext 為 drim.Field[json.RawMessage]:零值不改,drim.Null[json.RawMessage]() 去掉 ext,drim.Set(v) 替換;至少改一個 |
callback.Partial(rejected...) | 部分拒絕,只用於 GroupBeforeJoin 的邀請和建群(JoinedVia 為 invite、create);至少一項,使用者名稱必須在 req.Targets 中 |
Modify 的 ext 引數型別來自 drim 包,使用時匯入 drim "github.com/DeepRespond-AI/deeprespond-im-server-go-sdk"。修改後的訊息不再經過內容稽核,由服務端按正常規則重新校驗,不能改變型別。
時限與失敗
- 處理函式收到的
ctx帶截止時間:請求中的timeout_ms(服務端本次實際等待的時間,預設 1000 毫秒)減 50 毫秒。req.Timeout為服務端給出的等待時間。查詢資料庫、呼叫其他服務時使用這個ctx。 - 處理函式超時、返回錯誤、panic,或返回的結果不合規時,返回
HookFallback;沒有設定HookFallback時返回503,由服務端按應用為這種回撥設定的失敗策略(放行或拒絕)處理。超時後 SDK 不再等待處理函式,它的結果被丟棄。 HookFallback為Allow()時,出錯和超時都放行,不依賴服務端的失敗策略;返回503則計入服務端的熔斷統計,持續出錯時服務端會暫時不再呼叫你的接收端。- 處理函式返回
Verdict的零值(忘了構造結果)不當作放行,按出錯處理。 - 收到沒有設定處理函式的同步回撥時,同樣返回
HookFallback(或503)並記錯誤日誌。只在控制台開啟已設定了處理函式的同步回撥。 - SDK 在返回前檢查結果:
reason、message的格式,Modify的內容,Partial的使用者名稱,動作是否適用於這種回撥,響應不超過 64 KB。不合規的不發出,按出錯處理並記錯誤日誌,便於發現自己的缺陷。 - 同時執行的處理函式超過
MaxConcurrentHooks(預設 100)時,新的請求直接按HookFallback(或503)返回。
同步回撥直接影響使用者發訊息、加好友的速度,接收端慢或出錯時影響所有使用者。處理函式中只做本地的快速判斷;接入時先在控制台把失敗策略設為放行,穩定後再按需改為拒絕。
請求的型別(MessageBeforeSend 等)都嵌入 callback.HookBase:Test(控制台“測試傳送”產生的請求)、Timeout、Raw(原始的 data)。各欄位的含義見同步回撥。
URL 驗證
在控制台新建或修改回撥地址時,服務端傳送 URL 驗證請求。處理器校驗簽名後自動返回 200 和 {"challenge":"<请求中的值>"},不呼叫你的程式碼,見 URL 驗證。所以完成驗證之前,接收端要先部署好並配置了這個地址的金鑰。
接入的順序:部署接收端(先只配置儲存和金鑰) → 在控制台新建地址,把顯示的金鑰配置到 Secrets → 用控制台的“測試傳送”確認簽名通過 → 完成 URL 驗證 → 訂閱事件 → 同步回撥先以失敗時放行開啟。
隨機數和去重的儲存
NewMemoryNonceStore 和 NewMemoryDedupStore 只在一個程序內有效。多個例項、多臺伺服器部署時,同一事件或重放的請求可能到達另一個例項,要換成共享儲存,實現兩個介面:
// NonceStore 记录 10 分钟内见过的随机数:已存在返回 true,否则记下(ttl 后过期)并返回 false
type NonceStore interface {
Seen(ctx context.Context, nonce string, ttl time.Duration) (bool, error)
}
// DedupStore 按事件 ID 去重:开始处理时占用(得到占用的令牌),成功后改为已完成,失败时释放;
// 完成和释放只在令牌相同时生效(占用过期后被别的请求重新占用时,不改掉别人的占用)
type DedupStore interface {
Claim(ctx context.Context, eventID string, ttl time.Duration) (state callback.ClaimState, token string, err error)
Complete(ctx context.Context, eventID, token string) error
Release(ctx context.Context, eventID, token string) error
}Claim 的結果:callback.Acquired(佔用成功,可以處理)、callback.Done(已處理過,跳過)、callback.Busy(正在另一個請求中處理)。
儲存出錯時這一批返回 503,服務端稍後重發:隨機數儲存出錯時不能確認是否重放,寧可讓服務端重發。記憶體隨機數儲存最多 100 萬個,已滿時返回 callback.ErrStoreFull(同樣 503),不淘汰未到期的隨機數。
用 go-redis 實現的寫法(已完成的事件 ID 保留 7 天):
package callbackstore
import (
"context"
"crypto/rand"
"encoding/hex"
"time"
"github.com/redis/go-redis/v9"
"github.com/DeepRespond-AI/deeprespond-im-server-go-sdk/callback"
)
// RedisStores 同时实现 callback.NonceStore 和 callback.DedupStore。
type RedisStores struct {
R *redis.Client
Prefix string // 如 "im-callback:"
}
var (
_ callback.NonceStore = RedisStores{}
_ callback.DedupStore = RedisStores{}
)
func (s RedisStores) Seen(ctx context.Context, nonce string, ttl time.Duration) (bool, error) {
ok, err := s.R.SetNX(ctx, s.Prefix+"nonce:"+nonce, 1, ttl).Result()
return !ok, err
}
var claimScript = redis.NewScript(`
local v = redis.call('GET', KEYS[1])
if not v then redis.call('SET', KEYS[1], 'busy:' .. ARGV[1], 'PX', ARGV[2]) return 'acquired' end
if v == 'done' then return 'done' end
return 'busy'`)
var completeScript = redis.NewScript(`
if redis.call('GET', KEYS[1]) == 'busy:' .. ARGV[1] then
redis.call('SET', KEYS[1], 'done', 'PX', ARGV[2]) return 1
end
return 0`)
var releaseScript = redis.NewScript(`
if redis.call('GET', KEYS[1]) == 'busy:' .. ARGV[1] then return redis.call('DEL', KEYS[1]) end
return 0`)
func (s RedisStores) Claim(ctx context.Context, eventID string, ttl time.Duration) (callback.ClaimState, string, error) {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return 0, "", err
}
token := hex.EncodeToString(b[:])
r, err := claimScript.Run(ctx, s.R, []string{s.Prefix + "event:" + eventID}, token, ttl.Milliseconds()).Text()
switch {
case err != nil:
return 0, "", err
case r == "acquired":
return callback.Acquired, token, nil
case r == "done":
return callback.Done, "", nil
}
return callback.Busy, "", nil
}
func (s RedisStores) Complete(ctx context.Context, eventID, token string) error {
keep := 7 * 24 * time.Hour
return completeScript.Run(ctx, s.R, []string{s.Prefix + "event:" + eventID}, token, keep.Milliseconds()).Err()
}
func (s RedisStores) Release(ctx context.Context, eventID, token string) error {
return releaseScript.Run(ctx, s.R, []string{s.Prefix + "event:" + eventID}, token).Err()
}建立處理器時傳入 Nonces: stores, Dedup: stores(stores := RedisStores{R: rdb, Prefix: "im-callback:"})。
不使用處理器
需要完全自己控制流程時,可以只用校驗和解析的函式,隨機數和事件去重由你自己完成:
| 函式 | 說明 |
|---|---|
callback.Verify(secrets, header, body, now) | 校驗簽名和時間戳,secrets 為這個地址的金鑰列表;不通過時返回 *callback.VerifyError,Reason 為 bad_signature、bad_timestamp、stale_timestamp、bad_format |
callback.Parse(body) | 解析請求體為 *callback.Request(不校驗簽名,先呼叫 Verify) |
callback.Sign(secret, timestamp, nonce, body) | 計算簽名(小寫十六進位制,不含 v1=) |
callback.SignatureHeader(secrets, timestamp, nonce, body) | X-IM-Signature 的值,每個金鑰一個 v1= |
func verifyAndParse(r *http.Request, secrets []string) (*callback.Request, error) {
body, err := io.ReadAll(io.LimitReader(r.Body, 2<<20))
if err != nil {
return nil, err
}
if err := callback.Verify(secrets, r.Header, body, time.Now()); err != nil {
var verr *callback.VerifyError
if errors.As(err, &verr) {
log.Printf("签名校验失败:%s", verr.Reason)
}
return nil, err
}
// 校验通过后:记录 X-IM-Nonce(10 分钟内重复的拒绝)、核对 endpoint_id,再按 Kind 处理
return callback.Parse(body)
}callback.Request 的 Kind 為 event、hook 或 verify,Events 為事件列表,Hook、Data、TimeoutMs 為同步回撥的內容,Challenge 為 URL 驗證的隨機串。在處理器的處理函式中,callback.RequestFrom(ctx) 返回這個請求的公共資訊(RequestID、App、EndpointID、SentAt 等,不含事件和資料)。
本地測試
drimtest 包可以構造帶正確簽名的回撥請求,不需要真實的服務端就能測試你的處理函式:
package receiver_test
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/DeepRespond-AI/deeprespond-im-server-go-sdk/callback"
"github.com/DeepRespond-AI/deeprespond-im-server-go-sdk/drimtest"
)
const secret = "cbs_test_secret_for_unit_tests_0000000000000000"
func newHandler(t *testing.T, events callback.EventHandlers, hooks callback.HookHandlers) *callback.Handler {
t.Helper()
h, err := callback.NewHandler(callback.Config{
Secrets: callback.StaticSecrets{drimtest.DefaultEndpointID: {secret}},
Nonces: callback.NewMemoryNonceStore(),
Dedup: callback.NewMemoryDedupStore(time.Hour),
Events: events,
Hooks: hooks,
})
if err != nil {
t.Fatal(err)
}
return h
}
func TestUserCreatedIsHandledOnce(t *testing.T) {
created := 0
h := newHandler(t, callback.EventHandlers{
UserCreated: func(ctx context.Context, ev *callback.Event, d *callback.UserCreated) error {
created++
return nil
},
}, callback.HookHandlers{})
body := drimtest.EventRequest(drimtest.Event{
ID: "1840300000000000001",
Type: "user.created",
Data: map[string]any{"origin": "server", "username": "zhangsan", "created_via": "openapi"},
})
for i := 0; i < 2; i++ { // 同一事件到达两次:只处理一次,两次都返回 204
rec := httptest.NewRecorder()
h.ServeHTTP(rec, drimtest.SignedRequest(drimtest.DefaultEndpointID, body, secret))
if rec.Code != http.StatusNoContent {
t.Fatalf("status %d", rec.Code)
}
}
if created != 1 {
t.Fatalf("handled %d times", created)
}
}
func TestRejectBannedWord(t *testing.T) {
h := newHandler(t, callback.EventHandlers{}, callback.HookHandlers{
MessageBeforeSend: func(ctx context.Context, req *callback.MessageBeforeSend) (callback.Verdict, error) {
if strings.Contains(string(req.Body), "加微信") {
return callback.Reject("forbidden_word", "消息中有不允许的内容"), nil
}
return callback.Allow(), nil
},
})
body := drimtest.HookRequest("message.before_send", map[string]any{
"via": "client", "client_msg_id": "c1", "conversation_type": "single",
"sender_username": "zhangsan", "recipient_username": "lisi",
"type": "text", "body": map[string]any{"text": "加微信 123"},
})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, drimtest.SignedRequest(drimtest.DefaultEndpointID, body, secret))
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"action":"reject"`) {
t.Fatalf("status %d, body %s", rec.Code, rec.Body)
}
}
func TestWrongSecretIsRejected(t *testing.T) {
h := newHandler(t, callback.EventHandlers{}, callback.HookHandlers{})
body := drimtest.VerifyRequest("challenge-1")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, drimtest.SignedRequest(drimtest.DefaultEndpointID, body, "cbs_another_secret"))
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status %d", rec.Code)
}
}| 函式 | 說明 |
|---|---|
drimtest.EventRequest(events...) | 事件回撥的請求體,地址 ID 為 drimtest.DefaultEndpointID;drimtest.Event 的 ID 不給時隨機生成,Data 為 string、[]byte、json.RawMessage 時原樣使用,其他值編碼為 JSON |
drimtest.EventRequestFor(endpointID, events...) | 指定地址 ID 的事件回撥請求體 |
drimtest.HookRequest(hook, data)、HookRequestFor(endpointID, hook, timeoutMs, data) | 同步回撥的請求體,預設 timeout_ms 為 1000 |
drimtest.VerifyRequest(challenge) | URL 驗證的請求體 |
drimtest.SignedRequest(endpointID, body, secrets...) | 帶正確簽名的 *http.Request(當前時間、新的隨機數);輪換期間可以給新舊兩個金鑰 |
drimtest.SignedHeader(endpointID, body, at, nonce, secrets...) | 指定時間戳和隨機數的請求頭,用於測試過期和重放 |
drimtest.SignatureVectors() | 簽名的測試向量 |
簽名測試向量
drimtest.SignatureVectors() 返回一組簽名的測試向量,與服務端和簽名與安全中的示例使用同一套資料:有效的簽名、輪換期間的兩個簽名、多位元組字元和不轉義的 <>&、時間戳帶前導零或加號、過期、請求體改了一個位元組等。自己實現簽名校驗(或在閘道器上校驗)時,可以用它確認實現正確:
func TestSignatureVectors(t *testing.T) {
for _, v := range drimtest.SignatureVectors() {
err := callback.Verify([]string{v.Secret}, v.Header(), v.Body(), time.Unix(v.Now, 0))
var verr *callback.VerifyError
switch {
case v.Valid && err != nil:
t.Errorf("%s: want valid, got %v", v.Name, err)
case !v.Valid && (!errors.As(err, &verr) || verr.Reason != v.Error):
t.Errorf("%s: want %s, got %v", v.Name, v.Error, err)
}
}
}每個向量有 Name、Secret(接收端配置的金鑰)、Timestamp、Nonce、Body()(原始請求體)、Header()(請求頭)、Now(校驗時的當前時間)、Valid 和 Error(不通過時期望的原因)。
接入真實的服務端之後,用控制台回撥地址的“測試傳送”確認簽名能通過:簽名失敗時 SDK 的錯誤日誌寫明瞭可能的原因,常見的是金鑰配置錯誤,或閘道器、框架改動了請求體,見除錯簽名。
獨立頻道回撥
| 事件 | 強型別模型 |
|---|---|
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 種事件和其他事件一樣由處理器分派,同樣校驗簽名、去重和處理錯誤。大多數遊戲只需要處理 game_room.closed:它帶著結算結果和每個玩家的線上時長,用來發獎勵、記戰績、核對計費。
handler, err := callback.NewHandler(callback.Config{
Secrets: callback.StaticSecrets{endpointID: {secret}},
Nonces: callback.NewMemoryNonceStore(),
Dedup: callback.NewMemoryDedupStore(0),
Events: callback.EventHandlers{
GameRoomClosed: func(ctx context.Context, ev *callback.Event, d *callback.GameRoomClosed) error {
// d.Result 是结束房间时给的结算结果;超时、全员离开等自动结束时为 null
if err := saveMatch(ctx, d.RoomID, d.CloseReason, d.Result); err != nil {
return err // 返回错误时 IM 稍后重新投递
}
for _, m := range d.Members {
if err := addPlayMinutes(ctx, m.Username, m.BillableMinutes); err != nil {
return err
}
}
return nil
},
GameRoomMemberLeft: func(ctx context.Context, ev *callback.Event, d *callback.GameRoomMemberLeft) error {
log.Printf("%s 离开了 %s:%s", d.Username, d.RoomID, d.Reason)
return nil
},
},
})| 事件 | 處理函式的欄位和資料型別 | 說明 |
|---|---|---|
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 | 房間結束,帶時長、結算結果和每個玩家的線上時長、計費分鐘數 |
