package http import ( "encoding/json" "hyapp/services/gateway-service/internal/transport/http/httpkit" "io" "net/http" "strconv" "strings" "time" roomv1 "hyapp.local/api/proto/room/v1" userv1 "hyapp.local/api/proto/user/v1" "hyapp/pkg/appcode" "hyapp/pkg/imgroup" ) 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) { parsed := imgroup.Parse(body.GroupID) userID, ok := parseTencentIMUserID(body.Requestor) if !ok { writeTencentCallback(writer, http.StatusOK, 1, "join guard input invalid") return } switch parsed.Kind { case imgroup.KindGlobalBroadcast, imgroup.KindRegionBroadcast: // 播报群不属于 Room Cell,准入只看 user-service 的用户状态、资料完成度、app_code 和区域归属。 h.handleTencentIMBroadcastJoinCallback(writer, request, parsed, userID) return case imgroup.KindRoom: // 房间群仍走 room-service guard,保证只有当前房间 presence 用户能加入/留在房间 IM 群。 h.handleTencentIMRoomJoinCallback(writer, request, parsed.RoomID, userID) return default: writeTencentCallback(writer, http.StatusOK, 1, "group_id is invalid") return } } func (h *Handler) handleTencentIMRoomJoinCallback(writer http.ResponseWriter, request *http.Request, roomID string, userID int64) { if roomID == "" || 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: httpkit.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) handleTencentIMBroadcastJoinCallback(writer http.ResponseWriter, request *http.Request, parsed imgroup.ParsedGroup, userID int64) { user, err := h.tencentIMCallbackUser(request, parsed.AppCode, userID) if err != nil { writeTencentCallback(writer, http.StatusOK, 1, err.Error()) return } if parsed.Kind == imgroup.KindRegionBroadcast && user.GetRegionId() != parsed.RegionID { // 区域群只允许加入当前 user-service 归属区域;客户端缓存、IP 或自报参数都不可信。 writeTencentCallback(writer, http.StatusOK, 1, "region mismatch") return } writeTencentCallback(writer, http.StatusOK, 0, "") } func (h *Handler) handleTencentIMSendCallback(writer http.ResponseWriter, request *http.Request, body tencentIMCallbackBody) { parsed := imgroup.Parse(body.GroupID) switch parsed.Kind { case imgroup.KindGlobalBroadcast, imgroup.KindRegionBroadcast: if h.tencentIMCallbackSenderIsServer(body) { // 播报群是服务端只写通道;管理员账号发送的 REST 消息由腾讯回调放行。 writeTencentCallback(writer, http.StatusOK, 0, "") return } writeTencentCallback(writer, http.StatusOK, 1, "broadcast group is server-only") return case imgroup.KindRoom: userID, ok := parseTencentIMUserID(body.FromAccount) if !ok { writeTencentCallback(writer, http.StatusOK, 1, "send guard input invalid") return } h.handleTencentIMRoomSendCallback(writer, request, parsed.RoomID, userID) return default: writeTencentCallback(writer, http.StatusOK, 1, "group_id is invalid") return } } func (h *Handler) handleTencentIMRoomSendCallback(writer http.ResponseWriter, request *http.Request, roomID string, userID int64) { if roomID == "" || 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) tencentIMCallbackUser(request *http.Request, callbackAppCode string, userID int64) (*userv1.User, error) { if h.userProfileClient == nil { return nil, callbackError("user-service unavailable") } // 回调路径不信任客户端 join 参数,每次都回查 user-service 当前用户事实。 resp, err := h.userProfileClient.GetUser(appcode.WithContext(request.Context(), callbackAppCode), &userv1.GetUserRequest{ Meta: &userv1.RequestMeta{ RequestId: httpkit.RequestIDFromContext(request.Context()), Caller: "gateway-service", SentAtMs: time.Now().UnixMilli(), AppCode: callbackAppCode, }, UserId: userID, }) if err != nil || resp.GetUser() == nil { return nil, callbackError("user-service unavailable") } user := resp.GetUser() if !user.GetProfileCompleted() { return nil, callbackError("profile required") } if user.GetStatus() != userv1.UserStatus_USER_STATUS_ACTIVE { return nil, callbackError("user is not active") } if user.GetAppCode() != "" && appcode.Normalize(user.GetAppCode()) != callbackAppCode { // callbackAppCode 来自 GroupID 解析结果,用户 app_code 不匹配时拒绝跨租户入群。 return nil, callbackError("app_code mismatch") } return user, nil } func (h *Handler) tencentIMCallbackSenderIsServer(body tencentIMCallbackBody) bool { admin := strings.TrimSpace(h.tencentIM.AdminIdentifier) if admin == "" { return false } return subtleStringEqual(body.FromAccount, admin) || subtleStringEqual(body.OperatorAccount, admin) } type callbackError string func (e callbackError) Error() string { return string(e) } 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) }