Admin Control Panel: - Add full AdminControl.vue with 3 sections (Voting, Lottery, Global) - Add AdminLogin.vue with access code gate (20268888) - Add admin.ts store with state persistence - Add admin.types.ts with state machine types - Add router guards for /admin/director-console Voting System Fixes: - Add voting status check before accepting votes (VOTING_CLOSED error) - Fix client to display server error messages - Fix button disabled logic to prevent ambiguity in paused state - Auto-generate userId on connect to fix UNAUTHORIZED error Big Screen Enhancements: - Add LiveVotingView.vue with particle system - Add LotteryMachine.ts with 3-stage animation (Galaxy/Storm/Reveal) - Add useSocketClient.ts composable - Fix MainDisplay.vue SCSS syntax error - Add admin state sync listener in display store Server Updates: - Add admin.service.ts for state management - Add isVotingOpen() and getVotingStatus() methods - Add admin socket event handlers Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
130 lines
4.0 KiB
Lua
130 lines
4.0 KiB
Lua
-- cast_vote.lua
|
|
-- Atomic vote casting with 7-ticket system
|
|
--
|
|
-- Business Rules:
|
|
-- 1. Each user has 7 distinct tickets (creative, visual, etc.)
|
|
-- 2. Each ticket can only be assigned to ONE program
|
|
-- 3. A user can only give ONE ticket to each program (no multi-ticket to same program)
|
|
-- 4. Supports revoke: if user already used this ticket, revoke old vote first
|
|
--
|
|
-- KEYS[1] = vote:user:{userId}:tickets (Hash)
|
|
-- KEYS[2] = vote:user:{userId}:programs (Set)
|
|
-- KEYS[3] = vote:count:{programId}:{ticketType} (String counter)
|
|
-- KEYS[4] = vote:leaderboard:{ticketType} (Sorted Set)
|
|
-- KEYS[5] = vote:program:{programId}:voters (Set)
|
|
-- KEYS[6] = vote:sync:queue (List)
|
|
-- KEYS[7] = vote:lock:{userId} (String)
|
|
--
|
|
-- ARGV[1] = userId
|
|
-- ARGV[2] = programId
|
|
-- ARGV[3] = ticketType
|
|
-- ARGV[4] = timestamp
|
|
-- ARGV[5] = lockTtlMs
|
|
|
|
local user_tickets_key = KEYS[1]
|
|
local user_programs_key = KEYS[2]
|
|
local count_key = KEYS[3]
|
|
local leaderboard_key = KEYS[4]
|
|
local program_voters_key = KEYS[5]
|
|
local sync_queue_key = KEYS[6]
|
|
local lock_key = KEYS[7]
|
|
|
|
local user_id = ARGV[1]
|
|
local program_id = ARGV[2]
|
|
local ticket_type = ARGV[3]
|
|
local timestamp = ARGV[4]
|
|
local lock_ttl = tonumber(ARGV[5])
|
|
|
|
-- Step 1: Acquire distributed lock
|
|
local lock_acquired = redis.call('SET', lock_key, timestamp, 'NX', 'PX', lock_ttl)
|
|
if not lock_acquired then
|
|
return cjson.encode({
|
|
success = false,
|
|
error = 'LOCK_FAILED',
|
|
message = 'Another vote operation in progress'
|
|
})
|
|
end
|
|
|
|
-- Step 2: Check if user already voted for this program with ANY ticket
|
|
local already_voted_program = redis.call('SISMEMBER', user_programs_key, program_id)
|
|
local current_ticket_program = redis.call('HGET', user_tickets_key, ticket_type)
|
|
|
|
-- Case: User trying to vote same program with same ticket (no-op)
|
|
if current_ticket_program == program_id then
|
|
redis.call('DEL', lock_key)
|
|
return cjson.encode({
|
|
success = true,
|
|
message = 'Already voted for this program with this ticket',
|
|
program_id = program_id,
|
|
ticket_type = ticket_type,
|
|
is_duplicate = true
|
|
})
|
|
end
|
|
|
|
-- Case: User already voted for this program with a DIFFERENT ticket
|
|
if already_voted_program == 1 and current_ticket_program ~= program_id then
|
|
redis.call('DEL', lock_key)
|
|
return cjson.encode({
|
|
success = false,
|
|
error = 'ALREADY_VOTED_PROGRAM',
|
|
message = 'You already voted for this program with another ticket'
|
|
})
|
|
end
|
|
|
|
-- Step 3: If this ticket was used before, revoke the old vote
|
|
local old_program_id = current_ticket_program
|
|
local revoked = false
|
|
|
|
if old_program_id and old_program_id ~= false then
|
|
-- Decrement old program's count
|
|
local old_count_key = 'vote:count:' .. old_program_id .. ':' .. ticket_type
|
|
local old_leaderboard_key = 'vote:leaderboard:' .. ticket_type
|
|
local old_voters_key = 'vote:program:' .. old_program_id .. ':voters'
|
|
|
|
redis.call('DECR', old_count_key)
|
|
redis.call('ZINCRBY', old_leaderboard_key, -1, old_program_id)
|
|
redis.call('SREM', old_voters_key, user_id)
|
|
redis.call('SREM', user_programs_key, old_program_id)
|
|
|
|
revoked = true
|
|
end
|
|
|
|
-- Step 4: Cast the new vote
|
|
-- 4a: Set the ticket assignment
|
|
redis.call('HSET', user_tickets_key, ticket_type, program_id)
|
|
|
|
-- 4b: Add program to user's voted programs
|
|
redis.call('SADD', user_programs_key, program_id)
|
|
|
|
-- 4c: Increment vote count
|
|
local new_count = redis.call('INCR', count_key)
|
|
|
|
-- 4d: Update leaderboard
|
|
redis.call('ZINCRBY', leaderboard_key, 1, program_id)
|
|
|
|
-- 4e: Add user to program's voters
|
|
redis.call('SADD', program_voters_key, user_id)
|
|
|
|
-- Step 5: Queue for MySQL sync
|
|
local vote_record = cjson.encode({
|
|
action = 'cast',
|
|
user_id = user_id,
|
|
program_id = program_id,
|
|
ticket_type = ticket_type,
|
|
timestamp = timestamp,
|
|
revoked_program = old_program_id or nil
|
|
})
|
|
redis.call('RPUSH', sync_queue_key, vote_record)
|
|
|
|
-- Step 6: Release lock
|
|
redis.call('DEL', lock_key)
|
|
|
|
return cjson.encode({
|
|
success = true,
|
|
program_id = program_id,
|
|
ticket_type = ticket_type,
|
|
new_count = new_count,
|
|
revoked = revoked,
|
|
revoked_program = old_program_id or nil
|
|
})
|