2026-05-12 09:53:20 +08:00

346 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
}