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:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -41,3 +41,5 @@ packages/server/prisma/*.db-journal
|
||||
# Data files
|
||||
参与抽奖人员名单.xlsx
|
||||
测试清单.md
|
||||
|
||||
.codeartsdoer
|
||||
@@ -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: []
|
||||
|
||||
1
packages/client-mobile/src/components.d.ts
vendored
1
packages/client-mobile/src/components.d.ts
vendored
@@ -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']
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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/'),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 # 长时间停留
|
||||
|
||||
@@ -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 }}"
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
64
packages/server/src/middleware/auth.ts
Normal file
64
packages/server/src/middleware/auth.ts
Normal 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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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%)
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -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%)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user