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

335 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 (
"context"
"hyapp/services/gateway-service/internal/transport/http/httpkit"
"io"
"net/http"
"strings"
"time"
"hyapp/pkg/appcode"
"hyapp/pkg/idgen"
"hyapp/pkg/imgroup"
"hyapp/pkg/tencentim"
"hyapp/services/gateway-service/internal/auth"
"hyapp/services/gateway-service/internal/client"
roomv1 "hyapp.local/api/proto/room/v1"
userv1 "hyapp.local/api/proto/user/v1"
)
// Handler 是 gateway 的 HTTP transport adapter。
// 它只依赖跨服务 client不持有房间状态也不直接实现房间命令语义。
type Handler struct {
roomClient client.RoomClient
roomGuardClient client.RoomGuardClient
roomQueryClient client.RoomQueryClient
userClient client.UserAuthClient
userIdentityClient client.UserIdentityClient
userProfileClient client.UserProfileClient
userSocialClient client.UserSocialClient
userDeviceClient client.UserDeviceClient
userCountryClient client.UserCountryQueryClient
userHostClient client.UserHostClient
appRegistryClient client.AppRegistryClient
walletClient client.WalletClient
messageClient client.MessageInboxClient
taskClient client.TaskClient
broadcastClient client.BroadcastClient
appConfigReader appConfigReader
tencentIM TencentIMConfig
tencentRTC TencentRTCConfig
objectUploader ObjectUploader
authRateLimitConfig AuthRateLimitConfig
authRateLimiter publicAuthRateLimiter
loginRiskConfig LoginRiskConfig
loginRiskCache loginRiskCache
}
// TencentIMConfig 是 gateway transport 层签发客户端腾讯云 IM 登录票据所需的配置。
type TencentIMConfig struct {
SDKAppID int64
SecretKey string
UserSigTTL time.Duration
AdminIdentifier string
CallbackAuthToken string
}
// TencentRTCConfig 是 gateway transport 层签发客户端腾讯 RTC 进房票据所需的配置。
type TencentRTCConfig struct {
Enabled bool
SDKAppID int64
SecretKey string
UserSigTTL time.Duration
RoomIDType string
AppScene string
CallbackSignKey string
}
// ObjectUploader 是 gateway 上传接口依赖的对象存储最小能力。
type ObjectUploader interface {
PutObject(ctx context.Context, key string, reader io.Reader, sizeBytes int64, contentType string) (string, error)
}
// NewHandler 装配 gateway HTTP handler 的内部依赖。
func NewHandler(roomClient client.RoomClient, userClient ...client.UserAuthClient) *Handler {
config := defaultAuthRateLimitConfig()
handler := &Handler{roomClient: roomClient, authRateLimitConfig: config, authRateLimiter: newMemoryAuthRateLimiter(config), loginRiskConfig: defaultLoginRiskConfig()}
if len(userClient) > 0 {
handler.userClient = userClient[0]
}
return handler
}
// NewHandlerWithClients 装配需要用户资料接口的 gateway HTTP handler。
func NewHandlerWithClients(roomClient client.RoomClient, userClient client.UserAuthClient, userIdentityClient client.UserIdentityClient, userProfileClient ...client.UserProfileClient) *Handler {
handler := &Handler{
roomClient: roomClient,
userClient: userClient,
userIdentityClient: userIdentityClient,
loginRiskConfig: defaultLoginRiskConfig(),
}
handler.SetAuthRateLimit(defaultAuthRateLimitConfig())
if len(userProfileClient) > 0 {
handler.userProfileClient = userProfileClient[0]
}
return handler
}
// NewHandlerWithConfig 装配完整 gateway HTTP handler 和外部云服务配置。
func NewHandlerWithConfig(roomClient client.RoomClient, roomGuardClient client.RoomGuardClient, userClient client.UserAuthClient, userIdentityClient client.UserIdentityClient, tencentIM TencentIMConfig, userProfileClient ...client.UserProfileClient) *Handler {
handler := &Handler{
roomClient: roomClient,
roomGuardClient: roomGuardClient,
userClient: userClient,
userIdentityClient: userIdentityClient,
tencentIM: tencentIM,
loginRiskConfig: defaultLoginRiskConfig(),
}
handler.SetAuthRateLimit(defaultAuthRateLimitConfig())
if len(userProfileClient) > 0 {
handler.userProfileClient = userProfileClient[0]
}
return handler
}
// SetTencentRTC 注入 RTC 签发配置;缺失或 disabled 时 token endpoint 会 fail-closed。
func (h *Handler) SetTencentRTC(config TencentRTCConfig) {
h.tencentRTC = config
}
// SetObjectUploader 注入对象存储上传实现;生产环境使用腾讯云 COS。
func (h *Handler) SetObjectUploader(uploader ObjectUploader) {
h.objectUploader = uploader
}
// SetRoomQueryClient 注入 room-service 读模型查询 client。
func (h *Handler) SetRoomQueryClient(roomQueryClient client.RoomQueryClient) {
h.roomQueryClient = roomQueryClient
}
// SetUserCountryQueryClient 注入注册页国家列表查询 client。
func (h *Handler) SetUserCountryQueryClient(userCountryClient client.UserCountryQueryClient) {
h.userCountryClient = userCountryClient
}
// SetUserDeviceClient 注入 user-service 设备推送 token client。
func (h *Handler) SetUserDeviceClient(userDeviceClient client.UserDeviceClient) {
h.userDeviceClient = userDeviceClient
}
// SetUserHostClient 注入 user-service host domain 身份查询 client。
func (h *Handler) SetUserHostClient(userHostClient client.UserHostClient) {
h.userHostClient = userHostClient
}
// SetUserSocialClient 注入 user-service 访问、关注和好友关系 client。
func (h *Handler) SetUserSocialClient(userSocialClient client.UserSocialClient) {
h.userSocialClient = userSocialClient
}
// SetAppRegistryClient 注入 App 注册表解析 clientgateway 用它把包名转换成内部 app_code。
func (h *Handler) SetAppRegistryClient(appRegistryClient client.AppRegistryClient) {
h.appRegistryClient = appRegistryClient
}
// SetWalletClient 注入 wallet-service 余额查询 client。
func (h *Handler) SetWalletClient(walletClient client.WalletClient) {
h.walletClient = walletClient
}
// SetMessageInboxClient 注入 activity-service message inbox client。
func (h *Handler) SetMessageInboxClient(messageClient client.MessageInboxClient) {
h.messageClient = messageClient
}
// SetTaskClient 注入 activity-service task client。
func (h *Handler) SetTaskClient(taskClient client.TaskClient) {
h.taskClient = taskClient
}
// SetBroadcastClient 注入 activity-service 播报群成员关系 client。
func (h *Handler) SetBroadcastClient(broadcastClient client.BroadcastClient) {
h.broadcastClient = broadcastClient
}
// SetAppConfigReader 注入后台 App 配置读取依赖。
func (h *Handler) SetAppConfigReader(reader appConfigReader) {
h.appConfigReader = reader
}
// meta 把外部 HTTP 请求上下文转换成 room-service 命令元信息。
// request_id 只做链路追踪command_id 才是 room-service command log 使用的幂等键。
func meta(request *http.Request, roomID string, commandID string) *roomv1.RequestMeta {
return &roomv1.RequestMeta{
RequestId: httpkit.RequestIDFromContext(request.Context()),
CommandId: commandIDOrNew(commandID),
ActorUserId: auth.UserIDFromContext(request.Context()),
RoomId: roomID,
AppCode: appcode.FromContext(request.Context()),
GatewayNodeId: "gateway-local",
SessionId: idgen.New("sess"),
SentAtMs: time.Now().UnixMilli(),
}
}
func commandIDOrNew(commandID string) string {
commandID = strings.TrimSpace(commandID)
if commandID != "" {
// 客户端重试同一业务命令时必须复用该值room-service 才能命中幂等日志。
return commandID
}
// 自动生成只保证本次请求有 command_id不保证客户端重试仍命中同一个命令。
return idgen.New("cmd")
}
// decode 统一处理 HTTP JSON 入参解析失败分支。
// gateway 在这里拒绝非法 JSON不做房间业务校验避免把 Room Cell 规则复制到入口层。
func decode(writer http.ResponseWriter, request *http.Request, out any) bool {
if err := httpkit.DecodeJSON(request.Body, out); err != nil {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidJSON, "invalid request body")
return false
}
return true
}
// write 统一把内部 gRPC 调用结果转换为 HTTP JSON 响应。
// 首版不在 gateway 细分 gRPC 错误码,避免入口层提前耦合 room-service 的业务错误模型。
func write(writer http.ResponseWriter, request *http.Request, body any, err error) {
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
httpkit.WriteOK(writer, request, body)
}
func (h *Handler) issueTencentIMUserSig(writer http.ResponseWriter, request *http.Request) {
userID := auth.UserIDFromContext(request.Context())
if userID <= 0 {
// 正常路径已由 auth middleware 拦截;这里兜底避免签发匿名 IM 票据。
httpkit.WriteError(writer, request, http.StatusUnauthorized, httpkit.CodeUnauthorized, "unauthorized")
return
}
if h.userProfileClient == nil {
// 播报群 join 列表必须来自 user-service 当前资料,不能靠 token 快照猜区域。
httpkit.WriteError(writer, request, http.StatusInternalServerError, httpkit.CodeInternalError, "internal error")
return
}
userResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
Meta: authRequestMeta(request, ""),
UserId: userID,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
user := userResp.GetUser()
if user == nil {
httpkit.WriteError(writer, request, http.StatusInternalServerError, httpkit.CodeInternalError, "internal error")
return
}
if !user.GetProfileCompleted() {
httpkit.WriteError(writer, request, http.StatusForbidden, httpkit.CodeProfileRequired, "profile required")
return
}
app := appcode.FromContext(request.Context())
if user.GetAppCode() != "" && appcode.Normalize(user.GetAppCode()) != app {
// UserSig 是 IM 登录凭证,不能给跨 app_code 用户签发,否则会绕过播报群租户隔离。
httpkit.WriteError(writer, request, http.StatusForbidden, httpkit.CodePermissionDenied, "permission denied")
return
}
result, err := tencentim.GenerateUserSig(tencentim.UserSigConfig{
SDKAppID: h.tencentIM.SDKAppID,
SecretKey: h.tencentIM.SecretKey,
TTL: h.tencentIM.UserSigTTL,
}, tencentim.FormatUserID(userID), time.Now().UTC())
if err != nil {
// 缺少 SDKAppID 或 SecretKey 属于服务端配置错误,不能返回假票据给客户端。
httpkit.WriteError(writer, request, http.StatusInternalServerError, httpkit.CodeInternalError, "internal error")
return
}
joinGroups, ok := imJoinGroupsForUser(app, user.GetRegionId())
if !ok {
// 生成 join_groups 失败代表服务端 app_code/region_id 配置异常,不能返回缺失群列表让客户端自行猜。
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
httpkit.WriteOK(writer, request, tencentIMUserSigResponse{
SDKAppID: result.SDKAppID,
UserID: result.UserID,
UserSig: result.UserSig,
ExpireAtMS: result.ExpireAtMS,
JoinGroups: joinGroups,
})
}
type tencentIMUserSigResponse struct {
SDKAppID int64 `json:"sdk_app_id"`
UserID string `json:"user_id"`
UserSig string `json:"user_sig"`
ExpireAtMS int64 `json:"expire_at_ms"`
JoinGroups []tencentIMJoinGroup `json:"join_groups"`
}
type tencentIMJoinGroup struct {
GroupID string `json:"group_id"`
Type string `json:"type"`
RegionID int64 `json:"region_id,omitempty"`
}
func imJoinGroupsForUser(app string, regionID int64) ([]tencentIMJoinGroup, bool) {
globalGroupID, err := imgroup.GlobalBroadcastGroupID(app)
if err != nil {
return nil, false
}
// 全局群始终返回;实际消息量应很低,主要用于全服级运营通知或极少数全局事件。
groups := []tencentIMJoinGroup{{
GroupID: globalGroupID,
Type: string(imgroup.KindGlobalBroadcast),
}}
if regionID > 0 {
// 区域群来自 user-service 当前 region_id客户端不能自选区域群区域变更后下一次 UserSig 返回新群。
regionGroupID, err := imgroup.RegionBroadcastGroupID(app, regionID)
if err != nil {
return nil, false
}
groups = append(groups, tencentIMJoinGroup{
GroupID: regionGroupID,
Type: string(imgroup.KindRegionBroadcast),
RegionID: regionID,
})
}
return groups, true
}