feat: 下线土耳其游戏列表财富等级门禁

土耳其区域/IP 用户不再按财富等级隐藏游戏列表;保留 Evaluate 的
区域查询给 provider SQL 做 regions 过滤,删除 IP 国家解析器。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
hy001 2026-07-11 21:10:41 +08:00
parent a1114fbbf5
commit ca72ed3f97
5 changed files with 57 additions and 337 deletions

View File

@ -119,10 +119,7 @@ func main() {
lingxian.NewAppProvider(lingxianService), lingxian.NewAppProvider(lingxianService),
hotgame.NewAppProvider(hotgameService), hotgame.NewAppProvider(hotgameService),
) )
gameVisibility := gameprovider.NewVisibilityPolicy( gameVisibility := gameprovider.NewVisibilityPolicy(&app.Gateways)
&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,

View File

@ -200,7 +200,7 @@ func gameListAccess(c *gin.Context, visibility *gameprovider.VisibilityPolicy) (
return user, true, true return user, true, true
} }
// 游戏列表只在列表入口做展示门禁,结果里的区域继续向下传给 provider SQL 做 regions 过滤。 // 游戏列表只在列表入口做展示门禁,结果里的区域继续向下传给 provider SQL 做 regions 过滤。
result, err := visibility.Evaluate(c.Request.Context(), user, resolveClientIP(c)) result, err := visibility.Evaluate(c.Request.Context(), user)
if err != nil { if err != nil {
writeError(c, err) writeError(c, err)
return user, false, false return user, false, false

View File

@ -186,7 +186,7 @@ func TestPublicLaunchMapsLingxianToLeaderForOldClients(t *testing.T) {
} }
} }
func TestGameProviderRoutesReturnEmptyWhenVisibilityPolicyDenies(t *testing.T) { func TestGameProviderRoutesReturnRoomListForTurkeyRegionUser(t *testing.T) {
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
provider := &routeStubProvider{} provider := &routeStubProvider{}
engine := gin.New() engine := gin.New()
@ -196,11 +196,8 @@ func TestGameProviderRoutesReturnEmptyWhenVisibilityPolicyDenies(t *testing.T) {
gameprovider.NewRegistry(provider), gameprovider.NewRegistry(provider),
gameprovider.NewVisibilityPolicy( gameprovider.NewVisibilityPolicy(
routeStubVisibilityGateway{ routeStubVisibilityGateway{
region: integration.UserRegion{RegionID: "2046066409959649281", RegionCode: "土耳其"}, region: integration.UserRegion{RegionID: "2046066409959649281", RegionCode: "土耳其"},
level: integration.UserLevel{WealthLevel: 2},
targetRegionCode: "土耳其",
}, },
routeStubIPCountryResolver{countryCode: "TR"},
), ),
) )
@ -220,11 +217,11 @@ func TestGameProviderRoutesReturnEmptyWhenVisibilityPolicyDenies(t *testing.T) {
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("response json error = %v: %s", err, rec.Body.String()) t.Fatalf("response json error = %v: %s", err, rec.Body.String())
} }
if len(payload.Body.Items) != 0 { if len(payload.Body.Items) != 1 {
t.Fatalf("items = %d, want 0", len(payload.Body.Items)) t.Fatalf("items = %d, want 1", len(payload.Body.Items))
} }
if provider.listRoomCalls != 0 { if provider.listRoomCalls != 1 {
t.Fatalf("provider list calls = %d, want 0", provider.listRoomCalls) t.Fatalf("provider list calls = %d, want 1", provider.listRoomCalls)
} }
} }
@ -339,11 +336,8 @@ func TestGameProviderRoutesReturnProvidersWhenVisibilityPolicyAllows(t *testing.
gameprovider.NewRegistry(&routeStubProvider{}), gameprovider.NewRegistry(&routeStubProvider{}),
gameprovider.NewVisibilityPolicy( gameprovider.NewVisibilityPolicy(
routeStubVisibilityGateway{ routeStubVisibilityGateway{
region: integration.UserRegion{RegionID: "2046066409959649281", RegionCode: "土耳其"}, region: integration.UserRegion{RegionID: "2046066409959649281", RegionCode: "土耳其"},
level: integration.UserLevel{WealthLevel: 3},
targetRegionCode: "土耳其",
}, },
routeStubIPCountryResolver{countryCode: "ZA"},
), ),
) )
@ -422,31 +416,13 @@ func (routeStubAuthGateway) AuthenticateConsoleToken(context.Context, string) (i
} }
type routeStubVisibilityGateway struct { type routeStubVisibilityGateway struct {
region integration.UserRegion region integration.UserRegion
level integration.UserLevel
targetRegionCode string
} }
func (s routeStubVisibilityGateway) GetUserRegion(context.Context, int64, string) (integration.UserRegion, error) { func (s routeStubVisibilityGateway) GetUserRegion(context.Context, int64, string) (integration.UserRegion, error) {
return s.region, nil 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 { type routeStubProvider struct {
listRoomCalls int listRoomCalls int
shortcutCalls int shortcutCalls int

View File

@ -2,38 +2,16 @@ package gameprovider
import ( import (
"context" "context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings" "strings"
"time"
"chatapp3-golang/internal/integration" "chatapp3-golang/internal/integration"
"github.com/redis/go-redis/v9"
) )
const ( const gameListTargetSysOrigin = "LIKEI"
gameListTargetSysOrigin = "LIKEI"
gameListTurkeyCountryCode = "TR"
gameListTurkeyBlockLevel = 2
ipCountryCachePrefix = "GAME_LIST:IP_COUNTRY:"
ipCountryCacheTTL = 7 * 24 * time.Hour
)
// VisibilityGateway 聚合游戏列表门禁需要的 Java 用户区域和等级能力。 // VisibilityGateway 聚合游戏列表门禁需要的 Java 用户区域能力。
type VisibilityGateway interface { type VisibilityGateway interface {
GetUserRegion(ctx context.Context, userID int64, authorization string) (integration.UserRegion, error) 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 过滤。 // VisibilityResult 返回门禁结果,并把已经查到的区域带回路由继续参与 SQL 过滤。
@ -45,26 +23,21 @@ type VisibilityResult struct {
// VisibilityPolicy 判断当前用户是否可以看到游戏列表。 // VisibilityPolicy 判断当前用户是否可以看到游戏列表。
type VisibilityPolicy struct { type VisibilityPolicy struct {
java VisibilityGateway java VisibilityGateway
ipResolver IPCountryResolver targetSys string
targetCountry string
targetSys string
blockLevel int
} }
// NewVisibilityPolicy 创建 LIKEI 游戏列表门禁:土耳其区域或土耳其 IP 的用户财富等级必须大于 2其他用户直接展示。 // NewVisibilityPolicy 创建 LIKEI 游戏列表门禁:土耳其财富等级限制已下线,现在对所有区域直接展示,
func NewVisibilityPolicy(java VisibilityGateway, ipResolver IPCountryResolver) *VisibilityPolicy { // 保留 Evaluate 是因为路由仍依赖它查询用户区域给 provider SQL 做 regions 过滤。
func NewVisibilityPolicy(java VisibilityGateway) *VisibilityPolicy {
return &VisibilityPolicy{ return &VisibilityPolicy{
java: java, java: java,
ipResolver: ipResolver, targetSys: gameListTargetSysOrigin,
targetCountry: gameListTurkeyCountryCode,
targetSys: gameListTargetSysOrigin,
blockLevel: gameListTurkeyBlockLevel,
} }
} }
// Evaluate 只拦截土耳其区域或土耳其 IP 的低财富等级用户;非土耳其区域且非土耳其 IP 的用户直接展示游戏列表 // Evaluate 给 LIKEI 用户补齐区域信息并放行;非 LIKEI 用户沿用自带区域直接展示
func (p *VisibilityPolicy) Evaluate(ctx context.Context, user AuthUser, clientIP string) (VisibilityResult, error) { func (p *VisibilityPolicy) Evaluate(ctx context.Context, user AuthUser) (VisibilityResult, error) {
if p == nil || !strings.EqualFold(strings.TrimSpace(user.SysOrigin), p.targetSys) { if p == nil || !strings.EqualFold(strings.TrimSpace(user.SysOrigin), p.targetSys) {
return VisibilityResult{Allow: true, RegionID: user.RegionID, RegionCode: user.RegionCode}, nil return VisibilityResult{Allow: true, RegionID: user.RegionID, RegionCode: user.RegionCode}, nil
} }
@ -76,176 +49,9 @@ func (p *VisibilityPolicy) Evaluate(ctx context.Context, user AuthUser, clientIP
if err != nil { if err != nil {
return VisibilityResult{}, err return VisibilityResult{}, err
} }
result := VisibilityResult{ return VisibilityResult{
Allow: true,
RegionID: strings.TrimSpace(region.RegionID), RegionID: strings.TrimSpace(region.RegionID),
RegionCode: strings.TrimSpace(region.RegionCode), RegionCode: strings.TrimSpace(region.RegionCode),
} }, nil
// 区域配置是运营可变数据,所以先从 Java 区域配置解析 TR 对应的 regionCode再判断用户是否属于土耳其区域。
targetRegionCode, err := p.java.ResolveRegionCodeByCountryCode(ctx, user.SysOrigin, p.targetCountry)
if err != nil {
return result, err
}
turkeyRegion := matchesTargetRegion(result.RegionCode, targetRegionCode, p.targetCountry)
turkeyIP := false
if p.ipResolver != nil {
countryCode, err := p.ipResolver.CountryCode(ctx, clientIP)
if err != nil {
return result, err
}
turkeyIP = strings.EqualFold(strings.TrimSpace(countryCode), p.targetCountry)
}
// 用户区域和当前 IP 都不是土耳其时,直接放行;只有命中土耳其门禁时才需要读取财富等级。
if !turkeyRegion && !turkeyIP {
result.Allow = true
return result, nil
}
level, err := p.java.GetUserLevel(ctx, user.SysOrigin, user.UserID)
if err != nil {
return result, err
}
result.Allow = level.WealthLevel > p.blockLevel
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

@ -8,132 +8,73 @@ import (
) )
type stubVisibilityGateway struct { type stubVisibilityGateway struct {
region integration.UserRegion region integration.UserRegion
level integration.UserLevel
targetRegionCode string
} }
func (s stubVisibilityGateway) GetUserRegion(context.Context, int64, string) (integration.UserRegion, error) { func (s stubVisibilityGateway) GetUserRegion(context.Context, int64, string) (integration.UserRegion, error) {
return s.region, nil 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
}
const account1007500UserID int64 = 2054163533368717314 const account1007500UserID int64 = 2054163533368717314
func TestVisibilityPolicyAppliesTurkeyRegionOrIPWealthGate(t *testing.T) { func TestVisibilityPolicyAllowsAllRegions(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
region integration.UserRegion region integration.UserRegion
ipCountry string
wealth int
wantAllow bool
wantRegion integration.UserRegion
}{ }{
{ {
name: "turkey region level 2 is blocked", name: "turkey region is visible without wealth gate",
region: integration.UserRegion{RegionID: "2046066409959649281", RegionCode: "土耳其"}, region: integration.UserRegion{RegionID: "2046066409959649281", RegionCode: "土耳其"},
ipCountry: "ZA",
wealth: 2,
wantAllow: false,
wantRegion: integration.UserRegion{RegionID: "2046066409959649281", RegionCode: "土耳其"},
}, },
{ {
name: "turkey region level 3 is visible even when ip is not turkey", name: "south asia region is visible",
region: integration.UserRegion{RegionID: "2046066409959649281", RegionCode: "土耳其"}, region: integration.UserRegion{RegionID: "2045389832842178561", RegionCode: "南亚"},
ipCountry: "ZA",
wealth: 3,
wantAllow: true,
wantRegion: integration.UserRegion{RegionID: "2046066409959649281", RegionCode: "土耳其"},
},
{
name: "turkey ip level 2 is blocked",
region: integration.UserRegion{RegionID: "2045389832842178561", RegionCode: "南亚"},
ipCountry: "TR",
wealth: 2,
wantAllow: false,
wantRegion: integration.UserRegion{RegionID: "2045389832842178561", RegionCode: "南亚"},
},
{
name: "turkey ip level 3 is visible",
region: integration.UserRegion{RegionID: "2045389832842178561", RegionCode: "南亚"},
ipCountry: "TR",
wealth: 3,
wantAllow: true,
wantRegion: integration.UserRegion{RegionID: "2045389832842178561", RegionCode: "南亚"},
},
{
name: "non turkey region and non turkey ip are visible even at level 1",
region: integration.UserRegion{RegionID: "2045389832842178561", RegionCode: "南亚"},
ipCountry: "ZA",
wealth: 1,
wantAllow: true,
wantRegion: integration.UserRegion{RegionID: "2045389832842178561", RegionCode: "南亚"},
},
{
name: "account 1007500 current region and ip are visible",
region: integration.UserRegion{RegionID: "2045389832842178561", RegionCode: "南亚"},
ipCountry: "ZA",
wealth: 35,
wantAllow: true,
wantRegion: integration.UserRegion{RegionID: "2045389832842178561", RegionCode: "南亚"},
}, },
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
policy := NewVisibilityPolicy( policy := NewVisibilityPolicy(stubVisibilityGateway{region: tt.region})
stubVisibilityGateway{
region: tt.region,
level: integration.UserLevel{WealthLevel: tt.wealth},
targetRegionCode: "土耳其",
},
stubIPCountryResolver{countryCode: tt.ipCountry},
)
result, err := policy.Evaluate(context.Background(), AuthUser{UserID: account1007500UserID, SysOrigin: "LIKEI"}, "1.1.1.1") result, err := policy.Evaluate(context.Background(), AuthUser{UserID: account1007500UserID, SysOrigin: "LIKEI"})
if err != nil { if err != nil {
t.Fatalf("Evaluate() error = %v", err) t.Fatalf("Evaluate() error = %v", err)
} }
if result.Allow != tt.wantAllow { if !result.Allow {
t.Fatalf("Evaluate() allow = %v, want %v", result.Allow, tt.wantAllow) t.Fatalf("Evaluate() allow = false, want true")
} }
if result.RegionID != tt.wantRegion.RegionID || result.RegionCode != tt.wantRegion.RegionCode { if result.RegionID != tt.region.RegionID || result.RegionCode != tt.region.RegionCode {
t.Fatalf("Evaluate() region = %#v, want %#v", result, tt.wantRegion) t.Fatalf("Evaluate() region = %#v, want %#v", result, tt.region)
} }
}) })
} }
} }
func TestVisibilityPolicySkipsOtherSysOrigin(t *testing.T) { func TestVisibilityPolicySkipsOtherSysOrigin(t *testing.T) {
policy := NewVisibilityPolicy( policy := NewVisibilityPolicy(stubVisibilityGateway{
stubVisibilityGateway{ region: integration.UserRegion{RegionID: "2046067036517363714", RegionCode: "AR"},
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") result, err := policy.Evaluate(context.Background(), AuthUser{UserID: 1001, SysOrigin: "ASWAT", RegionID: "r-1", RegionCode: "SA"})
if err != nil { if err != nil {
t.Fatalf("Evaluate() error = %v", err) t.Fatalf("Evaluate() error = %v", err)
} }
if !result.Allow { if !result.Allow {
t.Fatalf("Evaluate() allow = false, want true") t.Fatalf("Evaluate() allow = false, want true")
} }
if result.RegionID != "r-1" || result.RegionCode != "SA" {
t.Fatalf("Evaluate() region = %#v, want user-provided region", result)
}
}
func TestVisibilityPolicyDeniesInvalidUser(t *testing.T) {
policy := NewVisibilityPolicy(stubVisibilityGateway{})
result, err := policy.Evaluate(context.Background(), AuthUser{UserID: 0, SysOrigin: "LIKEI"})
if err != nil {
t.Fatalf("Evaluate() error = %v", err)
}
if result.Allow {
t.Fatalf("Evaluate() allow = true, want false for invalid user")
}
} }