feat: 新增签到墙、摇一摇等功能及开发环境配置
新功能: - 签到墙页面 (CheckinWallView) 及后端接口 - 摇一摇互动页面 (ShakeView) 及服务 - 头像服务 (avatar.service) - 微信公众号静默授权登录增强 开发环境: - 新增 dev-tunnel skill 用于本地调试 - docker-compose.dev.yml 开发环境配置 - 客户端 .env.development 配置文件 其他改进: - VoteView 投票页面功能增强 - AdminControl 管理控制台更新 - 连接状态管理优化 - 新增马蹄声音效 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
BIN
packages/server/public/avatars/wx_oECwX1HxV-47N4qC.jpg
Normal file
BIN
packages/server/public/avatars/wx_oECwX1HxV-47N4qC.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
BIN
packages/server/public/avatars/wx_oECwX1KlFOgik0l_.jpg
Normal file
BIN
packages/server/public/avatars/wx_oECwX1KlFOgik0l_.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
@@ -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
|
||||
|
||||
82
packages/server/src/routes/checkin.routes.ts
Normal file
82
packages/server/src/routes/checkin.routes.ts
Normal file
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 => ({
|
||||
|
||||
75
packages/server/src/services/avatar.service.ts
Normal file
75
packages/server/src/services/avatar.service.ts
Normal file
@@ -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<string | null> {
|
||||
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();
|
||||
@@ -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');
|
||||
|
||||
|
||||
161
packages/server/src/services/shake.service.ts
Normal file
161
packages/server/src/services/shake.service.ts
Normal file
@@ -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<number> {
|
||||
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<number> {
|
||||
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<Record<string, number>> {
|
||||
try {
|
||||
const counts = await redis.hgetall(SHAKE_COUNTS_KEY);
|
||||
const result: Record<string, number> = {};
|
||||
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<Array<{ userId: string; shakeCount: number; rank: number }>> {
|
||||
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<number | null> {
|
||||
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<void> {
|
||||
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();
|
||||
Reference in New Issue
Block a user