feat: enhance lottery system with participant import and prize config

- Fix ES module import issue in admin.service.ts (require -> import)
- Fix lottery reveal ghosting by hiding name particles on complete
- Add participant import from Excel with tag calculation
- Add prize configuration service with JSON persistence
- Constrain winners overlay to scroll area dimensions
- Fix macOS lsof syntax in stop script
- Add HorseRace view and renderer (WIP)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
empty
2026-01-23 12:20:45 +08:00
parent 35d77cbb22
commit a442d050e4
23 changed files with 2523 additions and 325 deletions

View File

@@ -0,0 +1,42 @@
{
"prizes": [
{
"round": 1,
"level": "第一轮抽奖",
"name": "幸运伴手礼",
"winnerCount": 7,
"poolTag": "6070",
"description": "60/70年代"
},
{
"round": 2,
"level": "第二轮抽奖",
"name": "幸运伴手礼",
"winnerCount": 3,
"poolTag": "80",
"description": "80年代"
},
{
"round": 3,
"level": "第三轮抽奖",
"name": "幸运伴手礼",
"winnerCount": 4,
"poolTag": "90",
"description": "90年代"
},
{
"round": 4,
"level": "第四轮抽奖",
"name": "幸运伴手礼",
"winnerCount": 1,
"poolTag": "horse",
"zodiacFilter": "horse",
"description": "属马特供"
}
],
"settings": {
"minStormDuration": 3000,
"revealAnimationDuration": 2000,
"allowRepeatWinner": false
}
}

View File

@@ -17,27 +17,32 @@
},
"dependencies": {
"@gala/shared": "workspace:*",
"express": "^4.21.2",
"socket.io": "^4.8.1",
"@socket.io/redis-adapter": "^8.3.0",
"ioredis": "^5.4.2",
"@prisma/client": "^6.2.1",
"zod": "^3.24.1",
"cors": "^2.8.5",
"helmet": "^8.0.0",
"@socket.io/redis-adapter": "^8.3.0",
"compression": "^1.7.5",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"helmet": "^8.0.0",
"ioredis": "^5.4.2",
"multer": "^2.0.2",
"nanoid": "^5.0.9",
"pino": "^9.6.0",
"pino-pretty": "^13.0.0",
"nanoid": "^5.0.9"
"socket.io": "^4.8.1",
"uuid": "^13.0.0",
"xlsx": "^0.18.5",
"zod": "^3.24.1"
},
"devDependencies": {
"@types/express": "^5.0.0",
"@types/cors": "^2.8.17",
"@types/compression": "^1.7.5",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/multer": "^2.0.0",
"@types/uuid": "^11.0.0",
"prisma": "^6.2.1",
"tsx": "^4.19.2",
"tsup": "^8.3.5",
"tsx": "^4.19.2",
"typescript": "^5.7.3",
"vitest": "^2.1.8"
}

View File

