2026-03-13 10:03:05 +08:00
|
|
|
|
import { Injectable } from '@nestjs/common';
|
|
|
|
|
|
import { Role, RtcTokenBuilder } from '../../plugins/shengwang/RtcTokenBuilder2';
|
|
|
|
|
|
import { NacosConfigService } from '../../plugins/nacos/nacos-config.service';
|
|
|
|
|
|
import { EntityManager } from '@mikro-orm/core';
|
|
|
|
|
|
import type { TokenResponseDto } from './meeting.dto';
|
2026-03-14 14:24:39 +08:00
|
|
|
|
import { MeetingUser } from '@/entities/MeetingUser';
|
2026-03-13 10:03:05 +08:00
|
|
|
|
|
2026-03-14 14:52:18 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 学生信息接口
|
|
|
|
|
|
*/
|
|
|
|
|
|
export interface StudentInfo {
|
|
|
|
|
|
/** 学生 ID */
|
|
|
|
|
|
studentId: number;
|
|
|
|
|
|
/** 学生姓名 */
|
|
|
|
|
|
studentName: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 学生详细信息接口 (包含长短 ID)
|
|
|
|
|
|
*/
|
|
|
|
|
|
export interface StudentDetailInfo {
|
|
|
|
|
|
/** 学生长 ID (数据库 ID) */
|
|
|
|
|
|
longId: number;
|
|
|
|
|
|
/** 学生短 ID (声网短 ID) */
|
|
|
|
|
|
shortId: number;
|
|
|
|
|
|
/** 学生姓名 */
|
|
|
|
|
|
studentName: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-13 10:03:05 +08:00
|
|
|
|
@Injectable()
|
|
|
|
|
|
export class MeetingService {
|
|
|
|
|
|
constructor(
|
|
|
|
|
|
private readonly em: EntityManager,
|
|
|
|
|
|
private readonly nacosConfig: NacosConfigService
|
|
|
|
|
|
) {}
|
|
|
|
|
|
|
2026-03-14 14:52:18 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 通过作业 ID 获取学生详细信息列表 (包含长短 ID)
|
|
|
|
|
|
* @param homeworkId - 作业 ID
|
|
|
|
|
|
* @returns 学生详细信息列表,包含长 ID、短 ID 和姓名
|
|
|
|
|
|
*/
|
|
|
|
|
|
public async getStudentDetailsByHomeworkId(homeworkId: number): Promise<StudentDetailInfo[]> {
|
|
|
|
|
|
const id = Number(homeworkId);
|
|
|
|
|
|
if (!Number.isFinite(id) || id <= 0) {
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 查询学生信息并批量分配缺失的短 ID
|
|
|
|
|
|
const sql = `
|
|
|
|
|
|
SELECT u.Id AS longId, u.Name AS studentName, mu.id AS shortId
|
|
|
|
|
|
FROM icr_homework h
|
|
|
|
|
|
INNER JOIN scs_studentgroup sg ON h.StudentGroupId = sg.Id
|
|
|
|
|
|
INNER JOIN scs_studentgroupdetail sgd ON sg.Id = sgd.StudentGroupId
|
|
|
|
|
|
INNER JOIN scs_user u ON sgd.StudentId = u.Id
|
|
|
|
|
|
LEFT JOIN meeting_user mu ON u.Id = mu.longUserId
|
|
|
|
|
|
WHERE h.Id = ${id}
|
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
|
|
const rows = await this.em.getConnection().execute(sql);
|
|
|
|
|
|
const results = rows as Array<{ longId: number | string; studentName: string; shortId: number | null }>;
|
|
|
|
|
|
|
|
|
|
|
|
// 批量插入缺失的短 ID
|
|
|
|
|
|
const missingIds = results.filter((row) => row.shortId === null).map((row) => Number(row.longId));
|
|
|
|
|
|
if (missingIds.length > 0) {
|
|
|
|
|
|
// 批量插入缺失的短 ID
|
|
|
|
|
|
await this.em.getConnection().execute(`
|
|
|
|
|
|
INSERT INTO meeting_user (longUserId, createdAt)
|
|
|
|
|
|
VALUES ${missingIds.map((longUserId) => `(${longUserId}, NOW())`).join(',')}
|
|
|
|
|
|
ON DUPLICATE KEY UPDATE longUserId = longUserId
|
|
|
|
|
|
`);
|
|
|
|
|
|
|
|
|
|
|
|
// 查询新分配的短 ID
|
|
|
|
|
|
const newUsers = await this.em.getConnection().execute(`
|
|
|
|
|
|
SELECT longUserId, id FROM meeting_user WHERE longUserId IN (${missingIds.join(',')})
|
|
|
|
|
|
`);
|
|
|
|
|
|
const shortIdMap = new Map<number, number>();
|
|
|
|
|
|
(newUsers as Array<{ longUserId: number; id: number }>).forEach((user) => {
|
|
|
|
|
|
shortIdMap.set(user.longUserId, user.id);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 更新缺失的短 ID
|
|
|
|
|
|
results.forEach((row) => {
|
|
|
|
|
|
if (row.shortId === null) {
|
|
|
|
|
|
row.shortId = shortIdMap.get(Number(row.longId)) ?? null;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 转换为最终结果
|
|
|
|
|
|
return results.map((row) => ({
|
|
|
|
|
|
longId: Number(row.longId),
|
|
|
|
|
|
studentName: row.studentName || '',
|
|
|
|
|
|
shortId: row.shortId ?? 0,
|
|
|
|
|
|
}));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 通过作业 ID 获取学生列表
|
|
|
|
|
|
* @param homeworkId - 作业 ID
|
|
|
|
|
|
* @returns 学生列表,包含学生 ID 和姓名
|
|
|
|
|
|
*/
|
|
|
|
|
|
public async getStudentsByHomeworkId(homeworkId: number): Promise<StudentInfo[]> {
|
|
|
|
|
|
const id = Number(homeworkId);
|
|
|
|
|
|
if (!Number.isFinite(id) || id <= 0) {
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 使用单条 SQL JOIN 查询 (性能最优)
|
|
|
|
|
|
const sql = `
|
|
|
|
|
|
SELECT
|
|
|
|
|
|
u.Id AS studentId,
|
|
|
|
|
|
u.Name AS studentName
|
|
|
|
|
|
FROM icr_homework h
|
|
|
|
|
|
INNER JOIN scs_studentgroup sg ON h.StudentGroupId = sg.Id
|
|
|
|
|
|
INNER JOIN scs_studentgroupdetail sgd ON sg.Id = sgd.StudentGroupId
|
|
|
|
|
|
INNER JOIN scs_user u ON sgd.StudentId = u.Id
|
|
|
|
|
|
WHERE h.Id = ${id}
|
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
|
|
const rows = await this.em.getConnection().execute(sql);
|
|
|
|
|
|
return (rows as Array<{ studentId: number | string; studentName: string }>).map((row) => ({
|
|
|
|
|
|
studentId: Number(row.studentId),
|
|
|
|
|
|
studentName: row.studentName || '',
|
|
|
|
|
|
}));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-13 10:03:05 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 通过作业 ID 查询老师的长 ID
|
|
|
|
|
|
* @param homeworkId - 作业 ID(JoinRoomData.homeworkId)
|
|
|
|
|
|
* @returns 老师长 ID,未查询到返回 null
|
|
|
|
|
|
*/
|
|
|
|
|
|
public async getTeacherLongUserIdByHomeworkId(homeworkId: number): Promise<number | null> {
|
|
|
|
|
|
const id = Number(homeworkId);
|
|
|
|
|
|
if (!Number.isFinite(id) || id <= 0) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
const sql = 'SELECT B.TeacherId AS teacherId FROM icr_homework AS A LEFT JOIN scs_studentgroup AS B ON A.StudentGroupId = B.Id WHERE A.Id = ? LIMIT 1';
|
|
|
|
|
|
const rows = (await this.em.getConnection().execute(sql, [id])) as Array<{ teacherId?: number | string }>;
|
|
|
|
|
|
const teacherIdRaw = rows?.[0]?.teacherId;
|
|
|
|
|
|
const teacherId = teacherIdRaw === undefined ? NaN : Number(teacherIdRaw);
|
|
|
|
|
|
if (!Number.isFinite(teacherId) || teacherId <= 0) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
return teacherId;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 通过作业 ID 获取老师的短 UID
|
|
|
|
|
|
* @param homeworkId - 作业 ID(JoinRoomData.homeworkId)
|
|
|
|
|
|
* @returns 老师短 UID,未查询到返回 null
|
|
|
|
|
|
*/
|
|
|
|
|
|
public async getTeacherShortUidByHomeworkId(homeworkId: number): Promise<number | null> {
|
|
|
|
|
|
const teacherLongUserId = await this.getTeacherLongUserIdByHomeworkId(homeworkId);
|
|
|
|
|
|
if (!teacherLongUserId) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
return await this.getOrAssignShortUid(teacherLongUserId);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 获取或分配用户的短 UID
|
|
|
|
|
|
* @param longUserId - 用户的真实 ID(bigint)
|
|
|
|
|
|
* @returns 分配的短 UID(数据库自增 ID)
|
|
|
|
|
|
*/
|
|
|
|
|
|
public async getOrAssignShortUid(longUserId: number | string): Promise<number> {
|
|
|
|
|
|
const userId = typeof longUserId === 'string' ? BigInt(longUserId) : BigInt(longUserId);
|
|
|
|
|
|
|
|
|
|
|
|
// 尝试获取已存在的记录
|
|
|
|
|
|
const existingUser = await this.em.findOne(MeetingUser, { longUserId: Number(userId) });
|
|
|
|
|
|
|
|
|
|
|
|
if (existingUser) {
|
|
|
|
|
|
// 复用已有的短 UID
|
|
|
|
|
|
console.log(`[MeetingService] 用户 ${longUserId} 复用短 UID: ${existingUser.id}`);
|
|
|
|
|
|
return existingUser.id;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 创建新记录(数据库自增 ID 会自动分配)
|
|
|
|
|
|
try {
|
|
|
|
|
|
const newUser = this.em.create(MeetingUser, { longUserId: Number(userId), createdAt: new Date() });
|
|
|
|
|
|
await this.em.flush();
|
|
|
|
|
|
console.log(`[MeetingService] 为用户 ${longUserId} 分配短 UID: ${newUser.id}`);
|
|
|
|
|
|
return newUser.id;
|
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
|
// 处理并发导致的唯一索引冲突 (MySQL Error 1062: Duplicate entry)
|
|
|
|
|
|
if (error.code === 'ER_DUP_ENTRY' || error.message?.includes('Duplicate entry')) {
|
|
|
|
|
|
console.warn(`[MeetingService] 检测到并发创建冲突,重新获取用户 ${longUserId}`);
|
|
|
|
|
|
this.em.clear(); // 清除当前上下文,防止缓存干扰
|
|
|
|
|
|
const retryUser = await this.em.findOne(MeetingUser, { longUserId: Number(userId) });
|
|
|
|
|
|
if (retryUser) {
|
|
|
|
|
|
return retryUser.id;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
throw error;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public generateToken(channelName: string, uid: number): TokenResponseDto | null {
|
|
|
|
|
|
// 从 Nacos 配置获取 appId 和 appCertificate
|
|
|
|
|
|
const agoraConfig = this.nacosConfig.getDefaultAgoraAppConfig();
|
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
|
const role = Role.PUBLISHER;
|
|
|
|
|
|
const tokenExpirationInSecond = 60 * 60 * 24; // 24 小时
|
|
|
|
|
|
const privilegeExpirationInSecond = tokenExpirationInSecond;
|
|
|
|
|
|
const token = RtcTokenBuilder.buildTokenWithUid(
|
|
|
|
|
|
agoraConfig.appId,
|
|
|
|
|
|
agoraConfig.appCertificate,
|
|
|
|
|
|
channelName, // 使用清理后的频道名
|
|
|
|
|
|
uid,
|
|
|
|
|
|
role,
|
|
|
|
|
|
tokenExpirationInSecond,
|
|
|
|
|
|
privilegeExpirationInSecond
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
if (!token) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
const expiresAt = now + tokenExpirationInSecond * 1000;
|
|
|
|
|
|
return { appid: agoraConfig.appId, rtcToken: token, expiresAt };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|