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
参与抽奖人员名单.xlsx
测试清单.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
# (contrary to the memories, which are loaded on demand).
initial_prompt: ""
# the name by which the project can be referenced within Serena
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: []
# 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']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
StampDock: typeof import('./components/StampDock.vue')['default']
VanButton: typeof import('vant/es')['Button']
VanCell: typeof import('vant/es')['Cell']
VanCellGroup: typeof import('vant/es')['CellGroup']

View File

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

View File

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

View File

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

View File

@@ -2,24 +2,18 @@ import { createRouter, createWebHistory, type RouteLocationNormalized } from 'vu
// Admin auth constants
const ADMIN_TOKEN_KEY = 'gala_admin_token';
const ADMIN_ACCESS_CODE = '20268888';
// Auth guard for admin routes
function requireAdminAuth(to: RouteLocationNormalized) {
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 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 { ADMIN_TOKEN_KEY, ADMIN_ACCESS_CODE, generateToken };
export { ADMIN_TOKEN_KEY };
const router = createRouter({
history: createWebHistory('/screen/'),

View File

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

View File

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

View File

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

View File

@@ -22,7 +22,9 @@ function goBack() {
// Fetch lottery results from API
async function fetchLotteryResults() {
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();
if (data.success && 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 获取奖项配置
async function fetchPrizes() {
try {
const response = await fetch('/api/admin/prizes');
const response = await fetch('/api/public/prizes');
const data = await response.json();
if (data.success && data.data?.prizes) {
prizes.value = data.data.prizes;
@@ -53,7 +53,7 @@ let realParticipants: Participant[] = [];
async function fetchParticipants() {
try {
isLoading.value = true;
const response = await fetch('/api/admin/participants');
const response = await fetch('/api/public/participants');
const data = await response.json();
if (data.success && data.data?.participants) {

View File

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

View File

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

View File

@@ -12,6 +12,13 @@ import { participantService } from './services/participant.service';
async function main(): Promise<void> {
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
logger.info('Connecting to Database...');
await connectDatabase();
@@ -79,4 +86,3 @@ async function main(): Promise<void> {
}
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 { programConfigService } from '../services/program-config.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 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 system statistics
@@ -296,4 +336,3 @@ router.post('/cleanup', async (req, res, next) => {
});
export default router;

View File

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

View File

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

View File

@@ -326,7 +326,6 @@ class AdminService extends EventEmitter {
async addVoteStamp(
programId: string,
userName: string,
department: string,
ticketType: string,
options?: { revokedProgramId?: string }
): Promise<{ success: boolean; stamp?: VoteStamp; programVotes?: number; totalVotes?: number; revokedProgramId?: string; revokedProgramVotes?: number }> {
@@ -353,7 +352,6 @@ class AdminService extends EventEmitter {
const stamp: VoteStamp = {
id: `stamp_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`,
userName,
department,
ticketType,
x: 10 + Math.random() * 80, // Random X 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 jwt from 'jsonwebtoken';
import { redis } from '../config/redis';
import { config } from '../config';
import { logger } from '../utils/logger';
import type { ScanTokenData, ScanLoginStatus } from '@gala/shared/types';
import { createSessionToken } from '../utils/auth';
const SCAN_TOKEN_PREFIX = 'scan:';
const SESSION_TOKEN_PREFIX = 'session:';
const SCAN_TOKEN_TTL = 5 * 60; // 5 minutes
const SESSION_TOKEN_TTL = 24 * 60 * 60; // 24 hours
@@ -145,8 +144,7 @@ class ScanLoginService {
*/
async confirmLogin(
scanToken: string,
userName: string,
department: string
userName: string
): Promise<{
success: boolean;
data?: {
@@ -176,22 +174,14 @@ class ScanLoginService {
// Generate user ID and session token
const userId = `user_${Date.now()}_${uuidv4().slice(0, 8)}`;
const sessionToken = jwt.sign(
{ userId, userName, department },
config.jwtSecret || 'gala-secret-key',
{ expiresIn: '24h' }
);
// Store session
await redis.setex(
`${SESSION_TOKEN_PREFIX}${sessionToken}`,
SESSION_TOKEN_TTL,
JSON.stringify({ userId, userName, department })
const sessionToken = await createSessionToken(
{ userId, userName, role: 'user' },
SESSION_TOKEN_TTL
);
// Update scan token status
tokenData.status = 'confirmed';
tokenData.userInfo = { userId, userName, department };
tokenData.userInfo = { userId, userName };
await redis.setex(
`${SCAN_TOKEN_PREFIX}${scanToken}`,

View File

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

View File

@@ -8,6 +8,7 @@ import { voteService } from '../services/vote.service';
import { votingEngine } from '../services/voting.engine';
import { adminService } from '../services/admin.service';
import { SOCKET_EVENTS, SOCKET_ROOMS, TICKET_TYPES, VOTE_KEYS, type TicketType } from '@gala/shared/constants';
import { extractBearerToken, verifySessionToken } from '../utils/auth';
import type {
ServerToClientEvents,
ClientToServerEvents,
@@ -32,6 +33,16 @@ export type GalaServer = Server<ClientToServerEvents, ServerToClientEvents, Inte
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
*/
@@ -191,32 +202,58 @@ async function handleJoin(
};
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
socket.data.userId = userId;
socket.data.userName = userName;
socket.data.department = department || '未知部门';
socket.data.role = role;
socket.data.userId = effectiveUserId;
socket.data.userName = effectiveUserName;
socket.data.role = effectiveRole;
socket.data.connectedAt = new Date();
socket.data.sessionId = socket.id;
(socket.data as any).sessionToken = token || undefined;
(socket.data as any).authenticated = !!authUser;
// Join appropriate rooms
await socket.join(SOCKET_ROOMS.ALL);
if (role === 'user') {
if (effectiveRole === 'user') {
await socket.join(SOCKET_ROOMS.MOBILE_USERS);
} else if (role === 'screen') {
} else if (effectiveRole === 'screen') {
await socket.join(SOCKET_ROOMS.SCREEN_DISPLAY);
} else if (role === 'admin') {
} else if (effectiveRole === 'admin') {
await socket.join(SOCKET_ROOMS.ADMIN);
}
// Get user's voted categories and tickets
const votedCategories = await voteService.getUserVotedCategories(userId);
const userTickets = await redis.hgetall(VOTE_KEYS.userTickets(userId));
const votedCategories = await voteService.getUserVotedCategories(effectiveUserId);
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
const userCount = await getUserCount();
@@ -259,8 +296,9 @@ async function handleVoteSubmit(
};
const userId = socket.data.userId;
const authenticated = (socket.data as any).authenticated === true;
if (!userId) {
if (!userId || !authenticated) {
safeCallback({
success: false,
error: 'UNAUTHORIZED',
@@ -306,7 +344,6 @@ async function handleVoteSubmit(
const stampResult = await adminService.addVoteStamp(
data.candidateId,
socket.data.userName || '匿名用户',
socket.data.department || '未知部门',
category,
{ revokedProgramId: result.revoked_program }
);
@@ -359,7 +396,6 @@ async function handleVoteSubmit(
const stampResult = await adminService.addVoteStamp(
data.candidateId,
socket.data.userName || '匿名用户',
socket.data.department || '未知部门',
data.category as string
);

View File

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

View File

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