package auth import ( "context" "encoding/json" "fmt" "log/slog" "strings" "time" "hyapp/pkg/appcode" "hyapp/pkg/idgen" "hyapp/pkg/logx" authdomain "hyapp/services/user-service/internal/domain/auth" ) const revokeReasonGeoPolicyBlocked = "GEO_POLICY_BLOCKED" // LoginIPRiskWorkerOptions 控制 cron-service 触发的单批 IP 风控处理边界。 type LoginIPRiskWorkerOptions struct { WorkerID string LockTTL time.Duration BatchSize int } type providerAttempt struct { Provider string `json:"provider"` Order int `json:"order"` Country string `json:"country"` Status string `json:"status"` DurationMs int64 `json:"duration_ms"` ErrorCode string `json:"error_code"` } func (s *Service) ProcessLoginIPRiskBatch(ctx context.Context, options LoginIPRiskWorkerOptions) (int, error) { if !s.loginRiskPolicy.Enabled || s.authRepository == nil { return 0, nil } options = normalizeLoginIPRiskWorkerOptions(options) nowMs := s.now().UnixMilli() jobs, err := s.authRepository.ClaimLoginIPRiskJobs(ctx, options.WorkerID, nowMs, options.LockTTL, options.BatchSize) if err != nil { return 0, err } for _, job := range jobs { jobCtx := appcode.WithContext(ctx, job.AppCode) if err := s.processLoginIPRiskJob(jobCtx, job); err != nil { logx.Error(jobCtx, "login_ip_risk_job_failed", err, slog.String("component", "user_auth_risk"), slog.String("job_id", job.JobID), slog.String("session_id", job.SessionID), slog.String("client_ip_hash", job.ClientIPHash)) job.Status = authdomain.LoginIPRiskStatusFailed job.FailureReason = trimRiskFailure(err.Error()) job.UpdatedAtMs = s.now().UnixMilli() _ = s.authRepository.CompleteLoginIPRiskJob(jobCtx, job) } } return len(jobs), nil } func normalizeLoginIPRiskWorkerOptions(options LoginIPRiskWorkerOptions) LoginIPRiskWorkerOptions { if strings.TrimSpace(options.WorkerID) == "" { options.WorkerID = "user-service" } if options.LockTTL <= 0 { options.LockTTL = 30 * time.Second } if options.BatchSize <= 0 { options.BatchSize = 100 } return options } func (s *Service) processLoginIPRiskJob(ctx context.Context, job authdomain.LoginIPRiskJob) error { policy := s.loginRiskPolicy nowMs := s.now().UnixMilli() if cached, ok := s.freshCachedIPDecision(ctx, job); ok { return s.applyLoginIPRiskDecision(ctx, job, cached, nil, authdomain.LoginIPRiskStatusSkipped) } result, attempts := s.resolveIPCountry(ctx, job, policy) decision := s.decisionForCountry(ctx, result.CountryCode) ttl := s.ttlForDecision(decision) expiresAtMs := nowMs + ttl.Milliseconds() ipDecision := IPDecision{ Decision: decision, Country: result.CountryCode, Source: "geo_provider_chain", CheckedAtMs: nowMs, ExpiresAtMs: expiresAtMs, } if s.ipDecisionCache != nil { if err := s.ipDecisionCache.SetIPDecision(ctx, job.AppCode, job.ClientIPHash, ipDecision, ttl); err != nil { logx.Warn(ctx, "login_ip_decision_cache_write_failed", slog.String("component", "user_auth_risk"), slog.String("job_id", job.JobID), slog.String("client_ip_hash", job.ClientIPHash), slog.String("error", err.Error())) } } attemptsJSON, err := json.Marshal(attempts) if err != nil { return err } if err := s.authRepository.CreateLoginIPRiskDecision(ctx, authdomain.LoginIPRiskDecision{ AppCode: job.AppCode, DecisionID: idgen.New("ipdecision"), ClientIP: job.ClientIP, ClientIPHash: job.ClientIPHash, Decision: decision, CountryCode: result.CountryCode, Confidence: result.Confidence, ProviderAttemptsJSON: string(attemptsJSON), DecidedAtMs: nowMs, ExpiresAtMs: expiresAtMs, CreatedAtMs: nowMs, }); err != nil { return err } return s.applyLoginIPRiskDecision(ctx, job, ipDecision, attempts, authdomain.LoginIPRiskStatusCompleted) } func (s *Service) freshCachedIPDecision(ctx context.Context, job authdomain.LoginIPRiskJob) (IPDecision, bool) { if s.ipDecisionCache == nil { return IPDecision{}, false } decision, ok, err := s.ipDecisionCache.GetIPDecision(ctx, job.AppCode, job.ClientIPHash) if err != nil { logx.Warn(ctx, "login_ip_decision_cache_read_failed", slog.String("component", "user_auth_risk"), slog.String("job_id", job.JobID), slog.String("client_ip_hash", job.ClientIPHash), slog.String("error", err.Error())) return IPDecision{}, false } if !ok { return IPDecision{}, false } if decision.ExpiresAtMs > 0 && decision.ExpiresAtMs <= s.now().UnixMilli() { return IPDecision{}, false } if decision.Decision != authdomain.LoginIPRiskDecisionBlocked && decision.Decision != authdomain.LoginIPRiskDecisionAllowed { return IPDecision{}, false } return decision, true } func (s *Service) resolveIPCountry(ctx context.Context, job authdomain.LoginIPRiskJob, policy LoginRiskPolicy) (IPGeoResult, []providerAttempt) { providers := s.ipGeoProviders if len(providers) == 0 { providers = BuildIPGeoProviders(policy.ProviderNames) } attempts := make([]providerAttempt, 0, len(providers)) for index, provider := range providers { started := time.Now() providerCtx, cancel := context.WithTimeout(ctx, policy.ProviderTimeout) result, err := provider.ResolveCountry(providerCtx, job) cancel() attempt := providerAttempt{ Provider: provider.Name(), Order: index + 1, Country: normalizeCountryCode(result.CountryCode), DurationMs: time.Since(started).Milliseconds(), } if err != nil { attempt.Status = "failed" attempt.ErrorCode = trimRiskFailure(err.Error()) attempts = append(attempts, attempt) logx.Warn(ctx, "login_ip_geo_provider_attempt", slog.String("component", "user_auth_risk"), slog.String("job_id", job.JobID), slog.String("provider", provider.Name()), slog.Int("order", index+1), slog.String("status", attempt.Status), slog.String("error_code", attempt.ErrorCode), slog.Int64("duration_ms", attempt.DurationMs)) continue } if attempt.Country == "" { attempt.Status = "failed" attempt.ErrorCode = "empty_country" attempts = append(attempts, attempt) continue } attempt.Status = "ok" attempts = append(attempts, attempt) logx.Info(ctx, "login_ip_geo_provider_attempt", slog.String("component", "user_auth_risk"), slog.String("job_id", job.JobID), slog.String("provider", provider.Name()), slog.Int("order", index+1), slog.String("country", attempt.Country), slog.String("status", attempt.Status), slog.Int64("duration_ms", attempt.DurationMs)) return IPGeoResult{CountryCode: attempt.Country, Confidence: result.Confidence}, attempts } return IPGeoResult{}, attempts } func (s *Service) decisionForCountry(ctx context.Context, countryCode string) string { countryCode = normalizeCountryCode(countryCode) if countryCode == "" { return authdomain.LoginIPRiskDecisionUnknown } if s.countryBlockedByStaticPolicy(countryCode) { return authdomain.LoginIPRiskDecisionBlocked } dynamicBlocked, err := s.countryBlockedByDynamicPolicy(ctx, countryCode) if err != nil { // 动态配置读取失败不能扩大误伤面;保留静态配置阻断,其余按 fail-open 放行并记录。 logx.Warn(ctx, "login_risk_country_blocks_read_failed", slog.String("component", "user_auth_risk"), slog.String("country", countryCode), slog.String("error", err.Error())) return authdomain.LoginIPRiskDecisionAllowed } if dynamicBlocked { return authdomain.LoginIPRiskDecisionBlocked } return authdomain.LoginIPRiskDecisionAllowed } func (s *Service) ttlForDecision(decision string) time.Duration { switch decision { case authdomain.LoginIPRiskDecisionBlocked: return s.loginRiskPolicy.BlockedTTL case authdomain.LoginIPRiskDecisionAllowed: return s.loginRiskPolicy.AllowedTTL default: return s.loginRiskPolicy.UnknownTTL } } func (s *Service) applyLoginIPRiskDecision(ctx context.Context, job authdomain.LoginIPRiskJob, decision IPDecision, attempts []providerAttempt, status string) error { nowMs := s.now().UnixMilli() job.Status = status job.Decision = decision.Decision job.CountryCode = normalizeCountryCode(decision.Country) job.UpdatedAtMs = nowMs if decision.Decision == authdomain.LoginIPRiskDecisionUnknown { job.FailureReason = "provider_unavailable" } if err := s.authRepository.CompleteLoginIPRiskJob(ctx, job); err != nil { return err } logx.Info(ctx, "login_ip_risk_decision", slog.String("component", "user_auth_risk"), slog.String("job_id", job.JobID), slog.String("session_id", job.SessionID), slog.Int64("user_id", job.UserID), slog.String("decision", job.Decision), slog.String("country", job.CountryCode)) if decision.Decision != authdomain.LoginIPRiskDecisionBlocked { return nil } revoked, err := s.authRepository.RevokeSessionWithReason(ctx, job.SessionID, nowMs, revokeReasonGeoPolicyBlocked, job.RequestID, "risk_worker") if err != nil { return err } if s.ipDecisionCache != nil { if err := s.ipDecisionCache.SetRevokedSession(ctx, job.AppCode, job.SessionID, revokeReasonGeoPolicyBlocked, s.loginRiskPolicy.DenylistTTL); err != nil { logx.Warn(ctx, "revoked_session_denylist_write_failed", slog.String("component", "user_auth_risk"), slog.String("job_id", job.JobID), slog.String("session_id", job.SessionID), slog.String("error", err.Error())) } } if err := s.authRepository.MarkLoginAuditBlocked(ctx, job.RequestID, job.UserID, job.CountryCode, riskBlockReasonIPAsync); err != nil { logx.Warn(ctx, "login_audit_mark_blocked_failed", slog.String("component", "user_auth_risk"), slog.String("job_id", job.JobID), slog.String("session_id", job.SessionID), slog.String("error", err.Error())) } logx.Info(ctx, "login_session_revoked_by_risk", slog.String("component", "user_auth_risk"), slog.String("job_id", job.JobID), slog.String("session_id", job.SessionID), slog.Int64("user_id", job.UserID), slog.String("reason", revokeReasonGeoPolicyBlocked), slog.String("country", job.CountryCode), slog.Bool("revoked", revoked)) _ = attempts return nil } func trimRiskFailure(value string) string { value = strings.TrimSpace(value) if value == "" { return "" } if len(value) > 128 { return value[:128] } return value } func (r IPGeoResult) String() string { return fmt.Sprintf("%s/%d", r.CountryCode, r.Confidence) }