refactor: 重构会议进度管理和Android设备绑定服务

- 移除 `RedisOnlyMeetingProgressServiceAdapter` 和 `RedisValueSupport` 类
- 更新 `MeetingProgressServiceImpl` 使用新的 `MeetingProgressCache`
- 重构 `MeetingTaskRecoveryListener` 使用 `MeetingLockCache` 和 `MeetingAsrPermitCache`
- 添加 `AndroidDeviceBindingService` 和 `AndroidPushMessageService` 接口及其实现类
- 新增 `AndroidPublicMeetingSessionRequest` 和 `AndroidPublicMeetingSessionVO` DTO 类
- 更新 `AndroidMeetingPushService` 及其实现类,添加推送待处理会议功能
dev_na
chenhao 2026-06-02 17:19:40 +08:00
parent fb5c4b545e
commit 7c3b65624e
65 changed files with 2256 additions and 709 deletions

View File

@ -1,12 +1,21 @@
package com.imeeting.common; package com.imeeting.common;
public final class MeetingConstants { public final class MeetingConstants {
public static final String TYPE_OFFLINE = "OFFLINE"; public static final String TYPE_OFFLINE = "OFFLINE";
public static final String TYPE_REALTIME = "REALTIME"; public static final String TYPE_REALTIME = "REALTIME";
public static final String SOURCE_WEB = "WEB"; public static final String SOURCE_WEB = "WEB";
public static final String SOURCE_ANDROID = "ANDROID"; public static final String SOURCE_ANDROID = "ANDROID";
public static final String DEVICE_MODE_PUBLIC = "PUBLIC";
public static final String DEVICE_MODE_PRIVATE = "PRIVATE";
public static final String DEVICE_DELIVERY_NONE = "NONE";
public static final String DEVICE_DELIVERY_PENDING = "PENDING";
public static final String DEVICE_DELIVERY_ACKED = "ACKED";
public static final String DEVICE_DELIVERY_EXPIRED = "EXPIRED";
public static final String DEVICE_DELIVERY_CANCELLED = "CANCELLED";
public static final String SUMMARY_DETAIL_DETAILED = "DETAILED"; public static final String SUMMARY_DETAIL_DETAILED = "DETAILED";
public static final String SUMMARY_DETAIL_STANDARD = "STANDARD"; public static final String SUMMARY_DETAIL_STANDARD = "STANDARD";
public static final String SUMMARY_DETAIL_BRIEF = "BRIEF"; public static final String SUMMARY_DETAIL_BRIEF = "BRIEF";

View File

@ -111,6 +111,18 @@ public final class RedisKeys {
return "biz:meeting:realtime:event-seq:" + meetingId; return "biz:meeting:realtime:event-seq:" + meetingId;
} }
public static String publicMeetingSessionKey(String sessionId) {
return "biz:meeting:public-session:" + sessionId;
}
public static String androidChunkUploadSessionKey(String uploadSessionId) {
return "biz:meeting:android:chunk-upload:" + uploadSessionId;
}
public static String androidPendingMeetingDraftKey(Long meetingId) {
return "biz:meeting:android:draft:" + meetingId;
}
public static final String CACHE_EMPTY_MARKER = "EMPTY_MARKER"; public static final String CACHE_EMPTY_MARKER = "EMPTY_MARKER";
public static final String SYS_PARAM_FIELD_VALUE = "value"; public static final String SYS_PARAM_FIELD_VALUE = "value";
public static final String SYS_PARAM_FIELD_TYPE = "type"; public static final String SYS_PARAM_FIELD_TYPE = "type";

View File

@ -12,4 +12,6 @@ public final class SysParamKeys {
public static final String MEETING_MAX_PAUSE_DURATION = "meeting.max_pause_duration"; public static final String MEETING_MAX_PAUSE_DURATION = "meeting.max_pause_duration";
public static final String MEETING_MAX_MEETING_DURATION = "meeting.max_meeting_duration"; public static final String MEETING_MAX_MEETING_DURATION = "meeting.max_meeting_duration";
public static final String MEETING_PACKET_LOSS_RATE = "meeting.packet_loss_rate"; public static final String MEETING_PACKET_LOSS_RATE = "meeting.packet_loss_rate";
public static final String MEETING_ANDROID_AUDIO_CHUNK_UPLOAD_ENABLED = "meeting.android.audio.chunk_upload_enabled";
public static final String MEETING_ANDROID_AUDIO_CHUNK_DURATION_SECONDS = "meeting.android.audio.chunk_duration_seconds";
} }

View File

@ -1,16 +1,19 @@
package com.imeeting.controller.android; package com.imeeting.controller.android;
import com.imeeting.service.android.AndroidDeviceBindingService;
import com.imeeting.support.AndroidRequestLogHelper;
import com.unisbase.auth.JwtTokenProvider;
import com.unisbase.common.ApiResponse; import com.unisbase.common.ApiResponse;
import com.unisbase.dto.LoginRequest; import com.unisbase.dto.LoginRequest;
import com.unisbase.dto.RefreshRequest; import com.unisbase.dto.RefreshRequest;
import com.unisbase.dto.TokenResponse; import com.unisbase.dto.TokenResponse;
import com.unisbase.service.AuthService; import com.unisbase.service.AuthService;
import com.imeeting.support.AndroidRequestLogHelper; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@ -27,8 +30,9 @@ import org.springframework.web.bind.annotation.RestController;
@RequiredArgsConstructor @RequiredArgsConstructor
@Slf4j @Slf4j
public class AndroidAuthController { public class AndroidAuthController {
private final AuthService authService; private final AuthService authService;
private final AndroidDeviceBindingService androidDeviceBindingService;
private final JwtTokenProvider jwtTokenProvider;
@Operation(summary = "Android登录") @Operation(summary = "Android登录")
@ApiResponses({ @ApiResponses({
@ -39,9 +43,26 @@ public class AndroidAuthController {
) )
}) })
@PostMapping("/login") @PostMapping("/login")
public ApiResponse<TokenResponse> login(@Valid @RequestBody LoginRequest request) { public ApiResponse<TokenResponse> login(@Valid @RequestBody LoginRequest request,
AndroidRequestLogHelper.logRequest(log, "Android认证", "登录接口", "request", request); @RequestHeader(value = "X-Android-Device-Id", required = false) String deviceId,
return ApiResponse.ok(authService.login(request, true)); @RequestHeader(value = "X-Android-App-Version", required = false) String appVersion,
@RequestHeader(value = "X-Android-Platform", required = false) String platform) {
AndroidRequestLogHelper.logRequest(log, "Android认证", "登录接口",
"request", request,
"deviceId", deviceId,
"appVersion", appVersion,
"platform", platform);
TokenResponse response = authService.login(request, true);
if (response != null && response.getUser() != null && response.getCurrentTenantId() != null && StringUtils.hasText(deviceId)) {
androidDeviceBindingService.bindPrivateDevice(
deviceId.trim(),
response.getCurrentTenantId(),
response.getUser().getUserId(),
appVersion,
platform
);
}
return ApiResponse.ok(response);
} }
@Operation(summary = "Android刷新令牌") @Operation(summary = "Android刷新令牌")
@ -63,6 +84,26 @@ public class AndroidAuthController {
return ApiResponse.ok(authService.refresh(resolveRefreshToken(request, authorization, androidAccessToken))); return ApiResponse.ok(authService.refresh(resolveRefreshToken(request, authorization, androidAccessToken)));
} }
@Operation(summary = "Android退出登录")
@PostMapping("/logout")
public ApiResponse<Void> logout(HttpServletRequest request,
@RequestHeader(value = "Authorization", required = false) String authorization,
@RequestHeader(value = "X-Android-Device-Id", required = false) String deviceId) {
AndroidRequestLogHelper.logRequest(log, "Android认证", "退出登录接口",
"authorization", authorization,
"deviceId", deviceId);
String token = extractToken(authorization);
var claims = jwtTokenProvider.parseToken(token);
Long userId = claims.get("userId", Long.class);
Long tenantId = claims.get("tenantId", Long.class);
String sessionId = claims.get("sessionId", String.class);
authService.logout(userId, tenantId, sessionId);
if (StringUtils.hasText(deviceId)) {
androidDeviceBindingService.unbindPrivateDevice(deviceId.trim());
}
return ApiResponse.ok(null);
}
private String resolveRefreshToken(RefreshRequest request, String authorization, String androidAccessToken) { private String resolveRefreshToken(RefreshRequest request, String authorization, String androidAccessToken) {
if (request != null && StringUtils.hasText(request.getRefreshToken())) { if (request != null && StringUtils.hasText(request.getRefreshToken())) {
return request.getRefreshToken().trim(); return request.getRefreshToken().trim();
@ -79,4 +120,12 @@ public class AndroidAuthController {
} }
throw new IllegalArgumentException("refreshToken不能为空"); throw new IllegalArgumentException("refreshToken不能为空");
} }
private String extractToken(String authorization) {
if (!StringUtils.hasText(authorization)) {
throw new IllegalArgumentException("Authorization不能为空");
}
String value = authorization.trim();
return value.startsWith("Bearer ") ? value.substring(7).trim() : value;
}
} }

View File

@ -0,0 +1,93 @@
package com.imeeting.controller.android;
import com.imeeting.dto.android.AndroidAuthContext;
import com.imeeting.dto.android.legacy.LegacyUploadAudioResponse;
import com.imeeting.service.android.AndroidAuthService;
import com.imeeting.service.android.AndroidChunkUploadService;
import com.imeeting.support.AndroidRequestLogHelper;
import com.unisbase.annotation.Anonymous;
import com.unisbase.common.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
@Tag(name = "Android会议分片上传接口")
@RestController
@RequestMapping("/api/android/meetings/upload-audio")
@RequiredArgsConstructor
@Slf4j
public class AndroidMeetingChunkUploadController {
private final AndroidAuthService androidAuthService;
private final AndroidChunkUploadService androidChunkUploadService;
@Operation(summary = "上传会议音频分片")
@io.swagger.v3.oas.annotations.responses.ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "分片上传成功返回 true",
content = @Content(schema = @Schema(implementation = Boolean.class))
)
})
@PostMapping("/chunk")
@Anonymous
public ApiResponse<Boolean> uploadChunk(HttpServletRequest request,
@RequestParam("meeting_id") Long meetingId,
@RequestParam("upload_session_id") String uploadSessionId,
@RequestParam("chunk_index") Integer chunkIndex,
@RequestParam("total_chunks") Integer totalChunks,
@RequestParam("chunk_file") MultipartFile chunkFile) throws IOException {
AndroidRequestLogHelper.logRequest(log, "Android会议", "上传会议音频分片",
"meetingId", meetingId,
"uploadSessionId", uploadSessionId,
"chunkIndex", chunkIndex,
"totalChunks", totalChunks,
"chunkFile", chunkFile);
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
androidChunkUploadService.saveChunk(meetingId, uploadSessionId, chunkIndex, totalChunks, chunkFile, authContext);
return ApiResponse.ok(true);
}
@Operation(summary = "完成分片上传并触发会议音频处理")
@io.swagger.v3.oas.annotations.responses.ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "返回上传后的会议 ID 和音频地址",
content = @Content(schema = @Schema(implementation = LegacyUploadAudioResponse.class))
)
})
@PostMapping("/complete")
@Anonymous
public ApiResponse<LegacyUploadAudioResponse> completeUpload(HttpServletRequest request,
@RequestParam("meeting_id") Long meetingId,
@RequestParam("upload_session_id") String uploadSessionId,
@RequestParam(value = "force_replace", defaultValue = "false") boolean forceReplace,
@RequestParam(value = "prompt_id", required = false) Long promptId,
@RequestParam(value = "model_code", required = false) String modelCode) throws IOException {
AndroidRequestLogHelper.logRequest(log, "Android会议", "完成分片上传",
"meetingId", meetingId,
"uploadSessionId", uploadSessionId,
"forceReplace", forceReplace,
"promptId", promptId,
"modelCode", modelCode);
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
return ApiResponse.ok(androidChunkUploadService.completeUpload(
meetingId,
uploadSessionId,
forceReplace,
promptId,
modelCode,
authContext
));
}
}

View File

@ -2,9 +2,6 @@ package com.imeeting.controller.android;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.imeeting.common.RedisKeys;
import com.imeeting.common.SysParamKeys; import com.imeeting.common.SysParamKeys;
import com.imeeting.dto.android.AndroidAuthContext; import com.imeeting.dto.android.AndroidAuthContext;
import com.imeeting.dto.android.AndroidMeetingConfigVo; import com.imeeting.dto.android.AndroidMeetingConfigVo;
@ -25,7 +22,6 @@ import com.imeeting.service.android.AndroidAuthService;
import com.imeeting.service.android.legacy.LegacyMeetingAdapterService; import com.imeeting.service.android.legacy.LegacyMeetingAdapterService;
import com.imeeting.support.AndroidRequestLogHelper; import com.imeeting.support.AndroidRequestLogHelper;
import com.imeeting.service.biz.*; import com.imeeting.service.biz.*;
import com.imeeting.service.biz.impl.RedisOnlyMeetingProgressServiceAdapter;
import com.unisbase.common.ApiResponse; import com.unisbase.common.ApiResponse;
import com.unisbase.common.annotation.Log; import com.unisbase.common.annotation.Log;
import com.unisbase.dto.PageResult; import com.unisbase.dto.PageResult;
@ -44,7 +40,6 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
@ -91,7 +86,6 @@ public class AndroidMeetingController {
private final SysDictItemService dictItemService; private final SysDictItemService dictItemService;
private final SysParamService paramService; private final SysParamService paramService;
private final MeetingProgressService meetingProgressService; private final MeetingProgressService meetingProgressService;
private final ObjectMapper objectMapper;
@Autowired @Autowired
public AndroidMeetingController(AndroidAuthService androidAuthService, public AndroidMeetingController(AndroidAuthService androidAuthService,
@ -106,8 +100,7 @@ public class AndroidMeetingController {
AiModelService aiModelService, AiModelService aiModelService,
SysDictItemService dictItemService, SysDictItemService dictItemService,
SysParamService paramService, SysParamService paramService,
MeetingProgressService meetingProgressService, MeetingProgressService meetingProgressService) {
ObjectMapper objectMapper) {
this.androidAuthService = androidAuthService; this.androidAuthService = androidAuthService;
this.legacyMeetingAdapterService = legacyMeetingAdapterService; this.legacyMeetingAdapterService = legacyMeetingAdapterService;
this.meetingQueryService = meetingQueryService; this.meetingQueryService = meetingQueryService;
@ -118,44 +111,11 @@ public class AndroidMeetingController {
this.promptTemplateService = promptTemplateService; this.promptTemplateService = promptTemplateService;
this.sysUserMapper = sysUserMapper; this.sysUserMapper = sysUserMapper;
this.meetingProgressService = meetingProgressService; this.meetingProgressService = meetingProgressService;
this.objectMapper = objectMapper;
this.aiModelService = aiModelService; this.aiModelService = aiModelService;
this.paramService = paramService; this.paramService = paramService;
this.dictItemService = dictItemService; this.dictItemService = dictItemService;
} }
public AndroidMeetingController(AndroidAuthService androidAuthService,
LegacyMeetingAdapterService legacyMeetingAdapterService,
MeetingQueryService meetingQueryService,
MeetingAccessService meetingAccessService,
MeetingCommandService meetingCommandService,
MeetingService meetingService,
AiTaskService aiTaskService,
PromptTemplateService promptTemplateService,
SysUserMapper sysUserMapper,
AiModelService aiModelService,
SysDictItemService dictItemService,
SysParamService paramService,
StringRedisTemplate redisTemplate,
ObjectMapper objectMapper) {
this(
androidAuthService,
legacyMeetingAdapterService,
meetingQueryService,
meetingAccessService,
meetingCommandService,
meetingService,
aiTaskService,
promptTemplateService,
sysUserMapper,
aiModelService,
dictItemService,
paramService,
new RedisOnlyMeetingProgressServiceAdapter(redisTemplate, objectMapper),
objectMapper
);
}
@Operation(summary = "创建Android离线会议") @Operation(summary = "创建Android离线会议")
@ApiResponses({ @ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse( @io.swagger.v3.oas.annotations.responses.ApiResponse(
@ -170,7 +130,11 @@ public class AndroidMeetingController {
AndroidRequestLogHelper.logRequest(log, "Android会议", "创建离线会议接口", "request", command); AndroidRequestLogHelper.logRequest(log, "Android会议", "创建离线会议接口", "request", command);
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request); AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
LoginUser loginUser = AndroidLoginUserSupport.requireLoginUser(authContext); LoginUser loginUser = AndroidLoginUserSupport.requireLoginUser(authContext);
return ApiResponse.ok(legacyMeetingAdapterService.createMeeting(command, loginUser)); Meeting existingMeeting = findLatestUnfinishedMeetingByDevice(authContext.getDeviceId());
if (existingMeeting != null) {
return new ApiResponse<>("409", "设备端已有会议", meetingQueryService.getDetailIgnoreTenant(existingMeeting.getId()));
}
return ApiResponse.ok(legacyMeetingAdapterService.createMeeting(command, authContext, loginUser));
} }
@Operation(summary = "上传Android会议音频") @Operation(summary = "上传Android会议音频")
@ -195,6 +159,14 @@ public class AndroidMeetingController {
"forceReplace", forceReplace, "forceReplace", forceReplace,
"audioFile", audioFile); "audioFile", audioFile);
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request); AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
if (authContext.isAnonymous()) {
return ApiResponse.ok(legacyMeetingAdapterService.uploadAndTriggerOfflineProcessForPublicDevice(
meetingId,
forceReplace,
audioFile,
authContext
));
}
LoginUser loginUser = AndroidLoginUserSupport.requireLoginUser(authContext); LoginUser loginUser = AndroidLoginUserSupport.requireLoginUser(authContext);
return ApiResponse.ok(legacyMeetingAdapterService.uploadAndTriggerOfflineProcess( return ApiResponse.ok(legacyMeetingAdapterService.uploadAndTriggerOfflineProcess(
meetingId, meetingId,
@ -202,6 +174,7 @@ public class AndroidMeetingController {
modelCode, modelCode,
forceReplace, forceReplace,
audioFile, audioFile,
authContext,
loginUser loginUser
)); ));
} }
@ -341,6 +314,8 @@ public class AndroidMeetingController {
BigDecimal bigDecimal = new BigDecimal(paramService.getParamValue(SysParamKeys.MEETING_MAX_PAUSE_DURATION, "99")); BigDecimal bigDecimal = new BigDecimal(paramService.getParamValue(SysParamKeys.MEETING_MAX_PAUSE_DURATION, "99"));
bigDecimal = bigDecimal.setScale(2, RoundingMode.HALF_UP); bigDecimal = bigDecimal.setScale(2, RoundingMode.HALF_UP);
resultVo.setPacketLossRate(bigDecimal ); resultVo.setPacketLossRate(bigDecimal );
resultVo.setChunkUploadEnabled(Boolean.parseBoolean(paramService.getParamValue(SysParamKeys.MEETING_ANDROID_AUDIO_CHUNK_UPLOAD_ENABLED, "false")));
resultVo.setChunkDurationSeconds(Integer.valueOf(paramService.getParamValue(SysParamKeys.MEETING_ANDROID_AUDIO_CHUNK_DURATION_SECONDS, "60")));
return ApiResponse.ok(resultVo); return ApiResponse.ok(resultVo);
} }
@ -629,4 +604,15 @@ public class AndroidMeetingController {
private String formatDateTime(LocalDateTime value) { private String formatDateTime(LocalDateTime value) {
return value == null ? null : value.toString(); return value == null ? null : value.toString();
} }
private Meeting findLatestUnfinishedMeetingByDevice(String deviceId) {
if (deviceId == null || deviceId.isBlank()) {
return null;
}
return meetingService.getOne(new LambdaQueryWrapper<Meeting>()
.eq(Meeting::getSourceDeviceCode, deviceId)
.in(Meeting::getStatus, 0, 1, 2)
.orderByDesc(Meeting::getId)
.last("LIMIT 1"));
}
} }

View File

@ -0,0 +1,120 @@
package com.imeeting.controller.android;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.imeeting.dto.android.AndroidAuthContext;
import com.imeeting.dto.android.AndroidPublicMeetingSessionRequest;
import com.imeeting.dto.android.AndroidPublicMeetingSessionVO;
import com.imeeting.dto.biz.MeetingVO;
import com.imeeting.entity.biz.Meeting;
import com.imeeting.mapper.DeviceInfoMapper;
import com.imeeting.service.android.AndroidAuthService;
import com.imeeting.service.android.AndroidPublicMeetingSessionService;
import com.imeeting.service.biz.MeetingCommandService;
import com.imeeting.service.biz.MeetingQueryService;
import com.imeeting.service.biz.MeetingService;
import com.imeeting.support.AndroidRequestLogHelper;
import com.unisbase.annotation.Anonymous;
import com.unisbase.common.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Tag(name = "Android公有设备会议接口")
@RestController
@RequestMapping("/api/android/public-meetings")
@RequiredArgsConstructor
@Slf4j
public class AndroidPublicMeetingController {
private final AndroidAuthService androidAuthService;
private final AndroidPublicMeetingSessionService androidPublicMeetingSessionService;
private final MeetingQueryService meetingQueryService;
private final MeetingCommandService meetingCommandService;
private final MeetingService meetingService;
private final DeviceInfoMapper deviceInfoMapper;
@Operation(summary = "创建公有设备扫码发会会话")
@io.swagger.v3.oas.annotations.responses.ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "返回扫码会话信息,供设备展示二维码",
content = @Content(schema = @Schema(implementation = AndroidPublicMeetingSessionVO.class))
)
})
@PostMapping("/session")
@Anonymous
public ApiResponse<Object> createSession(HttpServletRequest request,
@RequestBody(required = false) AndroidPublicMeetingSessionRequest command) {
AndroidRequestLogHelper.logRequest(log, "Android公有会议", "创建扫码发会会话",
"request", command);
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
assertPublicDevice(authContext.getDeviceId());
Meeting existingMeeting = findLatestUnfinishedMeetingByDevice(authContext.getDeviceId());
if (existingMeeting != null) {
return new ApiResponse<>("409", "设备端已有会议", meetingQueryService.getDetailIgnoreTenant(existingMeeting.getId()));
}
AndroidPublicMeetingSessionVO vo = androidPublicMeetingSessionService.create(
authContext.getDeviceId(),
command == null ? null : command.getTitle()
);
return ApiResponse.ok(vo);
}
@Operation(summary = "公有设备删除未开始会议")
@io.swagger.v3.oas.annotations.responses.ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "删除成功返回 true",
content = @Content(schema = @Schema(implementation = Boolean.class))
)
})
@DeleteMapping("/{meetingId}")
@Anonymous
public ApiResponse<Boolean> deletePendingMeeting(HttpServletRequest request, @PathVariable Long meetingId) {
AndroidRequestLogHelper.logRequest(log, "Android公有会议", "删除未开始会议", "meetingId", meetingId);
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
assertPublicDevice(authContext.getDeviceId());
Meeting meeting = meetingService.getById(meetingId);
if (meeting == null) {
throw new RuntimeException("会议不存在");
}
if (meeting.getSourceDeviceCode() == null || !meeting.getSourceDeviceCode().equals(authContext.getDeviceId())) {
throw new RuntimeException("当前会议不属于该设备");
}
if (meeting.getStatus() != null && meeting.getStatus() > 2) {
throw new RuntimeException("当前会议状态不允许删除");
}
meetingCommandService.deleteMeeting(meetingId);
return ApiResponse.ok(true);
}
private Meeting findLatestUnfinishedMeetingByDevice(String deviceId) {
if (deviceId == null || deviceId.isBlank()) {
return null;
}
return meetingService.getOne(new LambdaQueryWrapper<Meeting>()
.eq(Meeting::getSourceDeviceCode, deviceId)
.in(Meeting::getStatus, 0, 1, 2)
.orderByDesc(Meeting::getId)
.last("LIMIT 1"));
}
private void assertPublicDevice(String deviceId) {
if (deviceId == null || deviceId.isBlank()) {
throw new RuntimeException("设备ID不能为空");
}
var device = deviceInfoMapper.selectByDeviceCodeIgnoreTenant(deviceId);
if (device != null && device.getUserId() != null) {
throw new RuntimeException("当前设备为私有设备,请走私有设备发会流程");
}
}
}

