修复投票计数与状态同步,完善票据与戳显示

修复投票系统:禁止重复投票、恢复状态、同步大屏

完善投票流程与展示:计数准确、状态可恢复、样式统一
This commit is contained in:
empty
2026-01-28 21:37:05 +08:00
parent 66ca67c137
commit d090c80e50
27 changed files with 1541 additions and 1023 deletions

View File

@@ -5,7 +5,7 @@
-- 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
-- 4. 禁止同一用户在同一奖项重复投票(不允许改投)
--
-- KEYS[1] = vote:user:{userId}:tickets (Hash)
-- KEYS[2] = vote:user:{userId}:programs (Set)
@@ -49,15 +49,13 @@ end
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
-- Case: User already used this ticket (no re-vote allowed)
if current_ticket_program and current_ticket_program ~= false 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
success = false,
error = 'ALREADY_VOTED',
message = 'You already used this ticket'
})
end
@@ -71,25 +69,7 @@ if already_voted_program == 1 and current_ticket_program ~= program_id then
})
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
-- Step 3: Cast the new vote
-- 4a: Set the ticket assignment
redis.call('HSET', user_tickets_key, ticket_type, program_id)
@@ -111,8 +91,7 @@ local vote_record = cjson.encode({
user_id = user_id,
program_id = program_id,
ticket_type = ticket_type,
timestamp = timestamp,
revoked_program = old_program_id or nil
timestamp = timestamp
})
redis.call('RPUSH', sync_queue_key, vote_record)
@@ -123,7 +102,5 @@ 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
new_count = new_count
})

View File

@@ -7,6 +7,7 @@
-- KEYS[4] = leaderboard:{category}
-- KEYS[5] = sync:queue:votes
-- KEYS[6] = lock:vote:{user_id}:{category}
-- KEYS[7] = vote:user:{userId}:tickets (Hash)
--
-- ARGV[1] = candidate_id
-- ARGV[2] = user_id
@@ -22,6 +23,7 @@ local category_voters_key = KEYS[3]
local leaderboard_key = KEYS[4]
local sync_queue_key = KEYS[5]
local lock_key = KEYS[6]
local user_tickets_key = KEYS[7]
local candidate_id = ARGV[1]
local user_id = ARGV[2]
@@ -70,6 +72,9 @@ local new_count = redis.call('HINCRBY', vote_count_key, candidate_id, 1)
-- 4b: Add category to user's voted categories
redis.call('SADD', user_categories_key, category)
-- 4b-2: Persist user's choice for this category
redis.call('HSET', user_tickets_key, category, candidate_id)
-- 4c: Add user to category's voters
redis.call('SADD', category_voters_key, user_id)

View File

