feat: 班级小助手 阶段1-3(monorepo + 后端 REST/Socket.IO + Web 管理端)

This commit is contained in:
LoveChengke committed 2026-09-11 21:57:38 +08:00
commit 84ab49de10
102 files changed
+14751

No files matched your search

+30
View File
@@ -0,0 +1,30 @@
import { SOCKET_ROOMS } from '@classhelper/shared';
/**
* 实时事件总线。
*
* 业务模块只依赖这里,不直接依赖 Socket.IO 实例,
* 因此「移除实时推送」只需让它注册不到 emitter,业务代码无需改动。
*/
export type RealtimeEmitter = (rooms: string[], event: string, payload: unknown) => void;
let emitter: RealtimeEmitter | null = null;
export function registerEmitter(next: RealtimeEmitter): void {
emitter = next;
}
export function emitToRooms(rooms: string[], event: string, payload: unknown): void {
if (!emitter || rooms.length === 0) return;
emitter(rooms, event, payload);
}
/** 按班级房间广播(class:{classId}) */
export function emitToClass(classId: string, event: string, payload: unknown): void {
emitToRooms([SOCKET_ROOMS.class(classId)], event, payload);
}
/** 定向推送给单个用户 */
export function emitToUser(userId: string, event: string, payload: unknown): void {
emitToRooms([SOCKET_ROOMS.user(userId)], event, payload);
}
+141
View File
@@ -0,0 +1,141 @@
import type { Server as HttpServer } from 'node:http';
import { Server } from 'socket.io';
import {
SOCKET_EVENTS,
SOCKET_ROOMS,
type ClientToServerEvents,
type ServerToClientEvents,
type SocketAuthPayload,
type UserRole,
} from '@classhelper/shared';
import { env } from '../config/env.js';
import { getAccessibleClassIds } from '../lib/access.js';
import { verifyToken } from '../lib/jwt.js';
import { logger } from '../lib/logger.js';
import { registerEmitter, type RealtimeEmitter } from './bus.js';
interface SocketData {
user: {
id: string;
username: string;
name: string;
role: UserRole;
classId: string | null;
};
}
export type RealtimeServer = Server<
ClientToServerEvents,
ServerToClientEvents,
Record<string, never>,
SocketData
>;
/** 绑定 Socket.IO 到 HTTP 服务,并注册到事件总线 */
export function initRealtime(httpServer: HttpServer): RealtimeServer {
const io: RealtimeServer = new Server(httpServer, {
cors: { origin: env.corsOrigins, credentials: true },
transports: ['websocket', 'polling'],
pingInterval: 25000,
pingTimeout: 20000,
});
// 握手鉴权:客户端通过 handshake.auth.token 传入 JWT
io.use((socket, next) => {
try {
const auth = (socket.handshake.auth ?? {}) as Partial<SocketAuthPayload>;
const queryToken =
typeof socket.handshake.query.token === 'string' ? socket.handshake.query.token : null;
const token = auth.token ?? queryToken;
if (!token) {
next(new Error('缺少认证 token'));
return;
}
const payload = verifyToken(token);
socket.data.user = {
id: payload.sub,
username: payload.username,
name: payload.name,
role: payload.role,
classId: payload.classId,
};
next();
} catch {
next(new Error('认证失败,请重新登录'));
}
});
io.on('connection', (socket) => {
const user = socket.data.user;
void (async () => {
const rooms = [SOCKET_ROOMS.user(user.id), SOCKET_ROOMS.role(user.role)];
try {
const classIds = await getAccessibleClassIds({
sub: user.id,
username: user.username,
name: user.name,
role: user.role,
classId: user.classId,
});
for (const classId of classIds) {
await socket.join(SOCKET_ROOMS.class(classId));
rooms.push(SOCKET_ROOMS.class(classId));
}
} catch (error) {
logger.warn(`加入班级房间失败(user=${user.id})`, error);
}
await socket.join(user.role === 'STUDENT' ? SOCKET_ROOMS.students : SOCKET_ROOMS.teachers);
rooms.push(user.role === 'STUDENT' ? SOCKET_ROOMS.students : SOCKET_ROOMS.teachers);
logger.info(`实时连接建立:${user.name}(${user.role}) sid=${socket.id}`);
socket.emit(SOCKET_EVENTS.connected, { userId: user.id, role: user.role, rooms });
})();
// 客户端可显式订阅某个班级(服务端二次校验权限)
socket.on('class:join', (classId, ack) => {
void (async () => {
try {
const allowed = await getAccessibleClassIds({
sub: user.id,
username: user.username,
name: user.name,
role: user.role,
classId: user.classId,
});
if (!allowed.includes(classId)) {
ack?.({ ok: false, message: '无权订阅该班级' });
return;
}
await socket.join(SOCKET_ROOMS.class(classId));
ack?.({ ok: true });
} catch {
ack?.({ ok: false, message: '订阅失败' });
}
})();
});
socket.on('class:leave', (classId) => {
void socket.leave(SOCKET_ROOMS.class(classId));
});
socket.on('ping', (ack) => {
ack?.();
});
socket.on('disconnect', (reason) => {
logger.debug(`实时连接断开:${user.name} (${reason})`);
});
});
const emit: RealtimeEmitter = (rooms, event, payload) => {
const target = io.to(rooms) as unknown as { emit: (event: string, payload: unknown) => void };
target.emit(event, payload);
logger.debug(`实时推送 ${event} -> ${rooms.join(',')}`);
};
registerEmitter(emit);
logger.info('Socket.IO 实时通道已就绪(房间规则:class:{classId} / user:{id})');
return io;
}