spree_cm_commissioner 2.8.2.pre.pre.2 → 2.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.gitignore +0 -4
- data/Gemfile.lock +1 -1
- data/app/controllers/spree/admin/homepage_section_controller.rb +1 -4
- data/app/controllers/spree/admin/taxons_controller_decorator.rb +0 -19
- data/app/controllers/spree/api/v2/storefront/homepage_sections_controller.rb +0 -1
- data/app/controllers/spree/api/v2/tenant/base_controller.rb +0 -4
- data/app/controllers/spree/api/v2/tenant/homepage_sections_controller.rb +0 -1
- data/app/controllers/spree/api/v2/tenant/products_controller.rb +1 -1
- data/app/controllers/spree/api/v2/tenant/taxons_controller.rb +1 -1
- data/app/controllers/spree_cm_commissioner/admin/products_controller_decorator.rb +0 -19
- data/app/finders/spree_cm_commissioner/events/find_matches.rb +0 -1
- data/app/helpers/spree_cm_commissioner/admin/homepage_segment_helper.rb +0 -2
- data/app/models/concerns/spree_cm_commissioner/homepage_section_bitwise.rb +1 -2
- data/app/models/concerns/spree_cm_commissioner/option_type_attr_type.rb +1 -2
- data/app/models/concerns/spree_cm_commissioner/order_state_machine.rb +0 -10
- data/app/models/concerns/spree_cm_commissioner/product_type.rb +1 -1
- data/app/models/spree_cm_commissioner/product_decorator.rb +0 -39
- data/app/models/spree_cm_commissioner/role_decorator.rb +1 -4
- data/app/models/spree_cm_commissioner/taxon_decorator.rb +0 -15
- data/app/models/spree_cm_commissioner/taxonomy_decorator.rb +1 -10
- data/app/models/spree_cm_commissioner/tenant.rb +0 -9
- data/app/models/spree_cm_commissioner/user_decorator.rb +0 -5
- data/app/models/spree_cm_commissioner/variant_options.rb +0 -4
- data/app/models/spree_cm_commissioner/vendor_decorator.rb +0 -4
- data/app/serializers/spree/v2/storefront/homepage_section_serializer.rb +1 -1
- data/app/serializers/spree/v2/storefront/product_serializer_decorator.rb +1 -1
- data/app/serializers/spree/v2/storefront/role_serializer.rb +1 -1
- data/app/serializers/spree/v2/storefront/taxon_serializer_decorator.rb +1 -2
- data/app/serializers/spree/v2/tenant/homepage_section_serializer.rb +1 -1
- data/app/serializers/spree/v2/tenant/role_serializer.rb +1 -1
- data/app/services/spree_cm_commissioner/api_caches/invalidate.rb +0 -12
- data/app/views/spree/admin/homepage_section/_form.html.erb +0 -5
- data/config/initializers/spree_permitted_attributes.rb +0 -8
- data/config/locales/en.yml +0 -14
- data/config/locales/km.yml +0 -10
- data/config/routes.rb +0 -26
- data/lib/spree_cm_commissioner/version.rb +1 -1
- data/lib/spree_cm_commissioner.rb +1 -7
- data/spree_cm_commissioner.gemspec +1 -1
- metadata +4 -116
- data/app/controllers/spree/api/v2/storefront/preview_products_controller.rb +0 -48
- data/app/controllers/spree/api/v2/storefront/preview_sections_controller.rb +0 -27
- data/app/controllers/spree/api/v2/storefront/preview_taxons_controller.rb +0 -18
- data/app/controllers/spree/api/v2/storefront/products_controller_decorator.rb +0 -15
- data/app/controllers/spree/api/v2/storefront/taxons_controller_decorator.rb +0 -15
- data/app/controllers/spree/api/v2/tenant/free_vote_claims_controller.rb +0 -37
- data/app/controllers/spree/api/v2/tenant/preview_products_controller.rb +0 -47
- data/app/controllers/spree/api/v2/tenant/preview_sections_controller.rb +0 -26
- data/app/controllers/spree/api/v2/tenant/preview_shows_controller.rb +0 -19
- data/app/controllers/spree/api/v2/tenant/preview_taxons_controller.rb +0 -19
- data/app/controllers/spree/api/v2/tenant/show_contestants_controller.rb +0 -52
- data/app/controllers/spree/api/v2/tenant/show_elimination_sessions_controller.rb +0 -57
- data/app/controllers/spree/api/v2/tenant/show_people_controller.rb +0 -49
- data/app/controllers/spree/api/v2/tenant/show_person_assignments_controller.rb +0 -36
- data/app/controllers/spree/api/v2/tenant/shows_controller.rb +0 -34
- data/app/controllers/spree/api/v2/tenant/votes_controller.rb +0 -94
- data/app/controllers/spree/api/v2/tenant/voting_contestants_controller.rb +0 -40
- data/app/controllers/spree/api/v2/tenant/voting_credit_transactions_controller.rb +0 -41
- data/app/controllers/spree/api/v2/tenant/voting_credits_controller.rb +0 -31
- data/app/jobs/spree_cm_commissioner/vote_fraud_event_job.rb +0 -9
- data/app/jobs/spree_cm_commissioner/voting_credit_allocation_job.rb +0 -10
- data/app/jobs/spree_cm_commissioner/voting_credit_de_allocation_job.rb +0 -10
- data/app/models/spree_cm_commissioner/maintenance_tasks/voting_session.rb +0 -36
- data/app/models/spree_cm_commissioner/preview_role.rb +0 -8
- data/app/models/spree_cm_commissioner/role_user_decorator.rb +0 -8
- data/app/models/spree_cm_commissioner/show.rb +0 -159
- data/app/models/spree_cm_commissioner/show_contestant.rb +0 -39
- data/app/models/spree_cm_commissioner/show_contestant_image.rb +0 -11
- data/app/models/spree_cm_commissioner/show_contestant_video.rb +0 -15
- data/app/models/spree_cm_commissioner/show_episode.rb +0 -135
- data/app/models/spree_cm_commissioner/show_person.rb +0 -15
- data/app/models/spree_cm_commissioner/show_person_assignment.rb +0 -20
- data/app/models/spree_cm_commissioner/show_person_image.rb +0 -11
- data/app/models/spree_cm_commissioner/vote.rb +0 -16
- data/app/models/spree_cm_commissioner/vote_fraud_event.rb +0 -19
- data/app/models/spree_cm_commissioner/voting_contestant.rb +0 -46
- data/app/models/spree_cm_commissioner/voting_credit.rb +0 -72
- data/app/models/spree_cm_commissioner/voting_credit_transaction.rb +0 -55
- data/app/models/spree_cm_commissioner/voting_session.rb +0 -223
- data/app/models/spree_cm_commissioner/voting_session_stat.rb +0 -8
- data/app/overrides/spree/admin/products/_form/preview_checkbox.html.erb.deface +0 -9
- data/app/overrides/spree/admin/taxons/_form/preview_checkbox.html.erb.deface +0 -7
- data/app/serializers/spree/v2/tenant/show_contestant_serializer.rb +0 -21
- data/app/serializers/spree/v2/tenant/show_episode_serializer.rb +0 -17
- data/app/serializers/spree/v2/tenant/show_person_assignment_serializer.rb +0 -16
- data/app/serializers/spree/v2/tenant/show_person_serializer.rb +0 -13
- data/app/serializers/spree/v2/tenant/show_serializer.rb +0 -26
- data/app/serializers/spree/v2/tenant/video_serializer.rb +0 -9
- data/app/serializers/spree/v2/tenant/vote_serializer.rb +0 -14
- data/app/serializers/spree/v2/tenant/voting_contestant_serializer.rb +0 -22
- data/app/serializers/spree/v2/tenant/voting_credit_serializer.rb +0 -10
- data/app/serializers/spree/v2/tenant/voting_credit_transaction_serializer.rb +0 -14
- data/app/serializers/spree/v2/tenant/voting_session_serializer.rb +0 -18
- data/app/services/spree_cm_commissioner/fraud_check.rb +0 -279
- data/app/services/spree_cm_commissioner/show_contestants/normalize_video_highlights.rb +0 -57
- data/app/services/spree_cm_commissioner/url_embed/youtube_embed.rb +0 -44
- data/app/services/spree_cm_commissioner/vote_counters/audit_counters.rb +0 -43
- data/app/services/spree_cm_commissioner/vote_counters/base.rb +0 -31
- data/app/services/spree_cm_commissioner/vote_counters/increment.rb +0 -44
- data/app/services/spree_cm_commissioner/vote_counters/per_contestant_counter.rb +0 -68
- data/app/services/spree_cm_commissioner/vote_counters/rebuild_from_db.rb +0 -70
- data/app/services/spree_cm_commissioner/vote_counters/snapshot_to_db.rb +0 -113
- data/app/services/spree_cm_commissioner/vote_credit_deductor.rb +0 -68
- data/app/services/spree_cm_commissioner/vote_package/create.rb +0 -145
- data/app/services/spree_cm_commissioner/vote_package/update.rb +0 -91
- data/app/services/spree_cm_commissioner/vote_processor.rb +0 -144
- data/app/services/spree_cm_commissioner/voting_contestants/advancer.rb +0 -334
- data/app/services/spree_cm_commissioner/voting_contestants/assigner.rb +0 -32
- data/app/services/spree_cm_commissioner/voting_contestants/bulk_updater.rb +0 -106
- data/app/services/spree_cm_commissioner/voting_credits/allocate.rb +0 -77
- data/app/services/spree_cm_commissioner/voting_credits/claim_free_votes.rb +0 -119
- data/app/services/spree_cm_commissioner/voting_credits/credit_calculator.rb +0 -35
- data/app/services/spree_cm_commissioner/voting_credits/de_allocate.rb +0 -87
- data/app/services/spree_cm_commissioner/voting_leaderboards/calculate_score.rb +0 -74
- data/app/services/spree_cm_commissioner/voting_sessions/finalize.rb +0 -66
- data/db/migrate/20260309230148_create_cm_show_people.rb +0 -14
- data/db/migrate/20260309230149_create_cm_show_people_assignments.rb +0 -16
- data/db/migrate/20260310082711_create_cm_show_contestants.rb +0 -28
- data/db/migrate/20260310082720_create_cm_voting_sessions.rb +0 -21
- data/db/migrate/20260310082721_create_cm_voting_contestants.rb +0 -23
- data/db/migrate/20260310082734_add_voting_fields_to_spree_taxons.rb +0 -9
- data/db/migrate/20260310082735_add_type_to_spree_products.rb +0 -6
- data/db/migrate/20260310082749_create_cm_voting_credits.rb +0 -27
- data/db/migrate/20260326080200_create_cm_voting_credit_transactions.rb +0 -27
- data/db/migrate/20260330160000_create_cm_votes.rb +0 -25
- data/db/migrate/20260401072500_add_advanced_from_to_cm_voting_contestants.rb +0 -7
- data/db/migrate/20260402000001_add_voting_credit_scope_to_spree_taxons.rb +0 -6
- data/db/migrate/20260402000002_rename_scopeable_to_votable_in_cm_voting_credits.rb +0 -12
- data/db/migrate/20260403070000_add_name_to_cm_voting_sessions.rb +0 -5
- data/db/migrate/20260406000001_add_vendor_id_to_voting_tables.rb +0 -6
- data/db/migrate/20260406000001_rename_votes_remaining_to_amount_in_cm_voting_credits.rb +0 -11
- data/db/migrate/20260408085255_add_show_id_and_vendor_id_to_cm_voting_sessions.rb +0 -9
- data/db/migrate/20260420000001_rename_type_to_credit_type_in_cm_voting_credits.rb +0 -25
- data/db/migrate/20260422000001_create_cm_vote_fraud_events.rb +0 -23
- data/db/migrate/20260423000001_add_preview_to_taxons_products_and_sections.rb +0 -11
- data/db/migrate/20260423000002_create_preview_roles.rb +0 -24
- data/db/migrate/20260515120000_add_public_metadata_to_cm_voting_sessions.rb +0 -5
- data/db/migrate/20260518090920_add_unique_voter_count_to_voting_contestants.rb +0 -5
- data/db/migrate/20260518094322_create_cm_voting_session_stats.rb +0 -17
- data/db/migrate/20260520000001_add_scoring_model_to_cm_voting_sessions.rb +0 -5
- data/db/migrate/20260520000001_optimize_cm_votes_indexes.rb +0 -22
- data/db/migrate/20260525042257_add_vote_number_to_cm_voting_contestants.rb +0 -18
- data/db/migrate/20260527035430_add_confirmed_rank_to_cm_voting_contestants.rb +0 -5
- data/db/migrate/20260527062005_add_eliminated_at_to_cm_show_contestants.rb +0 -5
- data/docs/sql/jsonb_query_guide.md +0 -57
- data/lib/spree_cm_commissioner/test_helper/factories/show_episode_factory.rb +0 -12
- data/lib/spree_cm_commissioner/test_helper/factories/show_factory.rb +0 -120
- data/lib/spree_cm_commissioner/test_helper/factories/vote_credit_factory.rb +0 -37
- data/lib/spree_cm_commissioner/test_helper/factories/vote_factory.rb +0 -28
- data/lib/spree_cm_commissioner/test_helper/factories/voting_credit_transaction_factory.rb +0 -11
- data/lib/spree_cm_commissioner/test_helper/factories/voting_session_factory.rb +0 -11
|
@@ -1,279 +0,0 @@
|
|
|
1
|
-
# Real-time fraud detection for vote submissions.
|
|
2
|
-
#
|
|
3
|
-
# Runs four checks in order:
|
|
4
|
-
# 1. User rate limit – max votes per minute per user
|
|
5
|
-
# 2. IP rate limit – max votes per minute per IP
|
|
6
|
-
# 3. Device account limit – max distinct accounts per device fingerprint
|
|
7
|
-
# 4. VPN / proxy block – reject known VPN IPs (when enabled)
|
|
8
|
-
|
|
9
|
-
# On any violation, raises RuntimeError with an I18n user-facing message
|
|
10
|
-
# and enqueues a VoteFraudEventJob asynchronously.
|
|
11
|
-
|
|
12
|
-
# All Redis operations use SpreeCmCommissioner.voting_redis_pool.
|
|
13
|
-
module SpreeCmCommissioner
|
|
14
|
-
class FraudCheck
|
|
15
|
-
prepend ::Spree::ServiceModule::Base
|
|
16
|
-
|
|
17
|
-
DEFAULTS = {
|
|
18
|
-
'max_votes_per_minute_per_user' => 5,
|
|
19
|
-
'max_votes_per_minute_per_ip' => 10,
|
|
20
|
-
'max_accounts_per_device' => 3,
|
|
21
|
-
'block_vpn' => false
|
|
22
|
-
}.freeze
|
|
23
|
-
|
|
24
|
-
RATE_WINDOW = 60 # seconds
|
|
25
|
-
VPN_CACHE_TTL = 3600 # 1 hour
|
|
26
|
-
|
|
27
|
-
attr_reader :voting_session, :contestant, :user, :params, :request
|
|
28
|
-
|
|
29
|
-
def call(voting_session:, user:, params:, request:, contestant: nil)
|
|
30
|
-
@voting_session = voting_session
|
|
31
|
-
@contestant = contestant
|
|
32
|
-
@user = user
|
|
33
|
-
@params = params
|
|
34
|
-
@request = request
|
|
35
|
-
|
|
36
|
-
return success(nil) if fraud_config.blank?
|
|
37
|
-
|
|
38
|
-
check_user_rate_limit!
|
|
39
|
-
check_ip_rate_limit!
|
|
40
|
-
check_device_account_limit!
|
|
41
|
-
check_vpn_block!
|
|
42
|
-
|
|
43
|
-
success(nil)
|
|
44
|
-
rescue Redis::BaseError, ConnectionPool::TimeoutError => e
|
|
45
|
-
# Fail-open: a Redis outage should not take down voting entirely.
|
|
46
|
-
# Catches both Redis protocol errors and connection pool exhaustion/timeout.
|
|
47
|
-
# Must be rescued BEFORE RuntimeError because Redis::BaseError < RuntimeError
|
|
48
|
-
# in redis gem v4.x — placing it after RuntimeError would let the wrong clause win.
|
|
49
|
-
# Log the error so it is visible in monitoring, but let the vote through.
|
|
50
|
-
# If the policy should be fail-closed instead, replace the rescue body
|
|
51
|
-
# with: failure(I18n.t('voting.errors.service_unavailable'))
|
|
52
|
-
CmAppLogger.error(
|
|
53
|
-
label: 'FraudCheck Redis error — failing open',
|
|
54
|
-
data: {
|
|
55
|
-
voting_session_id: voting_session.id,
|
|
56
|
-
user_id: user&.id,
|
|
57
|
-
error_class: e.class.name,
|
|
58
|
-
error_message: e.message
|
|
59
|
-
}
|
|
60
|
-
)
|
|
61
|
-
success(nil)
|
|
62
|
-
rescue RuntimeError => e
|
|
63
|
-
failure(nil, e.message)
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
private
|
|
67
|
-
|
|
68
|
-
def fraud_config
|
|
69
|
-
@fraud_config ||= voting_session.effective_fraud_config
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
def config_value(key)
|
|
73
|
-
value = fraud_config.fetch(key, DEFAULTS[key])
|
|
74
|
-
default = DEFAULTS[key]
|
|
75
|
-
default.is_a?(Integer) ? value.to_i : value
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
def session_id
|
|
79
|
-
voting_session.id
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
def user_identifier
|
|
83
|
-
user&.id || params[:voter_identifier]
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
def ip_address
|
|
87
|
-
request&.remote_ip || params[:ip_address]
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
def device_fingerprint
|
|
91
|
-
params[:device_fingerprint]
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
def quantity
|
|
95
|
-
[params[:quantity].to_i, 1].max
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
# --- Check 1: User rate limit ---
|
|
99
|
-
|
|
100
|
-
def check_user_rate_limit!
|
|
101
|
-
threshold = config_value('max_votes_per_minute_per_user')
|
|
102
|
-
return if threshold.nil? || threshold <= 0
|
|
103
|
-
|
|
104
|
-
key = "fraud:rate:user:#{session_id}:#{user_identifier}"
|
|
105
|
-
count = increment_with_expiry(key, RATE_WINDOW, by: quantity)
|
|
106
|
-
|
|
107
|
-
return unless count > threshold
|
|
108
|
-
|
|
109
|
-
log_fraud_event(:rate_limit, :medium)
|
|
110
|
-
raise I18n.t('voting.errors.rate_limit_user')
|
|
111
|
-
end
|
|
112
|
-
|
|
113
|
-
# --- Check 2: IP rate limit ---
|
|
114
|
-
|
|
115
|
-
def check_ip_rate_limit!
|
|
116
|
-
return if ip_address.blank?
|
|
117
|
-
|
|
118
|
-
threshold = config_value('max_votes_per_minute_per_ip')
|
|
119
|
-
return if threshold.nil? || threshold <= 0
|
|
120
|
-
|
|
121
|
-
key = "fraud:rate:ip:#{session_id}:#{ip_address}"
|
|
122
|
-
count = increment_with_expiry(key, RATE_WINDOW, by: quantity)
|
|
123
|
-
|
|
124
|
-
return unless count > threshold
|
|
125
|
-
|
|
126
|
-
log_fraud_event(:ip_cluster, :medium)
|
|
127
|
-
raise I18n.t('voting.errors.rate_limit_ip')
|
|
128
|
-
end
|
|
129
|
-
|
|
130
|
-
# --- Check 3: Device account limit ---
|
|
131
|
-
|
|
132
|
-
def check_device_account_limit!
|
|
133
|
-
return if device_fingerprint.blank?
|
|
134
|
-
|
|
135
|
-
threshold = config_value('max_accounts_per_device')
|
|
136
|
-
return if threshold.nil? || threshold <= 0
|
|
137
|
-
|
|
138
|
-
key = "fraud:device:#{session_id}:#{device_fingerprint}"
|
|
139
|
-
member = user_identifier.to_s
|
|
140
|
-
|
|
141
|
-
card = sadd_with_expiry(key, member, session_ttl)
|
|
142
|
-
|
|
143
|
-
return unless card > threshold
|
|
144
|
-
|
|
145
|
-
log_fraud_event(:device_duplicate, :high)
|
|
146
|
-
raise I18n.t('voting.errors.device_account_limit')
|
|
147
|
-
end
|
|
148
|
-
|
|
149
|
-
# --- Check 4: VPN / proxy block ---
|
|
150
|
-
|
|
151
|
-
def check_vpn_block!
|
|
152
|
-
return unless config_value('block_vpn')
|
|
153
|
-
return if ip_address.blank?
|
|
154
|
-
|
|
155
|
-
return unless vpn_detected?(ip_address)
|
|
156
|
-
|
|
157
|
-
log_fraud_event(:anomaly, :high)
|
|
158
|
-
raise I18n.t('voting.errors.vpn_blocked')
|
|
159
|
-
end
|
|
160
|
-
|
|
161
|
-
# --- Redis helpers ---
|
|
162
|
-
|
|
163
|
-
# Atomically increments a counter key and sets its TTL on the first write.
|
|
164
|
-
#
|
|
165
|
-
# Why Lua: issuing INCRBY then EXPIRE as two separate commands creates a race
|
|
166
|
-
# where another request can read the key between the two commands, or the TTL
|
|
167
|
-
# can be set more than once (resetting the window) if two requests race on the
|
|
168
|
-
# first write. Running both commands inside a Lua script executes them as a
|
|
169
|
-
# single atomic unit in Redis, eliminating the race entirely.
|
|
170
|
-
#
|
|
171
|
-
# KEYS[1] – the Redis key to increment
|
|
172
|
-
# ARGV[1] – increment amount (quantity)
|
|
173
|
-
# ARGV[2] – TTL in seconds for the rate window
|
|
174
|
-
#
|
|
175
|
-
# Returns the new count after incrementing.
|
|
176
|
-
INCRBY_WITH_EXPIRY_SCRIPT = <<~LUA.freeze
|
|
177
|
-
local count = redis.call('INCRBY', KEYS[1], ARGV[1])
|
|
178
|
-
if count == tonumber(ARGV[1]) then
|
|
179
|
-
redis.call('EXPIRE', KEYS[1], ARGV[2])
|
|
180
|
-
end
|
|
181
|
-
return count
|
|
182
|
-
LUA
|
|
183
|
-
|
|
184
|
-
# Atomically adds a member to a set, sets TTL on the first write, and
|
|
185
|
-
# returns the set cardinality.
|
|
186
|
-
#
|
|
187
|
-
# Why Lua: SADD, EXPIRE, and SCARD as separate commands can interleave with
|
|
188
|
-
# other requests — a concurrent SADD could reset the TTL unintentionally, or
|
|
189
|
-
# SCARD could be read before EXPIRE is applied. The Lua script runs all three
|
|
190
|
-
# atomically, guaranteeing a consistent view of the set on every call.
|
|
191
|
-
#
|
|
192
|
-
# KEYS[1] – the Redis set key
|
|
193
|
-
# ARGV[1] – the member to add (user identifier)
|
|
194
|
-
# ARGV[2] – TTL in seconds; skipped when 0
|
|
195
|
-
#
|
|
196
|
-
# Returns the number of distinct members in the set after the add.
|
|
197
|
-
SADD_WITH_EXPIRY_SCRIPT = <<~LUA.freeze
|
|
198
|
-
redis.call('SADD', KEYS[1], ARGV[1])
|
|
199
|
-
if redis.call('TTL', KEYS[1]) == -1 and tonumber(ARGV[2]) > 0 then
|
|
200
|
-
redis.call('EXPIRE', KEYS[1], ARGV[2])
|
|
201
|
-
end
|
|
202
|
-
return redis.call('SCARD', KEYS[1])
|
|
203
|
-
LUA
|
|
204
|
-
|
|
205
|
-
def increment_with_expiry(key, ttl, by: 1)
|
|
206
|
-
with_redis { |r| r.eval(INCRBY_WITH_EXPIRY_SCRIPT, keys: [key], argv: [by, ttl]) }
|
|
207
|
-
end
|
|
208
|
-
|
|
209
|
-
def sadd_with_expiry(key, member, ttl)
|
|
210
|
-
with_redis { |r| r.eval(SADD_WITH_EXPIRY_SCRIPT, keys: [key], argv: [member, ttl]) }
|
|
211
|
-
end
|
|
212
|
-
|
|
213
|
-
def with_redis(&block)
|
|
214
|
-
SpreeCmCommissioner.voting_redis_pool.with(&block)
|
|
215
|
-
end
|
|
216
|
-
|
|
217
|
-
def session_ttl
|
|
218
|
-
return RATE_WINDOW unless voting_session.closes_at
|
|
219
|
-
|
|
220
|
-
remaining = (voting_session.closes_at - Time.current).to_i
|
|
221
|
-
[remaining, RATE_WINDOW].max
|
|
222
|
-
end
|
|
223
|
-
|
|
224
|
-
# --- VPN detection ---
|
|
225
|
-
# Checks a Redis cache first (TTL = VPN_CACHE_TTL). If no cached result,
|
|
226
|
-
# delegates to lookup_vpn and caches the outcome.
|
|
227
|
-
#
|
|
228
|
-
# Cache values: '1' = VPN/proxy detected, '0' = clean.
|
|
229
|
-
# This avoids hitting the external provider on every vote request.
|
|
230
|
-
#
|
|
231
|
-
# To manually mark an IP as a VPN for testing:
|
|
232
|
-
# SpreeCmCommissioner.voting_redis_pool.with { |r| r.setex("fraud:vpn:<ip>", 3600, '1') }
|
|
233
|
-
|
|
234
|
-
def vpn_detected?(ip)
|
|
235
|
-
cache_key = "fraud:vpn:#{ip}"
|
|
236
|
-
|
|
237
|
-
cached = with_redis { |r| r.get(cache_key) }
|
|
238
|
-
return cached == '1' unless cached.nil?
|
|
239
|
-
|
|
240
|
-
result = lookup_vpn(ip)
|
|
241
|
-
with_redis { |r| r.setex(cache_key, VPN_CACHE_TTL, result ? '1' : '0') }
|
|
242
|
-
result
|
|
243
|
-
end
|
|
244
|
-
|
|
245
|
-
# Placeholder for external VPN/proxy detection. Currently always returns
|
|
246
|
-
# false, making block_vpn a no-op unless Redis is pre-seeded manually.
|
|
247
|
-
#
|
|
248
|
-
# To activate, replace this method body with a real provider call, e.g.:
|
|
249
|
-
# - ip-api.com → HTTP GET http://ip-api.com/json/<ip>?fields=proxy,hosting
|
|
250
|
-
# - MaxMind → local GeoIP2 DB lookup (no network call, best for high traffic)
|
|
251
|
-
# - Blocklist → check against a maintained list of known VPN/datacenter CIDRs
|
|
252
|
-
#
|
|
253
|
-
# The Redis caching in vpn_detected? is already in place, so the provider
|
|
254
|
-
# is only called once per unique IP per VPN_CACHE_TTL window.
|
|
255
|
-
def lookup_vpn(_ip)
|
|
256
|
-
false
|
|
257
|
-
end
|
|
258
|
-
|
|
259
|
-
# --- Fraud event logging (async) ---
|
|
260
|
-
|
|
261
|
-
def log_fraud_event(fraud_type, severity)
|
|
262
|
-
SpreeCmCommissioner::VoteFraudEventJob.perform_later(
|
|
263
|
-
tenant_id: voting_session.vendor.tenant_id,
|
|
264
|
-
user_id: user&.id,
|
|
265
|
-
voting_session_id: voting_session.id,
|
|
266
|
-
fraud_type: fraud_type,
|
|
267
|
-
severity: severity,
|
|
268
|
-
action_taken: :blocked,
|
|
269
|
-
details: {
|
|
270
|
-
ip_address: ip_address,
|
|
271
|
-
device_fingerprint: device_fingerprint,
|
|
272
|
-
voter_identifier: params[:voter_identifier],
|
|
273
|
-
contestant_id: contestant&.id,
|
|
274
|
-
contestant_name: contestant&.name
|
|
275
|
-
}
|
|
276
|
-
)
|
|
277
|
-
end
|
|
278
|
-
end
|
|
279
|
-
end
|
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
module SpreeCmCommissioner
|
|
2
|
-
module ShowContestants
|
|
3
|
-
class NormalizeVideoHighlights
|
|
4
|
-
prepend ::Spree::ServiceModule::Base
|
|
5
|
-
|
|
6
|
-
def call(raw_video_highlights: nil)
|
|
7
|
-
return success(video_highlights: []) if raw_video_highlights.blank?
|
|
8
|
-
|
|
9
|
-
video_highlights = normalize_video_highlights(raw_video_highlights: raw_video_highlights)
|
|
10
|
-
|
|
11
|
-
success(video_highlights: video_highlights)
|
|
12
|
-
rescue StandardError => e
|
|
13
|
-
failure(nil, e.message)
|
|
14
|
-
end
|
|
15
|
-
|
|
16
|
-
private
|
|
17
|
-
|
|
18
|
-
def normalize_video_highlights(raw_video_highlights:)
|
|
19
|
-
return [] if raw_video_highlights.blank?
|
|
20
|
-
|
|
21
|
-
source = normalize_source(raw_video_highlights)
|
|
22
|
-
normalized = source.map { |payload| normalize_video(payload) }
|
|
23
|
-
normalized.reject { |video| video_empty?(video) }
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
def normalize_source(raw_video_highlights)
|
|
27
|
-
return raw_video_highlights if raw_video_highlights.is_a?(Array)
|
|
28
|
-
|
|
29
|
-
raw_video_highlights.to_h.sort_by { |index, _| index.to_i }.map(&:last)
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
def normalize_video(payload)
|
|
33
|
-
payload_hash = payload.to_h
|
|
34
|
-
uri = extract_value(payload_hash, 'uri').to_s.strip
|
|
35
|
-
|
|
36
|
-
{
|
|
37
|
-
'title' => extract_value(payload_hash, 'title').to_s.strip,
|
|
38
|
-
'description' => extract_value(payload_hash, 'description').to_s.strip,
|
|
39
|
-
'uri' => uri.present? ? embed_youtube_url(uri) : uri
|
|
40
|
-
}
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
def extract_value(hash, key)
|
|
44
|
-
hash[key] || hash[key.to_sym]
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
def embed_youtube_url(uri)
|
|
48
|
-
result = UrlEmbed::YoutubeEmbed.call(url: uri)
|
|
49
|
-
result.success? ? result.value : uri
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
def video_empty?(video)
|
|
53
|
-
video&.dig('title').blank? && video&.dig('description').blank? && video&.dig('uri').blank?
|
|
54
|
-
end
|
|
55
|
-
end
|
|
56
|
-
end
|
|
57
|
-
end
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
# Converts various YouTube URL formats to embeddable iframe URLs.
|
|
2
|
-
#
|
|
3
|
-
# Examples:
|
|
4
|
-
# result = YoutubeEmbed.call(url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ")
|
|
5
|
-
# result.success? # => true
|
|
6
|
-
# result.value # => "https://www.youtube.com/embed/dQw4w9WgXcQ"
|
|
7
|
-
#
|
|
8
|
-
# result = YoutubeEmbed.call(url: "https://youtu.be/dQw4w9WgXcQ")
|
|
9
|
-
# result.success? # => true
|
|
10
|
-
module SpreeCmCommissioner
|
|
11
|
-
module UrlEmbed
|
|
12
|
-
class YoutubeEmbed
|
|
13
|
-
prepend ::Spree::ServiceModule::Base
|
|
14
|
-
|
|
15
|
-
def call(url:)
|
|
16
|
-
return failure(nil, 'URL is blank') if url.blank?
|
|
17
|
-
|
|
18
|
-
video_id = self.class.extract_video_id(url.to_s.strip)
|
|
19
|
-
return failure(nil, 'Invalid YouTube URL') unless video_id
|
|
20
|
-
|
|
21
|
-
success("https://www.youtube.com/embed/#{video_id}")
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
# Extracts video ID from YouTube URL (used for thumbnails, etc.)
|
|
25
|
-
def self.extract_video_id(url_string)
|
|
26
|
-
return nil if url_string.blank?
|
|
27
|
-
|
|
28
|
-
PATTERNS.lazy.map { |pattern| url_string[pattern, 1] }.find(&:present?)
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
# Regex patterns to extract video IDs from various YouTube URL formats
|
|
32
|
-
# Supports: watch?v=, youtu.be, embed, shorts, live, and /v/ URLs
|
|
33
|
-
# All patterns capture alphanumeric characters, underscores, and hyphens as video IDs
|
|
34
|
-
PATTERNS = [
|
|
35
|
-
%r{youtube\.com/watch\?v=([a-zA-Z0-9_-]+)}, # Standard watch URL
|
|
36
|
-
%r{youtu\.be/([a-zA-Z0-9_-]+)}, # Shortened youtu.be URL
|
|
37
|
-
%r{youtube\.com/embed/([a-zA-Z0-9_-]+)}, # Already embedded URL
|
|
38
|
-
%r{youtube\.com/shorts/([a-zA-Z0-9_-]+)}, # YouTube Shorts
|
|
39
|
-
%r{youtube\.com/live/([a-zA-Z0-9_-]+)}, # YouTube Live
|
|
40
|
-
%r{youtube\.com/v/([a-zA-Z0-9_-]+)} # Legacy /v/ format
|
|
41
|
-
].freeze
|
|
42
|
-
end
|
|
43
|
-
end
|
|
44
|
-
end
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
# Compares Redis vote counters against cm_votes for a given voting session.
|
|
2
|
-
#
|
|
3
|
-
# Logs a mismatch via CmAppLogger when Redis and DB counts diverge.
|
|
4
|
-
# Use for post-session auditing or on-demand reconciliation.
|
|
5
|
-
#
|
|
6
|
-
# Usage:
|
|
7
|
-
# SpreeCmCommissioner::VoteCounters::AuditCounters.call(voting_session_id: id)
|
|
8
|
-
module SpreeCmCommissioner
|
|
9
|
-
module VoteCounters
|
|
10
|
-
class AuditCounters < Base
|
|
11
|
-
prepend ::Spree::ServiceModule::Base
|
|
12
|
-
|
|
13
|
-
def call(voting_session_id:)
|
|
14
|
-
@voting_session_id = voting_session_id
|
|
15
|
-
|
|
16
|
-
db_counts = SpreeCmCommissioner::Vote.for_session(voting_session_id)
|
|
17
|
-
.group(:contestant_id)
|
|
18
|
-
.sum(:quantity)
|
|
19
|
-
per_contestant_result = SpreeCmCommissioner::VoteCounters::PerContestantCounter
|
|
20
|
-
.call(voting_session_id: voting_session_id)
|
|
21
|
-
return failure(nil, per_contestant_result.error.value) if per_contestant_result.failure?
|
|
22
|
-
|
|
23
|
-
redis_counts = per_contestant_result.value[:counts]
|
|
24
|
-
|
|
25
|
-
redis_counts.each do |contestant_id, redis_count|
|
|
26
|
-
db_count = db_counts[contestant_id] || 0
|
|
27
|
-
next if redis_count == db_count
|
|
28
|
-
|
|
29
|
-
CmAppLogger.log(
|
|
30
|
-
label: 'VoteCounter mismatch',
|
|
31
|
-
data: {
|
|
32
|
-
voting_session_id: voting_session_id,
|
|
33
|
-
contestant_id: contestant_id,
|
|
34
|
-
redis_count: redis_count,
|
|
35
|
-
db_count: db_count
|
|
36
|
-
}
|
|
37
|
-
)
|
|
38
|
-
end
|
|
39
|
-
success(nil)
|
|
40
|
-
end
|
|
41
|
-
end
|
|
42
|
-
end
|
|
43
|
-
end
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
module SpreeCmCommissioner
|
|
2
|
-
module VoteCounters
|
|
3
|
-
class Base
|
|
4
|
-
REDIS_KEY_TTL = 86_400 # 24 hours
|
|
5
|
-
|
|
6
|
-
attr_reader :voting_session_id
|
|
7
|
-
|
|
8
|
-
private
|
|
9
|
-
|
|
10
|
-
def vote_key(contestant_id)
|
|
11
|
-
"vote:session:#{voting_session_id}:contestant:#{contestant_id}"
|
|
12
|
-
end
|
|
13
|
-
|
|
14
|
-
def contestant_ids
|
|
15
|
-
@contestant_ids ||= voting_session.voting_contestants.pluck(:id)
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
def voting_session
|
|
19
|
-
@voting_session ||= SpreeCmCommissioner::VotingSession.find(voting_session_id)
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
def session_ttl
|
|
23
|
-
[(voting_session.closes_at - Time.current).to_i + 1.day.to_i, 1].max
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
def with_redis(&block)
|
|
27
|
-
SpreeCmCommissioner.voting_redis_pool.with(&block)
|
|
28
|
-
end
|
|
29
|
-
end
|
|
30
|
-
end
|
|
31
|
-
end
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
# Atomically increments a contestant's Redis vote counter.
|
|
2
|
-
#
|
|
3
|
-
# Uses a Lua script so INCRBY+EXPIRE is a single atomic round trip.
|
|
4
|
-
# Unique voter counts are read from cm_votes table, not tracked here.
|
|
5
|
-
#
|
|
6
|
-
# Usage:
|
|
7
|
-
# SpreeCmCommissioner::VoteCounters::Increment.call(
|
|
8
|
-
# voting_session_id: id,
|
|
9
|
-
# contestant_id: id,
|
|
10
|
-
# quantity: 3
|
|
11
|
-
# )
|
|
12
|
-
module SpreeCmCommissioner
|
|
13
|
-
module VoteCounters
|
|
14
|
-
class Increment < Base
|
|
15
|
-
prepend ::Spree::ServiceModule::Base
|
|
16
|
-
|
|
17
|
-
def call(voting_session_id:, contestant_id:, quantity: 1)
|
|
18
|
-
@voting_session_id = voting_session_id
|
|
19
|
-
|
|
20
|
-
with_redis do |redis|
|
|
21
|
-
redis.eval(
|
|
22
|
-
increment_script,
|
|
23
|
-
keys: [vote_key(contestant_id)],
|
|
24
|
-
argv: [quantity, session_ttl]
|
|
25
|
-
)
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
success(nil)
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
private
|
|
32
|
-
|
|
33
|
-
# INCRBY + EXPIRE in one atomic round trip.
|
|
34
|
-
# TTL is closes_at + 1 day so the key outlives the session and cleans up naturally.
|
|
35
|
-
def increment_script
|
|
36
|
-
<<~LUA
|
|
37
|
-
local new_count = redis.call('INCRBY', KEYS[1], tonumber(ARGV[1]))
|
|
38
|
-
redis.call('EXPIRE', KEYS[1], tonumber(ARGV[2]))
|
|
39
|
-
return new_count
|
|
40
|
-
LUA
|
|
41
|
-
end
|
|
42
|
-
end
|
|
43
|
-
end
|
|
44
|
-
end
|
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
# Fetches vote counts from Redis and unique voter counts from the database.
|
|
2
|
-
#
|
|
3
|
-
# Returns a Result whose value is:
|
|
4
|
-
# {
|
|
5
|
-
# counts: { contestant_id => Integer },
|
|
6
|
-
# unique_voters: Integer,
|
|
7
|
-
# unique_voters_per_contestant: { contestant_id => Integer }
|
|
8
|
-
# }
|
|
9
|
-
#
|
|
10
|
-
# Vote counts come from Redis (fast, real-time leaderboard).
|
|
11
|
-
# Unique voter counts come from cm_votes (accurate, no Redis set overhead).
|
|
12
|
-
#
|
|
13
|
-
# Usage:
|
|
14
|
-
# SpreeCmCommissioner::VoteCounters::PerContestantCounter.call(voting_session_id: id).value
|
|
15
|
-
module SpreeCmCommissioner
|
|
16
|
-
module VoteCounters
|
|
17
|
-
class PerContestantCounter < Base
|
|
18
|
-
prepend ::Spree::ServiceModule::Base
|
|
19
|
-
|
|
20
|
-
EMPTY_RESULT = {
|
|
21
|
-
counts: {}.freeze,
|
|
22
|
-
unique_voters: 0,
|
|
23
|
-
unique_voters_per_contestant: {}.freeze
|
|
24
|
-
}.freeze
|
|
25
|
-
|
|
26
|
-
def call(voting_session_id:)
|
|
27
|
-
@voting_session_id = voting_session_id
|
|
28
|
-
|
|
29
|
-
ids = contestant_ids
|
|
30
|
-
return success(EMPTY_RESULT) if ids.empty?
|
|
31
|
-
|
|
32
|
-
counts = with_redis do |redis|
|
|
33
|
-
values = redis.mget(*ids.map { |id| vote_key(id) })
|
|
34
|
-
ids.zip(values).to_h { |id, v| [id, v.to_i] }
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
success(
|
|
38
|
-
counts: counts,
|
|
39
|
-
unique_voters: session_unique_voter_count,
|
|
40
|
-
unique_voters_per_contestant: contestant_unique_voter_counts(ids)
|
|
41
|
-
)
|
|
42
|
-
rescue StandardError => e
|
|
43
|
-
failure(nil, e.message)
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
private
|
|
47
|
-
|
|
48
|
-
def session_unique_voter_count
|
|
49
|
-
SpreeCmCommissioner::Vote
|
|
50
|
-
.where(voting_session_id: voting_session_id)
|
|
51
|
-
.where.not(user_id: nil)
|
|
52
|
-
.distinct
|
|
53
|
-
.count(:user_id)
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
def contestant_unique_voter_counts(ids)
|
|
57
|
-
rows = SpreeCmCommissioner::Vote
|
|
58
|
-
.where(voting_session_id: voting_session_id, contestant_id: ids)
|
|
59
|
-
.where.not(user_id: nil)
|
|
60
|
-
.group(:contestant_id)
|
|
61
|
-
.distinct
|
|
62
|
-
.count(:user_id)
|
|
63
|
-
|
|
64
|
-
ids.index_with { |id| rows.fetch(id, 0) }
|
|
65
|
-
end
|
|
66
|
-
end
|
|
67
|
-
end
|
|
68
|
-
end
|
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
# Rebuilds Redis vote counters from cm_votes for a given voting session.
|
|
2
|
-
#
|
|
3
|
-
# Overwrites existing Redis keys with DB totals — use to recover after
|
|
4
|
-
# a Redis restart or counter drift.
|
|
5
|
-
#
|
|
6
|
-
# Usage:
|
|
7
|
-
# SpreeCmCommissioner::VoteCounters::RebuildFromDb.call(voting_session_id: id)
|
|
8
|
-
module SpreeCmCommissioner
|
|
9
|
-
module VoteCounters
|
|
10
|
-
class RebuildFromDb < Base
|
|
11
|
-
prepend ::Spree::ServiceModule::Base
|
|
12
|
-
|
|
13
|
-
def call(voting_session_id:, ttl: nil)
|
|
14
|
-
@voting_session_id = voting_session_id
|
|
15
|
-
@ttl = ttl
|
|
16
|
-
|
|
17
|
-
rebuild_counts
|
|
18
|
-
|
|
19
|
-
success(nil)
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
private
|
|
23
|
-
|
|
24
|
-
# Rebuilds vote count keys for all contestants in the session.
|
|
25
|
-
#
|
|
26
|
-
# Queries VotingContestant (not Vote) so contestants with 0 DB votes are
|
|
27
|
-
# included — their stale Redis keys get DELed instead of left behind.
|
|
28
|
-
# Uses a single Lua EVAL so the entire operation is atomic: concurrent
|
|
29
|
-
# Increment calls cannot interleave between the DEL and SET commands.
|
|
30
|
-
def rebuild_counts
|
|
31
|
-
all_ids = SpreeCmCommissioner::VotingContestant
|
|
32
|
-
.where(voting_session_id: @voting_session_id)
|
|
33
|
-
.pluck(:id)
|
|
34
|
-
return if all_ids.empty?
|
|
35
|
-
|
|
36
|
-
db_counts = SpreeCmCommissioner::Vote.for_session(@voting_session_id)
|
|
37
|
-
.group(:contestant_id)
|
|
38
|
-
.sum(:quantity)
|
|
39
|
-
|
|
40
|
-
keys = all_ids.map { |id| vote_key(id) }
|
|
41
|
-
counts = all_ids.map { |id| db_counts[id].to_i }
|
|
42
|
-
|
|
43
|
-
with_redis do |redis|
|
|
44
|
-
redis.eval(rebuild_counts_script, keys: keys, argv: counts + [@ttl])
|
|
45
|
-
end
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
# Atomically writes DB vote counts to Redis for all contestants.
|
|
49
|
-
# SET count + TTL for non-zero counts; DEL for zero-count contestants
|
|
50
|
-
# so stale keys from deleted votes are fully cleared.
|
|
51
|
-
def rebuild_counts_script
|
|
52
|
-
<<~LUA
|
|
53
|
-
local ttl = tonumber(ARGV[#ARGV])
|
|
54
|
-
for i, key in ipairs(KEYS) do
|
|
55
|
-
local count = tonumber(ARGV[i])
|
|
56
|
-
if count > 0 then
|
|
57
|
-
if ttl then
|
|
58
|
-
redis.call('SET', key, count, 'EX', ttl)
|
|
59
|
-
else
|
|
60
|
-
redis.call('SET', key, count)
|
|
61
|
-
end
|
|
62
|
-
else
|
|
63
|
-
redis.call('DEL', key)
|
|
64
|
-
end
|
|
65
|
-
end
|
|
66
|
-
LUA
|
|
67
|
-
end
|
|
68
|
-
end
|
|
69
|
-
end
|
|
70
|
-
end
|