feat: initialize Annual Gala Interactive System monorepo

- Set up pnpm workspace with 4 packages: shared, server, client-mobile, client-screen
- Implement Redis atomic voting with Lua scripts (HINCRBY + distributed lock)
- Add optimistic UI with IndexedDB queue for offline resilience
- Configure Socket.io with auto-reconnection (infinite retries)
- Separate mobile (Vant) and big screen (Pixi.js) dependencies

Tech stack:
- Frontend Mobile: Vue 3 + Vant + Socket.io-client
- Frontend Screen: Vue 3 + Pixi.js + GSAP
- Backend: Express + Socket.io + Redis + Prisma/MySQL

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
empty
2026-01-15 01:19:36 +08:00
commit e7397d22a9
74 changed files with 14088 additions and 0 deletions

View File

@@ -0,0 +1,56 @@
// Shared configuration values
export const CONFIG = {
// Voting
MAX_VOTES_PER_USER: 7,
VOTE_CATEGORIES_COUNT: 7,
// WebSocket
HEARTBEAT_INTERVAL_MS: 5000,
HEARTBEAT_TIMEOUT_MS: 10000,
RECONNECTION_DELAY_MS: 1000,
RECONNECTION_DELAY_MAX_MS: 5000,
RECONNECTION_ATTEMPTS: Infinity,
// Rate limiting
RATE_LIMIT_WINDOW_MS: 1000,
RATE_LIMIT_MAX_REQUESTS: 10,
// Session
SESSION_TTL_SECONDS: 3600, // 1 hour
// Queue
VOTE_QUEUE_MAX_RETRIES: 3,
VOTE_QUEUE_RETRY_DELAY_MS: 1000,
// Sync
SYNC_BATCH_SIZE: 100,
SYNC_INTERVAL_MS: 1000,
} as const;
export const REDIS_KEYS = {
// Vote counting
VOTE_COUNT: 'vote:count', // vote:count:{category}
USER_CATEGORIES: 'vote:user', // vote:user:{userId}:categories
CATEGORY_VOTERS: 'vote:category', // vote:category:{category}:voters
LEADERBOARD: 'leaderboard', // leaderboard:{category}
// Locking
VOTE_LOCK: 'lock:vote', // lock:vote:{userId}:{category}
// Sync
SYNC_QUEUE: 'sync:queue:votes',
SYNC_PROCESSING: 'sync:processing',
// Session
SESSION: 'session', // session:{sessionId}
USER_SESSION: 'session:user', // session:user:{userId}
// Rate limiting
RATE_LIMIT: 'ratelimit:vote', // ratelimit:vote:{userId}
// Draw
DRAW_ACTIVE: 'draw:active',
DRAW_PARTICIPANTS: 'draw:participants',
DRAW_WINNERS: 'draw:winners',
} as const;

View File

@@ -0,0 +1,48 @@
// Error codes and messages
export const ERROR_CODES = {
// Vote errors
ALREADY_VOTED: 'ALREADY_VOTED',
MAX_VOTES_REACHED: 'MAX_VOTES_REACHED',
INVALID_CANDIDATE: 'INVALID_CANDIDATE',
VOTING_CLOSED: 'VOTING_CLOSED',
// Auth errors
UNAUTHORIZED: 'UNAUTHORIZED',
INVALID_SESSION: 'INVALID_SESSION',
SESSION_EXPIRED: 'SESSION_EXPIRED',
// Rate limiting
RATE_LIMITED: 'RATE_LIMITED',
TOO_MANY_REQUESTS: 'TOO_MANY_REQUESTS',
// Server errors
INTERNAL_ERROR: 'INTERNAL_ERROR',
SERVICE_UNAVAILABLE: 'SERVICE_UNAVAILABLE',
LOCK_FAILED: 'LOCK_FAILED',
// Draw errors
DRAW_IN_PROGRESS: 'DRAW_IN_PROGRESS',
NO_PARTICIPANTS: 'NO_PARTICIPANTS',
DRAW_NOT_FOUND: 'DRAW_NOT_FOUND',
} as const;
export const ERROR_MESSAGES: Record<keyof typeof ERROR_CODES, string> = {
ALREADY_VOTED: '您已在此类别投过票',
MAX_VOTES_REACHED: '您已用完所有投票机会',
INVALID_CANDIDATE: '无效的候选人',
VOTING_CLOSED: '投票已结束',
UNAUTHORIZED: '请先登录',
INVALID_SESSION: '会话无效,请重新登录',
SESSION_EXPIRED: '会话已过期,请重新登录',
RATE_LIMITED: '操作过于频繁,请稍后再试',
TOO_MANY_REQUESTS: '请求过多,请稍后再试',
INTERNAL_ERROR: '服务器内部错误',
SERVICE_UNAVAILABLE: '服务暂时不可用',
LOCK_FAILED: '服务器繁忙,请重试',
DRAW_IN_PROGRESS: '抽奖正在进行中',
NO_PARTICIPANTS: '没有符合条件的参与者',
DRAW_NOT_FOUND: '抽奖不存在',
};
export type ErrorCode = keyof typeof ERROR_CODES;

