251 lines
7.4 KiB
Go
251 lines
7.4 KiB
Go
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))
|
||
}
|