diff --git a/cmd/api/main.go b/cmd/api/main.go index 95d8a08..0ce36bb 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -116,6 +116,10 @@ func main() { baishun.NewAppProvider(baishunService), lingxian.NewAppProvider(lingxianService), ) + gameVisibility := gameprovider.NewVisibilityPolicy( + &app.Gateways, + gameprovider.NewCachedIPCountryResolver(app.Repository.Redis, app.Config.HTTP.Timeout), + ) engine := router.NewRouter(app.Config, app.Repository, &app.Gateways, router.Services{ AppPopup: appPopupService, ErrorLog: errorLogService, @@ -126,6 +130,7 @@ func main() { Lingxian: lingxianService, GameOpen: gameOpenService, GameProviders: gameProviders, + GameVisibility: gameVisibility, LuckyGift: luckyGiftService, ManagerCenter: managerCenterService, PropsStore: propsStoreService, diff --git a/internal/common/auth_user.go b/internal/common/auth_user.go index e015fe5..ccda31b 100644 --- a/internal/common/auth_user.go +++ b/internal/common/auth_user.go @@ -6,4 +6,6 @@ type AuthUser struct { SysOrigin string Token string Authorization string + RegionID string + RegionCode string } diff --git a/internal/integration/gateways.go b/internal/integration/gateways.go index 0187bdb..b7d4313 100644 --- a/internal/integration/gateways.go +++ b/internal/integration/gateways.go @@ -146,6 +146,11 @@ func (g *Gateways) GetUserRegion(ctx context.Context, userID int64, authorizatio return g.Profile.GetUserRegion(ctx, userID, authorization) } +// GetUserLevel 透传到用户等级网关。 +func (g *Gateways) GetUserLevel(ctx context.Context, sysOrigin string, userID int64) (UserLevel, error) { + return g.Profile.GetUserLevel(ctx, sysOrigin, userID) +} + // ResolveRegionCodeByCountryCode 透传到 Java 区域配置网关。 func (g *Gateways) ResolveRegionCodeByCountryCode(ctx context.Context, sysOrigin string, countryCode string) (string, error) { return g.Profile.ResolveRegionCodeByCountryCode(ctx, sysOrigin, countryCode) @@ -400,6 +405,11 @@ func (g *ProfileGateway) GetUserRegion(ctx context.Context, userID int64, author return g.client.GetUserRegion(ctx, userID, authorization) } +// GetUserLevel 查询用户财富/魅力等级。 +func (g *ProfileGateway) GetUserLevel(ctx context.Context, sysOrigin string, userID int64) (UserLevel, error) { + return g.client.GetUserLevel(ctx, sysOrigin, userID) +} + // ResolveRegionCodeByCountryCode 根据 Java 区域配置把国家码解析成区域编码。 func (g *ProfileGateway) ResolveRegionCodeByCountryCode(ctx context.Context, sysOrigin string, countryCode string) (string, error) { return g.client.ResolveRegionCodeByCountryCode(ctx, sysOrigin, countryCode) diff --git a/internal/integration/java.go b/internal/integration/java.go index 849b0c3..0a34e40 100644 --- a/internal/integration/java.go +++ b/internal/integration/java.go @@ -59,6 +59,12 @@ type UserRegion struct { RegionCode string `json:"regionCode"` } +type UserLevel struct { + UserID Int64Value `json:"userId"` + WealthLevel int `json:"wealthLevel"` + CharmLevel int `json:"charmLevel"` +} + type RegionConfig struct { RegionCode string `json:"regionCode"` SysOrigin string `json:"sysOrigin"` @@ -1044,6 +1050,21 @@ func (c *Client) GetUserRegion(ctx context.Context, userID int64, authorization return resp.Body, nil } +func (c *Client) GetUserLevel(ctx context.Context, sysOrigin string, userID int64) (UserLevel, error) { + if userID <= 0 { + return UserLevel{}, fmt.Errorf("userId is required") + } + values := url.Values{} + values.Set("sysOrigin", strings.TrimSpace(sysOrigin)) + values.Set("userId", strconv.FormatInt(userID, 10)) + endpoint := c.cfg.Java.OtherBaseURL + "/user-level/client/getLevelByUserId?" + values.Encode() + var resp resultResponse[UserLevel] + if err := c.getJSON(ctx, endpoint, nil, &resp); err != nil { + return UserLevel{}, err + } + return resp.Body, nil +} + func (c *Client) ResolveRegionCodeByCountryCode(ctx context.Context, sysOrigin string, countryCode string) (string, error) { countryCode = strings.ToUpper(strings.TrimSpace(countryCode)) if countryCode == "" { diff --git a/internal/router/game_provider_routes.go b/internal/router/game_provider_routes.go index b096bf8..183393a 100644 --- a/internal/router/game_provider_routes.go +++ b/internal/router/game_provider_routes.go @@ -11,7 +11,7 @@ import ( ) // registerGameProviderRoutes 注册统一游戏厂商 app 路由。 -func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, registry *gameprovider.Registry) { +func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, registry *gameprovider.Registry, visibility *gameprovider.VisibilityPolicy) { if registry == nil { return } @@ -19,7 +19,15 @@ func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, regi appGroup := engine.Group("/app/game") appGroup.Use(authMiddleware(javaClient)) appGroup.GET("/room/shortcut", func(c *gin.Context) { - resp, err := registry.ListShortcutGames(c.Request.Context(), mustAuthUser(c), c.Query("roomId")) + user, visible, ok := gameListAccess(c, visibility) + if !ok { + return + } + if !visible { + writeOK(c, []publicRoomGameItem{}) + return + } + resp, err := registry.ListShortcutGames(c.Request.Context(), user, c.Query("roomId")) if err != nil { writeError(c, err) return @@ -27,7 +35,15 @@ func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, regi writeOK(c, publicRoomGameItems(resp)) }) appGroup.GET("/room/list", func(c *gin.Context) { - resp, err := registry.ListRoomGames(c.Request.Context(), mustAuthUser(c), c.Query("roomId"), c.Query("category")) + user, visible, ok := gameListAccess(c, visibility) + if !ok { + return + } + if !visible { + writeOK(c, publicRoomGameListResponse(&gameprovider.RoomGameListResponse{Items: []gameprovider.RoomGameListItem{}})) + return + } + resp, err := registry.ListRoomGames(c.Request.Context(), user, c.Query("roomId"), c.Query("category")) if err != nil { writeError(c, err) return @@ -72,14 +88,22 @@ func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, regi providerGroup := engine.Group("/app/game/providers") providerGroup.Use(authMiddleware(javaClient)) providerGroup.GET("", func(c *gin.Context) { - if gameprovider.IsEmptyGameListUser(mustAuthUser(c).UserID) { + _, visible, ok := gameListAccess(c, visibility) + if !ok { + return + } + if !visible { writeOK(c, gameprovider.ProviderListResponse{Items: []gameprovider.ProviderSummary{}}) return } writeOK(c, registry.List()) }) providerGroup.GET("/:provider/room/shortcut", func(c *gin.Context) { - if gameprovider.IsEmptyGameListUser(mustAuthUser(c).UserID) { + user, visible, ok := gameListAccess(c, visibility) + if !ok { + return + } + if !visible { writeOK(c, gin.H{"items": []publicRoomGameItem{}}) return } @@ -87,7 +111,7 @@ func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, regi if !ok { return } - resp, err := provider.ListShortcutGames(c.Request.Context(), mustAuthUser(c), c.Query("roomId")) + resp, err := provider.ListShortcutGames(c.Request.Context(), user, c.Query("roomId")) if err != nil { writeError(c, err) return @@ -95,7 +119,11 @@ func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, regi writeOK(c, gin.H{"items": publicRoomGameItems(resp)}) }) providerGroup.GET("/:provider/room/list", func(c *gin.Context) { - if gameprovider.IsEmptyGameListUser(mustAuthUser(c).UserID) { + user, visible, ok := gameListAccess(c, visibility) + if !ok { + return + } + if !visible { writeOK(c, publicRoomGameListResponse(&gameprovider.RoomGameListResponse{Items: []gameprovider.RoomGameListItem{}})) return } @@ -103,7 +131,7 @@ func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, regi if !ok { return } - resp, err := provider.ListRoomGames(c.Request.Context(), mustAuthUser(c), c.Query("roomId"), c.Query("category")) + resp, err := provider.ListRoomGames(c.Request.Context(), user, c.Query("roomId"), c.Query("category")) if err != nil { writeError(c, err) return @@ -158,6 +186,25 @@ func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, regi }) } +func gameListAccess(c *gin.Context, visibility *gameprovider.VisibilityPolicy) (common.AuthUser, bool, bool) { + user := mustAuthUser(c) + if gameprovider.IsEmptyGameListUser(user.UserID) { + return user, false, true + } + if visibility == nil { + return user, true, true + } + // 游戏列表只在列表入口做展示门禁,结果里的区域继续向下传给 provider SQL 做 regions 过滤。 + result, err := visibility.Evaluate(c.Request.Context(), user, resolveClientIP(c)) + if err != nil { + writeError(c, err) + return user, false, false + } + user.RegionID = result.RegionID + user.RegionCode = result.RegionCode + return user, result.Allow, true +} + func resolveGameProvider(c *gin.Context, registry *gameprovider.Registry) (gameprovider.Provider, bool) { provider, err := registry.Resolve(c.Param("provider")) if err != nil { diff --git a/internal/router/game_provider_routes_test.go b/internal/router/game_provider_routes_test.go index d861425..0fcce93 100644 --- a/internal/router/game_provider_routes_test.go +++ b/internal/router/game_provider_routes_test.go @@ -1,9 +1,17 @@ package router import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" "testing" + "chatapp3-golang/internal/common" + "chatapp3-golang/internal/integration" "chatapp3-golang/internal/service/gameprovider" + + "github.com/gin-gonic/gin" ) func TestPublicRoomGameListResponseKeepsLegacyProviderFields(t *testing.T) { @@ -141,3 +149,158 @@ func TestPublicLaunchMapsLingxianToLeaderForOldClients(t *testing.T) { t.Fatalf("roomState.provider = %q, want LEADER", resp.RoomState.Provider) } } + +func TestGameProviderRoutesReturnEmptyWhenVisibilityPolicyDenies(t *testing.T) { + gin.SetMode(gin.TestMode) + provider := &routeStubProvider{} + engine := gin.New() + registerGameProviderRoutes( + engine, + routeStubAuthGateway{}, + gameprovider.NewRegistry(provider), + gameprovider.NewVisibilityPolicy( + routeStubVisibilityGateway{ + region: integration.UserRegion{RegionID: "2046066409959649281", RegionCode: "TR"}, + level: integration.UserLevel{WealthLevel: 1}, + targetRegionCode: "TR", + }, + routeStubIPCountryResolver{countryCode: "TR"}, + ), + ) + + req := httptest.NewRequest(http.MethodGet, "/app/game/room/list?roomId=1", nil) + req.Header.Set("Authorization", "Bearer token") + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String()) + } + var payload struct { + Body struct { + Items []publicRoomGameItem `json:"items"` + } `json:"body"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("response json error = %v: %s", err, rec.Body.String()) + } + if len(payload.Body.Items) != 0 { + t.Fatalf("items = %d, want 0", len(payload.Body.Items)) + } + if provider.listRoomCalls != 0 { + t.Fatalf("provider list calls = %d, want 0", provider.listRoomCalls) + } +} + +func TestGameProviderRoutesReturnProvidersWhenVisibilityPolicyAllows(t *testing.T) { + gin.SetMode(gin.TestMode) + engine := gin.New() + registerGameProviderRoutes( + engine, + routeStubAuthGateway{}, + gameprovider.NewRegistry(&routeStubProvider{}), + gameprovider.NewVisibilityPolicy( + routeStubVisibilityGateway{ + region: integration.UserRegion{RegionID: "2046066409959649281", RegionCode: "TR"}, + level: integration.UserLevel{WealthLevel: 2}, + targetRegionCode: "TR", + }, + routeStubIPCountryResolver{countryCode: "TR"}, + ), + ) + + req := httptest.NewRequest(http.MethodGet, "/app/game/providers", nil) + req.Header.Set("Authorization", "Bearer token") + req.Header.Set("X-Real-IP", "88.255.1.1") + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String()) + } + var payload struct { + Body struct { + Items []gameprovider.ProviderSummary `json:"items"` + } `json:"body"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("response json error = %v: %s", err, rec.Body.String()) + } + if len(payload.Body.Items) != 1 { + t.Fatalf("providers = %d, want 1", len(payload.Body.Items)) + } + if payload.Body.Items[0].Key != "TEST" { + t.Fatalf("provider key = %q, want TEST", payload.Body.Items[0].Key) + } +} + +type routeStubAuthGateway struct{} + +func (routeStubAuthGateway) AuthenticateToken(context.Context, string) (integration.UserCredential, error) { + return integration.UserCredential{UserID: integration.Int64Value(1001), SysOrigin: "LIKEI"}, nil +} + +func (routeStubAuthGateway) AuthenticateConsoleToken(context.Context, string) (integration.ConsoleAccount, error) { + return integration.ConsoleAccount{}, nil +} + +type routeStubVisibilityGateway struct { + region integration.UserRegion + level integration.UserLevel + targetRegionCode string +} + +func (s routeStubVisibilityGateway) GetUserRegion(context.Context, int64, string) (integration.UserRegion, error) { + return s.region, nil +} + +func (s routeStubVisibilityGateway) GetUserLevel(context.Context, string, int64) (integration.UserLevel, error) { + return s.level, nil +} + +func (s routeStubVisibilityGateway) ResolveRegionCodeByCountryCode(context.Context, string, string) (string, error) { + return s.targetRegionCode, nil +} + +type routeStubIPCountryResolver struct { + countryCode string +} + +func (s routeStubIPCountryResolver) CountryCode(context.Context, string) (string, error) { + return s.countryCode, nil +} + +type routeStubProvider struct { + listRoomCalls int +} + +func (p *routeStubProvider) Key() string { return "TEST" } + +func (p *routeStubProvider) DisplayName() string { return "Test Provider" } + +func (p *routeStubProvider) SupportsLaunch(context.Context, gameprovider.AuthUser, gameprovider.LaunchRequest) (bool, error) { + return false, nil +} + +func (p *routeStubProvider) ListShortcutGames(context.Context, gameprovider.AuthUser, string) ([]gameprovider.RoomGameListItem, error) { + return []gameprovider.RoomGameListItem{{ID: 1, GameID: "test_1", Provider: "TEST", ProviderGameID: "1", Name: "Test Game"}}, nil +} + +func (p *routeStubProvider) ListRoomGames(context.Context, gameprovider.AuthUser, string, string) (*gameprovider.RoomGameListResponse, error) { + p.listRoomCalls++ + return &gameprovider.RoomGameListResponse{ + Items: []gameprovider.RoomGameListItem{{ID: 1, GameID: "test_1", Provider: "TEST", ProviderGameID: "1", Name: "Test Game"}}, + }, nil +} + +func (p *routeStubProvider) GetRoomState(context.Context, gameprovider.AuthUser, string) (*gameprovider.RoomStateResponse, error) { + return &gameprovider.RoomStateResponse{}, nil +} + +func (p *routeStubProvider) LaunchGame(context.Context, gameprovider.AuthUser, gameprovider.LaunchRequest, string) (*gameprovider.LaunchResponse, error) { + return nil, common.NewAppError(http.StatusBadRequest, "unsupported", "unsupported") +} + +func (p *routeStubProvider) CloseGame(context.Context, gameprovider.AuthUser, gameprovider.CloseRequest) (*gameprovider.RoomStateResponse, error) { + return &gameprovider.RoomStateResponse{}, nil +} diff --git a/internal/router/middleware.go b/internal/router/middleware.go index b10ed76..5b69555 100644 --- a/internal/router/middleware.go +++ b/internal/router/middleware.go @@ -112,6 +112,9 @@ func trimBearer(authorization string) string { // resolveClientIP 解析请求来源 IP。 func resolveClientIP(c *gin.Context) string { + if realIP := strings.TrimSpace(c.GetHeader("X-Real-IP")); realIP != "" { + return realIP + } if forwarded := strings.TrimSpace(c.GetHeader("X-Forwarded-For")); forwarded != "" { parts := strings.Split(forwarded, ",") if len(parts) > 0 { diff --git a/internal/router/router.go b/internal/router/router.go index 243fe52..80d8d28 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -48,6 +48,7 @@ type Services struct { Lingxian *lingxian.Service GameOpen *gameopen.GameOpenService GameProviders *gameprovider.Registry + GameVisibility *gameprovider.VisibilityPolicy LuckyGift *luckygift.LuckyGiftService ManagerCenter *managercenter.Service PropsStore *propsstore.Service @@ -100,7 +101,7 @@ func NewRouter( registerRoomTurnoverRewardRoutes(engine, javaClient, services.RoomTurnoverReward) registerHostCenterRoutes(engine, services.HostCenter) registerGameOpenRoutes(engine, cfg, services.GameOpen) - registerGameProviderRoutes(engine, javaClient, services.GameProviders) + registerGameProviderRoutes(engine, javaClient, services.GameProviders, services.GameVisibility) registerBaishunRoutes(engine, cfg, javaClient, services.Baishun) registerLingxianRoutes(engine, javaClient, services.Lingxian) registerLuckyGiftRoutes(engine, cfg, services.LuckyGift) diff --git a/internal/service/baishun/room.go b/internal/service/baishun/room.go index efeb3f4..5fd9175 100644 --- a/internal/service/baishun/room.go +++ b/internal/service/baishun/room.go @@ -13,7 +13,7 @@ import ( // ListShortcutGames 返回房间里的快捷游戏列表,数量上限为 5 个。 func (s *BaishunService) ListShortcutGames(ctx context.Context, user AuthUser, roomID string) ([]RoomGameListItem, error) { - items, err := s.listRoomGames(ctx, user.SysOrigin, roomID, "") + items, err := s.listRoomGames(ctx, user.SysOrigin, user.RegionID, roomID, "") if err != nil { return nil, err } @@ -25,7 +25,7 @@ func (s *BaishunService) ListShortcutGames(ctx context.Context, user AuthUser, r // ListRoomGames 返回房间可启动的完整游戏列表。 func (s *BaishunService) ListRoomGames(ctx context.Context, user AuthUser, roomID string, category string) (*RoomGameListResponse, error) { - items, err := s.listRoomGames(ctx, user.SysOrigin, roomID, category) + items, err := s.listRoomGames(ctx, user.SysOrigin, user.RegionID, roomID, category) if err != nil { return nil, err } @@ -59,7 +59,7 @@ func (s *BaishunService) GetRoomState(ctx context.Context, user AuthUser, roomID } // listRoomGames 联表读取房间可启动游戏,并按展示规则排序。 -func (s *BaishunService) listRoomGames(ctx context.Context, sysOrigin, roomID, category string) ([]RoomGameListItem, error) { +func (s *BaishunService) listRoomGames(ctx context.Context, sysOrigin, regionID, roomID, category string) ([]RoomGameListItem, error) { sysOrigin = normalizeAdminSysOrigin(sysOrigin) profile, err := s.activeProfile(ctx, sysOrigin) if err != nil { @@ -102,6 +102,10 @@ func (s *BaishunService) listRoomGames(ctx context.Context, sysOrigin, roomID, c Joins("JOIN sys_game_list_vendor_ext ext ON ext.game_list_config_id = cfg.id AND ext.enabled = 1 AND ext.profile = ?", profile). Joins("LEFT JOIN baishun_game_catalog cat ON cat.sys_origin = cfg.sys_origin AND cat.profile = ext.profile AND CAST(cat.vendor_game_id AS CHAR) = ext.vendor_game_id"). Where("cfg.sys_origin = ? AND cfg.is_showcase = 1 AND ext.vendor_type = ?", sysOrigin, baishunVendorType) + if strings.TrimSpace(regionID) != "" { + // regions 为空表示全区域;有值时按用户区域 ID 精确匹配,避免土耳其用户看到其他区域游戏。 + query = query.Where("(COALESCE(TRIM(cfg.regions), '') = '' OR FIND_IN_SET(?, REPLACE(COALESCE(cfg.regions, ''), ' ', '')) > 0)", strings.TrimSpace(regionID)) + } if strings.TrimSpace(category) != "" && !strings.EqualFold(strings.TrimSpace(category), "CHAT_ROOM") { query = query.Where("cfg.category = ?", category) } diff --git a/internal/service/gameprovider/visibility.go b/internal/service/gameprovider/visibility.go new file mode 100644 index 0000000..8a2e3f0 --- /dev/null +++ b/internal/service/gameprovider/visibility.go @@ -0,0 +1,250 @@ +package gameprovider + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "chatapp3-golang/internal/integration" + + "github.com/redis/go-redis/v9" +) + +const ( + gameListTargetSysOrigin = "LIKEI" + gameListTargetCountryCode = "TR" + gameListMinWealthLevel = 2 + ipCountryCachePrefix = "GAME_LIST:IP_COUNTRY:" + ipCountryCacheTTL = 7 * 24 * time.Hour +) + +// VisibilityGateway 聚合游戏列表门禁需要的 Java 用户区域和等级能力。 +type VisibilityGateway interface { + GetUserRegion(ctx context.Context, userID int64, authorization string) (integration.UserRegion, error) + GetUserLevel(ctx context.Context, sysOrigin string, userID int64) (integration.UserLevel, error) + ResolveRegionCodeByCountryCode(ctx context.Context, sysOrigin string, countryCode string) (string, error) +} + +// IPCountryResolver 把请求 IP 解析成国家二字码。 +type IPCountryResolver interface { + CountryCode(ctx context.Context, ip string) (string, error) +} + +// VisibilityResult 返回门禁结果,并把已经查到的区域带回路由继续参与 SQL 过滤。 +type VisibilityResult struct { + Allow bool + RegionID string + RegionCode string +} + +// VisibilityPolicy 判断当前用户是否可以看到游戏列表。 +type VisibilityPolicy struct { + java VisibilityGateway + ipResolver IPCountryResolver + targetCountry string + targetSys string + minWealth int +} + +// NewVisibilityPolicy 创建 LIKEI 游戏列表门禁:土耳其区域、土耳其 IP、财富等级 2 级及以上。 +func NewVisibilityPolicy(java VisibilityGateway, ipResolver IPCountryResolver) *VisibilityPolicy { + return &VisibilityPolicy{ + java: java, + ipResolver: ipResolver, + targetCountry: gameListTargetCountryCode, + targetSys: gameListTargetSysOrigin, + minWealth: gameListMinWealthLevel, + } +} + +// Evaluate 串行校验区域、财富等级和 IP 国家;任何条件不能确认时都按不可见处理。 +func (p *VisibilityPolicy) Evaluate(ctx context.Context, user AuthUser, clientIP string) (VisibilityResult, error) { + if p == nil || !strings.EqualFold(strings.TrimSpace(user.SysOrigin), p.targetSys) { + return VisibilityResult{Allow: true, RegionID: user.RegionID, RegionCode: user.RegionCode}, nil + } + if user.UserID <= 0 || p.java == nil { + return VisibilityResult{}, nil + } + + region, err := p.java.GetUserRegion(ctx, user.UserID, user.Authorization) + if err != nil { + return VisibilityResult{}, err + } + result := VisibilityResult{ + RegionID: strings.TrimSpace(region.RegionID), + RegionCode: strings.TrimSpace(region.RegionCode), + } + + // 区域配置是运营可变数据,所以先从 Java 区域配置解析 TR 对应的 regionCode,再与当前用户区域对比。 + targetRegionCode, err := p.java.ResolveRegionCodeByCountryCode(ctx, user.SysOrigin, p.targetCountry) + if err != nil { + return result, err + } + if !matchesTargetRegion(result.RegionCode, targetRegionCode, p.targetCountry) { + return result, nil + } + + level, err := p.java.GetUserLevel(ctx, user.SysOrigin, user.UserID) + if err != nil { + return result, err + } + if level.WealthLevel < p.minWealth { + return result, nil + } + + if p.ipResolver == nil { + return result, nil + } + countryCode, err := p.ipResolver.CountryCode(ctx, clientIP) + if err != nil || !strings.EqualFold(strings.TrimSpace(countryCode), p.targetCountry) { + return result, nil + } + result.Allow = true + return result, nil +} + +func matchesTargetRegion(userRegionCode, targetRegionCode, targetCountry string) bool { + userRegionCode = strings.TrimSpace(userRegionCode) + targetRegionCode = strings.TrimSpace(targetRegionCode) + if targetRegionCode != "" { + return strings.EqualFold(userRegionCode, targetRegionCode) + } + return strings.EqualFold(userRegionCode, strings.TrimSpace(targetCountry)) +} + +// CachedIPCountryResolver 使用 Redis 缓存 IP 国家码,未命中时按顺序调用几个公开 geoip provider。 +type CachedIPCountryResolver struct { + cache redis.Cmdable + httpClient *http.Client +} + +// NewCachedIPCountryResolver 创建可复用的 IP 国家解析器。 +func NewCachedIPCountryResolver(cache redis.Cmdable, timeout time.Duration) *CachedIPCountryResolver { + if timeout <= 0 { + timeout = 5 * time.Second + } + return &CachedIPCountryResolver{ + cache: cache, + httpClient: &http.Client{ + Timeout: timeout, + }, + } +} + +// CountryCode 返回 IP 对应国家二字码;解析不到时返回空字符串,调用方负责按不可见处理。 +func (r *CachedIPCountryResolver) CountryCode(ctx context.Context, ip string) (string, error) { + ip = strings.TrimSpace(ip) + if ip == "" || ip == "-" { + return "", nil + } + if cached := r.cachedCountryCode(ctx, ip); cached != "" { + return cached, nil + } + for _, provider := range ipGeoProviders { + countryCode := r.requestCountryCode(ctx, provider, ip) + if countryCode == "" { + continue + } + r.cacheCountryCode(ctx, ip, countryCode) + return countryCode, nil + } + return "", nil +} + +func (r *CachedIPCountryResolver) cachedCountryCode(ctx context.Context, ip string) string { + if r == nil || r.cache == nil { + return "" + } + value, err := r.cache.Get(ctx, ipCountryCachePrefix+ip).Result() + if err != nil && !errors.Is(err, redis.Nil) { + return "" + } + return strings.ToUpper(strings.TrimSpace(value)) +} + +func (r *CachedIPCountryResolver) cacheCountryCode(ctx context.Context, ip string, countryCode string) { + if r == nil || r.cache == nil || strings.TrimSpace(countryCode) == "" { + return + } + _ = r.cache.Set(ctx, ipCountryCachePrefix+ip, strings.ToUpper(strings.TrimSpace(countryCode)), ipCountryCacheTTL).Err() +} + +type ipGeoProvider struct { + name string + urlFormat string +} + +var ipGeoProviders = []ipGeoProvider{ + {name: "ip.sb", urlFormat: "https://api.ip.sb/geoip/%s"}, + {name: "freeipapi", urlFormat: "https://freeipapi.com/api/json/%s"}, + {name: "ipwhois", urlFormat: "https://ipwhois.app/json/%s?format=json"}, + {name: "ip.nc.gy", urlFormat: "https://ip.nc.gy/json?ip=%s"}, +} + +func (r *CachedIPCountryResolver) requestCountryCode(ctx context.Context, provider ipGeoProvider, ip string) string { + if r == nil || r.httpClient == nil { + return "" + } + endpoint := fmt.Sprintf(provider.urlFormat, url.QueryEscape(ip)) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return "" + } + resp, err := r.httpClient.Do(req) + if err != nil { + return "" + } + defer resp.Body.Close() + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return "" + } + body, err := io.ReadAll(resp.Body) + if err != nil || len(body) == 0 { + return "" + } + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + return "" + } + return countryCodeFromProvider(provider.name, payload) +} + +func countryCodeFromProvider(provider string, payload map[string]any) string { + switch provider { + case "ip.sb": + return upperString(payload["country_code"]) + case "freeipapi": + return upperString(payload["countryCode"]) + case "ipwhois": + if success, ok := payload["success"].(bool); ok && !success { + return "" + } + return upperString(payload["country_code"]) + case "ip.nc.gy": + if code := countryObjectCode(payload["country"]); code != "" { + return code + } + return countryObjectCode(payload["registered_country"]) + default: + return "" + } +} + +func countryObjectCode(value any) string { + object, ok := value.(map[string]any) + if !ok { + return "" + } + return upperString(object["iso_code"]) +} + +func upperString(value any) string { + text, _ := value.(string) + return strings.ToUpper(strings.TrimSpace(text)) +} diff --git a/internal/service/gameprovider/visibility_test.go b/internal/service/gameprovider/visibility_test.go new file mode 100644 index 0000000..446c5b5 --- /dev/null +++ b/internal/service/gameprovider/visibility_test.go @@ -0,0 +1,132 @@ +package gameprovider + +import ( + "context" + "testing" + + "chatapp3-golang/internal/integration" +) + +type stubVisibilityGateway struct { + region integration.UserRegion + level integration.UserLevel + targetRegionCode string +} + +func (s stubVisibilityGateway) GetUserRegion(context.Context, int64, string) (integration.UserRegion, error) { + return s.region, nil +} + +func (s stubVisibilityGateway) GetUserLevel(context.Context, string, int64) (integration.UserLevel, error) { + return s.level, nil +} + +func (s stubVisibilityGateway) ResolveRegionCodeByCountryCode(context.Context, string, string) (string, error) { + return s.targetRegionCode, nil +} + +type stubIPCountryResolver struct { + countryCode string +} + +func (s stubIPCountryResolver) CountryCode(context.Context, string) (string, error) { + return s.countryCode, nil +} + +func TestVisibilityPolicyAllowsTurkeyRegionTurkeyIPAndWealthLevel2(t *testing.T) { + policy := NewVisibilityPolicy( + stubVisibilityGateway{ + region: integration.UserRegion{RegionID: "2046066409959649281", RegionCode: "TR"}, + level: integration.UserLevel{WealthLevel: 2}, + targetRegionCode: "TR", + }, + stubIPCountryResolver{countryCode: "TR"}, + ) + + result, err := policy.Evaluate(context.Background(), AuthUser{UserID: 1001, SysOrigin: "LIKEI"}, "1.1.1.1") + if err != nil { + t.Fatalf("Evaluate() error = %v", err) + } + if !result.Allow { + t.Fatalf("Evaluate() allow = false, want true") + } + if result.RegionID != "2046066409959649281" || result.RegionCode != "TR" { + t.Fatalf("Evaluate() region = %#v", result) + } +} + +func TestVisibilityPolicyDeniesNonTurkeyIP(t *testing.T) { + policy := NewVisibilityPolicy( + stubVisibilityGateway{ + region: integration.UserRegion{RegionID: "2046066409959649281", RegionCode: "TR"}, + level: integration.UserLevel{WealthLevel: 2}, + targetRegionCode: "TR", + }, + stubIPCountryResolver{countryCode: "SA"}, + ) + + result, err := policy.Evaluate(context.Background(), AuthUser{UserID: 1001, SysOrigin: "LIKEI"}, "1.1.1.1") + if err != nil { + t.Fatalf("Evaluate() error = %v", err) + } + if result.Allow { + t.Fatalf("Evaluate() allow = true, want false") + } +} + +func TestVisibilityPolicyDeniesLowWealthLevel(t *testing.T) { + policy := NewVisibilityPolicy( + stubVisibilityGateway{ + region: integration.UserRegion{RegionID: "2046066409959649281", RegionCode: "TR"}, + level: integration.UserLevel{WealthLevel: 1}, + targetRegionCode: "TR", + }, + stubIPCountryResolver{countryCode: "TR"}, + ) + + result, err := policy.Evaluate(context.Background(), AuthUser{UserID: 1001, SysOrigin: "LIKEI"}, "1.1.1.1") + if err != nil { + t.Fatalf("Evaluate() error = %v", err) + } + if result.Allow { + t.Fatalf("Evaluate() allow = true, want false") + } +} + +func TestVisibilityPolicyDeniesNonTurkeyRegion(t *testing.T) { + policy := NewVisibilityPolicy( + stubVisibilityGateway{ + region: integration.UserRegion{RegionID: "2046067036517363714", RegionCode: "AR"}, + level: integration.UserLevel{WealthLevel: 2}, + targetRegionCode: "TR", + }, + stubIPCountryResolver{countryCode: "TR"}, + ) + + result, err := policy.Evaluate(context.Background(), AuthUser{UserID: 1001, SysOrigin: "LIKEI"}, "1.1.1.1") + if err != nil { + t.Fatalf("Evaluate() error = %v", err) + } + if result.Allow { + t.Fatalf("Evaluate() allow = true, want false") + } +} + +func TestVisibilityPolicySkipsOtherSysOrigin(t *testing.T) { + policy := NewVisibilityPolicy( + stubVisibilityGateway{ + region: integration.UserRegion{RegionID: "2046067036517363714", RegionCode: "AR"}, + level: integration.UserLevel{WealthLevel: 0}, + targetRegionCode: "TR", + }, + stubIPCountryResolver{countryCode: "SA"}, + ) + + result, err := policy.Evaluate(context.Background(), AuthUser{UserID: 1001, SysOrigin: "ASWAT"}, "1.1.1.1") + if err != nil { + t.Fatalf("Evaluate() error = %v", err) + } + if !result.Allow { + t.Fatalf("Evaluate() allow = false, want true") + } +} diff --git a/internal/service/lingxian/app_provider.go b/internal/service/lingxian/app_provider.go index 89c6745..46681b2 100644 --- a/internal/service/lingxian/app_provider.go +++ b/internal/service/lingxian/app_provider.go @@ -47,7 +47,7 @@ func (p *AppProvider) ListShortcutGames(ctx context.Context, user gameprovider.A } func (p *AppProvider) ListRoomGames(ctx context.Context, user gameprovider.AuthUser, roomID, category string) (*gameprovider.RoomGameListResponse, error) { - items, err := p.service.listRoomGames(ctx, user.SysOrigin, category) + items, err := p.service.listRoomGames(ctx, user.SysOrigin, user.RegionID, category) if err != nil { return nil, err } @@ -116,7 +116,7 @@ func (p *AppProvider) CloseGame(ctx context.Context, user gameprovider.AuthUser, return &gameprovider.RoomStateResponse{RoomID: req.RoomID, State: "IDLE", Provider: vendorType}, nil } -func (s *Service) listRoomGames(ctx context.Context, sysOrigin, category string) ([]gameprovider.RoomGameListItem, error) { +func (s *Service) listRoomGames(ctx context.Context, sysOrigin, regionID, category string) ([]gameprovider.RoomGameListItem, error) { sysOrigin = normalizeSysOrigin(sysOrigin) profile, err := s.activeProfile(ctx, sysOrigin) if err != nil { @@ -125,6 +125,10 @@ func (s *Service) listRoomGames(ctx context.Context, sysOrigin, category string) var rows []gameRow query := s.baseGameQuery(ctx, sysOrigin, profile). Where("cfg.sys_origin = ? AND cfg.is_showcase = 1 AND ext.vendor_type = ?", sysOrigin, vendorType) + if strings.TrimSpace(regionID) != "" { + // regions 为空表示全区域;有值时按用户区域 ID 精确匹配,避免土耳其用户看到其他区域游戏。 + query = query.Where("(COALESCE(TRIM(cfg.regions), '') = '' OR FIND_IN_SET(?, REPLACE(COALESCE(cfg.regions, ''), ' ', '')) > 0)", strings.TrimSpace(regionID)) + } if strings.TrimSpace(category) != "" && !strings.EqualFold(strings.TrimSpace(category), roomCategory) { query = query.Where("cfg.category = ?", category) } diff --git a/migrations/051_game_list_turkey_region.sql b/migrations/051_game_list_turkey_region.sql new file mode 100644 index 0000000..794711f --- /dev/null +++ b/migrations/051_game_list_turkey_region.sql @@ -0,0 +1,17 @@ +-- Limit LIKEI app game-list rows to the Turkey region. +-- Production Turkey Region observed in sys_region_config: 2046066409959649281. +-- Re-check sys_region_config before production execution if region data has been rebuilt. +-- After direct SQL execution, clear Redis GAME_LIST* cache or wait for its 1-hour TTL. + +START TRANSACTION; + +SET @sys_origin := 'LIKEI'; +SET @turkey_region_id := '2046066409959649281'; + +UPDATE sys_game_list_config +SET regions = @turkey_region_id, + update_time = NOW(3) +WHERE sys_origin = @sys_origin + AND is_showcase = 1; + +COMMIT;