View File

@@ -0,0 +1,39 @@
// Socket event name constants for consistency across client and server
export const SOCKET_EVENTS = {
// Vote events
VOTE_SUBMIT: 'vote:submit',
VOTE_BATCH: 'vote:batch',
VOTE_UPDATED: 'vote:updated',
VOTE_RESULT: 'vote:result',
VOTE_ERROR: 'vote:error',
// Draw events
DRAW_START: 'draw:start',
DRAW_STARTED: 'draw:started',
DRAW_SPINNING: 'draw:spinning',
DRAW_WINNER: 'draw:winner',
DRAW_STOP: 'draw:stop',
DRAW_ENDED: 'draw:ended',
// Connection events
CONNECTION_ACK: 'connection:ack',
CONNECTION_PING: 'connection:ping',
CONNECTION_PONG: 'connection:pong',
CONNECTION_JOIN: 'connection:join',
CONNECTION_USERS_COUNT: 'connection:users_count',
// Sync events
SYNC_REQUEST: 'sync:request',
SYNC_STATE: 'sync:state',
} as const;
export const SOCKET_ROOMS = {
MOBILE_USERS: 'room:mobile',
SCREEN_DISPLAY: 'room:screen',
ADMIN: 'room:admin',
ALL: 'room:all',
} as const;
export type SocketEventName = (typeof SOCKET_EVENTS)[keyof typeof SOCKET_EVENTS];
export type SocketRoomName = (typeof SOCKET_ROOMS)[keyof typeof SOCKET_ROOMS];

View File

@@ -0,0 +1,3 @@
export * from './events';
export * from './errors';
export * from './config';

View File

@@ -0,0 +1,4 @@
// Main entry point - re-export everything
export * from './types';
export * from './constants';
export * from './utils';

View File

@@ -0,0 +1,99 @@
import type { PrizeLevel } from './socket.types';
// ============================================================================
// User Types
// ============================================================================
export interface User {
id: string;
name: string;
department: string;
avatar?: string;
birthYear?: number;
zodiac?: string;
joinDate?: Date;
isActive: boolean;
createdAt: Date;
}
export interface UserSession {
sessionId: string;
visitorId: string;
userId: string;
userName: string;
role: 'user' | 'admin' | 'screen';
createdAt: Date;
lastActivity: Date;
ipAddress?: string;
userAgent?: string;
}
// ============================================================================
// Draw Types
// ============================================================================
export interface DrawParticipant {
userId: string;
userName: string;
department: string;
avatar?: string;
zodiac?: string;
birthYear?: number;
}
export interface DrawResult {
id: string;
drawId: string;
prizeLevel: PrizeLevel;
prizeName: string;
winnerId: string;
winnerName: string;
winnerDepartment: string;
drawnAt: Date;
drawnBy: string; // Admin who triggered the draw
}
export interface DrawSession {
id: string;
prizeLevel: PrizeLevel;
prizeName: string;
totalPrizes: number;
drawnCount: number;
isActive: boolean;
startedAt?: Date;
endedAt?: Date;
filters?: {
excludeWinners: boolean;
zodiacFilter?: string;
ageRange?: { min?: number; max?: number };
departments?: string[];
};
}
export interface DrawHistory {
sessions: DrawSession[];
results: DrawResult[];
totalParticipants: number;
totalWinners: number;
}
// ============================================================================
// API Types
// ============================================================================
export interface StartDrawRequest {
prizeLevel: PrizeLevel;
prizeName: string;
filters?: DrawSession['filters'];
}
export interface StartDrawResponse {
success: boolean;
drawId?: string;
participantCount?: number;
error?: string;
}
export interface GetDrawHistoryResponse {
history: DrawHistory;
}

View File

