隐藏土耳其地区的游戏

This commit is contained in:
hy001 2026-06-09 15:42:52 +08:00
parent cbe388b071
commit 4e3c9d01aa
13 changed files with 673 additions and 14 deletions

View File

@ -116,6 +116,10 @@ func main() {
baishun.NewAppProvider(baishunService), baishun.NewAppProvider(baishunService),
lingxian.NewAppProvider(lingxianService), 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{ engine := router.NewRouter(app.Config, app.Repository, &app.Gateways, router.Services{
AppPopup: appPopupService, AppPopup: appPopupService,
ErrorLog: errorLogService, ErrorLog: errorLogService,
@ -126,6 +130,7 @@ func main() {
Lingxian: lingxianService, Lingxian: lingxianService,
GameOpen: gameOpenService, GameOpen: gameOpenService,
GameProviders: gameProviders, GameProviders: gameProviders,
GameVisibility: gameVisibility,
LuckyGift: luckyGiftService, LuckyGift: luckyGiftService,
ManagerCenter: managerCenterService, ManagerCenter: managerCenterService,
PropsStore: propsStoreService, PropsStore: propsStoreService,

View File

@ -6,4 +6,6 @@ type AuthUser struct {
SysOrigin string SysOrigin string
Token string Token string
Authorization string Authorization string
RegionID string
RegionCode string
} }

View File

@ -146,6 +146,11 @@ func (g *Gateways) GetUserRegion(ctx context.Context, userID int64, authorizatio
return g.Profile.GetUserRegion(ctx, userID, authorization) 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 区域配置网关。 // ResolveRegionCodeByCountryCode 透传到 Java 区域配置网关。
func (g *Gateways) ResolveRegionCodeByCountryCode(ctx context.Context, sysOrigin string, countryCode string) (string, error) { func (g *Gateways) ResolveRegionCodeByCountryCode(ctx context.Context, sysOrigin string, countryCode string) (string, error) {
return g.Profile.ResolveRegionCodeByCountryCode(ctx, sysOrigin, countryCode) 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) 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 区域配置把国家码解析成区域编码。 // ResolveRegionCodeByCountryCode 根据 Java 区域配置把国家码解析成区域编码。
func (g *ProfileGateway) ResolveRegionCodeByCountryCode(ctx context.Context, sysOrigin string, countryCode string) (string, error) { func (g *ProfileGateway) ResolveRegionCodeByCountryCode(ctx context.Context, sysOrigin string, countryCode string) (string, error) {
return g.client.ResolveRegionCodeByCountryCode(ctx, sysOrigin, countryCode) return g.client.ResolveRegionCodeByCountryCode(ctx, sysOrigin, countryCode)

View File

@ -59,6 +59,12 @@ type UserRegion struct {
RegionCode string `json:"regionCode"` RegionCode string `json:"regionCode"`
} }
type UserLevel struct {
UserID Int64Value `json:"userId"`
WealthLevel int `json:"wealthLevel"`
CharmLevel int `json:"charmLevel"`
}
type RegionConfig struct { type RegionConfig struct {
RegionCode string `json:"regionCode"` RegionCode string `json:"regionCode"`
SysOrigin string `json:"sysOrigin"` SysOrigin string `json:"sysOrigin"`
@ -1044,6 +1050,21 @@ func (c *Client) GetUserRegion(ctx context.Context, userID int64, authorization
return resp.Body, nil 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) { func (c *Client) ResolveRegionCodeByCountryCode(ctx context.Context, sysOrigin string, countryCode string) (string, error) {
countryCode = strings.ToUpper(strings.TrimSpace(countryCode)) countryCode = strings.ToUpper(strings.TrimSpace(countryCode))
if countryCode == "" { if countryCode == "" {

View File

@ -11,7 +11,7 @@ import (
) )
// registerGameProviderRoutes 注册统一游戏厂商 app 路由。 // 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 { if registry == nil {
return return
} }
@ -19,7 +19,15 @@ func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, regi
appGroup := engine.Group("/app/game") appGroup := engine.Group("/app/game")
appGroup.Use(authMiddleware(javaClient)) appGroup.Use(authMiddleware(javaClient))
appGroup.GET("/room/shortcut", func(c *gin.Context) { 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 { if err != nil {
writeError(c, err) writeError(c, err)
return return
@ -27,7 +35,15 @@ func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, regi
writeOK(c, publicRoomGameItems(resp)) writeOK(c, publicRoomGameItems(resp))
}) })
appGroup.GET("/room/list", func(c *gin.Context) { 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 { if err != nil {
writeError(c, err) writeError(c, err)
return return
@ -72,14 +88,22 @@ func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, regi
providerGroup := engine.Group("/app/game/providers") providerGroup := engine.Group("/app/game/providers")
providerGroup.Use(authMiddleware(javaClient)) providerGroup.Use(authMiddleware(javaClient))
providerGroup.GET("", func(c *gin.Context) { 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{}}) writeOK(c, gameprovider.ProviderListResponse{Items: []gameprovider.ProviderSummary{}})
return return
} }
writeOK(c, registry.List()) writeOK(c, registry.List())
}) })
providerGroup.GET("/:provider/room/shortcut", func(c *gin.Context) { 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{}}) writeOK(c, gin.H{"items": []publicRoomGameItem{}})
return return
} }
@ -87,7 +111,7 @@ func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, regi
if !ok { if !ok {
return 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 { if err != nil {
writeError(c, err) writeError(c, err)
return return
@ -95,7 +119,11 @@ func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, regi
writeOK(c, gin.H{"items": publicRoomGameItems(resp)}) writeOK(c, gin.H{"items": publicRoomGameItems(resp)})
}) })
providerGroup.GET("/:provider/room/list", func(c *gin.Context) { 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{}})) writeOK(c, publicRoomGameListResponse(&gameprovider.RoomGameListResponse{Items: []gameprovider.RoomGameListItem{}}))
return return
} }
@ -103,7 +131,7 @@ func registerGameProviderRoutes(engine *gin.Engine, javaClient authGateway, regi
if !ok { if !ok {
return 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 { if err != nil {
writeError(c, err) writeError(c, err)
return 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) { func resolveGameProvider(c *gin.Context, registry *gameprovider.Registry) (gameprovider.Provider, bool) {
provider, err := registry.Resolve(c.Param("provider")) provider, err := registry.Resolve(c.Param("provider"))
if err != nil { if err != nil {

View File

@ -1,9 +1,17 @@
package router package router
import ( import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing" "testing"
"chatapp3-golang/internal/common"
"chatapp3-golang/internal/integration"
"chatapp3-golang/internal/service/gameprovider" "chatapp3-golang/internal/service/gameprovider"
"github.com/gin-gonic/gin"
) )
func TestPublicRoomGameListResponseKeepsLegacyProviderFields(t *testing.T) { func TestPublicRoomGameListResponseKeepsLegacyProviderFields(t *testing.T) {
@ -141,3 +149,158 @@ func TestPublicLaunchMapsLingxianToLeaderForOldClients(t *testing.T) {
t.Fatalf("roomState.provider = %q, want LEADER", resp.RoomState.Provider) 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
}

View File

@ -112,6 +112,9 @@ func trimBearer(authorization string) string {
// resolveClientIP 解析请求来源 IP。 // resolveClientIP 解析请求来源 IP。
func resolveClientIP(c *gin.Context) string { 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 != "" { if forwarded := strings.TrimSpace(c.GetHeader("X-Forwarded-For")); forwarded != "" {
parts := strings.Split(forwarded, ",") parts := strings.Split(forwarded, ",")
if len(parts) > 0 { if len(parts) > 0 {

View File

@ -48,6 +48,7 @@ type Services struct {
Lingxian *lingxian.Service Lingxian *lingxian.Service
GameOpen *gameopen.GameOpenService GameOpen *gameopen.GameOpenService
GameProviders *gameprovider.Registry GameProviders *gameprovider.Registry
GameVisibility *gameprovider.VisibilityPolicy
LuckyGift *luckygift.LuckyGiftService LuckyGift *luckygift.LuckyGiftService
ManagerCenter *managercenter.Service ManagerCenter *managercenter.Service
PropsStore *propsstore.Service PropsStore *propsstore.Service
@ -100,7 +101,7 @@ func NewRouter(
registerRoomTurnoverRewardRoutes(engine, javaClient, services.RoomTurnoverReward) registerRoomTurnoverRewardRoutes(engine, javaClient, services.RoomTurnoverReward)
registerHostCenterRoutes(engine, services.HostCenter) registerHostCenterRoutes(engine, services.HostCenter)
registerGameOpenRoutes(engine, cfg, services.GameOpen) registerGameOpenRoutes(engine, cfg, services.GameOpen)
registerGameProviderRoutes(engine, javaClient, services.GameProviders) registerGameProviderRoutes(engine, javaClient, services.GameProviders, services.GameVisibility)
registerBaishunRoutes(engine, cfg, javaClient, services.Baishun) registerBaishunRoutes(engine, cfg, javaClient, services.Baishun)
registerLingxianRoutes(engine, javaClient, services.Lingxian) registerLingxianRoutes(engine, javaClient, services.Lingxian)
registerLuckyGiftRoutes(engine, cfg, services.LuckyGift) registerLuckyGiftRoutes(engine, cfg, services.LuckyGift)

View File

@ -13,7 +13,7 @@ import (
// ListShortcutGames 返回房间里的快捷游戏列表,数量上限为 5 个。 // ListShortcutGames 返回房间里的快捷游戏列表,数量上限为 5 个。
func (s *BaishunService) ListShortcutGames(ctx context.Context, user AuthUser, roomID string) ([]RoomGameListItem, error) { 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 { if err != nil {
return nil, err return nil, err
} }
@ -25,7 +25,7 @@ func (s *BaishunService) ListShortcutGames(ctx context.Context, user AuthUser, r
// ListRoomGames 返回房间可启动的完整游戏列表。 // ListRoomGames 返回房间可启动的完整游戏列表。
func (s *BaishunService) ListRoomGames(ctx context.Context, user AuthUser, roomID string, category string) (*RoomGameListResponse, error) { 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 { if err != nil {
return nil, err return nil, err
} }
@ -59,7 +59,7 @@ func (s *BaishunService) GetRoomState(ctx context.Context, user AuthUser, roomID
} }
// listRoomGames 联表读取房间可启动游戏,并按展示规则排序。 // 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) sysOrigin = normalizeAdminSysOrigin(sysOrigin)
profile, err := s.activeProfile(ctx, sysOrigin) profile, err := s.activeProfile(ctx, sysOrigin)
if err != nil { 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("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"). 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) 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") { if strings.TrimSpace(category) != "" && !strings.EqualFold(strings.TrimSpace(category), "CHAT_ROOM") {
query = query.Where("cfg.category = ?", category) query = query.Where("cfg.category = ?", category)
} }

View File

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

View File

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

View File

@ -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) { 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 { if err != nil {
return nil, err 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 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) sysOrigin = normalizeSysOrigin(sysOrigin)
profile, err := s.activeProfile(ctx, sysOrigin) profile, err := s.activeProfile(ctx, sysOrigin)
if err != nil { if err != nil {
@ -125,6 +125,10 @@ func (s *Service) listRoomGames(ctx context.Context, sysOrigin, category string)
var rows []gameRow var rows []gameRow
query := s.baseGameQuery(ctx, sysOrigin, profile). query := s.baseGameQuery(ctx, sysOrigin, profile).
Where("cfg.sys_origin = ? AND cfg.is_showcase = 1 AND ext.vendor_type = ?", sysOrigin, vendorType) 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) { if strings.TrimSpace(category) != "" && !strings.EqualFold(strings.TrimSpace(category), roomCategory) {
query = query.Where("cfg.category = ?", category) query = query.Where("cfg.category = ?", category)
} }

View File

@ -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;