View File

@ -2,10 +2,9 @@ package com.imeeting.controller.android.legacy;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.imeeting.common.RedisKeys;
import com.imeeting.dto.android.legacy.LegacyApiResponse; import com.imeeting.dto.android.legacy.LegacyApiResponse;
import com.imeeting.dto.android.AndroidAuthContext;
import com.imeeting.dto.android.legacy.LegacyMeetingAccessPasswordRequest; import com.imeeting.dto.android.legacy.LegacyMeetingAccessPasswordRequest;
import com.imeeting.dto.android.legacy.LegacyMeetingAccessPasswordResponse; import com.imeeting.dto.android.legacy.LegacyMeetingAccessPasswordResponse;
import com.imeeting.dto.android.legacy.LegacyMeetingAttendeeResponse; import com.imeeting.dto.android.legacy.LegacyMeetingAttendeeResponse;
@ -31,7 +30,6 @@ import com.imeeting.service.biz.MeetingProgressService;
import com.imeeting.service.biz.MeetingQueryService; import com.imeeting.service.biz.MeetingQueryService;
import com.imeeting.service.biz.MeetingService; import com.imeeting.service.biz.MeetingService;
import com.imeeting.service.biz.PromptTemplateService; import com.imeeting.service.biz.PromptTemplateService;
import com.imeeting.service.biz.impl.RedisOnlyMeetingProgressServiceAdapter;
import com.unisbase.common.annotation.Log; import com.unisbase.common.annotation.Log;
import com.unisbase.dto.PageResult; import com.unisbase.dto.PageResult;
import com.unisbase.entity.SysUser; import com.unisbase.entity.SysUser;
@ -41,7 +39,6 @@ import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.DeleteMapping;
@ -109,32 +106,6 @@ public class LegacyMeetingController {
new ObjectMapper()); new ObjectMapper());
} }
public LegacyMeetingController(LegacyMeetingAdapterService legacyMeetingAdapterService,
MeetingQueryService meetingQueryService,
MeetingAccessService meetingAccessService,
MeetingCommandService meetingCommandService,
MeetingService meetingService,
AiTaskService aiTaskService,
PromptTemplateService promptTemplateService,
MeetingTranscriptMapper meetingTranscriptMapper,
SysUserMapper sysUserMapper,
StringRedisTemplate redisTemplate,
ObjectMapper objectMapper) {
this(
legacyMeetingAdapterService,
meetingQueryService,
meetingAccessService,
meetingCommandService,
meetingService,
aiTaskService,
promptTemplateService,
meetingTranscriptMapper,
sysUserMapper,
new RedisOnlyMeetingProgressServiceAdapter(redisTemplate, objectMapper),
objectMapper
);
}
@Autowired @Autowired
public LegacyMeetingController(LegacyMeetingAdapterService legacyMeetingAdapterService, public LegacyMeetingController(LegacyMeetingAdapterService legacyMeetingAdapterService,
MeetingQueryService meetingQueryService, MeetingQueryService meetingQueryService,
@ -166,7 +137,7 @@ public class LegacyMeetingController {
@Log(value = "新增兼容会议", type = "兼容会议管理") @Log(value = "新增兼容会议", type = "兼容会议管理")
public LegacyApiResponse<LegacyMeetingCreateResponse> create(@RequestBody LegacyMeetingCreateRequest request) { public LegacyApiResponse<LegacyMeetingCreateResponse> create(@RequestBody LegacyMeetingCreateRequest request) {
AndroidRequestLogHelper.logRequest(log, "兼容会议", "创建会议接口", "request", request); AndroidRequestLogHelper.logRequest(log, "兼容会议", "创建会议接口", "request", request);
MeetingVO meeting = legacyMeetingAdapterService.createMeeting(request, currentLoginUser()); MeetingVO meeting = legacyMeetingAdapterService.createMeeting(request, buildLegacyAuthContext(), currentLoginUser());
return LegacyApiResponse.ok(new LegacyMeetingCreateResponse(meeting.getId())); return LegacyApiResponse.ok(new LegacyMeetingCreateResponse(meeting.getId()));
} }
@ -190,6 +161,7 @@ public class LegacyMeetingController {
modelCode, modelCode,
forceReplace, forceReplace,
audioFile, audioFile,
buildLegacyAuthContext(),
currentLoginUser() currentLoginUser()
); );
return LegacyApiResponse.ok("上传成功", null); return LegacyApiResponse.ok("上传成功", null);
@ -665,4 +637,7 @@ public class LegacyMeetingController {
private String resolveCreatorName(LoginUser loginUser) { private String resolveCreatorName(LoginUser loginUser) {
return loginUser.getDisplayName() != null ? loginUser.getDisplayName() : loginUser.getUsername(); return loginUser.getDisplayName() != null ? loginUser.getDisplayName() : loginUser.getUsername();
} }
private AndroidAuthContext buildLegacyAuthContext() {
return new AndroidAuthContext();
}
} }

View File

@ -2,7 +2,6 @@ package com.imeeting.controller.biz;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.imeeting.common.MeetingConstants; import com.imeeting.common.MeetingConstants;
import com.imeeting.common.RedisKeys;
import com.imeeting.common.SysParamKeys; import com.imeeting.common.SysParamKeys;
import com.imeeting.dto.biz.CreateMeetingCommand; import com.imeeting.dto.biz.CreateMeetingCommand;
import com.imeeting.dto.biz.CreateRealtimeMeetingCommand; import com.imeeting.dto.biz.CreateRealtimeMeetingCommand;
@ -36,7 +35,6 @@ import com.imeeting.service.biz.PromptTemplateService;
import com.imeeting.service.biz.RealtimeMeetingSessionStateService; import com.imeeting.service.biz.RealtimeMeetingSessionStateService;
import com.imeeting.service.biz.RealtimeMeetingSocketSessionService; import com.imeeting.service.biz.RealtimeMeetingSocketSessionService;
import com.imeeting.service.biz.impl.MeetingAudioUploadSupport; import com.imeeting.service.biz.impl.MeetingAudioUploadSupport;
import com.imeeting.service.biz.impl.RedisOnlyMeetingProgressServiceAdapter;
import com.unisbase.common.ApiResponse; import com.unisbase.common.ApiResponse;
import com.unisbase.common.annotation.Log; import com.unisbase.common.annotation.Log;
import com.unisbase.dto.PageResult; import com.unisbase.dto.PageResult;
@ -46,7 +44,6 @@ import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
@ -88,7 +85,6 @@ public class MeetingController {
private final MeetingProgressService meetingProgressService; private final MeetingProgressService meetingProgressService;
private final SysParamService sysParamService; private final SysParamService sysParamService;
private final AiTaskService aiTaskService; private final AiTaskService aiTaskService;
private AiTaskService compatibilityAiTaskService;
@Autowired @Autowired
public MeetingController(MeetingQueryService meetingQueryService, public MeetingController(MeetingQueryService meetingQueryService,
@ -117,35 +113,6 @@ public class MeetingController {
this.aiTaskService = aiTaskService; this.aiTaskService = aiTaskService;
} }
public MeetingController(MeetingQueryService meetingQueryService,
MeetingCommandService meetingCommandService,
MeetingAccessService meetingAccessService,
MeetingExportService meetingExportService,
MeetingTranscriptFileService meetingTranscriptFileService,
PromptTemplateService promptTemplateService,
RealtimeMeetingSocketSessionService realtimeMeetingSocketSessionService,
RealtimeMeetingSessionStateService realtimeMeetingSessionStateService,
AiTaskService unusedAiTaskService,
MeetingAudioUploadSupport meetingAudioUploadSupport,
StringRedisTemplate redisTemplate,
SysParamService sysParamService) {
this(
meetingQueryService,
meetingCommandService,
meetingAccessService,
meetingExportService,
meetingTranscriptFileService,
promptTemplateService,
realtimeMeetingSocketSessionService,
realtimeMeetingSessionStateService,
meetingAudioUploadSupport,
new RedisOnlyMeetingProgressServiceAdapter(redisTemplate, new com.fasterxml.jackson.databind.ObjectMapper()),
sysParamService,
unusedAiTaskService
);
this.compatibilityAiTaskService = unusedAiTaskService;
}
@Operation(summary = "查询会议处理进度") @Operation(summary = "查询会议处理进度")
@GetMapping("/{id}/progress") @GetMapping("/{id}/progress")
@PreAuthorize("isAuthenticated()") @PreAuthorize("isAuthenticated()")
@ -154,8 +121,8 @@ public class MeetingController {
Meeting meeting = meetingAccessService.requireMeeting(id); Meeting meeting = meetingAccessService.requireMeeting(id);
meetingAccessService.assertCanViewMeeting(meeting, loginUser); meetingAccessService.assertCanViewMeeting(meeting, loginUser);
Map<String, Object> progress = meetingProgressService.getProgressMap(id); Map<String, Object> progress = meetingProgressService.getProgressMap(id);
if (compatibilityAiTaskService != null && "Waiting...".equals(progress.get("message"))) { if ("Waiting...".equals(progress.get("message"))) {
AiTask asrTask = compatibilityAiTaskService.getOne(new LambdaQueryWrapper<AiTask>() AiTask asrTask = aiTaskService.getOne(new LambdaQueryWrapper<AiTask>()
.eq(AiTask::getMeetingId, id) .eq(AiTask::getMeetingId, id)
.eq(AiTask::getTaskType, "ASR") .eq(AiTask::getTaskType, "ASR")
.orderByDesc(AiTask::getId) .orderByDesc(AiTask::getId)
@ -188,8 +155,8 @@ public class MeetingController {
Meeting meeting = meetingAccessService.requireMeeting(id); Meeting meeting = meetingAccessService.requireMeeting(id);
meetingAccessService.assertCanViewMeeting(meeting, loginUser); meetingAccessService.assertCanViewMeeting(meeting, loginUser);
Map<String, Object> progress = meetingProgressService.getProgressMap(id); Map<String, Object> progress = meetingProgressService.getProgressMap(id);
if (compatibilityAiTaskService != null && "Waiting...".equals(progress.get("message"))) { if ("Waiting...".equals(progress.get("message"))) {
AiTask asrTask = compatibilityAiTaskService.getOne(new LambdaQueryWrapper<AiTask>() AiTask asrTask = aiTaskService.getOne(new LambdaQueryWrapper<AiTask>()
.eq(AiTask::getMeetingId, id) .eq(AiTask::getMeetingId, id)
.eq(AiTask::getTaskType, "ASR") .eq(AiTask::getTaskType, "ASR")
.orderByDesc(AiTask::getId) .orderByDesc(AiTask::getId)

View File

@ -0,0 +1,98 @@
package com.imeeting.controller.biz;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.imeeting.dto.android.AndroidPublicMeetingSessionState;
import com.imeeting.dto.biz.MeetingVO;
import com.imeeting.dto.biz.PublicDeviceMeetingCreateCommand;
import com.imeeting.entity.biz.Meeting;
import com.imeeting.service.android.AndroidMeetingPushService;
import com.imeeting.service.android.AndroidPublicMeetingSessionService;
import com.imeeting.service.biz.MeetingCommandService;
import com.imeeting.service.biz.MeetingQueryService;
import com.imeeting.service.biz.MeetingService;
import com.unisbase.common.ApiResponse;
import com.unisbase.security.LoginUser;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Tag(name = "公有设备扫码建会接口")
@RestController
@RequestMapping("/api/biz/public-device-meetings")
public class PublicDeviceMeetingController {
private final AndroidPublicMeetingSessionService androidPublicMeetingSessionService;
private final MeetingCommandService meetingCommandService;
private final MeetingQueryService meetingQueryService;
private final AndroidMeetingPushService androidMeetingPushService;
private final MeetingService meetingService;
public PublicDeviceMeetingController(AndroidPublicMeetingSessionService androidPublicMeetingSessionService,
MeetingCommandService meetingCommandService,
MeetingQueryService meetingQueryService,
AndroidMeetingPushService androidMeetingPushService,
MeetingService meetingService) {
this.androidPublicMeetingSessionService = androidPublicMeetingSessionService;
this.meetingCommandService = meetingCommandService;
this.meetingQueryService = meetingQueryService;
this.androidMeetingPushService = androidMeetingPushService;
this.meetingService = meetingService;
}
@Operation(summary = "H5扫码为公有设备创建会议")
@io.swagger.v3.oas.annotations.responses.ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "返回已创建的会议详情;若设备已有未结束会议,则返回已有会议",
content = @Content(schema = @Schema(implementation = MeetingVO.class))
)
})
@PostMapping("/sessions/{sessionId}/create")
public ApiResponse<Object> createBySession(@PathVariable String sessionId,
@Valid @RequestBody PublicDeviceMeetingCreateCommand command) {
LoginUser loginUser = currentLoginUser();
AndroidPublicMeetingSessionState session = androidPublicMeetingSessionService.require(sessionId);
Meeting existingMeeting = findLatestUnfinishedMeetingByDevice(session.getDeviceId());
if (existingMeeting != null) {
return new ApiResponse<>("409", "设备端已有会议", meetingQueryService.getDetailIgnoreTenant(existingMeeting.getId()));
}
String creatorName = loginUser.getDisplayName() != null ? loginUser.getDisplayName() : loginUser.getUsername();
MeetingVO vo = meetingCommandService.createPublicDeviceMeeting(
command,
loginUser.getTenantId(),
loginUser.getUserId(),
creatorName,
session.getDeviceId()
);
androidMeetingPushService.pushPendingMeetingToDevice(vo.getId(), session.getDeviceId());
androidPublicMeetingSessionService.clear(sessionId);
return ApiResponse.ok(vo);
}
private Meeting findLatestUnfinishedMeetingByDevice(String deviceId) {
if (deviceId == null || deviceId.isBlank()) {
return null;
}
return meetingService.getOne(new LambdaQueryWrapper<Meeting>()
.eq(Meeting::getSourceDeviceCode, deviceId)
.in(Meeting::getStatus, 0, 1, 2)
.orderByDesc(Meeting::getId)
.last("LIMIT 1"));
}
private LoginUser currentLoginUser() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !(authentication.getPrincipal() instanceof LoginUser loginUser)) {
throw new RuntimeException("未获取到登录用户");
}
return loginUser;
}
}

View File

@ -0,0 +1,17 @@
package com.imeeting.dto.android;
import lombok.Data;
import java.util.Set;
import java.util.TreeSet;
@Data
public class AndroidChunkUploadSessionState {
private String uploadSessionId;
private Long meetingId;
private String deviceId;
private Integer totalChunks;
private String fileName;
private String contentType;
private Set<Integer> receivedChunks = new TreeSet<>();
}

View File

@ -25,10 +25,20 @@ import java.util.List;
*/ */
@Data @Data
public class AndroidMeetingConfigVo { public class AndroidMeetingConfigVo {
@io.swagger.v3.oas.annotations.media.Schema(description = "可用模型列表")
private List<AiModelVO> modelsList; private List<AiModelVO> modelsList;
@io.swagger.v3.oas.annotations.media.Schema(description = "可用模板列表")
private List<PromptTemplateVO> templateList; private List<PromptTemplateVO> templateList;
@io.swagger.v3.oas.annotations.media.Schema(description = "总结详细程度字典项")
private List<SysDictItemDTO> summaryDegreeOfDetail; private List<SysDictItemDTO> summaryDegreeOfDetail;
@io.swagger.v3.oas.annotations.media.Schema(description = "允许暂停最大时长,单位秒")
private Integer maxPauseDuration; private Integer maxPauseDuration;
@io.swagger.v3.oas.annotations.media.Schema(description = "最大会议时长,单位分钟")
private Integer maxMeetingDuration; private Integer maxMeetingDuration;
@io.swagger.v3.oas.annotations.media.Schema(description = "允许的最大丢包率")
private BigDecimal packetLossRate; private BigDecimal packetLossRate;
@io.swagger.v3.oas.annotations.media.Schema(description = "是否启用音频分片上传")
private Boolean chunkUploadEnabled;
@io.swagger.v3.oas.annotations.media.Schema(description = "分片时长,单位秒")
private Integer chunkDurationSeconds;
} }

View File

@ -0,0 +1,13 @@
package com.imeeting.dto.android;
import com.imeeting.dto.biz.PublicDeviceMeetingCreateCommand;
import lombok.Data;
@Data
public class AndroidPendingMeetingDraft {
private Long meetingId;
private String deviceId;
private Long tenantId;
private Long creatorId;
private PublicDeviceMeetingCreateCommand command;
}

View File

@ -0,0 +1,11 @@
package com.imeeting.dto.android;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "公有设备发会准备请求")
public class AndroidPublicMeetingSessionRequest {
@Schema(description = "设备端展示用途的会话标题,可为空")
private String title;
}

View File

@ -0,0 +1,14 @@
package com.imeeting.dto.android;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class AndroidPublicMeetingSessionState {
private String sessionId;
private String sessionToken;
private String deviceId;
private String title;
private LocalDateTime expireAt;
}

View File

@ -0,0 +1,22 @@
package com.imeeting.dto.android;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@Schema(description = "公有设备发会会话")
public class AndroidPublicMeetingSessionVO {
@Schema(description = "发会会话ID")
private String sessionId;
@Schema(description = "用于H5扫码建会的token")
private String sessionToken;
@Schema(description = "设备ID")
private String deviceId;
@Schema(description = "会话过期时间")
private LocalDateTime expireAt;
}

View File

@ -43,6 +43,10 @@ public class MeetingVO {
private String meetingType; private String meetingType;
@Schema(description = "会议来源") @Schema(description = "会议来源")
private String meetingSource; private String meetingSource;
@Schema(description = "来源设备编码")
private String sourceDeviceCode;
@Schema(description = "来源设备模式")
private String sourceDeviceMode;
@Schema(description = "总结详细程度") @Schema(description = "总结详细程度")
private String summaryDetailLevel; private String summaryDetailLevel;
@Schema(description = "音频保存状态") @Schema(description = "音频保存状态")

View File

@ -0,0 +1,75 @@
package com.imeeting.dto.biz;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.imeeting.common.MeetingConstants;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
@Data
@Schema(description = "公有设备扫码创建会议请求")
public class PublicDeviceMeetingCreateCommand {
@NotBlank(message = "标题不能为空")
@Schema(description = "会议标题")
private String title;
@NotNull(message = "meetingTime不能为空")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@Schema(description = "会议时间")
private LocalDateTime meetingTime;
@Schema(description = "参会人ID串逗号分隔")
private String participants;
@Schema(description = "会议标签")
private String tags;
@Schema(description = "主持人用户ID")
private Long hostUserId;
@Schema(description = "主持人名称")
private String hostName;
@NotNull(message = "asrModelId不能为空")
@Schema(description = "ASR模型ID")
private Long asrModelId;
@NotNull(message = "summaryModelId不能为空")
@Schema(description = "总结模型ID")
private Long summaryModelId;
@Schema(description = "章节模型ID可为空默认复用总结模型")
private Long chapterModelId;
@NotNull(message = "promptId不能为空")
@Schema(description = "模板ID")
private Long promptId;
@Schema(description = "热词组ID")
private Long hotWordGroupId;
@Size(max = 2000, message = "userPrompt length must be <= 2000")
@Schema(description = "用户补充提示词")
private String userPrompt;
@Schema(
description = "总结详细程度DETAILED=详细STANDARD=标准BRIEF=简洁",
allowableValues = {
MeetingConstants.SUMMARY_DETAIL_DETAILED,
MeetingConstants.SUMMARY_DETAIL_STANDARD,
MeetingConstants.SUMMARY_DETAIL_BRIEF
}
)
private String summaryDetailLevel;
@Schema(description = "是否启用说话人分离")
private Integer useSpkId;
@Schema(description = "是否启用文本规整")
private Boolean enableTextRefine;
@Schema(description = "热词列表")
private List<String> hotWords;
@Schema(description = "会议访问密码")
private String accessPassword;
}

View File

@ -0,0 +1,42 @@
package com.imeeting.entity.biz;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.unisbase.entity.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("biz_android_push_message")
public class AndroidPushMessage extends BaseEntity {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
private Long meetingId;
private String deviceCode;
private String messageId;
private String messageType;
private String payload;
private Integer needAck;
private Integer acked;
private String pushStatus;
private Integer pushCount;
private LocalDateTime lastPushAt;
private LocalDateTime ackAt;
private LocalDateTime expireAt;
}

View File

@ -40,6 +40,13 @@ public class Meeting extends BaseEntity {
@Schema(description = "会议来源") @Schema(description = "会议来源")
private String meetingSource; private String meetingSource;
@Schema(description = "来源设备编码")
private String sourceDeviceCode;
@Schema(description = "来源设备模式")
private String sourceDeviceMode;
@Schema(description = "总结详细程度") @Schema(description = "总结详细程度")
private String summaryDetailLevel; private String summaryDetailLevel;

View File

@ -4,16 +4,14 @@ import lombok.Getter;
@Getter @Getter
public enum MeetingPushTypeEnum { public enum MeetingPushTypeEnum {
MEETING_COMPLETED("MEETING_COMPLETED","会议完成通知"),; MEETING_PENDING("MEETING_PENDING", "待开始会议通知"),
MEETING_COMPLETED("MEETING_COMPLETED", "会议完成通知");
private final String code; private final String code;
private final String desc; private final String desc;
MeetingPushTypeEnum(String code, String desc) { MeetingPushTypeEnum(String code, String desc) {
this.code = code; this.code = code;
this.desc = desc; this.desc = desc;
}; }
} }

View File

@ -3,6 +3,7 @@ package com.imeeting.grpc.push;
import com.imeeting.dto.android.AndroidAuthContext; import com.imeeting.dto.android.AndroidAuthContext;
import com.imeeting.dto.android.AndroidDeviceSessionState; import com.imeeting.dto.android.AndroidDeviceSessionState;
import com.imeeting.service.android.AndroidAuthService; import com.imeeting.service.android.AndroidAuthService;
import com.imeeting.service.android.AndroidPushMessageService;
import com.imeeting.service.android.AndroidDeviceSessionService; import com.imeeting.service.android.AndroidDeviceSessionService;
import com.imeeting.service.android.AndroidGatewayPushService; import com.imeeting.service.android.AndroidGatewayPushService;
import com.imeeting.service.biz.DeviceOnlineManagementService; import com.imeeting.service.biz.DeviceOnlineManagementService;
@ -26,6 +27,7 @@ public class AndroidPushGrpcService extends PushServiceGrpc.PushServiceImplBase
private final AndroidAuthService androidAuthService; private final AndroidAuthService androidAuthService;
private final AndroidDeviceSessionService androidDeviceSessionService; private final AndroidDeviceSessionService androidDeviceSessionService;
private final AndroidGatewayPushService androidGatewayPushService; private final AndroidGatewayPushService androidGatewayPushService;
private final AndroidPushMessageService androidPushMessageService;
private final DeviceOnlineManagementService deviceOnlineManagementService; private final DeviceOnlineManagementService deviceOnlineManagementService;
@Override @Override
@ -203,6 +205,7 @@ public class AndroidPushGrpcService extends PushServiceGrpc.PushServiceImplBase
deviceId, deviceId,
appVersion, appVersion,
platform)); platform));
androidPushMessageService.ack(request.getMessageId(), deviceId);
} }
private boolean validateConnected() { private boolean validateConnected() {

View File

@ -1,17 +1,16 @@
package com.imeeting.listener; package com.imeeting.listener;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.imeeting.common.RedisKeys;
import com.imeeting.entity.biz.Meeting; import com.imeeting.entity.biz.Meeting;
import com.imeeting.mapper.biz.MeetingMapper; import com.imeeting.mapper.biz.MeetingMapper;
import com.imeeting.service.biz.AiTaskService; import com.imeeting.service.biz.AiTaskService;
import com.imeeting.support.RedisValueSupport;
import com.imeeting.support.TaskSecurityContextRunner; import com.imeeting.support.TaskSecurityContextRunner;
import com.imeeting.support.redis.MeetingAsrPermitCache;
import com.imeeting.support.redis.MeetingLockCache;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationArguments; import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner; import org.springframework.boot.ApplicationRunner;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.util.List; import java.util.List;
@ -19,32 +18,14 @@ import java.util.concurrent.TimeUnit;
@Component @Component
@Slf4j @Slf4j
@RequiredArgsConstructor
public class MeetingTaskRecoveryListener implements ApplicationRunner { public class MeetingTaskRecoveryListener implements ApplicationRunner {
private final MeetingMapper meetingMapper; private final MeetingMapper meetingMapper;
private final AiTaskService aiTaskService; private final AiTaskService aiTaskService;
private final RedisValueSupport redisValueSupport; private final MeetingLockCache meetingLockCache;
private final MeetingAsrPermitCache meetingAsrPermitCache;
private final TaskSecurityContextRunner taskSecurityContextRunner; private final TaskSecurityContextRunner taskSecurityContextRunner;
private StringRedisTemplate compatibilityRedisTemplate;
@Autowired
public MeetingTaskRecoveryListener(MeetingMapper meetingMapper,
AiTaskService aiTaskService,
RedisValueSupport redisValueSupport,
TaskSecurityContextRunner taskSecurityContextRunner) {
this.meetingMapper = meetingMapper;
this.aiTaskService = aiTaskService;
this.redisValueSupport = redisValueSupport;
this.taskSecurityContextRunner = taskSecurityContextRunner;
}
public MeetingTaskRecoveryListener(MeetingMapper meetingMapper,
AiTaskService aiTaskService,
StringRedisTemplate redisTemplate,
TaskSecurityContextRunner taskSecurityContextRunner) {
this(meetingMapper, aiTaskService, new RedisValueSupport(redisTemplate, new com.fasterxml.jackson.databind.ObjectMapper()), taskSecurityContextRunner);
this.compatibilityRedisTemplate = redisTemplate;
}
@Override @Override
public void run(ApplicationArguments args) { public void run(ApplicationArguments args) {
@ -62,9 +43,8 @@ public class MeetingTaskRecoveryListener implements ApplicationRunner {
for (Meeting meeting : pendingMeetings) { for (Meeting meeting : pendingMeetings) {
try { try {
redisValueSupport.delete(RedisKeys.meetingPollingLockKey(meeting.getId())); meetingLockCache.clearDispatchLocks(meeting.getId());
redisValueSupport.delete(RedisKeys.meetingSummaryLockKey(meeting.getId())); meetingAsrPermitCache.clearRecoveryState(meeting.getId());
clearLegacyRedisState(meeting.getId());
if (Integer.valueOf(1).equals(meeting.getStatus())) { if (Integer.valueOf(1).equals(meeting.getStatus())) {
log.info("Recovering ASR task for meeting {}", meeting.getId()); log.info("Recovering ASR task for meeting {}", meeting.getId());
@ -82,15 +62,4 @@ public class MeetingTaskRecoveryListener implements ApplicationRunner {
log.info("Meeting task recovery processed {} meetings.", pendingMeetings.size()); log.info("Meeting task recovery processed {} meetings.", pendingMeetings.size());
} }
private void clearLegacyRedisState(Long meetingId) {
if (compatibilityRedisTemplate == null || meetingId == null) {
return;
}
compatibilityRedisTemplate.delete(List.of(
RedisKeys.meetingAsrPermitSyncLockKey(),
RedisKeys.meetingAsrRefillLockKey()
));
compatibilityRedisTemplate.opsForSet().remove(RedisKeys.meetingAsrPermitSetKey(), String.valueOf(meetingId));
}
} }

View File

@ -0,0 +1,9 @@
package com.imeeting.mapper.biz;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.imeeting.entity.biz.AndroidPushMessage;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface AndroidPushMessageMapper extends BaseMapper<AndroidPushMessage> {
}

View File

@ -0,0 +1,23 @@
package com.imeeting.service.android;
import com.imeeting.dto.android.AndroidAuthContext;
import com.imeeting.dto.android.legacy.LegacyUploadAudioResponse;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
public interface AndroidChunkUploadService {
void saveChunk(Long meetingId,
String uploadSessionId,
Integer chunkIndex,
Integer totalChunks,
MultipartFile chunkFile,
AndroidAuthContext authContext) throws IOException;
LegacyUploadAudioResponse completeUpload(Long meetingId,
String uploadSessionId,
boolean forceReplace,
Long promptId,
String modelCode,
AndroidAuthContext authContext) throws IOException;
}

View File

@ -0,0 +1,9 @@
package com.imeeting.service.android;
public interface AndroidDeviceBindingService {
void bindPrivateDevice(String deviceCode, Long tenantId, Long userId, String appVersion, String platform);
void validatePrivateDeviceAccess(String deviceCode, Long tenantId, Long userId);
void unbindPrivateDevice(String deviceCode);
}

View File

@ -1,10 +1,7 @@
package com.imeeting.service.android; package com.imeeting.service.android;
public interface AndroidMeetingPushService { public interface AndroidMeetingPushService {
void pushPendingMeetingToDevice(Long meetingId, String deviceId);
void pushMeetingCompleted(Long meetingId); void pushMeetingCompleted(Long meetingId);
} }

View File

@ -0,0 +1,11 @@
package com.imeeting.service.android;
import com.imeeting.dto.android.AndroidPendingMeetingDraft;
public interface AndroidPendingMeetingDraftService {
void save(AndroidPendingMeetingDraft draft);
AndroidPendingMeetingDraft get(Long meetingId);
void clear(Long meetingId);
}

View File

@ -0,0 +1,12 @@
package com.imeeting.service.android;
import com.imeeting.dto.android.AndroidPublicMeetingSessionState;
import com.imeeting.dto.android.AndroidPublicMeetingSessionVO;
public interface AndroidPublicMeetingSessionService {
AndroidPublicMeetingSessionVO create(String deviceId, String title);
AndroidPublicMeetingSessionState require(String sessionId);
void clear(String sessionId);
}

View File

@ -0,0 +1,20 @@
package com.imeeting.service.android;
import com.imeeting.entity.biz.AndroidPushMessage;
import com.imeeting.grpc.push.PushMessage;
import java.util.List;
public interface AndroidPushMessageService {
AndroidPushMessage saveMeetingPushMessage(Long tenantId, Long meetingId, String deviceCode, PushMessage pushMessage, long expireAfterMinutes);
boolean ack(String messageId, String deviceCode);
List<AndroidPushMessage> listPendingMeetingPushMessages();
void markPushed(Long id);
void markExpired(Long id);
void markCancelledByMeeting(Long meetingId);
}

View File

@ -5,6 +5,7 @@ import com.imeeting.dto.android.AndroidAuthContext;
import com.imeeting.entity.biz.DeviceInfoEntity; import com.imeeting.entity.biz.DeviceInfoEntity;
import com.imeeting.mapper.DeviceInfoMapper; import com.imeeting.mapper.DeviceInfoMapper;
import com.imeeting.service.android.AndroidAuthService; import com.imeeting.service.android.AndroidAuthService;
import com.imeeting.service.android.AndroidDeviceBindingService;
import com.unisbase.common.exception.BusinessException; import com.unisbase.common.exception.BusinessException;
import com.unisbase.dto.InternalAuthCheckResponse; import com.unisbase.dto.InternalAuthCheckResponse;
import com.unisbase.security.LoginUser; import com.unisbase.security.LoginUser;
@ -32,6 +33,7 @@ public class AndroidAuthServiceImpl implements AndroidAuthService {
private final AndroidGrpcAuthProperties properties; private final AndroidGrpcAuthProperties properties;
private final TokenValidationService tokenValidationService; private final TokenValidationService tokenValidationService;
private final DeviceInfoMapper deviceInfoMapper; private final DeviceInfoMapper deviceInfoMapper;
private final AndroidDeviceBindingService androidDeviceBindingService;
@Override @Override
public AndroidAuthContext authenticateGrpc(String deviceId, String appVersion, String platform) { public AndroidAuthContext authenticateGrpc(String deviceId, String appVersion, String platform) {
@ -39,7 +41,13 @@ public class AndroidAuthServiceImpl implements AndroidAuthService {
throw new RuntimeException("Android gRPC push does not allow anonymous access"); throw new RuntimeException("Android gRPC push does not allow anonymous access");
} }
assertDeviceEnabled(deviceId); assertDeviceEnabled(deviceId);
return buildContext("NONE", true, deviceId, null, appVersion, platform, null, null, null, null); AndroidAuthContext context = buildContext("NONE", true, deviceId, null, appVersion, platform, null, null, null, null);
DeviceInfoEntity device = deviceInfoMapper.selectByDeviceCodeIgnoreTenant(deviceId.trim());
if (device != null) {
context.setUserId(device.getUserId());
context.setTenantId(device.getTenantId());
}
return context;
} }
@Override @Override
@ -55,6 +63,7 @@ public class AndroidAuthServiceImpl implements AndroidAuthService {
log.info("[安卓接口访问]X-Android-Device-Id={},X-Android-App-Version={},X-Android-Platform={}",deviceId,appVersion,platform); log.info("[安卓接口访问]X-Android-Device-Id={},X-Android-App-Version={},X-Android-Platform={}",deviceId,appVersion,platform);
assertDeviceEnabled(deviceId); assertDeviceEnabled(deviceId);
if (loginUser != null) { if (loginUser != null) {
androidDeviceBindingService.validatePrivateDeviceAccess(deviceId, loginUser.getTenantId(), loginUser.getUserId());
return buildContext("USER_JWT", false, return buildContext("USER_JWT", false,
deviceId, deviceId,
appId, appId,
@ -68,6 +77,7 @@ public class AndroidAuthServiceImpl implements AndroidAuthService {
if (StringUtils.hasText(resolvedToken)) { if (StringUtils.hasText(resolvedToken)) {
InternalAuthCheckResponse authResult = validateToken(resolvedToken); InternalAuthCheckResponse authResult = validateToken(resolvedToken);
androidDeviceBindingService.validatePrivateDeviceAccess(deviceId, authResult.getTenantId(), authResult.getUserId());
return buildContext("USER_JWT", false, return buildContext("USER_JWT", false,
deviceId, deviceId,
appId, appId,

View File

@ -0,0 +1,243 @@
package com.imeeting.service.android.impl;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.imeeting.dto.android.AndroidAuthContext;
import com.imeeting.dto.android.AndroidChunkUploadSessionState;
import com.imeeting.dto.android.legacy.LegacyUploadAudioResponse;
import com.imeeting.service.android.AndroidChunkUploadService;
import com.imeeting.service.android.legacy.LegacyMeetingAdapterService;
import com.imeeting.support.redis.AndroidChunkUploadSessionCache;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.util.Objects;
import com.unisbase.security.LoginUser;
@Service
@RequiredArgsConstructor
public class AndroidChunkUploadServiceImpl implements AndroidChunkUploadService {
private final AndroidChunkUploadSessionCache sessionCache;
private final LegacyMeetingAdapterService legacyMeetingAdapterService;
@Value("${unisbase.app.upload-path}")
private String uploadPath;
@Override
public void saveChunk(Long meetingId,
String uploadSessionId,
Integer chunkIndex,
Integer totalChunks,
MultipartFile chunkFile,
AndroidAuthContext authContext) throws IOException {
if (meetingId == null || uploadSessionId == null || uploadSessionId.isBlank()) {
throw new RuntimeException("uploadSessionId不能为空");
}
if (chunkIndex == null || totalChunks == null || chunkIndex < 0 || totalChunks <= 0) {
throw new RuntimeException("分片参数无效");
}
if (chunkFile == null || chunkFile.isEmpty()) {
throw new RuntimeException("chunk_file不能为空");
}
AndroidChunkUploadSessionState state = getOrCreateState(meetingId, uploadSessionId, totalChunks, chunkFile, authContext);
if (!Objects.equals(state.getMeetingId(), meetingId) || !Objects.equals(state.getDeviceId(), authContext.getDeviceId())) {
throw new RuntimeException("分片上传会话与当前设备或会议不匹配");
}
Path sessionDir = sessionDir(uploadSessionId);
Files.createDirectories(sessionDir);
Files.write(sessionDir.resolve(chunkIndex + ".part"), chunkFile.getBytes(), StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
state.getReceivedChunks().add(chunkIndex);
saveState(uploadSessionId, state);
}
@Override
public LegacyUploadAudioResponse completeUpload(Long meetingId,
String uploadSessionId,
boolean forceReplace,
Long promptId,
String modelCode,
AndroidAuthContext authContext) throws IOException {
AndroidChunkUploadSessionState state = requireState(uploadSessionId);
if (!Objects.equals(state.getMeetingId(), meetingId) || !Objects.equals(state.getDeviceId(), authContext.getDeviceId())) {
throw new RuntimeException("分片上传会话与当前设备或会议不匹配");
}
for (int i = 0; i < state.getTotalChunks(); i++) {
if (!state.getReceivedChunks().contains(i)) {
throw new RuntimeException("分片未上传完整");
}
}
Path mergedFile = mergeChunks(state);
try {
MultipartFile mergedMultipart = new LocalMultipartFile(
state.getFileName() == null ? "meeting-audio.bin" : state.getFileName(),
state.getContentType(),
Files.readAllBytes(mergedFile)
);
if (authContext.isAnonymous()) {
return legacyMeetingAdapterService.uploadAndTriggerOfflineProcessForPublicDevice(
meetingId,
forceReplace,
mergedMultipart,
authContext
);
}
LoginUser loginUser = toLoginUser(authContext);
return legacyMeetingAdapterService.uploadAndTriggerOfflineProcess(
meetingId,
promptId,
modelCode,
forceReplace,
mergedMultipart,
authContext,
loginUser
);
} finally {
cleanup(uploadSessionId);
}
}
private AndroidChunkUploadSessionState getOrCreateState(Long meetingId,
String uploadSessionId,
Integer totalChunks,
MultipartFile chunkFile,
AndroidAuthContext authContext) throws IOException {
AndroidChunkUploadSessionState existing = getState(uploadSessionId);
if (existing != null) {
return existing;
}
AndroidChunkUploadSessionState state = new AndroidChunkUploadSessionState();
state.setUploadSessionId(uploadSessionId);
state.setMeetingId(meetingId);
state.setDeviceId(authContext.getDeviceId());
state.setTotalChunks(totalChunks);
state.setFileName(chunkFile.getOriginalFilename());
state.setContentType(chunkFile.getContentType());
saveState(uploadSessionId, state);
return state;
}
private Path mergeChunks(AndroidChunkUploadSessionState state) throws IOException {
Path sessionDir = sessionDir(state.getUploadSessionId());
Path merged = sessionDir.resolve("merged.bin");
Files.deleteIfExists(merged);
Files.createFile(merged);
for (int i = 0; i < state.getTotalChunks(); i++) {
Files.write(merged, Files.readAllBytes(sessionDir.resolve(i + ".part")), StandardOpenOption.APPEND);
}
return merged;
}
private AndroidChunkUploadSessionState requireState(String uploadSessionId) {
AndroidChunkUploadSessionState state = getState(uploadSessionId);
if (state == null) {
throw new RuntimeException("分片上传会话不存在或已过期");
}
return state;
}
private AndroidChunkUploadSessionState getState(String uploadSessionId) {
return sessionCache.get(uploadSessionId);
}
private void saveState(String uploadSessionId, AndroidChunkUploadSessionState state) {
sessionCache.save(uploadSessionId, state);
}
private void cleanup(String uploadSessionId) throws IOException {
sessionCache.clear(uploadSessionId);
Path sessionDir = sessionDir(uploadSessionId);
if (Files.exists(sessionDir)) {
try (var paths = Files.walk(sessionDir)) {
paths.sorted((left, right) -> right.compareTo(left)).forEach(path -> {
try {
Files.deleteIfExists(path);
} catch (IOException ex) {
throw new RuntimeException(ex);
}
});
}
}
}
private Path sessionDir(String uploadSessionId) {
String normalizedBasePath = uploadPath.endsWith("/") || uploadPath.endsWith("\\") ? uploadPath : uploadPath + "/";
return Paths.get(normalizedBasePath, "android-chunks", uploadSessionId);
}
private LoginUser toLoginUser(AndroidAuthContext authContext) {
if (authContext == null || authContext.isAnonymous() || authContext.getUserId() == null || authContext.getTenantId() == null) {
throw new RuntimeException("安卓用户未登录或认证无效");
}
LoginUser loginUser = new LoginUser(
authContext.getUserId(),
authContext.getTenantId(),
authContext.getUsername(),
authContext.getPlatformAdmin(),
authContext.getTenantAdmin(),
authContext.getPermissions()
);
loginUser.setDisplayName(authContext.getDisplayName());
return loginUser;
}
private static final class LocalMultipartFile implements MultipartFile {
private final String originalFilename;
private final String contentType;
private final byte[] bytes;
private LocalMultipartFile(String originalFilename, String contentType, byte[] bytes) {
this.originalFilename = originalFilename;
this.contentType = contentType;
this.bytes = bytes == null ? new byte[0] : bytes;
}
@Override
public String getName() {
return originalFilename;
}
@Override
public String getOriginalFilename() {
return originalFilename;
}
@Override
public String getContentType() {
return contentType;
}
@Override
public boolean isEmpty() {
return bytes.length == 0;
}
@Override
public long getSize() {
return bytes.length;
}
@Override
public byte[] getBytes() {
return bytes;
}
@Override
public InputStream getInputStream() {
return new ByteArrayInputStream(bytes);
}
@Override
public void transferTo(java.io.File dest) throws IOException, IllegalStateException {
Files.write(dest.toPath(), bytes);
}
}
}

View File

@ -0,0 +1,79 @@
package com.imeeting.service.android.impl;
import com.imeeting.mapper.DeviceInfoMapper;
import com.imeeting.entity.biz.DeviceInfoEntity;
import com.imeeting.service.android.AndroidDeviceBindingService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.Objects;
@Service
@RequiredArgsConstructor
public class AndroidDeviceBindingServiceImpl implements AndroidDeviceBindingService {
private final DeviceInfoMapper deviceInfoMapper;
@Override
public void bindPrivateDevice(String deviceCode, Long tenantId, Long userId, String appVersion, String platform) {
if (!StringUtils.hasText(deviceCode) || userId == null || tenantId == null) {
throw new RuntimeException("设备登录缺少绑定上下文");
}
DeviceInfoEntity existing = deviceInfoMapper.selectByDeviceCodeIgnoreTenant(deviceCode.trim());
LocalDateTime now = LocalDateTime.now();
if (existing == null) {
DeviceInfoEntity created = new DeviceInfoEntity();
created.setTenantId(tenantId);
created.setUserId(userId);
created.setDeviceCode(deviceCode.trim());
created.setTerminalType(normalize(platform));
created.setTerminalVersion(normalize(appVersion));
created.setLastOnlineAt(now);
created.setStatus(1);
deviceInfoMapper.insert(created);
return;
}
existing.setTenantId(tenantId);
existing.setUserId(userId);
existing.setTerminalType(normalize(platform));
existing.setTerminalVersion(normalize(appVersion));
existing.setLastOnlineAt(now);
deviceInfoMapper.updateConnectionInfoByIdIgnoreTenant(existing);
}
@Override
public void validatePrivateDeviceAccess(String deviceCode, Long tenantId, Long userId) {
if (!StringUtils.hasText(deviceCode) || userId == null || tenantId == null) {
throw new RuntimeException("设备登录态无效");
}
DeviceInfoEntity existing = deviceInfoMapper.selectByDeviceCodeIgnoreTenant(deviceCode.trim());
if (existing == null || existing.getUserId() == null || existing.getTenantId() == null) {
throw new RuntimeException("设备未登录,请先完成设备登录");
}
if (!Objects.equals(existing.getUserId(), userId) || !Objects.equals(existing.getTenantId(), tenantId)) {
throw new RuntimeException("当前设备已被其他用户占用,请使用当前登录用户或重新登录占用设备");
}
}
@Override
public void unbindPrivateDevice(String deviceCode) {
if (!StringUtils.hasText(deviceCode)) {
return;
}
DeviceInfoEntity existing = deviceInfoMapper.selectByDeviceCodeIgnoreTenant(deviceCode.trim());
if (existing == null) {
return;
}
existing.setUserId(null);
existing.setTenantId(null);
deviceInfoMapper.updateConnectionInfoByIdIgnoreTenant(existing);
}
private String normalize(String value) {
if (!StringUtils.hasText(value)) {
return null;
}
return value.trim().toLowerCase();
}
}

View File

@ -1,14 +1,12 @@
package com.imeeting.service.android.impl; package com.imeeting.service.android.impl;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.imeeting.common.RedisKeys;
import com.imeeting.config.grpc.GrpcServerProperties; import com.imeeting.config.grpc.GrpcServerProperties;
import com.imeeting.dto.android.AndroidAuthContext; import com.imeeting.dto.android.AndroidAuthContext;
import com.imeeting.dto.android.AndroidDeviceSessionState; import com.imeeting.dto.android.AndroidDeviceSessionState;
import com.imeeting.service.android.AndroidDeviceSessionService; import com.imeeting.service.android.AndroidDeviceSessionService;
import com.imeeting.support.redis.AndroidDeviceSessionCache;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.time.Duration; import java.time.Duration;
@ -24,8 +22,7 @@ public class AndroidDeviceSessionServiceImpl implements AndroidDeviceSessionServ
private static final DateTimeFormatter LOG_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); private static final DateTimeFormatter LOG_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final StringRedisTemplate redisTemplate; private final AndroidDeviceSessionCache sessionCache;
private final ObjectMapper objectMapper;
private final GrpcServerProperties grpcServerProperties; private final GrpcServerProperties grpcServerProperties;
@Override @Override
@ -61,63 +58,37 @@ public class AndroidDeviceSessionServiceImpl implements AndroidDeviceSessionServ
@Override @Override
public AndroidDeviceSessionState getByConnectionId(String connectionId) { public AndroidDeviceSessionState getByConnectionId(String connectionId) {
String raw = redisTemplate.opsForValue().get(RedisKeys.androidDeviceConnectionKey(connectionId)); return sessionCache.getByConnectionId(connectionId);
if (raw == null || raw.isBlank()) {
return null;
}
try {
return objectMapper.readValue(raw, AndroidDeviceSessionState.class);
} catch (Exception ex) {
log.warn("Failed to read android device session, connectionId={}", connectionId, ex);
return null;
}
} }
@Override @Override
public AndroidDeviceSessionState getByDeviceId(String deviceId) { public AndroidDeviceSessionState getByDeviceId(String deviceId) {
String raw = redisTemplate.opsForValue().get(RedisKeys.androidDeviceOnlineKey(deviceId)); return sessionCache.getByDeviceId(deviceId);
if (raw == null || raw.isBlank()) {
return null;
}
try {
return objectMapper.readValue(raw, AndroidDeviceSessionState.class);
} catch (Exception ex) {
log.warn("Failed to read android device online state, deviceId={}", deviceId, ex);
return null;
}
} }
@Override @Override
public String getActiveConnectionId(String deviceId) { public String getActiveConnectionId(String deviceId) {
String value = redisTemplate.opsForValue().get(RedisKeys.androidDeviceActiveConnectionKey(deviceId)); return sessionCache.getActiveConnectionId(deviceId);
return value == null || value.isBlank() ? null : value;
} }
@Override @Override
public void updateTopics(String deviceId, List<String> topics) { public void updateTopics(String deviceId, List<String> topics) {
try { sessionCache.saveTopics(deviceId, topics);
redisTemplate.opsForValue().set(
RedisKeys.androidDeviceTopicsKey(deviceId),
objectMapper.writeValueAsString(topics == null ? List.of() : topics)
);
} catch (Exception ex) {
throw new RuntimeException("Failed to update android device topics", ex);
}
} }
@Override @Override
public void closeSession(String connectionId) { public void closeSession(String connectionId) {
AndroidDeviceSessionState state = getByConnectionId(connectionId); AndroidDeviceSessionState state = getByConnectionId(connectionId);
if (state == null) { if (state == null) {
redisTemplate.delete(RedisKeys.androidDeviceConnectionKey(connectionId)); sessionCache.deleteConnection(connectionId);
return; return;
} }
String activeConn = getActiveConnectionId(state.getDeviceId()); String activeConn = getActiveConnectionId(state.getDeviceId());
if (connectionId.equals(activeConn)) { if (connectionId.equals(activeConn)) {
redisTemplate.delete(RedisKeys.androidDeviceActiveConnectionKey(state.getDeviceId())); sessionCache.deleteActiveConnection(state.getDeviceId());
redisTemplate.delete(RedisKeys.androidDeviceOnlineKey(state.getDeviceId())); sessionCache.deleteOnlineState(state.getDeviceId());
} }
redisTemplate.delete(RedisKeys.androidDeviceConnectionKey(connectionId)); sessionCache.deleteConnection(connectionId);
log.info(buildLog("gRPC会话关闭", log.info(buildLog("gRPC会话关闭",
"关闭Android设备会话连接ID=" + connectionId, "关闭Android设备会话连接ID=" + connectionId,
state.getDeviceId(), state.getDeviceId(),
@ -127,14 +98,7 @@ public class AndroidDeviceSessionServiceImpl implements AndroidDeviceSessionServ
private void writeState(AndroidDeviceSessionState state) { private void writeState(AndroidDeviceSessionState state) {
Duration ttl = Duration.ofSeconds(grpcServerProperties.getGateway().getHeartbeatTimeoutSeconds()); Duration ttl = Duration.ofSeconds(grpcServerProperties.getGateway().getHeartbeatTimeoutSeconds());
try { sessionCache.saveState(state, ttl);
String json = objectMapper.writeValueAsString(state);
redisTemplate.opsForValue().set(RedisKeys.androidDeviceOnlineKey(state.getDeviceId()), json, ttl);
redisTemplate.opsForValue().set(RedisKeys.androidDeviceActiveConnectionKey(state.getDeviceId()), state.getConnectionId(), ttl);
redisTemplate.opsForValue().set(RedisKeys.androidDeviceConnectionKey(state.getConnectionId()), json, ttl);
} catch (Exception ex) {
throw new RuntimeException("Failed to store android device session", ex);
}
} }
private String nonBlank(String value, String defaultValue) { private String nonBlank(String value, String defaultValue) {

View File

@ -1,19 +1,17 @@
package com.imeeting.service.android.impl; package com.imeeting.service.android.impl;
import cn.hutool.json.JSON;
import cn.hutool.json.JSONUtil; import cn.hutool.json.JSONUtil;
import com.imeeting.dto.biz.MeetingVO; import com.imeeting.dto.biz.MeetingVO;
import com.imeeting.entity.biz.Meeting;
import com.imeeting.enums.MeetingPushTypeEnum; import com.imeeting.enums.MeetingPushTypeEnum;
import com.imeeting.grpc.push.PushMessage; import com.imeeting.grpc.push.PushMessage;
import com.imeeting.service.android.AndroidGatewayPushService; import com.imeeting.service.android.AndroidGatewayPushService;
import com.imeeting.service.android.AndroidMeetingPushService; import com.imeeting.service.android.AndroidMeetingPushService;
import com.imeeting.service.android.AndroidPushMessageService;
import com.imeeting.service.biz.MeetingQueryService; import com.imeeting.service.biz.MeetingQueryService;
import com.imeeting.service.biz.MeetingService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Lazy; import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@ -25,23 +23,50 @@ import java.util.UUID;
@Slf4j @Slf4j
@Service @Service
public class AndroidMeetingPushServiceImpl implements AndroidMeetingPushService { public class AndroidMeetingPushServiceImpl implements AndroidMeetingPushService {
private static final DateTimeFormatter TITLE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); private static final DateTimeFormatter TITLE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
@Autowired @Autowired
@Lazy @Lazy
private MeetingQueryService meetingService; private MeetingQueryService meetingQueryService;
@Autowired @Autowired
private AndroidGatewayPushService androidGatewayPushService; private AndroidGatewayPushService androidGatewayPushService;
@Autowired
private AndroidPushMessageService androidPushMessageService;
@Value("${imeeting.android.push.pending-expire-minutes:30}")
private long pendingExpireMinutes;
@Override
public void pushPendingMeetingToDevice(Long meetingId, String deviceId) {
if (meetingId == null || deviceId == null || deviceId.isBlank()) {
return;
}
MeetingVO meeting = meetingQueryService.getDetailIgnoreTenant(meetingId);
if (meeting == null) {
return;
}
PushMessage message = PushMessage.newBuilder()
.setMessageId("meeting_pending:" + meetingId + ":" + UUID.randomUUID())
.setTimestamp(System.currentTimeMillis())
.setType(MeetingPushTypeEnum.MEETING_PENDING.getCode())
.setTitle(resolvePendingTitle(meeting))
.setContent(buildPendingContent(meeting))
.setNeedAck(true)
.build();
var pushEntity = androidPushMessageService.saveMeetingPushMessage(meeting.getTenantId(), meetingId, deviceId, message, pendingExpireMinutes);
int pushed = androidGatewayPushService.pushToDevice(deviceId, message);
if (pushEntity.getId() != null) {
androidPushMessageService.markPushed(pushEntity.getId());
}
log.info("Android pending meeting push finished, meetingId={}, deviceId={}, pushedConnections={}", meetingId, deviceId, pushed);
}
@Override @Override
public void pushMeetingCompleted(Long meetingId) { public void pushMeetingCompleted(Long meetingId) {
if (meetingId == null) { if (meetingId == null) {
return; return;
} }
MeetingVO meeting = meetingService.getDetailIgnoreTenant(meetingId); MeetingVO meeting = meetingQueryService.getDetailIgnoreTenant(meetingId);
if (meeting == null || meeting.getTenantId() == null || meeting.getCreatorId() == null) { if (meeting == null || meeting.getTenantId() == null || meeting.getCreatorId() == null) {
return; return;
} }
@ -49,8 +74,8 @@ public class AndroidMeetingPushServiceImpl implements AndroidMeetingPushService
.setMessageId("meeting_completed:" + meetingId + ":" + UUID.randomUUID()) .setMessageId("meeting_completed:" + meetingId + ":" + UUID.randomUUID())
.setTimestamp(System.currentTimeMillis()) .setTimestamp(System.currentTimeMillis())
.setType(MeetingPushTypeEnum.MEETING_COMPLETED.getCode()) .setType(MeetingPushTypeEnum.MEETING_COMPLETED.getCode())
.setTitle(resolveTitle(meeting)) .setTitle(resolveCompletedTitle(meeting))
.setContent(resolveContent(meeting)) .setContent(buildCompletedContent(meeting))
.setNeedAck(false) .setNeedAck(false)
.build(); .build();
int pushed = androidGatewayPushService.pushToUser(meeting.getTenantId(), meeting.getCreatorId(), message); int pushed = androidGatewayPushService.pushToUser(meeting.getTenantId(), meeting.getCreatorId(), message);
@ -58,8 +83,18 @@ public class AndroidMeetingPushServiceImpl implements AndroidMeetingPushService
meetingId, meeting.getTenantId(), meeting.getCreatorId(), pushed); meetingId, meeting.getTenantId(), meeting.getCreatorId(), pushed);
} }
private String resolvePendingTitle(MeetingVO meeting) {
String title = meeting.getTitle();
if (title != null && !title.isBlank()) {
return "待开始会议: " + title.trim();
}
LocalDateTime meetingTime = meeting.getMeetingTime();
return meetingTime == null
? "待开始会议"
: "待开始会议: " + TITLE_TIME_FORMATTER.format(meetingTime);
}
private String resolveTitle(MeetingVO meeting) { private String resolveCompletedTitle(MeetingVO meeting) {
String title = meeting.getTitle(); String title = meeting.getTitle();
if (title != null && !title.isBlank()) { if (title != null && !title.isBlank()) {
return "会议已完成: " + title.trim(); return "会议已完成: " + title.trim();
@ -70,7 +105,18 @@ public class AndroidMeetingPushServiceImpl implements AndroidMeetingPushService
: "会议已完成: " + TITLE_TIME_FORMATTER.format(meetingTime); : "会议已完成: " + TITLE_TIME_FORMATTER.format(meetingTime);
} }
private String resolveContent(MeetingVO meeting) { private String buildPendingContent(MeetingVO meeting) {
Map<String, Object> result = new HashMap<>();
result.put("meetingId", meeting.getId());
result.put("title", meeting.getTitle());
result.put("meetingTime", meeting.getMeetingTime());
result.put("sourceDeviceCode", meeting.getSourceDeviceCode());
result.put("sourceDeviceMode", meeting.getSourceDeviceMode());
result.put("status", meeting.getStatus());
return JSONUtil.toJsonStr(result);
}
private String buildCompletedContent(MeetingVO meeting) {
Map<String, Object> result = new HashMap<>(); Map<String, Object> result = new HashMap<>();
result.put("meetingId", meeting.getId()); result.put("meetingId", meeting.getId());
return JSONUtil.toJsonStr(result); return JSONUtil.toJsonStr(result);

View File

@ -0,0 +1,29 @@
package com.imeeting.service.android.impl;
import com.imeeting.dto.android.AndroidPendingMeetingDraft;
import com.imeeting.service.android.AndroidPendingMeetingDraftService;
import com.imeeting.support.redis.AndroidPendingMeetingDraftCache;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class AndroidPendingMeetingDraftServiceImpl implements AndroidPendingMeetingDraftService {
private final AndroidPendingMeetingDraftCache draftCache;
@Override
public void save(AndroidPendingMeetingDraft draft) {
draftCache.save(draft);
}
@Override
public AndroidPendingMeetingDraft get(Long meetingId) {
return draftCache.get(meetingId);
}
@Override
public void clear(Long meetingId) {
draftCache.clear(meetingId);
}
}

View File

@ -0,0 +1,55 @@
package com.imeeting.service.android.impl;
import com.imeeting.dto.android.AndroidPublicMeetingSessionState;
import com.imeeting.dto.android.AndroidPublicMeetingSessionVO;
import com.imeeting.service.android.AndroidPublicMeetingSessionService;
import com.imeeting.support.redis.AndroidPublicMeetingSessionCache;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.UUID;
@Service
@RequiredArgsConstructor
public class AndroidPublicMeetingSessionServiceImpl implements AndroidPublicMeetingSessionService {
private final AndroidPublicMeetingSessionCache sessionCache;
@Value("${imeeting.public-device.session-ttl-minutes:30}")
private long sessionTtlMinutes;
@Override
public AndroidPublicMeetingSessionVO create(String deviceId, String title) {
String sessionId = UUID.randomUUID().toString().replace("-", "");
LocalDateTime expireAt = LocalDateTime.now().plusMinutes(Math.max(sessionTtlMinutes, 1));
AndroidPublicMeetingSessionState state = new AndroidPublicMeetingSessionState();
state.setSessionId(sessionId);
state.setSessionToken(sessionId);
state.setDeviceId(deviceId);
state.setTitle(title);
state.setExpireAt(expireAt);
sessionCache.save(sessionId, state, Duration.ofMinutes(Math.max(sessionTtlMinutes, 1)));
AndroidPublicMeetingSessionVO vo = new AndroidPublicMeetingSessionVO();
vo.setSessionId(sessionId);
vo.setSessionToken(sessionId);
vo.setDeviceId(deviceId);
vo.setExpireAt(expireAt);
return vo;
}
@Override
public AndroidPublicMeetingSessionState require(String sessionId) {
AndroidPublicMeetingSessionState state = sessionCache.get(sessionId);
if (state == null) {
throw new RuntimeException("公有设备发会会话不存在或已过期");
}
return state;
}
@Override
public void clear(String sessionId) {
sessionCache.clear(sessionId);
}
}

View File

@ -0,0 +1,95 @@
package com.imeeting.service.android.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.imeeting.common.MeetingConstants;
import com.imeeting.entity.biz.AndroidPushMessage;
import com.imeeting.grpc.push.PushMessage;
import com.imeeting.mapper.biz.AndroidPushMessageMapper;
import com.imeeting.service.android.AndroidPushMessageService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.List;
@Service
@RequiredArgsConstructor
public class AndroidPushMessageServiceImpl implements AndroidPushMessageService {
private final AndroidPushMessageMapper androidPushMessageMapper;
@Override
public AndroidPushMessage saveMeetingPushMessage(Long tenantId,
Long meetingId,
String deviceCode,
PushMessage pushMessage,
long expireAfterMinutes) {
AndroidPushMessage entity = new AndroidPushMessage();
entity.setTenantId(tenantId);
entity.setMeetingId(meetingId);
entity.setDeviceCode(deviceCode);
entity.setMessageId(pushMessage.getMessageId());
entity.setMessageType(pushMessage.getType());
entity.setPayload(pushMessage.getContent());
entity.setNeedAck(pushMessage.getNeedAck() ? 1 : 0);
entity.setAcked(0);
entity.setPushStatus(MeetingConstants.DEVICE_DELIVERY_PENDING);
entity.setPushCount(0);
entity.setExpireAt(LocalDateTime.now().plusMinutes(Math.max(expireAfterMinutes, 1)));
androidPushMessageMapper.insert(entity);
return entity;
}
@Override
public boolean ack(String messageId, String deviceCode) {
if (messageId == null || messageId.isBlank() || deviceCode == null || deviceCode.isBlank()) {
return false;
}
int updated = androidPushMessageMapper.update(null, new LambdaUpdateWrapper<AndroidPushMessage>()
.eq(AndroidPushMessage::getMessageId, messageId)
.eq(AndroidPushMessage::getDeviceCode, deviceCode)
.eq(AndroidPushMessage::getIsDeleted, 0)
.set(AndroidPushMessage::getAcked, 1)
.set(AndroidPushMessage::getPushStatus, MeetingConstants.DEVICE_DELIVERY_ACKED)
.set(AndroidPushMessage::getAckAt, LocalDateTime.now()));
return updated > 0;
}
@Override
public List<AndroidPushMessage> listPendingMeetingPushMessages() {
return androidPushMessageMapper.selectList(new LambdaQueryWrapper<AndroidPushMessage>()
.eq(AndroidPushMessage::getNeedAck, 1)
.eq(AndroidPushMessage::getAcked, 0)
.eq(AndroidPushMessage::getPushStatus, MeetingConstants.DEVICE_DELIVERY_PENDING)
.eq(AndroidPushMessage::getIsDeleted, 0));
}
@Override
public void markPushed(Long id) {
androidPushMessageMapper.update(null, new LambdaUpdateWrapper<AndroidPushMessage>()
.eq(AndroidPushMessage::getId, id)
.eq(AndroidPushMessage::getIsDeleted, 0)
.setSql("push_count = COALESCE(push_count, 0) + 1")
.set(AndroidPushMessage::getLastPushAt, LocalDateTime.now()));
}
@Override
public void markExpired(Long id) {
androidPushMessageMapper.update(null, new LambdaUpdateWrapper<AndroidPushMessage>()
.eq(AndroidPushMessage::getId, id)
.eq(AndroidPushMessage::getIsDeleted, 0)
.set(AndroidPushMessage::getPushStatus, MeetingConstants.DEVICE_DELIVERY_EXPIRED));
}
@Override
public void markCancelledByMeeting(Long meetingId) {
if (meetingId == null) {
return;
}
androidPushMessageMapper.update(null, new LambdaUpdateWrapper<AndroidPushMessage>()
.eq(AndroidPushMessage::getMeetingId, meetingId)
.eq(AndroidPushMessage::getAcked, 0)
.eq(AndroidPushMessage::getIsDeleted, 0)
.set(AndroidPushMessage::getPushStatus, MeetingConstants.DEVICE_DELIVERY_CANCELLED));
}
}

View File

@ -2,6 +2,7 @@ package com.imeeting.service.android.legacy;
import com.imeeting.dto.android.legacy.LegacyMeetingCreateRequest; import com.imeeting.dto.android.legacy.LegacyMeetingCreateRequest;
import com.imeeting.dto.android.legacy.LegacyUploadAudioResponse; import com.imeeting.dto.android.legacy.LegacyUploadAudioResponse;
import com.imeeting.dto.android.AndroidAuthContext;
import com.imeeting.dto.biz.MeetingVO; import com.imeeting.dto.biz.MeetingVO;
import com.unisbase.security.LoginUser; import com.unisbase.security.LoginUser;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
@ -9,12 +10,18 @@ import org.springframework.web.multipart.MultipartFile;
import java.io.IOException; import java.io.IOException;
public interface LegacyMeetingAdapterService { public interface LegacyMeetingAdapterService {
MeetingVO createMeeting(LegacyMeetingCreateRequest request, LoginUser loginUser); MeetingVO createMeeting(LegacyMeetingCreateRequest request, AndroidAuthContext authContext, LoginUser loginUser);
LegacyUploadAudioResponse uploadAndTriggerOfflineProcess(Long meetingId, LegacyUploadAudioResponse uploadAndTriggerOfflineProcess(Long meetingId,
Long promptId, Long promptId,
String modelCode, String modelCode,
boolean forceReplace, boolean forceReplace,
MultipartFile audioFile, MultipartFile audioFile,
AndroidAuthContext authContext,
LoginUser loginUser) throws IOException; LoginUser loginUser) throws IOException;
LegacyUploadAudioResponse uploadAndTriggerOfflineProcessForPublicDevice(Long meetingId,
boolean forceReplace,
MultipartFile audioFile,
AndroidAuthContext authContext) throws IOException;
} }

View File

@ -2,9 +2,12 @@ package com.imeeting.service.android.legacy.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.imeeting.common.MeetingConstants; import com.imeeting.common.MeetingConstants;
import com.imeeting.dto.android.AndroidAuthContext;
import com.imeeting.dto.android.AndroidPendingMeetingDraft;
import com.imeeting.dto.android.legacy.LegacyMeetingCreateRequest; import com.imeeting.dto.android.legacy.LegacyMeetingCreateRequest;
import com.imeeting.dto.android.legacy.LegacyUploadAudioResponse; import com.imeeting.dto.android.legacy.LegacyUploadAudioResponse;
import com.imeeting.dto.biz.MeetingVO; import com.imeeting.dto.biz.MeetingVO;
import com.imeeting.dto.biz.PublicDeviceMeetingCreateCommand;
import com.imeeting.dto.biz.RealtimeMeetingRuntimeProfile; import com.imeeting.dto.biz.RealtimeMeetingRuntimeProfile;
import com.imeeting.entity.biz.AiTask; import com.imeeting.entity.biz.AiTask;
import com.imeeting.entity.biz.LlmModel; import com.imeeting.entity.biz.LlmModel;
@ -14,6 +17,7 @@ import com.imeeting.entity.biz.PromptTemplate;
import com.imeeting.mapper.biz.LlmModelMapper; import com.imeeting.mapper.biz.LlmModelMapper;
import com.imeeting.mapper.biz.MeetingTranscriptMapper; import com.imeeting.mapper.biz.MeetingTranscriptMapper;
import com.imeeting.service.android.legacy.LegacyMeetingAdapterService; import com.imeeting.service.android.legacy.LegacyMeetingAdapterService;
import com.imeeting.service.android.AndroidPendingMeetingDraftService;
import com.imeeting.service.biz.AiTaskService; import com.imeeting.service.biz.AiTaskService;
import com.imeeting.service.biz.MeetingAccessService; import com.imeeting.service.biz.MeetingAccessService;
import com.imeeting.service.biz.MeetingRuntimeProfileResolver; import com.imeeting.service.biz.MeetingRuntimeProfileResolver;
@ -56,10 +60,11 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ
private final MeetingTranscriptMapper transcriptMapper; private final MeetingTranscriptMapper transcriptMapper;
private final LlmModelMapper llmModelMapper; private final LlmModelMapper llmModelMapper;
private final MeetingAudioUploadSupport meetingAudioUploadSupport; private final MeetingAudioUploadSupport meetingAudioUploadSupport;
private final AndroidPendingMeetingDraftService androidPendingMeetingDraftService;
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public MeetingVO createMeeting(LegacyMeetingCreateRequest request, LoginUser loginUser) { public MeetingVO createMeeting(LegacyMeetingCreateRequest request, AndroidAuthContext authContext, LoginUser loginUser) {
if (request == null || request.getTitle() == null || request.getTitle().isBlank()) { if (request == null || request.getTitle() == null || request.getTitle().isBlank()) {
throw new RuntimeException("会议标题不能为空"); throw new RuntimeException("会议标题不能为空");
} }
@ -82,7 +87,9 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ
loginUser.getUserId(), loginUser.getUserId(),
resolveCreatorName(loginUser), resolveCreatorName(loginUser),
MeetingConstants.SUMMARY_DETAIL_STANDARD, MeetingConstants.SUMMARY_DETAIL_STANDARD,
0 0,
authContext == null ? null : authContext.getDeviceId(),
MeetingConstants.DEVICE_MODE_PRIVATE
); );
meetingService.save(meeting); meetingService.save(meeting);
@ -98,6 +105,7 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ
String modelCode, String modelCode,
boolean forceReplace, boolean forceReplace,
MultipartFile audioFile, MultipartFile audioFile,
AndroidAuthContext authContext,
LoginUser loginUser) throws IOException { LoginUser loginUser) throws IOException {
if (meetingId == null) { if (meetingId == null) {
throw new RuntimeException("meeting_id 不能为空"); throw new RuntimeException("meeting_id 不能为空");
@ -108,6 +116,7 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ
Meeting meeting = meetingAccessService.requireMeeting(meetingId); Meeting meeting = meetingAccessService.requireMeeting(meetingId);
meetingAccessService.assertCanEditMeeting(meeting, loginUser); meetingAccessService.assertCanEditMeeting(meeting, loginUser);
assertDeviceOwnsMeeting(meeting, authContext);
if (!forceReplace && meeting.getAudioUrl() != null && !meeting.getAudioUrl().isBlank()) { if (!forceReplace && meeting.getAudioUrl() != null && !meeting.getAudioUrl().isBlank()) {
throw new RuntimeException("当前会议已存在音频,如需替换请设置 force_replace=true"); throw new RuntimeException("当前会议已存在音频,如需替换请设置 force_replace=true");
@ -158,6 +167,69 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ
return new LegacyUploadAudioResponse(meetingId, relocatedUrl); return new LegacyUploadAudioResponse(meetingId, relocatedUrl);
} }
@Override
@Transactional(rollbackFor = Exception.class)
public LegacyUploadAudioResponse uploadAndTriggerOfflineProcessForPublicDevice(Long meetingId,
boolean forceReplace,
MultipartFile audioFile,
AndroidAuthContext authContext) throws IOException {
if (meetingId == null) {
throw new RuntimeException("meeting_id 不能为空");
}
if (audioFile == null || audioFile.isEmpty()) {
throw new RuntimeException("audio_file 不能为空");
}
Meeting meeting = meetingAccessService.requireMeeting(meetingId);
assertDeviceOwnsMeeting(meeting, authContext);
if (!MeetingConstants.DEVICE_MODE_PUBLIC.equals(meeting.getSourceDeviceMode())) {
throw new RuntimeException("当前会议不是公有设备会议");
}
if (!forceReplace && meeting.getAudioUrl() != null && !meeting.getAudioUrl().isBlank()) {
throw new RuntimeException("当前会议已存在音频,如需替换请设置 force_replace=true");
}
AndroidPendingMeetingDraft draft = androidPendingMeetingDraftService.get(meetingId);
if (draft == null || draft.getCommand() == null) {
throw new RuntimeException("未找到公有设备会议配置草稿");
}
long transcriptCount = transcriptMapper.selectCount(new LambdaQueryWrapper<MeetingTranscript>()
.eq(MeetingTranscript::getMeetingId, meetingId));
if (transcriptCount > 0) {
throw new RuntimeException("当前会议已存在转录内容,不支持替换已生成的转录");
}
PublicDeviceMeetingCreateCommand command = draft.getCommand();
RealtimeMeetingRuntimeProfile profile = runtimeProfileResolver.resolve(
meeting.getTenantId(),
command.getAsrModelId(),
command.getSummaryModelId(),
command.getPromptId(),
null,
null,
command.getUseSpkId(),
null,
null,
command.getEnableTextRefine(),
null,
command.getHotWordGroupId(),
command.getHotWords()
);
String stagingUrl = storeStagingAudio(audioFile);
String relocatedUrl = meetingDomainSupport.relocateAudioUrl(meetingId, stagingUrl);
meeting.setAudioUrl(relocatedUrl);
meeting.setAudioSaveStatus(RealtimeMeetingAudioStorageService.STATUS_SUCCESS);
meeting.setAudioSaveMessage(null);
meeting.setStatus(1);
meetingService.updateById(meeting);
resetOrCreateAsrTask(meetingId, profile);
Long summaryModelId = profile.getResolvedSummaryModelId();
Long chapterModelId = command.getChapterModelId() != null ? command.getChapterModelId() : summaryModelId;
resetOrCreateChapterTask(meetingId, summaryModelId, chapterModelId, profile.getResolvedPromptId(), command.getUserPrompt(), command.getSummaryDetailLevel());
resetOrCreateSummaryTask(meetingId, summaryModelId, chapterModelId, profile.getResolvedPromptId(), command.getUserPrompt(), command.getSummaryDetailLevel());
dispatchTasksAfterCommit(meetingId, meeting.getTenantId(), meeting.getCreatorId());
androidPendingMeetingDraftService.clear(meetingId);
return new LegacyUploadAudioResponse(meetingId, relocatedUrl);
}
private String joinIds(List<Long> ids) { private String joinIds(List<Long> ids) {
if (ids == null || ids.isEmpty()) { if (ids == null || ids.isEmpty()) {
return ""; return "";
@ -249,22 +321,65 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ
} }
private void resetOrCreateSummaryTask(Long meetingId, RealtimeMeetingRuntimeProfile profile) { private void resetOrCreateSummaryTask(Long meetingId, RealtimeMeetingRuntimeProfile profile) {
AiTask task = findLatestTask(meetingId, "SUMMARY"); resetOrCreateSummaryTask(meetingId, profile, null, null);
Map<String, Object> taskConfig = meetingSummaryPromptAssembler.buildTaskConfig( }
private void resetOrCreateSummaryTask(Long meetingId, RealtimeMeetingRuntimeProfile profile, String userPrompt, String summaryDetailLevel) {
resetOrCreateSummaryTask(
meetingId,
profile.getResolvedSummaryModelId(),
profile.getResolvedSummaryModelId(), profile.getResolvedSummaryModelId(),
profile.getResolvedPromptId(), profile.getResolvedPromptId(),
null userPrompt,
summaryDetailLevel
);
}
private void resetOrCreateSummaryTask(Long meetingId,
Long summaryModelId,
Long chapterModelId,
Long promptId,
String userPrompt,
String summaryDetailLevel) {
AiTask task = findLatestTask(meetingId, "SUMMARY");
Map<String, Object> taskConfig = meetingSummaryPromptAssembler.buildTaskConfig(
summaryModelId,
chapterModelId,
promptId,
userPrompt,
summaryDetailLevel
); );
resetOrCreateTask(task, meetingId, "SUMMARY", taskConfig); resetOrCreateTask(task, meetingId, "SUMMARY", taskConfig);
} }
private void resetOrCreateChapterTask(Long meetingId, RealtimeMeetingRuntimeProfile profile) { private void resetOrCreateChapterTask(Long meetingId, RealtimeMeetingRuntimeProfile profile) {
AiTask task = findLatestTask(meetingId, "CHAPTER"); resetOrCreateChapterTask(meetingId, profile, null, null);
Map<String, Object> taskConfig = meetingSummaryPromptAssembler.buildTaskConfig( }
private void resetOrCreateChapterTask(Long meetingId, RealtimeMeetingRuntimeProfile profile, String userPrompt, String summaryDetailLevel) {
resetOrCreateChapterTask(
meetingId,
profile.getResolvedSummaryModelId(), profile.getResolvedSummaryModelId(),
profile.getResolvedSummaryModelId(), profile.getResolvedSummaryModelId(),
profile.getResolvedPromptId(), profile.getResolvedPromptId(),
null userPrompt,
summaryDetailLevel
);
}
private void resetOrCreateChapterTask(Long meetingId,
Long summaryModelId,
Long chapterModelId,
Long promptId,
String userPrompt,
String summaryDetailLevel) {
AiTask task = findLatestTask(meetingId, "CHAPTER");
Map<String, Object> taskConfig = meetingSummaryPromptAssembler.buildTaskConfig(
summaryModelId,
chapterModelId,
promptId,
userPrompt,
summaryDetailLevel
); );
resetOrCreateTask(task, meetingId, "CHAPTER", taskConfig); resetOrCreateTask(task, meetingId, "CHAPTER", taskConfig);
} }
@ -315,4 +430,14 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ
private String resolveCreatorName(LoginUser loginUser) { private String resolveCreatorName(LoginUser loginUser) {
return loginUser.getDisplayName() != null ? loginUser.getDisplayName() : loginUser.getUsername(); return loginUser.getDisplayName() != null ? loginUser.getDisplayName() : loginUser.getUsername();
} }
private void assertDeviceOwnsMeeting(Meeting meeting, AndroidAuthContext authContext) {
if (meeting == null || authContext == null || authContext.getDeviceId() == null) {
return;
}
if (meeting.getSourceDeviceCode() != null && !meeting.getSourceDeviceCode().isBlank()
&& !meeting.getSourceDeviceCode().equals(authContext.getDeviceId())) {
throw new RuntimeException("当前会议不属于该设备");
}
}
} }

View File

@ -8,6 +8,7 @@ import com.imeeting.dto.biz.MeetingSummaryFinalizeDTO;
import com.imeeting.dto.biz.MeetingTranscriptChapterImportDTO; import com.imeeting.dto.biz.MeetingTranscriptChapterImportDTO;
import com.imeeting.dto.biz.MeetingTranscriptChapterImportResultVO; import com.imeeting.dto.biz.MeetingTranscriptChapterImportResultVO;
import com.imeeting.dto.biz.MeetingVO; import com.imeeting.dto.biz.MeetingVO;
import com.imeeting.dto.biz.PublicDeviceMeetingCreateCommand;
import com.imeeting.dto.biz.RealtimeTranscriptItemDTO; import com.imeeting.dto.biz.RealtimeTranscriptItemDTO;
import com.imeeting.dto.biz.UpdateMeetingBasicCommand; import com.imeeting.dto.biz.UpdateMeetingBasicCommand;
import com.imeeting.dto.biz.UpdateMeetingTranscriptCommand; import com.imeeting.dto.biz.UpdateMeetingTranscriptCommand;
@ -19,6 +20,12 @@ public interface MeetingCommandService {
MeetingVO createRealtimeMeeting(CreateRealtimeMeetingCommand command, Long tenantId, Long creatorId, String creatorName, String meetingSource); MeetingVO createRealtimeMeeting(CreateRealtimeMeetingCommand command, Long tenantId, Long creatorId, String creatorName, String meetingSource);
MeetingVO createPublicDeviceMeeting(PublicDeviceMeetingCreateCommand command,
Long tenantId,
Long creatorId,
String creatorName,
String deviceCode);
void deleteMeeting(Long id); void deleteMeeting(Long id);
void appendRealtimeTranscripts(Long meetingId, List<RealtimeTranscriptItemDTO> items); void appendRealtimeTranscripts(Long meetingId, List<RealtimeTranscriptItemDTO> items);

View File

@ -7,7 +7,6 @@ import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.imeeting.common.MeetingProgressStage; import com.imeeting.common.MeetingProgressStage;
import com.imeeting.common.RedisKeys;
import com.imeeting.dto.biz.AiModelVO; import com.imeeting.dto.biz.AiModelVO;
import com.imeeting.dto.biz.MeetingSummarySource; import com.imeeting.dto.biz.MeetingSummarySource;
import com.imeeting.dto.biz.MeetingTranscriptSourceVO; import com.imeeting.dto.biz.MeetingTranscriptSourceVO;
@ -28,8 +27,8 @@ import com.imeeting.service.biz.MeetingSummaryFileService;
import com.imeeting.service.biz.MeetingTranscriptChapterService; import com.imeeting.service.biz.MeetingTranscriptChapterService;
import com.imeeting.service.biz.MeetingTranscriptFileService; import com.imeeting.service.biz.MeetingTranscriptFileService;
import com.imeeting.support.RedisValueSupport; import com.imeeting.support.TaskSecurityContextRunner;
import com.imeeting.support.redis.MeetingLockCache;
import com.unisbase.entity.SysUser; import com.unisbase.entity.SysUser;
import com.unisbase.mapper.SysUserMapper; import com.unisbase.mapper.SysUserMapper;
import com.unisbase.service.SysParamService; import com.unisbase.service.SysParamService;
@ -38,7 +37,6 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Lazy; import org.springframework.context.annotation.Lazy;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.scheduling.annotation.Async; import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.annotation.Scheduled; import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@ -59,7 +57,6 @@ import java.time.Duration;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.*; import java.util.*;
import java.util.concurrent.Executor; import java.util.concurrent.Executor;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@Service @Service
@ -72,7 +69,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final SysUserMapper sysUserMapper; private final SysUserMapper sysUserMapper;
private final HotWordService hotWordService; private final HotWordService hotWordService;
private final RedisValueSupport redisValueSupport; private final MeetingLockCache meetingLockCache;
private final MeetingProgressService meetingProgressService; private final MeetingProgressService meetingProgressService;
private final MeetingSummaryFileService meetingSummaryFileService; private final MeetingSummaryFileService meetingSummaryFileService;
private final MeetingTranscriptFileService meetingTranscriptFileService; private final MeetingTranscriptFileService meetingTranscriptFileService;
@ -117,7 +114,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
ObjectMapper objectMapper, ObjectMapper objectMapper,
SysUserMapper sysUserMapper, SysUserMapper sysUserMapper,
HotWordService hotWordService, HotWordService hotWordService,
RedisValueSupport redisValueSupport, MeetingLockCache meetingLockCache,
MeetingProgressService meetingProgressService, MeetingProgressService meetingProgressService,
MeetingSummaryFileService meetingSummaryFileService, MeetingSummaryFileService meetingSummaryFileService,
MeetingTranscriptFileService meetingTranscriptFileService, MeetingTranscriptFileService meetingTranscriptFileService,
@ -133,7 +130,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
this.sysUserMapper = sysUserMapper; this.sysUserMapper = sysUserMapper;
this.hotWordService = hotWordService; this.hotWordService = hotWordService;
this.redisValueSupport = redisValueSupport; this.meetingLockCache = meetingLockCache;
this.meetingProgressService = meetingProgressService; this.meetingProgressService = meetingProgressService;
this.meetingSummaryFileService = meetingSummaryFileService; this.meetingSummaryFileService = meetingSummaryFileService;
this.meetingTranscriptFileService = meetingTranscriptFileService; this.meetingTranscriptFileService = meetingTranscriptFileService;
@ -145,41 +142,6 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
this.androidMeetingPushService = androidMeetingPushService; this.androidMeetingPushService = androidMeetingPushService;
} }
public AiTaskServiceImpl(MeetingMapper meetingMapper,
MeetingTranscriptMapper transcriptMapper,
AiModelService aiModelService,
ObjectMapper objectMapper,
SysUserMapper sysUserMapper,
HotWordService hotWordService,
StringRedisTemplate redisTemplate,
MeetingSummaryFileService meetingSummaryFileService,
MeetingTranscriptFileService meetingTranscriptFileService,
MeetingTranscriptChapterService meetingTranscriptChapterService,
MeetingSummaryPromptAssembler meetingSummaryPromptAssembler,
TaskSecurityContextRunner taskSecurityContextRunner,
MeetingExternalSummaryWebhookTrigger meetingExternalSummaryWebhookTrigger,
AndroidMeetingPushService androidMeetingPushService) {
this(
meetingMapper,
transcriptMapper,
aiModelService,
objectMapper,
sysUserMapper,
hotWordService,
new RedisValueSupport(redisTemplate, objectMapper),
new RedisOnlyMeetingProgressServiceAdapter(redisTemplate, objectMapper),
meetingSummaryFileService,
meetingTranscriptFileService,
meetingTranscriptChapterService,
meetingSummaryPromptAssembler,
taskSecurityContextRunner,
meetingExternalSummaryWebhookTrigger,
null,
androidMeetingPushService
);
}
@Override @Override
@Async("asrDispatchExecutor") @Async("asrDispatchExecutor")
public void dispatchTasks(Long meetingId, Long tenantId, Long userId) { public void dispatchTasks(Long meetingId, Long tenantId, Long userId) {
@ -216,9 +178,8 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
private void doDispatchTasks(Long meetingId) { private void doDispatchTasks(Long meetingId) {
String lockKey = RedisKeys.meetingPollingLockKey(meetingId); boolean acquired = meetingLockCache.tryAcquirePollingLock(meetingId, Duration.ofMinutes(30));
Boolean acquired = redisValueSupport.setIfAbsent(lockKey, "locked", 30, TimeUnit.MINUTES); if (!acquired) {
if (Boolean.FALSE.equals(acquired)) {
log.warn("Meeting {} is already being processed", meetingId); log.warn("Meeting {} is already being processed", meetingId);
return; return;
} }
@ -293,7 +254,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
updateMeetingStatus(meetingId, 4); updateMeetingStatus(meetingId, 4);
updateProgress(meetingId, -1, "分析失败: " + e.getMessage(), 0); updateProgress(meetingId, -1, "分析失败: " + e.getMessage(), 0);
} finally { } finally {
redisValueSupport.delete(lockKey); meetingLockCache.releasePollingLock(meetingId);
scheduleQueuedAsrTasks(); scheduleQueuedAsrTasks();
} }
} }
@ -390,9 +351,8 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
if (queuedCount <= 0) { if (queuedCount <= 0) {
return; return;
} }
String scheduleLockKey = RedisKeys.meetingAsrScheduleLockKey(); boolean acquired = meetingLockCache.tryAcquireAsrScheduleLock(Duration.ofSeconds(30));
Boolean acquired = redisValueSupport.setIfAbsent(scheduleLockKey, "locked", 30, TimeUnit.SECONDS); if (!acquired) {
if (Boolean.FALSE.equals(acquired)) {
return; return;
} }
try { try {
@ -425,7 +385,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
self.dispatchTasks(queuedMeeting.getId(), queuedMeeting.getTenantId(), queuedMeeting.getCreatorId()); self.dispatchTasks(queuedMeeting.getId(), queuedMeeting.getTenantId(), queuedMeeting.getCreatorId());
} }
} finally { } finally {
redisValueSupport.delete(scheduleLockKey); meetingLockCache.releaseAsrScheduleLock();
} }
} }
@ -1070,9 +1030,8 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
triggerExternalSummaryWebhook(meeting, sumTask, chapterTask, "AUTO_AFTER_TRANSCRIPT_READY", false); triggerExternalSummaryWebhook(meeting, sumTask, chapterTask, "AUTO_AFTER_TRANSCRIPT_READY", false);
return; return;
} }
String summaryLockKey = RedisKeys.meetingSummaryLockKey(meeting.getId()); boolean acquired = meetingLockCache.tryAcquireSummaryLock(meeting.getId(), Duration.ofMinutes(30));
Boolean acquired = redisValueSupport.setIfAbsent(summaryLockKey, "locked", 30, TimeUnit.MINUTES); if (!acquired) {
if (Boolean.FALSE.equals(acquired)) {
log.warn("Meeting {} summary is already being processed", meeting.getId()); log.warn("Meeting {} summary is already being processed", meeting.getId());
return; return;
} }
@ -1086,7 +1045,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
processSummaryTask(meeting, summarySource, sumTask); processSummaryTask(meeting, summarySource, sumTask);
reconcileMeetingStatus(meeting.getId()); reconcileMeetingStatus(meeting.getId());
} finally { } finally {
redisValueSupport.delete(summaryLockKey); meetingLockCache.releaseSummaryLock(meeting.getId());
} }
} }

View File

@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.imeeting.common.MeetingConstants; import com.imeeting.common.MeetingConstants;
import com.imeeting.common.RedisKeys; import com.imeeting.common.RedisKeys;
import com.imeeting.dto.android.AndroidPendingMeetingDraft;
import com.imeeting.dto.biz.CreateMeetingCommand; import com.imeeting.dto.biz.CreateMeetingCommand;
import com.imeeting.dto.biz.CreateRealtimeMeetingCommand; import com.imeeting.dto.biz.CreateRealtimeMeetingCommand;
import com.imeeting.dto.biz.MeetingExternalWorkflowFailureDTO; import com.imeeting.dto.biz.MeetingExternalWorkflowFailureDTO;
@ -14,6 +15,7 @@ import com.imeeting.dto.biz.MeetingTranscriptChapterImportDTO;
import com.imeeting.dto.biz.MeetingTranscriptChapterImportResultVO; import com.imeeting.dto.biz.MeetingTranscriptChapterImportResultVO;
import com.imeeting.dto.biz.MeetingTranscriptSourceVO; import com.imeeting.dto.biz.MeetingTranscriptSourceVO;
import com.imeeting.dto.biz.MeetingVO; import com.imeeting.dto.biz.MeetingVO;
import com.imeeting.dto.biz.PublicDeviceMeetingCreateCommand;
import com.imeeting.dto.biz.RealtimeMeetingRuntimeProfile; import com.imeeting.dto.biz.RealtimeMeetingRuntimeProfile;
import com.imeeting.dto.biz.RealtimeMeetingResumeConfig; import com.imeeting.dto.biz.RealtimeMeetingResumeConfig;
import com.imeeting.dto.biz.RealtimeMeetingSessionStatusVO; import com.imeeting.dto.biz.RealtimeMeetingSessionStatusVO;
@ -26,6 +28,8 @@ import com.imeeting.entity.biz.Meeting;
import com.imeeting.entity.biz.MeetingTranscript; import com.imeeting.entity.biz.MeetingTranscript;
import com.imeeting.entity.biz.MeetingTranscriptChapterVersion; import com.imeeting.entity.biz.MeetingTranscriptChapterVersion;
import com.imeeting.service.android.AndroidMeetingPushService; import com.imeeting.service.android.AndroidMeetingPushService;
import com.imeeting.service.android.AndroidPendingMeetingDraftService;
import com.imeeting.service.android.AndroidPushMessageService;
import com.imeeting.service.biz.AiTaskService; import com.imeeting.service.biz.AiTaskService;
import com.imeeting.service.biz.HotWordService; import com.imeeting.service.biz.HotWordService;
import com.imeeting.service.biz.MeetingCommandService; import com.imeeting.service.biz.MeetingCommandService;
@ -37,12 +41,12 @@ import com.imeeting.service.biz.MeetingTranscriptChapterService;
import com.imeeting.service.biz.MeetingTranscriptFileService; import com.imeeting.service.biz.MeetingTranscriptFileService;
import com.imeeting.service.biz.MeetingTranscriptRevisionService; import com.imeeting.service.biz.MeetingTranscriptRevisionService;
import com.imeeting.service.biz.RealtimeMeetingSessionStateService; import com.imeeting.service.biz.RealtimeMeetingSessionStateService;
import com.imeeting.service.biz.impl.RedisOnlyMeetingProgressServiceAdapter;
import com.imeeting.service.realtime.RealtimeMeetingAudioStorageService; import com.imeeting.service.realtime.RealtimeMeetingAudioStorageService;
import com.imeeting.support.redis.MeetingAsrPermitCache;
import com.imeeting.support.redis.MeetingLockCache;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization; import org.springframework.transaction.support.TransactionSynchronization;
@ -77,7 +81,10 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final MeetingExternalSummaryWebhookTrigger meetingExternalSummaryWebhookTrigger; private final MeetingExternalSummaryWebhookTrigger meetingExternalSummaryWebhookTrigger;
private final AndroidMeetingPushService androidMeetingPushService; private final AndroidMeetingPushService androidMeetingPushService;
private StringRedisTemplate compatibilityRedisTemplate; private final AndroidPushMessageService androidPushMessageService;
private final AndroidPendingMeetingDraftService androidPendingMeetingDraftService;
private final MeetingLockCache meetingLockCache;
private final MeetingAsrPermitCache meetingAsrPermitCache;
@Value("${imeeting.summary-orchestration.mode:INTERNAL_BUILTIN}") @Value("${imeeting.summary-orchestration.mode:INTERNAL_BUILTIN}")
private String summaryOrchestrationMode; private String summaryOrchestrationMode;
@ -98,7 +105,11 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
MeetingProgressService meetingProgressService, MeetingProgressService meetingProgressService,
ObjectMapper objectMapper, ObjectMapper objectMapper,
MeetingExternalSummaryWebhookTrigger meetingExternalSummaryWebhookTrigger, MeetingExternalSummaryWebhookTrigger meetingExternalSummaryWebhookTrigger,
AndroidMeetingPushService androidMeetingPushService) { AndroidMeetingPushService androidMeetingPushService,
AndroidPushMessageService androidPushMessageService,
AndroidPendingMeetingDraftService androidPendingMeetingDraftService,
MeetingLockCache meetingLockCache,
MeetingAsrPermitCache meetingAsrPermitCache) {
this.meetingService = meetingService; this.meetingService = meetingService;
this.aiTaskService = aiTaskService; this.aiTaskService = aiTaskService;
this.hotWordService = hotWordService; this.hotWordService = hotWordService;
@ -115,43 +126,10 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
this.meetingExternalSummaryWebhookTrigger = meetingExternalSummaryWebhookTrigger; this.meetingExternalSummaryWebhookTrigger = meetingExternalSummaryWebhookTrigger;
this.androidMeetingPushService = androidMeetingPushService; this.androidMeetingPushService = androidMeetingPushService;
} this.androidPushMessageService = androidPushMessageService;
this.androidPendingMeetingDraftService = androidPendingMeetingDraftService;
public MeetingCommandServiceImpl(MeetingService meetingService, this.meetingLockCache = meetingLockCache;
AiTaskService aiTaskService, this.meetingAsrPermitCache = meetingAsrPermitCache;
HotWordService hotWordService,
com.imeeting.mapper.biz.MeetingTranscriptMapper transcriptMapper,
MeetingSummaryFileService meetingSummaryFileService,
MeetingTranscriptFileService meetingTranscriptFileService,
MeetingTranscriptChapterService meetingTranscriptChapterService,
MeetingDomainSupport meetingDomainSupport,
MeetingRuntimeProfileResolver meetingRuntimeProfileResolver,
RealtimeMeetingSessionStateService realtimeMeetingSessionStateService,
RealtimeMeetingAudioStorageService realtimeMeetingAudioStorageService,
StringRedisTemplate redisTemplate,
ObjectMapper objectMapper,
MeetingExternalSummaryWebhookTrigger meetingExternalSummaryWebhookTrigger,
AndroidMeetingPushService androidMeetingPushService) {
this(
meetingService,
aiTaskService,
hotWordService,
transcriptMapper,
meetingSummaryFileService,
meetingTranscriptFileService,
meetingTranscriptChapterService,
meetingDomainSupport,
meetingRuntimeProfileResolver,
realtimeMeetingSessionStateService,
realtimeMeetingAudioStorageService,
new RedisOnlyMeetingProgressServiceAdapter(redisTemplate, objectMapper),
objectMapper,
meetingExternalSummaryWebhookTrigger,
androidMeetingPushService
);
this.compatibilityRedisTemplate = redisTemplate;
} }
@Override @Override
@ -273,6 +251,65 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
return vo; return vo;
} }
@Override
@Transactional(rollbackFor = Exception.class)
public MeetingVO createPublicDeviceMeeting(PublicDeviceMeetingCreateCommand command,
Long tenantId,
Long creatorId,
String creatorName,
String deviceCode) {
RealtimeMeetingRuntimeProfile runtimeProfile = meetingRuntimeProfileResolver.resolve(
tenantId,
command.getAsrModelId(),
command.getSummaryModelId(),
command.getPromptId(),
null,
null,
command.getUseSpkId(),
null,
null,
command.getEnableTextRefine(),
null,
command.getHotWordGroupId(),
command.getHotWords()
);
Long hostUserId = resolveHostUserId(command.getHostUserId(), creatorId);
String hostName = resolveHostName(command.getHostName(), creatorName, creatorId, hostUserId);
String summaryDetailLevel = resolveSummaryDetailLevel(command.getSummaryDetailLevel());
Meeting meeting = meetingDomainSupport.initMeeting(
command.getTitle(),
command.getMeetingTime(),
command.getParticipants(),
command.getTags(),
null,
MeetingConstants.TYPE_OFFLINE,
MeetingConstants.SOURCE_ANDROID,
tenantId,
creatorId,
creatorName,
hostUserId,
hostName,
summaryDetailLevel,
0,
deviceCode,
MeetingConstants.DEVICE_MODE_PUBLIC
);
meeting.setAccessPassword(command.getAccessPassword());
meetingService.save(meeting);
AndroidPendingMeetingDraft draft = new AndroidPendingMeetingDraft();
draft.setMeetingId(meeting.getId());
draft.setDeviceId(deviceCode);
draft.setTenantId(tenantId);
draft.setCreatorId(creatorId);
draft.setCommand(command);
androidPendingMeetingDraftService.save(draft);
MeetingVO vo = new MeetingVO();
meetingDomainSupport.fillMeetingVO(meeting, vo, false, false);
return vo;
}
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public void deleteMeeting(Long id) { public void deleteMeeting(Long id) {
@ -283,6 +320,8 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
meetingService.removeById(id); meetingService.removeById(id);
realtimeMeetingSessionStateService.clear(id); realtimeMeetingSessionStateService.clear(id);
meetingProgressService.clear(id); meetingProgressService.clear(id);
androidPushMessageService.markCancelledByMeeting(id);
androidPendingMeetingDraftService.clear(id);
deleteMeetingArtifactsAfterCommit(id); deleteMeetingArtifactsAfterCommit(id);
} }
@ -1073,12 +1112,11 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
} }
private void clearLegacyDispatchState(Long meetingId) { private void clearLegacyDispatchState(Long meetingId) {
if (compatibilityRedisTemplate == null || meetingId == null) { if (meetingId == null) {
return; return;
} }
compatibilityRedisTemplate.delete(RedisKeys.meetingPollingLockKey(meetingId)); meetingLockCache.clearDispatchLocks(meetingId);
compatibilityRedisTemplate.delete(RedisKeys.meetingSummaryLockKey(meetingId)); meetingAsrPermitCache.removePermit(meetingId);
compatibilityRedisTemplate.opsForSet().remove(RedisKeys.meetingAsrPermitSetKey(), String.valueOf(meetingId));
} }
private void ensureExternalSummaryModeEnabled() { private void ensureExternalSummaryModeEnabled() {

View File

@ -54,6 +54,16 @@ public class MeetingDomainSupport {
String audioUrl, String meetingType, String meetingSource, String audioUrl, String meetingType, String meetingSource,
Long tenantId, Long creatorId, String creatorName, Long tenantId, Long creatorId, String creatorName,
Long hostUserId, String hostName, String summaryDetailLevel, int status) { Long hostUserId, String hostName, String summaryDetailLevel, int status) {
return initMeeting(title, meetingTime, participants, tags, audioUrl, meetingType, meetingSource,
tenantId, creatorId, creatorName, hostUserId, hostName, summaryDetailLevel, status,
null, null);
}
public Meeting initMeeting(String title, LocalDateTime meetingTime, String participants, String tags,
String audioUrl, String meetingType, String meetingSource,
Long tenantId, Long creatorId, String creatorName,
Long hostUserId, String hostName, String summaryDetailLevel, int status,
String sourceDeviceCode, String sourceDeviceMode) {
Meeting meeting = new Meeting(); Meeting meeting = new Meeting();
meeting.setTitle(title); meeting.setTitle(title);
meeting.setMeetingTime(meetingTime); meeting.setMeetingTime(meetingTime);
@ -67,6 +77,8 @@ public class MeetingDomainSupport {
meeting.setHostName(hostName); meeting.setHostName(hostName);
meeting.setTenantId(tenantId != null ? tenantId : 0L); meeting.setTenantId(tenantId != null ? tenantId : 0L);
meeting.setAudioUrl(audioUrl); meeting.setAudioUrl(audioUrl);
meeting.setSourceDeviceCode(sourceDeviceCode);
meeting.setSourceDeviceMode(sourceDeviceMode);
meeting.setSummaryDetailLevel(normalizeSummaryDetailLevel(summaryDetailLevel)); meeting.setSummaryDetailLevel(normalizeSummaryDetailLevel(summaryDetailLevel));
meeting.setAudioSaveStatus(RealtimeMeetingAudioStorageService.STATUS_NONE); meeting.setAudioSaveStatus(RealtimeMeetingAudioStorageService.STATUS_NONE);
meeting.setStatus(status); meeting.setStatus(status);
@ -365,6 +377,8 @@ public class MeetingDomainSupport {
} }
vo.setMeetingType(meeting.getMeetingType()); vo.setMeetingType(meeting.getMeetingType());
vo.setMeetingSource(meeting.getMeetingSource()); vo.setMeetingSource(meeting.getMeetingSource());
vo.setSourceDeviceCode(meeting.getSourceDeviceCode());
vo.setSourceDeviceMode(meeting.getSourceDeviceMode());
vo.setSummaryDetailLevel(normalizeSummaryDetailLevel(meeting.getSummaryDetailLevel())); vo.setSummaryDetailLevel(normalizeSummaryDetailLevel(meeting.getSummaryDetailLevel()));
vo.setAudioSaveStatus(meeting.getAudioSaveStatus()); vo.setAudioSaveStatus(meeting.getAudioSaveStatus());
vo.setAudioSaveMessage(meeting.getAudioSaveMessage()); vo.setAudioSaveMessage(meeting.getAudioSaveMessage());

View File

@ -3,14 +3,13 @@ package com.imeeting.service.biz.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.imeeting.common.MeetingProgressStage; import com.imeeting.common.MeetingProgressStage;
import com.imeeting.common.RedisKeys;
import com.imeeting.dto.biz.MeetingProgressSnapshot; import com.imeeting.dto.biz.MeetingProgressSnapshot;
import com.imeeting.entity.biz.AiTask; import com.imeeting.entity.biz.AiTask;
import com.imeeting.entity.biz.Meeting; import com.imeeting.entity.biz.Meeting;
import com.imeeting.mapper.biz.AiTaskMapper; import com.imeeting.mapper.biz.AiTaskMapper;
import com.imeeting.mapper.biz.MeetingMapper; import com.imeeting.mapper.biz.MeetingMapper;
import com.imeeting.service.biz.MeetingProgressService; import com.imeeting.service.biz.MeetingProgressService;
import com.imeeting.support.RedisValueSupport; import com.imeeting.support.redis.MeetingProgressCache;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionSynchronization; import org.springframework.transaction.support.TransactionSynchronization;
@ -21,17 +20,14 @@ import java.util.HashMap;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.TimeUnit;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class MeetingProgressServiceImpl implements MeetingProgressService { public class MeetingProgressServiceImpl implements MeetingProgressService {
private static final long PROGRESS_TTL_HOURS = 1L;
private final MeetingMapper meetingMapper; private final MeetingMapper meetingMapper;
private final AiTaskMapper aiTaskMapper; private final AiTaskMapper aiTaskMapper;
private final RedisValueSupport redisValueSupport; private final MeetingProgressCache meetingProgressCache;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
@Override @Override
@ -39,12 +35,12 @@ public class MeetingProgressServiceImpl implements MeetingProgressService {
if (meetingId == null) { if (meetingId == null) {
return; return;
} }
redisValueSupport.delete(RedisKeys.meetingProgressKey(meetingId)); meetingProgressCache.clear(meetingId);
} }
@Override @Override
public Map<String, Object> getProgressMap(Long meetingId) { public Map<String, Object> getProgressMap(Long meetingId) {
MeetingProgressSnapshot snapshot = redisValueSupport.getJson(RedisKeys.meetingProgressKey(meetingId), MeetingProgressSnapshot.class); MeetingProgressSnapshot snapshot = meetingProgressCache.getSnapshot(meetingId);
if (snapshot == null) { if (snapshot == null) {
snapshot = buildFallbackSnapshot(meetingId); snapshot = buildFallbackSnapshot(meetingId);
if (snapshot != null) { if (snapshot != null) {
@ -74,7 +70,7 @@ public class MeetingProgressServiceImpl implements MeetingProgressService {
@Override @Override
public Integer resolvePercent(Long meetingId) { public Integer resolvePercent(Long meetingId) {
MeetingProgressSnapshot snapshot = redisValueSupport.getJson(RedisKeys.meetingProgressKey(meetingId), MeetingProgressSnapshot.class); MeetingProgressSnapshot snapshot = meetingProgressCache.getSnapshot(meetingId);
if (snapshot != null && snapshot.getPercent() != null) { if (snapshot != null && snapshot.getPercent() != null) {
return snapshot.getPercent(); return snapshot.getPercent();
} }
@ -115,11 +111,11 @@ public class MeetingProgressServiceImpl implements MeetingProgressService {
if (snapshot == null || snapshot.getMeetingId() == null) { if (snapshot == null || snapshot.getMeetingId() == null) {
return; return;
} }
MeetingProgressSnapshot existing = redisValueSupport.getJson(RedisKeys.meetingProgressKey(snapshot.getMeetingId()), MeetingProgressSnapshot.class); MeetingProgressSnapshot existing = meetingProgressCache.getSnapshot(snapshot.getMeetingId());
if (!shouldReplace(existing, snapshot)) { if (!shouldReplace(existing, snapshot)) {
return; return;
} }
redisValueSupport.setJson(RedisKeys.meetingProgressKey(snapshot.getMeetingId()), snapshot, PROGRESS_TTL_HOURS, TimeUnit.HOURS); meetingProgressCache.saveSnapshot(snapshot);
} }
private MeetingProgressSnapshot buildSnapshot(Long meetingId, private MeetingProgressSnapshot buildSnapshot(Long meetingId,

View File

@ -1,8 +1,6 @@
package com.imeeting.service.biz.impl; package com.imeeting.service.biz.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.imeeting.common.RedisKeys;
import com.imeeting.dto.biz.RealtimeMeetingResumeConfig; import com.imeeting.dto.biz.RealtimeMeetingResumeConfig;
import com.imeeting.dto.biz.RealtimeMeetingSessionState; import com.imeeting.dto.biz.RealtimeMeetingSessionState;
import com.imeeting.dto.biz.RealtimeMeetingSessionStatusVO; import com.imeeting.dto.biz.RealtimeMeetingSessionStatusVO;
@ -11,10 +9,11 @@ import com.imeeting.entity.biz.MeetingTranscript;
import com.imeeting.mapper.biz.MeetingMapper; import com.imeeting.mapper.biz.MeetingMapper;
import com.imeeting.mapper.biz.MeetingTranscriptMapper; import com.imeeting.mapper.biz.MeetingTranscriptMapper;
import com.imeeting.service.biz.RealtimeMeetingSessionStateService; import com.imeeting.service.biz.RealtimeMeetingSessionStateService;
import com.imeeting.support.redis.MeetingLockCache;
import com.imeeting.support.redis.RealtimeMeetingSessionCache;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.time.Duration; import java.time.Duration;
@ -27,8 +26,8 @@ import java.util.Map;
@RequiredArgsConstructor @RequiredArgsConstructor
public class RealtimeMeetingSessionStateServiceImpl implements RealtimeMeetingSessionStateService { public class RealtimeMeetingSessionStateServiceImpl implements RealtimeMeetingSessionStateService {
private final StringRedisTemplate redisTemplate; private final RealtimeMeetingSessionCache sessionCache;
private final ObjectMapper objectMapper; private final MeetingLockCache meetingLockCache;
private final MeetingTranscriptMapper transcriptMapper; private final MeetingTranscriptMapper transcriptMapper;
private final MeetingMapper meetingMapper; private final MeetingMapper meetingMapper;
@ -113,8 +112,8 @@ public class RealtimeMeetingSessionStateServiceImpl implements RealtimeMeetingSe
state.setUpdatedAt(now); state.setUpdatedAt(now);
writeState(state); writeState(state);
redisTemplate.delete(RedisKeys.realtimeMeetingResumeTimeoutKey(meetingId)); sessionCache.clearResumeTimeout(meetingId);
redisTemplate.delete(RedisKeys.realtimeMeetingEmptyTimeoutKey(meetingId)); sessionCache.clearEmptyTimeout(meetingId);
return true; return true;
} }
@ -194,8 +193,8 @@ public class RealtimeMeetingSessionStateServiceImpl implements RealtimeMeetingSe
if ("PAUSED_EMPTY".equals(state.getStatus()) || "PAUSED_RESUMABLE".equals(state.getStatus())) { if ("PAUSED_EMPTY".equals(state.getStatus()) || "PAUSED_RESUMABLE".equals(state.getStatus())) {
state.setStatus("PAUSED_RESUMABLE"); state.setStatus("PAUSED_RESUMABLE");
state.setResumeExpireAt(now + Duration.ofMinutes(getResumeWindowMinutes()).toMillis()); state.setResumeExpireAt(now + Duration.ofMinutes(getResumeWindowMinutes()).toMillis());
ensureTimeoutKey(meetingId, RedisKeys.realtimeMeetingResumeTimeoutKey(meetingId), Duration.ofMinutes(getResumeWindowMinutes())); sessionCache.saveResumeTimeout(meetingId, Duration.ofMinutes(getResumeWindowMinutes()));
redisTemplate.delete(RedisKeys.realtimeMeetingEmptyTimeoutKey(meetingId)); sessionCache.clearEmptyTimeout(meetingId);
} }
writeState(state); writeState(state);
@ -203,9 +202,8 @@ public class RealtimeMeetingSessionStateServiceImpl implements RealtimeMeetingSe
@Override @Override
public boolean markCompletingIfResumeExpired(Long meetingId) { public boolean markCompletingIfResumeExpired(Long meetingId) {
String lockKey = RedisKeys.realtimeMeetingTimeoutLockKey(meetingId); boolean locked = meetingLockCache.tryAcquireRealtimeTimeoutLock(meetingId, Duration.ofMinutes(1));
Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", Duration.ofMinutes(1)); if (!locked) {
if (Boolean.FALSE.equals(locked)) {
return false; return false;
} }
@ -227,7 +225,7 @@ public class RealtimeMeetingSessionStateServiceImpl implements RealtimeMeetingSe
state.setLastTranscriptAt(now); state.setLastTranscriptAt(now);
state.setResumeExpireAt(now + Duration.ofMinutes(getResumeWindowMinutes()).toMillis()); state.setResumeExpireAt(now + Duration.ofMinutes(getResumeWindowMinutes()).toMillis());
writeState(state); writeState(state);
ensureTimeoutKey(meetingId, RedisKeys.realtimeMeetingResumeTimeoutKey(meetingId), Duration.ofMinutes(getResumeWindowMinutes())); sessionCache.saveResumeTimeout(meetingId, Duration.ofMinutes(getResumeWindowMinutes()));
return false; return false;
} }
@ -236,7 +234,7 @@ public class RealtimeMeetingSessionStateServiceImpl implements RealtimeMeetingSe
writeState(state); writeState(state);
return true; return true;
} finally { } finally {
redisTemplate.delete(lockKey); meetingLockCache.releaseRealtimeTimeoutLock(meetingId);
} }
} }
@ -253,10 +251,7 @@ public class RealtimeMeetingSessionStateServiceImpl implements RealtimeMeetingSe
@Override @Override
public void clear(Long meetingId) { public void clear(Long meetingId) {
redisTemplate.delete(RedisKeys.realtimeMeetingSessionStateKey(meetingId)); sessionCache.clearAll(meetingId);
redisTemplate.delete(RedisKeys.realtimeMeetingResumeTimeoutKey(meetingId));
redisTemplate.delete(RedisKeys.realtimeMeetingEmptyTimeoutKey(meetingId));
redisTemplate.delete(RedisKeys.realtimeMeetingEventSeqKey(meetingId));
} }
private RealtimeMeetingSessionStatusVO pauseState(Long meetingId, RealtimeMeetingSessionState state) { private RealtimeMeetingSessionStatusVO pauseState(Long meetingId, RealtimeMeetingSessionState state) {
@ -272,16 +267,16 @@ public class RealtimeMeetingSessionStateServiceImpl implements RealtimeMeetingSe
if (transcriptCount > 0) { if (transcriptCount > 0) {
state.setStatus("PAUSED_RESUMABLE"); state.setStatus("PAUSED_RESUMABLE");
state.setResumeExpireAt(now + Duration.ofMinutes(getResumeWindowMinutes()).toMillis()); state.setResumeExpireAt(now + Duration.ofMinutes(getResumeWindowMinutes()).toMillis());
ensureTimeoutKey(meetingId, RedisKeys.realtimeMeetingResumeTimeoutKey(meetingId), Duration.ofMinutes(getResumeWindowMinutes())); sessionCache.saveResumeTimeout(meetingId, Duration.ofMinutes(getResumeWindowMinutes()));
redisTemplate.delete(RedisKeys.realtimeMeetingEmptyTimeoutKey(meetingId)); sessionCache.clearEmptyTimeout(meetingId);
if (state.getLastTranscriptAt() == null) { if (state.getLastTranscriptAt() == null) {
state.setLastTranscriptAt(now); state.setLastTranscriptAt(now);
} }
} else { } else {
state.setStatus("PAUSED_EMPTY"); state.setStatus("PAUSED_EMPTY");
state.setResumeExpireAt(null); state.setResumeExpireAt(null);
redisTemplate.delete(RedisKeys.realtimeMeetingResumeTimeoutKey(meetingId)); sessionCache.clearResumeTimeout(meetingId);
ensureTimeoutKey(meetingId, RedisKeys.realtimeMeetingEmptyTimeoutKey(meetingId), Duration.ofMinutes(getEmptySessionRetentionMinutes())); sessionCache.saveEmptyTimeout(meetingId, Duration.ofMinutes(getEmptySessionRetentionMinutes()));
} }
writeState(state); writeState(state);
@ -372,31 +367,11 @@ public class RealtimeMeetingSessionStateServiceImpl implements RealtimeMeetingSe
} }
private RealtimeMeetingSessionState readState(Long meetingId) { private RealtimeMeetingSessionState readState(Long meetingId) {
String raw = redisTemplate.opsForValue().get(RedisKeys.realtimeMeetingSessionStateKey(meetingId)); return sessionCache.getState(meetingId);
if (raw == null || raw.isBlank()) {
return null;
}
try {
return objectMapper.readValue(raw, RealtimeMeetingSessionState.class);
} catch (Exception ex) {
log.warn("Failed to read realtime meeting session state, meetingId={}", meetingId, ex);
return null;
}
} }
private void writeState(RealtimeMeetingSessionState state) { private void writeState(RealtimeMeetingSessionState state) {
try { sessionCache.saveState(state);
redisTemplate.opsForValue().set(
RedisKeys.realtimeMeetingSessionStateKey(state.getMeetingId()),
objectMapper.writeValueAsString(state)
);
} catch (Exception ex) {
throw new RuntimeException("写入实时会议会话状态失败", ex);
}
}
private void ensureTimeoutKey(Long meetingId, String key, Duration ttl) {
redisTemplate.opsForValue().set(key, String.valueOf(meetingId), ttl);
} }
private long getResumeWindowMinutes() { private long getResumeWindowMinutes() {

View File

@ -1,8 +1,6 @@
package com.imeeting.service.biz.impl; package com.imeeting.service.biz.impl;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.imeeting.common.MeetingConstants; import com.imeeting.common.MeetingConstants;
import com.imeeting.common.RedisKeys;
import com.imeeting.dto.biz.AiModelVO; import com.imeeting.dto.biz.AiModelVO;
import com.imeeting.dto.biz.RealtimeMeetingResumeConfig; import com.imeeting.dto.biz.RealtimeMeetingResumeConfig;
import com.imeeting.dto.biz.RealtimeSocketSessionData; import com.imeeting.dto.biz.RealtimeSocketSessionData;
@ -12,12 +10,11 @@ import com.imeeting.service.biz.AiModelService;
import com.imeeting.service.biz.MeetingAccessService; import com.imeeting.service.biz.MeetingAccessService;
import com.imeeting.service.biz.RealtimeMeetingSessionStateService; import com.imeeting.service.biz.RealtimeMeetingSessionStateService;
import com.imeeting.service.biz.RealtimeMeetingSocketSessionService; import com.imeeting.service.biz.RealtimeMeetingSocketSessionService;
import com.imeeting.support.redis.RealtimeMeetingSocketSessionCache;
import com.unisbase.security.LoginUser; import com.unisbase.security.LoginUser;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.time.Duration;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@ -27,11 +24,9 @@ import java.util.UUID;
@RequiredArgsConstructor @RequiredArgsConstructor
public class RealtimeMeetingSocketSessionServiceImpl implements RealtimeMeetingSocketSessionService { public class RealtimeMeetingSocketSessionServiceImpl implements RealtimeMeetingSocketSessionService {
private static final Duration SESSION_TTL = Duration.ofMinutes(10);
private static final String WS_PATH = "/ws/meeting/realtime"; private static final String WS_PATH = "/ws/meeting/realtime";
private final ObjectMapper objectMapper; private final RealtimeMeetingSocketSessionCache socketSessionCache;
private final StringRedisTemplate redisTemplate;
private final MeetingAccessService meetingAccessService; private final MeetingAccessService meetingAccessService;
private final AiModelService aiModelService; private final AiModelService aiModelService;
private final RealtimeMeetingSessionStateService realtimeMeetingSessionStateService; private final RealtimeMeetingSessionStateService realtimeMeetingSessionStateService;
@ -90,20 +85,12 @@ public class RealtimeMeetingSocketSessionServiceImpl implements RealtimeMeetingS
sessionData.setTargetWsUrl(targetWsUrl); sessionData.setTargetWsUrl(targetWsUrl);
String sessionToken = UUID.randomUUID().toString().replace("-", ""); String sessionToken = UUID.randomUUID().toString().replace("-", "");
try { socketSessionCache.save(sessionToken, sessionData);
redisTemplate.opsForValue().set(
RedisKeys.realtimeMeetingSocketSessionKey(sessionToken),
objectMapper.writeValueAsString(sessionData),
SESSION_TTL
);
} catch (Exception ex) {
throw new RuntimeException("创建实时 Socket 会话失败", ex);
}
RealtimeSocketSessionVO vo = new RealtimeSocketSessionVO(); RealtimeSocketSessionVO vo = new RealtimeSocketSessionVO();
vo.setSessionToken(sessionToken); vo.setSessionToken(sessionToken);
vo.setPath(WS_PATH); vo.setPath(WS_PATH);
vo.setExpiresInSeconds(SESSION_TTL.toSeconds()); vo.setExpiresInSeconds(socketSessionCache.getSessionTtlSeconds());
vo.setStartMessage(buildStartMessage( vo.setStartMessage(buildStartMessage(
asrModel, asrModel,
meetingId, meetingId,
@ -121,18 +108,7 @@ public class RealtimeMeetingSocketSessionServiceImpl implements RealtimeMeetingS
@Override @Override
public RealtimeSocketSessionData getSessionData(String sessionToken) { public RealtimeSocketSessionData getSessionData(String sessionToken) {
if (sessionToken == null || sessionToken.isBlank()) { return socketSessionCache.get(sessionToken);
return null;
}
String raw = redisTemplate.opsForValue().get(RedisKeys.realtimeMeetingSocketSessionKey(sessionToken));
if (raw == null || raw.isBlank()) {
return null;
}
try {
return objectMapper.readValue(raw, RealtimeSocketSessionData.class);
} catch (Exception ex) {
throw new RuntimeException("读取实时 Socket 会话失败", ex);
}
} }
private String resolveWsUrl(AiModelVO model) { private String resolveWsUrl(AiModelVO model) {

View File

@ -1,149 +0,0 @@
package com.imeeting.service.biz.impl;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.imeeting.common.MeetingProgressStage;
import com.imeeting.common.RedisKeys;
import com.imeeting.dto.biz.MeetingProgressSnapshot;
import com.imeeting.entity.biz.AiTask;
import com.imeeting.service.biz.MeetingProgressService;
import org.springframework.data.redis.core.StringRedisTemplate;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
public class RedisOnlyMeetingProgressServiceAdapter implements MeetingProgressService {
private final StringRedisTemplate redisTemplate;
private final ObjectMapper objectMapper;
public RedisOnlyMeetingProgressServiceAdapter(StringRedisTemplate redisTemplate, ObjectMapper objectMapper) {
this.redisTemplate = redisTemplate;
this.objectMapper = objectMapper == null ? new ObjectMapper() : objectMapper;
}
@Override
public void clear(Long meetingId) {
if (redisTemplate == null || meetingId == null) {
return;
}
redisTemplate.delete(RedisKeys.meetingProgressKey(meetingId));
}
@Override
public Map<String, Object> getProgressMap(Long meetingId) {
MeetingProgressSnapshot snapshot = readSnapshot(meetingId);
if (snapshot == null) {
return Map.of("percent", 0, "message", "Waiting...");
}
return objectMapper.convertValue(snapshot, Map.class);
}
@Override
public Map<Long, Map<String, Object>> getProgressMaps(List<Long> meetingIds) {
Map<Long, Map<String, Object>> result = new LinkedHashMap<>();
if (meetingIds == null || meetingIds.isEmpty()) {
return result;
}
for (Long meetingId : meetingIds) {
if (meetingId == null) {
continue;
}
result.put(meetingId, getProgressMap(meetingId));
}
return result;
}
@Override
public Integer resolvePercent(Long meetingId) {
MeetingProgressSnapshot snapshot = readSnapshot(meetingId);
return snapshot == null ? null : snapshot.getPercent();
}
@Override
public void markQueued(Long meetingId, AiTask task, Integer meetingStatus, String message) {
writeSnapshot(buildSnapshot(meetingId, task, meetingStatus, MeetingProgressStage.QUEUED, 0, message, 0));
}
@Override
public void markQueuedAfterCommitOrNow(Long meetingId, AiTask task, Integer meetingStatus, String message) {
markQueued(meetingId, task, meetingStatus, message);
}
@Override
public void markStage(Long meetingId, AiTask task, Integer meetingStatus, MeetingProgressStage stage, int percent, String message, int eta) {
writeSnapshot(buildSnapshot(meetingId, task, meetingStatus, stage, percent, message, eta));
}
@Override
public void markStageAfterCommitOrNow(Long meetingId, AiTask task, Integer meetingStatus, MeetingProgressStage stage, int percent, String message, int eta) {
markStage(meetingId, task, meetingStatus, stage, percent, message, eta);
}
@Override
public void syncFromDatabase(Long meetingId) {
// No-op for constructor compatibility in tests.
}
@Override
public void writeSnapshot(MeetingProgressSnapshot snapshot) {
if (redisTemplate == null || snapshot == null || snapshot.getMeetingId() == null) {
return;
}
try {
redisTemplate.opsForValue().set(
RedisKeys.meetingProgressKey(snapshot.getMeetingId()),
objectMapper.writeValueAsString(snapshot),
1,
TimeUnit.HOURS
);
} catch (Exception ignored) {
// Compatibility adapter keeps test setup lightweight.
}
}
private MeetingProgressSnapshot readSnapshot(Long meetingId) {
if (redisTemplate == null || meetingId == null) {
return null;
}
try {
String raw = redisTemplate.opsForValue().get(RedisKeys.meetingProgressKey(meetingId));
if (raw == null || raw.isBlank()) {
return null;
}
return objectMapper.readValue(raw, MeetingProgressSnapshot.class);
} catch (Exception ignored) {
return null;
}
}
private MeetingProgressSnapshot buildSnapshot(Long meetingId,
AiTask task,
Integer meetingStatus,
MeetingProgressStage stage,
int percent,
String message,
int eta) {
String resolvedMessage = message;
if (stage == MeetingProgressStage.QUEUED && (resolvedMessage == null || resolvedMessage.isBlank())) {
resolvedMessage = "已进入 ASR 队列,等待执行";
}
return MeetingProgressSnapshot.builder()
.meetingId(meetingId)
.taskId(task == null ? null : task.getId())
.taskType(task == null ? null : task.getTaskType())
.taskStatus(task == null ? null : task.getStatus())
.meetingStatus(meetingStatus)
.stage(stage.getCode())
.stageOrder(stage.getOrder())
.percent(percent)
.message(resolvedMessage)
.eta(eta)
.queuedAt(task == null ? null : task.getQueuedAt())
.startedAt(task == null ? null : task.getStartedAt())
.completedAt(task == null ? null : task.getCompletedAt())
.updateAt(System.currentTimeMillis())
.build();
}
}

View File

@ -25,14 +25,12 @@ import com.imeeting.service.biz.MeetingService;
import com.imeeting.service.biz.MeetingTranscriptChapterService; import com.imeeting.service.biz.MeetingTranscriptChapterService;
import com.imeeting.service.biz.MeetingTranscriptFileService; import com.imeeting.service.biz.MeetingTranscriptFileService;
import com.imeeting.service.biz.PromptTemplateService; import com.imeeting.service.biz.PromptTemplateService;
import com.imeeting.service.biz.impl.RedisOnlyMeetingProgressServiceAdapter;
import com.unisbase.dto.PageResult; import com.unisbase.dto.PageResult;
import com.unisbase.entity.SysUser; import com.unisbase.entity.SysUser;
import com.unisbase.mapper.SysUserMapper; import com.unisbase.mapper.SysUserMapper;
import com.unisbase.security.LoginUser; import com.unisbase.security.LoginUser;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@ -87,30 +85,6 @@ public class MeetingMcpToolService {
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
} }
public MeetingMcpToolService(MeetingQueryService meetingQueryService,
MeetingAccessService meetingAccessService,
MeetingService meetingService,
AiTaskService aiTaskService,
PromptTemplateService promptTemplateService,
MeetingTranscriptFileService meetingTranscriptFileService,
MeetingTranscriptChapterService meetingTranscriptChapterService,
SysUserMapper sysUserMapper,
StringRedisTemplate redisTemplate,
ObjectMapper objectMapper) {
this(
meetingQueryService,
meetingAccessService,
meetingService,
aiTaskService,
promptTemplateService,
meetingTranscriptFileService,
meetingTranscriptChapterService,
sysUserMapper,
new RedisOnlyMeetingProgressServiceAdapter(redisTemplate, objectMapper),
objectMapper
);
}
@Value("${unisbase.app.server-base-url:}") @Value("${unisbase.app.server-base-url:}")
private String serverBaseUrl; private String serverBaseUrl;

View File

@ -0,0 +1,119 @@
package com.imeeting.support;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.Collection;
@Component
@Slf4j
@RequiredArgsConstructor
public class RedisSupport {
private final StringRedisTemplate redisTemplate;
private final ObjectMapper objectMapper;
public String getStringQuietly(String key) {
try {
return redisTemplate.opsForValue().get(key);
} catch (Exception ex) {
log.warn("读取 Redis 字符串失败, key={}", key, ex);
return null;
}
}
public <T> T getJsonQuietly(String key, Class<T> type) {
String raw = getStringQuietly(key);
if (raw == null || raw.isBlank()) {
return null;
}
try {
return objectMapper.readValue(raw, type);
} catch (Exception ex) {
log.warn("读取 Redis JSON 失败, key={}, type={}", key, type == null ? null : type.getSimpleName(), ex);
return null;
}
}
public void setString(String key, String value) {
try {
redisTemplate.opsForValue().set(key, value);
} catch (Exception ex) {
throw new RuntimeException("写入 Redis 字符串失败, key=" + key, ex);
}
}
public void setString(String key, String value, Duration ttl) {
try {
redisTemplate.opsForValue().set(key, value, ttl);
} catch (Exception ex) {
throw new RuntimeException("写入 Redis 字符串失败, key=" + key, ex);
}
}
public void setJson(String key, Object value) {
setString(key, writeJson(value));
}
public void setJson(String key, Object value, Duration ttl) {
setString(key, writeJson(value), ttl);
}
public boolean setIfAbsentQuietly(String key, String value, Duration ttl) {
try {
Boolean success = redisTemplate.opsForValue().setIfAbsent(key, value, ttl);
return Boolean.TRUE.equals(success);
} catch (Exception ex) {
log.warn("写入 Redis 锁失败, key={}", key, ex);
return false;
}
}
public boolean setIfAbsentOrThrow(String key, String value, Duration ttl) {
try {
Boolean success = redisTemplate.opsForValue().setIfAbsent(key, value, ttl);
return Boolean.TRUE.equals(success);
} catch (Exception ex) {
throw new RuntimeException("写入 Redis 锁失败, key=" + key, ex);
}
}
public void deleteQuietly(String key) {
try {
redisTemplate.delete(key);
} catch (Exception ex) {
log.warn("删除 Redis Key 失败, key={}", key, ex);
}
}
public void deleteQuietly(Collection<String> keys) {
try {
redisTemplate.delete(keys);
} catch (Exception ex) {
log.warn("批量删除 Redis Key 失败, keys={}", keys, ex);
}
}
public void removeFromSetQuietly(String key, String... members) {
if (members == null || members.length == 0) {
return;
}
try {
redisTemplate.opsForSet().remove(key, (Object[]) members);
} catch (Exception ex) {
log.warn("从 Redis Set 删除成员失败, key={}", key, ex);
}
}
private String writeJson(Object value) {
try {
return objectMapper.writeValueAsString(value);
} catch (Exception ex) {
throw new RuntimeException("序列化 Redis JSON 失败", ex);
}
}
}

View File

@ -1,74 +0,0 @@
package com.imeeting.support;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.util.Collection;
import java.util.concurrent.TimeUnit;
@Component
@Slf4j
@RequiredArgsConstructor
public class RedisValueSupport {
private final StringRedisTemplate redisTemplate;
private final ObjectMapper objectMapper;
public <T> T getJson(String key, Class<T> type) {
String raw = getString(key);
if (raw == null || raw.isBlank()) {
return null;
}
try {
return objectMapper.readValue(raw, type);
} catch (Exception ex) {
log.warn("Failed to parse redis json, key={}", key, ex);
return null;
}
}
public String getString(String key) {
try {
return redisTemplate.opsForValue().get(key);
} catch (Exception ex) {
log.warn("Failed to get redis value, key={}", key, ex);
return null;
}
}
public void setJson(String key, Object value, long ttl, TimeUnit unit) {
try {
redisTemplate.opsForValue().set(key, objectMapper.writeValueAsString(value), ttl, unit);
} catch (Exception ex) {
log.warn("Failed to write redis json, key={}", key, ex);
}
}
public Boolean setIfAbsent(String key, String value, long ttl, TimeUnit unit) {
try {
return redisTemplate.opsForValue().setIfAbsent(key, value, ttl, unit);
} catch (Exception ex) {
log.warn("Failed to acquire redis lock, key={}", key, ex);
return false;
}
}
public void delete(String key) {
try {
redisTemplate.delete(key);
} catch (Exception ex) {
log.warn("Failed to delete redis key, key={}", key, ex);
}
}
public void delete(Collection<String> keys) {
try {
redisTemplate.delete(keys);
} catch (Exception ex) {
log.warn("Failed to delete redis keys, keys={}", keys, ex);
}
}
}

View File

@ -0,0 +1,36 @@
package com.imeeting.support.redis;
import com.imeeting.common.RedisKeys;
import com.imeeting.dto.android.AndroidChunkUploadSessionState;
import com.imeeting.support.RedisSupport;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.time.Duration;
@Component
@RequiredArgsConstructor
public class AndroidChunkUploadSessionCache {
private static final Duration SESSION_TTL = Duration.ofHours(6);
private final RedisSupport redisSupport;
public AndroidChunkUploadSessionState get(String uploadSessionId) {
if (uploadSessionId == null || uploadSessionId.isBlank()) {
return null;
}
return redisSupport.getJsonQuietly(RedisKeys.androidChunkUploadSessionKey(uploadSessionId), AndroidChunkUploadSessionState.class);
}
public void save(String uploadSessionId, AndroidChunkUploadSessionState state) {
redisSupport.setJson(RedisKeys.androidChunkUploadSessionKey(uploadSessionId), state, SESSION_TTL);
}
public void clear(String uploadSessionId) {
if (uploadSessionId == null || uploadSessionId.isBlank()) {
return;
}
redisSupport.deleteQuietly(RedisKeys.androidChunkUploadSessionKey(uploadSessionId));
}
}

View File

@ -0,0 +1,61 @@
package com.imeeting.support.redis;
import com.imeeting.common.RedisKeys;
import com.imeeting.dto.android.AndroidDeviceSessionState;
import com.imeeting.support.RedisSupport;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.List;
@Component
@RequiredArgsConstructor
public class AndroidDeviceSessionCache {
private final RedisSupport redisSupport;
public AndroidDeviceSessionState getByConnectionId(String connectionId) {
if (connectionId == null || connectionId.isBlank()) {
return null;
}
return redisSupport.getJsonQuietly(RedisKeys.androidDeviceConnectionKey(connectionId), AndroidDeviceSessionState.class);
}
public AndroidDeviceSessionState getByDeviceId(String deviceId) {
if (deviceId == null || deviceId.isBlank()) {
return null;
}
return redisSupport.getJsonQuietly(RedisKeys.androidDeviceOnlineKey(deviceId), AndroidDeviceSessionState.class);
}
public String getActiveConnectionId(String deviceId) {
if (deviceId == null || deviceId.isBlank()) {
return null;
}
String value = redisSupport.getStringQuietly(RedisKeys.androidDeviceActiveConnectionKey(deviceId));
return value == null || value.isBlank() ? null : value;
}
public void saveTopics(String deviceId, List<String> topics) {
redisSupport.setJson(RedisKeys.androidDeviceTopicsKey(deviceId), topics == null ? List.of() : topics);
}
public void saveState(AndroidDeviceSessionState state, Duration ttl) {
redisSupport.setJson(RedisKeys.androidDeviceOnlineKey(state.getDeviceId()), state, ttl);
redisSupport.setString(RedisKeys.androidDeviceActiveConnectionKey(state.getDeviceId()), state.getConnectionId(), ttl);
redisSupport.setJson(RedisKeys.androidDeviceConnectionKey(state.getConnectionId()), state, ttl);
}
public void deleteConnection(String connectionId) {
redisSupport.deleteQuietly(RedisKeys.androidDeviceConnectionKey(connectionId));
}
public void deleteActiveConnection(String deviceId) {
redisSupport.deleteQuietly(RedisKeys.androidDeviceActiveConnectionKey(deviceId));
}
public void deleteOnlineState(String deviceId) {
redisSupport.deleteQuietly(RedisKeys.androidDeviceOnlineKey(deviceId));
}
}

View File

@ -0,0 +1,39 @@
package com.imeeting.support.redis;
import com.imeeting.common.RedisKeys;
import com.imeeting.dto.android.AndroidPendingMeetingDraft;
import com.imeeting.support.RedisSupport;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.time.Duration;
@Component
@RequiredArgsConstructor
public class AndroidPendingMeetingDraftCache {
private static final Duration DRAFT_TTL = Duration.ofHours(24);
private final RedisSupport redisSupport;
public void save(AndroidPendingMeetingDraft draft) {
if (draft == null || draft.getMeetingId() == null) {
return;
}
redisSupport.setJson(RedisKeys.androidPendingMeetingDraftKey(draft.getMeetingId()), draft, DRAFT_TTL);
}
public AndroidPendingMeetingDraft get(Long meetingId) {
if (meetingId == null) {
return null;
}
return redisSupport.getJsonQuietly(RedisKeys.androidPendingMeetingDraftKey(meetingId), AndroidPendingMeetingDraft.class);
}
public void clear(Long meetingId) {
if (meetingId == null) {
return;
}
redisSupport.deleteQuietly(RedisKeys.androidPendingMeetingDraftKey(meetingId));
}
}

View File

@ -0,0 +1,34 @@
package com.imeeting.support.redis;
import com.imeeting.common.RedisKeys;
import com.imeeting.dto.android.AndroidPublicMeetingSessionState;
import com.imeeting.support.RedisSupport;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.time.Duration;
@Component
@RequiredArgsConstructor
public class AndroidPublicMeetingSessionCache {
private final RedisSupport redisSupport;
public void save(String sessionId, AndroidPublicMeetingSessionState state, Duration ttl) {
redisSupport.setJson(RedisKeys.publicMeetingSessionKey(sessionId), state, ttl);
}
public AndroidPublicMeetingSessionState get(String sessionId) {
if (sessionId == null || sessionId.isBlank()) {
return null;
}
return redisSupport.getJsonQuietly(RedisKeys.publicMeetingSessionKey(sessionId), AndroidPublicMeetingSessionState.class);
}
public void clear(String sessionId) {
if (sessionId == null || sessionId.isBlank()) {
return;
}
redisSupport.deleteQuietly(RedisKeys.publicMeetingSessionKey(sessionId));
}
}

View File

@ -0,0 +1,32 @@
package com.imeeting.support.redis;
import com.imeeting.common.RedisKeys;
import com.imeeting.support.RedisSupport;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.util.List;
@Component
@RequiredArgsConstructor
public class MeetingAsrPermitCache {
private final RedisSupport redisSupport;
public void clearRecoveryState(Long meetingId) {
redisSupport.deleteQuietly(List.of(
RedisKeys.meetingAsrPermitSyncLockKey(),
RedisKeys.meetingAsrRefillLockKey()
));
if (meetingId != null) {
redisSupport.removeFromSetQuietly(RedisKeys.meetingAsrPermitSetKey(), String.valueOf(meetingId));
}
}
public void removePermit(Long meetingId) {
if (meetingId == null) {
return;
}
redisSupport.removeFromSetQuietly(RedisKeys.meetingAsrPermitSetKey(), String.valueOf(meetingId));
}
}

View File

@ -0,0 +1,55 @@
package com.imeeting.support.redis;
import com.imeeting.common.RedisKeys;
import com.imeeting.support.RedisSupport;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.time.Duration;
@Component
@RequiredArgsConstructor
public class MeetingLockCache {
private final RedisSupport redisSupport;
public boolean tryAcquirePollingLock(Long meetingId, Duration ttl) {
return redisSupport.setIfAbsentOrThrow(RedisKeys.meetingPollingLockKey(meetingId), "locked", ttl);
}
public boolean tryAcquireSummaryLock(Long meetingId, Duration ttl) {
return redisSupport.setIfAbsentOrThrow(RedisKeys.meetingSummaryLockKey(meetingId), "locked", ttl);
}
public boolean tryAcquireAsrScheduleLock(Duration ttl) {
return redisSupport.setIfAbsentOrThrow(RedisKeys.meetingAsrScheduleLockKey(), "locked", ttl);
}
public boolean tryAcquireRealtimeTimeoutLock(Long meetingId, Duration ttl) {
return redisSupport.setIfAbsentOrThrow(RedisKeys.realtimeMeetingTimeoutLockKey(meetingId), "1", ttl);
}
public void releasePollingLock(Long meetingId) {
redisSupport.deleteQuietly(RedisKeys.meetingPollingLockKey(meetingId));
}
public void releaseSummaryLock(Long meetingId) {
redisSupport.deleteQuietly(RedisKeys.meetingSummaryLockKey(meetingId));
}
public void releaseAsrScheduleLock() {
redisSupport.deleteQuietly(RedisKeys.meetingAsrScheduleLockKey());
}
public void releaseRealtimeTimeoutLock(Long meetingId) {
redisSupport.deleteQuietly(RedisKeys.realtimeMeetingTimeoutLockKey(meetingId));
}
public void clearDispatchLocks(Long meetingId) {
if (meetingId == null) {
return;
}
redisSupport.deleteQuietly(RedisKeys.meetingPollingLockKey(meetingId));
redisSupport.deleteQuietly(RedisKeys.meetingSummaryLockKey(meetingId));
}
}

View File

@ -0,0 +1,45 @@
package com.imeeting.support.redis;
import com.imeeting.common.RedisKeys;
import com.imeeting.dto.biz.MeetingProgressSnapshot;
import com.imeeting.support.RedisSupport;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.time.Duration;
@Component
@Slf4j
@RequiredArgsConstructor
public class MeetingProgressCache {
private static final Duration PROGRESS_TTL = Duration.ofHours(1);
private final RedisSupport redisSupport;
public MeetingProgressSnapshot getSnapshot(Long meetingId) {
if (meetingId == null) {
return null;
}
return redisSupport.getJsonQuietly(RedisKeys.meetingProgressKey(meetingId), MeetingProgressSnapshot.class);
}
public void saveSnapshot(MeetingProgressSnapshot snapshot) {
if (snapshot == null || snapshot.getMeetingId() == null) {
return;
}
try {
redisSupport.setJson(RedisKeys.meetingProgressKey(snapshot.getMeetingId()), snapshot, PROGRESS_TTL);
} catch (Exception ex) {
log.warn("写入会议进度缓存失败, meetingId={}", snapshot.getMeetingId(), ex);
}
}
public void clear(Long meetingId) {
if (meetingId == null) {
return;
}
redisSupport.deleteQuietly(RedisKeys.meetingProgressKey(meetingId));
}
}

View File

@ -0,0 +1,56 @@
package com.imeeting.support.redis;
import com.imeeting.common.RedisKeys;
import com.imeeting.dto.biz.RealtimeMeetingSessionState;
import com.imeeting.support.RedisSupport;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.time.Duration;
@Component
@RequiredArgsConstructor
public class RealtimeMeetingSessionCache {
private final RedisSupport redisSupport;
public RealtimeMeetingSessionState getState(Long meetingId) {
if (meetingId == null) {
return null;
}
return redisSupport.getJsonQuietly(RedisKeys.realtimeMeetingSessionStateKey(meetingId), RealtimeMeetingSessionState.class);
}
public void saveState(RealtimeMeetingSessionState state) {
if (state == null || state.getMeetingId() == null) {
return;
}
redisSupport.setJson(RedisKeys.realtimeMeetingSessionStateKey(state.getMeetingId()), state);
}
public void saveResumeTimeout(Long meetingId, Duration ttl) {
redisSupport.setString(RedisKeys.realtimeMeetingResumeTimeoutKey(meetingId), String.valueOf(meetingId), ttl);
}
public void saveEmptyTimeout(Long meetingId, Duration ttl) {
redisSupport.setString(RedisKeys.realtimeMeetingEmptyTimeoutKey(meetingId), String.valueOf(meetingId), ttl);
}
public void clearResumeTimeout(Long meetingId) {
redisSupport.deleteQuietly(RedisKeys.realtimeMeetingResumeTimeoutKey(meetingId));
}
public void clearEmptyTimeout(Long meetingId) {
redisSupport.deleteQuietly(RedisKeys.realtimeMeetingEmptyTimeoutKey(meetingId));
}
public void clearAll(Long meetingId) {
if (meetingId == null) {
return;
}
redisSupport.deleteQuietly(RedisKeys.realtimeMeetingSessionStateKey(meetingId));
redisSupport.deleteQuietly(RedisKeys.realtimeMeetingResumeTimeoutKey(meetingId));
redisSupport.deleteQuietly(RedisKeys.realtimeMeetingEmptyTimeoutKey(meetingId));
redisSupport.deleteQuietly(RedisKeys.realtimeMeetingEventSeqKey(meetingId));
}
}

View File

@ -0,0 +1,33 @@
package com.imeeting.support.redis;
import com.imeeting.common.RedisKeys;
import com.imeeting.dto.biz.RealtimeSocketSessionData;
import com.imeeting.support.RedisSupport;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.time.Duration;
@Component
@RequiredArgsConstructor
public class RealtimeMeetingSocketSessionCache {
private static final Duration SESSION_TTL = Duration.ofMinutes(10);
private final RedisSupport redisSupport;
public void save(String sessionToken, RealtimeSocketSessionData sessionData) {
redisSupport.setJson(RedisKeys.realtimeMeetingSocketSessionKey(sessionToken), sessionData, SESSION_TTL);
}
public RealtimeSocketSessionData get(String sessionToken) {
if (sessionToken == null || sessionToken.isBlank()) {
return null;
}
return redisSupport.getJsonQuietly(RedisKeys.realtimeMeetingSocketSessionKey(sessionToken), RealtimeSocketSessionData.class);
}
public long getSessionTtlSeconds() {
return SESSION_TTL.toSeconds();
}
}

View File

@ -0,0 +1,45 @@
package com.imeeting.task;
import com.imeeting.entity.biz.AndroidPushMessage;
import com.imeeting.grpc.push.PushMessage;
import com.imeeting.service.android.AndroidGatewayPushService;
import com.imeeting.service.android.AndroidPushMessageService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.List;
@Slf4j
@Component
@RequiredArgsConstructor
public class AndroidPushMessageRetryTask {
private final AndroidPushMessageService androidPushMessageService;
private final AndroidGatewayPushService androidGatewayPushService;
@Scheduled(fixedDelayString = "${imeeting.android.push.retry-interval-ms:15000}")
public void retryPendingMessages() {
List<AndroidPushMessage> pendingMessages = androidPushMessageService.listPendingMeetingPushMessages();
for (AndroidPushMessage message : pendingMessages) {
if (message.getExpireAt() != null && message.getExpireAt().isBefore(LocalDateTime.now())) {
androidPushMessageService.markExpired(message.getId());
continue;
}
PushMessage pushMessage = PushMessage.newBuilder()
.setMessageId(message.getMessageId())
.setTimestamp(System.currentTimeMillis())
.setType(message.getMessageType())
.setTitle("待开始会议")
.setContent(message.getPayload() == null ? "" : message.getPayload())
.setNeedAck(true)
.build();
int pushed = androidGatewayPushService.pushToDevice(message.getDeviceCode(), pushMessage);
if (pushed > 0) {
androidPushMessageService.markPushed(message.getId());
log.info("Retried android push message, messageId={}, deviceCode={}, pushCountIncreased=true", message.getMessageId(), message.getDeviceCode());
}
}
}
}

View File

@ -10,6 +10,8 @@ export interface MeetingCreateConfig {
offlineEnabled: boolean; offlineEnabled: boolean;
realtimeEnabled: boolean; realtimeEnabled: boolean;
offlineAudioMaxSizeMb: number; offlineAudioMaxSizeMb: number;
chunkUploadEnabled?: boolean;
chunkDurationSeconds?: number;
} }
export interface MeetingVO { export interface MeetingVO {
@ -28,6 +30,8 @@ export interface MeetingVO {
playbackAudioUrl?: string; playbackAudioUrl?: string;
meetingType?: "OFFLINE" | "REALTIME"; meetingType?: "OFFLINE" | "REALTIME";
meetingSource?: "WEB" | "ANDROID"; meetingSource?: "WEB" | "ANDROID";
sourceDeviceCode?: string;
sourceDeviceMode?: "PUBLIC" | "PRIVATE";
summaryDetailLevel?: SummaryDetailLevel; summaryDetailLevel?: SummaryDetailLevel;
audioSaveStatus?: "NONE" | "SUCCESS" | "FAILED"; audioSaveStatus?: "NONE" | "SUCCESS" | "FAILED";
audioSaveMessage?: string; audioSaveMessage?: string;
@ -95,6 +99,26 @@ export interface CreateMeetingCommand {
hotWords?: string[]; hotWords?: string[];
} }
export interface PublicDeviceMeetingCreateCommand {
title: string;
meetingTime: string;
participants: string;
tags: string;
hostUserId?: number;
hostName?: string;
asrModelId: number;
summaryModelId: number;
chapterModelId?: number;
promptId: number;
hotWordGroupId?: number;
userPrompt?: string;
summaryDetailLevel?: SummaryDetailLevel;
useSpkId?: number;
enableTextRefine?: boolean;
hotWords?: string[];
accessPassword?: string;
}
export type MeetingDTO = CreateMeetingCommand; export type MeetingDTO = CreateMeetingCommand;
export interface CreateRealtimeMeetingCommand { export interface CreateRealtimeMeetingCommand {
@ -160,6 +184,13 @@ export const createMeeting = (data: CreateMeetingCommand) => {
); );
}; };
export const createPublicDeviceMeetingBySession = (sessionId: string, data: PublicDeviceMeetingCreateCommand) => {
return http.post<{ code: string; data: MeetingVO; msg: string }>(
`/api/biz/public-device-meetings/sessions/${sessionId}/create`,
data
);
};
export interface RealtimeTranscriptItemDTO { export interface RealtimeTranscriptItemDTO {
speakerId?: string; speakerId?: string;
speakerName?: string; speakerName?: string;

View File

@ -101,7 +101,9 @@ export default function Login() {
} }
message.success(t("common.success")); message.success(t("common.success"));
window.location.href = "/"; const searchParams = new URLSearchParams(window.location.search);
const redirect = searchParams.get("redirect");
window.location.href = redirect || "/";
} catch { } catch {
if (captchaEnabled) { if (captchaEnabled) {
await loadCaptcha(); await loadCaptcha();

View File

@ -239,8 +239,9 @@ const MeetingCardItem: React.FC<{
progress: MeetingProgress | null; progress: MeetingProgress | null;
onOpenMeeting: (meeting: MeetingVO) => void; onOpenMeeting: (meeting: MeetingVO) => void;
onRetrySchedule: (meeting: MeetingVO) => void; onRetrySchedule: (meeting: MeetingVO) => void;
onDelete: (id: number) => void;
retrying: boolean; retrying: boolean;
}> = ({ item, config, progress, onOpenMeeting, onRetrySchedule, retrying }) => { }> = ({ item, config, progress, onOpenMeeting, onRetrySchedule, onDelete, retrying }) => {
const effectiveStatus = getEffectiveStatus(item, progress); const effectiveStatus = getEffectiveStatus(item, progress);
const isProcessing = shouldTrackGenerationProgress(item); const isProcessing = shouldTrackGenerationProgress(item);
const isPaused = effectiveStatus === PAUSED_DISPLAY_STATUS; const isPaused = effectiveStatus === PAUSED_DISPLAY_STATUS;
@ -310,7 +311,7 @@ const MeetingCardItem: React.FC<{
<Popconfirm <Popconfirm
title="确定删除会议吗?" title="确定删除会议吗?"
description="删除后将无法找回该会议记录。" description="删除后将无法找回该会议记录。"
onConfirm={() => deleteMeeting(item.id).then(fetchData)} onConfirm={() => onDelete(item.id)}
okText="删除" okText="删除"
cancelText="取消" cancelText="取消"
okButtonProps={{ danger: true }} okButtonProps={{ danger: true }}
@ -905,6 +906,7 @@ const Meetings: React.FC = () => {
progress={progress} progress={progress}
onOpenMeeting={handleOpenMeeting} onOpenMeeting={handleOpenMeeting}
onRetrySchedule={(meeting) => { void handleRetrySchedule(meeting); }} onRetrySchedule={(meeting) => { void handleRetrySchedule(meeting); }}
onDelete={(id) => { deleteMeeting(id).then(() => { message.success('删除成功'); fetchData(); }) }}
retrying={!!retryingMeetingIds[item.id]} retrying={!!retryingMeetingIds[item.id]}
/> />
); );

View File

@ -1,5 +1,5 @@
import { Suspense, lazy } from "react"; import { Suspense, lazy } from "react";
import { Navigate, Route, Routes } from "react-router-dom"; import { Navigate, Route, Routes, useLocation } from "react-router-dom";
import AppLayout from "@/layouts/AppLayout"; import AppLayout from "@/layouts/AppLayout";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { menuRoutes,extraRoutes } from "./routes"; import { menuRoutes,extraRoutes } from "./routes";
@ -7,6 +7,7 @@ import { menuRoutes,extraRoutes } from "./routes";
const Login = lazy(() => import("@/pages/auth/login")); const Login = lazy(() => import("@/pages/auth/login"));
const ResetPassword = lazy(() => import("@/pages/auth/reset-password")); const ResetPassword = lazy(() => import("@/pages/auth/reset-password"));
const MeetingPreview = lazy(() => import("@/pages/business/MeetingPreview")); const MeetingPreview = lazy(() => import("@/pages/business/MeetingPreview"));
const PublicDeviceMeetingCreate = lazy(() => import("@/pages/business/PublicDeviceMeetingCreate"));
function RouteFallback() { function RouteFallback() {
let platformName = "iMeeting"; let platformName = "iMeeting";
@ -50,9 +51,11 @@ function RouteFallback() {
function RequireAuth({ children }: { children: JSX.Element }) { function RequireAuth({ children }: { children: JSX.Element }) {
const { isAuthed, profile } = useAuth(); const { isAuthed, profile } = useAuth();
const location = useLocation();
if (!isAuthed) { if (!isAuthed) {
return <Navigate to="/login" replace />; const redirect = encodeURIComponent(`${location.pathname}${location.search}`);
return <Navigate to={`/login?redirect=${redirect}`} replace />;
} }
if (profile?.pwdResetRequired === 1) { if (profile?.pwdResetRequired === 1) {
@ -72,6 +75,14 @@ export default function AppRoutes() {
path="/meetings/:id/preview" path="/meetings/:id/preview"
element={<MeetingPreview />} element={<MeetingPreview />}
/> />
<Route
path="/public-device-meetings/:sessionId/create"
element={
<RequireAuth>
<PublicDeviceMeetingCreate />
</RequireAuth>
}
/>
<Route <Route
path="/" path="/"
element={ element={