@@ -20,6 +20,7 @@ import type {
VoteStamp,
} from '@gala/shared/types';
import { INITIAL_ADMIN_STATE, PRIZE_CONFIG } from '@gala/shared/types';
import { VOTE_KEYS, TICKET_TYPES } from '@gala/shared/constants';
const ADMIN_STATE_KEY = 'gala:admin:state';
@@ -39,6 +40,17 @@ class AdminService extends EventEmitter {
const savedState = await redis.get(ADMIN_STATE_KEY);
if (savedState) {
const parsed = JSON.parse(savedState);
const configPrograms = programConfigService.getVotingPrograms();
const savedPrograms = (parsed?.voting?.programs || []) as VotingProgram[];
const mergedPrograms = configPrograms.map((program) => {
const saved = savedPrograms.find(p => p.id === program.id);
return {
...program,
status: saved?.status ?? program.status,
votes: saved?.votes ?? 0,
stamps: saved?.stamps ?? [],
};
});
// Deep merge to ensure new fields have defaults
this.state = {
...INITIAL_ADMIN_STATE,
@@ -46,10 +58,9 @@ class AdminService extends EventEmitter {
voting: {
...INITIAL_ADMIN_STATE.voting,
...parsed.voting,
// Ensure programs always has default values from config service
programs: parsed.voting?.programs?.length > 0
? parsed.voting.programs
: programConfigService.getVotingPrograms(),
// 始终从配置服务加载最新的 programs 和 awards
programs: mergedPrograms,
awards: programConfigService.getAwards(),
},
lottery: {
...INITIAL_ADMIN_STATE.lottery,
@@ -62,10 +73,24 @@ class AdminService extends EventEmitter {
};
logger.info('Admin state restored from Redis (merged with defaults)');
} else {
const programSettings = programConfigService.getSettings();
this.state = {
...INITIAL_ADMIN_STATE,
voting: {
...INITIAL_ADMIN_STATE.voting,
programs: programConfigService.getVotingPrograms(),
awards: programConfigService.getAwards(),
allowLateCatch: programSettings.allowLateCatch,
},
};
await this.saveState();
logger.info('Admin state initialized with defaults');
}
// Sync actual vote counts from Redis (VotingEngine is source of truth)
await this.syncVotesFromRedis();
// 从配置文件刷新当前轮次的奖项信息
await this.refreshPrizeFromConfig();
} catch (error) {
@@ -297,12 +322,30 @@ class AdminService extends EventEmitter {
/**
* Add a vote stamp to a program (for display on big screen)
*/
async addVoteStamp(programId: string, userName: string, department: string, ticketType: string): Promise<{ success: boolean; stamp?: VoteStamp }> {
async addVoteStamp(
programId: string,
userName: string,
department: string,
ticketType: string,
options?: { revokedProgramId?: string }
): Promise<{ success: boolean; stamp?: VoteStamp; programVotes?: number; totalVotes?: number; revokedProgramId?: string; revokedProgramVotes?: number }> {
const program = this.state.voting.programs.find(p => p.id === programId);
if (!program) {
return { success: false };
}
let wasReplacement = false;
let revokedProgramVotes: number | undefined;
const revokedProgramId = options?.revokedProgramId;
if (revokedProgramId && revokedProgramId !== programId) {
const revokedProgram = this.state.voting.programs.find(p => p.id === revokedProgramId);
if (revokedProgram && revokedProgram.votes > 0) {
revokedProgram.votes -= 1;
revokedProgramVotes = revokedProgram.votes;
wasReplacement = true;
}
}
const now = new Date();
const dateStr = `${now.getFullYear()}.${String(now.getMonth() + 1).padStart(2, '0')}.${String(now.getDate()).padStart(2, '0')}`;
@@ -321,10 +364,19 @@ class AdminService extends EventEmitter {
if (!program.stamps) program.stamps = [];
program.stamps.push(stamp);
program.votes++;
this.state.voting.totalVotes++;
if (!wasReplacement) {
this.state.voting.totalVotes++;
}
await this.saveState();
return { success: true, stamp };
return {
success: true,
stamp,
programVotes: program.votes,
totalVotes: this.state.voting.totalVotes,
revokedProgramId,
revokedProgramVotes,
};
}
/**
@@ -501,12 +553,15 @@ class AdminService extends EventEmitter {
try {
if (scope === 'all' || scope === 'voting') {
const programSettings = programConfigService.getSettings();
this.state.voting = {
...INITIAL_ADMIN_STATE.voting,
programs: programConfigService.getVotingPrograms(),
awards: programConfigService.getAwards(),
allowLateCatch: programSettings.allowLateCatch,
};
// Clear voting data in Redis
await redis.del('gala:votes:*');
await this.clearVotingRedisData();
}
if (scope === 'all' || scope === 'lottery') {
@@ -577,6 +632,66 @@ class AdminService extends EventEmitter {
this.state.voting.totalVotes = count;
await this.saveState();
}
/**
* Sync vote counts from Redis (source of truth) to local state
*/
async syncVotesFromRedis(): Promise<void> {
try {
let total = 0;
for (const program of this.state.voting.programs) {
let count = 0;
for (const ticketType of TICKET_TYPES) {
const key = VOTE_KEYS.count(program.id, ticketType);
const value = await redis.get(key);
if (value) {
const parsed = Number.parseInt(value, 10);
if (!Number.isNaN(parsed)) {
count += parsed;
}
}
}
program.votes = count;
total += count;
}
this.state.voting.totalVotes = total;
// We don't save state here immediately to avoid overwriting other potential changes
// during init, but since we called this in initialize(), we should save.
await this.saveState();
logger.info({ totalVotes: total }, 'Synced vote counts from Redis');
} catch (error) {
logger.error({ error }, 'Failed to sync votes from Redis');
}
}
/**
* Clear voting-related Redis keys (both new ticket system and legacy)
*/
private async clearVotingRedisData(): Promise<void> {
const patterns = [
'vote:count:*',
'vote:user:*',
'vote:program:*',
'vote:leaderboard:*',
'vote:category:*',
'vote:sync:queue',
'sync:queue:votes',
'vote:lock:*',
'lock:vote:*',
'leaderboard:*',
];
for (const pattern of patterns) {
let cursor = '0';
do {
const [nextCursor, keys] = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 1000);
cursor = nextCursor;
if (keys.length > 0) {
await redis.del(...keys);
}
} while (cursor !== '0');
}
}
}
export const adminService = new AdminService();

View File

@@ -18,6 +18,15 @@ export interface ProgramConfig {
id: string;
name: string;
teamName: string;
performer?: string; // 表演者
order: number;
remark?: string; // 节目备注
}
export interface AwardConfig {
id: string;
name: string;
icon: string;
order: number;
}
@@ -28,6 +37,7 @@ export interface ProgramSettings {
export interface ProgramConfigFile {
programs: ProgramConfig[];
awards: AwardConfig[];
settings: ProgramSettings;
}
@@ -49,8 +59,19 @@ class ProgramConfigService {
this.config = JSON.parse(content);
logger.info({
programCount: this.config?.programs.length,
awardCount: this.config?.awards?.length || 0,
configPath: this.configPath
}, 'Program config loaded');
// Validate: programs.length === awards.length
if (this.config?.programs && this.config?.awards) {
if (this.config.programs.length !== this.config.awards.length) {
logger.warn({
programCount: this.config.programs.length,
awardCount: this.config.awards.length
}, 'Warning: program count does not match award count');
}
}
} else {
logger.warn({ configPath: this.configPath }, 'Program config file not found, using defaults');
this.config = this.getDefaults();
@@ -67,14 +88,22 @@ class ProgramConfigService {
private getDefaults(): ProgramConfigFile {
return {
programs: [
{ id: 'p1', name: '龙腾四海', teamName: '市场部', order: 1 },
{ id: 'p2', name: '金马奔腾', teamName: '技术部', order: 2 },
{ id: 'p3', name: '春风得意', teamName: '人力资源部', order: 3 },
{ id: 'p4', name: '鸿运当头', teamName: '财务部', order: 4 },
{ id: 'p5', name: '马到成功', teamName: '运营部', order: 5 },
{ id: 'p6', name: '一马当先', teamName: '产品部', order: 6 },
{ id: 'p7', name: '万马奔腾', teamName: '设计部', order: 7 },
{ id: 'p8', name: '龙马精神', teamName: '销售部', order: 8 },
{ id: 'p1', name: '龙腾四海', teamName: '市场部', performer: '待定', order: 1, remark: '赞美节目如琥珀般凝固了某个经典、美好、闪光的瞬间。' },
{ id: 'p2', name: '金马奔腾', teamName: '技术部', performer: '待定', order: 2, remark: '强调节目留下了值得回味的"声音"。' },
{ id: 'p3', name: '春风得意', teamName: '人力资源部', performer: '待定', order: 3, remark: '赞美节目引发了跨越时代的共鸣。' },
{ id: 'p4', name: '鸿运当头', teamName: '财务部', performer: '待定', order: 4, remark: '形容节目用声音和表演编织了一个时代的梦境。' },
{ id: 'p5', name: '马到成功', teamName: '运营部', performer: '待定', order: 5, remark: '既指复刻了过去的潮流,也指创造了今晚的潮流。' },
{ id: 'p6', name: '一马当先', teamName: '产品部', performer: '待定', order: 6, remark: '强调节目的独特韵味与精心打磨。' },
{ id: 'p7', name: '万马奔腾', teamName: '设计部', performer: '待定', order: 7, remark: '赞美节目与"复古70-80"主题高度契合。' },
],
awards: [
{ id: 'time_amber', name: '时光琥珀奖', icon: '🏆', order: 1 },
{ id: 'echo_years', name: '岁月留声奖', icon: '🎵', order: 2 },
{ id: 'resonance', name: '风华共鸣奖', icon: '🎭', order: 3 },
{ id: 'dream_weaver', name: '光影织梦奖', icon: '✨', order: 4 },
{ id: 'trend_mark', name: '潮流印记奖', icon: '🌊', order: 5 },
{ id: 'craftsmanship', name: '匠心独韵奖', icon: '💎', order: 6 },
{ id: 'in_sync', name: '同频时代奖', icon: '📻', order: 7 },
],
settings: {
allowLateCatch: true,
@@ -90,6 +119,20 @@ class ProgramConfigService {
return this.config?.programs || this.getDefaults().programs;
}
/**
* Get all awards
*/
getAwards(): AwardConfig[] {
return this.config?.awards || this.getDefaults().awards;
}
/**
* Get award by id
*/
getAwardById(id: string): AwardConfig | undefined {
return this.getAwards().find(a => a.id === id);
}
/**
* Convert config programs to VotingProgram format (with runtime fields)
*/
@@ -135,6 +178,24 @@ class ProgramConfigService {
}
}
/**
* Update awards and save to file
*/
async updateAwards(awards: AwardConfig[]): Promise<{ success: boolean; error?: string }> {
try {
if (!this.config) {
this.config = this.getDefaults();
}
this.config.awards = awards;
await this.saveToFile();
logger.info({ awardCount: awards.length }, 'Awards updated');
return { success: true };
} catch (error) {
logger.error({ error }, 'Failed to update awards');
return { success: false, error: (error as Error).message };
}
}
/**
* Update settings and save to file
*/

View File

@@ -1,7 +1,7 @@
import { redis } from '../config/redis';
import { config } from '../config';
import { logger } from '../utils/logger';
import { REDIS_KEYS } from '@gala/shared/constants';
import { REDIS_KEYS, VOTE_KEYS } from '@gala/shared/constants';
import type { VoteCategory } from '@gala/shared/types';
import { readFileSync } from 'fs';
import { join, dirname } from 'path';
@@ -85,6 +85,7 @@ export class VoteService {
`${REDIS_KEYS.LEADERBOARD}:${category}`,
REDIS_KEYS.SYNC_QUEUE,
`${REDIS_KEYS.VOTE_LOCK}:${userId}:${category}`,
VOTE_KEYS.userTickets(userId),
];
const args = [

View File

@@ -7,7 +7,7 @@ import { logger } from '../utils/logger';
import { voteService } from '../services/vote.service';
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 { SOCKET_EVENTS, SOCKET_ROOMS, TICKET_TYPES, VOTE_KEYS, type TicketType } from '@gala/shared/constants';
import type {
ServerToClientEvents,
ClientToServerEvents,
@@ -206,8 +206,9 @@ async function handleJoin(
await socket.join(SOCKET_ROOMS.ADMIN);
}
// Get user's voted categories
// Get user's voted categories and tickets
const votedCategories = await voteService.getUserVotedCategories(userId);
const userTickets = await redis.hgetall(VOTE_KEYS.userTickets(userId));
logger.info({ socketId: socket.id, userId, userName, role }, 'User joined');
@@ -221,6 +222,8 @@ async function handleJoin(
sessionId: socket.id,
serverTime: Date.now(),
reconnected: false,
votedCategories,
userTickets,
},
});
} catch (error) {
@@ -298,18 +301,30 @@ async function handleVoteSubmit(
data.candidateId,
socket.data.userName || '匿名用户',
socket.data.department || '未知部门',
category
category,
{ revokedProgramId: result.revoked_program }
);
// Broadcast vote update to all clients with stamp info
io.to(SOCKET_ROOMS.ALL).emit(SOCKET_EVENTS.VOTE_UPDATED as any, {
candidateId: data.candidateId,
category: category,
totalVotes: result.new_count!,
totalVotes: stampResult.totalVotes ?? 0,
programVotes: stampResult.programVotes ?? 0,
delta: 1,
stamp: stampResult.stamp, // Pass the stamp info for animation
});
if (stampResult.revokedProgramId && stampResult.revokedProgramVotes !== undefined) {
io.to(SOCKET_ROOMS.ALL).emit(SOCKET_EVENTS.VOTE_UPDATED as any, {
candidateId: stampResult.revokedProgramId,
category: category,
totalVotes: stampResult.totalVotes ?? 0,
programVotes: stampResult.revokedProgramVotes,
delta: -1,
});
}
safeCallback({
success: true,
data: {
@@ -346,7 +361,8 @@ async function handleVoteSubmit(
io.to(SOCKET_ROOMS.ALL).emit(SOCKET_EVENTS.VOTE_UPDATED as any, {
candidateId: data.candidateId,
category: data.category,
totalVotes: result.new_count!,
totalVotes: stampResult.totalVotes ?? 0,
programVotes: stampResult.programVotes ?? 0,
delta: 1,
stamp: stampResult.stamp, // Include stamp for big screen
});
@@ -380,10 +396,12 @@ async function handleSyncRequest(socket: GalaSocket): Promise<void> {
try {
const votedCategories = await voteService.getUserVotedCategories(userId);
const userTickets = await redis.hgetall(VOTE_KEYS.userTickets(userId));
socket.emit(SOCKET_EVENTS.SYNC_STATE as any, {
votes: {}, // TODO: Include current vote counts
userVotedCategories: votedCategories,
userTickets,
});
} catch (error) {
logger.error({ socketId: socket.id, userId, error }, 'Sync request error');
@@ -649,4 +667,3 @@ function handleScanUnsubscribe(socket: GalaSocket, data: ScanSubscribePayload):
socket.leave(roomName);
logger.info({ socketId: socket.id, scanToken }, 'Socket unsubscribed from scan updates');
}