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:
@@ -41,6 +41,15 @@ const confirmResetCode = ref('');
|
|||||||
const showResetModal = ref(false);
|
const showResetModal = ref(false);
|
||||||
const resetScope = ref<'all' | 'voting' | 'lottery'>('all');
|
const resetScope = ref<'all' | 'voting' | 'lottery'>('all');
|
||||||
|
|
||||||
|
// Advanced cleanup state
|
||||||
|
const showAdvancedCleanupModal = ref(false);
|
||||||
|
const confirmCleanupCode = ref('');
|
||||||
|
const cleanupLoading = ref(false);
|
||||||
|
const cleanupOptions = ref({
|
||||||
|
lottery: { redis: true, mysql: true },
|
||||||
|
voting: { redis: false, mysql: false },
|
||||||
|
});
|
||||||
|
|
||||||
// Toast notification state
|
// Toast notification state
|
||||||
const toast = ref<{ show: boolean; message: string; type: 'error' | 'success' | 'info' }>({
|
const toast = ref<{ show: boolean; message: string; type: 'error' | 'success' | 'info' }>({
|
||||||
show: false,
|
show: false,
|
||||||
@@ -237,7 +246,7 @@ function goBack() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Phase control
|
// Phase control
|
||||||
function setPhase(phase: 'IDLE' | 'VOTING' | 'LOTTERY' | 'RESULTS') {
|
function setPhase(phase: 'IDLE' | 'VOTING' | 'LOTTERY' | 'RESULTS' | 'LOTTERY_RESULTS') {
|
||||||
admin.setPhase(phase);
|
admin.setPhase(phase);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,6 +324,28 @@ const resetLottery = debounceLeading(() => {
|
|||||||
admin.controlLottery('reset');
|
admin.controlLottery('reset');
|
||||||
}, 500);
|
}, 500);
|
||||||
|
|
||||||
|
// Redraw current round
|
||||||
|
const showRedrawConfirm = ref(false);
|
||||||
|
|
||||||
|
async function redrawCurrentRound() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/lottery/redraw', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
showToast(data.message || '本轮已重置,可重新抽取', 'success');
|
||||||
|
} else {
|
||||||
|
showToast(data.error || '重抽失败', 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showToast('网络错误', 'error');
|
||||||
|
} finally {
|
||||||
|
showRedrawConfirm.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Emergency reset
|
// Emergency reset
|
||||||
function openResetModal(scope: 'all' | 'voting' | 'lottery') {
|
function openResetModal(scope: 'all' | 'voting' | 'lottery') {
|
||||||
resetScope.value = scope;
|
resetScope.value = scope;
|
||||||
@@ -333,6 +364,54 @@ function cancelReset() {
|
|||||||
confirmResetCode.value = '';
|
confirmResetCode.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Advanced cleanup
|
||||||
|
function openAdvancedCleanupModal() {
|
||||||
|
confirmCleanupCode.value = '';
|
||||||
|
cleanupOptions.value = {
|
||||||
|
lottery: { redis: true, mysql: true },
|
||||||
|
voting: { redis: false, mysql: false },
|
||||||
|
};
|
||||||
|
showAdvancedCleanupModal.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelAdvancedCleanup() {
|
||||||
|
showAdvancedCleanupModal.value = false;
|
||||||
|
confirmCleanupCode.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmAdvancedCleanup() {
|
||||||
|
if (confirmCleanupCode.value !== 'ADVANCED_CLEANUP') return;
|
||||||
|
|
||||||
|
cleanupLoading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/cleanup', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
lottery: cleanupOptions.value.lottery,
|
||||||
|
voting: cleanupOptions.value.voting,
|
||||||
|
confirmCode: confirmCleanupCode.value,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
const results = data.data;
|
||||||
|
let message = '清理完成:';
|
||||||
|
if (results.lottery?.mysql) message += `抽奖数据 ${results.lottery.dbCount} 条;`;
|
||||||
|
if (results.voting?.mysql) message += `投票数据 ${results.voting.dbCount} 条;`;
|
||||||
|
showToast(message, 'success');
|
||||||
|
} else {
|
||||||
|
showToast(data.error || '清理失败', 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showToast('网络错误', 'error');
|
||||||
|
} finally {
|
||||||
|
cleanupLoading.value = false;
|
||||||
|
showAdvancedCleanupModal.value = false;
|
||||||
|
confirmCleanupCode.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Music control
|
// Music control
|
||||||
function toggleMusic() {
|
function toggleMusic() {
|
||||||
if (admin.musicPlaying) {
|
if (admin.musicPlaying) {
|
||||||
@@ -379,6 +458,7 @@ const phaseLabel = computed(() => {
|
|||||||
case 'VOTING': return '投票中';
|
case 'VOTING': return '投票中';
|
||||||
case 'LOTTERY': return '抽奖中';
|
case 'LOTTERY': return '抽奖中';
|
||||||
case 'RESULTS': return '结果展示';
|
case 'RESULTS': return '结果展示';
|
||||||
|
case 'LOTTERY_RESULTS': return '抽奖结果';
|
||||||
default: return '未知';
|
default: return '未知';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -643,7 +723,13 @@ onMounted(() => {
|
|||||||
:disabled="admin.pendingAction === 'lottery_reset'"
|
:disabled="admin.pendingAction === 'lottery_reset'"
|
||||||
@click="resetLottery"
|
@click="resetLottery"
|
||||||
>
|
>
|
||||||
重置
|
重置本轮
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="ctrl-btn warning"
|
||||||
|
@click="showRedrawConfirm = true"
|
||||||
|
>
|
||||||
|
重抽本轮
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -800,6 +886,13 @@ onMounted(() => {
|
|||||||
>
|
>
|
||||||
结果展示
|
结果展示
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="ctrl-btn"
|
||||||
|
:class="{ active: admin.systemPhase === 'LOTTERY_RESULTS' }"
|
||||||
|
@click="setPhase('LOTTERY_RESULTS')"
|
||||||
|
>
|
||||||
|
抽奖结果
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -833,15 +926,25 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Developer Options (Collapsible) -->
|
||||||
|
<details class="control-group dev-options">
|
||||||
|
<summary><h4>开发者选项</h4></summary>
|
||||||
|
<div class="button-group">
|
||||||
|
<button class="ctrl-btn warning-outline" @click="openAdvancedCleanupModal">
|
||||||
|
高级数据清理
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
<!-- Emergency Reset -->
|
<!-- Emergency Reset -->
|
||||||
<div class="control-group danger-zone">
|
<div class="control-group danger-zone">
|
||||||
<h4>紧急操作</h4>
|
<h4>数据管理</h4>
|
||||||
<div class="button-group">
|
<div class="button-group">
|
||||||
<button class="ctrl-btn danger-outline" @click="openResetModal('voting')">
|
<button class="ctrl-btn danger-outline" @click="openResetModal('voting')">
|
||||||
重置投票
|
清空投票数据
|
||||||
</button>
|
</button>
|
||||||
<button class="ctrl-btn danger-outline" @click="openResetModal('lottery')">
|
<button class="ctrl-btn danger-outline" @click="openResetModal('lottery')">
|
||||||
重置抽奖
|
清空抽奖数据
|
||||||
</button>
|
</button>
|
||||||
<button class="ctrl-btn danger" @click="openResetModal('all')">
|
<button class="ctrl-btn danger" @click="openResetModal('all')">
|
||||||
全部重置
|
全部重置
|
||||||
@@ -855,12 +958,12 @@ onMounted(() => {
|
|||||||
<!-- Reset Confirmation Modal -->
|
<!-- Reset Confirmation Modal -->
|
||||||
<div v-if="showResetModal" class="modal-overlay" @click.self="cancelReset">
|
<div v-if="showResetModal" class="modal-overlay" @click.self="cancelReset">
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
<h3>确认重置</h3>
|
<h3>确认操作</h3>
|
||||||
<p>
|
<p>
|
||||||
您即将重置
|
您即将清空
|
||||||
<strong>{{ resetScope === 'all' ? '所有数据' : resetScope === 'voting' ? '投票数据' : '抽奖数据' }}</strong>
|
<strong>{{ resetScope === 'all' ? '所有数据' : resetScope === 'voting' ? '投票数据' : '抽奖数据' }}</strong>
|
||||||
</p>
|
</p>
|
||||||
<p class="warning-text">此操作不可撤销!</p>
|
<p class="warning-text">此操作将清除 Redis 缓存和数据库记录,不可撤销!</p>
|
||||||
<div class="modal-input">
|
<div class="modal-input">
|
||||||
<label>请输入 <code>RESET</code> 确认:</label>
|
<label>请输入 <code>RESET</code> 确认:</label>
|
||||||
<input
|
<input
|
||||||
@@ -882,6 +985,86 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Redraw Confirmation Modal -->
|
||||||
|
<div v-if="showRedrawConfirm" class="modal-overlay" @click.self="showRedrawConfirm = false">
|
||||||
|
<div class="modal">
|
||||||
|
<h3>确认重抽本轮</h3>
|
||||||
|
<p>您即将清除<strong>第{{ admin.lotteryRound }}轮</strong>的中奖者记录</p>
|
||||||
|
<p class="warning-text">清除后可重新抽取本轮,此操作不可撤销!</p>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="ctrl-btn outline" @click="showRedrawConfirm = false">取消</button>
|
||||||
|
<button class="ctrl-btn warning" @click="redrawCurrentRound">
|
||||||
|
确认重抽
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Advanced Cleanup Modal -->
|
||||||
|
<div v-if="showAdvancedCleanupModal" class="modal-overlay" @click.self="cancelAdvancedCleanup">
|
||||||
|
<div class="modal cleanup-modal">
|
||||||
|
<h3>高级数据清理</h3>
|
||||||
|
<p>选择要清理的数据类型和存储层:</p>
|
||||||
|
|
||||||
|
<div class="cleanup-sections">
|
||||||
|
<!-- Lottery Cleanup Options -->
|
||||||
|
<div class="cleanup-section">
|
||||||
|
<h4>🎁 抽奖数据</h4>
|
||||||
|
<div class="cleanup-options">
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" v-model="cleanupOptions.lottery.redis" />
|
||||||
|
<span>Redis 缓存</span>
|
||||||
|
<small>(中奖者集合、抽奖历史)</small>
|
||||||
|
</label>
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" v-model="cleanupOptions.lottery.mysql" />
|
||||||
|
<span>MySQL 数据库</span>
|
||||||
|
<small>(中奖记录、抽奖会话)</small>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Voting Cleanup Options -->
|
||||||
|
<div class="cleanup-section">
|
||||||
|
<h4>🗳️ 投票数据</h4>
|
||||||
|
<div class="cleanup-options">
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" v-model="cleanupOptions.voting.redis" />
|
||||||
|
<span>Redis 缓存</span>
|
||||||
|
<small>(实时票数、排行榜)</small>
|
||||||
|
</label>
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" v-model="cleanupOptions.voting.mysql" />
|
||||||
|
<span>MySQL 数据库</span>
|
||||||
|
<small>(投票记录、票数统计)</small>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="warning-text">⚠️ 此操作不可撤销!系统状态将保持不变。</p>
|
||||||
|
<div class="modal-input">
|
||||||
|
<label>请输入 <code>ADVANCED_CLEANUP</code> 确认:</label>
|
||||||
|
<input
|
||||||
|
v-model="confirmCleanupCode"
|
||||||
|
type="text"
|
||||||
|
placeholder="ADVANCED_CLEANUP"
|
||||||
|
@keyup.enter="confirmAdvancedCleanup"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="ctrl-btn outline" @click="cancelAdvancedCleanup">取消</button>
|
||||||
|
<button
|
||||||
|
class="ctrl-btn warning"
|
||||||
|
:disabled="confirmCleanupCode !== 'ADVANCED_CLEANUP' || cleanupLoading"
|
||||||
|
@click="confirmAdvancedCleanup"
|
||||||
|
>
|
||||||
|
{{ cleanupLoading ? '清理中...' : '确认清理' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -1368,6 +1551,16 @@ $admin-danger: #ef4444;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.warning-outline {
|
||||||
|
background: transparent;
|
||||||
|
border-color: $admin-warning;
|
||||||
|
color: $admin-warning;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: rgba($admin-warning, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&.outline {
|
&.outline {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border-color: $admin-border;
|
border-color: $admin-border;
|
||||||
@@ -1535,6 +1728,47 @@ $admin-danger: #ef4444;
|
|||||||
color: $admin-danger;
|
color: $admin-danger;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.warning-zone {
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid rgba($admin-warning, 0.3);
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
color: $admin-warning;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.dev-options {
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid rgba($admin-warning, 0.3);
|
||||||
|
|
||||||
|
summary {
|
||||||
|
cursor: pointer;
|
||||||
|
list-style: none;
|
||||||
|
|
||||||
|
&::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
display: inline;
|
||||||
|
color: $admin-warning;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '▶ ';
|
||||||
|
font-size: 0.8em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&[open] summary h4::before {
|
||||||
|
content: '▼ ';
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-group {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Modal
|
// Modal
|
||||||
@@ -1609,6 +1843,75 @@ $admin-danger: #ef4444;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Advanced Cleanup Modal Styles
|
||||||
|
.cleanup-modal {
|
||||||
|
width: 500px;
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cleanup-sections {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cleanup-section {
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
border: 1px solid $admin-border;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
font-size: 14px;
|
||||||
|
color: $admin-text;
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cleanup-options {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
.checkbox-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="checkbox"] {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
accent-color: $admin-warning;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-size: 14px;
|
||||||
|
color: $admin-text;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 12px;
|
||||||
|
color: $admin-text-muted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Award Statistics Styles
|
// Award Statistics Styles
|
||||||
.award-stats {
|
.award-stats {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -1899,6 +2202,30 @@ $admin-danger: #ef4444;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cleanup-modal {
|
||||||
|
width: 95vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cleanup-section {
|
||||||
|
padding: 12px;
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cleanup-options .checkbox-label {
|
||||||
|
padding: 8px 10px;
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.modal-input input {
|
.modal-input input {
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import multer from 'multer';
|
|||||||
import { participantService } from '../services/participant.service';
|
import { participantService } from '../services/participant.service';
|
||||||
import { prizeConfigService } from '../services/prize-config.service';
|
import { prizeConfigService } from '../services/prize-config.service';
|
||||||
import { programConfigService } from '../services/program-config.service';
|
import { programConfigService } from '../services/program-config.service';
|
||||||
|
import { adminService } from '../services/admin.service';
|
||||||
|
|
||||||
const router: IRouter = Router();
|
const router: IRouter = Router();
|
||||||
const upload = multer({ storage: multer.memoryStorage() });
|
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;
|
export default router;
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
import { EventEmitter } from 'events';
|
import { EventEmitter } from 'events';
|
||||||
import { redis } from '../config/redis';
|
import { redis } from '../config/redis';
|
||||||
|
import { prisma } from '../config/db';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
import { prizeConfigService } from './prize-config.service';
|
import { prizeConfigService } from './prize-config.service';
|
||||||
import { programConfigService } from './program-config.service';
|
import { programConfigService } from './program-config.service';
|
||||||
@@ -436,10 +437,14 @@ class AdminService extends EventEmitter {
|
|||||||
if (this.state.lottery.subPhase !== 'STORM') {
|
if (this.state.lottery.subPhase !== 'STORM') {
|
||||||
return { success: false, message: 'Must be in STORM state to reveal' };
|
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();
|
const winners = this.pickRandomWinners();
|
||||||
this.state.lottery.subPhase = 'REVEAL';
|
this.state.lottery.subPhase = 'REVEAL';
|
||||||
this.state.lottery.currentWinners = winners;
|
this.state.lottery.currentWinners = winners;
|
||||||
|
|
||||||
|
// Persist winners to database
|
||||||
|
await this.persistLotteryResults(winners);
|
||||||
|
|
||||||
// Auto-play fanfare music
|
// Auto-play fanfare music
|
||||||
this.state.music.isPlaying = true;
|
this.state.music.isPlaying = true;
|
||||||
this.state.music.track = 'fanfare';
|
this.state.music.track = 'fanfare';
|
||||||
@@ -478,17 +483,9 @@ class AdminService extends EventEmitter {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'reset':
|
case 'reset':
|
||||||
// 使用服务器配置获取第一轮奖项
|
// 仅重置当前轮次状态到 IDLE,不清理数据
|
||||||
const resetPrizes = prizeConfigService.getPrizes();
|
this.state.lottery.subPhase = 'IDLE';
|
||||||
const firstPrize = resetPrizes.find(p => p.round === 1) || resetPrizes[0] || PRIZE_CONFIG[0];
|
this.state.lottery.currentWinners = [];
|
||||||
this.state.lottery = {
|
|
||||||
round: 1,
|
|
||||||
subPhase: 'IDLE',
|
|
||||||
prizeLevel: firstPrize?.level || '第一轮抽奖',
|
|
||||||
prizeName: firstPrize?.name || '幸运伴手礼',
|
|
||||||
winnerCount: firstPrize?.winnerCount || 7,
|
|
||||||
currentWinners: [],
|
|
||||||
};
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
await this.saveState();
|
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
|
* Pick random winners from real participants
|
||||||
*/
|
*/
|
||||||
@@ -540,6 +570,106 @@ class AdminService extends EventEmitter {
|
|||||||
return winners;
|
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
|
* Emergency reset
|
||||||
*/
|
*/
|
||||||
@@ -560,8 +690,9 @@ class AdminService extends EventEmitter {
|
|||||||
awards: programConfigService.getAwards(),
|
awards: programConfigService.getAwards(),
|
||||||
allowLateCatch: programSettings.allowLateCatch,
|
allowLateCatch: programSettings.allowLateCatch,
|
||||||
};
|
};
|
||||||
// Clear voting data in Redis
|
// Clear voting data in Redis and Database
|
||||||
await this.clearVotingRedisData();
|
await this.clearVotingRedisData();
|
||||||
|
await this.clearVotingDbData();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (scope === 'all' || scope === 'lottery') {
|
if (scope === 'all' || scope === 'lottery') {
|
||||||
@@ -576,6 +707,9 @@ class AdminService extends EventEmitter {
|
|||||||
winnerCount: firstPrize?.winnerCount || 7,
|
winnerCount: firstPrize?.winnerCount || 7,
|
||||||
currentWinners: [],
|
currentWinners: [],
|
||||||
};
|
};
|
||||||
|
// Clear lottery data in Redis and Database
|
||||||
|
await this.clearLotteryRedisData();
|
||||||
|
await this.clearLotteryDbData();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (scope === 'all') {
|
if (scope === 'all') {
|
||||||
@@ -692,6 +826,180 @@ class AdminService extends EventEmitter {
|
|||||||
} while (cursor !== '0');
|
} 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();
|
export const adminService = new AdminService();
|
||||||
|
|||||||
Reference in New Issue
Block a user