package http import ( "encoding/json" "io" "net/http" "strconv" "strings" roomv1 "hyapp.local/api/proto/room/v1" "hyapp/pkg/appcode" ) const ( tencentCallbackJoinGroup = "Group.CallbackBeforeApplyJoinGroup" tencentCallbackSendMsg = "Group.CallbackBeforeSendMsg" ) // tencentIMCallbackBody 覆盖本阶段需要处理的腾讯云 IM 群回调字段。 // 字段名保持腾讯云原始大小写,避免在 transport 层引入二次映射歧义。 type tencentIMCallbackBody struct { CallbackCommand string `json:"CallbackCommand"` GroupID string `json:"GroupId"` Requestor string `json:"Requestor_Account"` FromAccount string `json:"From_Account"` OperatorAccount string `json:"Operator_Account"` } // tencentIMCallbackResponse 是腾讯云 IM 回调要求的原生响应结构。 // 该接口不走项目 envelope,因为腾讯云只识别 ActionStatus/ErrorCode/ErrorInfo。 type tencentIMCallbackResponse struct { ActionStatus string `json:"ActionStatus"` ErrorInfo string `json:"ErrorInfo"` ErrorCode int `json:"ErrorCode"` } // handleTencentIMCallback 处理腾讯云 IM 服务端回调。 // gateway 只校验来源、解析腾讯字段并回查 room-service guard,不在本地保存房间状态。 func (h *Handler) handleTencentIMCallback(writer http.ResponseWriter, request *http.Request) { if request.Method != http.MethodPost { writeTencentCallback(writer, http.StatusMethodNotAllowed, 1, "method not allowed") return } if !h.tencentIMCallbackAuthenticated(request) { // 回调鉴权失败必须 fail-closed,不能让未知来源绕过房间 presence 守卫。 writeTencentCallback(writer, http.StatusOK, 1, "callback authentication failed") return } if !h.tencentIMSDKAppIDMatched(request) { // SDKAppID 不匹配代表回调不属于当前应用,直接拒绝该次腾讯云动作。 writeTencentCallback(writer, http.StatusOK, 1, "sdk_app_id mismatch") return } queryCommand := strings.TrimSpace(request.URL.Query().Get("CallbackCommand")) body, bodyValid := decodeTencentIMCallbackBody(request.Body) if !bodyValid { if queryCommand != "" && !knownTencentIMCallback(queryCommand) { // 未知回调按计划放行;即使 body 不是本服务认识的结构,也不阻断腾讯云新增能力。 writeTencentCallback(writer, http.StatusOK, 0, "") return } writeTencentCallback(writer, http.StatusOK, 1, "invalid callback body") return } command, ok := resolveTencentIMCallbackCommand(queryCommand, body.CallbackCommand) if !ok { writeTencentCallback(writer, http.StatusOK, 1, "invalid callback command") return } if !knownTencentIMCallback(command) { // 白名单外回调只做来源校验和 SDKAppID 校验,避免未知回调阻断腾讯云 IM 主链路。 writeTencentCallback(writer, http.StatusOK, 0, "") return } switch command { case tencentCallbackJoinGroup: h.handleTencentIMJoinCallback(writer, request, body) case tencentCallbackSendMsg: h.handleTencentIMSendCallback(writer, request, body) default: writeTencentCallback(writer, http.StatusOK, 0, "") } } func (h *Handler) handleTencentIMJoinCallback(writer http.ResponseWriter, request *http.Request, body tencentIMCallbackBody) { roomID := strings.TrimSpace(body.GroupID) userID, ok := parseTencentIMUserID(body.Requestor) if roomID == "" || !ok || h.roomGuardClient == nil { writeTencentCallback(writer, http.StatusOK, 1, "join guard input invalid") return } resp, err := h.roomGuardClient.VerifyRoomPresence(request.Context(), &roomv1.VerifyRoomPresenceRequest{ RoomId: roomID, UserId: userID, RequestId: requestIDFromContext(request.Context()), AppCode: appcode.FromContext(request.Context()), }) if err != nil || !resp.GetPresent() { writeTencentCallback(writer, http.StatusOK, 1, tencentGuardErrorInfo(err, resp.GetReason())) return } writeTencentCallback(writer, http.StatusOK, 0, "") } func (h *Handler) handleTencentIMSendCallback(writer http.ResponseWriter, request *http.Request, body tencentIMCallbackBody) { roomID := strings.TrimSpace(body.GroupID) userID, ok := parseTencentIMUserID(body.FromAccount) if roomID == "" || !ok || h.roomGuardClient == nil { writeTencentCallback(writer, http.StatusOK, 1, "send guard input invalid") return } resp, err := h.roomGuardClient.CheckSpeakPermission(request.Context(), &roomv1.CheckSpeakPermissionRequest{ RoomId: roomID, UserId: userID, AppCode: appcode.FromContext(request.Context()), }) if err != nil || !resp.GetAllowed() { writeTencentCallback(writer, http.StatusOK, 1, tencentGuardErrorInfo(err, resp.GetReason())) return } writeTencentCallback(writer, http.StatusOK, 0, "") } func (h *Handler) tencentIMCallbackAuthenticated(request *http.Request) bool { token := strings.TrimSpace(h.tencentIM.CallbackAuthToken) if token == "" { // 本地未配置回调鉴权 token 时允许调试,线上配置非空后自动 fail-closed。 return true } return subtleStringEqual(request.URL.Query().Get("CallbackAuthToken"), token) || subtleStringEqual(request.Header.Get("X-Tencent-IM-Callback-Token"), token) } func (h *Handler) tencentIMSDKAppIDMatched(request *http.Request) bool { if h.tencentIM.SDKAppID <= 0 { // 没有 SDKAppID 时无法证明回调属于当前应用,必须拒绝。 return false } raw := strings.TrimSpace(request.URL.Query().Get("SdkAppid")) value, err := strconv.ParseInt(raw, 10, 64) return err == nil && value == h.tencentIM.SDKAppID } func decodeTencentIMCallbackBody(reader io.Reader) (tencentIMCallbackBody, bool) { payload, err := io.ReadAll(reader) if err != nil { return tencentIMCallbackBody{}, false } if len(strings.TrimSpace(string(payload))) == 0 { return tencentIMCallbackBody{}, true } var body tencentIMCallbackBody if err := json.Unmarshal(payload, &body); err != nil { return tencentIMCallbackBody{}, false } return body, true } func resolveTencentIMCallbackCommand(queryCommand string, bodyCommand string) (string, bool) { queryCommand = strings.TrimSpace(queryCommand) bodyCommand = strings.TrimSpace(bodyCommand) if queryCommand != "" && bodyCommand != "" && queryCommand != bodyCommand { // query 和 body 不一致时拒绝,避免中间层或伪造请求混淆回调语义。 return "", false } if queryCommand != "" { return queryCommand, true } if bodyCommand != "" { return bodyCommand, true } return "", false } func knownTencentIMCallback(command string) bool { return command == tencentCallbackJoinGroup || command == tencentCallbackSendMsg } func parseTencentIMUserID(value string) (int64, bool) { userID, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64) return userID, err == nil && userID > 0 } func tencentGuardErrorInfo(err error, reason string) string { if err != nil { return "room guard unavailable" } if strings.TrimSpace(reason) != "" { return reason } return "permission denied" } func subtleStringEqual(left string, right string) bool { left = strings.TrimSpace(left) right = strings.TrimSpace(right) if len(left) != len(right) { return false } var diff byte for index := range left { diff |= left[index] ^ right[index] } return diff == 0 } func writeTencentCallback(writer http.ResponseWriter, statusCode int, errorCode int, errorInfo string) { writer.Header().Set("Content-Type", "application/json") writer.WriteHeader(statusCode) response := tencentIMCallbackResponse{ ActionStatus: "OK", ErrorInfo: errorInfo, ErrorCode: errorCode, } if errorCode != 0 && errorInfo == "" { response.ErrorInfo = "rejected" } _ = json.NewEncoder(writer).Encode(response) }