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:
42
packages/server/config/prizes.json
Normal file
42
packages/server/config/prizes.json
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
295
packages/server/src/services/participant.service.ts
Normal file
295
packages/server/src/services/participant.service.ts
Normal 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();
|
||||
150
packages/server/src/services/prize-config.service.ts
Normal file
150
packages/server/src/services/prize-config.service.ts
Normal 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();
|
||||
Reference in New Issue
Block a user