@@ -0,0 +1,4 @@
// Re-export all types
export * from './socket.types';
export * from './vote.types';
export * from './draw.types';

View File

@@ -0,0 +1,224 @@
// Socket.io event type definitions for type-safe client-server communication
// ============================================================================
// Vote Types
// ============================================================================
export interface VoteSubmitPayload {
candidateId: string;
category: VoteCategory;
clientTimestamp: number;
localId: string; // For optimistic UI reconciliation
}
export interface VoteBatchPayload {
votes: VoteSubmitPayload[];
}
export interface VoteUpdatePayload {
candidateId: string;
category: VoteCategory;
totalVotes: number;
delta: number;
}
export interface VoteResultPayload {
category: VoteCategory;
results: Array<{
candidateId: string;
candidateName: string;
votes: number;
rank: number;
}>;
}
// ============================================================================
// Draw Types
// ============================================================================
export interface DrawStartPayload {
drawId: string;
prizeLevel: PrizeLevel;
prizeName: string;
participantCount: number;
}
export interface DrawSpinPayload {
drawId: string;
currentNames: string[]; // Names being shuffled on screen
phase: 'accelerating' | 'spinning' | 'decelerating';
}
export interface DrawWinnerPayload {
drawId: string;
winner: {
odrawId: string;
visitorId: string;
userId: string;
userName: string;
department: string;
avatar?: string;
};
prizeLevel: PrizeLevel;
prizeName: string;
}
export interface DrawStartRequest {
prizeLevel: PrizeLevel;
filters?: DrawFilters;
}
export interface DrawFilters {
excludeWinners?: boolean; // Exclude previous winners
zodiacFilter?: string; // e.g., 'horse' for Year of the Horse
ageRange?: { min?: number; max?: number };
departments?: string[];
}
// ============================================================================
// Connection Types
// ============================================================================
export interface JoinPayload {
userId: string;
userName: string;
role: UserRole;
sessionToken?: string;
}
export interface ConnectionAckPayload {
sessionId: string;
serverTime: number;
reconnected: boolean;
missedEvents?: unknown[];
}
export interface SyncStatePayload {
votes: Record<VoteCategory, Record<string, number>>; // category -> candidateId -> count
userVotedCategories: VoteCategory[];
currentDraw?: {
isActive: boolean;
prizeLevel: PrizeLevel;
drawId: string;
};
}
export interface SyncRequestPayload {
lastEventId?: string;
lastTimestamp?: number;
}
// ============================================================================
// Error Types
// ============================================================================
export interface ErrorPayload {
code: SocketErrorCode;
message: string;
details?: Record<string, unknown>;
}
export type SocketErrorCode =
| 'ALREADY_VOTED'
| 'MAX_VOTES_REACHED'
| 'INVALID_CANDIDATE'
| 'VOTING_CLOSED'
| 'UNAUTHORIZED'
| 'RATE_LIMITED'
| 'INTERNAL_ERROR';
// ============================================================================
// Callback Types
// ============================================================================
export type AckCallback<T = unknown> = (response: AckResponse<T>) => void;
export interface AckResponse<T = unknown> {
success: boolean;
error?: SocketErrorCode;
message?: string;
data?: T;
}
// ============================================================================
// Socket.io Event Maps
// ============================================================================
export interface ServerToClientEvents {
// Vote events
'vote:updated': (data: VoteUpdatePayload) => void;
'vote:result': (data: VoteResultPayload) => void;
'vote:error': (data: ErrorPayload) => void;
// Draw events
'draw:started': (data: DrawStartPayload) => void;
'draw:spinning': (data: DrawSpinPayload) => void;
'draw:winner': (data: DrawWinnerPayload) => void;
'draw:ended': () => void;
// Connection events
'connection:ack': (data: ConnectionAckPayload) => void;
'connection:users_count': (count: number) => void;
'connection:pong': () => void;
// Sync events
'sync:state': (data: SyncStatePayload) => void;
}
export interface ClientToServerEvents {
// Vote events
'vote:submit': (data: VoteSubmitPayload, callback: AckCallback<{ newCount: number }>) => void;
'vote:batch': (data: VoteBatchPayload, callback: AckCallback<{ processed: number }>) => void;
// Draw events (admin only)
'draw:start': (data: DrawStartRequest, callback: AckCallback<{ drawId: string }>) => void;
'draw:stop': (callback: AckCallback) => void;
// Connection events
'connection:ping': () => void;
'connection:join': (data: JoinPayload, callback: AckCallback<ConnectionAckPayload>) => void;
// Sync events
'sync:request': (data: SyncRequestPayload) => void;
}
export interface InterServerEvents {
ping: () => void;
}
export interface SocketData {
userId: string;
userName: string;
role: UserRole;
connectedAt: Date;
sessionId: string;
}
// ============================================================================
// Enums & Constants
// ============================================================================
export type UserRole = 'user' | 'admin' | 'screen';
export type VoteCategory =
| 'best_employee'
| 'best_team'
| 'best_newcomer'
| 'best_innovation'
| 'best_service'
| 'best_collaboration'
| 'best_leadership';
export type PrizeLevel = 'special' | 'first' | 'second' | 'third' | 'participation';
export const VOTE_CATEGORIES: VoteCategory[] = [
'best_employee',
'best_team',
'best_newcomer',
'best_innovation',
'best_service',
'best_collaboration',
'best_leadership',
];
export const PRIZE_LEVELS: PrizeLevel[] = ['special', 'first', 'second', 'third', 'participation'];

