346 lines
12 KiB
Go
346 lines
12 KiB
Go
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)
|
||
}
|