refactor: optimize data cleanup functions in director console

- Add redrawCurrentRound() method to clear current round winners
- Add /api/admin/lottery/redraw API endpoint
- Rename "重置" to "重置本轮" (reset current round state only)
- Add "重抽本轮" button (clear winners and allow re-draw)
- Rename "紧急操作" to "数据管理" with clearer button labels
- Change "高级清理" to collapsible "开发者选项"
- Update confirmation modal text for clarity

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
empty
2026-01-29 01:59:13 +08:00
parent 4a2f1e7c8a
commit aba96f88df
3 changed files with 747 additions and 21 deletions

View File

@@ -3,6 +3,7 @@ import multer from 'multer';
import { participantService } from '../services/participant.service';
import { prizeConfigService } from '../services/prize-config.service';
import { programConfigService } from '../services/program-config.service';
import { adminService } from '../services/admin.service';
const router: IRouter = Router();
const upload = multer({ storage: multer.memoryStorage() });
@@ -204,5 +205,95 @@ router.get('/participants', async (_req, res, next) => {
}
});
/**
* GET /api/admin/lottery/results
* Get all lottery results from database
*/
router.get('/lottery/results', async (_req, res, next) => {
try {
const results = await adminService.getLotteryResults();
return res.json({
success: true,
data: results,
});
} catch (error) {
next(error);
}
});
/**
* DELETE /api/admin/lottery/history
* Clear all lottery history (requires confirmation code)
*/
router.delete('/lottery/history', async (req, res, next) => {
try {
const { confirmCode } = req.body;
const result = await adminService.clearLotteryHistory(confirmCode);
if (result.success) {
return res.json({
success: true,
message: result.message,
data: result.cleared,
});
} else {
return res.status(400).json({
success: false,
error: result.message,
});
}
} catch (error) {
next(error);
}
});
/**
* POST /api/admin/lottery/redraw
* Redraw current round - clears current round winners and allows re-drawing
*/
router.post('/lottery/redraw', async (_req, res, next) => {
try {
const result = await adminService.redrawCurrentRound();
if (result.success) {
return res.json({
success: true,
message: result.message,
});
} else {
return res.status(400).json({
success: false,
error: result.message,
});
}
} catch (error) {
next(error);
}
});
/**
* POST /api/admin/cleanup
* Advanced data cleanup with granular control
* Body: { lottery?: { redis?: boolean; mysql?: boolean }, voting?: { redis?: boolean; mysql?: boolean }, confirmCode: string }
*/
router.post('/cleanup', async (req, res, next) => {
try {
const { lottery, voting, confirmCode } = req.body;
const result = await adminService.advancedCleanup({ lottery, voting }, confirmCode);
if (result.success) {
return res.json({
success: true,
message: result.message,
data: result.results,
});
} else {
return res.status(400).json({
success: false,
error: result.message,
});
}
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -3,6 +3,7 @@
*/
import { EventEmitter } from 'events';
import { redis } from '../config/redis';
import { prisma } from '../config/db';
import { logger } from '../utils/logger';
import { prizeConfigService } from './prize-config.service';
import { programConfigService } from './program-config.service';
@@ -436,10 +437,14 @@ class AdminService extends EventEmitter {
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)
// Pick random winners from participants
const winners = this.pickRandomWinners();
this.state.lottery.subPhase = 'REVEAL';
this.state.lottery.currentWinners = winners;
// Persist winners to database
await this.persistLotteryResults(winners);
// Auto-play fanfare music
this.state.music.isPlaying = true;
this.state.music.track = 'fanfare';
@@ -478,17 +483,9 @@ class AdminService extends EventEmitter {
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: firstPrize?.level || '第一轮抽奖',
prizeName: firstPrize?.name || '幸运伴手礼',
winnerCount: firstPrize?.winnerCount || 7,
currentWinners: [],
};
// 仅重置当前轮次状态到 IDLE不清理数据
this.state.lottery.subPhase = 'IDLE';
this.state.lottery.currentWinners = [];
break;
}
await this.saveState();
@@ -500,6 +497,39 @@ class AdminService extends EventEmitter {
}
}
/**
* Redraw current round - clears current round winners and resets state
* Allows re-drawing the same round
*/
async redrawCurrentRound(): Promise<{ success: boolean; message?: string }> {
try {
const currentRound = this.state.lottery.round;
// Clear current round winners from Redis
const roundWinnersKey = `draw:round:${currentRound}:winners`;
const winnersToRemove = await redis.smembers(roundWinnersKey);
if (winnersToRemove.length > 0) {
// Remove from global winners set
await redis.srem('draw:winners', ...winnersToRemove);
// Clear round-specific winners
await redis.del(roundWinnersKey);
}
// Reset lottery state to IDLE for current round
this.state.lottery.subPhase = 'IDLE';
this.state.lottery.currentWinners = [];
await this.saveState();
logger.warn({ round: currentRound, clearedWinners: winnersToRemove.length }, 'Current round redrawn');
return { success: true, message: `${currentRound}轮已重置,可重新抽取` };
} catch (error) {
logger.error({ error }, 'Failed to redraw current round');
return { success: false, message: 'Failed to redraw current round' };
}
}
/**
* Pick random winners from real participants
*/
@@ -540,6 +570,106 @@ class AdminService extends EventEmitter {
return winners;
}
/**
* Persist lottery results to database
*/
private async persistLotteryResults(winners: LotteryWinner[]): Promise<void> {
try {
const drawId = `draw-${Date.now()}`;
const prizeLevel = this.state.lottery.prizeLevel;
const prizeName = this.state.lottery.prizeName;
const round = this.state.lottery.round;
// Create draw results in database
await prisma.drawResult.createMany({
data: winners.map(winner => ({
drawId,
prizeLevel,
prizeName,
winnerId: winner.id,
winnerName: winner.name,
winnerDepartment: winner.department,
drawnBy: 'system',
})),
});
// Create or update draw session
await prisma.drawSession.upsert({
where: { id: drawId },
create: {
id: drawId,
prizeLevel,
prizeName,
totalPrizes: winners.length,
drawnCount: winners.length,
isActive: false,
startedAt: new Date(),
endedAt: new Date(),
},
update: {
drawnCount: winners.length,
endedAt: new Date(),
},
});
logger.info(
{ drawId, round, prizeName, winnerCount: winners.length },
'Lottery results persisted to database'
);
} catch (error) {
logger.error({ error }, 'Failed to persist lottery results');
// Don't throw - we don't want to break the lottery flow if DB fails
}
}
/**
* Get all lottery results from database
*/
async getLotteryResults(): Promise<{
draws: Array<{
id: string;
prizeLevel: string;
prizeName: string;
winners: Array<{
id: string;
name: string;
department: string;
}>;
drawnAt: Date;
}>;
}> {
try {
const results = await prisma.drawResult.findMany({
orderBy: { drawnAt: 'desc' },
});
// Group by drawId
const grouped = new Map<string, typeof results>();
for (const result of results) {
const existing = grouped.get(result.drawId) || [];
existing.push(result);
grouped.set(result.drawId, existing);
}
const draws = Array.from(grouped.entries()).map(([drawId, winners]) => ({
id: drawId,
prizeLevel: winners[0]?.prizeLevel || '',
prizeName: winners[0]?.prizeName || '',
winners: winners.map(w => ({
id: w.winnerId,
name: w.winnerName,
department: w.winnerDepartment,
})),
drawnAt: winners[0]?.drawnAt || new Date(),
}));
return { draws };
} catch (error) {
logger.error({ error }, 'Failed to get lottery results');
return { draws: [] };
}
}
/**
* Emergency reset
*/
@@ -560,8 +690,9 @@ class AdminService extends EventEmitter {
awards: programConfigService.getAwards(),
allowLateCatch: programSettings.allowLateCatch,
};
// Clear voting data in Redis
// Clear voting data in Redis and Database
await this.clearVotingRedisData();
await this.clearVotingDbData();
}
if (scope === 'all' || scope === 'lottery') {
@@ -576,6 +707,9 @@ class AdminService extends EventEmitter {
winnerCount: firstPrize?.winnerCount || 7,
currentWinners: [],
};
// Clear lottery data in Redis and Database
await this.clearLotteryRedisData();
await this.clearLotteryDbData();
}
if (scope === 'all') {
@@ -692,6 +826,180 @@ class AdminService extends EventEmitter {
} while (cursor !== '0');
}
}
/**
* Clear lottery-related Redis keys
*/
private async clearLotteryRedisData(): Promise<void> {
const patterns = [
'draw:winners*',
'draw:pool:*',
'draw:history',
'draw:excluded',
'draw:temp:*',
];
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');
}
logger.info('Lottery Redis data cleared');
}
/**
* Clear lottery results from database
*/
private async clearLotteryDbData(): Promise<void> {
try {
// Delete all draw results
await prisma.drawResult.deleteMany({});
// Delete all draw sessions
await prisma.drawSession.deleteMany({});
logger.info('Lottery database records cleared');
} catch (error) {
logger.error({ error }, 'Failed to clear lottery database records');
// Don't throw - we don't want to break the reset flow if DB fails
}
}
/**
* Clear voting results from database
*/
private async clearVotingDbData(): Promise<void> {
try {
// Delete all votes
await prisma.vote.deleteMany({});
// Delete all vote counts
await prisma.voteCount.deleteMany({});
logger.info('Voting database records cleared');
} catch (error) {
logger.error({ error }, 'Failed to clear voting database records');
// Don't throw - we don't want to break the reset flow if DB fails
}
}
/**
* Clear all lottery data (Redis + Database)
* This is a destructive operation and should be used with caution
*/
async clearLotteryHistory(confirmCode: string): Promise<{ success: boolean; message?: string; cleared?: { db: number; redis: boolean } }> {
if (confirmCode !== 'CLEAR_LOTTERY') {
return { success: false, message: 'Invalid confirmation code. Use CLEAR_LOTTERY to confirm.' };
}
try {
// Clear Redis data
await this.clearLotteryRedisData();
// Clear database records
const dbResult = await prisma.drawResult.count();
await this.clearLotteryDbData();
logger.warn('Lottery history cleared by admin');
return {
success: true,
message: 'Lottery history cleared successfully',
cleared: {
db: dbResult,
redis: true,
},
};
} catch (error) {
logger.error({ error }, 'Failed to clear lottery history');
return { success: false, message: 'Failed to clear lottery history' };
}
}
/**
* Advanced data cleanup - clears data by type and storage layer
* This provides granular control over what data to clear
*/
async advancedCleanup(
options: {
lottery?: { redis?: boolean; mysql?: boolean };
voting?: { redis?: boolean; mysql?: boolean };
},
confirmCode: string
): Promise<{
success: boolean;
message?: string;
results?: {
lottery?: { redis: boolean; mysql: boolean; dbCount: number };
voting?: { redis: boolean; mysql: boolean; dbCount: number };
};
}> {
if (confirmCode !== 'ADVANCED_CLEANUP') {
return { success: false, message: 'Invalid confirmation code. Use ADVANCED_CLEANUP to confirm.' };
}
const results: {
lottery?: { redis: boolean; mysql: boolean; dbCount: number };
voting?: { redis: boolean; mysql: boolean; dbCount: number };
} = {};
try {
// Handle lottery cleanup
if (options.lottery) {
const lotteryResult: { redis: boolean; mysql: boolean; dbCount: number } = {
redis: false,
mysql: false,
dbCount: 0,
};
if (options.lottery.redis) {
await this.clearLotteryRedisData();
lotteryResult.redis = true;
}
if (options.lottery.mysql) {
lotteryResult.dbCount = await prisma.drawResult.count();
await this.clearLotteryDbData();
lotteryResult.mysql = true;
}
results.lottery = lotteryResult;
}
// Handle voting cleanup
if (options.voting) {
const votingResult: { redis: boolean; mysql: boolean; dbCount: number } = {
redis: false,
mysql: false,
dbCount: 0,
};
if (options.voting.redis) {
await this.clearVotingRedisData();
votingResult.redis = true;
}
if (options.voting.mysql) {
votingResult.dbCount = await prisma.vote.count();
await this.clearVotingDbData();
votingResult.mysql = true;
}
results.voting = votingResult;
}
logger.warn({ options, results }, 'Advanced cleanup executed by admin');
return {
success: true,
message: 'Advanced cleanup completed successfully',
results,
};
} catch (error) {
logger.error({ error, options }, 'Failed to execute advanced cleanup');
return { success: false, message: 'Failed to execute advanced cleanup' };
}
}
}
export const adminService = new AdminService();