@@ -5,6 +5,8 @@ import { connectRedis } from './config/redis';
import { initializeSocket } from './socket';
import { loadLuaScripts } from './services/vote.service';
import { loadVotingScripts } from './services/voting.engine';
import { prizeConfigService } from './services/prize-config.service';
import { participantService } from './services/participant.service';
async function main(): Promise<void> {
try {
@@ -17,6 +19,14 @@ async function main(): Promise<void> {
await loadLuaScripts();
await loadVotingScripts();
// Load prize configuration
logger.info('Loading prize configuration...');
await prizeConfigService.load();
// Restore participants from Redis
logger.info('Restoring participants from Redis...');
await participantService.restoreFromRedis();
// Create HTTP server
const httpServer = createServer(app);
@@ -55,3 +65,4 @@ async function main(): Promise<void> {
}
main();

View File

@@ -1,6 +1,10 @@
import { Router, IRouter } from 'express';
import multer from 'multer';
import { participantService } from '../services/participant.service';
import { prizeConfigService } from '../services/prize-config.service';
const router: IRouter = Router();
const upload = multer({ storage: multer.memoryStorage() });
/**
* GET /api/admin/stats
@@ -12,7 +16,7 @@ router.get('/stats', async (_req, res, next) => {
return res.json({
success: true,
data: {
totalUsers: 0,
totalUsers: participantService.getCount(),
totalVotes: 0,
activeConnections: 0,
},
@@ -22,6 +26,53 @@ router.get('/stats', async (_req, res, next) => {
}
});
/**
* GET /api/admin/prizes
* Get prize configuration
*/
router.get('/prizes', async (_req, res, next) => {
try {
const config = prizeConfigService.getFullConfig();
return res.json({
success: true,
data: config,
});
} catch (error) {
next(error);
}
});
/**
* PUT /api/admin/prizes
* Update prize configuration
*/
router.put('/prizes', async (req, res, next) => {
try {
const { prizes, settings } = req.body;
if (prizes) {
const result = await prizeConfigService.updatePrizes(prizes);
if (!result.success) {
return res.status(400).json({ success: false, error: result.error });
}
}
if (settings) {
const result = await prizeConfigService.updateSettings(settings);
if (!result.success) {
return res.status(400).json({ success: false, error: result.error });
}
}
return res.json({
success: true,
data: prizeConfigService.getFullConfig(),
});
} catch (error) {
next(error);
}
});
/**
* POST /api/admin/draw/start
* Start a lucky draw
@@ -54,4 +105,54 @@ router.post('/draw/stop', async (_req, res, next) => {
}
});
/**
* POST /api/admin/participants/import
* Import participants from Excel file
* Expected columns: 岗位, 姓名, 年份
*/
router.post('/participants/import', upload.single('file'), async (req, res, next) => {
try {
if (!req.file) {
return res.status(400).json({
success: false,
error: '请上传 Excel 文件',
});
}
const result = await participantService.importFromExcel(req.file.buffer);
return res.json({
success: result.success,
data: {
totalCount: result.totalCount,
importedCount: result.importedCount,
tagDistribution: result.tagDistribution,
errors: result.errors,
},
});
} catch (error) {
next(error);
}
});
/**
* GET /api/admin/participants
* Get all participants
*/
router.get('/participants', async (_req, res, next) => {
try {
const participants = participantService.getAll();
return res.json({
success: true,
data: {
count: participants.length,
participants,
},
});
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -3,6 +3,8 @@
*/
import { redis } from '../config/redis';
import { logger } from '../utils/logger';
import { prizeConfigService } from './prize-config.service';
import { participantService } from './participant.service';
import type {
AdminState,
SystemPhase,
@@ -60,11 +62,37 @@ class AdminService {
await this.saveState();
logger.info('Admin state initialized with defaults');
}
// 从配置文件刷新当前轮次的奖项信息
await this.refreshPrizeFromConfig();
} catch (error) {
logger.error({ error }, 'Failed to initialize admin state');
}
}
/**
* Refresh lottery prize info from config file
*/
async refreshPrizeFromConfig(): Promise<void> {
try {
const prizes = prizeConfigService.getPrizes();
const currentPrize = prizes.find(p => p.round === this.state.lottery.round);
if (currentPrize) {
this.state.lottery.prizeLevel = currentPrize.level;
this.state.lottery.prizeName = currentPrize.name;
this.state.lottery.winnerCount = currentPrize.winnerCount;
await this.saveState();
logger.info({
round: this.state.lottery.round,
level: currentPrize.level,
name: currentPrize.name
}, 'Prize config refreshed from config file');
}
} catch (error) {
logger.error({ error }, 'Failed to refresh prize config');
}
}
/**
* Save current state to Redis
*/
@@ -370,7 +398,9 @@ class AdminService {
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);
// 使用服务器配置
const serverPrizes = prizeConfigService.getPrizes();
const prizeConfig = serverPrizes.find(p => p.round === nextRound) || PRIZE_CONFIG.find(p => p.round === nextRound);
this.state.lottery.round = nextRound;
this.state.lottery.subPhase = 'IDLE';
this.state.lottery.currentWinners = [];
@@ -383,12 +413,15 @@ class AdminService {
break;
case 'reset':
// 使用服务器配置获取第一轮奖项
const resetPrizes = prizeConfigService.getPrizes();
const firstPrize = resetPrizes.find(p => p.round === 1) || resetPrizes[0] || PRIZE_CONFIG[0];
this.state.lottery = {
round: 1,
subPhase: 'IDLE',
prizeLevel: '特等奖',
prizeName: 'iPhone 16 Pro Max',
winnerCount: 1,
prizeLevel: firstPrize?.level || '特等奖',
prizeName: firstPrize?.name || '待配置',
winnerCount: firstPrize?.winnerCount || 1,
currentWinners: [],
};
break;
@@ -403,29 +436,43 @@ class AdminService {
}
/**
* Pick random winners (simplified implementation)
* Pick random winners from real participants
*/
private pickRandomWinners(): LotteryWinner[] {
const prizeConfig = PRIZE_CONFIG.find(p => p.round === this.state.lottery.round);
const prizes = prizeConfigService.getPrizes();
const prizeConfig = prizes.find(p => p.round === this.state.lottery.round) || 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: '市场部' },
];
// 从导入的参与者中抽取
const allParticipants = participantService.getAll();
// Shuffle and pick
const shuffled = [...demoNames].sort(() => Math.random() - 0.5);
return shuffled.slice(0, count);
if (allParticipants.length === 0) {
logger.warn('No participants available, using demo data');
// 降级到 demo 数据
const demoNames = [
{ id: 'demo1', name: '张三', department: '待导入' },
{ id: 'demo2', name: '李四', department: '待导入' },
{ id: 'demo3', name: '王五', department: '待导入' },
];
return demoNames.slice(0, count);
}
// 根据当前轮次的 poolTag 过滤参与者
let eligibleParticipants = allParticipants;
const poolTag = (prizeConfig as any)?.poolTag;
if (poolTag && poolTag !== 'all') {
eligibleParticipants = allParticipants.filter((p: any) => p.tags?.includes(poolTag));
}
// 随机抽取
const shuffled = [...eligibleParticipants].sort(() => Math.random() - 0.5);
const winners = shuffled.slice(0, count).map((p: any) => ({
id: p.id,
name: p.name,
department: p.position, // 岗位作为部门显示
}));
return winners;
}
/**

View File

@@ -0,0 +1,295 @@
import { z } from 'zod';
import * as xlsx from 'xlsx';
import { v4 as uuidv4 } from 'uuid';
import { drawEngine, calculateUserTags } from './draw.engine';
import { logger } from '../utils/logger';
import { redis } from '../config/redis';
import type { UserTag } from '@gala/shared/constants';
// Redis key for storing participants
const PARTICIPANTS_KEY = 'gala:participants:list';
// Types
// ============================================================================
export interface Participant {
id: string;
name: string;
position: string; // 岗位
birthYear: number;
tags: UserTag[];
}
export interface ImportResult {
success: boolean;
totalCount: number;
importedCount: number;
tagDistribution: Record<string, number>;
errors: string[];
participants: Participant[];
}
// ============================================================================
// Excel Column Mapping
// ============================================================================
// Expected columns: 岗位, 姓名, 年份
const COLUMN_MAPPING = {
position: ['岗位', '职位', 'Position', 'position'],
name: ['姓名', '名字', 'Name', 'name'],
birthYear: ['年份', '出生年份', 'Year', 'year', '生日'],
};
function findColumnIndex(headers: string[], possibleNames: string[]): number {
for (const name of possibleNames) {
const index = headers.findIndex(
(h) => h && h.toString().trim().toLowerCase() === name.toLowerCase()
);
if (index !== -1) return index;
}
return -1;
}
// ============================================================================
// Participant Service
// ============================================================================
class ParticipantService {
private participants: Map<string, Participant> = new Map();
/**
* Parse Excel buffer and import participants
*/
async importFromExcel(buffer: Buffer): Promise<ImportResult> {
const errors: string[] = [];
const participants: Participant[] = [];
const tagDistribution: Record<string, number> = {};
try {
// Parse Excel
const workbook = xlsx.read(buffer, { type: 'buffer' });
const sheetName = workbook.SheetNames[0];
const sheet = workbook.Sheets[sheetName];
// Convert to JSON with header row
const data = xlsx.utils.sheet_to_json<string[]>(sheet, { header: 1 });
if (data.length < 2) {
return {
success: false,
totalCount: 0,
importedCount: 0,
tagDistribution: {},
errors: ['Excel 文件为空或只有表头'],
participants: [],
};
}
// Find column indices
const headers = data[0] as string[];
const positionIndex = findColumnIndex(headers, COLUMN_MAPPING.position);
const nameIndex = findColumnIndex(headers, COLUMN_MAPPING.name);
const yearIndex = findColumnIndex(headers, COLUMN_MAPPING.birthYear);
if (positionIndex === -1) {
errors.push('未找到"岗位"列');
}
if (nameIndex === -1) {
errors.push('未找到"姓名"列');
}
if (yearIndex === -1) {
errors.push('未找到"年份"列');
}
if (errors.length > 0) {
return {
success: false,
totalCount: data.length - 1,
importedCount: 0,
tagDistribution: {},
errors,
participants: [],
};
}
// Process rows
for (let i = 1; i < data.length; i++) {
const row = data[i];
if (!row || row.length === 0) continue;
const position = row[positionIndex]?.toString().trim() || '';
const name = row[nameIndex]?.toString().trim() || '';
const yearValue = row[yearIndex];
if (!name) {
errors.push(`${i + 1} 行: 姓名为空`);
continue;
}
let birthYear: number;
if (typeof yearValue === 'number') {
birthYear = yearValue;
} else {
birthYear = parseInt(yearValue?.toString().trim() || '', 10);
}
if (isNaN(birthYear) || birthYear < 1940 || birthYear > 2010) {
errors.push(`${i + 1} 行 (${name}): 年份无效 (${yearValue})`);
continue;
}
// Calculate tags using existing draw engine logic
// Convert year to birth date string format (YYYY-01-01)
const tags = calculateUserTags(`${birthYear}-01-01`);
const participant: Participant = {
id: uuidv4(),
name,
position,
birthYear,
tags,
};
participants.push(participant);
// Update tag distribution
for (const tag of tags) {
tagDistribution[tag] = (tagDistribution[tag] || 0) + 1;
}
}
// Store in memory
this.participants.clear();
for (const p of participants) {
this.participants.set(p.id, p);
}
// Persist to Redis
await this.saveToRedis();
// Initialize draw pools in Redis
await drawEngine.initializePools(
participants.map((p) => ({
userId: p.id,
birthDate: `${p.birthYear}-01-01`,
}))
);
logger.info(
{
totalCount: data.length - 1,
importedCount: participants.length,
tagDistribution,
},
'Participants imported successfully'
);
return {
success: true,
totalCount: data.length - 1,
importedCount: participants.length,
tagDistribution,
errors,
participants,
};
} catch (error) {
logger.error({ error }, 'Failed to import participants from Excel');
return {
success: false,
totalCount: 0,
importedCount: 0,
tagDistribution: {},
errors: [`解析错误: ${(error as Error).message}`],
participants: [],
};
}
}
/**
* Get all participants
*/
getAll(): Participant[] {
return Array.from(this.participants.values());
}
/**
* Get participant by ID
*/
getById(id: string): Participant | undefined {
return this.participants.get(id);
}
/**
* Get participants by tag
*/
getByTag(tag: UserTag): Participant[] {
return this.getAll().filter((p) => p.tags.includes(tag));
}
/**
* Get participant count
*/
getCount(): number {
return this.participants.size;
}
/**
* Save participants to Redis for persistence
*/
private async saveToRedis(): Promise<void> {
try {
const participants = this.getAll();
if (participants.length === 0) {
await redis.del(PARTICIPANTS_KEY);
return;
}
await redis.set(PARTICIPANTS_KEY, JSON.stringify(participants));
logger.info({ count: participants.length }, 'Participants saved to Redis');
} catch (error) {
logger.error({ error }, 'Failed to save participants to Redis');
}
}
/**
* Restore participants from Redis on server start
*/
async restoreFromRedis(): Promise<void> {
try {
const data = await redis.get(PARTICIPANTS_KEY);
if (!data) {
logger.info('No participants found in Redis');
return;
}
const participants: Participant[] = JSON.parse(data);
this.participants.clear();
for (const p of participants) {
this.participants.set(p.id, p);
}
// Re-initialize draw pools
if (participants.length > 0) {
await drawEngine.initializePools(
participants.map((p) => ({
userId: p.id,
birthDate: `${p.birthYear}-01-01`,
}))
);
}
logger.info({ count: participants.length }, 'Participants restored from Redis');
} catch (error) {
logger.error({ error }, 'Failed to restore participants from Redis');
}
}
/**
* Clear all participants (for admin reset)
*/
async clear(): Promise<void> {
this.participants.clear();
await redis.del(PARTICIPANTS_KEY);
logger.info('Participants cleared');
}
}
export const participantService = new ParticipantService();

View File

@@ -0,0 +1,150 @@
/**
* Prize Configuration Service
* Loads prize config from JSON file and provides API for management
*/
import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';
import { logger } from '../utils/logger';
import type { PrizeConfig, LotteryRound } from '@gala/shared/types';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Default config path
const CONFIG_PATH = path.join(__dirname, '../../config/prizes.json');
export interface PrizeSettings {
minStormDuration: number;
revealAnimationDuration: number;
allowRepeatWinner: boolean;
}
export interface PrizeConfigFile {
prizes: PrizeConfig[];
settings: PrizeSettings;
}
class PrizeConfigService {
private config: PrizeConfigFile | null = null;
private configPath: string;
constructor() {
this.configPath = CONFIG_PATH;
}
/**
* Load config from file
*/
async load(): Promise<void> {
try {
if (fs.existsSync(this.configPath)) {
const content = fs.readFileSync(this.configPath, 'utf-8');
this.config = JSON.parse(content);
logger.info({
prizeCount: this.config?.prizes.length,
configPath: this.configPath
}, 'Prize config loaded');
} else {
logger.warn({ configPath: this.configPath }, 'Prize config file not found, using defaults');
this.config = this.getDefaults();
}
} catch (error) {
logger.error({ error, configPath: this.configPath }, 'Failed to load prize config');
this.config = this.getDefaults();
}
}
/**
* Get default configuration
*/
private getDefaults(): PrizeConfigFile {
return {
prizes: [
{ round: 1 as LotteryRound, level: '特等奖', name: 'iPhone 16 Pro Max', winnerCount: 1 },
{ round: 2 as LotteryRound, level: '一等奖', name: 'iPad Pro', winnerCount: 3 },
{ round: 3 as LotteryRound, level: '二等奖', name: 'AirPods Pro', winnerCount: 5 },
{ round: 4 as LotteryRound, level: '三等奖', name: '京东卡 500元', winnerCount: 10, zodiacFilter: 'horse' },
],
settings: {
minStormDuration: 3000,
revealAnimationDuration: 2000,
allowRepeatWinner: false,
},
};
}
/**
* Get all prizes
*/
getPrizes(): PrizeConfig[] {
return this.config?.prizes || this.getDefaults().prizes;
}
/**
* Get prize by round
*/
getPrizeByRound(round: LotteryRound): PrizeConfig | undefined {
return this.getPrizes().find(p => p.round === round);
}
/**
* Get settings
*/
getSettings(): PrizeSettings {
return this.config?.settings || this.getDefaults().settings;
}
/**
* Update prizes and save to file
*/
async updatePrizes(prizes: PrizeConfig[]): Promise<{ success: boolean; error?: string }> {
try {
if (!this.config) {
this.config = this.getDefaults();
}
this.config.prizes = prizes;
await this.saveToFile();
logger.info({ prizeCount: prizes.length }, 'Prizes updated');
return { success: true };
} catch (error) {
logger.error({ error }, 'Failed to update prizes');
return { success: false, error: (error as Error).message };
}
}
/**
* Update settings and save to file
*/
async updateSettings(settings: Partial<PrizeSettings>): Promise<{ success: boolean; error?: string }> {
try {
if (!this.config) {
this.config = this.getDefaults();
}
this.config.settings = { ...this.config.settings, ...settings };
await this.saveToFile();
logger.info({ settings: this.config.settings }, 'Prize settings updated');
return { success: true };
} catch (error) {
logger.error({ error }, 'Failed to update settings');
return { success: false, error: (error as Error).message };
}
}
/**
* Save config to file
*/
private async saveToFile(): Promise<void> {
const content = JSON.stringify(this.config, null, 2);
fs.writeFileSync(this.configPath, content, 'utf-8');
}
/**
* Get full config for API response
*/
getFullConfig(): PrizeConfigFile {
return this.config || this.getDefaults();
}
}
export const prizeConfigService = new PrizeConfigService();