diff --git a/node_api/src/common/pipes/non-empty-string.pipe.ts b/node_api/src/common/pipes/non-empty-string.pipe.ts index 8b88302..d7e14bb 100644 --- a/node_api/src/common/pipes/non-empty-string.pipe.ts +++ b/node_api/src/common/pipes/non-empty-string.pipe.ts @@ -1,18 +1,14 @@ -import { HttpException, Injectable, type PipeTransform, UnprocessableEntityException } from '@nestjs/common'; +import { Injectable, type PipeTransform, UnprocessableEntityException } from '@nestjs/common'; @Injectable() export class NonEmptyStringPipe implements PipeTransform { - public constructor( - private readonly name?: string, - private readonly code: 422 | 423 = 422 - ) {} + /** + * 静态 transform 方法 (用于装饰器) + * @param value - 待验证的值 + */ public transform(value: unknown): string { if (typeof value !== 'string' || value.trim().length === 0) { - const message = `${this.name ?? '参数'}不能为空`; - if (this.code === 422) { - throw new UnprocessableEntityException(message); - } - throw new HttpException(message, 423); + throw new UnprocessableEntityException('参数不能为空'); } return value.trim(); } diff --git a/node_api/src/common/pipes/uint32.pipe.ts b/node_api/src/common/pipes/uint32.pipe.ts index 24f1a1c..faf9d8a 100644 --- a/node_api/src/common/pipes/uint32.pipe.ts +++ b/node_api/src/common/pipes/uint32.pipe.ts @@ -1,43 +1,25 @@ -import { HttpException, Injectable, type PipeTransform, UnprocessableEntityException } from '@nestjs/common'; +import { Injectable, type PipeTransform, UnprocessableEntityException } from '@nestjs/common'; @Injectable() export class Uint32Pipe implements PipeTransform { - public constructor( - private readonly name?: string, - private readonly code: 422 | 423 = 422 - ) {} - + /** + * 静态 transform 方法 (用于装饰器) + * @param value - 待验证的值 + */ public transform(value: unknown): number { - const field = this.name ?? '参数'; if (value === undefined || value === null || value === '') { - const message = `${field}不能为空`; - if (this.code === 422) { - throw new UnprocessableEntityException(message); - } - throw new HttpException(message, 423); + throw new UnprocessableEntityException('参数不能为空'); } if ((typeof value === 'string' && !/^\d+$/.test(value)) || isNaN(Number(value))) { - const message = `${field}必须是数字`; - if (this.code === 422) { - throw new UnprocessableEntityException(message); - } - throw new HttpException(message, 423); + throw new UnprocessableEntityException('参数必须是数字'); } const num = Number(value); if (!Number.isSafeInteger(num)) { - const message = `${field}超出安全整数范围`; - if (this.code === 422) { - throw new UnprocessableEntityException(message); - } - throw new HttpException(message, 423); + throw new UnprocessableEntityException('参数超出安全整数范围'); } else if (num < 0 || num > 4294967295) { - const message = `${field}必须在0到4294967295之间`; - if (this.code === 422) { - throw new UnprocessableEntityException(message); - } - throw new HttpException(message, 423); + throw new UnprocessableEntityException('参数必须在 0 到 4294967295 之间'); } return num; } diff --git a/node_api/src/config/mikro-orm.config.ts b/node_api/src/config/mikro-orm.config.ts index 3e99b1b..3bde9fb 100644 --- a/node_api/src/config/mikro-orm.config.ts +++ b/node_api/src/config/mikro-orm.config.ts @@ -1,5 +1,5 @@ import { defineConfig } from '@mikro-orm/mysql'; -import { MeetingUser } from '../modules/meeting/entities/meeting-user.entity'; +import { MeetingUser } from '../entities/MeetingUser'; const config = defineConfig({ dbName: process.env.DB_NAME || 'scs', diff --git a/node_api/src/modules/meeting/meeting.controller.ts b/node_api/src/modules/meeting/meeting.controller.ts index 9f6af7f..511bf1b 100644 --- a/node_api/src/modules/meeting/meeting.controller.ts +++ b/node_api/src/modules/meeting/meeting.controller.ts @@ -1,12 +1,13 @@ -import { Controller, Delete, Get, HttpCode, HttpStatus, Param } from '@nestjs/common'; -import { ApiParam, ApiProperty, ApiTags } from '@nestjs/swagger'; +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseIntPipe, Post, Query } from '@nestjs/common'; +import { ApiParam, ApiProperty, ApiQuery, ApiTags } from '@nestjs/swagger'; import { FailResult, OkResult } from '../../common/dto/result.dto'; import { MeetingService } from './meeting.service'; import { NonEmptyStringPipe } from '@/common/pipes/non-empty-string.pipe'; import { Uint32Pipe } from '@/common/pipes/uint32.pipe'; import { ApiCustomOkResponse } from '@/common/decorators/swagger.decorator'; -import { StudentListResponseDto, TokenResponseDto } from './meeting.dto'; +import { SaveStudentOrderDto, StudentListResponseDto, TokenResponseDto } from './meeting.dto'; import { MeetingRedisService } from '../websocket/meeting-redis.service'; +import { MeetingWebSocketGateway } from '../websocket/meeting.websocket'; /** * 黑名单用户 DTO @@ -28,7 +29,8 @@ class BlacklistUserDto { export class MeetingController { public constructor( private readonly meetingService: MeetingService, - private readonly redis: MeetingRedisService + private readonly redis: MeetingRedisService, + private readonly websocketGateway: MeetingWebSocketGateway // 注入 WebSocket Gateway ) {} /** @@ -44,7 +46,7 @@ export class MeetingController { }) @ApiParam({ name: 'channelName', description: '需要加入的频道名(格式: `n_课程名称`)', example: 'n_1234567890' }) @ApiParam({ name: 'uid', description: '用户的短 UID(0~4294967295)', example: '1001' }) - public async getToken(@Param('channelName', new NonEmptyStringPipe('channelName')) channelName: string, @Param('uid', new Uint32Pipe('uid')) uid: number) { + public async getToken(@Param('channelName', NonEmptyStringPipe) channelName: string, @Param('uid', Uint32Pipe) uid: number) { const result = this.meetingService.generateToken(channelName, uid); if (!result) { return new FailResult('生成 Token 失败'); @@ -64,7 +66,7 @@ export class MeetingController { apiDescription: '房间黑名单(包含短 UID 和名称的数组)', resDescription: '房间黑名单(包含短 UID 和名称的数组)', }) - public async getBlacklist(@Param('roomId', new NonEmptyStringPipe('roomId')) roomId: string) { + public async getBlacklist(@Param('roomId', NonEmptyStringPipe) roomId: string) { const list = await this.redis.getBlacklist(roomId); return new OkResult(list); } @@ -82,10 +84,7 @@ export class MeetingController { apiDescription: '从房间黑名单移除指定用户(短 UID)', resDescription: '是否成功移除用户', }) - public async removeFromBlacklist( - @Param('roomId', new NonEmptyStringPipe('roomId')) roomId: string, - @Param('shortUid', new Uint32Pipe('shortUid')) shortUid: number - ) { + public async removeFromBlacklist(@Param('roomId', NonEmptyStringPipe) roomId: string, @Param('shortUid', Uint32Pipe) shortUid: number) { await this.redis.removeFromBlacklist(roomId, shortUid); return new OkResult(true); } @@ -93,20 +92,57 @@ export class MeetingController { /** * 通过作业 ID 查询学生详细信息列表 */ - @Get('students/:homeworkId') + @Get('students') @HttpCode(HttpStatus.OK) - @ApiParam({ name: 'homeworkId', description: '作业 ID', example: '1234567890' }) + @ApiQuery({ name: 'homeworkId', description: '作业 ID', example: '1234567890' }) + @ApiQuery({ name: 'roomId', description: '房间 ID (可选,用于获取 Redis 中的排序)', required: false, example: 'n_1234567890' }) @ApiCustomOkResponse({ summary: '学生详细信息列表', model: StudentListResponseDto, apiDescription: '学生详细信息列表,包含长 ID、短 ID (可能为 null) 和姓名', resDescription: '学生详细信息列表', }) - public async getStudentDetails(@Param('homeworkId', new Uint32Pipe('homeworkId')) homeworkId: number) { - const students = await this.meetingService.getStudentDetailsByHomeworkId(homeworkId); + public async getStudentDetails(@Query('homeworkId', ParseIntPipe) homeworkId: number, @Query('roomId', NonEmptyStringPipe) roomId?: string) { + const students = await this.meetingService.getStudentDetailsByHomeworkId(homeworkId, roomId); return new OkResult({ total: students.length, students, }); } + + /** + * 保存学生排序顺序 + */ + @Post('student-order') + @HttpCode(HttpStatus.OK) + @ApiCustomOkResponse({ + summary: '保存学生排序顺序', + model: Boolean, + apiDescription: '保存学生排序顺序到 Redis 并发送给监控端', + resDescription: '是否成功保存', + }) + public async saveStudentOrder(@Body() dto: SaveStudentOrderDto) { + // 1. 保存到 Redis + await this.meetingService.saveStudentOrder(dto.roomId, dto.studentLongIds); + + // 2. 获取房间内所有 socket 并过滤出监控端 (monitor 模式) + const ns = this.websocketGateway.getServer(); + if (ns) { + const sockets = await ns.in(dto.roomId).fetchSockets(); + const monitorSocketIds = sockets.filter((socket) => socket.data.joinMode === 'monitor').map((socket) => socket.id); + + if (monitorSocketIds.length > 0) { + // 发送给所有监控端的 socket + ns.to(monitorSocketIds).emit('message', { + type: 'sev_student_order_changed', + data: { + fromRoomId: dto.roomId, + studentLongIds: dto.studentLongIds, + }, + }); + } + } + + return new OkResult(true); + } } diff --git a/node_api/src/modules/meeting/meeting.dto.ts b/node_api/src/modules/meeting/meeting.dto.ts index 27841e9..3000746 100644 --- a/node_api/src/modules/meeting/meeting.dto.ts +++ b/node_api/src/modules/meeting/meeting.dto.ts @@ -38,3 +38,14 @@ export class StudentListResponseDto { @ApiProperty({ description: '学生列表', type: [StudentDetailResponseDto] }) students!: StudentDetailResponseDto[]; } + +/** + * 保存学生排序请求 DTO + */ +export class SaveStudentOrderDto { + @ApiProperty({ description: '房间 ID', example: 'room_123' }) + roomId!: string; + + @ApiProperty({ description: '学生长 ID 数组 (按排序顺序)', example: [1234567890, 1234567891, 1234567892] }) + studentLongIds!: number[]; +} diff --git a/node_api/src/modules/meeting/meeting.service.ts b/node_api/src/modules/meeting/meeting.service.ts index 1ef0ba5..e2c4064 100644 --- a/node_api/src/modules/meeting/meeting.service.ts +++ b/node_api/src/modules/meeting/meeting.service.ts @@ -4,6 +4,7 @@ import { NacosConfigService } from '../../plugins/nacos/nacos-config.service'; import { EntityManager } from '@mikro-orm/core'; import type { TokenResponseDto } from './meeting.dto'; import { MeetingUser } from '@/entities/MeetingUser'; +import { MeetingRedisService } from '../websocket/meeting-redis.service'; /** * 学生信息接口 @@ -31,7 +32,8 @@ export interface StudentDetailInfo { export class MeetingService { constructor( private readonly em: EntityManager, - private readonly nacosConfig: NacosConfigService + private readonly nacosConfig: NacosConfigService, + private readonly redis: MeetingRedisService ) {} /** @@ -39,7 +41,7 @@ export class MeetingService { * @param homeworkId - 作业 ID * @returns 学生详细信息列表,包含长 ID、短 ID 和姓名 */ - public async getStudentDetailsByHomeworkId(homeworkId: number): Promise { + public async getStudentDetailsByHomeworkId(homeworkId: number, roomId?: string): Promise { const id = Number(homeworkId); if (!Number.isFinite(id) || id <= 0) { return []; @@ -87,11 +89,47 @@ export class MeetingService { } // 转换为最终结果 - return results.map((row) => ({ + let finalResults = results.map((row) => ({ longId: Number(row.longId), studentName: row.studentName || '', shortId: row.shortId ?? 0, })); + + // 如果提供了 roomId,从 Redis 获取排序并应用 + if (roomId) { + const orderedIds = await this.redis.getStudentOrder(roomId); + if (orderedIds.length > 0) { + // 创建映射表 + const studentMap = new Map(); + finalResults.forEach((student) => { + studentMap.set(student.longId, student); + }); + + // 按 Redis 中的顺序重新排列 + const orderedResults: StudentDetailInfo[] = []; + for (const longId of orderedIds) { + const student = studentMap.get(longId); + if (student) { + orderedResults.push(student); + studentMap.delete(longId); // 移除已添加的学生 + } + } + + // 添加 Redis 中没有的学生 (新加入的学生) + finalResults = [...orderedResults, ...Array.from(studentMap.values())]; + } + } + + return finalResults; + } + + /** + * 保存学生排序顺序 + * @param roomId - 房间 ID + * @param studentLongIds - 学生长 ID 数组 (按排序顺序) + */ + public async saveStudentOrder(roomId: string, studentLongIds: number[]): Promise { + await this.redis.setStudentOrder(roomId, studentLongIds); } /** @@ -171,7 +209,7 @@ export class MeetingService { if (existingUser) { // 复用已有的短 UID console.log(`[MeetingService] 用户 ${longUserId} 复用短 UID: ${existingUser.id}`); - return existingUser.id; + return Number(existingUser.id); } // 创建新记录(数据库自增 ID 会自动分配) @@ -179,7 +217,7 @@ export class MeetingService { 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; + return Number(newUser.id); } catch (error: any) { // 处理并发导致的唯一索引冲突 (MySQL Error 1062: Duplicate entry) if (error.code === 'ER_DUP_ENTRY' || error.message?.includes('Duplicate entry')) { @@ -187,7 +225,7 @@ export class MeetingService { this.em.clear(); // 清除当前上下文,防止缓存干扰 const retryUser = await this.em.findOne(MeetingUser, { longUserId: Number(userId) }); if (retryUser) { - return retryUser.id; + return Number(retryUser.id); } } throw error; diff --git a/node_api/src/modules/websocket/meeting-redis.service.ts b/node_api/src/modules/websocket/meeting-redis.service.ts index 0a9b0a0..553907d 100644 --- a/node_api/src/modules/websocket/meeting-redis.service.ts +++ b/node_api/src/modules/websocket/meeting-redis.service.ts @@ -59,7 +59,7 @@ export class MeetingRedisService { * 用途:存储房间的全局状态信息 * 数据结构:Hash * Key 格式:meeting:room:{roomId} - * Hash 字段: + * Hash 字段: * - classStatus: 课堂状态(not_started | in_class | finished) * - speakerUid: 当前主讲人短 UID(可选) * - teacherUid: 老师短 UID(可选) @@ -68,6 +68,17 @@ export class MeetingRedisService { * 过期时间:24 小时 */ ROOM_STATE: 'meeting:room:', + + /** + * 学生排序 Key + * 用途:存储学生列表的排序顺序 + * 数据结构:List + * Key 格式:meeting:student-order:{roomId} + * List 内容:按顺序存储学生的长 ID (字符串) + * 适用场景:教师端拖动排序后,监控端按相同顺序显示 + * 过期时间:24 小时 + */ + STUDENT_ORDER: 'meeting:student-order:', }; constructor(private readonly redisService: RedisService) { @@ -382,7 +393,9 @@ export class MeetingRedisService { const socketEntries = Array.isArray(currentState.socketEntries) ? currentState.socketEntries : []; const nextEntries = socketEntries.length > 0 ? socketEntries.filter((e) => e.socketId !== socketId) : []; const nextSocketIds = - nextEntries.length > 0 ? [...new Set(nextEntries.map((e) => e.socketId).filter(Boolean))] : (currentState.socketIds || []).filter((id) => id !== socketId); + nextEntries.length > 0 + ? [...new Set(nextEntries.map((e) => e.socketId).filter(Boolean))] + : (currentState.socketIds || []).filter((id) => id !== socketId); if (nextSocketIds.length > 0) { const hm: Record = { socketIds: JSON.stringify(nextSocketIds) }; @@ -486,6 +499,42 @@ export class MeetingRedisService { return !socketChecks.includes(true); } + /** + * 设置学生排序顺序 + * @param roomId - 房间 ID + * @param studentLongIds - 学生长 ID 数组 (按排序顺序) + */ + async setStudentOrder(roomId: string, studentLongIds: number[]): Promise { + const key = `${this.KEY_PREFIX.STUDENT_ORDER}${roomId}`; + const client = this.getClient(); + await client.del(key); // 先删除旧数据 + if (studentLongIds.length > 0) { + const stringIds = studentLongIds.map((id) => String(id)); + await client.rpush(key, ...stringIds); + await client.expire(key, 24 * 60 * 60); + } + } + + /** + * 获取学生排序顺序 + * @param roomId - 房间 ID + * @returns 学生长 ID 数组 (按排序顺序),未设置返回空数组 + */ + async getStudentOrder(roomId: string): Promise { + const key = `${this.KEY_PREFIX.STUDENT_ORDER}${roomId}`; + const ids = await this.getClient().lrange(key, 0, -1); + return ids.map((id) => Number(id)).filter((id) => Number.isFinite(id)); + } + + /** + * 清理学生排序 + * @param roomId - 房间 ID + */ + async clearStudentOrder(roomId: string): Promise { + const key = `${this.KEY_PREFIX.STUDENT_ORDER}${roomId}`; + await this.getClient().del(key); + } + // ==================== 清理 ==================== /** diff --git a/node_api/src/modules/websocket/meeting.websocket.ts b/node_api/src/modules/websocket/meeting.websocket.ts index 17d8883..30d2367 100644 --- a/node_api/src/modules/websocket/meeting.websocket.ts +++ b/node_api/src/modules/websocket/meeting.websocket.ts @@ -62,6 +62,14 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi this.logger.info({}, message, 'MeetingWebSocket'); } + /** + * 获取 Socket.IO Server 实例 + * @returns Socket.IO Namespace 实例 + */ + public getServer(): MeetingNamespace | null { + return this.server; + } + /** * 查找目标用户的所有 Socket(本节点 + 跨节点) * @param roomId - 服务端房间 ID(Socket.IO 房间名) @@ -239,7 +247,7 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi const roomId = courseRoomId; // 从鉴权后的 user 中获取 shortUid(鉴权通过就有短 ID) - const shortUid = socket.data.user?.shortUid; + const shortUid = Number(socket.data.user?.shortUid || 0); const userName = socket.data.user?.userName || data.userName || '用户'; const isHost = socket.data.user?.role !== 0; const joinMode: JoinMode = @@ -697,4 +705,36 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi this.server?.to(data.roomId).emit('message', { type: 'sev_stop_screen_share', data: { fromRoomId: data.roomId, targetUid, screenUid } }); this.log(`创建者停止投屏:roomId=${data.roomId}, shortUid=${targetUid}, screenUid=${screenUid}`); } + + /** + * 处理学生排序消息 (仅创建者) + */ + @SubscribeMessage('client_student_order_changed') + public async handleStudentOrderChanged( + @MessageBody() data: { roomId: string; studentLongIds: number[] }, + @ConnectedSocket() socket: MeetingSocket + ): Promise { + if (!data?.roomId || !Array.isArray(data.studentLongIds)) { + return; + } + const isHost = socket.data.user?.role !== 0; + if (!isHost) { + this.logger.warn({}, `[会议] 非创建者尝试设置学生排序:userId=${socket.data.user?.userId}`, 'MeetingWebSocket'); + return; + } + // 保存到 Redis + await this.redisService.setStudentOrder(data.roomId, data.studentLongIds); + // 获取房间内所有 socket 并过滤出监控端 (monitor 模式) + const ns = this.server; + if (ns) { + const sockets = await ns.in(data.roomId).fetchSockets(); + const monitorSocketIds = sockets.filter((s) => s.data.joinMode === 'monitor').map((s) => s.id); + + if (monitorSocketIds.length > 0) { + // 发送给所有监控端的 socket + ns.to(monitorSocketIds).emit('message', { type: 'sev_student_order_changed', data: { fromRoomId: data.roomId, studentLongIds: data.studentLongIds } }); + } + } + this.log(`创建者更新了学生排序:roomId=${data.roomId}, 学生数量=${data.studentLongIds.length}`); + } } diff --git a/node_api/src/modules/websocket/types.ts b/node_api/src/modules/websocket/types.ts index 11247e1..4fc8a2f 100644 --- a/node_api/src/modules/websocket/types.ts +++ b/node_api/src/modules/websocket/types.ts @@ -214,6 +214,13 @@ export interface MeetingRoomEndTimestampUpdatedData { endTimestamp: number; } +export interface MeetingStudentOrderChangedData { + /** 来源房间 */ + fromRoomId: string; + /** 学生长 ID 数组 (按排序顺序) */ + studentLongIds: number[]; +} + export type MeetingDownlinkPacket = /** 统一错误包(WsExceptionFilter/业务侧主动 emit 的错误) */ | ErrorMessage @@ -237,6 +244,8 @@ export type MeetingDownlinkPacket = | MeetingWsMessagePacket<'sev_start_screen_share', MeetingScreenShareNotifyData> /** 停止投屏通知 */ | MeetingWsMessagePacket<'sev_stop_screen_share', MeetingScreenShareNotifyData> + /** 学生排序变更通知 */ + | MeetingWsMessagePacket<'sev_student_order_changed', MeetingStudentOrderChangedData> /** 解除禁麦通知 */ | MeetingWsMessagePacket<'sev_unmute_audio', MeetingControlData> /** 解除禁视频通知 */ @@ -265,6 +274,7 @@ export type ClientToServerMessageType = | 'client_start_class' // 开始课程(仅创建者) | 'client_start_screen_share' // 开始投屏 | 'client_stop_screen_share' // 停止投屏 + | 'client_student_order_changed' // 学生排序变更(仅创建者) | 'client_unmute_audio' // 解除禁麦(仅创建者) | 'client_unmute_video' // 解除禁视频(仅创建者) | 'client_renew_room_end_timestamp'; // 续期房间结束时间(仅创建者) @@ -283,6 +293,7 @@ export type ServerToClientMessageType = | 'sev_set_main_video' // 设置主屏通知 | 'sev_start_screen_share' // 开始投屏通知(广播给所有人) | 'sev_stop_screen_share' // 停止投屏通知(广播给所有人) + | 'sev_student_order_changed' // 学生排序变更通知(广播给监控端) | 'sev_unmute_audio' // 解除禁麦通知(转发给目标用户) | 'sev_unmute_video' // 解除禁视频通知(转发给目标用户) | 'sev_room_will_expire' // 房间即将到期提醒(仅老师端) diff --git a/src/api/meeting.ts b/src/api/meeting.ts index e2ce814..322d874 100644 --- a/src/api/meeting.ts +++ b/src/api/meeting.ts @@ -36,7 +36,21 @@ export function removeFromBlacklistAxios(roomId: string, shortUid: number): Prom } /** - * 黑名单用户项(包含短 UID 和名称) + * 获取学生详细信息列表 (带排序) + * @param homeworkId - 作业 ID + * @param roomId - 房间 ID (用于获取 Redis 中的排序) + */ +export function getStudentDetailsAxios(homeworkId: number, roomId: string): Promise> { + return axios.get>(`${import.meta.env.VITE_MEETING_BASE_URL}/meeting/students`, { + params: { + homeworkId, + roomId, + }, + }); +} + +/** + * 黑名单用户项 (包含短 UID 和名称) */ export interface BlacklistItem { /** 短 UID */ @@ -45,6 +59,18 @@ export interface BlacklistItem { userName: string; } +/** + * 学生详细信息项 + */ +export interface StudentDetailItem { + /** 学生长 ID (数据库 ID) */ + longId: number; + /** 学生短 ID (声网短 ID) */ + shortId: number; + /** 学生姓名 */ + studentName: string; +} + export interface ReqMeetingToken { channelName: string; uid: number; diff --git a/src/views/meeting/meeting-room/meeting-room-teacher.vue b/src/views/meeting/meeting-room/meeting-room-teacher.vue index b3910cb..edea6fc 100644 --- a/src/views/meeting/meeting-room/meeting-room-teacher.vue +++ b/src/views/meeting/meeting-room/meeting-room-teacher.vue @@ -94,6 +94,10 @@ 黑名单 +