package auth import ( "context" "database/sql" "strings" "time" "hyapp/pkg/appcode" authdomain "hyapp/services/user-service/internal/domain/auth" ) func (r *Repository) CreateLoginIPRiskJob(ctx context.Context, job authdomain.LoginIPRiskJob) error { _, err := r.db.ExecContext(ctx, ` INSERT INTO login_ip_risk_jobs ( app_code, job_id, session_id, user_id, client_ip, client_ip_hash, channel, platform, language, timezone, request_id, edge_country_code, status, decision, country_code, failure_reason, locked_by, locked_until_ms, attempt_count, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', '', '', NULL, NULL, 0, ?, ?) `, appcode.Normalize(job.AppCode), job.JobID, job.SessionID, job.UserID, job.ClientIP, job.ClientIPHash, job.Channel, job.Platform, job.Language, job.Timezone, job.RequestID, job.EdgeCountryCode, job.Status, job.CreatedAtMs, job.UpdatedAtMs) return err } func (r *Repository) ClaimLoginIPRiskJobs(ctx context.Context, workerID string, nowMs int64, lockTTL time.Duration, batchSize int) ([]authdomain.LoginIPRiskJob, error) { if batchSize <= 0 { batchSize = 100 } rows, err := r.db.QueryContext(ctx, ` SELECT app_code, job_id, session_id, user_id, client_ip, client_ip_hash, channel, platform, language, timezone, request_id, edge_country_code, status, decision, country_code, failure_reason, COALESCE(locked_by, ''), COALESCE(locked_until_ms, 0), attempt_count, created_at_ms, updated_at_ms FROM login_ip_risk_jobs WHERE app_code = ? AND status IN (?, ?) AND (locked_until_ms IS NULL OR locked_until_ms <= ?) ORDER BY updated_at_ms ASC, job_id ASC LIMIT ? `, appcode.FromContext(ctx), authdomain.LoginIPRiskStatusPending, authdomain.LoginIPRiskStatusFailed, nowMs, batchSize) if err != nil { return nil, err } defer rows.Close() candidates := make([]authdomain.LoginIPRiskJob, 0, batchSize) for rows.Next() { job, err := scanLoginIPRiskJob(rows) if err != nil { return nil, err } candidates = append(candidates, job) } if err := rows.Err(); err != nil { return nil, err } claimed := make([]authdomain.LoginIPRiskJob, 0, len(candidates)) lockUntilMs := nowMs + lockTTL.Milliseconds() for _, job := range candidates { result, err := r.db.ExecContext(ctx, ` UPDATE login_ip_risk_jobs SET status = ?, locked_by = ?, locked_until_ms = ?, attempt_count = attempt_count + 1, updated_at_ms = ? WHERE app_code = ? AND job_id = ? AND status IN (?, ?) AND (locked_until_ms IS NULL OR locked_until_ms <= ?) `, authdomain.LoginIPRiskStatusRunning, workerID, lockUntilMs, nowMs, job.AppCode, job.JobID, authdomain.LoginIPRiskStatusPending, authdomain.LoginIPRiskStatusFailed, nowMs) if err != nil { return nil, err } affected, err := result.RowsAffected() if err != nil { return nil, err } if affected == 1 { job.Status = authdomain.LoginIPRiskStatusRunning job.LockedBy = workerID job.LockedUntilMs = lockUntilMs job.AttemptCount++ job.UpdatedAtMs = nowMs claimed = append(claimed, job) } } return claimed, nil } func (r *Repository) CompleteLoginIPRiskJob(ctx context.Context, job authdomain.LoginIPRiskJob) error { _, err := r.db.ExecContext(ctx, ` UPDATE login_ip_risk_jobs SET status = ?, decision = ?, country_code = ?, failure_reason = ?, locked_by = NULL, locked_until_ms = NULL, updated_at_ms = ? WHERE app_code = ? AND job_id = ? `, job.Status, job.Decision, job.CountryCode, job.FailureReason, job.UpdatedAtMs, appcode.Normalize(job.AppCode), job.JobID) return err } func (r *Repository) CreateLoginIPRiskDecision(ctx context.Context, decision authdomain.LoginIPRiskDecision) error { _, err := r.db.ExecContext(ctx, ` INSERT INTO login_ip_risk_decisions ( app_code, decision_id, client_ip, client_ip_hash, decision, country_code, confidence, provider_attempts_json, decided_at_ms, expires_at_ms, created_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, CAST(? AS JSON), ?, ?, ?) `, appcode.Normalize(decision.AppCode), decision.DecisionID, decision.ClientIP, decision.ClientIPHash, decision.Decision, decision.CountryCode, decision.Confidence, decision.ProviderAttemptsJSON, decision.DecidedAtMs, decision.ExpiresAtMs, decision.CreatedAtMs) return err } func (r *Repository) ListEnabledLoginRiskCountryBlocks(ctx context.Context) ([]authdomain.LoginRiskCountryBlock, error) { rows, err := r.db.QueryContext(ctx, ` SELECT app_code, block_id, keyword, country_code, enabled, created_at_ms, updated_at_ms FROM login_risk_country_blocks WHERE app_code = ? AND enabled = 1 ORDER BY country_code ASC, keyword ASC, block_id ASC `, appcode.FromContext(ctx)) if err != nil { return nil, err } defer rows.Close() blocks := make([]authdomain.LoginRiskCountryBlock, 0) for rows.Next() { var block authdomain.LoginRiskCountryBlock if err := rows.Scan(&block.AppCode, &block.BlockID, &block.Keyword, &block.CountryCode, &block.Enabled, &block.CreatedAtMs, &block.UpdatedAtMs); err != nil { return nil, err } block.CountryCode = normalizeCountryCode(block.CountryCode) blocks = append(blocks, block) } if err := rows.Err(); err != nil { return nil, err } return blocks, nil } type loginIPRiskJobScanner interface { Scan(dest ...any) error } func normalizeCountryCode(country string) string { country = strings.ToUpper(strings.TrimSpace(country)) if len(country) < 2 || len(country) > 3 { return "" } return country } func scanLoginIPRiskJob(scanner loginIPRiskJobScanner) (authdomain.LoginIPRiskJob, error) { var job authdomain.LoginIPRiskJob err := scanner.Scan( &job.AppCode, &job.JobID, &job.SessionID, &job.UserID, &job.ClientIP, &job.ClientIPHash, &job.Channel, &job.Platform, &job.Language, &job.Timezone, &job.RequestID, &job.EdgeCountryCode, &job.Status, &job.Decision, &job.CountryCode, &job.FailureReason, &job.LockedBy, &job.LockedUntilMs, &job.AttemptCount, &job.CreatedAtMs, &job.UpdatedAtMs, ) if err == sql.ErrNoRows { return authdomain.LoginIPRiskJob{}, err } return job, err }