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

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
);