View File

@@ -0,0 +1,102 @@
import type { VoteCategory } from './socket.types';
// ============================================================================
// Candidate Types
// ============================================================================
export interface Candidate {
id: string;
name: string;
department: string;
avatar?: string;
description?: string;
category: VoteCategory;
createdAt: Date;
}
export interface CandidateWithVotes extends Candidate {
voteCount: number;
rank?: number;
percentage?: number;
}
// ============================================================================
// Vote Types
// ============================================================================
export interface Vote {
id: string;
odrawId: string;
userId: string;
candidateId: string;
category: VoteCategory;
createdAt: Date;
}
export interface VoteStats {
candidateId: string;
category: VoteCategory;
totalVotes: number;
percentage: number;
rank: number;
}
export interface CategoryVoteStats {
category: VoteCategory;
totalVotes: number;
candidates: VoteStats[];
updatedAt: Date;
}
// ============================================================================
// Voting Session Types
// ============================================================================
export interface VotingSession {
id: string;
name: string;
startTime: Date;
endTime: Date;
isActive: boolean;
maxVotesPerUser: number;
categories: VoteCategory[];
}
export interface UserVoteStatus {
userId: string;
votedCategories: VoteCategory[];
remainingVotes: number;
votes: Array<{
category: VoteCategory;
candidateId: string;
votedAt: Date;
}>;
}
// ============================================================================
// API Request/Response Types
// ============================================================================
export interface SubmitVoteRequest {
candidateId: string;
category: VoteCategory;
}
export interface SubmitVoteResponse {
success: boolean;
voteId?: string;
newCount?: number;
error?: string;
}
export interface GetCategoryResultsRequest {
category: VoteCategory;
limit?: number;
}
export interface GetCategoryResultsResponse {
category: VoteCategory;
results: CandidateWithVotes[];
totalVotes: number;
updatedAt: Date;
}

View File

@@ -0,0 +1,72 @@
// Date and number formatting utilities
/**
* Format a date to Chinese locale string
*/
export function formatDate(date: Date | string | number): string {
const d = new Date(date);
return d.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
}
/**
* Format a date with time
*/
export function formatDateTime(date: Date | string | number): string {
const d = new Date(date);
return d.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
/**
* Format a number with thousand separators
*/
export function formatNumber(num: number): string {
return num.toLocaleString('zh-CN');
}
/**
* Format a percentage
*/
export function formatPercentage(value: number, decimals = 1): string {
return `${(value * 100).toFixed(decimals)}%`;
}
/**
* Format vote count with unit
*/
export function formatVoteCount(count: number): string {
if (count >= 10000) {
return `${(count / 10000).toFixed(1)}万票`;
}
return `${count}`;
}
/**
* Get relative time string (e.g., "3分钟前")
*/
export function getRelativeTime(date: Date | string | number): string {
const now = Date.now();
const then = new Date(date).getTime();
const diff = now - then;
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) return `${days}天前`;
if (hours > 0) return `${hours}小时前`;
if (minutes > 0) return `${minutes}分钟前`;
if (seconds > 10) return `${seconds}秒前`;
return '刚刚';
}

View File

@@ -0,0 +1,3 @@
export * from './validation';
export * from './formatters';
export * from './retry';

View File

