diff --git a/.claude/skills/dev-tunnel/Caddyfile.debug b/.claude/skills/dev-tunnel/Caddyfile.debug new file mode 100644 index 0000000..88667c7 --- /dev/null +++ b/.claude/skills/dev-tunnel/Caddyfile.debug @@ -0,0 +1,38 @@ +{ + email let5sne@gmail.com +} + +2026.cptp.let5see.xyz { + # WeChat verification file + handle /A6e75lM1ge.txt { + respond "3a5d2d7f410fbcbc32e3c222b2cf28bf" + } + + # Screen client → local dev server via tunnel + handle /screen* { + reverse_proxy localhost:5174 + } + + # API proxy → local backend via tunnel + handle /api/* { + header Cache-Control "no-store, no-cache, must-revalidate" + reverse_proxy localhost:3001 + } + + # WebSocket proxy → local backend via tunnel + handle /socket.io/* { + reverse_proxy localhost:3001 + } + + # Avatars proxy → local backend via tunnel + handle /avatars/* { + reverse_proxy localhost:3001 + } + + # Mobile client → local dev server via tunnel + handle { + reverse_proxy localhost:5173 + } + + encode gzip +} diff --git a/.claude/skills/dev-tunnel/Caddyfile.production b/.claude/skills/dev-tunnel/Caddyfile.production new file mode 100644 index 0000000..d1d86f1 --- /dev/null +++ b/.claude/skills/dev-tunnel/Caddyfile.production @@ -0,0 +1,48 @@ +{ + email let5sne@gmail.com +} + +2026.cptp.let5see.xyz { + # WeChat verification file + handle /A6e75lM1ge.txt { + respond "3a5d2d7f410fbcbc32e3c222b2cf28bf" + } + + # Screen client + handle /screen* { + root * /root/company-celebration/packages/client-screen/dist + uri strip_prefix /screen + try_files {path} /index.html + file_server + + header Cache-Control "no-cache, no-store, must-revalidate" + + @screenassets path_regexp assets \/assets\/.* + header @screenassets Cache-Control "public, max-age=31536000, immutable" + } + + # API proxy + handle /api/* { + header Cache-Control "no-store, no-cache, must-revalidate" + reverse_proxy localhost:3000 + } + + # WebSocket proxy + handle /socket.io/* { + reverse_proxy localhost:3000 + } + + # Mobile client (default) + handle { + root * /root/company-celebration/packages/client-mobile/dist + try_files {path} /index.html + file_server + + header Cache-Control "no-cache, no-store, must-revalidate" + + @mobileassets path_regexp assets \/assets\/.* + header @mobileassets Cache-Control "public, max-age=31536000, immutable" + } + + encode gzip +} diff --git a/.claude/skills/dev-tunnel/SKILL.md b/.claude/skills/dev-tunnel/SKILL.md new file mode 100644 index 0000000..42aa35d --- /dev/null +++ b/.claude/skills/dev-tunnel/SKILL.md @@ -0,0 +1,228 @@ +# dev-tunnel - 本地开发隧道 + +通过 SSH 反向隧道将本地服务暴露到生产服务器,实现完整的本地开发调试(前端+后端),同时使用生产域名和微信授权。 + +## 使用场景 + +- 本地调试需要微信授权回调 +- 本地调试需要使用生产域名 +- 需要同时调试前端和后端 +- 本地没有公网 IP + +## 命令 + +``` +/dev-tunnel start # 启动本地调试模式 +/dev-tunnel stop # 恢复生产环境 +/dev-tunnel status # 查看当前状态 +``` + +## 架构说明 + +``` +生产环境: + /api/*, /socket.io/* → localhost:3000 → PM2(gala-server) + /screen* → 静态文件 (client-screen/dist) + 其他 → 静态文件 (client-mobile/dist) + +调试模式: + /api/*, /socket.io/* → localhost:3001 → 隧道 → 本地:3000 (后端) + /screen* → localhost:5174 → 隧道 → 本地:5174 (大屏 dev) + 其他 → localhost:5173 → 隧道 → 本地:5173 (移动端 dev) +``` + +## 隧道端口映射 + +| 服务 | 服务器端口 | 本地端口 | +|------|-----------|---------| +| 后端 API | 3001 | 3000 | +| 移动端前端 | 5173 | 5173 | +| 大屏端前端 | 5174 | 5174 | + +## 操作步骤 + +### start - 启动调试模式 + +1. **服务器**: 停止 PM2 后端服务 +2. **服务器**: 切换 Caddy 为调试模式配置 +3. **本地**: 切换 MOBILE_CLIENT_URL 为生产域名(自动执行) +4. **本地**: 建立 SSH 隧道(自动执行) +5. **本地**: 启动后端和前端 dev server(手动执行) + +### stop - 恢复生产环境 + +1. **本地**: 终止 SSH 隧道进程(自动执行) +2. **本地**: 恢复 MOBILE_CLIENT_URL 为本地地址(自动执行) +3. **服务器**: 恢复 Caddy 生产环境配置 +4. **服务器**: 重启 PM2 后端服务 + +## 具体命令 + +### 启动调试模式 + +```bash +# 1. 停止服务器后端 +ssh vote "pm2 stop gala-server" + +# 2. 切换 Caddy 为调试模式配置 +# 注意:需要先将 Caddyfile.debug 上传到服务器,或直接替换内容 +ssh vote "cat > /etc/caddy/Caddyfile << 'EOF' +{ + email let5sne@gmail.com +} + +2026.cptp.let5see.xyz { + handle /A6e75lM1ge.txt { + respond \"3a5d2d7f410fbcbc32e3c222b2cf28bf\" + } + handle /screen* { + reverse_proxy localhost:5174 + } + handle /api/* { + header Cache-Control \"no-store, no-cache, must-revalidate\" + reverse_proxy localhost:3001 + } + handle /socket.io/* { + reverse_proxy localhost:3001 + } + handle /avatars/* { + reverse_proxy localhost:3001 + } + handle { + reverse_proxy localhost:5173 + } + encode gzip +} +EOF" + +# 3. 重载 Caddy +ssh vote "caddy reload --config /etc/caddy/Caddyfile" + +# 4. 切换 MOBILE_CLIENT_URL 为生产域名 +sed -i '' 's|MOBILE_CLIENT_URL=http://localhost:5173|MOBILE_CLIENT_URL=https://2026.cptp.let5see.xyz|g' packages/server/.env + +# 5. 切换大屏端 VITE_MOBILE_URL 为生产域名 +sed -i '' 's|VITE_MOBILE_URL=http://localhost:5173|VITE_MOBILE_URL=https://2026.cptp.let5see.xyz|g' packages/client-screen/.env.development + +# 6. 切换移动端 VITE_SOCKET_URL/VITE_API_URL 为生产域名 +sed -i '' 's|VITE_SOCKET_URL=http://localhost:3000|VITE_SOCKET_URL=https://2026.cptp.let5see.xyz|g' packages/client-mobile/.env.development +sed -i '' 's|VITE_API_URL=http://localhost:3000|VITE_API_URL=https://2026.cptp.let5see.xyz|g' packages/client-mobile/.env.development + +# 7. 建立 SSH 隧道(自动后台运行) +ssh -R 3001:localhost:3000 -R 5173:localhost:5173 -R 5174:localhost:5174 vote -N -o ServerAliveInterval=30 + +# 6. 本地启动服务(3个终端,手动执行) +# 终端1: 后端 +pnpm --filter @gala/server dev + +# 终端2: 移动端前端 +pnpm --filter @gala/client-mobile dev --host + +# 终端3: 大屏端前端 +pnpm --filter @gala/client-screen dev --host +``` + +### 恢复生产环境 + +```bash +# 1. 终止本地 SSH 隧道进程 +pkill -f "ssh -R 3001:localhost:3000" + +# 2. 恢复 MOBILE_CLIENT_URL 为本地地址 +sed -i '' 's|MOBILE_CLIENT_URL=https://2026.cptp.let5see.xyz|MOBILE_CLIENT_URL=http://localhost:5173|g' packages/server/.env + +# 3. 恢复大屏端 VITE_MOBILE_URL 为本地地址 +sed -i '' 's|VITE_MOBILE_URL=https://2026.cptp.let5see.xyz|VITE_MOBILE_URL=http://localhost:5173|g' packages/client-screen/.env.development + +# 4. 恢复移动端 VITE_SOCKET_URL/VITE_API_URL 为本地地址 +sed -i '' 's|VITE_SOCKET_URL=https://2026.cptp.let5see.xyz|VITE_SOCKET_URL=http://localhost:3000|g' packages/client-mobile/.env.development +sed -i '' 's|VITE_API_URL=https://2026.cptp.let5see.xyz|VITE_API_URL=http://localhost:3000|g' packages/client-mobile/.env.development + +# 5. 恢复 Caddy 生产环境配置 +ssh vote "cat > /etc/caddy/Caddyfile << 'EOF' +{ + email let5sne@gmail.com +} + +2026.cptp.let5see.xyz { + handle /A6e75lM1ge.txt { + respond \"3a5d2d7f410fbcbc32e3c222b2cf28bf\" + } + handle /screen* { + root * /root/company-celebration/packages/client-screen/dist + uri strip_prefix /screen + try_files {path} /index.html + file_server + header Cache-Control \"no-cache, no-store, must-revalidate\" + @screenassets path_regexp assets \\/assets\\/.* + header @screenassets Cache-Control \"public, max-age=31536000, immutable\" + } + handle /api/* { + header Cache-Control \"no-store, no-cache, must-revalidate\" + reverse_proxy localhost:3000 + } + handle /socket.io/* { + reverse_proxy localhost:3000 + } + handle { + root * /root/company-celebration/packages/client-mobile/dist + try_files {path} /index.html + file_server + header Cache-Control \"no-cache, no-store, must-revalidate\" + @mobileassets path_regexp assets \\/assets\\/.* + header @mobileassets Cache-Control \"public, max-age=31536000, immutable\" + } + encode gzip +} +EOF" + +# 3. 重载 Caddy +ssh vote "caddy reload --config /etc/caddy/Caddyfile" + +# 4. 重启服务器后端 +ssh vote "pm2 restart gala-server" +``` + +## 前端 dev server 配置 + +### 必需配置 + +两个前端项目的 `vite.config.ts` 需要包含以下配置: + +**client-mobile** (端口 5173): +```ts +server: { + host: '0.0.0.0', + port: 5173, + allowedHosts: ['2026.cptp.let5see.xyz'], +} +``` + +**client-screen** (端口 5174): +```ts +server: { + host: '0.0.0.0', + port: 5174, + allowedHosts: ['2026.cptp.let5see.xyz'], +} +``` + +### 配置说明 + +| 配置项 | 说明 | +|--------|------| +| `host: '0.0.0.0'` | 允许外部网络访问 | +| `allowedHosts` | 允许通过生产域名访问,否则 Vite 会拒绝请求 | +| `base: '/screen/'` | 大屏端需要配置,支持 `/screen` 路径前缀 | + +## 注意事项 + +- SSH 隧道断开后,生产环境将不可用,务必及时恢复 +- 调试完成后**必须执行 stop** 恢复生产环境 +- 隧道命令需要保持终端运行,关闭终端会断开隧道 +- 本地需要同时运行 4 个终端(后端 + 2个前端 + 隧道) + +## 文件说明 + +- `Caddyfile.production` - 生产环境 Caddy 配置模板 +- `Caddyfile.debug` - 调试模式 Caddy 配置模板 diff --git a/CLAUDE.md b/CLAUDE.md index 1fe1667..d416188 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -101,6 +101,19 @@ VITE_API_URL=https://your-domain.com --- +## 本地调试 + +需要使用生产域名进行本地调试(如微信授权)时,使用 `/dev-tunnel` skill: + +``` +/dev-tunnel start # 启动本地调试模式 +/dev-tunnel stop # 恢复生产环境 +``` + +详见 `.claude/skills/dev-tunnel/SKILL.md` + +--- + ## 开发规范 ### 类型定义 diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..fa576b4 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,17 @@ +version: '3.8' + +services: + mysql: + image: mysql:8.0 + container_name: gala-mysql-dev + restart: unless-stopped + ports: + - "3306:3306" + environment: + - MYSQL_ROOT_PASSWORD=ts123321qQ + - MYSQL_DATABASE=gala + volumes: + - mysql_dev_data:/var/lib/mysql + +volumes: + mysql_dev_data: diff --git a/package.json b/package.json index bfad374..50351a9 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "eslint-config-prettier": "^9.1.0", "eslint-plugin-vue": "^9.32.0", "prettier": "^3.4.2", + "tsx": "^4.19.2", "typescript": "^5.7.3" }, "engines": { diff --git a/packages/client-mobile/.env.development b/packages/client-mobile/.env.development new file mode 100644 index 0000000..f8437ad --- /dev/null +++ b/packages/client-mobile/.env.development @@ -0,0 +1,2 @@ +VITE_SOCKET_URL=https://2026.cptp.let5see.xyz +VITE_API_URL=https://2026.cptp.let5see.xyz diff --git a/packages/client-mobile/index.html b/packages/client-mobile/index.html index ae00657..5e98b38 100644 --- a/packages/client-mobile/index.html +++ b/packages/client-mobile/index.html @@ -2,6 +2,9 @@ + + + diff --git a/packages/client-mobile/src/components.d.ts b/packages/client-mobile/src/components.d.ts index de3ab23..d251777 100644 --- a/packages/client-mobile/src/components.d.ts +++ b/packages/client-mobile/src/components.d.ts @@ -8,6 +8,7 @@ export {} declare module 'vue' { export interface GlobalComponents { ConnectionStatus: typeof import('./components/ConnectionStatus.vue')['default'] + OnboardingTour: typeof import('./components/OnboardingTour.vue')['default'] Postmark: typeof import('./components/Postmark.vue')['default'] ProgramCard: typeof import('./components/ProgramCard.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] diff --git a/packages/client-mobile/src/router/index.ts b/packages/client-mobile/src/router/index.ts index 97cbf0e..a43dadd 100644 --- a/packages/client-mobile/src/router/index.ts +++ b/packages/client-mobile/src/router/index.ts @@ -28,6 +28,11 @@ const router = createRouter({ name: 'scan-login', component: () => import('../views/ScanLoginView.vue'), }, + { + path: '/shake', + name: 'shake', + component: () => import('../views/ShakeView.vue'), + }, ], }); diff --git a/packages/client-mobile/src/stores/connection.ts b/packages/client-mobile/src/stores/connection.ts index ba7ee9a..8f97eb6 100644 --- a/packages/client-mobile/src/stores/connection.ts +++ b/packages/client-mobile/src/stores/connection.ts @@ -284,7 +284,7 @@ export const useConnectionStore = defineStore('connection', () => { } /** - * Logout and clear stored user info + * Logout and clear all stored data */ function logout() { // Clear state @@ -293,10 +293,25 @@ export const useConnectionStore = defineStore('connection', () => { votedCategories.value = []; sessionToken.value = null; - // Clear localStorage - localStorage.removeItem(STORAGE_KEYS.USER_ID); - localStorage.removeItem(STORAGE_KEYS.USER_NAME); - localStorage.removeItem(STORAGE_KEYS.SESSION_TOKEN); + // Clear all localStorage + localStorage.clear(); + + // Clear sessionStorage + sessionStorage.clear(); + + // Clear service worker cache (PWA) + if ('caches' in window) { + caches.keys().then((names) => { + names.forEach((name) => caches.delete(name)); + }); + } + + // Unregister service workers + if ('serviceWorker' in navigator) { + navigator.serviceWorker.getRegistrations().then((registrations) => { + registrations.forEach((registration) => registration.unregister()); + }); + } // Disconnect socket disconnect(); diff --git a/packages/client-mobile/src/views/ShakeView.vue b/packages/client-mobile/src/views/ShakeView.vue new file mode 100644 index 0000000..a18b137 --- /dev/null +++ b/packages/client-mobile/src/views/ShakeView.vue @@ -0,0 +1,370 @@ + + + + + diff --git a/packages/client-mobile/src/views/VoteView.vue b/packages/client-mobile/src/views/VoteView.vue index 0af9525..bbee7e9 100644 --- a/packages/client-mobile/src/views/VoteView.vue +++ b/packages/client-mobile/src/views/VoteView.vue @@ -1,9 +1,9 @@ + + + + diff --git a/packages/client-screen/vite.config.ts b/packages/client-screen/vite.config.ts index 2d4f03a..f825810 100644 --- a/packages/client-screen/vite.config.ts +++ b/packages/client-screen/vite.config.ts @@ -12,7 +12,8 @@ export default defineConfig({ }, server: { host: '0.0.0.0', - port: 5173, + port: 5174, + allowedHosts: ['2026.cptp.let5see.xyz'], proxy: { '/api': { target: 'http://localhost:3000', diff --git a/packages/server/public/avatars/wx_oECwX1HxV-47N4qC.jpg b/packages/server/public/avatars/wx_oECwX1HxV-47N4qC.jpg new file mode 100644 index 0000000..fa43388 Binary files /dev/null and b/packages/server/public/avatars/wx_oECwX1HxV-47N4qC.jpg differ diff --git a/packages/server/public/avatars/wx_oECwX1KlFOgik0l_.jpg b/packages/server/public/avatars/wx_oECwX1KlFOgik0l_.jpg new file mode 100644 index 0000000..0da4de4 Binary files /dev/null and b/packages/server/public/avatars/wx_oECwX1KlFOgik0l_.jpg differ diff --git a/packages/server/src/config/index.ts b/packages/server/src/config/index.ts index 5602510..0bb55af 100644 --- a/packages/server/src/config/index.ts +++ b/packages/server/src/config/index.ts @@ -1,4 +1,16 @@ -import 'dotenv/config'; +import dotenv from 'dotenv'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +// ES module compatible __dirname +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// Load .env from server package directory +const envPath = resolve(__dirname, '../../.env'); +console.log('[Config] Loading .env from:', envPath); +const result = dotenv.config({ path: envPath }); +console.log('[Config] MOBILE_CLIENT_URL:', process.env.MOBILE_CLIENT_URL); export const config = { // Server diff --git a/packages/server/src/routes/checkin.routes.ts b/packages/server/src/routes/checkin.routes.ts new file mode 100644 index 0000000..99e32f7 --- /dev/null +++ b/packages/server/src/routes/checkin.routes.ts @@ -0,0 +1,82 @@ +import { Router, Request, Response } from 'express'; +import { redis } from '../config/redis'; +import { shakeService } from '../services/shake.service'; +import { logger } from '../utils/logger'; + +const router = Router(); + +const CHECKIN_USERS_KEY = 'checkin:users'; + +/** + * GET /api/checkin/users + * 获取所有签到用户列表 + */ +router.get('/users', async (_req: Request, res: Response) => { + try { + const usersData = await redis.hgetall(CHECKIN_USERS_KEY); + const shakeCounts = await shakeService.getAllShakeCounts(); + + const users = Object.entries(usersData).map(([id, data]) => { + const user = JSON.parse(data); + return { + ...user, + shakeCount: shakeCounts[id] || 0, + }; + }); + + // 按摇动次数降序排列 + users.sort((a, b) => b.shakeCount - a.shakeCount); + + return res.json({ + success: true, + data: { + users, + totalCount: users.length, + }, + }); + } catch (error) { + logger.error({ error }, 'Failed to get checkin users'); + return res.status(500).json({ + success: false, + error: 'Internal server error', + }); + } +}); + +/** + * GET /api/checkin/leaderboard + * 获取摇一摇排行榜 + */ +router.get('/leaderboard', async (req: Request, res: Response) => { + try { + const limit = parseInt(req.query.limit as string) || 50; + const leaderboard = await shakeService.getLeaderboard(limit); + + // 补充用户信息 + const usersData = await redis.hgetall(CHECKIN_USERS_KEY); + const enrichedLeaderboard = leaderboard.map((entry) => { + const userData = usersData[entry.userId]; + const user = userData ? JSON.parse(userData) : { name: '未知用户' }; + return { + ...entry, + userName: user.name, + avatar: user.avatar, + }; + }); + + return res.json({ + success: true, + data: { + leaderboard: enrichedLeaderboard, + }, + }); + } catch (error) { + logger.error({ error }, 'Failed to get shake leaderboard'); + return res.status(500).json({ + success: false, + error: 'Internal server error', + }); + } +}); + +export default router; diff --git a/packages/server/src/routes/public.routes.ts b/packages/server/src/routes/public.routes.ts index ea2ddfa..737a455 100644 --- a/packages/server/src/routes/public.routes.ts +++ b/packages/server/src/routes/public.routes.ts @@ -1,6 +1,7 @@ import { Router, IRouter } from 'express'; import { prizeConfigService } from '../services/prize-config.service'; import { participantService } from '../services/participant.service'; +import { adminService } from '../services/admin.service'; const router: IRouter = Router(); @@ -41,5 +42,21 @@ router.get('/participants', (_req, res, next) => { } }); +/** + * GET /api/public/lottery/results + * Public read-only lottery results (for screen display) + */ +router.get('/lottery/results', async (_req, res, next) => { + try { + const results = await adminService.getLotteryResults(); + return res.json({ + success: true, + data: results, + }); + } catch (error) { + next(error); + } +}); + export default router; diff --git a/packages/server/src/routes/wechat-mp.routes.ts b/packages/server/src/routes/wechat-mp.routes.ts index f0fdc61..70ca0d1 100644 --- a/packages/server/src/routes/wechat-mp.routes.ts +++ b/packages/server/src/routes/wechat-mp.routes.ts @@ -1,10 +1,14 @@ import { Router, Request, Response } from 'express'; import { wechatMpService } from '../services/wechat-mp.service'; +import { avatarService } from '../services/avatar.service'; import { config } from '../config'; import { logger } from '../utils/logger'; +import { redis } from '../config/redis'; const router = Router(); +const CHECKIN_USERS_KEY = 'checkin:users'; + /** * GET /api/mp/auth-url * 获取公众号网页授权URL @@ -145,4 +149,91 @@ function isRedirectAllowed(redirectUri: string): boolean { } } +/** + * POST /api/mp/upgrade-auth + * 升级授权获取用户头像(snsapi_userinfo) + */ +router.post('/upgrade-auth', async (req: Request, res: Response) => { + try { + const { code, state, userId } = req.body; + + if (!code || !state || !userId) { + return res.status(400).json({ + success: false, + error: 'Missing required parameters', + }); + } + + if (!wechatMpService.isConfigured()) { + return res.json({ + success: false, + error: 'WeChat MP not configured', + }); + } + + const isValidState = await wechatMpService.validateState(state); + if (!isValidState) { + return res.status(400).json({ + success: false, + error: 'Invalid or expired state', + }); + } + + // 获取 access_token + const tokenResult = await wechatMpService.getAccessToken(code); + if (!tokenResult.success || !tokenResult.data) { + return res.json({ + success: false, + error: tokenResult.error || 'Failed to get access token', + }); + } + + // 获取用户信息(包含头像) + const userInfo = await wechatMpService.getUserInfo( + tokenResult.data.accessToken, + tokenResult.data.openid + ); + + if (!userInfo || !userInfo.headimgurl) { + return res.json({ + success: false, + error: 'Failed to get user info', + }); + } + + // 下载头像到本地 + const localAvatar = await avatarService.downloadAvatar(userId, userInfo.headimgurl); + + if (!localAvatar) { + return res.json({ + success: false, + error: 'Failed to download avatar', + }); + } + + // 更新签到用户信息 + const existingData = await redis.hget(CHECKIN_USERS_KEY, userId); + const userData = existingData ? JSON.parse(existingData) : { id: userId, name: userInfo.nickname }; + userData.avatar = localAvatar; + userData.nickname = userInfo.nickname; + await redis.hset(CHECKIN_USERS_KEY, userId, JSON.stringify(userData)); + + logger.info({ userId, avatar: localAvatar }, 'User avatar updated'); + + return res.json({ + success: true, + data: { + avatar: localAvatar, + nickname: userInfo.nickname, + }, + }); + } catch (error) { + logger.error({ error }, 'Failed to upgrade auth'); + return res.status(500).json({ + success: false, + error: 'Internal server error', + }); + } +}); + export default router; diff --git a/packages/server/src/services/admin.service.ts b/packages/server/src/services/admin.service.ts index 2267082..ef951d0 100644 --- a/packages/server/src/services/admin.service.ts +++ b/packages/server/src/services/admin.service.ts @@ -173,6 +173,22 @@ class AdminService extends EventEmitter { } } + + /** + * Update event title + */ + async setEventTitle(title: string): Promise<{ success: boolean; message?: string }> { + try { + this.state.eventTitle = title; + await this.saveState(); + logger.info({ title }, 'Event title updated'); + return { success: true }; + } catch (error) { + logger.error({ error, title }, 'Failed to update event title'); + return { success: false, message: 'Failed to update event title' }; + } + } + /** * Control voting state */ @@ -439,13 +455,13 @@ class AdminService extends EventEmitter { const winners = this.pickRandomWinners(); this.state.lottery.subPhase = 'REVEAL'; this.state.lottery.currentWinners = winners; - + // Persist winners to database await this.persistLotteryResults(winners); - - // Auto-play fanfare music + + // Auto-play reveal music - horse for round 4, fanfare for others this.state.music.isPlaying = true; - this.state.music.track = 'fanfare'; + this.state.music.track = this.state.lottery.round === 4 ? 'horse' : 'fanfare'; // After reveal animation, set to COMPLETE and stop music setTimeout(() => { this.state.lottery.subPhase = 'COMPLETE'; @@ -580,7 +596,7 @@ class AdminService extends EventEmitter { const prizeLevel = this.state.lottery.prizeLevel; const prizeName = this.state.lottery.prizeName; const round = this.state.lottery.round; - + // Create draw results in database await prisma.drawResult.createMany({ data: winners.map(winner => ({ diff --git a/packages/server/src/services/avatar.service.ts b/packages/server/src/services/avatar.service.ts new file mode 100644 index 0000000..3ed0003 --- /dev/null +++ b/packages/server/src/services/avatar.service.ts @@ -0,0 +1,75 @@ +import { createWriteStream, existsSync, mkdirSync } from 'fs'; +import { join } from 'path'; +import { pipeline } from 'stream/promises'; +import { Readable } from 'stream'; +import { logger } from '../utils/logger'; + +const AVATARS_DIR = join(process.cwd(), 'public', 'avatars'); + +/** + * 头像下载和存储服务 + */ +class AvatarService { + constructor() { + this.ensureAvatarsDir(); + } + + /** + * 确保头像目录存在 + */ + private ensureAvatarsDir(): void { + if (!existsSync(AVATARS_DIR)) { + mkdirSync(AVATARS_DIR, { recursive: true }); + logger.info({ dir: AVATARS_DIR }, 'Created avatars directory'); + } + } + + /** + * 从微信 URL 下载头像并保存到本地 + */ + async downloadAvatar(userId: string, avatarUrl: string): Promise { + try { + if (!avatarUrl) { + return null; + } + + // 生成本地文件名 + const filename = `${userId}.jpg`; + const filepath = join(AVATARS_DIR, filename); + + // 下载头像 + const response = await fetch(avatarUrl); + if (!response.ok) { + logger.error({ userId, avatarUrl, status: response.status }, 'Failed to download avatar'); + return null; + } + + // 保存到本地 + const fileStream = createWriteStream(filepath); + await pipeline(Readable.fromWeb(response.body as any), fileStream); + + const localUrl = `/avatars/${filename}`; + logger.info({ userId, localUrl }, 'Avatar downloaded successfully'); + + return localUrl; + } catch (error) { + logger.error({ error, userId, avatarUrl }, 'Failed to download avatar'); + return null; + } + } + + /** + * 获取用户头像本地路径 + */ + getAvatarPath(userId: string): string | null { + const filename = `${userId}.jpg`; + const filepath = join(AVATARS_DIR, filename); + + if (existsSync(filepath)) { + return `/avatars/${filename}`; + } + return null; + } +} + +export const avatarService = new AvatarService(); diff --git a/packages/server/src/services/scan-login.service.ts b/packages/server/src/services/scan-login.service.ts index 09b924f..7857dad 100644 --- a/packages/server/src/services/scan-login.service.ts +++ b/packages/server/src/services/scan-login.service.ts @@ -38,8 +38,9 @@ class ScanLoginService { ); // Generate QR code URL (mobile client will access this) + // Add timestamp to bypass WeChat WebView cache const baseUrl = config.mobileClientUrl || 'http://localhost:5174'; - const qrCodeUrl = `${baseUrl}/scan-login?token=${scanToken}`; + const qrCodeUrl = `${baseUrl}/scan-login?token=${scanToken}&t=${Date.now()}`; logger.info({ scanToken, pcSocketId }, 'Scan token generated'); diff --git a/packages/server/src/services/shake.service.ts b/packages/server/src/services/shake.service.ts new file mode 100644 index 0000000..783bef3 --- /dev/null +++ b/packages/server/src/services/shake.service.ts @@ -0,0 +1,161 @@ +import { redis } from '../config/redis'; +import { logger } from '../utils/logger'; + +const SHAKE_COUNTS_KEY = 'shake:counts'; +const SHAKE_LEADERBOARD_KEY = 'shake:leaderboard'; +const SHAKE_RATE_LIMIT_PREFIX = 'shake:rate:'; +const MAX_REQUESTS_PER_SECOND = 5; + +/** + * Lua 脚本:原子操作更新计数 + 限流检查 + * KEYS[1]: shake:counts (Hash) + * KEYS[2]: shake:leaderboard (Sorted Set) + * KEYS[3]: shake:rate:{userId} (限流 key) + * ARGV[1]: userId + * ARGV[2]: increment + * ARGV[3]: maxPerSecond + * + * 返回: [newCount, currentRate] 或 [-1, currentRate] 表示被限流 + */ +const SHAKE_LUA_SCRIPT = ` +local hashKey = KEYS[1] +local sortedSetKey = KEYS[2] +local rateLimitKey = KEYS[3] +local userId = ARGV[1] +local increment = tonumber(ARGV[2]) +local maxPerSecond = tonumber(ARGV[3]) + +-- 检查限流 +local currentRate = redis.call('INCR', rateLimitKey) +if currentRate == 1 then + redis.call('EXPIRE', rateLimitKey, 1) +end +if currentRate > maxPerSecond then + return {-1, currentRate} +end + +-- 原子更新计数和排行榜 +local newCount = redis.call('HINCRBY', hashKey, userId, increment) +redis.call('ZADD', sortedSetKey, newCount, userId) +return {newCount, currentRate} +`; + +/** + * 摇一摇计数服务 + * 使用 Redis Hash 存储每个用户的摇动次数 + */ +class ShakeService { + /** + * 增加用户摇动次数(带限流) + * @returns newCount 或 -1 表示被限流 + */ + async incrementShakeCount(userId: string, increment: number = 1): Promise { + try { + const rateLimitKey = `${SHAKE_RATE_LIMIT_PREFIX}${userId}`; + const result = await redis.eval( + SHAKE_LUA_SCRIPT, + 3, + SHAKE_COUNTS_KEY, + SHAKE_LEADERBOARD_KEY, + rateLimitKey, + userId, + increment.toString(), + MAX_REQUESTS_PER_SECOND.toString() + ) as [number, number]; + + const [newCount, currentRate] = result; + + if (newCount === -1) { + logger.debug({ userId, currentRate }, 'Shake rate limited'); + } + + return newCount; + } catch (error) { + logger.error({ error, userId }, 'Failed to increment shake count'); + throw error; + } + } + + /** + * 获取用户摇动次数 + */ + async getShakeCount(userId: string): Promise { + try { + const count = await redis.hget(SHAKE_COUNTS_KEY, userId); + return count ? parseInt(count, 10) : 0; + } catch (error) { + logger.error({ error, userId }, 'Failed to get shake count'); + return 0; + } + } + + /** + * 获取所有用户的摇动次数 + */ + async getAllShakeCounts(): Promise> { + try { + const counts = await redis.hgetall(SHAKE_COUNTS_KEY); + const result: Record = {}; + for (const [userId, count] of Object.entries(counts)) { + result[userId] = parseInt(count, 10); + } + return result; + } catch (error) { + logger.error({ error }, 'Failed to get all shake counts'); + return {}; + } + } + + /** + * 获取摇动排行榜(按摇动次数降序) + */ + async getLeaderboard(limit: number = 100): Promise> { + try { + // 使用 ZREVRANGE 获取降序排列的用户 + const results = await redis.zrevrange(SHAKE_LEADERBOARD_KEY, 0, limit - 1, 'WITHSCORES'); + const leaderboard: Array<{ userId: string; shakeCount: number; rank: number }> = []; + + for (let i = 0; i < results.length; i += 2) { + leaderboard.push({ + userId: results[i], + shakeCount: parseInt(results[i + 1], 10), + rank: Math.floor(i / 2) + 1, + }); + } + + return leaderboard; + } catch (error) { + logger.error({ error }, 'Failed to get shake leaderboard'); + return []; + } + } + + /** + * 获取用户排名 + */ + async getUserRank(userId: string): Promise { + try { + const rank = await redis.zrevrank(SHAKE_LEADERBOARD_KEY, userId); + return rank !== null ? rank + 1 : null; + } catch (error) { + logger.error({ error, userId }, 'Failed to get user rank'); + return null; + } + } + + /** + * 重置所有摇动数据 + */ + async resetAll(): Promise { + try { + await redis.del(SHAKE_COUNTS_KEY); + await redis.del(SHAKE_LEADERBOARD_KEY); + logger.info('Shake data reset'); + } catch (error) { + logger.error({ error }, 'Failed to reset shake data'); + throw error; + } + } +} + +export const shakeService = new ShakeService(); diff --git a/packages/shared/src/constants/events.ts b/packages/shared/src/constants/events.ts index 12af1da..2696e16 100644 --- a/packages/shared/src/constants/events.ts +++ b/packages/shared/src/constants/events.ts @@ -29,6 +29,7 @@ export const SOCKET_EVENTS = { // Admin control events ADMIN_PHASE_CHANGE: 'admin:phase_change', + ADMIN_EVENT_TITLE_UPDATE: 'admin:event_title_update', ADMIN_VOTING_CONTROL: 'admin:voting_control', ADMIN_LOTTERY_CONTROL: 'admin:lottery_control', ADMIN_EMERGENCY_RESET: 'admin:emergency_reset', @@ -43,6 +44,19 @@ export const SOCKET_EVENTS = { // QR code display control events DISPLAY_SHOW_ENTRY_QR: 'display:show_entry_qr', DISPLAY_HIDE_QR: 'display:hide_qr', + + // Checkin wall events + CHECKIN_USER_JOIN: 'checkin:user_join', + CHECKIN_USER_UPDATE: 'checkin:user_update', + CHECKIN_WALL_SYNC: 'checkin:wall_sync', + + // Shake events + SHAKE_COUNT: 'shake:count', + SHAKE_UPDATE: 'shake:update', + SHAKE_LEADERBOARD: 'shake:leaderboard', + + // Avatar events + AVATAR_UPDATED: 'avatar:updated', } as const; export const SOCKET_ROOMS = { diff --git a/packages/shared/src/types/admin.types.ts b/packages/shared/src/types/admin.types.ts index c6aa964..a2a4ab7 100644 --- a/packages/shared/src/types/admin.types.ts +++ b/packages/shared/src/types/admin.types.ts @@ -4,7 +4,7 @@ // System Phase State Machine // ============================================================================ -export type SystemPhase = 'IDLE' | 'VOTING' | 'LOTTERY' | 'RESULTS' | 'LOTTERY_RESULTS'; +export type SystemPhase = 'IDLE' | 'VOTING' | 'LOTTERY' | 'RESULTS' | 'LOTTERY_RESULTS' | 'CHECKIN_WALL'; export type VotingSubPhase = 'CLOSED' | 'OPEN' | 'PAUSED'; @@ -23,6 +23,7 @@ export type LotteryRound = 1 | 2 | 3 | 4; export interface AdminState { systemPhase: SystemPhase; + eventTitle: string; voting: VotingState; lottery: LotteryState; music: MusicState; @@ -96,7 +97,7 @@ export interface LotteryWinner { export interface MusicState { isPlaying: boolean; - track: 'bgm' | 'lottery' | 'fanfare' | 'award' | 'none'; + track: 'bgm' | 'lottery' | 'fanfare' | 'award' | 'horse' | 'none'; volume: number; } @@ -174,6 +175,7 @@ export const DEFAULT_PROGRAMS: VotingProgram[] = [ export const INITIAL_ADMIN_STATE: AdminState = { systemPhase: 'IDLE', + eventTitle: '公司2026年会', voting: { subPhase: 'CLOSED', totalVotes: 0, diff --git a/packages/shared/src/types/checkin.types.ts b/packages/shared/src/types/checkin.types.ts new file mode 100644 index 0000000..066b26d --- /dev/null +++ b/packages/shared/src/types/checkin.types.ts @@ -0,0 +1,75 @@ +// Checkin Wall & Shake Types + +/** + * 签到用户信息 + */ +export interface CheckinUser { + id: string; + name: string; + avatar?: string; + shakeCount: number; + checkinTime: number; +} + +/** + * 签到墙同步数据 + */ +export interface CheckinWallSyncPayload { + users: CheckinUser[]; + totalCount: number; +} + +/** + * 用户加入签到墙事件 + */ +export interface CheckinUserJoinPayload { + user: CheckinUser; +} + +/** + * 用户信息更新事件(头像、摇动次数等) + */ +export interface CheckinUserUpdatePayload { + userId: string; + avatar?: string; + shakeCount?: number; +} + +/** + * 摇一摇计数事件 + */ +export interface ShakeCountPayload { + userId: string; + count: number; +} + +/** + * 摇一摇更新广播 + */ +export interface ShakeUpdatePayload { + userId: string; + userName: string; + avatar?: string; + shakeCount: number; +} + +/** + * 摇一摇排行榜 + */ +export interface ShakeLeaderboardPayload { + leaderboard: Array<{ + userId: string; + userName: string; + avatar?: string; + shakeCount: number; + rank: number; + }>; +} + +/** + * 头像更新事件 + */ +export interface AvatarUpdatedPayload { + userId: string; + avatar: string; +} diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 87d8fc6..1885de4 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -5,3 +5,4 @@ export * from './draw.types'; export * from './admin.types'; export * from './scan-login.types'; export * from './wechat.types'; +export * from './checkin.types'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 858458f..853d793 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: prettier: specifier: ^3.4.2 version: 3.7.4 + tsx: + specifier: ^4.19.2 + version: 4.21.0 typescript: specifier: ^5.7.3 version: 5.9.3 @@ -4205,11 +4208,12 @@ packages: glob@11.1.0: resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} engines: {node: 20 || >=22} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me globals@13.24.0: resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==}