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>
This commit is contained in:
@@ -4,6 +4,7 @@ import { config } from './config';
|
||||
import { connectRedis } from './config/redis';
|
||||
import { initializeSocket } from './socket';
|
||||
import { loadLuaScripts } from './services/vote.service';
|
||||
import { loadVotingScripts } from './services/voting.engine';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
try {
|
||||
@@ -14,6 +15,7 @@ async function main(): Promise<void> {
|
||||
// Load Lua scripts
|
||||
logger.info('Loading Lua scripts...');
|
||||
await loadLuaScripts();
|
||||
await loadVotingScripts();
|
||||
|
||||
// Create HTTP server
|
||||
const httpServer = createServer(app);
|
||||
|
||||
129
packages/server/src/lua/cast_vote.lua
Normal file
129
packages/server/src/lua/cast_vote.lua
Normal file
@@ -0,0 +1,129 @@
|
||||
-- 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
|
||||
})
|
||||
75
packages/server/src/lua/revoke_vote.lua
Normal file
75
packages/server/src/lua/revoke_vote.lua
Normal file
@@ -0,0 +1,75 @@
|
||||
-- revoke_vote.lua
|
||||
-- Atomic vote revocation
|
||||
--
|
||||
-- KEYS[1] = vote:user:{userId}:tickets (Hash)
|
||||
-- KEYS[2] = vote:user:{userId}:programs (Set)
|
||||
-- KEYS[3] = vote:sync:queue (List)
|
||||
-- KEYS[4] = vote:lock:{userId} (String)
|
||||
--
|
||||
-- ARGV[1] = userId
|
||||
-- ARGV[2] = ticketType
|
||||
-- ARGV[3] = timestamp
|
||||
-- ARGV[4] = lockTtlMs
|
||||
|
||||
local user_tickets_key = KEYS[1]
|
||||
local user_programs_key = KEYS[2]
|
||||
local sync_queue_key = KEYS[3]
|
||||
local lock_key = KEYS[4]
|
||||
|
||||
local user_id = ARGV[1]
|
||||
local ticket_type = ARGV[2]
|
||||
local timestamp = ARGV[3]
|
||||
local lock_ttl = tonumber(ARGV[4])
|
||||
|
||||
-- Step 1: Acquire 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'
|
||||
})
|
||||
end
|
||||
|
||||
-- Step 2: Check if ticket was used
|
||||
local program_id = redis.call('HGET', user_tickets_key, ticket_type)
|
||||
if not program_id or program_id == false then
|
||||
redis.call('DEL', lock_key)
|
||||
return cjson.encode({
|
||||
success = false,
|
||||
error = 'TICKET_NOT_USED',
|
||||
message = 'This ticket has not been used'
|
||||
})
|
||||
end
|
||||
|
||||
-- Step 3: Revoke the vote
|
||||
local count_key = 'vote:count:' .. program_id .. ':' .. ticket_type
|
||||
local leaderboard_key = 'vote:leaderboard:' .. ticket_type
|
||||
local voters_key = 'vote:program:' .. program_id .. ':voters'
|
||||
|
||||
-- Decrement count
|
||||
redis.call('DECR', count_key)
|
||||
redis.call('ZINCRBY', leaderboard_key, -1, program_id)
|
||||
|
||||
-- Remove from sets
|
||||
redis.call('SREM', voters_key, user_id)
|
||||
redis.call('SREM', user_programs_key, program_id)
|
||||
redis.call('HDEL', user_tickets_key, ticket_type)
|
||||
|
||||
-- Step 4: Queue for sync
|
||||
local record = cjson.encode({
|
||||
action = 'revoke',
|
||||
user_id = user_id,
|
||||
program_id = program_id,
|
||||
ticket_type = ticket_type,
|
||||
timestamp = timestamp
|
||||
})
|
||||
redis.call('RPUSH', sync_queue_key, record)
|
||||
|
||||
-- Step 5: Release lock
|
||||
redis.call('DEL', lock_key)
|
||||
|
||||
return cjson.encode({
|
||||
success = true,
|
||||
revoked_program = program_id,
|
||||
ticket_type = ticket_type
|
||||
})
|
||||
333
packages/server/src/services/admin.service.ts
Normal file
333
packages/server/src/services/admin.service.ts
Normal file
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* Admin Service - Manages system phase, voting control, and lottery state
|
||||
*/
|
||||
import { redis } from '../config/redis';
|
||||
import { logger } from '../utils/logger';
|
||||
import type {
|
||||
AdminState,
|
||||
SystemPhase,
|
||||
VotingControlPayload,
|
||||
LotteryControlPayload,
|
||||
EmergencyResetPayload,
|
||||
MusicControlPayload,
|
||||
LotteryRound,
|
||||
LotteryWinner,
|
||||
} from '@gala/shared/types';
|
||||
import { INITIAL_ADMIN_STATE, PRIZE_CONFIG } from '@gala/shared/types';
|
||||
|
||||
const ADMIN_STATE_KEY = 'gala:admin:state';
|
||||
|
||||
class AdminService {
|
||||
private state: AdminState;
|
||||
|
||||
constructor() {
|
||||
this.state = { ...INITIAL_ADMIN_STATE };
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize admin state from Redis or use default
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
try {
|
||||
const savedState = await redis.get(ADMIN_STATE_KEY);
|
||||
if (savedState) {
|
||||
this.state = JSON.parse(savedState);
|
||||
logger.info('Admin state restored from Redis');
|
||||
} else {
|
||||
await this.saveState();
|
||||
logger.info('Admin state initialized with defaults');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ error }, 'Failed to initialize admin state');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save current state to Redis
|
||||
*/
|
||||
private async saveState(): Promise<void> {
|
||||
this.state.lastUpdated = Date.now();
|
||||
await redis.set(ADMIN_STATE_KEY, JSON.stringify(this.state));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current admin state
|
||||
*/
|
||||
getState(): AdminState {
|
||||
return { ...this.state };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if voting is currently open
|
||||
*/
|
||||
isVotingOpen(): boolean {
|
||||
return this.state.voting.subPhase === 'OPEN';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get voting status for error messages
|
||||
*/
|
||||
getVotingStatus(): { isOpen: boolean; reason: string } {
|
||||
const subPhase = this.state.voting.subPhase;
|
||||
if (subPhase === 'OPEN') {
|
||||
return { isOpen: true, reason: '' };
|
||||
} else if (subPhase === 'PAUSED') {
|
||||
return { isOpen: false, reason: '投票已暂停' };
|
||||
} else {
|
||||
return { isOpen: false, reason: '投票通道已关闭' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Change system phase
|
||||
*/
|
||||
async setPhase(phase: SystemPhase): Promise<{ success: boolean; message?: string }> {
|
||||
try {
|
||||
this.state.systemPhase = phase;
|
||||
await this.saveState();
|
||||
logger.info({ phase }, 'System phase changed');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error({ error, phase }, 'Failed to change phase');
|
||||
return { success: false, message: 'Failed to change phase' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Control voting state
|
||||
*/
|
||||
async controlVoting(action: VotingControlPayload['action']): Promise<{ success: boolean; message?: string }> {
|
||||
try {
|
||||
switch (action) {
|
||||
case 'open':
|
||||
if (this.state.voting.subPhase === 'OPEN') {
|
||||
return { success: false, message: 'Voting is already open' };
|
||||
}
|
||||
this.state.voting.subPhase = 'OPEN';
|
||||
this.state.voting.openedAt = Date.now();
|
||||
break;
|
||||
case 'close':
|
||||
if (this.state.voting.subPhase === 'CLOSED') {
|
||||
return { success: false, message: 'Voting is already closed' };
|
||||
}
|
||||
this.state.voting.subPhase = 'CLOSED';
|
||||
this.state.voting.closedAt = Date.now();
|
||||
break;
|
||||
case 'pause':
|
||||
if (this.state.voting.subPhase !== 'OPEN') {
|
||||
return { success: false, message: 'Voting is not open' };
|
||||
}
|
||||
this.state.voting.subPhase = 'PAUSED';
|
||||
break;
|
||||
case 'resume':
|
||||
if (this.state.voting.subPhase !== 'PAUSED') {
|
||||
return { success: false, message: 'Voting is not paused' };
|
||||
}
|
||||
this.state.voting.subPhase = 'OPEN';
|
||||
break;
|
||||
}
|
||||
await this.saveState();
|
||||
logger.info({ action, subPhase: this.state.voting.subPhase }, 'Voting control action');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error({ error, action }, 'Failed to control voting');
|
||||
return { success: false, message: 'Failed to control voting' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Control lottery state machine
|
||||
*/
|
||||
async controlLottery(
|
||||
action: LotteryControlPayload['action'],
|
||||
round?: LotteryRound
|
||||
): Promise<{ success: boolean; message?: string; data?: { winners?: LotteryWinner[] } }> {
|
||||
try {
|
||||
switch (action) {
|
||||
case 'start_galaxy':
|
||||
if (this.state.lottery.subPhase !== 'IDLE') {
|
||||
return { success: false, message: 'Lottery is not in IDLE state' };
|
||||
}
|
||||
this.state.lottery.subPhase = 'GALAXY';
|
||||
this.state.lottery.startedAt = Date.now();
|
||||
break;
|
||||
|
||||
case 'start_storm':
|
||||
if (this.state.lottery.subPhase !== 'GALAXY') {
|
||||
return { success: false, message: 'Must be in GALAXY state to start storm' };
|
||||
}
|
||||
this.state.lottery.subPhase = 'STORM';
|
||||
this.state.lottery.stormStartedAt = Date.now();
|
||||
break;
|
||||
|
||||
case 'stop_reveal':
|
||||
if (this.state.lottery.subPhase !== 'STORM') {
|
||||
return { success: false, message: 'Must be in STORM state to reveal' };
|
||||
}
|
||||
// Pick random winners (simplified - in production would use database)
|
||||
const winners = this.pickRandomWinners();
|
||||
this.state.lottery.subPhase = 'REVEAL';
|
||||
this.state.lottery.currentWinners = winners;
|
||||
// After reveal animation, set to COMPLETE
|
||||
setTimeout(() => {
|
||||
this.state.lottery.subPhase = 'COMPLETE';
|
||||
this.saveState();
|
||||
}, 3000);
|
||||
await this.saveState();
|
||||
return { success: true, data: { winners } };
|
||||
|
||||
case 'next_round':
|
||||
if (this.state.lottery.subPhase !== 'COMPLETE') {
|
||||
return { success: false, message: 'Current round not complete' };
|
||||
}
|
||||
if (this.state.lottery.round >= 4) {
|
||||
return { success: false, message: 'Already at final round' };
|
||||
}
|
||||
const nextRound = (this.state.lottery.round + 1) as LotteryRound;
|
||||
const prizeConfig = PRIZE_CONFIG.find(p => p.round === nextRound);
|
||||
this.state.lottery.round = nextRound;
|
||||
this.state.lottery.subPhase = 'IDLE';
|
||||
this.state.lottery.currentWinners = [];
|
||||
this.state.lottery.stormStartedAt = undefined;
|
||||
if (prizeConfig) {
|
||||
this.state.lottery.prizeLevel = prizeConfig.level;
|
||||
this.state.lottery.prizeName = prizeConfig.name;
|
||||
this.state.lottery.winnerCount = prizeConfig.winnerCount;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'reset':
|
||||
this.state.lottery = {
|
||||
round: 1,
|
||||
subPhase: 'IDLE',
|
||||
prizeLevel: '特等奖',
|
||||
prizeName: 'iPhone 16 Pro Max',
|
||||
winnerCount: 1,
|
||||
currentWinners: [],
|
||||
};
|
||||
break;
|
||||
}
|
||||
await this.saveState();
|
||||
logger.info({ action, lotteryState: this.state.lottery }, 'Lottery control action');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error({ error, action }, 'Failed to control lottery');
|
||||
return { success: false, message: 'Failed to control lottery' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick random winners (simplified implementation)
|
||||
*/
|
||||
private pickRandomWinners(): LotteryWinner[] {
|
||||
const prizeConfig = PRIZE_CONFIG.find(p => p.round === this.state.lottery.round);
|
||||
const count = prizeConfig?.winnerCount || 1;
|
||||
|
||||
// Demo winners for testing
|
||||
const demoNames = [
|
||||
{ id: 'w1', name: '张明', department: '技术部' },
|
||||
{ id: 'w2', name: '李华', department: '市场部' },
|
||||
{ id: 'w3', name: '王强', department: '产品部' },
|
||||
{ id: 'w4', name: '赵丽', department: '设计部' },
|
||||
{ id: 'w5', name: '刘伟', department: '运营部' },
|
||||
{ id: 'w6', name: '陈晨', department: '财务部' },
|
||||
{ id: 'w7', name: '周杰', department: '人事部' },
|
||||
{ id: 'w8', name: '吴欣', department: '销售部' },
|
||||
{ id: 'w9', name: '郑雪', department: '技术部' },
|
||||
{ id: 'w10', name: '孙浩', department: '市场部' },
|
||||
];
|
||||
|
||||
// Shuffle and pick
|
||||
const shuffled = [...demoNames].sort(() => Math.random() - 0.5);
|
||||
return shuffled.slice(0, count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emergency reset
|
||||
*/
|
||||
async emergencyReset(
|
||||
scope: EmergencyResetPayload['scope'],
|
||||
confirmCode: string
|
||||
): Promise<{ success: boolean; message?: string }> {
|
||||
if (confirmCode !== 'RESET') {
|
||||
return { success: false, message: 'Invalid confirmation code' };
|
||||
}
|
||||
|
||||
try {
|
||||
if (scope === 'all' || scope === 'voting') {
|
||||
this.state.voting = {
|
||||
subPhase: 'CLOSED',
|
||||
totalVotes: 0,
|
||||
};
|
||||
// Clear voting data in Redis
|
||||
await redis.del('gala:votes:*');
|
||||
}
|
||||
|
||||
if (scope === 'all' || scope === 'lottery') {
|
||||
this.state.lottery = {
|
||||
round: 1,
|
||||
subPhase: 'IDLE',
|
||||
prizeLevel: '特等奖',
|
||||
prizeName: 'iPhone 16 Pro Max',
|
||||
winnerCount: 1,
|
||||
currentWinners: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (scope === 'all') {
|
||||
this.state.systemPhase = 'IDLE';
|
||||
this.state.music = {
|
||||
isPlaying: false,
|
||||
track: 'none',
|
||||
volume: 0.7,
|
||||
};
|
||||
}
|
||||
|
||||
await this.saveState();
|
||||
logger.warn({ scope }, 'Emergency reset executed');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error({ error, scope }, 'Failed to execute emergency reset');
|
||||
return { success: false, message: 'Failed to execute emergency reset' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Control music
|
||||
*/
|
||||
async controlMusic(
|
||||
action: MusicControlPayload['action'],
|
||||
track?: MusicControlPayload['track']
|
||||
): Promise<{ success: boolean; message?: string }> {
|
||||
try {
|
||||
switch (action) {
|
||||
case 'play':
|
||||
this.state.music.isPlaying = true;
|
||||
if (track) this.state.music.track = track;
|
||||
break;
|
||||
case 'stop':
|
||||
this.state.music.isPlaying = false;
|
||||
break;
|
||||
case 'change_track':
|
||||
if (track) this.state.music.track = track;
|
||||
break;
|
||||
}
|
||||
await this.saveState();
|
||||
logger.info({ action, musicState: this.state.music }, 'Music control action');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error({ error, action }, 'Failed to control music');
|
||||
return { success: false, message: 'Failed to control music' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update total vote count
|
||||
*/
|
||||
async updateTotalVotes(count: number): Promise<void> {
|
||||
this.state.voting.totalVotes = count;
|
||||
await this.saveState();
|
||||
}
|
||||
}
|
||||
|
||||
export const adminService = new AdminService();
|
||||
227
packages/server/src/services/draw.engine.ts
Normal file
227
packages/server/src/services/draw.engine.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import { redis } from '../config/redis';
|
||||
import { logger } from '../utils/logger';
|
||||
import {
|
||||
GENERATION_TAGS,
|
||||
ZODIAC_TAGS,
|
||||
DRAW_KEYS,
|
||||
type UserTag,
|
||||
type GenerationTag,
|
||||
type ZodiacTag,
|
||||
} from '@gala/shared/constants';
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
interface UserWithTags {
|
||||
userId: string;
|
||||
birthDate: string;
|
||||
tags: UserTag[];
|
||||
}
|
||||
|
||||
interface DrawResult {
|
||||
success: boolean;
|
||||
winners?: WinnerInfo[];
|
||||
error?: string;
|
||||
poolSize?: number;
|
||||
requestedCount?: number;
|
||||
}
|
||||
|
||||
interface WinnerInfo {
|
||||
userId: string;
|
||||
drawnAt: number;
|
||||
round: number;
|
||||
poolTag: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tag Calculation
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Calculate user tags based on birth date
|
||||
*
|
||||
* Generation Tags:
|
||||
* - TAG_6070: 1960 <= Year <= 1979
|
||||
* - TAG_80: 1980 <= Year <= 1989
|
||||
* - TAG_90: Year >= 1990
|
||||
*
|
||||
* Zodiac Tag (Year of the Horse):
|
||||
* - (Year - 1906) % 12 === 0
|
||||
* - Matches: 1966, 1978, 1990, 2002, 2014...
|
||||
*/
|
||||
export function calculateUserTags(birthDate: string): UserTag[] {
|
||||
const tags: UserTag[] = [];
|
||||
|
||||
// Parse birth year
|
||||
const year = parseInt(birthDate.substring(0, 4), 10);
|
||||
if (isNaN(year)) {
|
||||
logger.warn({ birthDate }, 'Invalid birth date format');
|
||||
return tags;
|
||||
}
|
||||
|
||||
// Generation tag
|
||||
if (year >= 1960 && year <= 1979) {
|
||||
tags.push(GENERATION_TAGS.TAG_6070);
|
||||
} else if (year >= 1980 && year <= 1989) {
|
||||
tags.push(GENERATION_TAGS.TAG_80);
|
||||
} else if (year >= 1990) {
|
||||
tags.push(GENERATION_TAGS.TAG_90);
|
||||
}
|
||||
|
||||
// Zodiac tag: Year of the Horse
|
||||
// Horse years: 1906, 1918, 1930, 1942, 1954, 1966, 1978, 1990, 2002, 2014...
|
||||
if ((year - 1906) % 12 === 0) {
|
||||
tags.push(ZODIAC_TAGS.HORSE);
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DrawEngine Class
|
||||
// ============================================================================
|
||||
|
||||
export class DrawEngine {
|
||||
/**
|
||||
* Initialize pools from user data
|
||||
* Call this when importing users or on server start
|
||||
*/
|
||||
async initializePools(users: Array<{ userId: string; birthDate: string }>): Promise<void> {
|
||||
const pipeline = redis.pipeline();
|
||||
|
||||
// Clear existing pools
|
||||
const poolKeys = [
|
||||
DRAW_KEYS.pool(GENERATION_TAGS.TAG_6070),
|
||||
DRAW_KEYS.pool(GENERATION_TAGS.TAG_80),
|
||||
DRAW_KEYS.pool(GENERATION_TAGS.TAG_90),
|
||||
DRAW_KEYS.pool(ZODIAC_TAGS.HORSE),
|
||||
DRAW_KEYS.allPool,
|
||||
];
|
||||
for (const key of poolKeys) {
|
||||
pipeline.del(key);
|
||||
}
|
||||
|
||||
// Add users to pools
|
||||
for (const user of users) {
|
||||
const tags = calculateUserTags(user.birthDate);
|
||||
|
||||
// Add to all pool
|
||||
pipeline.sadd(DRAW_KEYS.allPool, user.userId);
|
||||
|
||||
// Add to tag-specific pools
|
||||
for (const tag of tags) {
|
||||
pipeline.sadd(DRAW_KEYS.pool(tag), user.userId);
|
||||
}
|
||||
}
|
||||
|
||||
await pipeline.exec();
|
||||
logger.info({ userCount: users.length }, 'Draw pools initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw winners from a pool
|
||||
*
|
||||
* @param poolTag - The pool to draw from (e.g., '6070', '80', '90', 'horse')
|
||||
* @param count - Number of winners to draw
|
||||
* @param round - Current round number (for tracking)
|
||||
* @param excludePreviousWinners - Whether to exclude users who already won
|
||||
*/
|
||||
async drawWinner(
|
||||
poolTag: string,
|
||||
count: number,
|
||||
round: number,
|
||||
excludePreviousWinners = true
|
||||
): Promise<DrawResult> {
|
||||
const poolKey = DRAW_KEYS.pool(poolTag);
|
||||
|
||||
// Step 1: Get eligible candidates (pool - winners - excluded)
|
||||
let eligibleKey = poolKey;
|
||||
|
||||
if (excludePreviousWinners) {
|
||||
// Create temp key with eligible users
|
||||
eligibleKey = `draw:temp:eligible:${Date.now()}`;
|
||||
|
||||
await redis.sdiffstore(
|
||||
eligibleKey,
|
||||
poolKey,
|
||||
DRAW_KEYS.winners,
|
||||
DRAW_KEYS.excluded
|
||||
);
|
||||
}
|
||||
|
||||
// Step 2: Check pool size
|
||||
const poolSize = await redis.scard(eligibleKey);
|
||||
|
||||
if (poolSize === 0) {
|
||||
if (eligibleKey !== poolKey) await redis.del(eligibleKey);
|
||||
return {
|
||||
success: false,
|
||||
error: 'EMPTY_POOL',
|
||||
poolSize: 0,
|
||||
requestedCount: count,
|
||||
};
|
||||
}
|
||||
|
||||
const actualCount = Math.min(count, poolSize);
|
||||
|
||||
// Step 3: Random draw using SRANDMEMBER
|
||||
const winners = await redis.srandmember(eligibleKey, actualCount);
|
||||
|
||||
if (eligibleKey !== poolKey) await redis.del(eligibleKey);
|
||||
|
||||
if (!winners || winners.length === 0) {
|
||||
return { success: false, error: 'DRAW_FAILED' };
|
||||
}
|
||||
|
||||
// Step 4: Record winners
|
||||
const winnerList = Array.isArray(winners) ? winners : [winners];
|
||||
const timestamp = Date.now();
|
||||
|
||||
const pipeline = redis.pipeline();
|
||||
const winnerInfos: WinnerInfo[] = [];
|
||||
|
||||
for (const odrawId of winnerList) {
|
||||
// Add to global winners set
|
||||
pipeline.sadd(DRAW_KEYS.winners, odrawId);
|
||||
|
||||
// Add to round-specific winners
|
||||
pipeline.sadd(DRAW_KEYS.roundWinners(round), odrawId);
|
||||
|
||||
// Record in history
|
||||
const record = JSON.stringify({
|
||||
odrawId,
|
||||
round,
|
||||
poolTag,
|
||||
drawnAt: timestamp,
|
||||
});
|
||||
pipeline.rpush(DRAW_KEYS.history, record);
|
||||
|
||||
winnerInfos.push({
|
||||
userId: odrawId,
|
||||
drawnAt: timestamp,
|
||||
round,
|
||||
poolTag,
|
||||
});
|
||||
}
|
||||
|
||||
await pipeline.exec();
|
||||
|
||||
logger.info({
|
||||
poolTag,
|
||||
round,
|
||||
requestedCount: count,
|
||||
actualCount: winnerList.length,
|
||||
winners: winnerList,
|
||||
}, 'Draw completed');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
winners: winnerInfos,
|
||||
poolSize,
|
||||
requestedCount: count,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const drawEngine = new DrawEngine();
|
||||
226
packages/server/src/services/voting.engine.ts
Normal file
226
packages/server/src/services/voting.engine.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { redis } from '../config/redis';
|
||||
import { logger } from '../utils/logger';
|
||||
import { VOTE_KEYS, TICKET_TYPES, VOTE_CONFIG, type TicketType } from '@gala/shared/constants';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
interface CastVoteResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
message?: string;
|
||||
program_id?: string;
|
||||
ticket_type?: string;
|
||||
new_count?: number;
|
||||
revoked?: boolean;
|
||||
revoked_program?: string;
|
||||
is_duplicate?: boolean;
|
||||
}
|
||||
|
||||
interface RevokeVoteResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
message?: string;
|
||||
revoked_program?: string;
|
||||
ticket_type?: string;
|
||||
}
|
||||
|
||||
interface UserVoteStatus {
|
||||
userId: string;
|
||||
tickets: Record<TicketType, string | null>;
|
||||
usedTickets: number;
|
||||
remainingTickets: number;
|
||||
}
|
||||
|
||||
interface ProgramRanking {
|
||||
programId: string;
|
||||
voteCount: number;
|
||||
rank: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Lua Script Management
|
||||
// ============================================================================
|
||||
|
||||
const luaScripts = {
|
||||
castVote: readFileSync(join(__dirname, '../lua/cast_vote.lua'), 'utf-8'),
|
||||
revokeVote: readFileSync(join(__dirname, '../lua/revoke_vote.lua'), 'utf-8'),
|
||||
};
|
||||
|
||||
let scriptShas: Record<string, string> = {};
|
||||
|
||||
export async function loadVotingScripts(): Promise<void> {
|
||||
try {
|
||||
const [castVoteSha, revokeVoteSha] = await Promise.all([
|
||||
redis.script('LOAD', luaScripts.castVote),
|
||||
redis.script('LOAD', luaScripts.revokeVote),
|
||||
]);
|
||||
|
||||
scriptShas = {
|
||||
castVote: castVoteSha as string,
|
||||
revokeVote: revokeVoteSha as string,
|
||||
};
|
||||
|
||||
logger.info('Voting Lua scripts loaded');
|
||||
} catch (error) {
|
||||
logger.error({ error }, 'Failed to load voting scripts');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// VotingEngine Class
|
||||
// ============================================================================
|
||||
|
||||
export class VotingEngine {
|
||||
/**
|
||||
* Cast a vote: assign a ticket to a program
|
||||
*
|
||||
* Rules enforced atomically:
|
||||
* - Each ticket can only go to ONE program
|
||||
* - Each program can only receive ONE ticket from each user
|
||||
* - If ticket was used before, old vote is revoked first
|
||||
*/
|
||||
async castVote(
|
||||
userId: string,
|
||||
programId: string,
|
||||
ticketType: TicketType
|
||||
): Promise<CastVoteResult> {
|
||||
// Validate ticket type
|
||||
if (!TICKET_TYPES.includes(ticketType)) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'INVALID_TICKET_TYPE',
|
||||
message: `Invalid ticket type: ${ticketType}`,
|
||||
};
|
||||
}
|
||||
|
||||
const timestamp = Date.now().toString();
|
||||
|
||||
const keys = [
|
||||
VOTE_KEYS.userTickets(userId),
|
||||
VOTE_KEYS.userPrograms(userId),
|
||||
VOTE_KEYS.count(programId, ticketType),
|
||||
VOTE_KEYS.leaderboard(ticketType),
|
||||
VOTE_KEYS.programVoters(programId),
|
||||
VOTE_KEYS.syncQueue,
|
||||
VOTE_KEYS.lock(userId),
|
||||
];
|
||||
|
||||
const args = [
|
||||
userId,
|
||||
programId,
|
||||
ticketType,
|
||||
timestamp,
|
||||
VOTE_CONFIG.LOCK_TTL_MS.toString(),
|
||||
];
|
||||
|
||||
try {
|
||||
const resultJson = await redis.evalsha(
|
||||
scriptShas.castVote,
|
||||
keys.length,
|
||||
...keys,
|
||||
...args
|
||||
);
|
||||
|
||||
const result = JSON.parse(resultJson as string) as CastVoteResult;
|
||||
|
||||
if (result.success) {
|
||||
logger.info({
|
||||
userId,
|
||||
programId,
|
||||
ticketType,
|
||||
revoked: result.revoked,
|
||||
}, 'Vote cast successfully');
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error({ error, userId, programId, ticketType }, 'Cast vote error');
|
||||
return {
|
||||
success: false,
|
||||
error: 'INTERNAL_ERROR',
|
||||
message: 'Failed to cast vote',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a vote: remove ticket assignment
|
||||
*/
|
||||
async revokeVote(userId: string, ticketType: TicketType): Promise<RevokeVoteResult> {
|
||||
if (!TICKET_TYPES.includes(ticketType)) {
|
||||
return { success: false, error: 'INVALID_TICKET_TYPE' };
|
||||
}
|
||||
|
||||
const timestamp = Date.now().toString();
|
||||
const keys = [
|
||||
VOTE_KEYS.userTickets(userId),
|
||||
VOTE_KEYS.userPrograms(userId),
|
||||
VOTE_KEYS.syncQueue,
|
||||
VOTE_KEYS.lock(userId),
|
||||
];
|
||||
const args = [userId, ticketType, timestamp, VOTE_CONFIG.LOCK_TTL_MS.toString()];
|
||||
|
||||
try {
|
||||
const resultJson = await redis.evalsha(
|
||||
scriptShas.revokeVote,
|
||||
keys.length,
|
||||
...keys,
|
||||
...args
|
||||
);
|
||||
return JSON.parse(resultJson as string) as RevokeVoteResult;
|
||||
} catch (error) {
|
||||
logger.error({ error, userId, ticketType }, 'Revoke vote error');
|
||||
return { success: false, error: 'INTERNAL_ERROR' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's current vote status
|
||||
*/
|
||||
async getUserVoteStatus(userId: string): Promise<UserVoteStatus> {
|
||||
const ticketsData = await redis.hgetall(VOTE_KEYS.userTickets(userId));
|
||||
|
||||
const tickets: Record<TicketType, string | null> = {} as any;
|
||||
let usedTickets = 0;
|
||||
|
||||
for (const t of TICKET_TYPES) {
|
||||
tickets[t] = ticketsData[t] || null;
|
||||
if (ticketsData[t]) usedTickets++;
|
||||
}
|
||||
|
||||
return {
|
||||
userId,
|
||||
tickets,
|
||||
usedTickets,
|
||||
remainingTickets: VOTE_CONFIG.MAX_TICKETS - usedTickets,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get leaderboard for a ticket type
|
||||
*/
|
||||
async getLeaderboard(ticketType: TicketType, limit = 10): Promise<ProgramRanking[]> {
|
||||
const key = VOTE_KEYS.leaderboard(ticketType);
|
||||
const data = await redis.zrevrange(key, 0, limit - 1, 'WITHSCORES');
|
||||
|
||||
const rankings: ProgramRanking[] = [];
|
||||
for (let i = 0; i < data.length; i += 2) {
|
||||
rankings.push({
|
||||
programId: data[i],
|
||||
voteCount: parseInt(data[i + 1], 10),
|
||||
rank: Math.floor(i / 2) + 1,
|
||||
});
|
||||
}
|
||||
return rankings;
|
||||
}
|
||||
}
|
||||
|
||||
export const votingEngine = new VotingEngine();
|
||||
@@ -5,7 +5,9 @@ import { redis } from '../config/redis';
|
||||
import { config } from '../config';
|
||||
import { logger } from '../utils/logger';
|
||||
import { voteService } from '../services/vote.service';
|
||||
import { SOCKET_EVENTS, SOCKET_ROOMS } from '@gala/shared/constants';
|
||||
import { votingEngine } from '../services/voting.engine';
|
||||
import { adminService } from '../services/admin.service';
|
||||
import { SOCKET_EVENTS, SOCKET_ROOMS, TICKET_TYPES, type TicketType } from '@gala/shared/constants';
|
||||
import type {
|
||||
ServerToClientEvents,
|
||||
ClientToServerEvents,
|
||||
@@ -16,6 +18,12 @@ import type {
|
||||
AckCallback,
|
||||
VoteCategory,
|
||||
ConnectionAckPayload,
|
||||
PhaseChangePayload,
|
||||
VotingControlPayload,
|
||||
LotteryControlPayload,
|
||||
EmergencyResetPayload,
|
||||
MusicControlPayload,
|
||||
AdminState,
|
||||
} from '@gala/shared/types';
|
||||
|
||||
export type GalaSocket = Socket<ClientToServerEvents, ServerToClientEvents, InterServerEvents, SocketData>;
|
||||
@@ -45,6 +53,9 @@ export async function initializeSocket(httpServer: HttpServer): Promise<GalaServ
|
||||
|
||||
io.adapter(createAdapter(pubClient, subClient));
|
||||
|
||||
// Initialize admin service
|
||||
await adminService.initialize();
|
||||
|
||||
// Connection handler
|
||||
io.on('connection', handleConnection);
|
||||
|
||||
@@ -79,6 +90,31 @@ function handleConnection(socket: GalaSocket): void {
|
||||
handleSyncRequest(socket);
|
||||
});
|
||||
|
||||
// Admin events
|
||||
socket.on(SOCKET_EVENTS.ADMIN_PHASE_CHANGE as any, (data: PhaseChangePayload, callback: AckCallback<void>) => {
|
||||
handlePhaseChange(socket, data, callback);
|
||||
});
|
||||
|
||||
socket.on(SOCKET_EVENTS.ADMIN_VOTING_CONTROL as any, (data: VotingControlPayload, callback: AckCallback<void>) => {
|
||||
handleVotingControl(socket, data, callback);
|
||||
});
|
||||
|
||||
socket.on(SOCKET_EVENTS.ADMIN_LOTTERY_CONTROL as any, (data: LotteryControlPayload, callback: AckCallback<any>) => {
|
||||
handleLotteryControl(socket, data, callback);
|
||||
});
|
||||
|
||||
socket.on(SOCKET_EVENTS.ADMIN_MUSIC_CONTROL as any, (data: MusicControlPayload, callback: AckCallback<void>) => {
|
||||
handleMusicControl(socket, data, callback);
|
||||
});
|
||||
|
||||
socket.on(SOCKET_EVENTS.ADMIN_EMERGENCY_RESET as any, (data: EmergencyResetPayload, callback: AckCallback<void>) => {
|
||||
handleEmergencyReset(socket, data, callback);
|
||||
});
|
||||
|
||||
socket.on(SOCKET_EVENTS.ADMIN_STATE_SYNC as any, () => {
|
||||
handleAdminStateSync(socket);
|
||||
});
|
||||
|
||||
// Disconnect handler
|
||||
socket.on('disconnect', (reason) => {
|
||||
handleDisconnect(socket, reason);
|
||||
@@ -148,6 +184,7 @@ async function handleJoin(
|
||||
|
||||
/**
|
||||
* Handle vote submission via WebSocket
|
||||
* Supports both new TicketType system (7-ticket) and legacy VoteCategory system
|
||||
*/
|
||||
async function handleVoteSubmit(
|
||||
socket: GalaSocket,
|
||||
@@ -165,37 +202,86 @@ async function handleVoteSubmit(
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await voteService.submitVote(
|
||||
userId,
|
||||
data.category as VoteCategory,
|
||||
data.candidateId,
|
||||
data.localId
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
callback({
|
||||
success: false,
|
||||
error: result.error as any,
|
||||
message: result.message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Broadcast vote update to all clients
|
||||
io.to(SOCKET_ROOMS.ALL).emit(SOCKET_EVENTS.VOTE_UPDATED as any, {
|
||||
candidateId: data.candidateId,
|
||||
category: data.category,
|
||||
totalVotes: result.new_count!,
|
||||
delta: 1,
|
||||
});
|
||||
|
||||
// Check if voting is open
|
||||
const votingStatus = adminService.getVotingStatus();
|
||||
if (!votingStatus.isOpen) {
|
||||
callback({
|
||||
success: true,
|
||||
data: {
|
||||
newCount: result.new_count!,
|
||||
},
|
||||
success: false,
|
||||
error: 'VOTING_CLOSED',
|
||||
message: votingStatus.reason,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if this is a TicketType (new 7-ticket system) or VoteCategory (legacy)
|
||||
const category = data.category as string;
|
||||
const isTicketType = TICKET_TYPES.includes(category as TicketType);
|
||||
|
||||
if (isTicketType) {
|
||||
// Use new voting engine for 7-ticket system
|
||||
const result = await votingEngine.castVote(
|
||||
userId,
|
||||
data.candidateId,
|
||||
category as TicketType
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
callback({
|
||||
success: false,
|
||||
error: result.error as any,
|
||||
message: result.message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Broadcast vote update to all clients
|
||||
io.to(SOCKET_ROOMS.ALL).emit(SOCKET_EVENTS.VOTE_UPDATED as any, {
|
||||
candidateId: data.candidateId,
|
||||
category: category,
|
||||
totalVotes: result.new_count!,
|
||||
delta: 1,
|
||||
});
|
||||
|
||||
callback({
|
||||
success: true,
|
||||
data: {
|
||||
newCount: result.new_count!,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Use legacy vote service for VoteCategory system
|
||||
const result = await voteService.submitVote(
|
||||
userId,
|
||||
data.category as VoteCategory,
|
||||
data.candidateId,
|
||||
data.localId
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
callback({
|
||||
success: false,
|
||||
error: result.error as any,
|
||||
message: result.message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Broadcast vote update to all clients
|
||||
io.to(SOCKET_ROOMS.ALL).emit(SOCKET_EVENTS.VOTE_UPDATED as any, {
|
||||
candidateId: data.candidateId,
|
||||
category: data.category,
|
||||
totalVotes: result.new_count!,
|
||||
delta: 1,
|
||||
});
|
||||
|
||||
callback({
|
||||
success: true,
|
||||
data: {
|
||||
newCount: result.new_count!,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ socketId: socket.id, userId, error }, 'Vote submit error');
|
||||
callback({
|
||||
@@ -264,3 +350,129 @@ export function getIO(): GalaServer {
|
||||
}
|
||||
return io;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Admin Event Handlers
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Handle system phase change
|
||||
*/
|
||||
async function handlePhaseChange(
|
||||
socket: GalaSocket,
|
||||
data: PhaseChangePayload,
|
||||
callback: AckCallback<void>
|
||||
): Promise<void> {
|
||||
if (socket.data.role !== 'admin') {
|
||||
callback({ success: false, error: 'UNAUTHORIZED', message: 'Admin access required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await adminService.setPhase(data.phase);
|
||||
if (result.success) {
|
||||
// Broadcast state change to all clients
|
||||
io.to(SOCKET_ROOMS.ALL).emit(SOCKET_EVENTS.ADMIN_STATE_SYNC as any, adminService.getState());
|
||||
callback({ success: true });
|
||||
} else {
|
||||
callback({ success: false, message: result.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle voting control
|
||||
*/
|
||||
async function handleVotingControl(
|
||||
socket: GalaSocket,
|
||||
data: VotingControlPayload,
|
||||
callback: AckCallback<void>
|
||||
): Promise<void> {
|
||||
if (socket.data.role !== 'admin') {
|
||||
callback({ success: false, error: 'UNAUTHORIZED', message: 'Admin access required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await adminService.controlVoting(data.action);
|
||||
if (result.success) {
|
||||
// Broadcast state change to all clients
|
||||
io.to(SOCKET_ROOMS.ALL).emit(SOCKET_EVENTS.ADMIN_STATE_SYNC as any, adminService.getState());
|
||||
callback({ success: true });
|
||||
} else {
|
||||
callback({ success: false, message: result.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle lottery control
|
||||
*/
|
||||
async function handleLotteryControl(
|
||||
socket: GalaSocket,
|
||||
data: LotteryControlPayload,
|
||||
callback: AckCallback<any>
|
||||
): Promise<void> {
|
||||
if (socket.data.role !== 'admin') {
|
||||
callback({ success: false, error: 'UNAUTHORIZED', message: 'Admin access required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await adminService.controlLottery(data.action, data.round);
|
||||
if (result.success) {
|
||||
// Broadcast state change to all clients
|
||||
io.to(SOCKET_ROOMS.ALL).emit(SOCKET_EVENTS.ADMIN_STATE_SYNC as any, adminService.getState());
|
||||
callback({ success: true, data: result.data });
|
||||
} else {
|
||||
callback({ success: false, message: result.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle music control
|
||||
*/
|
||||
async function handleMusicControl(
|
||||
socket: GalaSocket,
|
||||
data: MusicControlPayload,
|
||||
callback: AckCallback<void>
|
||||
): Promise<void> {
|
||||
if (socket.data.role !== 'admin') {
|
||||
callback({ success: false, error: 'UNAUTHORIZED', message: 'Admin access required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await adminService.controlMusic(data.action, data.track);
|
||||
if (result.success) {
|
||||
// Broadcast state change to all clients
|
||||
io.to(SOCKET_ROOMS.ALL).emit(SOCKET_EVENTS.ADMIN_STATE_SYNC as any, adminService.getState());
|
||||
callback({ success: true });
|
||||
} else {
|
||||
callback({ success: false, message: result.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle emergency reset
|
||||
*/
|
||||
async function handleEmergencyReset(
|
||||
socket: GalaSocket,
|
||||
data: EmergencyResetPayload,
|
||||
callback: AckCallback<void>
|
||||
): Promise<void> {
|
||||
if (socket.data.role !== 'admin') {
|
||||
callback({ success: false, error: 'UNAUTHORIZED', message: 'Admin access required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await adminService.emergencyReset(data.scope, data.confirmCode);
|
||||
if (result.success) {
|
||||
// Broadcast state change to all clients
|
||||
io.to(SOCKET_ROOMS.ALL).emit(SOCKET_EVENTS.ADMIN_STATE_SYNC as any, adminService.getState());
|
||||
callback({ success: true });
|
||||
} else {
|
||||
callback({ success: false, message: result.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle admin state sync request
|
||||
*/
|
||||
function handleAdminStateSync(socket: GalaSocket): void {
|
||||
socket.emit(SOCKET_EVENTS.ADMIN_STATE_SYNC as any, adminService.getState());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user