package auth import ( "context" "net/netip" "sort" "strings" "time" "hyapp/pkg/appcode" authdomain "hyapp/services/user-service/internal/domain/auth" ) const ( loginRiskIPWhitelistCacheTTL = 30 * time.Second loginRiskUserWhitelistCacheTTL = 5 * time.Minute ) // LoginRiskWhitelistCacheSnapshot 是后台保存配置后主动刷新缓存的结果。 // IP 白名单仍沿用原短 TTL;用户白名单按运营要求缓存 5 分钟,避免每个风控任务回源 MySQL。 type LoginRiskWhitelistCacheSnapshot struct { Refreshed bool IPWhitelistCount int UserWhitelistCount int UserCacheExpiresAtMs int64 } // ListLoginRiskBlockedCountries 返回当前 App 实际生效的登录地区风控词表。 // 静态配置和后台动态配置都下发给 App;App 可异步做本地风险提示,最终封禁仍以服务端 worker 为准。 func (s *Service) ListLoginRiskBlockedCountries(ctx context.Context) ([]authdomain.LoginRiskCountryBlock, error) { if !s.loginRiskPolicy.Enabled { return nil, nil } appCode := appcode.FromContext(ctx) blocks := make([]authdomain.LoginRiskCountryBlock, 0, len(s.loginRiskPolicy.UnsupportedCountries)) seen := map[string]struct{}{} for _, country := range s.loginRiskPolicy.UnsupportedCountries { countryCode := normalizeCountryCode(country) if countryCode == "" { continue } block := authdomain.LoginRiskCountryBlock{ AppCode: appCode, Keyword: countryCode, CountryCode: countryCode, Enabled: true, } blocks = appendUniqueCountryBlock(blocks, seen, block) } if s.authRepository != nil { dynamicBlocks, err := s.authRepository.ListEnabledLoginRiskCountryBlocks(ctx) if err != nil { return nil, err } for _, block := range dynamicBlocks { block.AppCode = appcode.Normalize(block.AppCode) block.Keyword = strings.TrimSpace(block.Keyword) block.CountryCode = normalizeCountryCode(block.CountryCode) block.Enabled = true if block.Keyword == "" { continue } blocks = appendUniqueCountryBlock(blocks, seen, block) } } sort.SliceStable(blocks, func(i, j int) bool { if blocks[i].CountryCode != blocks[j].CountryCode { return blocks[i].CountryCode < blocks[j].CountryCode } return strings.ToLower(blocks[i].Keyword) < strings.ToLower(blocks[j].Keyword) }) return blocks, nil } // CheckLoginRiskIPWhitelisted 判断入口 IP 是否命中后台白名单。 // 白名单是“跳过登录地区屏蔽”的明确运营例外,只做标准化后的单 IP 精确匹配,不扩展为网段或地区推断。 func (s *Service) CheckLoginRiskIPWhitelisted(ctx context.Context, clientIP string) (bool, error) { ip := normalizeLoginRiskIP(clientIP) if ip == "" { return false, nil } ips, err := s.listLoginRiskIPWhitelist(ctx) if err != nil { return false, err } for _, item := range ips { if item == ip { return true, nil } } return false, nil } // CheckLoginRiskUserWhitelisted 判断登录用户是否命中后台白名单。 // 该白名单只在 user-service 登录后风控 worker 中使用;gateway 登录前没有可信 user_id,不能用它跳过入口快拦。 func (s *Service) CheckLoginRiskUserWhitelisted(ctx context.Context, userID int64) (bool, error) { if userID <= 0 { return false, nil } userIDs, err := s.listLoginRiskUserWhitelist(ctx) if err != nil { return false, err } for _, item := range userIDs { if item == userID { return true, nil } } return false, nil } // RefreshLoginRiskWhitelistCache 从 MySQL 读取当前 App 白名单并立即写入 Redis。 // 后台保存直接调用该方法,避免新增白名单后仍等 5 分钟 TTL 自然过期才生效。 func (s *Service) RefreshLoginRiskWhitelistCache(ctx context.Context) (LoginRiskWhitelistCacheSnapshot, error) { if s.authRepository == nil { return LoginRiskWhitelistCacheSnapshot{}, nil } ips, err := s.loadLoginRiskIPWhitelist(ctx) if err != nil { return LoginRiskWhitelistCacheSnapshot{}, err } userIDs, err := s.loadLoginRiskUserWhitelist(ctx) if err != nil { return LoginRiskWhitelistCacheSnapshot{}, err } snapshot := LoginRiskWhitelistCacheSnapshot{ IPWhitelistCount: len(ips), UserWhitelistCount: len(userIDs), UserCacheExpiresAtMs: s.now().Add(loginRiskUserWhitelistCacheTTL).UnixMilli(), } if s.ipDecisionCache == nil { return snapshot, nil } // IP 和用户白名单共用一次刷新入口;任一缓存写失败都返回错误,让后台保存页能提示重试刷新。 if err := s.ipDecisionCache.SetIPWhitelist(ctx, appcode.FromContext(ctx), ips, loginRiskIPWhitelistCacheTTL); err != nil { return LoginRiskWhitelistCacheSnapshot{}, err } if err := s.ipDecisionCache.SetUserWhitelist(ctx, appcode.FromContext(ctx), userIDs, loginRiskUserWhitelistCacheTTL); err != nil { return LoginRiskWhitelistCacheSnapshot{}, err } snapshot.Refreshed = true return snapshot, nil } func (s *Service) listLoginRiskIPWhitelist(ctx context.Context) ([]string, error) { appCode := appcode.FromContext(ctx) if s.ipDecisionCache != nil { ips, ok, err := s.ipDecisionCache.GetIPWhitelist(ctx, appCode) if err == nil && ok { return normalizeLoginRiskIPList(ips), nil } } ips, err := s.loadLoginRiskIPWhitelist(ctx) if err != nil { return nil, err } if s.ipDecisionCache != nil { // 白名单读取结果按 App 整体缓存 30 秒;空列表也缓存,避免无配置时每次登录都回源 DB。 _ = s.ipDecisionCache.SetIPWhitelist(ctx, appCode, ips, loginRiskIPWhitelistCacheTTL) } return ips, nil } func (s *Service) listLoginRiskUserWhitelist(ctx context.Context) ([]int64, error) { appCode := appcode.FromContext(ctx) if s.ipDecisionCache != nil { userIDs, ok, err := s.ipDecisionCache.GetUserWhitelist(ctx, appCode) if err == nil && ok { return normalizeLoginRiskUserIDList(userIDs), nil } } userIDs, err := s.loadLoginRiskUserWhitelist(ctx) if err != nil { return nil, err } if s.ipDecisionCache != nil { // 用户白名单按运营要求缓存 5 分钟;空列表也缓存,减少无配置时的任务回源。 _ = s.ipDecisionCache.SetUserWhitelist(ctx, appCode, userIDs, loginRiskUserWhitelistCacheTTL) } return userIDs, nil } func (s *Service) loadLoginRiskIPWhitelist(ctx context.Context) ([]string, error) { if s.authRepository == nil { return nil, nil } items, err := s.authRepository.ListEnabledLoginRiskIPWhitelist(ctx) if err != nil { return nil, err } ips := make([]string, 0, len(items)) for _, item := range items { if ip := normalizeLoginRiskIP(item.IPAddress); ip != "" { ips = append(ips, ip) } } return normalizeLoginRiskIPList(ips), nil } func (s *Service) loadLoginRiskUserWhitelist(ctx context.Context) ([]int64, error) { if s.authRepository == nil { return nil, nil } items, err := s.authRepository.ListEnabledLoginRiskUserWhitelist(ctx) if err != nil { return nil, err } userIDs := make([]int64, 0, len(items)) for _, item := range items { if item.UserID > 0 { userIDs = append(userIDs, item.UserID) } } return normalizeLoginRiskUserIDList(userIDs), nil } func appendUniqueCountryBlock(blocks []authdomain.LoginRiskCountryBlock, seen map[string]struct{}, block authdomain.LoginRiskCountryBlock) []authdomain.LoginRiskCountryBlock { key := strings.ToLower(block.CountryCode + "\x00" + block.Keyword) if _, ok := seen[key]; ok { return blocks } seen[key] = struct{}{} return append(blocks, block) } func (s *Service) countryBlockedByStaticPolicy(countryCode string) bool { for _, country := range s.loginRiskPolicy.UnsupportedCountries { if normalizeCountryCode(country) == countryCode { return true } } return false } func (s *Service) countryBlockedByDynamicPolicy(ctx context.Context, countryCode string) (bool, error) { if s.authRepository == nil { return false, nil } blocks, err := s.authRepository.ListEnabledLoginRiskCountryBlocks(ctx) if err != nil { return false, err } for _, block := range blocks { if normalizeCountryCode(block.CountryCode) == countryCode || normalizeCountryCode(block.Keyword) == countryCode { return true, nil } } return false, nil } func normalizeLoginRiskIP(raw string) string { addr, err := netip.ParseAddr(strings.TrimSpace(raw)) if err != nil { return "" } return addr.String() } func normalizeLoginRiskIPList(raw []string) []string { seen := make(map[string]struct{}, len(raw)) ips := make([]string, 0, len(raw)) for _, item := range raw { ip := normalizeLoginRiskIP(item) if ip == "" { continue } if _, ok := seen[ip]; ok { continue } seen[ip] = struct{}{} ips = append(ips, ip) } sort.Strings(ips) return ips } func normalizeLoginRiskUserIDList(raw []int64) []int64 { seen := make(map[int64]struct{}, len(raw)) userIDs := make([]int64, 0, len(raw)) for _, userID := range raw { if userID <= 0 { continue } if _, ok := seen[userID]; ok { continue } seen[userID] = struct{}{} userIDs = append(userIDs, userID) } sort.Slice(userIDs, func(i, j int) bool { return userIDs[i] < userIDs[j] }) return userIDs }