diff --git a/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingController.java b/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingController.java index 70199f4..9e9fa9c 100644 --- a/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingController.java +++ b/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingController.java @@ -38,7 +38,9 @@ import com.unisbase.annotation.Anonymous; import com.unisbase.common.ApiResponse; import com.unisbase.common.annotation.Log; import com.unisbase.dto.PageResult; +import com.unisbase.entity.SysTenant; import com.unisbase.entity.SysUser; +import com.unisbase.mapper.SysTenantMapper; import com.unisbase.mapper.SysUserMapper; import com.unisbase.security.LoginUser; import com.unisbase.service.SysDictItemService; @@ -90,6 +92,7 @@ public class AndroidMeetingController { private static final String STAGE_AUDIO_TRANSCRIPTION = "audio_transcription"; private static final String STAGE_SUMMARY_GENERATION = "summary_generation"; private static final String STAGE_COMPLETED = "completed"; + private static final String TENANT_CODE_HEADER = "X-Tenant-Code"; @Value("${imeeting.h5.base-url:}") private String h5BaseUrl; @@ -103,6 +106,7 @@ public class AndroidMeetingController { private final MeetingService meetingService; private final AiTaskService aiTaskService; private final PromptTemplateService promptTemplateService; + private final SysTenantMapper sysTenantMapper; private final SysUserMapper sysUserMapper; private final AiModelService aiModelService; private final SysDictItemService dictItemService; @@ -120,6 +124,7 @@ public class AndroidMeetingController { MeetingService meetingService, AiTaskService aiTaskService, PromptTemplateService promptTemplateService, + SysTenantMapper sysTenantMapper, SysUserMapper sysUserMapper, AiModelService aiModelService, SysDictItemService dictItemService, @@ -135,6 +140,7 @@ public class AndroidMeetingController { this.meetingService = meetingService; this.aiTaskService = aiTaskService; this.promptTemplateService = promptTemplateService; + this.sysTenantMapper = sysTenantMapper; this.sysUserMapper = sysUserMapper; this.meetingProgressService = meetingProgressService; this.aiModelService = aiModelService; @@ -154,9 +160,12 @@ public class AndroidMeetingController { @PostMapping("/create") @Anonymous @Log(value = "新增Android会议", type = "Android会议管理") - public ApiResponse create(HttpServletRequest request, @RequestBody LegacyMeetingCreateRequest command) { + public ApiResponse create(HttpServletRequest request, + + @RequestBody LegacyMeetingCreateRequest command) { AndroidRequestLogHelper.logRequest(log, "Android会议", "创建离线会议接口", "request", command); AndroidAuthContext authContext = androidAuthService.authenticateHttp(request); + resolvePublicDeviceTenantId(request, command, authContext); LoginUser loginUser = authContext.isAnonymous() ? null : AndroidLoginUserSupport.requireLoginUser(authContext); try { // Meeting existingMeeting = findLatestUnfinishedMeetingByDevice(authContext.getDeviceId()); @@ -468,6 +477,26 @@ public class AndroidMeetingController { return ApiResponse.ok(resultVo); } + private void resolvePublicDeviceTenantId(HttpServletRequest request, + LegacyMeetingCreateRequest command, + AndroidAuthContext authContext) { + if (command == null || command.getTenantId() != null || authContext == null || !authContext.isAnonymous()) { + return; + } + String tenantCode = request == null ? null : request.getHeader(TENANT_CODE_HEADER); + if (!StringUtils.hasText(tenantCode)) { + throw new IllegalArgumentException("tenantCode不能为空"); + } + SysTenant tenant = sysTenantMapper.selectOne(new LambdaQueryWrapper() + .eq(SysTenant::getTenantCode, tenantCode.trim()) + .eq(SysTenant::getIsDeleted, 0) + .last("LIMIT 1")); + if (tenant == null || tenant.getId() == null) { + throw new IllegalArgumentException("tenantCode无效,无法获取tenantId"); + } + command.setTenantId(tenant.getId()); + } + private AndroidMeetingCreateResponse buildAndroidMeetingCreateResponse(MeetingVO meeting) { AndroidMeetingCreateResponse response = new AndroidMeetingCreateResponse(); if (meeting == null) { diff --git a/backend/src/main/java/com/imeeting/controller/biz/MeetingInternalWorkflowController.java b/backend/src/main/java/com/imeeting/controller/biz/MeetingInternalWorkflowController.java index 76f1eef..bf29f63 100644 --- a/backend/src/main/java/com/imeeting/controller/biz/MeetingInternalWorkflowController.java +++ b/backend/src/main/java/com/imeeting/controller/biz/MeetingInternalWorkflowController.java @@ -1,15 +1,14 @@ package com.imeeting.controller.biz; import com.imeeting.dto.android.AndroidGrpcConnectionSnapshotVO; -import com.imeeting.dto.biz.MeetingSummaryFinalizeDTO; import com.imeeting.dto.biz.MeetingExternalWorkflowFailureDTO; +import com.imeeting.dto.biz.MeetingSummaryFinalizeDTO; import com.imeeting.dto.biz.MeetingSummaryPromptContextRequestDTO; import com.imeeting.dto.biz.MeetingSummaryPromptContextVO; import com.imeeting.dto.biz.MeetingTranscriptChapterImportDTO; import com.imeeting.dto.biz.MeetingTranscriptChapterImportResultVO; import com.imeeting.dto.biz.MeetingTranscriptSourceVO; import com.imeeting.service.android.AndroidGatewayPushService; -import com.imeeting.service.android.AndroidMeetingPushService; import com.imeeting.service.biz.MeetingCommandService; import com.imeeting.service.biz.MeetingQueryService; import com.unisbase.common.ApiResponse; @@ -34,7 +33,6 @@ public class MeetingInternalWorkflowController { private final MeetingCommandService meetingCommandService; private final MeetingQueryService meetingQueryService; private final AndroidGatewayPushService androidGatewayPushService; - private final AndroidMeetingPushService androidMeetingPushService; private final UnisBaseProperties unisBaseProperties; @Value("${imeeting.summary-orchestration.mode:INTERNAL_BUILTIN}") @@ -43,12 +41,10 @@ public class MeetingInternalWorkflowController { public MeetingInternalWorkflowController(MeetingCommandService meetingCommandService, MeetingQueryService meetingQueryService, AndroidGatewayPushService androidGatewayPushService, - AndroidMeetingPushService androidMeetingPushService, UnisBaseProperties unisBaseProperties) { this.meetingCommandService = meetingCommandService; this.meetingQueryService = meetingQueryService; this.androidGatewayPushService = androidGatewayPushService; - this.androidMeetingPushService = androidMeetingPushService; this.unisBaseProperties = unisBaseProperties; } @@ -128,18 +124,6 @@ public class MeetingInternalWorkflowController { return ApiResponse.ok(true); } - @Operation(summary = "手工触发会议完成推送") - @PostMapping("/{meetingId}/push/meeting-completed") - public ApiResponse pushMeetingCompleted(HttpServletRequest request, - @PathVariable Long meetingId) { - if (!isInternalSecretValid(request)) { - return ApiResponse.error("Invalid internal secret"); - } - - androidMeetingPushService.pushMeetingCompleted(meetingId); - return ApiResponse.ok(true); - } - @Operation(summary = "查询 Android gRPC 连接详情") @GetMapping("/grpc/connections") public ApiResponse listGrpcConnections(HttpServletRequest request) { diff --git a/backend/src/main/java/com/imeeting/enums/MeetingPushTypeEnum.java b/backend/src/main/java/com/imeeting/enums/MeetingPushTypeEnum.java index 4582fb7..ac803fb 100644 --- a/backend/src/main/java/com/imeeting/enums/MeetingPushTypeEnum.java +++ b/backend/src/main/java/com/imeeting/enums/MeetingPushTypeEnum.java @@ -6,7 +6,6 @@ import lombok.Getter; public enum MeetingPushTypeEnum { PUBLIC_MEETING_LOGIN_CONFIRM("PUBLIC_MEETING_LOGIN_CONFIRM", "公有设备扫码登录确认消息"), MEETING_PENDING("MEETING_PENDING", "待开始会议通知"), - MEETING_COMPLETED("MEETING_COMPLETED", "会议完成通知"), MEETING_STATUS_CHANGED("MEETING_STATUS_CHANGED", "会议状态变更通知"); private final String code; diff --git a/backend/src/main/java/com/imeeting/service/android/AndroidMeetingPushService.java b/backend/src/main/java/com/imeeting/service/android/AndroidMeetingPushService.java index 84328df..5520223 100644 --- a/backend/src/main/java/com/imeeting/service/android/AndroidMeetingPushService.java +++ b/backend/src/main/java/com/imeeting/service/android/AndroidMeetingPushService.java @@ -7,7 +7,5 @@ public interface AndroidMeetingPushService { void pushPublicLoginConfirm(String deviceId, AndroidPublicLoginConfirmPayload payload); - void pushMeetingCompleted(Long meetingId); - void pushMeetingStatusChanged(Long meetingId, String statusCode); } diff --git a/backend/src/main/java/com/imeeting/service/android/impl/AndroidMeetingPushServiceImpl.java b/backend/src/main/java/com/imeeting/service/android/impl/AndroidMeetingPushServiceImpl.java index 8bff325..b723924 100644 --- a/backend/src/main/java/com/imeeting/service/android/impl/AndroidMeetingPushServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/android/impl/AndroidMeetingPushServiceImpl.java @@ -84,28 +84,6 @@ public class AndroidMeetingPushServiceImpl implements AndroidMeetingPushService deviceId, payload.getSessionId(), pushed); } - @Override - public void pushMeetingCompleted(Long meetingId) { - if (meetingId == null) { - return; - } - Meeting meeting = meetingMapper.selectByIdIgnoreTenant(meetingId); - if (meeting == null || meeting.getTenantId() == null || meeting.getCreatorId() == null) { - return; - } - PushMessage message = PushMessage.newBuilder() - .setMessageId("meeting_completed:" + meetingId + ":" + UUID.randomUUID()) - .setTimestamp(System.currentTimeMillis()) - .setType(MeetingPushTypeEnum.MEETING_COMPLETED.getCode()) - .setTitle(resolveCompletedTitle(meeting)) - .setContent(buildCompletedContent(meeting)) - .setNeedAck(false) - .build(); - int pushed = androidGatewayPushService.pushToUser(meeting.getTenantId(), meeting.getCreatorId(), message); - log.info("Android meeting completion push finished, meetingId={}, tenantId={}, creatorId={}, pushedConnections={}", - meetingId, meeting.getTenantId(), meeting.getCreatorId(), pushed); - } - @Override public void pushMeetingStatusChanged(Long meetingId, String statusCode) { if (meetingId == null || statusCode == null || statusCode.isBlank()) { @@ -131,23 +109,12 @@ public class AndroidMeetingPushServiceImpl implements AndroidMeetingPushService private String resolvePendingTitle(Meeting meeting) { String title = meeting.getTitle(); if (title != null && !title.isBlank()) { - return "待开始会议 " + title.trim(); + return "待开始会议:" + title.trim(); } LocalDateTime meetingTime = meeting.getMeetingTime(); return meetingTime == null ? "待开始会议" - : "待开始会议 " + TITLE_TIME_FORMATTER.format(meetingTime); - } - - private String resolveCompletedTitle(Meeting 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); + : "待开始会议:" + TITLE_TIME_FORMATTER.format(meetingTime); } private String buildPendingContent(Meeting meeting) { @@ -161,12 +128,6 @@ public class AndroidMeetingPushServiceImpl implements AndroidMeetingPushService return JSONUtil.toJsonStr(result); } - private String buildCompletedContent(Meeting meeting) { - Map result = new HashMap<>(); - result.put("meetingId", meeting.getId()); - return JSONUtil.toJsonStr(result); - } - private String buildStatusChangedContent(Long meetingId, String statusCode) { Map result = new HashMap<>(); result.put("meetingId", meetingId); diff --git a/backend/src/main/java/com/imeeting/service/biz/MeetingPointsService.java b/backend/src/main/java/com/imeeting/service/biz/MeetingPointsService.java index 40ad0a5..d95fe05 100644 --- a/backend/src/main/java/com/imeeting/service/biz/MeetingPointsService.java +++ b/backend/src/main/java/com/imeeting/service/biz/MeetingPointsService.java @@ -15,6 +15,10 @@ public interface MeetingPointsService { void recordSummarySuccessCharge(Meeting meeting, AiTask summaryTask); + void assertSufficientPointsBeforeAsrSubmit(Meeting meeting, AiTask asrTask); + + void assertSufficientPointsBeforeSummarySubmit(Meeting meeting, AiTask summaryTask); + void markSummaryChargeFailed(Long summaryTaskId, String failureReason); String resolveLatestBlockedReason(Long summaryTaskId); diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java index 7877bec..ab5a682 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java @@ -18,7 +18,6 @@ import com.imeeting.enums.MeetingStatusEnum; import com.imeeting.mapper.biz.AiTaskMapper; import com.imeeting.mapper.biz.MeetingMapper; import com.imeeting.mapper.biz.MeetingTranscriptMapper; -import com.imeeting.service.android.AndroidMeetingPushService; import com.imeeting.support.TaskSecurityContextRunner; import com.imeeting.service.biz.AiModelService; import com.imeeting.service.biz.AiTaskService; @@ -43,8 +42,6 @@ import org.springframework.scheduling.annotation.Async; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.transaction.support.TransactionSynchronization; -import org.springframework.transaction.support.TransactionSynchronizationManager; import java.net.URI; import java.net.URLEncoder; @@ -65,6 +62,9 @@ import java.util.stream.Collectors; @Slf4j public class AiTaskServiceImpl extends ServiceImpl implements AiTaskService { + private static final Duration ASR_SUBMIT_REQUEST_TIMEOUT = Duration.ofSeconds(30); + private static final Duration ASR_QUERY_REQUEST_TIMEOUT = Duration.ofSeconds(30); + private final MeetingMapper meetingMapper; private final MeetingTranscriptMapper transcriptMapper; private final AiModelService aiModelService; @@ -82,7 +82,6 @@ public class AiTaskServiceImpl extends ServiceImpl impleme private final TaskSecurityContextRunner taskSecurityContextRunner; private final MeetingExternalSummaryWebhookTrigger meetingExternalSummaryWebhookTrigger; private final SysParamService sysParamService; - private final AndroidMeetingPushService androidMeetingPushService; @Autowired @Qualifier("asrTaskExecutor") @@ -126,8 +125,7 @@ public class AiTaskServiceImpl extends ServiceImpl impleme MeetingSummaryPromptAssembler meetingSummaryPromptAssembler, TaskSecurityContextRunner taskSecurityContextRunner, MeetingExternalSummaryWebhookTrigger meetingExternalSummaryWebhookTrigger, - SysParamService sysParamService, - AndroidMeetingPushService androidMeetingPushService) { + SysParamService sysParamService) { this.meetingMapper = meetingMapper; this.transcriptMapper = transcriptMapper; this.aiModelService = aiModelService; @@ -144,7 +142,6 @@ public class AiTaskServiceImpl extends ServiceImpl impleme this.taskSecurityContextRunner = taskSecurityContextRunner; this.meetingExternalSummaryWebhookTrigger = meetingExternalSummaryWebhookTrigger; this.sysParamService = sysParamService; - this.androidMeetingPushService = androidMeetingPushService; } @Override @@ -316,6 +313,7 @@ public class AiTaskServiceImpl extends ServiceImpl impleme reconcileMeetingStatus(meetingId); } catch (Exception e) { log.error("Re-summary failed for meeting {}", meetingId, e); + failPendingSummaryTask(sumTask, e.getMessage()); reconcileMeetingStatus(meetingId); } } @@ -481,6 +479,19 @@ public class AiTaskServiceImpl extends ServiceImpl impleme } private String processAsrTask(Meeting meeting, AiTask taskRecord) throws Exception { + try { + return doProcessAsrTask(meeting, taskRecord); + } catch (Exception ex) { + if (taskRecord != null + && !Integer.valueOf(2).equals(taskRecord.getStatus()) + && !Integer.valueOf(3).equals(taskRecord.getStatus())) { + updateAiTaskFail(taskRecord, buildAsrFailureMessage(ex)); + } + throw ex; + } + } + + private String doProcessAsrTask(Meeting meeting, AiTask taskRecord) throws Exception { updateMeetingStatus(meeting.getId(), 1); taskRecord.setStatus(1); @@ -644,6 +655,7 @@ public class AiTaskServiceImpl extends ServiceImpl impleme Map req = buildAsrRequest(meeting, taskRecord, asrModel); taskRecord.setRequestData(req); this.updateById(taskRecord); + meetingPointsService.assertSufficientPointsBeforeAsrSubmit(meeting, taskRecord); String respBody = postJson(submitUrl, req, asrModel.getApiKey()); JsonNode submitNode = objectMapper.readTree(respBody); @@ -875,6 +887,7 @@ public class AiTaskServiceImpl extends ServiceImpl impleme updateMeetingStatus(meeting.getId(), 2); updateProgress(meeting.getId(), 90, "正在生成智能总结纪要...", 0); + meetingPointsService.assertSufficientPointsBeforeSummarySubmit(meeting, taskRecord); taskRecord.setStatus(1); taskRecord.setStartedAt(LocalDateTime.now()); Map initialResponseData = new HashMap<>(); @@ -954,7 +967,6 @@ public class AiTaskServiceImpl extends ServiceImpl impleme Files.writeString(filePath, markdownContent, StandardCharsets.UTF_8); - boolean alreadyCompleted = MeetingStatusEnum.isCode(meeting.getStatus(), MeetingStatusEnum.COMPLETED); taskRecord.setResultFilePath("meetings/" + meeting.getId() + "/summaries/" + fileName); Map responseData = objectMapper.convertValue(respNode, Map.class); responseData.put("summarySource", summarySource.toSnapshot()); @@ -976,7 +988,6 @@ public class AiTaskServiceImpl extends ServiceImpl impleme AiTask latestChapterTask = findLatestTask(meeting.getId(), "CHAPTER"); if (latestChapterTask != null && Integer.valueOf(2).equals(latestChapterTask.getStatus())) { updateProgress(meeting.getId(), 100, "全流程分析完成", 0); - pushMeetingCompletedAfterCommitIfNeeded(meeting.getId(), alreadyCompleted); } else { updateProgress(meeting.getId(), 95, "总结生成完成,等待 AI 目录完成...", 0); } @@ -1107,10 +1118,12 @@ public class AiTaskServiceImpl extends ServiceImpl impleme } updateMeetingStatus(meeting.getId(), 2); try { + meetingPointsService.assertSufficientPointsBeforeSummarySubmit(meeting, summaryTask); var result = meetingExternalSummaryWebhookTrigger.trigger(meeting, summaryTask, chapterTask, triggerSource, force); this.updateById(summaryTask); updateProgress(meeting.getId(), 95, result.getMessage(), 0); } catch (Exception ex) { + failPendingSummaryTask(summaryTask, ex.getMessage()); this.updateById(summaryTask); updateProgress(meeting.getId(), -1, "闂佽崵鍠愰悷杈╃不閹达絻浜归柛灞剧☉缁剁偤鏌″搴″箹闁?n8n 缂傚倸鍊搁崐褰掓偋濡ゅ啯鏆滈柟鐐綑缁剁偤寮堕崼顐函鐞? " + ex.getMessage(), 0); log.error("Failed to trigger external n8n webhook for meeting {}", meeting.getId(), ex); @@ -1230,6 +1243,7 @@ public class AiTaskServiceImpl extends ServiceImpl impleme private String postJson(String url, Object body, String apiKey) throws Exception { HttpRequest.Builder builder = HttpRequest.newBuilder() .uri(buildUri(url)) + .timeout(ASR_SUBMIT_REQUEST_TIMEOUT) .header("Content-Type", "application/json"); if (apiKey != null && !apiKey.isBlank()) { builder.header("Authorization", "Bearer " + apiKey); @@ -1241,7 +1255,9 @@ public class AiTaskServiceImpl extends ServiceImpl impleme } private String get(String url, String apiKey) throws Exception { - HttpRequest.Builder builder = HttpRequest.newBuilder().uri(buildUri(url)); + HttpRequest.Builder builder = HttpRequest.newBuilder() + .uri(buildUri(url)) + .timeout(ASR_QUERY_REQUEST_TIMEOUT); if (apiKey != null && !apiKey.isBlank()) { builder.header("Authorization", "Bearer " + apiKey); } @@ -1319,22 +1335,6 @@ public class AiTaskServiceImpl extends ServiceImpl impleme meetingMapper.updateById(m); } - private void pushMeetingCompletedAfterCommitIfNeeded(Long meetingId, boolean alreadyCompleted) { - if (alreadyCompleted) { - return; - } - if (!TransactionSynchronizationManager.isSynchronizationActive()) { - androidMeetingPushService.pushMeetingCompleted(meetingId); - return; - } - TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { - @Override - public void afterCommit() { - androidMeetingPushService.pushMeetingCompleted(meetingId); - } - }); - } - private AiTask createAiTask(Long meetingId, String type, Map req) { AiTask task = new AiTask(); task.setMeetingId(meetingId); @@ -1362,6 +1362,17 @@ public class AiTaskServiceImpl extends ServiceImpl impleme meetingPointsService.markSummaryChargeFailed(task.getId(), error); } } + + private String buildAsrFailureMessage(Exception ex) { + if (ex == null) { + return "ASR task failed"; + } + String message = ex.getMessage(); + if (message == null || message.isBlank()) { + return "ASR task failed: " + ex.getClass().getSimpleName(); + } + return "ASR task failed: " + message; + } } diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingCommandServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingCommandServiceImpl.java index 60e76ed..5d94732 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingCommandServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingCommandServiceImpl.java @@ -28,7 +28,6 @@ import com.imeeting.entity.biz.Meeting; import com.imeeting.entity.biz.MeetingTranscript; import com.imeeting.entity.biz.MeetingTranscriptChapterVersion; import com.imeeting.enums.MeetingStatusEnum; -import com.imeeting.service.android.AndroidMeetingPushService; import com.imeeting.service.android.AndroidPendingMeetingDraftService; import com.imeeting.service.android.AndroidPushMessageService; import com.imeeting.service.biz.AiTaskService; @@ -83,7 +82,6 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { private final MeetingPointsService meetingPointsService; private final ObjectMapper objectMapper; private final MeetingExternalSummaryWebhookTrigger meetingExternalSummaryWebhookTrigger; - private final AndroidMeetingPushService androidMeetingPushService; private final AndroidPushMessageService androidPushMessageService; private final AndroidPendingMeetingDraftService androidPendingMeetingDraftService; private final MeetingLockCache meetingLockCache; @@ -109,7 +107,6 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { MeetingPointsService meetingPointsService, ObjectMapper objectMapper, MeetingExternalSummaryWebhookTrigger meetingExternalSummaryWebhookTrigger, - AndroidMeetingPushService androidMeetingPushService, AndroidPushMessageService androidPushMessageService, AndroidPendingMeetingDraftService androidPendingMeetingDraftService, MeetingLockCache meetingLockCache, @@ -130,7 +127,6 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { this.meetingPointsService = meetingPointsService; this.objectMapper = objectMapper; this.meetingExternalSummaryWebhookTrigger = meetingExternalSummaryWebhookTrigger; - this.androidMeetingPushService = androidMeetingPushService; this.androidPushMessageService = androidPushMessageService; this.androidPendingMeetingDraftService = androidPendingMeetingDraftService; this.meetingLockCache = meetingLockCache; @@ -886,7 +882,6 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { aiTaskService.updateById(summaryTask); meetingPointsService.recordSummarySuccessCharge(meeting, summaryTask); - boolean alreadyCompleted = MeetingStatusEnum.isCode(meeting.getStatus(), MeetingStatusEnum.COMPLETED); meeting.setLatestSummaryTaskId(summaryTask.getId()); meetingService.updateById(meeting); aiTaskService.reconcileMeetingStatus(meeting.getId()); @@ -898,7 +893,6 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { .last("LIMIT 1")); if (latestChapterTask != null && Integer.valueOf(2).equals(latestChapterTask.getStatus())) { updateMeetingProgress(meeting.getId(), 100, "外部总结回填完成", 0); - pushMeetingCompletedAfterCommitIfNeeded(meeting.getId(), alreadyCompleted); } else { updateMeetingProgress(meeting.getId(), 95, "外部总结回填完成,等待 AI 目录完成...", 0); } @@ -1320,22 +1314,6 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { }); } - private void pushMeetingCompletedAfterCommitIfNeeded(Long meetingId, boolean alreadyCompleted) { - if (alreadyCompleted) { - return; - } - if (!TransactionSynchronizationManager.isSynchronizationActive()) { - androidMeetingPushService.pushMeetingCompleted(meetingId); - return; - } - TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { - @Override - public void afterCommit() { - androidMeetingPushService.pushMeetingCompleted(meetingId); - } - }); - } - private void updateMeetingProgress(Long meetingId, int percent, String message, int eta) { com.imeeting.common.MeetingProgressStage stage; int meetingStatus; diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingPointsServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingPointsServiceImpl.java index eff4591..f344766 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingPointsServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingPointsServiceImpl.java @@ -85,7 +85,10 @@ public class MeetingPointsServiceImpl implements MeetingPointsService { throw new RuntimeException("分配积分必须大于0"); } - MeetingPointsAccount publicAccount = getOrCreateAccountForMutation(tenantId, UNIFIED_ACCOUNT_USER_ID, 0L, false); + MeetingPointsAccount publicAccount = findAccountForMutation(tenantId, UNIFIED_ACCOUNT_USER_ID); + if (publicAccount == null) { + throw new RuntimeException("公共积分账户不存在"); + } long publicBefore = defaultLong(publicAccount.getCurrentBalance()); if (publicBefore < safePoints) { throw new RuntimeException("公共账户积分不足"); @@ -258,6 +261,33 @@ public class MeetingPointsServiceImpl implements MeetingPointsService { return vo; } + @Override + @Transactional(rollbackFor = Exception.class) + public void assertSufficientPointsBeforeAsrSubmit(Meeting meeting, AiTask asrTask) { + if (!shouldEnforceBalance() || meeting == null || asrTask == null || meeting.getId() == null) { + return; + } + Integer durationSeconds = resolveEffectiveAudioDurationSeconds(meeting); + if (durationSeconds == null || durationSeconds <= 0) { + throw new RuntimeException("无法解析录音时长,不能校验积分余额"); + } + ensureSufficientPoints(meeting, null, buildChargeSnapshot(durationSeconds).asrPoints(), "ASR_SUBMIT"); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void assertSufficientPointsBeforeSummarySubmit(Meeting meeting, AiTask summaryTask) { + if (!shouldEnforceBalance() || meeting == null || summaryTask == null || meeting.getId() == null) { + return; + } + String chargeTriggerType = resolveChargeTriggerType(summaryTask); + Integer durationSeconds = resolveEffectiveAudioDurationSeconds(meeting); + if (durationSeconds == null || durationSeconds <= 0) { + throw new RuntimeException("无法解析录音时长,不能校验积分余额"); + } + ensureSufficientPoints(meeting, summaryTask, chargeTriggerType, durationSeconds, "SUMMARY_SUBMIT"); + } + private MeetingSummaryChargeRecord getOrCreateChargeRecord(Meeting meeting, AiTask summaryTask, String chargeTriggerType, int durationSeconds) { MeetingSummaryChargeRecord record = meetingSummaryChargeRecordMapper.selectForUpdateBySummaryTaskId(summaryTask.getId()); if (record != null) { @@ -297,6 +327,68 @@ public class MeetingPointsServiceImpl implements MeetingPointsService { return record; } + private void ensureSufficientPoints(Meeting meeting, + AiTask summaryTask, + String chargeTriggerType, + int durationSeconds, + String submitStage) { + MeetingSummaryChargeRecord record = getOrCreateChargeRecord(meeting, summaryTask, chargeTriggerType, durationSeconds); + long requiredPoints = defaultLong(record.getTotalPoints()) - defaultLong(record.getChargedTotalPoints()); + if (requiredPoints <= 0L) { + clearBlockedReason(record); + return; + } + long availableBalance = resolveAvailableBalanceForCheck(meeting.getTenantId(), record.getUserId()); + if (availableBalance < requiredPoints) { + record.setBlockedReason("INSUFFICIENT_POINTS"); + record.setFailureReason("INSUFFICIENT_POINTS at " + submitStage + ", required=" + + requiredPoints + ", available=" + availableBalance); + record.setBalanceBefore(availableBalance); + record.setBalanceAfter(availableBalance); + record.setSummaryStatus("BLOCKED"); + saveOrUpdateRecord(record); + throw new RuntimeException("积分余额不足"); + } + clearBlockedReason(record); + } + + private void ensureSufficientPoints(Meeting meeting, + MeetingSummaryChargeRecord record, + long requiredPoints, + String submitStage) { + if (requiredPoints <= 0L) { + clearBlockedReason(record); + return; + } + Long ownerUserId = record == null || record.getUserId() == null ? meeting.getCreatorId() : record.getUserId(); + long availableBalance = resolveAvailableBalanceForCheck(meeting.getTenantId(), ownerUserId); + if (availableBalance < requiredPoints) { + if (record != null) { + record.setBlockedReason("INSUFFICIENT_POINTS"); + record.setFailureReason("INSUFFICIENT_POINTS at " + submitStage + ", required=" + + requiredPoints + ", available=" + availableBalance); + record.setBalanceBefore(availableBalance); + record.setBalanceAfter(availableBalance); + record.setSummaryStatus("BLOCKED"); + saveOrUpdateRecord(record); + } + throw new RuntimeException("积分余额不足"); + } + clearBlockedReason(record); + } + + private void clearBlockedReason(MeetingSummaryChargeRecord record) { + if (record == null || record.getId() == null) { + return; + } + if (record.getBlockedReason() != null || "BLOCKED".equals(record.getSummaryStatus())) { + record.setBlockedReason(null); + record.setFailureReason(null); + record.setSummaryStatus(isPointsEnabled() ? STATUS_PENDING : STATUS_DISABLED); + saveOrUpdateRecord(record); + } + } + private void applyChargeSnapshot(MeetingSummaryChargeRecord record, Meeting meeting, String chargeTriggerType, int durationSeconds) { ChargeSnapshot snapshot = buildChargeSnapshot(durationSeconds); String accountMode = resolveAccountMode(); @@ -328,7 +420,7 @@ public class MeetingPointsServiceImpl implements MeetingPointsService { for (ChargeTarget target : chargeTargets) { totalBalanceBefore += defaultLong(target.account().getCurrentBalance()); } - if (isBalanceEnforced() && totalBalanceBefore < chargeAmount) { + if (shouldEnforceBalance() && totalBalanceBefore < chargeAmount) { record.setBlockedReason("INSUFFICIENT_POINTS"); saveOrUpdateRecord(record); throw new RuntimeException("积分余额不足"); @@ -374,7 +466,7 @@ public class MeetingPointsServiceImpl implements MeetingPointsService { if (remaining <= 0L) { return 0L; } - if (isBalanceEnforced()) { + if (shouldEnforceBalance()) { return Math.min(Math.max(currentBalance, 0L), remaining); } if (lastTarget) { @@ -429,6 +521,30 @@ public class MeetingPointsServiceImpl implements MeetingPointsService { .last("LIMIT 1")); } + private MeetingPointsAccount findAccountForMutation(Long tenantId, Long userId) { + if (tenantId == null || userId == null) { + return null; + } + return meetingPointsAccountMapper.selectForUpdate(tenantId, userId); + } + + private long resolveAvailableBalanceForCheck(Long tenantId, Long ownerUserId) { + Long personalUserId = ownerUserId == null ? UNIFIED_ACCOUNT_USER_ID : ownerUserId; + String accountMode = resolveAccountMode(); + if (ACCOUNT_MODE_PUBLIC.equals(accountMode) || personalUserId.equals(UNIFIED_ACCOUNT_USER_ID)) { + return positiveBalance(findAccount(tenantId, UNIFIED_ACCOUNT_USER_ID)); + } + if (ACCOUNT_MODE_PERSONAL.equals(accountMode)) { + return positiveBalance(findAccount(tenantId, personalUserId)); + } + return positiveBalance(findAccount(tenantId, personalUserId)) + + positiveBalance(findAccount(tenantId, UNIFIED_ACCOUNT_USER_ID)); + } + + private long positiveBalance(MeetingPointsAccount account) { + return account == null ? 0L : Math.max(defaultLong(account.getCurrentBalance()), 0L); + } + private MeetingPointsAccount getOrCreateAccountForMutation(Long tenantId, Long userId, long initialBalance, boolean createInitLedger) { MeetingPointsAccount account = meetingPointsAccountMapper.selectForUpdate(tenantId, userId); if (account != null) { @@ -463,34 +579,34 @@ public class MeetingPointsServiceImpl implements MeetingPointsService { String chargePriority = resolveChargePriority(); List targets = new ArrayList<>(); if (ACCOUNT_MODE_PUBLIC.equals(accountMode)) { - targets.add(new ChargeTarget(ACCOUNT_MODE_PUBLIC, UNIFIED_ACCOUNT_USER_ID, - getOrCreateAccountForMutation(tenantId, UNIFIED_ACCOUNT_USER_ID, 0L, false))); + addChargeTargetIfAccountExists(targets, ACCOUNT_MODE_PUBLIC, tenantId, UNIFIED_ACCOUNT_USER_ID); return targets; } if (ACCOUNT_MODE_PERSONAL.equals(accountMode)) { - targets.add(new ChargeTarget(ACCOUNT_MODE_PERSONAL, personalUserId, - getOrCreateAccountForMutation(tenantId, personalUserId, 0L, false))); + addChargeTargetIfAccountExists(targets, ACCOUNT_MODE_PERSONAL, tenantId, personalUserId); return targets; } if (personalUserId.equals(UNIFIED_ACCOUNT_USER_ID)) { - targets.add(new ChargeTarget(ACCOUNT_MODE_PUBLIC, UNIFIED_ACCOUNT_USER_ID, - getOrCreateAccountForMutation(tenantId, UNIFIED_ACCOUNT_USER_ID, 0L, false))); + addChargeTargetIfAccountExists(targets, ACCOUNT_MODE_PUBLIC, tenantId, UNIFIED_ACCOUNT_USER_ID); return targets; } if (CHARGE_PRIORITY_PUBLIC_FIRST.equals(chargePriority)) { - targets.add(new ChargeTarget(ACCOUNT_MODE_PUBLIC, UNIFIED_ACCOUNT_USER_ID, - getOrCreateAccountForMutation(tenantId, UNIFIED_ACCOUNT_USER_ID, 0L, false))); - targets.add(new ChargeTarget(ACCOUNT_MODE_PERSONAL, personalUserId, - getOrCreateAccountForMutation(tenantId, personalUserId, 0L, false))); + addChargeTargetIfAccountExists(targets, ACCOUNT_MODE_PUBLIC, tenantId, UNIFIED_ACCOUNT_USER_ID); + addChargeTargetIfAccountExists(targets, ACCOUNT_MODE_PERSONAL, tenantId, personalUserId); return targets; } - targets.add(new ChargeTarget(ACCOUNT_MODE_PERSONAL, personalUserId, - getOrCreateAccountForMutation(tenantId, personalUserId, 0L, false))); - targets.add(new ChargeTarget(ACCOUNT_MODE_PUBLIC, UNIFIED_ACCOUNT_USER_ID, - getOrCreateAccountForMutation(tenantId, UNIFIED_ACCOUNT_USER_ID, 0L, false))); + addChargeTargetIfAccountExists(targets, ACCOUNT_MODE_PERSONAL, tenantId, personalUserId); + addChargeTargetIfAccountExists(targets, ACCOUNT_MODE_PUBLIC, tenantId, UNIFIED_ACCOUNT_USER_ID); return targets; } + private void addChargeTargetIfAccountExists(List targets, String accountMode, Long tenantId, Long userId) { + MeetingPointsAccount account = findAccountForMutation(tenantId, userId); + if (account != null) { + targets.add(new ChargeTarget(accountMode, userId, account)); + } + } + private ChargeSnapshot buildChargeSnapshot(int durationSeconds) { int chargedMinutes = toChargedMinutes(durationSeconds); int unitMinutes = positiveInt(sysParamService.getCachedParamValue(SysParamKeys.MEETING_POINTS_UNIT_MINUTES, "1"), 1); @@ -557,7 +673,11 @@ public class MeetingPointsServiceImpl implements MeetingPointsService { } private boolean isBalanceEnforced() { - return Boolean.parseBoolean(sysParamService.getCachedParamValue(SysParamKeys.MEETING_POINTS_ENFORCE_BALANCE, "false")); + return Boolean.parseBoolean(sysParamService.getCachedParamValue(SysParamKeys.MEETING_POINTS_ENFORCE_BALANCE, "true")); + } + + private boolean shouldEnforceBalance() { + return isPointsEnabled() && isBalanceEnforced(); } private String resolveAccountMode() { diff --git a/frontend/src/locales/en-US.json b/frontend/src/locales/en-US.json index c907107..c6b5080 100644 --- a/frontend/src/locales/en-US.json +++ b/frontend/src/locales/en-US.json @@ -351,7 +351,7 @@ "kickDeviceConfirm": "Kick this device offline?", "kickSucceeded": "Device has been kicked offline", "resetStats": "Reset stats", - "resetStatsConfirm": "Reset this device's homepage statistics?", + "resetStatsConfirm": "Reset this device's statistics?", "resetStatsSucceeded": "Device statistics reset", "deleteDevice": "Delete this device?", "weatherCityName": "Weather City", diff --git a/frontend/src/locales/zh-CN.json b/frontend/src/locales/zh-CN.json index 45be8ae..8762d78 100644 --- a/frontend/src/locales/zh-CN.json +++ b/frontend/src/locales/zh-CN.json @@ -358,7 +358,11 @@ "searchSelectUser": "搜索并选择用户", "deviceCodeRequired": "请输入设备编码", "deviceCodePlaceholder": "输入唯一设备编码", - "deviceNamePlaceholder": "例如:A 会议室录音设备" + "deviceNamePlaceholder": "例如:A 会议室录音设备", + "weatherCityName": "城市", + "statsResetAt": "重置时间", + "resetStatsConfirm": "确认重置?" + }, "dashboardExt": { "processing": "处理中", diff --git a/frontend/src/pages/devices/index.tsx b/frontend/src/pages/devices/index.tsx index 8494ba8..2dfb8c0 100644 --- a/frontend/src/pages/devices/index.tsx +++ b/frontend/src/pages/devices/index.tsx @@ -385,7 +385,7 @@ export default function Devices() { - +