chore: sync various improvements and fixes

- Update gitignore and serena config
- Improve connection and voting stores
- Enhance admin routes and socket handling
- Update client-screen views
- Add auth middleware

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
empty
2026-02-03 23:31:38 +08:00
parent 39caecdd95
commit 83bf1d3a43
25 changed files with 284 additions and 122 deletions

2
.gitignore vendored
View File

@@ -41,3 +41,5 @@ packages/server/prisma/*.db-journal
# Data files # Data files
参与抽奖人员名单.xlsx 参与抽奖人员名单.xlsx
测试清单.md 测试清单.md
.codeartsdoer

View File

@@ -84,6 +84,27 @@ excluded_tools: []
# initial prompt for the project. It will always be given to the LLM upon activating the project # initial prompt for the project. It will always be given to the LLM upon activating the project
# (contrary to the memories, which are loaded on demand). # (contrary to the memories, which are loaded on demand).
initial_prompt: "" initial_prompt: ""
# the name by which the project can be referenced within Serena
project_name: "company-celebration2" project_name: "company-celebration2"
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default)
included_optional_tools: [] included_optional_tools: []
# list of mode names to that are always to be included in the set of active modes
# The full set of modes to be activated is base_modes + default_modes.
# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this setting overrides the global configuration.
# Set this to [] to disable base modes for this project.
# Set this to a list of mode names to always include the respective modes for this project.
base_modes:
# list of mode names that are to be activated by default.
# The full set of modes to be activated is base_modes + default_modes.
# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
# This setting can, in turn, be overridden by CLI parameters (--mode).
default_modes:
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
fixed_tools: []

View File

@@ -12,7 +12,6 @@ declare module 'vue' {
ProgramCard: typeof import('./components/ProgramCard.vue')['default'] ProgramCard: typeof import('./components/ProgramCard.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']
StampDock: typeof import('./components/StampDock.vue')['default']
VanButton: typeof import('vant/es')['Button'] VanButton: typeof import('vant/es')['Button']
VanCell: typeof import('vant/es')['Cell'] VanCell: typeof import('vant/es')['Cell']
VanCellGroup: typeof import('vant/es')['CellGroup'] VanCellGroup: typeof import('vant/es')['CellGroup']

View File

@@ -18,7 +18,7 @@ type GalaSocket = Socket<ServerToClientEvents, ClientToServerEvents>;
const STORAGE_KEYS = { const STORAGE_KEYS = {
USER_ID: 'gala_user_id', USER_ID: 'gala_user_id',
USER_NAME: 'gala_user_name', USER_NAME: 'gala_user_name',
DEPARTMENT: 'gala_department', SESSION_TOKEN: 'gala_session_token',
}; };
// Helper functions for localStorage // Helper functions for localStorage
@@ -50,7 +50,7 @@ export const useConnectionStore = defineStore('connection', () => {
const reconnectAttempts = ref(0); const reconnectAttempts = ref(0);
const userId = ref<string | null>(loadFromStorage(STORAGE_KEYS.USER_ID, null)); const userId = ref<string | null>(loadFromStorage(STORAGE_KEYS.USER_ID, null));
const userName = ref<string | null>(loadFromStorage(STORAGE_KEYS.USER_NAME, null)); const userName = ref<string | null>(loadFromStorage(STORAGE_KEYS.USER_NAME, null));
const department = ref<string | null>(loadFromStorage(STORAGE_KEYS.DEPARTMENT, null)); const sessionToken = ref<string | null>(loadFromStorage(STORAGE_KEYS.SESSION_TOKEN, null));
const votedCategories = ref<VoteCategory[]>([]); const votedCategories = ref<VoteCategory[]>([]);
// Computed // Computed
@@ -85,6 +85,7 @@ export const useConnectionStore = defineStore('connection', () => {
reconnectionDelayMax: CONFIG.RECONNECTION_DELAY_MAX_MS, reconnectionDelayMax: CONFIG.RECONNECTION_DELAY_MAX_MS,
timeout: CONFIG.HEARTBEAT_TIMEOUT_MS, timeout: CONFIG.HEARTBEAT_TIMEOUT_MS,
transports: ['websocket', 'polling'], transports: ['websocket', 'polling'],
auth: sessionToken.value ? { token: sessionToken.value } : undefined,
}); });
// Connection events // Connection events
@@ -193,8 +194,8 @@ export const useConnectionStore = defineStore('connection', () => {
{ {
userId: userId.value, userId: userId.value,
userName: userName.value || 'Guest', userName: userName.value || 'Guest',
department: department.value || '未知部门',
role: 'user', role: 'user',
sessionToken: sessionToken.value || undefined,
}, },
(response: any) => { (response: any) => {
if (response.success) { if (response.success) {
@@ -264,15 +265,17 @@ export const useConnectionStore = defineStore('connection', () => {
/** /**
* Set user info (and persist to localStorage) * Set user info (and persist to localStorage)
*/ */
function setUser(id: string, name: string, dept: string) { function setUser(id: string, name: string, token?: string) {
userId.value = id; userId.value = id;
userName.value = name; userName.value = name;
department.value = dept; if (token) {
sessionToken.value = token;
saveToStorage(STORAGE_KEYS.SESSION_TOKEN, token);
}
// Persist to localStorage // Persist to localStorage
saveToStorage(STORAGE_KEYS.USER_ID, id); saveToStorage(STORAGE_KEYS.USER_ID, id);
saveToStorage(STORAGE_KEYS.USER_NAME, name); saveToStorage(STORAGE_KEYS.USER_NAME, name);
saveToStorage(STORAGE_KEYS.DEPARTMENT, dept);
// Rejoin if already connected // Rejoin if already connected
if (socket.value?.connected) { if (socket.value?.connected) {
@@ -287,13 +290,13 @@ export const useConnectionStore = defineStore('connection', () => {
// Clear state // Clear state
userId.value = null; userId.value = null;
userName.value = null; userName.value = null;
department.value = null;
votedCategories.value = []; votedCategories.value = [];
sessionToken.value = null;
// Clear localStorage // Clear localStorage
localStorage.removeItem(STORAGE_KEYS.USER_ID); localStorage.removeItem(STORAGE_KEYS.USER_ID);
localStorage.removeItem(STORAGE_KEYS.USER_NAME); localStorage.removeItem(STORAGE_KEYS.USER_NAME);
localStorage.removeItem(STORAGE_KEYS.DEPARTMENT); localStorage.removeItem(STORAGE_KEYS.SESSION_TOKEN);
// Disconnect socket // Disconnect socket
disconnect(); disconnect();
@@ -333,7 +336,7 @@ export const useConnectionStore = defineStore('connection', () => {
reconnectAttempts, reconnectAttempts,
userId, userId,
userName, userName,
department, sessionToken,
votedCategories, votedCategories,
// Computed // Computed

View File

@@ -152,6 +152,10 @@ export const useVotingStore = defineStore('voting', () => {
showToast({ message: '请先选择一个奖项', position: 'bottom' }); showToast({ message: '请先选择一个奖项', position: 'bottom' });
return false; return false;
} }
if (!connectionStore.sessionToken) {
showToast({ message: '请先扫码登录', position: 'bottom' });
return false;
}
// 检查是否已为该节目投过票(任何奖项) // 检查是否已为该节目投过票(任何奖项)
const existingAward = getProgramAward(programId); const existingAward = getProgramAward(programId);

View File

@@ -14,7 +14,6 @@ const isValidating = ref<boolean>(true);
const isValid = ref<boolean>(false); const isValid = ref<boolean>(false);
const isSubmitting = ref<boolean>(false); const isSubmitting = ref<boolean>(false);
const userName = ref<string>(''); const userName = ref<string>('');
const department = ref<string>('技术部');
const errorMessage = ref<string>(''); const errorMessage = ref<string>('');
// API base URL - use LAN IP for mobile access // API base URL - use LAN IP for mobile access
@@ -22,7 +21,7 @@ const apiUrl = import.meta.env.VITE_API_URL || 'http://192.168.1.5:3000';
// Computed // Computed
const canSubmit = computed(() => { const canSubmit = computed(() => {
return userName.value.trim().length > 0 && department.value.trim().length > 0; return userName.value.trim().length > 0;
}); });
// Validate token on mount // Validate token on mount
@@ -92,7 +91,6 @@ async function handleSubmit() {
body: JSON.stringify({ body: JSON.stringify({
scanToken: token.value, scanToken: token.value,
userName: userName.value.trim(), userName: userName.value.trim(),
department: department.value.trim(),
}), }),
}); });
@@ -101,8 +99,9 @@ async function handleSubmit() {
if (result.success) { if (result.success) {
// Set user info in connection store // Set user info in connection store
const userId = result.data?.sessionToken || `user_${Date.now()}`; const userId = result.data?.userId || `user_${Date.now()}`;
connectionStore.setUser(userId, userName.value.trim(), department.value.trim()); const sessionToken = result.data?.sessionToken;
connectionStore.setUser(userId, userName.value.trim(), sessionToken);
showToast({ message: '登录成功!', type: 'success' }); showToast({ message: '登录成功!', type: 'success' });
@@ -156,13 +155,6 @@ async function handleSubmit() {
:rules="[{ required: true, message: '请输入姓名' }]" :rules="[{ required: true, message: '请输入姓名' }]"
maxlength="20" maxlength="20"
/> />
<van-field
v-model="department"
label="部门"
placeholder="请输入您的部门"
:rules="[{ required: true, message: '请输入部门' }]"
maxlength="20"
/>
</van-cell-group> </van-cell-group>
<div class="button-wrapper"> <div class="button-wrapper">

View File

@@ -2,24 +2,18 @@ import { createRouter, createWebHistory, type RouteLocationNormalized } from 'vu
// Admin auth constants // Admin auth constants
const ADMIN_TOKEN_KEY = 'gala_admin_token'; const ADMIN_TOKEN_KEY = 'gala_admin_token';
const ADMIN_ACCESS_CODE = '20268888';
// Auth guard for admin routes // Auth guard for admin routes
function requireAdminAuth(to: RouteLocationNormalized) { function requireAdminAuth(to: RouteLocationNormalized) {
const token = localStorage.getItem(ADMIN_TOKEN_KEY); const token = localStorage.getItem(ADMIN_TOKEN_KEY);
if (!token || token !== generateToken(ADMIN_ACCESS_CODE)) { if (!token) {
return { path: '/admin/login', query: { redirect: to.fullPath } }; return { path: '/admin/login', query: { redirect: to.fullPath } };
} }
return true; return true;
} }
// Simple token generator (not cryptographically secure, but sufficient for internal event)
function generateToken(code: string): string {
return btoa(`gala2026:${code}:${code.split('').reverse().join('')}`);
}
// Export for use in login component // Export for use in login component
export { ADMIN_TOKEN_KEY, ADMIN_ACCESS_CODE, generateToken }; export { ADMIN_TOKEN_KEY };
const router = createRouter({ const router = createRouter({
history: createWebHistory('/screen/'), history: createWebHistory('/screen/'),

View File

@@ -14,6 +14,7 @@ import type {
} from '@gala/shared/types'; } from '@gala/shared/types';
import { PRIZE_CONFIG } from '@gala/shared/types'; import { PRIZE_CONFIG } from '@gala/shared/types';
import { SOCKET_EVENTS } from '@gala/shared/constants'; import { SOCKET_EVENTS } from '@gala/shared/constants';
import { ADMIN_TOKEN_KEY } from '../router';
type GalaSocket = Socket<ServerToClientEvents, ClientToServerEvents>; type GalaSocket = Socket<ServerToClientEvents, ClientToServerEvents>;
@@ -162,6 +163,8 @@ export const useAdminStore = defineStore('admin', () => {
isConnecting.value = true; isConnecting.value = true;
restoreState(); restoreState();
const adminToken = localStorage.getItem(ADMIN_TOKEN_KEY) || '';
const socketInstance = io(import.meta.env.VITE_SOCKET_URL || '', { const socketInstance = io(import.meta.env.VITE_SOCKET_URL || '', {
reconnection: true, reconnection: true,
reconnectionAttempts: Infinity, reconnectionAttempts: Infinity,
@@ -169,6 +172,7 @@ export const useAdminStore = defineStore('admin', () => {
reconnectionDelayMax: 5000, reconnectionDelayMax: 5000,
timeout: 10000, timeout: 10000,
transports: ['websocket', 'polling'], transports: ['websocket', 'polling'],
auth: adminToken ? { token: adminToken } : undefined,
}); });
socketInstance.on('connect', () => { socketInstance.on('connect', () => {
@@ -181,6 +185,7 @@ export const useAdminStore = defineStore('admin', () => {
userId: 'admin_main', userId: 'admin_main',
userName: 'Admin Console', userName: 'Admin Console',
role: 'admin', role: 'admin',
sessionToken: adminToken || undefined,
}, () => { }); }, () => { });
// Request state sync // Request state sync

View File

@@ -4,6 +4,7 @@ import { useRouter } from 'vue-router';
import { useAdminStore } from '../stores/admin'; import { useAdminStore } from '../stores/admin';
import { PRIZE_CONFIG } from '@gala/shared/types'; import { PRIZE_CONFIG } from '@gala/shared/types';
import type { PrizeConfig, LotteryRound } from '@gala/shared/types'; import type { PrizeConfig, LotteryRound } from '@gala/shared/types';
import { ADMIN_TOKEN_KEY } from '../router';
// 简单的防抖函数 // 简单的防抖函数
function debounce<T extends (...args: any[]) => void>(fn: T, delay: number = 300): (...args: Parameters<T>) => void { function debounce<T extends (...args: any[]) => void>(fn: T, delay: number = 300): (...args: Parameters<T>) => void {
@@ -35,6 +36,15 @@ function debounceLeading<T extends (...args: any[]) => void>(fn: T, delay: numbe
const router = useRouter(); const router = useRouter();
const admin = useAdminStore(); const admin = useAdminStore();
const adminToken = () => localStorage.getItem(ADMIN_TOKEN_KEY) || '';
function getAdminHeaders(extra?: Record<string, string>) {
return {
'Content-Type': 'application/json',
'x-session-token': adminToken(),
...extra,
};
}
// Local UI state // Local UI state
const confirmResetCode = ref(''); const confirmResetCode = ref('');
@@ -89,7 +99,9 @@ async function readJsonSafe(res: Response): Promise<any> {
async function loadPrizeConfig() { async function loadPrizeConfig() {
prizeConfigLoading.value = true; prizeConfigLoading.value = true;
try { try {
const res = await fetch('/api/admin/prizes'); const res = await fetch('/api/admin/prizes', {
headers: getAdminHeaders(),
});
const data = await readJsonSafe(res); const data = await readJsonSafe(res);
if (!res.ok) { if (!res.ok) {
throw new Error(data?.error || data?.message || `加载奖项配置失败(${res.status})`); throw new Error(data?.error || data?.message || `加载奖项配置失败(${res.status})`);
@@ -112,7 +124,7 @@ async function savePrizeConfig() {
try { try {
const res = await fetch('/api/admin/prizes', { const res = await fetch('/api/admin/prizes', {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: getAdminHeaders(),
body: JSON.stringify({ prizes: editingPrizes.value }), body: JSON.stringify({ prizes: editingPrizes.value }),
}); });
const data = await readJsonSafe(res); const data = await readJsonSafe(res);
@@ -172,6 +184,7 @@ async function importParticipants() {
const response = await fetch('/api/admin/participants/import', { const response = await fetch('/api/admin/participants/import', {
method: 'POST', method: 'POST',
headers: { 'x-session-token': adminToken() },
body: formData, body: formData,
}); });
@@ -219,7 +232,9 @@ const tagLabels: Record<string, string> = {
// Load existing participants from server // Load existing participants from server
async function loadParticipants() { async function loadParticipants() {
try { try {
const response = await fetch('/api/admin/participants'); const response = await fetch('/api/admin/participants', {
headers: getAdminHeaders(),
});
const data = await readJsonSafe(response); const data = await readJsonSafe(response);
if (!response.ok) { if (!response.ok) {
throw new Error(data?.error || data?.message || `加载参与者失败(${response.status})`); throw new Error(data?.error || data?.message || `加载参与者失败(${response.status})`);
@@ -331,7 +346,7 @@ async function redrawCurrentRound() {
try { try {
const res = await fetch('/api/admin/lottery/redraw', { const res = await fetch('/api/admin/lottery/redraw', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: getAdminHeaders(),
}); });
const data = await res.json(); const data = await res.json();
if (data.success) { if (data.success) {
@@ -386,7 +401,7 @@ async function confirmAdvancedCleanup() {
try { try {
const res = await fetch('/api/admin/cleanup', { const res = await fetch('/api/admin/cleanup', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: getAdminHeaders(),
body: JSON.stringify({ body: JSON.stringify({
lottery: cleanupOptions.value.lottery, lottery: cleanupOptions.value.lottery,
voting: cleanupOptions.value.voting, voting: cleanupOptions.value.voting,

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { ref } from 'vue';
import { useRouter, useRoute } from 'vue-router'; import { useRouter, useRoute } from 'vue-router';
import { ADMIN_TOKEN_KEY, ADMIN_ACCESS_CODE, generateToken } from '../router'; import { ADMIN_TOKEN_KEY } from '../router';
const router = useRouter(); const router = useRouter();
const route = useRoute(); const route = useRoute();
@@ -20,22 +20,29 @@ async function handleLogin() {
isLoading.value = true; isLoading.value = true;
// Simulate network delay for UX try {
await new Promise(resolve => setTimeout(resolve, 500)); const response = await fetch('/api/admin/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ accessCode: accessCode.value.trim() }),
});
const result = await response.json();
if (accessCode.value === ADMIN_ACCESS_CODE) { if (!response.ok || !result.success) {
// Save token to localStorage error.value = result.error || '访问码错误';
localStorage.setItem(ADMIN_TOKEN_KEY, generateToken(ADMIN_ACCESS_CODE));
// Redirect to console or original destination
const redirect = route.query.redirect as string || '/admin/director-console';
router.push(redirect);
} else {
error.value = '访问码错误';
accessCode.value = ''; accessCode.value = '';
return;
} }
localStorage.setItem(ADMIN_TOKEN_KEY, result.data.sessionToken);
const redirect = route.query.redirect as string || '/admin/director-console';
router.push(redirect);
} catch (e) {
error.value = '网络错误,请重试';
} finally {
isLoading.value = false; isLoading.value = false;
}
} }
function handleKeydown(e: KeyboardEvent) { function handleKeydown(e: KeyboardEvent) {

View File

@@ -22,7 +22,9 @@ function goBack() {
// Fetch lottery results from API // Fetch lottery results from API
async function fetchLotteryResults() { async function fetchLotteryResults() {
try { try {
const res = await fetch('/api/admin/lottery/results'); const res = await fetch('/api/admin/lottery/results', {
headers: { 'x-session-token': localStorage.getItem('gala_admin_token') || '' },
});
const data = await res.json(); const data = await res.json();
if (data.success && data.data?.draws) { if (data.success && data.data?.draws) {
lotteryResults.value = data.data.draws; lotteryResults.value = data.data.draws;

View File

@@ -30,7 +30,7 @@ const prizes = ref<Array<{ level: string; name: string; winnerCount: number; poo
// 从 API 获取奖项配置 // 从 API 获取奖项配置
async function fetchPrizes() { async function fetchPrizes() {
try { try {
const response = await fetch('/api/admin/prizes'); const response = await fetch('/api/public/prizes');
const data = await response.json(); const data = await response.json();
if (data.success && data.data?.prizes) { if (data.success && data.data?.prizes) {
prizes.value = data.data.prizes; prizes.value = data.data.prizes;
@@ -53,7 +53,7 @@ let realParticipants: Participant[] = [];
async function fetchParticipants() { async function fetchParticipants() {
try { try {
isLoading.value = true; isLoading.value = true;
const response = await fetch('/api/admin/participants'); const response = await fetch('/api/public/participants');
const data = await response.json(); const data = await response.json();
if (data.success && data.data?.participants) { if (data.success && data.data?.participants) {

View File

@@ -146,7 +146,6 @@ scenarios:
userId: "{{ userId }}" userId: "{{ userId }}"
userName: "压测用户-{{ userId }}" userName: "压测用户-{{ userId }}"
role: "user" role: "user"
department: "测试部门"
- think: 0.5 - think: 0.5
- loop: - loop:
- function: "selectRandomProgram" - function: "selectRandomProgram"
@@ -176,7 +175,6 @@ scenarios:
userId: "{{ userId }}" userId: "{{ userId }}"
userName: "速投用户-{{ userId }}" userName: "速投用户-{{ userId }}"
role: "user" role: "user"
department: "压力测试"
- loop: - loop:
- function: "selectRandomProgram" - function: "selectRandomProgram"
- function: "selectRandomTicketType" - function: "selectRandomTicketType"
@@ -204,5 +202,4 @@ scenarios:
userId: "{{ userId }}" userId: "{{ userId }}"
userName: "观望用户-{{ userId }}" userName: "观望用户-{{ userId }}"
role: "user" role: "user"
department: "观望组"
- think: 8 # 长时间停留 - think: 8 # 长时间停留

View File

@@ -81,7 +81,6 @@ scenarios:
userId: "{{ userId }}" userId: "{{ userId }}"
userName: "快投-{{ userId }}" userName: "快投-{{ userId }}"
role: "user" role: "user"
department: "快速组"
# 快速投7票每票间隔 0.2-0.4 秒) # 快速投7票每票间隔 0.2-0.4 秒)
- loop: - loop:
- function: "selectSequentialTicket" - function: "selectSequentialTicket"
@@ -108,7 +107,6 @@ scenarios:
userId: "{{ userId }}" userId: "{{ userId }}"
userName: "正常-{{ userId }}" userName: "正常-{{ userId }}"
role: "user" role: "user"
department: "正常组"
# 正常速度投7票每票间隔 1-2 秒,看节目、思考) # 正常速度投7票每票间隔 1-2 秒,看节目、思考)
- loop: - loop:
- function: "selectSequentialTicket" - function: "selectSequentialTicket"
@@ -136,7 +134,6 @@ scenarios:
userId: "{{ userId }}" userId: "{{ userId }}"
userName: "慢投-{{ userId }}" userName: "慢投-{{ userId }}"
role: "user" role: "user"
department: "慢速组"
# 先观望 5-10 秒 # 先观望 5-10 秒
- function: "randomLongDelay" - function: "randomLongDelay"
- think: "{{ longDelay }}" - think: "{{ longDelay }}"

View File

@@ -12,6 +12,13 @@ import { participantService } from './services/participant.service';
async function main(): Promise<void> { async function main(): Promise<void> {
try { try {
if (!config.adminAccessCode) {
logger.warn('ADMIN_ACCESS_CODE 未配置,管理员登录将被拒绝');
}
if (!config.isDev && config.jwtSecret === 'dev-secret-change-in-production') {
logger.error('生产环境禁止使用默认 JWT_SECRET请配置环境变量');
process.exit(1);
}
// Connect to Database // Connect to Database
logger.info('Connecting to Database...'); logger.info('Connecting to Database...');
await connectDatabase(); await connectDatabase();
@@ -79,4 +86,3 @@ async function main(): Promise<void> {
} }
main(); main();

View File

@@ -0,0 +1,64 @@
import type { Request, Response, NextFunction } from 'express';
import { extractBearerToken, verifySessionToken } from '../utils/auth';
export interface AuthenticatedRequest extends Request {
user?: {
userId: string;
userName?: string;
role: 'user' | 'admin' | 'screen';
token: string;
};
}
function getTokenFromRequest(req: Request): string | null {
const bearer = extractBearerToken(req.headers.authorization);
if (bearer) return bearer;
const headerToken = req.headers['x-session-token'];
if (typeof headerToken === 'string' && headerToken.trim()) {
return headerToken.trim();
}
return null;
}
export async function requireAuth(
req: AuthenticatedRequest,
res: Response,
next: NextFunction
): Promise<void> {
const token = getTokenFromRequest(req);
if (!token) {
res.status(401).json({ success: false, error: 'UNAUTHORIZED', message: 'Missing session token' });
return;
}
const user = await verifySessionToken(token);
if (!user) {
res.status(401).json({ success: false, error: 'UNAUTHORIZED', message: 'Invalid session token' });
return;
}
req.user = user;
next();
}
export async function requireAdmin(
req: AuthenticatedRequest,
res: Response,
next: NextFunction
): Promise<void> {
const token = getTokenFromRequest(req);
if (!token) {
res.status(401).json({ success: false, error: 'UNAUTHORIZED', message: 'Missing session token' });
return;
}
const user = await verifySessionToken(token);
if (!user || user.role !== 'admin') {
res.status(403).json({ success: false, error: 'FORBIDDEN', message: 'Admin access required' });
return;
}
req.user = user;
next();
}

View File

@@ -4,10 +4,50 @@ 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'; import { adminService } from '../services/admin.service';
import { requireAdmin } from '../middleware/auth';
import { createSessionToken } from '../utils/auth';
import { config } from '../config';
const router: IRouter = Router(); const router: IRouter = Router();
const upload = multer({ storage: multer.memoryStorage() }); const upload = multer({ storage: multer.memoryStorage() });
/**
* POST /api/admin/login
* Admin login to obtain session token
*/
router.post('/login', async (req, res, next) => {
try {
const { accessCode } = req.body as { accessCode?: string };
if (!accessCode) {
return res.status(400).json({ success: false, error: '访问码不能为空' });
}
if (!config.adminAccessCode) {
return res.status(500).json({ success: false, error: '服务端未配置访问码' });
}
if (accessCode !== config.adminAccessCode) {
return res.status(401).json({ success: false, error: '访问码错误' });
}
const sessionToken = await createSessionToken(
{ userId: 'admin_main', userName: 'Admin Console', role: 'admin' },
config.adminTokenTtlSeconds
);
return res.json({
success: true,
data: {
sessionToken,
expiresIn: config.adminTokenTtlSeconds,
},
});
} catch (error) {
next(error);
}
});
// Admin auth guard (applies to all routes below)
router.use(requireAdmin);
/** /**
* GET /api/admin/stats * GET /api/admin/stats
* Get system statistics * Get system statistics
@@ -296,4 +336,3 @@ router.post('/cleanup', async (req, res, next) => {
}); });
export default router; export default router;

View File

@@ -97,19 +97,18 @@ router.post('/scanned', async (req, res, next) => {
*/ */
router.post('/confirm', async (req, res, next) => { router.post('/confirm', async (req, res, next) => {
try { try {
const { scanToken, userName, department } = req.body; const { scanToken, userName } = req.body;
if (!scanToken || !userName || !department) { if (!scanToken || !userName) {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
error: 'scanToken, userName, and department are required', error: 'scanToken and userName are required',
}); });
} }
const result = await scanLoginService.confirmLogin( const result = await scanLoginService.confirmLogin(
scanToken, scanToken,
userName, userName
department
); );
if (result.success && result.data) { if (result.success && result.data) {
@@ -121,7 +120,6 @@ router.post('/confirm', async (req, res, next) => {
userInfo: { userInfo: {
userId: result.data.userId, userId: result.data.userId,
userName, userName,
department,
sessionToken: result.data.sessionToken, sessionToken: result.data.sessionToken,
}, },
}; };

View File

@@ -3,6 +3,7 @@ import { voteService } from '../services/vote.service';
import { voteSubmitSchema } from '@gala/shared/utils'; import { voteSubmitSchema } from '@gala/shared/utils';
import { VOTE_CATEGORIES } from '@gala/shared/types'; import { VOTE_CATEGORIES } from '@gala/shared/types';
import type { VoteCategory } from '@gala/shared/types'; import type { VoteCategory } from '@gala/shared/types';
import { requireAuth } from '../middleware/auth';
const router: IRouter = Router(); const router: IRouter = Router();
@@ -10,10 +11,9 @@ const router: IRouter = Router();
* POST /api/vote/submit * POST /api/vote/submit
* Submit a vote (HTTP fallback for WebSocket) * Submit a vote (HTTP fallback for WebSocket)
*/ */
router.post('/submit', async (req, res, next) => { router.post('/submit', requireAuth, async (req, res, next) => {
try { try {
// TODO: Get userId from auth middleware const userId = (req as any).user?.userId as string;
const userId = req.headers['x-user-id'] as string;
if (!userId) { if (!userId) {
return res.status(401).json({ return res.status(401).json({
success: false, success: false,
@@ -121,9 +121,9 @@ router.get('/results', async (_req, res, next) => {
* GET /api/vote/status * GET /api/vote/status
* Get user's vote status * Get user's vote status
*/ */
router.get('/status', async (req, res, next) => { router.get('/status', requireAuth, async (req, res, next) => {
try { try {
const userId = req.headers['x-user-id'] as string; const userId = (req as any).user?.userId as string;
if (!userId) { if (!userId) {
return res.status(401).json({ return res.status(401).json({
success: false, success: false,

View File

@@ -326,7 +326,6 @@ class AdminService extends EventEmitter {
async addVoteStamp( async addVoteStamp(
programId: string, programId: string,
userName: string, userName: string,
department: string,
ticketType: string, ticketType: string,
options?: { revokedProgramId?: string } options?: { revokedProgramId?: string }
): Promise<{ success: boolean; stamp?: VoteStamp; programVotes?: number; totalVotes?: number; revokedProgramId?: string; revokedProgramVotes?: number }> { ): Promise<{ success: boolean; stamp?: VoteStamp; programVotes?: number; totalVotes?: number; revokedProgramId?: string; revokedProgramVotes?: number }> {
@@ -353,7 +352,6 @@ class AdminService extends EventEmitter {
const stamp: VoteStamp = { const stamp: VoteStamp = {
id: `stamp_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`, id: `stamp_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`,
userName, userName,
department,
ticketType, ticketType,
x: 10 + Math.random() * 80, // Random X position (10-90%) x: 10 + Math.random() * 80, // Random X position (10-90%)
y: 10 + Math.random() * 80, // Random Y position (10-90%) y: 10 + Math.random() * 80, // Random Y position (10-90%)

View File

@@ -1,12 +1,11 @@
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import jwt from 'jsonwebtoken';
import { redis } from '../config/redis'; import { redis } from '../config/redis';
import { config } from '../config'; import { config } from '../config';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import type { ScanTokenData, ScanLoginStatus } from '@gala/shared/types'; import type { ScanTokenData, ScanLoginStatus } from '@gala/shared/types';
import { createSessionToken } from '../utils/auth';
const SCAN_TOKEN_PREFIX = 'scan:'; const SCAN_TOKEN_PREFIX = 'scan:';
const SESSION_TOKEN_PREFIX = 'session:';
const SCAN_TOKEN_TTL = 5 * 60; // 5 minutes const SCAN_TOKEN_TTL = 5 * 60; // 5 minutes
const SESSION_TOKEN_TTL = 24 * 60 * 60; // 24 hours const SESSION_TOKEN_TTL = 24 * 60 * 60; // 24 hours
@@ -145,8 +144,7 @@ class ScanLoginService {
*/ */
async confirmLogin( async confirmLogin(
scanToken: string, scanToken: string,
userName: string, userName: string
department: string
): Promise<{ ): Promise<{
success: boolean; success: boolean;
data?: { data?: {
@@ -176,22 +174,14 @@ class ScanLoginService {
// Generate user ID and session token // Generate user ID and session token
const userId = `user_${Date.now()}_${uuidv4().slice(0, 8)}`; const userId = `user_${Date.now()}_${uuidv4().slice(0, 8)}`;
const sessionToken = jwt.sign( const sessionToken = await createSessionToken(
{ userId, userName, department }, { userId, userName, role: 'user' },
config.jwtSecret || 'gala-secret-key', SESSION_TOKEN_TTL
{ expiresIn: '24h' }
);
// Store session
await redis.setex(
`${SESSION_TOKEN_PREFIX}${sessionToken}`,
SESSION_TOKEN_TTL,
JSON.stringify({ userId, userName, department })
); );
// Update scan token status // Update scan token status
tokenData.status = 'confirmed'; tokenData.status = 'confirmed';
tokenData.userInfo = { userId, userName, department }; tokenData.userInfo = { userId, userName };
await redis.setex( await redis.setex(
`${SCAN_TOKEN_PREFIX}${scanToken}`, `${SCAN_TOKEN_PREFIX}${scanToken}`,

View File

@@ -1,5 +1,4 @@
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import jwt from 'jsonwebtoken';
import { redis } from '../config/redis'; import { redis } from '../config/redis';
import { config } from '../config'; import { config } from '../config';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
@@ -8,9 +7,9 @@ import type {
WechatAccessTokenResponse, WechatAccessTokenResponse,
WechatUserInfo, WechatUserInfo,
} from '@gala/shared/types'; } from '@gala/shared/types';
import { createSessionToken } from '../utils/auth';
const WECHAT_STATE_PREFIX = 'wechat:state:'; const WECHAT_STATE_PREFIX = 'wechat:state:';
const SESSION_TOKEN_PREFIX = 'session:';
const STATE_TTL = 5 * 60; // 5 minutes const STATE_TTL = 5 * 60; // 5 minutes
const SESSION_TOKEN_TTL = 24 * 60 * 60; // 24 hours const SESSION_TOKEN_TTL = 24 * 60 * 60; // 24 hours
@@ -52,7 +51,7 @@ class WechatService {
*/ */
async createLoginState(pcSocketId: string): Promise<{ async createLoginState(pcSocketId: string): Promise<{
success: boolean; success: boolean;
data?: { authUrl: string; state: string; expiresAt: number }; data?: { appId: string; redirectUri: string; state: string; expiresAt: number };
error?: string; error?: string;
}> { }> {
if (!this.isConfigured()) { if (!this.isConfigured()) {
@@ -77,13 +76,16 @@ class WechatService {
JSON.stringify(stateData) JSON.stringify(stateData)
); );
const authUrl = this.generateAuthUrl(state);
logger.info({ state, pcSocketId }, 'WeChat login state created'); logger.info({ state, pcSocketId }, 'WeChat login state created');
return { return {
success: true, success: true,
data: { authUrl, state, expiresAt }, data: {
appId: this.appId,
redirectUri: this.redirectUri,
state,
expiresAt,
},
}; };
} catch (error) { } catch (error) {
logger.error({ error }, 'Failed to create WeChat login state'); logger.error({ error }, 'Failed to create WeChat login state');
@@ -204,17 +206,9 @@ class WechatService {
const userId = `wx_${openid.slice(0, 16)}`; const userId = `wx_${openid.slice(0, 16)}`;
const userName = userInfo?.nickname || `微信用户_${openid.slice(-6)}`; const userName = userInfo?.nickname || `微信用户_${openid.slice(-6)}`;
const sessionToken = jwt.sign( const sessionToken = await createSessionToken(
{ userId, userName, openid }, { userId, userName, openid, role: 'user' },
config.jwtSecret || 'gala-secret-key', SESSION_TOKEN_TTL
{ expiresIn: '24h' }
);
// Store session
await redis.setex(
`${SESSION_TOKEN_PREFIX}${sessionToken}`,
SESSION_TOKEN_TTL,
JSON.stringify({ userId, userName, openid })
); );
// Delete used state // Delete used state

View File

@@ -8,6 +8,7 @@ import { voteService } from '../services/vote.service';
import { votingEngine } from '../services/voting.engine'; import { votingEngine } from '../services/voting.engine';
import { adminService } from '../services/admin.service'; import { adminService } from '../services/admin.service';
import { SOCKET_EVENTS, SOCKET_ROOMS, TICKET_TYPES, VOTE_KEYS, type TicketType } from '@gala/shared/constants'; import { SOCKET_EVENTS, SOCKET_ROOMS, TICKET_TYPES, VOTE_KEYS, type TicketType } from '@gala/shared/constants';
import { extractBearerToken, verifySessionToken } from '../utils/auth';
import type { import type {
ServerToClientEvents, ServerToClientEvents,
ClientToServerEvents, ClientToServerEvents,
@@ -32,6 +33,16 @@ export type GalaServer = Server<ClientToServerEvents, ServerToClientEvents, Inte
let io: GalaServer; let io: GalaServer;
function getTokenFromSocket(socket: GalaSocket, payloadToken?: string): string | null {
if (payloadToken) return payloadToken;
const authToken = (socket.handshake.auth as any)?.token;
if (typeof authToken === 'string' && authToken.trim()) return authToken.trim();
const headerToken = socket.handshake.headers['x-session-token'];
if (typeof headerToken === 'string' && headerToken.trim()) return headerToken.trim();
const bearer = extractBearerToken(socket.handshake.headers.authorization);
return bearer || null;
}
/** /**
* Initialize Socket.io server * Initialize Socket.io server
*/ */
@@ -191,32 +202,58 @@ async function handleJoin(
}; };
try { try {
const { userId, userName, role, department } = data; const { userId, userName, role, sessionToken } = data;
const token = getTokenFromSocket(socket, sessionToken);
const authUser = token ? await verifySessionToken(token) : null;
if (token && !authUser) {
safeCallback({
success: false,
error: 'UNAUTHORIZED',
message: 'Invalid session token',
});
return;
}
if (role === 'admin' && (!authUser || authUser.role !== 'admin')) {
safeCallback({
success: false,
error: 'UNAUTHORIZED',
message: 'Admin access required',
});
return;
}
const effectiveRole = authUser?.role || role;
const effectiveUserId = authUser?.userId || userId;
const effectiveUserName = authUser?.userName || userName;
// Store user data in socket // Store user data in socket
socket.data.userId = userId; socket.data.userId = effectiveUserId;
socket.data.userName = userName; socket.data.userName = effectiveUserName;
socket.data.department = department || '未知部门'; socket.data.role = effectiveRole;
socket.data.role = role;
socket.data.connectedAt = new Date(); socket.data.connectedAt = new Date();
socket.data.sessionId = socket.id; socket.data.sessionId = socket.id;
(socket.data as any).sessionToken = token || undefined;
(socket.data as any).authenticated = !!authUser;
// Join appropriate rooms // Join appropriate rooms
await socket.join(SOCKET_ROOMS.ALL); await socket.join(SOCKET_ROOMS.ALL);
if (role === 'user') { if (effectiveRole === 'user') {
await socket.join(SOCKET_ROOMS.MOBILE_USERS); await socket.join(SOCKET_ROOMS.MOBILE_USERS);
} else if (role === 'screen') { } else if (effectiveRole === 'screen') {
await socket.join(SOCKET_ROOMS.SCREEN_DISPLAY); await socket.join(SOCKET_ROOMS.SCREEN_DISPLAY);
} else if (role === 'admin') { } else if (effectiveRole === 'admin') {
await socket.join(SOCKET_ROOMS.ADMIN); await socket.join(SOCKET_ROOMS.ADMIN);
} }
// Get user's voted categories and tickets // Get user's voted categories and tickets
const votedCategories = await voteService.getUserVotedCategories(userId); const votedCategories = await voteService.getUserVotedCategories(effectiveUserId);
const userTickets = await redis.hgetall(VOTE_KEYS.userTickets(userId)); const userTickets = await redis.hgetall(VOTE_KEYS.userTickets(effectiveUserId));
logger.info({ socketId: socket.id, userId, userName, role }, 'User joined'); logger.info({ socketId: socket.id, userId: effectiveUserId, userName: effectiveUserName, role: effectiveRole }, 'User joined');
// Broadcast user count update // Broadcast user count update
const userCount = await getUserCount(); const userCount = await getUserCount();
@@ -259,8 +296,9 @@ async function handleVoteSubmit(
}; };
const userId = socket.data.userId; const userId = socket.data.userId;
const authenticated = (socket.data as any).authenticated === true;
if (!userId) { if (!userId || !authenticated) {
safeCallback({ safeCallback({
success: false, success: false,
error: 'UNAUTHORIZED', error: 'UNAUTHORIZED',
@@ -306,7 +344,6 @@ async function handleVoteSubmit(
const stampResult = await adminService.addVoteStamp( const stampResult = await adminService.addVoteStamp(
data.candidateId, data.candidateId,
socket.data.userName || '匿名用户', socket.data.userName || '匿名用户',
socket.data.department || '未知部门',
category, category,
{ revokedProgramId: result.revoked_program } { revokedProgramId: result.revoked_program }
); );
@@ -359,7 +396,6 @@ async function handleVoteSubmit(
const stampResult = await adminService.addVoteStamp( const stampResult = await adminService.addVoteStamp(
data.candidateId, data.candidateId,
socket.data.userName || '匿名用户', socket.data.userName || '匿名用户',
socket.data.department || '未知部门',
data.category as string data.category as string
); );

View File

@@ -69,7 +69,6 @@ export interface VotingProgram {
export interface VoteStamp { export interface VoteStamp {
id: string; id: string;
userName: string; userName: string;
department: string;
ticketType: string; ticketType: string;
x: number; // 随机 X 位置 (0-100%) x: number; // 随机 X 位置 (0-100%)
y: number; // 随机 Y 位置 (0-100%) y: number; // 随机 Y 位置 (0-100%)

View File

@@ -83,7 +83,6 @@ export interface DrawFilters {
export interface JoinPayload { export interface JoinPayload {
userId: string; userId: string;
userName: string; userName: string;
department?: string;
role: UserRole; role: UserRole;
sessionToken?: string; sessionToken?: string;
} }
@@ -192,10 +191,11 @@ export interface InterServerEvents {
export interface SocketData { export interface SocketData {
userId: string; userId: string;
userName: string; userName: string;
department: string;
role: UserRole; role: UserRole;
connectedAt: Date; connectedAt: Date;
sessionId: string; sessionId: string;
sessionToken?: string;
authenticated?: boolean;
} }
// ============================================================================ // ============================================================================