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:
empty
2026-01-15 15:34:37 +08:00
parent e7397d22a9
commit 30cd29d45d
45 changed files with 7791 additions and 715 deletions

View File

@@ -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);

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

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

View 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();

View 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();

View 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();

View File

@@ -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());
}