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