335 lines
12 KiB
Go
335 lines
12 KiB
Go
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 注册表解析 client;gateway 用它把包名转换成内部 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
|
||
}
|