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:
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user