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),
hotgame.NewAppProvider(hotgameService),
)
gameVisibility := gameprovider.NewVisibilityPolicy(
&app.Gateways,
gameprovider.NewCachedIPCountryResolver(app.Repository.Redis, app.Config.HTTP.Timeout),
)
gameVisibility := gameprovider.NewVisibilityPolicy(&app.Gateways)
engine := router.NewRouter(app.Config, app.Repository, &app.Gateways, router.Services{
AppPopup: appPopupService,
ErrorLog: errorLogService,

View File

@ -200,7 +200,7 @@ func gameListAccess(c *gin.Context, visibility *gameprovider.VisibilityPolicy) (
return user, true, true
}
// 游戏列表只在列表入口做展示门禁,结果里的区域继续向下传给 provider SQL 做 regions 过滤。
result, err := visibility.Evaluate(c.Request.Context(), user, resolveClientIP(c))
result, err := visibility.Evaluate(c.Request.Context(), user)
if err != nil {
writeError(c, err)
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)
provider := &routeStubProvider{}
engine := gin.New()
@ -196,11 +196,8 @@ func TestGameProviderRoutesReturnEmptyWhenVisibilityPolicyDenies(t *testing.T) {
gameprovider.NewRegistry(provider),
gameprovider.NewVisibilityPolicy(
routeStubVisibilityGateway{
region: integration.UserRegion{RegionID: "2046066409959649281", RegionCode: "土耳其"},
level: integration.UserLevel{WealthLevel: 2},
targetRegionCode: "土耳其",
region: integration.UserRegion{RegionID: "2046066409959649281", RegionCode: "土耳其"},
},
routeStubIPCountryResolver{countryCode: "TR"},
),
)
@ -220,11 +217,11 @@ func TestGameProviderRoutesReturnEmptyWhenVisibilityPolicyDenies(t *testing.T) {
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 len(payload.Body.Items) != 1 {
t.Fatalf("items = %d, want 1", len(payload.Body.Items))
}
if provider.listRoomCalls != 0 {
t.Fatalf("provider list calls = %d, want 0", provider.listRoomCalls)
if provider.listRoomCalls != 1 {
t.Fatalf("provider list calls = %d, want 1", provider.listRoomCalls)
}
}
@ -339,11 +336,8 @@ func TestGameProviderRoutesReturnProvidersWhenVisibilityPolicyAllows(t *testing.
gameprovider.NewRegistry(&routeStubProvider{}),
gameprovider.NewVisibilityPolicy(
routeStubVisibilityGateway{
region: integration.UserRegion{RegionID: "2046066409959649281", RegionCode: "土耳其"},
level: integration.UserLevel{WealthLevel: 3},
targetRegionCode: "土耳其",
region: integration.UserRegion{RegionID: "2046066409959649281", RegionCode: "土耳其"},
},
routeStubIPCountryResolver{countryCode: "ZA"},
),
)
@ -422,31 +416,13 @@ func (routeStubAuthGateway) AuthenticateConsoleToken(context.Context, string) (i
}
type routeStubVisibilityGateway struct {
region integration.UserRegion
level integration.UserLevel
targetRegionCode string
region integration.UserRegion
}
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
shortcutCalls int

View File

@ -2,38 +2,16 @@ 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"
gameListTurkeyCountryCode = "TR"
gameListTurkeyBlockLevel = 2
ipCountryCachePrefix = "GAME_LIST:IP_COUNTRY:"
ipCountryCacheTTL = 7 * 24 * time.Hour
)
const gameListTargetSysOrigin = "LIKEI"
// VisibilityGateway 聚合游戏列表门禁需要的 Java 用户区域和等级能力。
// 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 过滤。
@ -45,26 +23,21 @@ type VisibilityResult struct {
// VisibilityPolicy 判断当前用户是否可以看到游戏列表。
type VisibilityPolicy struct {
java VisibilityGateway
ipResolver IPCountryResolver
targetCountry string
targetSys string
blockLevel int
java VisibilityGateway
targetSys string
}
// NewVisibilityPolicy 创建 LIKEI 游戏列表门禁:土耳其区域或土耳其 IP 的用户财富等级必须大于 2其他用户直接展示。
func NewVisibilityPolicy(java VisibilityGateway, ipResolver IPCountryResolver) *VisibilityPolicy {
// NewVisibilityPolicy 创建 LIKEI 游戏列表门禁:土耳其财富等级限制已下线,现在对所有区域直接展示,
// 保留 Evaluate 是因为路由仍依赖它查询用户区域给 provider SQL 做 regions 过滤。
func NewVisibilityPolicy(java VisibilityGateway) *VisibilityPolicy {
return &VisibilityPolicy{
java: java,
ipResolver: ipResolver,
targetCountry: gameListTurkeyCountryCode,
targetSys: gameListTargetSysOrigin,
blockLevel: gameListTurkeyBlockLevel,
java: java,
targetSys: gameListTargetSysOrigin,
}
}
// Evaluate 只拦截土耳其区域或土耳其 IP 的低财富等级用户;非土耳其区域且非土耳其 IP 的用户直接展示游戏列表
func (p *VisibilityPolicy) Evaluate(ctx context.Context, user AuthUser, clientIP string) (VisibilityResult, error) {
// Evaluate 给 LIKEI 用户补齐区域信息并放行;非 LIKEI 用户沿用自带区域直接展示
func (p *VisibilityPolicy) Evaluate(ctx context.Context, user AuthUser) (VisibilityResult, error) {
if p == nil || !strings.EqualFold(strings.TrimSpace(user.SysOrigin), p.targetSys) {
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 {
return VisibilityResult{}, err
}
result := VisibilityResult{
return VisibilityResult{
Allow: true,
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
}
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))
}, nil
}

View File

@ -8,132 +8,73 @@ import (
)
type stubVisibilityGateway struct {
region integration.UserRegion
level integration.UserLevel
targetRegionCode string
region integration.UserRegion
}
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
}
const account1007500UserID int64 = 2054163533368717314
func TestVisibilityPolicyAppliesTurkeyRegionOrIPWealthGate(t *testing.T) {
func TestVisibilityPolicyAllowsAllRegions(t *testing.T) {
tests := []struct {
name string
region integration.UserRegion
ipCountry string
wealth int
wantAllow bool
wantRegion integration.UserRegion
name string
region integration.UserRegion
}{
{
name: "turkey region level 2 is blocked",
region: integration.UserRegion{RegionID: "2046066409959649281", RegionCode: "土耳其"},
ipCountry: "ZA",
wealth: 2,
wantAllow: false,
wantRegion: integration.UserRegion{RegionID: "2046066409959649281", RegionCode: "土耳其"},
name: "turkey region is visible without wealth gate",
region: integration.UserRegion{RegionID: "2046066409959649281", RegionCode: "土耳其"},
},
{
name: "turkey region level 3 is visible even when ip is not turkey",
region: integration.UserRegion{RegionID: "2046066409959649281", 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: "南亚"},
name: "south asia region is visible",
region: integration.UserRegion{RegionID: "2045389832842178561", RegionCode: "南亚"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
policy := NewVisibilityPolicy(
stubVisibilityGateway{
region: tt.region,
level: integration.UserLevel{WealthLevel: tt.wealth},
targetRegionCode: "土耳其",
},
stubIPCountryResolver{countryCode: tt.ipCountry},
)
policy := NewVisibilityPolicy(stubVisibilityGateway{region: tt.region})
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 {
t.Fatalf("Evaluate() error = %v", err)
}
if result.Allow != tt.wantAllow {
t.Fatalf("Evaluate() allow = %v, want %v", result.Allow, tt.wantAllow)
if !result.Allow {
t.Fatalf("Evaluate() allow = false, want true")
}
if result.RegionID != tt.wantRegion.RegionID || result.RegionCode != tt.wantRegion.RegionCode {
t.Fatalf("Evaluate() region = %#v, want %#v", result, tt.wantRegion)
if result.RegionID != tt.region.RegionID || result.RegionCode != tt.region.RegionCode {
t.Fatalf("Evaluate() region = %#v, want %#v", result, tt.region)
}
})
}
}
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"},
)
policy := NewVisibilityPolicy(stubVisibilityGateway{
region: integration.UserRegion{RegionID: "2046067036517363714", RegionCode: "AR"},
})
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 {
t.Fatalf("Evaluate() error = %v", err)
}
if !result.Allow {
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")
}
}