From 0b8014d1afc6de014c54280ef2fafeabcf99fdbd Mon Sep 17 00:00:00 2001 From: chenhao Date: Thu, 23 Apr 2026 17:53:12 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E4=BC=9A=E8=AE=AE?= =?UTF-8?q?=E6=9D=A5=E6=BA=90=E5=B9=B3=E5=8F=B0=E6=8E=A7=E5=88=B6=E5=92=8C?= =?UTF-8?q?=E5=AE=9E=E6=97=B6=E4=BC=9A=E8=AE=AE=E7=8A=B6=E6=80=81=E5=A4=84?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 `MeetingAccessService` 和 `MeetingCommandService` 中添加 `assertCanControlRealtimeMeeting` 方法,支持不同平台的实时会议控制 - 更新 `createMeeting` 和 `createRealtimeMeeting` 方法,以包含 `meetingSource` 参数 - 在前端 `Meetings.tsx` 和 `RealtimeAsrSession.tsx` 中添加对跨平台实时会议的控制逻辑 - 更新数据库表结构和文档,添加 `meeting_type` 和 `meeting_source` 字段 - 更新相关测试类以验证新的控制逻辑 --- backend/design/db_schema.md | 2 + backend/design/db_schema_pgsql.sql | 2 + .../AndroidMeetingRealtimeController.java | 10 +- .../controller/biz/MeetingController.java | 13 +- .../java/com/imeeting/dto/biz/MeetingVO.java | 4 + .../java/com/imeeting/entity/biz/Meeting.java | 6 + .../impl/LegacyMeetingAdapterServiceImpl.java | 3 + .../service/biz/MeetingAccessService.java | 2 + .../biz/MeetingAuthorizationService.java | 2 + .../service/biz/MeetingCommandService.java | 4 +- .../biz/impl/MeetingAccessServiceImpl.java | 15 ++ .../impl/MeetingAuthorizationServiceImpl.java | 17 ++ .../biz/impl/MeetingCommandServiceImpl.java | 9 +- .../biz/impl/MeetingDomainSupport.java | 7 +- ...altimeMeetingSocketSessionServiceImpl.java | 3 +- ...droidRealtimeSessionTicketServiceImpl.java | 3 +- .../impl/MeetingAccessServiceImplTest.java | 50 +++--- frontend/src/api/business/meeting.ts | 2 + frontend/src/pages/business/Meetings.tsx | 36 +++- .../src/pages/business/RealtimeAsrSession.tsx | 25 +++ .../src/pages/organization/tenants/index.tsx | 169 ++++++++++++------ 21 files changed, 287 insertions(+), 97 deletions(-) diff --git a/backend/design/db_schema.md b/backend/design/db_schema.md index 152b624..110bee2 100644 --- a/backend/design/db_schema.md +++ b/backend/design/db_schema.md @@ -347,6 +347,8 @@ | participants | TEXT | | 鍙備細浜轰俊鎭?| | tags | VARCHAR(255) | | 鏍囩 | | audio_url | VARCHAR(500) | | 涓撳睘闊抽璺緞 | +| meeting_type | VARCHAR(32) | | 会议类型:OFFLINE / REALTIME | +| meeting_source | VARCHAR(32) | | 会议来源平台:WEB / ANDROID | | creator_id | BIGINT | | 鍙戣捣浜篒D | | creator_name | VARCHAR(100) | | 鍙戣捣浜哄鍚?| | latest_summary_task_id | BIGINT | | 鏈€鏂版垚鍔熸€荤粨浠诲姟ID | diff --git a/backend/design/db_schema_pgsql.sql b/backend/design/db_schema_pgsql.sql index baa5655..455112f 100644 --- a/backend/design/db_schema_pgsql.sql +++ b/backend/design/db_schema_pgsql.sql @@ -403,6 +403,8 @@ CREATE TABLE biz_meetings ( participants TEXT, tags VARCHAR(255), audio_url VARCHAR(500), + meeting_type VARCHAR(32), -- OFFLINE / REALTIME + meeting_source VARCHAR(32), -- WEB / ANDROID creator_id BIGINT, -- 发起人ID creator_name VARCHAR(100), -- 发起人姓名 latest_summary_task_id BIGINT, -- 最新成功总结任务ID diff --git a/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingRealtimeController.java b/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingRealtimeController.java index 73f3170..a6c7415 100644 --- a/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingRealtimeController.java +++ b/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingRealtimeController.java @@ -1,5 +1,6 @@ package com.imeeting.controller.android; +import com.imeeting.common.MeetingConstants; import com.imeeting.config.grpc.GrpcServerProperties; import com.imeeting.dto.android.AndroidAuthContext; import com.imeeting.dto.android.AndroidCreateRealtimeMeetingCommand; @@ -91,7 +92,8 @@ public class AndroidMeetingRealtimeController { createCommand, authContext.getTenantId(), authContext.getUserId(), - resolveCreatorName(authContext) + resolveCreatorName(authContext), + MeetingConstants.SOURCE_ANDROID ); RealtimeMeetingSessionStatusVO status = realtimeMeetingSessionStateService.getStatus(meeting.getId()); @@ -158,7 +160,7 @@ public class AndroidMeetingRealtimeController { public ApiResponse pauseRealtimeMeeting(@PathVariable Long id, HttpServletRequest request) { AndroidAuthContext authContext = androidAuthService.authenticateHttp(request); Meeting meeting = meetingAccessService.requireMeeting(id); - meetingAuthorizationService.assertCanManageRealtimeMeeting(meeting, authContext); + meetingAuthorizationService.assertCanControlRealtimeMeeting(meeting, authContext, MeetingConstants.SOURCE_ANDROID); return ApiResponse.ok(realtimeMeetingSessionStateService.pause(id)); } @@ -176,7 +178,7 @@ public class AndroidMeetingRealtimeController { @RequestBody(required = false) RealtimeMeetingCompleteDTO dto) { AndroidAuthContext authContext = androidAuthService.authenticateHttp(request); Meeting meeting = meetingAccessService.requireMeeting(id); - meetingAuthorizationService.assertCanManageRealtimeMeeting(meeting, authContext); + meetingAuthorizationService.assertCanControlRealtimeMeeting(meeting, authContext, MeetingConstants.SOURCE_ANDROID); meetingCommandService.completeRealtimeMeeting( id, dto != null ? dto.getAudioUrl() : null, @@ -199,7 +201,7 @@ public class AndroidMeetingRealtimeController { @RequestBody(required = false) AndroidOpenRealtimeGrpcSessionCommand command) { AndroidAuthContext authContext = androidAuthService.authenticateHttp(request); Meeting meeting = meetingAccessService.requireMeeting(id); - meetingAuthorizationService.assertCanManageRealtimeMeeting(meeting, authContext); + meetingAuthorizationService.assertCanControlRealtimeMeeting(meeting, authContext, MeetingConstants.SOURCE_ANDROID); return ApiResponse.ok(androidRealtimeSessionTicketService.createSession(id, command, authContext)); } diff --git a/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java b/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java index 5d84f06..8613803 100644 --- a/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java +++ b/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java @@ -1,5 +1,6 @@ package com.imeeting.controller.biz; +import com.imeeting.common.MeetingConstants; import com.imeeting.common.RedisKeys; import com.imeeting.dto.biz.CreateMeetingCommand; import com.imeeting.dto.biz.CreateRealtimeMeetingCommand; @@ -141,7 +142,8 @@ public class MeetingController { command, loginUser.getTenantId(), loginUser.getUserId(), - resolveCreatorName(loginUser) + resolveCreatorName(loginUser), + MeetingConstants.SOURCE_WEB )); } @@ -155,7 +157,8 @@ public class MeetingController { command, loginUser.getTenantId(), loginUser.getUserId(), - resolveCreatorName(loginUser) + resolveCreatorName(loginUser), + MeetingConstants.SOURCE_WEB )); } @@ -269,7 +272,7 @@ public class MeetingController { public ApiResponse appendRealtimeTranscripts(@PathVariable Long id, @RequestBody List items) { LoginUser loginUser = currentLoginUser(); Meeting meeting = meetingAccessService.requireMeeting(id); - meetingAccessService.assertCanManageRealtimeMeeting(meeting, loginUser); + meetingAccessService.assertCanControlRealtimeMeeting(meeting, loginUser, MeetingConstants.SOURCE_WEB); meetingCommandService.appendRealtimeTranscripts(id, items); return ApiResponse.ok(true); } @@ -280,7 +283,7 @@ public class MeetingController { public ApiResponse pauseRealtimeMeeting(@PathVariable Long id) { LoginUser loginUser = currentLoginUser(); Meeting meeting = meetingAccessService.requireMeeting(id); - meetingAccessService.assertCanManageRealtimeMeeting(meeting, loginUser); + meetingAccessService.assertCanControlRealtimeMeeting(meeting, loginUser, MeetingConstants.SOURCE_WEB); return ApiResponse.ok(realtimeMeetingSessionStateService.pause(id)); } @@ -311,7 +314,7 @@ public class MeetingController { public ApiResponse completeRealtimeMeeting(@PathVariable Long id, @RequestBody(required = false) RealtimeMeetingCompleteDTO dto) { LoginUser loginUser = currentLoginUser(); Meeting meeting = meetingAccessService.requireMeeting(id); - meetingAccessService.assertCanManageRealtimeMeeting(meeting, loginUser); + meetingAccessService.assertCanControlRealtimeMeeting(meeting, loginUser, MeetingConstants.SOURCE_WEB); meetingCommandService.completeRealtimeMeeting( id, dto != null ? dto.getAudioUrl() : null, diff --git a/backend/src/main/java/com/imeeting/dto/biz/MeetingVO.java b/backend/src/main/java/com/imeeting/dto/biz/MeetingVO.java index fdb44c2..fd17b58 100644 --- a/backend/src/main/java/com/imeeting/dto/biz/MeetingVO.java +++ b/backend/src/main/java/com/imeeting/dto/biz/MeetingVO.java @@ -37,6 +37,10 @@ public class MeetingVO { private String tags; @Schema(description = "音频地址") private String audioUrl; + @Schema(description = "会议类型") + private String meetingType; + @Schema(description = "会议来源") + private String meetingSource; @Schema(description = "音频保存状态") private String audioSaveStatus; @Schema(description = "音频保存说明") diff --git a/backend/src/main/java/com/imeeting/entity/biz/Meeting.java b/backend/src/main/java/com/imeeting/entity/biz/Meeting.java index 5a9497d..fc1268a 100644 --- a/backend/src/main/java/com/imeeting/entity/biz/Meeting.java +++ b/backend/src/main/java/com/imeeting/entity/biz/Meeting.java @@ -35,6 +35,12 @@ public class Meeting extends BaseEntity { @Schema(description = "音频地址") private String audioUrl; + @Schema(description = "会议类型") + private String meetingType; + + @Schema(description = "会议来源") + private String meetingSource; + @Schema(description = "音频保存状态") private String audioSaveStatus; diff --git a/backend/src/main/java/com/imeeting/service/android/legacy/impl/LegacyMeetingAdapterServiceImpl.java b/backend/src/main/java/com/imeeting/service/android/legacy/impl/LegacyMeetingAdapterServiceImpl.java index 0392c67..13cad9f 100644 --- a/backend/src/main/java/com/imeeting/service/android/legacy/impl/LegacyMeetingAdapterServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/android/legacy/impl/LegacyMeetingAdapterServiceImpl.java @@ -1,6 +1,7 @@ package com.imeeting.service.android.legacy.impl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.imeeting.common.MeetingConstants; import com.imeeting.dto.android.legacy.LegacyMeetingCreateRequest; import com.imeeting.dto.android.legacy.LegacyUploadAudioResponse; import com.imeeting.dto.biz.MeetingVO; @@ -73,6 +74,8 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ joinIds(request.getAttendeeIds()), normalizeTags(request.getTags()), null, + MeetingConstants.TYPE_OFFLINE, + MeetingConstants.SOURCE_ANDROID, loginUser.getTenantId(), loginUser.getUserId(), resolveCreatorName(loginUser), diff --git a/backend/src/main/java/com/imeeting/service/biz/MeetingAccessService.java b/backend/src/main/java/com/imeeting/service/biz/MeetingAccessService.java index 5114a00..29df08c 100644 --- a/backend/src/main/java/com/imeeting/service/biz/MeetingAccessService.java +++ b/backend/src/main/java/com/imeeting/service/biz/MeetingAccessService.java @@ -18,5 +18,7 @@ public interface MeetingAccessService { void assertCanManageRealtimeMeeting(Meeting meeting, LoginUser loginUser); + void assertCanControlRealtimeMeeting(Meeting meeting, LoginUser loginUser, String currentPlatform); + void assertCanExportMeeting(Meeting meeting, LoginUser loginUser); } diff --git a/backend/src/main/java/com/imeeting/service/biz/MeetingAuthorizationService.java b/backend/src/main/java/com/imeeting/service/biz/MeetingAuthorizationService.java index c0bba2d..ad6426e 100644 --- a/backend/src/main/java/com/imeeting/service/biz/MeetingAuthorizationService.java +++ b/backend/src/main/java/com/imeeting/service/biz/MeetingAuthorizationService.java @@ -9,4 +9,6 @@ public interface MeetingAuthorizationService { void assertCanViewMeeting(Meeting meeting, AndroidAuthContext authContext); void assertCanManageRealtimeMeeting(Meeting meeting, AndroidAuthContext authContext); + + void assertCanControlRealtimeMeeting(Meeting meeting, AndroidAuthContext authContext, String currentPlatform); } diff --git a/backend/src/main/java/com/imeeting/service/biz/MeetingCommandService.java b/backend/src/main/java/com/imeeting/service/biz/MeetingCommandService.java index 3978119..3179bd9 100644 --- a/backend/src/main/java/com/imeeting/service/biz/MeetingCommandService.java +++ b/backend/src/main/java/com/imeeting/service/biz/MeetingCommandService.java @@ -10,9 +10,9 @@ import com.imeeting.dto.biz.UpdateMeetingTranscriptCommand; import java.util.List; public interface MeetingCommandService { - MeetingVO createMeeting(CreateMeetingCommand command, Long tenantId, Long creatorId, String creatorName); + MeetingVO createMeeting(CreateMeetingCommand command, Long tenantId, Long creatorId, String creatorName, String meetingSource); - MeetingVO createRealtimeMeeting(CreateRealtimeMeetingCommand command, Long tenantId, Long creatorId, String creatorName); + MeetingVO createRealtimeMeeting(CreateRealtimeMeetingCommand command, Long tenantId, Long creatorId, String creatorName, String meetingSource); void deleteMeeting(Long id); diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingAccessServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingAccessServiceImpl.java index 7fa25ae..2e344b7 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingAccessServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingAccessServiceImpl.java @@ -1,5 +1,6 @@ package com.imeeting.service.biz.impl; +import com.imeeting.common.MeetingConstants; import com.imeeting.entity.biz.Meeting; import com.imeeting.mapper.biz.MeetingMapper; import com.imeeting.service.biz.MeetingAccessService; @@ -96,6 +97,20 @@ public class MeetingAccessServiceImpl implements MeetingAccessService { throw new RuntimeException("No permission to manage this realtime meeting"); } + @Override + public void assertCanControlRealtimeMeeting(Meeting meeting, LoginUser loginUser, String currentPlatform) { + assertCanManageRealtimeMeeting(meeting, loginUser); + if (!MeetingConstants.TYPE_REALTIME.equalsIgnoreCase(meeting.getMeetingType())) { + throw new RuntimeException("Current meeting is not a realtime meeting"); + } + if (meeting.getMeetingSource() == null || meeting.getMeetingSource().isBlank()) { + return; + } + if (!meeting.getMeetingSource().equalsIgnoreCase(currentPlatform)) { + throw new RuntimeException("Cross-platform realtime takeover is not allowed"); + } + } + @Override public void assertCanExportMeeting(Meeting meeting, LoginUser loginUser) { if (isPlatformAdmin(loginUser)) { diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingAuthorizationServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingAuthorizationServiceImpl.java index 65163e2..305f031 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingAuthorizationServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingAuthorizationServiceImpl.java @@ -1,5 +1,6 @@ package com.imeeting.service.biz.impl; +import com.imeeting.common.MeetingConstants; import com.imeeting.dto.android.AndroidAuthContext; import com.imeeting.entity.biz.Meeting; import com.imeeting.service.biz.MeetingAccessService; @@ -38,6 +39,22 @@ public class MeetingAuthorizationServiceImpl implements MeetingAuthorizationServ meetingAccessService.assertCanManageRealtimeMeeting(meeting, requireUser(authContext)); } + @Override + public void assertCanControlRealtimeMeeting(Meeting meeting, AndroidAuthContext authContext, String currentPlatform) { + if (allowAnonymous(authContext)) { + if (!MeetingConstants.TYPE_REALTIME.equalsIgnoreCase(meeting.getMeetingType())) { + throw new RuntimeException("Current meeting is not a realtime meeting"); + } + if (meeting.getMeetingSource() != null + && !meeting.getMeetingSource().isBlank() + && !meeting.getMeetingSource().equalsIgnoreCase(currentPlatform)) { + throw new RuntimeException("Cross-platform realtime takeover is not allowed"); + } + return; + } + meetingAccessService.assertCanControlRealtimeMeeting(meeting, requireUser(authContext), currentPlatform); + } + private boolean allowAnonymous(AndroidAuthContext authContext) { return authContext != null && authContext.isAnonymous(); } diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingCommandServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingCommandServiceImpl.java index 9c9f43f..354c067 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingCommandServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingCommandServiceImpl.java @@ -3,6 +3,7 @@ package com.imeeting.service.biz.impl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.fasterxml.jackson.databind.ObjectMapper; +import com.imeeting.common.MeetingConstants; import com.imeeting.common.RedisKeys; import com.imeeting.dto.biz.CreateMeetingCommand; import com.imeeting.dto.biz.CreateRealtimeMeetingCommand; @@ -61,12 +62,12 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { @Override @Transactional(rollbackFor = Exception.class) - public MeetingVO createMeeting(CreateMeetingCommand command, Long tenantId, Long creatorId, String creatorName) { + public MeetingVO createMeeting(CreateMeetingCommand command, Long tenantId, Long creatorId, String creatorName, String meetingSource) { RealtimeMeetingRuntimeProfile runtimeProfile = resolveCreateProfile(command, tenantId); Long hostUserId = resolveHostUserId(command.getHostUserId(), creatorId); String hostName = resolveHostName(command.getHostName(), creatorName, creatorId, hostUserId); Meeting meeting = meetingDomainSupport.initMeeting(command.getTitle(), command.getMeetingTime(), command.getParticipants(), command.getTags(), - command.getAudioUrl(), tenantId, creatorId, creatorName, hostUserId, hostName, 0); + command.getAudioUrl(), MeetingConstants.TYPE_OFFLINE, meetingSource, tenantId, creatorId, creatorName, hostUserId, hostName, 0); meetingService.save(meeting); AiTask asrTask = new AiTask(); @@ -111,12 +112,12 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { @Override @Transactional(rollbackFor = Exception.class) - public MeetingVO createRealtimeMeeting(CreateRealtimeMeetingCommand command, Long tenantId, Long creatorId, String creatorName) { + public MeetingVO createRealtimeMeeting(CreateRealtimeMeetingCommand command, Long tenantId, Long creatorId, String creatorName, String meetingSource) { RealtimeMeetingRuntimeProfile runtimeProfile = resolveCreateProfile(command, tenantId); Long hostUserId = resolveHostUserId(command.getHostUserId(), creatorId); String hostName = resolveHostName(command.getHostName(), creatorName, creatorId, hostUserId); Meeting meeting = meetingDomainSupport.initMeeting(command.getTitle(), command.getMeetingTime(), command.getParticipants(), command.getTags(), - null, tenantId, creatorId, creatorName, hostUserId, hostName, 0); + null, MeetingConstants.TYPE_REALTIME, meetingSource, tenantId, creatorId, creatorName, hostUserId, hostName, 0); meetingService.save(meeting); meetingDomainSupport.createSummaryTask( meeting.getId(), diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingDomainSupport.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingDomainSupport.java index 8f91750..74daadc 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingDomainSupport.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingDomainSupport.java @@ -49,13 +49,16 @@ public class MeetingDomainSupport { private String uploadPath; public Meeting initMeeting(String title, LocalDateTime meetingTime, String participants, String tags, - String audioUrl, Long tenantId, Long creatorId, String creatorName, + String audioUrl, String meetingType, String meetingSource, + Long tenantId, Long creatorId, String creatorName, Long hostUserId, String hostName, int status) { Meeting meeting = new Meeting(); meeting.setTitle(title); meeting.setMeetingTime(meetingTime); meeting.setParticipants(participants); meeting.setTags(tags); + meeting.setMeetingType(meetingType); + meeting.setMeetingSource(meetingSource); meeting.setCreatorId(creatorId); meeting.setCreatorName(creatorName); meeting.setHostUserId(hostUserId); @@ -265,6 +268,8 @@ public class MeetingDomainSupport { vo.setMeetingTime(meeting.getMeetingTime()); vo.setTags(meeting.getTags()); vo.setAudioUrl(meeting.getAudioUrl()); + vo.setMeetingType(meeting.getMeetingType()); + vo.setMeetingSource(meeting.getMeetingSource()); vo.setAudioSaveStatus(meeting.getAudioSaveStatus()); vo.setAudioSaveMessage(meeting.getAudioSaveMessage()); vo.setAccessPassword(meeting.getAccessPassword()); diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/RealtimeMeetingSocketSessionServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/RealtimeMeetingSocketSessionServiceImpl.java index b37d8c6..5cbde42 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/RealtimeMeetingSocketSessionServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/RealtimeMeetingSocketSessionServiceImpl.java @@ -1,6 +1,7 @@ package com.imeeting.service.biz.impl; import com.fasterxml.jackson.databind.ObjectMapper; +import com.imeeting.common.MeetingConstants; import com.imeeting.common.RedisKeys; import com.imeeting.dto.biz.AiModelVO; import com.imeeting.dto.biz.RealtimeMeetingResumeConfig; @@ -48,7 +49,7 @@ public class RealtimeMeetingSocketSessionServiceImpl implements RealtimeMeetingS } Meeting meeting = meetingAccessService.requireMeeting(meetingId); - meetingAccessService.assertCanManageRealtimeMeeting(meeting, loginUser); + meetingAccessService.assertCanControlRealtimeMeeting(meeting, loginUser, MeetingConstants.SOURCE_WEB); realtimeMeetingSessionStateService.initSessionIfAbsent(meetingId, loginUser.getTenantId(), loginUser.getUserId()); realtimeMeetingSessionStateService.assertCanOpenSession(meetingId); diff --git a/backend/src/main/java/com/imeeting/service/realtime/impl/AndroidRealtimeSessionTicketServiceImpl.java b/backend/src/main/java/com/imeeting/service/realtime/impl/AndroidRealtimeSessionTicketServiceImpl.java index 44943c3..adfcd6c 100644 --- a/backend/src/main/java/com/imeeting/service/realtime/impl/AndroidRealtimeSessionTicketServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/realtime/impl/AndroidRealtimeSessionTicketServiceImpl.java @@ -1,6 +1,7 @@ package com.imeeting.service.realtime.impl; import com.fasterxml.jackson.databind.ObjectMapper; +import com.imeeting.common.MeetingConstants; import com.imeeting.common.RedisKeys; import com.imeeting.config.grpc.GrpcServerProperties; import com.imeeting.dto.android.AndroidAuthContext; @@ -92,7 +93,7 @@ public class AndroidRealtimeSessionTicketServiceImpl implements AndroidRealtimeS throw new RuntimeException("Meeting ID is required"); } Meeting meeting = meetingAccessService.requireMeeting(meetingId); - meetingAuthorizationService.assertCanManageRealtimeMeeting(meeting, authContext); + meetingAuthorizationService.assertCanControlRealtimeMeeting(meeting, authContext, MeetingConstants.SOURCE_ANDROID); realtimeMeetingSessionStateService.initSessionIfAbsent(meetingId, meeting.getTenantId(), meeting.getCreatorId()); RealtimeMeetingSessionStatusVO currentStatus = realtimeMeetingSessionStateService.getStatus(meetingId); RealtimeMeetingResumeConfig currentResumeConfig = currentStatus == null ? null : currentStatus.getResumeConfig(); diff --git a/backend/src/test/java/com/imeeting/service/biz/impl/MeetingAccessServiceImplTest.java b/backend/src/test/java/com/imeeting/service/biz/impl/MeetingAccessServiceImplTest.java index b5698f8..8dce5a0 100644 --- a/backend/src/test/java/com/imeeting/service/biz/impl/MeetingAccessServiceImplTest.java +++ b/backend/src/test/java/com/imeeting/service/biz/impl/MeetingAccessServiceImplTest.java @@ -1,14 +1,13 @@ package com.imeeting.service.biz.impl; +import com.imeeting.common.MeetingConstants; import com.imeeting.entity.biz.Meeting; import com.imeeting.mapper.biz.MeetingMapper; +import com.unisbase.security.LoginUser; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; class MeetingAccessServiceImplTest { @@ -16,32 +15,41 @@ class MeetingAccessServiceImplTest { private final MeetingAccessServiceImpl service = new MeetingAccessServiceImpl(mock(MeetingMapper.class)); @Test - void previewPasswordShouldBeOptionalWhenMeetingHasNoPassword() { - Meeting meeting = new Meeting(); - meeting.setAccessPassword(" "); + void allowsRealtimeControlFromSourcePlatform() { + Meeting meeting = buildMeeting(MeetingConstants.TYPE_REALTIME, MeetingConstants.SOURCE_ANDROID); + LoginUser loginUser = buildLoginUser(); - assertFalse(service.isPreviewPasswordRequired(meeting)); - assertDoesNotThrow(() -> service.assertCanPreviewMeeting(meeting, null)); + assertDoesNotThrow(() -> service.assertCanControlRealtimeMeeting(meeting, loginUser, MeetingConstants.SOURCE_ANDROID)); } @Test - void previewPasswordShouldRejectMissingOrWrongPassword() { - Meeting meeting = new Meeting(); - meeting.setAccessPassword("123456"); + void rejectsCrossPlatformRealtimeControl() { + Meeting meeting = buildMeeting(MeetingConstants.TYPE_REALTIME, MeetingConstants.SOURCE_ANDROID); + LoginUser loginUser = buildLoginUser(); - RuntimeException missingError = assertThrows(RuntimeException.class, () -> service.assertCanPreviewMeeting(meeting, null)); - assertEquals("Access password is required", missingError.getMessage()); - - RuntimeException wrongError = assertThrows(RuntimeException.class, () -> service.assertCanPreviewMeeting(meeting, "654321")); - assertEquals("Access password is incorrect", wrongError.getMessage()); + assertThrows(RuntimeException.class, + () -> service.assertCanControlRealtimeMeeting(meeting, loginUser, MeetingConstants.SOURCE_WEB)); } @Test - void previewPasswordShouldAllowTrimmedMatch() { - Meeting meeting = new Meeting(); - meeting.setAccessPassword(" 123456 "); + void rejectsRealtimeControlForOfflineMeeting() { + Meeting meeting = buildMeeting(MeetingConstants.TYPE_OFFLINE, MeetingConstants.SOURCE_WEB); + LoginUser loginUser = buildLoginUser(); - assertTrue(service.isPreviewPasswordRequired(meeting)); - assertDoesNotThrow(() -> service.assertCanPreviewMeeting(meeting, " 123456 ")); + assertThrows(RuntimeException.class, + () -> service.assertCanControlRealtimeMeeting(meeting, loginUser, MeetingConstants.SOURCE_WEB)); + } + + private Meeting buildMeeting(String meetingType, String meetingSource) { + Meeting meeting = new Meeting(); + meeting.setTenantId(100L); + meeting.setCreatorId(200L); + meeting.setMeetingType(meetingType); + meeting.setMeetingSource(meetingSource); + return meeting; + } + + private LoginUser buildLoginUser() { + return new LoginUser(200L, 100L, "tester", false, false, null); } } diff --git a/frontend/src/api/business/meeting.ts b/frontend/src/api/business/meeting.ts index fe1f6a6..e0ad2cd 100644 --- a/frontend/src/api/business/meeting.ts +++ b/frontend/src/api/business/meeting.ts @@ -16,6 +16,8 @@ export interface MeetingVO { participantIds?: number[]; tags: string; audioUrl: string; + meetingType?: "OFFLINE" | "REALTIME"; + meetingSource?: "WEB" | "ANDROID"; audioSaveStatus?: "NONE" | "SUCCESS" | "FAILED"; audioSaveMessage?: string; accessPassword?: string; diff --git a/frontend/src/pages/business/Meetings.tsx b/frontend/src/pages/business/Meetings.tsx index 0548049..23bb5e7 100644 --- a/frontend/src/pages/business/Meetings.tsx +++ b/frontend/src/pages/business/Meetings.tsx @@ -56,11 +56,18 @@ import {SysUser} from '../../types'; const { Text, Title } = Typography; const { Option } = Select; +const CURRENT_PLATFORM = 'WEB' as const; const PAUSED_DISPLAY_STATUS = 5; const REALTIME_ACTIVE_DISPLAY_STATUS = 6; const REALTIME_IDLE_DISPLAY_STATUS = 7; -const isRealtimeMeetingCandidate = (item: MeetingVO) => item.status === 0 && !item.audioUrl; +const isRealtimeMeetingCandidate = (item: MeetingVO) => + item.meetingType === 'REALTIME' || (!item.meetingType && item.status === 0 && !item.audioUrl); + +const canControlRealtimeFromCurrentPlatform = (item: MeetingVO) => + !item.meetingSource || item.meetingSource === CURRENT_PLATFORM; + +const getRealtimeSourceLabel = (item: MeetingVO) => item.meetingSource === 'ANDROID' ? '安卓端' : 'Web 端'; const isPausedRealtimeSessionStatus = (status?: RealtimeMeetingSessionStatus["status"]) => status === 'PAUSED_EMPTY' || status === 'PAUSED_RESUMABLE'; @@ -188,6 +195,8 @@ const MeetingCardItem: React.FC<{ item: MeetingVO, config: any, fetchData: () => const isPaused = effectiveStatus === PAUSED_DISPLAY_STATUS; const isRealtimeActive = effectiveStatus === REALTIME_ACTIVE_DISPLAY_STATUS; const isRealtimeIdle = effectiveStatus === REALTIME_IDLE_DISPLAY_STATUS; + const isCrossPlatformRealtime = (isPaused || isRealtimeActive || isRealtimeIdle) && !canControlRealtimeFromCurrentPlatform(item); + const crossPlatformHint = `该实时会议需在${getRealtimeSourceLabel(item)}继续`; return ( @@ -247,6 +256,25 @@ const MeetingCardItem: React.FC<{ item: MeetingVO, config: any, fetchData: () => {progress?.message || '等待引擎调度...'} + ) : isCrossPlatformRealtime ? ( +
+ + + {crossPlatformHint} + +
) : isPaused ? (
{ return; } + if (!canControlRealtimeFromCurrentPlatform(meeting)) { + message.info(`该实时会议需在${getRealtimeSourceLabel(meeting)}继续,当前仅支持查看详情`); + navigate(`/meetings/${meeting.id}`); + return; + } + if (canOpenRealtimeSession(meeting.realtimeSessionStatus)) { navigate(`/meeting-live-session/${meeting.id}`); return; diff --git a/frontend/src/pages/business/RealtimeAsrSession.tsx b/frontend/src/pages/business/RealtimeAsrSession.tsx index 8e7027c..76c775f 100644 --- a/frontend/src/pages/business/RealtimeAsrSession.tsx +++ b/frontend/src/pages/business/RealtimeAsrSession.tsx @@ -33,6 +33,7 @@ import { const { Text, Title } = Typography; const SAMPLE_RATE = 16000; const CHUNK_SIZE = 1280; +const CURRENT_PLATFORM = "WEB" as const; type WsSpeaker = string | { name?: string; user_id?: string | number } | undefined; type WsMessage = { @@ -229,6 +230,7 @@ export function RealtimeAsrSession() { ); const statusColor = recording ? "#1677ff" : connecting || finishing ? "#faad14" : "#94a3b8"; const hasRemoteActiveConnection = Boolean(sessionStatus?.activeConnection) && !recording && !connecting; + const canControlCurrentMeeting = !meeting?.meetingSource || meeting.meetingSource === CURRENT_PLATFORM; useEffect(() => { if (!meetingId || Number.isNaN(meetingId)) { @@ -248,6 +250,17 @@ export function RealtimeAsrSession() { ]); const detail = detailRes.data.data; const realtimeStatus = statusRes.data.data; + if (detail.meetingType && detail.meetingType !== "REALTIME") { + message.warning("当前会议不是实时会议,无法进入实时控制页"); + navigate(`/meetings/${meetingId}`); + return; + } + if (detail.meetingSource && detail.meetingSource !== CURRENT_PLATFORM) { + const sourceLabel = detail.meetingSource === "ANDROID" ? "安卓端" : "Web 端"; + message.warning(`该实时会议需在${sourceLabel}继续,当前仅支持查看详情`); + navigate(`/meetings/${meetingId}`); + return; + } setMeeting(detail); setSessionStatus(realtimeStatus); const fallbackDraft = buildDraftFromStatus(meetingId, detail, realtimeStatus); @@ -429,6 +442,10 @@ export function RealtimeAsrSession() { if (!meetingId || pausing || finishing || (!recording && !connecting)) { return; } + if (!canControlCurrentMeeting) { + message.error("当前会议不允许在 Web 端继续实时控制"); + return; + } setPausing(true); setStatusText("暂停识别中..."); @@ -460,6 +477,10 @@ export function RealtimeAsrSession() { message.error("未找到实时识别配置,请返回创建页重新进入"); return; } + if (!canControlCurrentMeeting) { + message.error("当前会议不允许在 Web 端继续实时控制"); + return; + } if (recording || connecting) { return; } @@ -571,6 +592,10 @@ export function RealtimeAsrSession() { if (!meetingId || completeOnceRef.current) { return; } + if (!canControlCurrentMeeting) { + message.error("当前会议不允许在 Web 端继续实时控制"); + return; + } completeOnceRef.current = true; setFinishing(true); diff --git a/frontend/src/pages/organization/tenants/index.tsx b/frontend/src/pages/organization/tenants/index.tsx index 612b43f..0ef627c 100644 --- a/frontend/src/pages/organization/tenants/index.tsx +++ b/frontend/src/pages/organization/tenants/index.tsx @@ -1,5 +1,5 @@ -import { Avatar, Button, Card, Col, DatePicker, Divider, Drawer, Empty, Form, Input, List, Popconfirm, Row, Select, Space, Tag, Tooltip, Typography, message } from "antd"; -import { useEffect, useState } from "react"; +import { Avatar, Button, Card, Col, Divider, Drawer, Empty, Form, Input, List, Pagination, Popconfirm, Row, Select, Space, Tag, Tooltip, Typography, message } from "antd"; +import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { DeleteOutlined, EditOutlined, PhoneOutlined, PlusOutlined, ReloadOutlined, SearchOutlined, ShopOutlined, UserOutlined } from "@ant-design/icons"; import dayjs from "dayjs"; @@ -7,7 +7,7 @@ import { createTenant, deleteTenant, listTenants, updateTenant } from "@/api"; import { useDict } from "@/hooks/useDict"; import { usePermission } from "@/hooks/usePermission"; import PageHeader from "@/components/shared/PageHeader"; -import AppPagination from "@/components/shared/AppPagination"; +import { getStandardPagination } from "@/utils/pagination"; import { LOGIN_NAME_PATTERN, sanitizeLoginName } from "@/utils/loginName"; import type { SysTenant } from "@/types"; @@ -21,38 +21,40 @@ export default function Tenants() { const [saving, setSaving] = useState(false); const [data, setData] = useState([]); const [total, setTotal] = useState(0); - const [params, setParams] = useState({ current: 1, size: 12, name: "", code: "" }); + const [queryParams, setQueryParams] = useState({ current: 1, size: 12, name: "", code: "" }); const [drawerOpen, setDrawerOpen] = useState(false); const [editing, setEditing] = useState(null); const [adminAccountTouched, setAdminAccountTouched] = useState(false); const [form] = Form.useForm(); + const [searchForm] = Form.useForm(); const watchedTenantCode = Form.useWatch("tenantCode", form); - const loadData = async (currentParams = params) => { + const loadData = useCallback(async (params = queryParams) => { setLoading(true); try { - const result = await listTenants(currentParams); + const result = await listTenants(params); setData(result.records || []); setTotal(result.total || 0); } finally { setLoading(false); } - }; + }, [queryParams]); useEffect(() => { loadData(); - }, [params.current, params.size]); + }, [loadData, queryParams.current, queryParams.size]); - const handleSearch = () => { - const nextParams = { ...params, current: 1 }; - setParams(nextParams); - loadData(nextParams); + const handleSearch = (values: any) => { + setQueryParams((prev) => ({ ...prev, ...values, current: 1 })); }; const handleReset = () => { - const resetParams = { current: 1, size: 12, name: "", code: "" }; - setParams(resetParams); - loadData(resetParams); + searchForm.resetFields(); + setQueryParams({ current: 1, size: 12, name: "", code: "" }); + }; + + const handlePageChange = (page: number, size: number) => { + setQueryParams((prev) => ({ ...prev, current: page, size })); }; const openCreate = () => { @@ -79,15 +81,18 @@ export default function Tenants() { }, [adminAccountTouched, drawerOpen, editing, form, watchedTenantCode]); const handleDelete = async (id: number) => { - await deleteTenant(id); - message.success(t("common.success")); - loadData(); + try { + await deleteTenant(id); + message.success(t("common.success")); + loadData(); + } catch { + } }; const submit = async () => { - const values = await form.validateFields(); - setSaving(true); try { + const values = await form.validateFields(); + setSaving(true); const payload = { ...values, defaultAdminUsername: values.defaultAdminUsername?.trim(), @@ -110,65 +115,102 @@ export default function Tenants() { const statusItem = statusDict.find((dictItem) => dictItem.itemValue === String(item.status)); return ( - + openEdit(item)} style={{ color: "#1677ff" }} />, - can("sys_tenant:delete") && handleDelete(item.id)}> + can("sys_tenant:delete") && ( + handleDelete(item.id)} + > + + + ) ].filter(Boolean) as React.ReactNode[]} >
- } style={{ backgroundColor: item.status === 1 ? "#e6f4ff" : "#fff1f0", color: item.status === 1 ? "#1677ff" : "#ff4d4f", marginRight: 12, borderRadius: "8px" }} /> + } + style={{ backgroundColor: item.status === 1 ? "#e6f4ff" : "#fff1f0", color: item.status === 1 ? "#1677ff" : "#ff4d4f", marginRight: 12, borderRadius: "8px" }} + />
{item.tenantName} - {statusItem ? statusItem.itemLabel : item.status === 1 ? "Enabled" : "Disabled"} + + {statusItem ? statusItem.itemLabel : item.status === 1 ? "Enabled" : "Disabled"} +
CODE: {item.tenantCode}
-
+
-
{item.contactName || "-"}
-
{item.contactPhone || "-"}
+
+ + {item.contactName || "-"} +
+
+ + {item.contactPhone || "-"} +
+ {item.remark && ( + <> + + + {item.remark} + + + )}
- {item.remark && ( - <> - - {item.remark} - - )} ); }; return ( -
- +
+
+ +
- - - - } style={{ width: 220, borderRadius: "6px" }} value={params.name} onChange={(event) => setParams({ ...params, name: event.target.value })} allowClear /> - setParams({ ...params, code: event.target.value })} allowClear /> - - - - {can("sys_tenant:create") && } - - +
+ +
+
+ + } allowClear style={{ width: 200 }} /> + + + + + + + + + + +
+ {can("sys_tenant:create") && } +
+
+
-
+
}} />
- setParams({ ...params, current: page, size: size || params.size })} - /> + +
+ +
-