feat: 班级小助手 阶段1-3(monorepo + 后端 REST/Socket.IO + Web 管理端)
This commit is contained in:
commit
84ab49de10
102 files changed
+14751
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user