@@ -0,0 +1,116 @@
// Retry utility with exponential backoff
export interface RetryOptions {
maxRetries?: number;
initialDelayMs?: number;
maxDelayMs?: number;
backoffMultiplier?: number;
shouldRetry?: (error: unknown, attempt: number) => boolean;
onRetry?: (error: unknown, attempt: number, delayMs: number) => void;
}
const DEFAULT_OPTIONS: Required<Omit<RetryOptions, 'shouldRetry' | 'onRetry'>> = {
maxRetries: 3,
initialDelayMs: 1000,
maxDelayMs: 30000,
backoffMultiplier: 2,
};
/**
* Execute a function with retry logic and exponential backoff
*/
export async function retry<T>(
fn: () => Promise<T>,
options: RetryOptions = {}
): Promise<T> {
const opts = { ...DEFAULT_OPTIONS, ...options };
let lastError: unknown;
let delay = opts.initialDelayMs;
for (let attempt = 1; attempt <= opts.maxRetries + 1; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error;
if (attempt > opts.maxRetries) {
break;
}
// Check if we should retry
if (opts.shouldRetry && !opts.shouldRetry(error, attempt)) {
break;
}
// Notify about retry
if (opts.onRetry) {
opts.onRetry(error, attempt, delay);
}
// Wait before retrying
await sleep(delay);
// Calculate next delay with exponential backoff
delay = Math.min(delay * opts.backoffMultiplier, opts.maxDelayMs);
}
}
throw lastError;
}
/**
* Sleep for a specified duration
*/
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Create a timeout promise that rejects after specified duration
*/
export function timeout<T>(promise: Promise<T>, ms: number, message?: string): Promise<T> {
return Promise.race([
promise,
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error(message || `Timeout after ${ms}ms`)), ms)
),
]);
}
/**
* Debounce a function
*/
export function debounce<T extends (...args: unknown[]) => unknown>(
fn: T,
delayMs: number
): (...args: Parameters<T>) => void {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
return (...args: Parameters<T>) => {
if (timeoutId) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(() => {
fn(...args);
timeoutId = null;
}, delayMs);
};
}
/**
* Throttle a function
*/
export function throttle<T extends (...args: unknown[]) => unknown>(
fn: T,
limitMs: number
): (...args: Parameters<T>) => void {
let lastRun = 0;
return (...args: Parameters<T>) => {
const now = Date.now();
if (now - lastRun >= limitMs) {
lastRun = now;
fn(...args);
}
};
}

View File

@@ -0,0 +1,64 @@
import { z } from 'zod';
import { VOTE_CATEGORIES, PRIZE_LEVELS } from '../types/socket.types';
// ============================================================================
// Vote Validation Schemas
// ============================================================================
export const voteSubmitSchema = z.object({
candidateId: z.string().min(1).max(64),
category: z.enum(VOTE_CATEGORIES as unknown as [string, ...string[]]),
clientTimestamp: z.number().positive(),
localId: z.string().uuid(),
});
export const voteBatchSchema = z.object({
votes: z.array(voteSubmitSchema).min(1).max(7),
});
// ============================================================================
// Draw Validation Schemas
// ============================================================================
export const drawFiltersSchema = z.object({
excludeWinners: z.boolean().optional(),
zodiacFilter: z.string().optional(),
ageRange: z
.object({
min: z.number().min(18).max(100).optional(),
max: z.number().min(18).max(100).optional(),
})
.optional(),
departments: z.array(z.string()).optional(),
});
export const drawStartSchema = z.object({
prizeLevel: z.enum(PRIZE_LEVELS as unknown as [string, ...string[]]),
filters: drawFiltersSchema.optional(),
});
// ============================================================================
// Connection Validation Schemas
// ============================================================================
export const joinPayloadSchema = z.object({
userId: z.string().min(1).max(64),
userName: z.string().min(1).max(100),
role: z.enum(['user', 'admin', 'screen']),
sessionToken: z.string().optional(),
});
export const syncRequestSchema = z.object({
lastEventId: z.string().optional(),
lastTimestamp: z.number().optional(),
});
// ============================================================================
// Type Exports
// ============================================================================
export type VoteSubmitInput = z.infer<typeof voteSubmitSchema>;
export type VoteBatchInput = z.infer<typeof voteBatchSchema>;
export type DrawStartInput = z.infer<typeof drawStartSchema>;
export type JoinPayloadInput = z.infer<typeof joinPayloadSchema>;
export type SyncRequestInput = z.infer<typeof syncRequestSchema>;