package auth import ( "context" "database/sql" "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 } type loginIPRiskJobScanner interface { Scan(dest ...any) error } 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 }