Files
company-celebration/packages/server/src/lua/cast_vote.lua
empty 30cd29d45d feat: add Admin Control Panel, voting status check, and router security
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>
2026-01-15 15:34:37 +08:00

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
})