package http import ( "bytes" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "hyapp/services/gateway-service/internal/transport/http/httpkit" "io" "log/slog" "net/http" "strconv" "strings" "time" roomv1 "hyapp.local/api/proto/room/v1" "hyapp/pkg/appcode" "hyapp/pkg/logx" "hyapp/pkg/tencentrtc" "hyapp/pkg/xerr" ) const ( tencentRTCEventGroupRoom = 1 tencentRTCEventGroupMedia = 2 tencentRTCEventRoomExit = 104 tencentRTCEventStartAudio = 203 tencentRTCEventStopAudio = 204 ) // tencentRTCCallbackBody 覆盖当前语音房需要消费的腾讯 RTC 房间/媒体回调字段。 type tencentRTCCallbackBody struct { EventGroupID int `json:"EventGroupId"` EventType int `json:"EventType"` CallbackTs int64 `json:"CallbackTs"` EventInfo tencentRTCCallbackEvent `json:"EventInfo"` } type tencentRTCCallbackEvent struct { RoomID json.RawMessage `json:"RoomId"` UserID string `json:"UserId"` EventTs int64 `json:"EventTs"` EventMsTs int64 `json:"EventMsTs"` Reason int `json:"Reason"` } type tencentRTCCallbackResponse struct { Code int `json:"code"` Message string `json:"message,omitempty"` } // handleTencentRTCCallback 处理腾讯 RTC 服务端事件回调。 func (h *Handler) handleTencentRTCCallback(writer http.ResponseWriter, request *http.Request) { if request.Method != http.MethodPost { writeTencentRTCCallback(writer, http.StatusMethodNotAllowed, 1, "method not allowed") return } payload, err := io.ReadAll(io.LimitReader(request.Body, 1<<20)) if err != nil || len(bytes.TrimSpace(payload)) == 0 { writeTencentRTCCallback(writer, http.StatusBadRequest, 1, "invalid callback body") return } if !h.tencentRTCSDKAppIDMatched(request) { writeTencentRTCCallback(writer, http.StatusForbidden, 1, "sdk_app_id mismatch") return } if !tencentrtc.VerifyCallbackSignature(h.tencentRTC.CallbackSignKey, payload, request.Header.Get(tencentrtc.CallbackHeaderSign)) { // 腾讯 RTC 回调签名使用原始 body;配置缺失或签名缺失都必须 fail-closed。 writeTencentRTCCallback(writer, http.StatusForbidden, 1, "callback signature invalid") return } var body tencentRTCCallbackBody if err := json.Unmarshal(payload, &body); err != nil { writeTencentRTCCallback(writer, http.StatusBadRequest, 1, "invalid callback body") return } eventType, known := normalizeTencentRTCEventType(body.EventGroupID, body.EventType) if !known { // 白名单外事件只做验签和 SDKAppID 校验,避免腾讯新增事件造成重试风暴。 writeTencentRTCCallback(writer, http.StatusOK, 0, "") return } roomID, ok := parseTencentRTCRoomID(body.EventInfo.RoomID) userID, userOK := parseTencentIMUserID(body.EventInfo.UserID) eventTimeMS := tencentRTCEventTimeMS(body) logCtx := logx.With(request.Context(), slog.String("request_id", httpkit.RequestIDFromContext(request.Context())), slog.String("app_code", tencentRTCCallbackAppCode(request))) if !ok || !userOK || eventTimeMS <= 0 || h.roomClient == nil { logx.Warn(logCtx, "gateway_rtc_callback_ignored", slog.String("provider", "tencent"), slog.Int("event_group", body.EventGroupID), slog.Int("event_type", body.EventType), slog.String("room_id", roomID), slog.String("external_user_id", body.EventInfo.UserID), slog.String("reason", "input_invalid"), ) writeTencentRTCCallback(writer, http.StatusOK, 0, "") return } resp, err := h.roomClient.ApplyRTCEvent(request.Context(), &roomv1.ApplyRTCEventRequest{ Meta: &roomv1.RequestMeta{ RequestId: httpkit.RequestIDFromContext(request.Context()), CommandId: tencentRTCCallbackCommandID(body, roomID, userID, eventTimeMS), ActorUserId: userID, RoomId: roomID, AppCode: tencentRTCCallbackAppCode(request), GatewayNodeId: "gateway-local", SessionId: fmt.Sprintf("tencent-rtc-%d", body.EventType), SentAtMs: time.Now().UnixMilli(), }, TargetUserId: userID, EventType: eventType, EventTimeMs: eventTimeMS, Reason: tencentRTCCallbackReason(body), Source: "tencent_rtc_callback", ExternalEventId: tencentRTCCallbackExternalEventID(body, roomID, userID, eventTimeMS), }) if err != nil { if tencentRTCCallbackCanAckBusinessError(err) { logx.Warn(logCtx, "gateway_rtc_callback_business_ignored", slog.String("provider", "tencent"), slog.String("room_id", roomID), slog.Int64("user_id", userID), slog.Int("event_group", body.EventGroupID), slog.Int("event_type", body.EventType), slog.String("reason", string(xerr.ReasonFromGRPC(err))), ) writeTencentRTCCallback(writer, http.StatusOK, 0, "") return } logx.Error(logCtx, "gateway_rtc_callback_room_service_error", err, slog.String("provider", "tencent"), slog.String("room_id", roomID), slog.Int64("user_id", userID), slog.Int("event_group", body.EventGroupID), slog.Int("event_type", body.EventType), ) writeTencentRTCCallback(writer, http.StatusInternalServerError, 1, "room service error") return } logx.Info(logCtx, "gateway_rtc_callback_accepted", slog.String("provider", "tencent"), slog.String("room_id", roomID), slog.Int64("user_id", userID), slog.Int("event_group", body.EventGroupID), slog.Int("event_type", body.EventType), slog.Int64("room_version", resp.GetResult().GetRoomVersion()), slog.Bool("applied", resp.GetResult().GetApplied()), ) writeTencentRTCCallback(writer, http.StatusOK, 0, "") } func (h *Handler) tencentRTCSDKAppIDMatched(request *http.Request) bool { if h.tencentRTC.SDKAppID <= 0 { return false } raw := strings.TrimSpace(request.Header.Get(tencentrtc.CallbackHeaderSDKAppID)) value, err := strconv.ParseInt(raw, 10, 64) return err == nil && value == h.tencentRTC.SDKAppID } func normalizeTencentRTCEventType(groupID int, eventType int) (string, bool) { switch { case groupID == tencentRTCEventGroupMedia && eventType == tencentRTCEventStartAudio: return "audio_started", true case groupID == tencentRTCEventGroupMedia && eventType == tencentRTCEventStopAudio: return "audio_stopped", true case groupID == tencentRTCEventGroupRoom && eventType == tencentRTCEventRoomExit: return "room_exited", true default: return "", false } } func parseTencentRTCRoomID(raw json.RawMessage) (string, bool) { raw = bytes.TrimSpace(raw) if len(raw) == 0 || bytes.Equal(raw, []byte("null")) { return "", false } decoder := json.NewDecoder(bytes.NewReader(raw)) decoder.UseNumber() var value any if err := decoder.Decode(&value); err != nil { return "", false } switch typed := value.(type) { case string: roomID := strings.TrimSpace(typed) return roomID, roomID != "" case json.Number: roomID := strings.TrimSpace(typed.String()) return roomID, roomID != "" default: return "", false } } func tencentRTCEventTimeMS(body tencentRTCCallbackBody) int64 { if body.EventInfo.EventMsTs > 0 { return body.EventInfo.EventMsTs } if body.EventInfo.EventTs > 0 { return body.EventInfo.EventTs * 1000 } return body.CallbackTs } func tencentRTCCallbackReason(body tencentRTCCallbackBody) string { switch body.EventType { case tencentRTCEventRoomExit: return fmt.Sprintf("rtc_room_exited:%d", body.EventInfo.Reason) case tencentRTCEventStopAudio: return fmt.Sprintf("rtc_audio_stopped:%d", body.EventInfo.Reason) case tencentRTCEventStartAudio: return "rtc_audio_started" default: return fmt.Sprintf("rtc_event:%d:%d", body.EventGroupID, body.EventType) } } func tencentRTCCallbackCommandID(body tencentRTCCallbackBody, roomID string, userID int64, eventTimeMS int64) string { return "cmd_" + tencentRTCCallbackDigest(body, roomID, userID, eventTimeMS) } func tencentRTCCallbackExternalEventID(body tencentRTCCallbackBody, roomID string, userID int64, eventTimeMS int64) string { return "rtc_evt_" + tencentRTCCallbackDigest(body, roomID, userID, eventTimeMS) } func tencentRTCCallbackDigest(body tencentRTCCallbackBody, roomID string, userID int64, eventTimeMS int64) string { seed := fmt.Sprintf("%d:%d:%s:%d:%d:%d", body.EventGroupID, body.EventType, roomID, userID, eventTimeMS, body.EventInfo.Reason) sum := sha256.Sum256([]byte(seed)) return hex.EncodeToString(sum[:16]) } func tencentRTCCallbackAppCode(request *http.Request) string { if raw := strings.TrimSpace(request.URL.Query().Get("app_code")); raw != "" { return appcode.Normalize(raw) } return appcode.FromContext(request.Context()) } func tencentRTCCallbackCanAckBusinessError(err error) bool { reason := xerr.ReasonFromGRPC(err) return reason == xerr.NotFound || reason == xerr.InvalidArgument || reason == xerr.RoomClosed || reason == xerr.PermissionDenied } func writeTencentRTCCallback(writer http.ResponseWriter, statusCode int, code int, message string) { writer.Header().Set("Content-Type", "application/json") writer.WriteHeader(statusCode) _ = json.NewEncoder(writer).Encode(tencentRTCCallbackResponse{ Code: code, Message: message, }) }