From 29551dfbe2451e2a8127d32d41fe7bd64eb7b8be Mon Sep 17 00:00:00 2001 From: chenhao Date: Wed, 22 Apr 2026 16:38:45 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E4=BC=9A=E8=AE=AE?= =?UTF-8?q?=E9=9F=B3=E9=A2=91=E4=B8=8A=E4=BC=A0=E6=94=AF=E6=8C=81=E5=92=8C?= =?UTF-8?q?=E7=83=AD=E8=AF=8D=E7=BB=84=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 `MeetingAudioUploadSupport` 类,支持音频文件上传和验证 - 在 `MeetingCommandServiceImpl` 中添加删除会议时清理相关数据和工件的逻辑 - 添加热词组相关的实体、服务、控制器和映射器 - 更新测试类以包含新的依赖和测试用例 --- .../biz/HotWordGroupController.java | 91 ++++++++++ .../controller/biz/MeetingController.java | 27 +-- .../com/imeeting/dto/biz/HotWordGroupDTO.java | 24 +++ .../com/imeeting/dto/biz/HotWordGroupVO.java | 38 ++++ .../com/imeeting/entity/biz/HotWordGroup.java | 29 +++ .../mapper/biz/HotWordGroupMapper.java | 28 +++ .../impl/LegacyMeetingAdapterServiceImpl.java | 36 +--- .../service/biz/HotWordGroupService.java | 22 +++ .../biz/impl/HotWordGroupServiceImpl.java | 148 +++++++++++++++ .../biz/impl/MeetingAudioUploadSupport.java | 169 ++++++++++++++++++ .../biz/impl/MeetingCommandServiceImpl.java | 28 +++ .../biz/impl/MeetingDomainSupport.java | 52 +++++- .../LegacyMeetingAdapterServiceImplTest.java | 4 +- .../impl/MeetingCommandServiceImplTest.java | 42 +++++ .../biz/impl/MeetingDomainSupportTest.java | 36 ++++ 15 files changed, 712 insertions(+), 62 deletions(-) create mode 100644 backend/src/main/java/com/imeeting/controller/biz/HotWordGroupController.java create mode 100644 backend/src/main/java/com/imeeting/dto/biz/HotWordGroupDTO.java create mode 100644 backend/src/main/java/com/imeeting/dto/biz/HotWordGroupVO.java create mode 100644 backend/src/main/java/com/imeeting/entity/biz/HotWordGroup.java create mode 100644 backend/src/main/java/com/imeeting/mapper/biz/HotWordGroupMapper.java create mode 100644 backend/src/main/java/com/imeeting/service/biz/HotWordGroupService.java create mode 100644 backend/src/main/java/com/imeeting/service/biz/impl/HotWordGroupServiceImpl.java create mode 100644 backend/src/main/java/com/imeeting/service/biz/impl/MeetingAudioUploadSupport.java diff --git a/backend/src/main/java/com/imeeting/controller/biz/HotWordGroupController.java b/backend/src/main/java/com/imeeting/controller/biz/HotWordGroupController.java new file mode 100644 index 0000000..6da09d6 --- /dev/null +++ b/backend/src/main/java/com/imeeting/controller/biz/HotWordGroupController.java @@ -0,0 +1,91 @@ +package com.imeeting.controller.biz; + +import com.imeeting.dto.biz.HotWordGroupDTO; +import com.imeeting.dto.biz.HotWordGroupVO; +import com.imeeting.service.biz.HotWordGroupService; +import com.unisbase.common.ApiResponse; +import com.unisbase.dto.PageResult; +import com.unisbase.security.LoginUser; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "热词组管理") +@RestController +@RequestMapping("/api/biz/hotword-group") +public class HotWordGroupController { + + private final HotWordGroupService hotWordGroupService; + + public HotWordGroupController(HotWordGroupService hotWordGroupService) { + this.hotWordGroupService = hotWordGroupService; + } + + private Long resolveTargetTenantId(LoginUser loginUser, Long tenantId) { + if (Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()) && Long.valueOf(0L).equals(tenantId)) { + return 0L; + } + return null; + } + + @Operation(summary = "新增热词组") + @PostMapping + @PreAuthorize("isAuthenticated()") + public ApiResponse save(@RequestBody HotWordGroupDTO dto) { + LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + Long targetTenantId = resolveTargetTenantId(loginUser, dto.getTenantId()); + return ApiResponse.ok(hotWordGroupService.saveGroup(dto, loginUser.getUserId(), targetTenantId)); + } + + @Operation(summary = "修改热词组") + @PutMapping + @PreAuthorize("isAuthenticated()") + public ApiResponse update(@RequestBody HotWordGroupDTO dto) { + LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + Long targetTenantId = resolveTargetTenantId(loginUser, dto.getTenantId()); + HotWordGroupVO existing = hotWordGroupService.listVisibleOptions(targetTenantId).stream() + .filter(item -> item.getId().equals(dto.getId())) + .findFirst() + .orElse(null); + if (existing == null) { + return ApiResponse.error("热词组不存在"); + } + dto.setTenantId(targetTenantId); + return ApiResponse.ok(hotWordGroupService.updateGroup(dto)); + } + + @Operation(summary = "删除热词组") + @DeleteMapping("/{id}") + @PreAuthorize("isAuthenticated()") + public ApiResponse delete(@PathVariable Long id, @RequestParam(required = false) Long tenantId) { + LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + Long targetTenantId = resolveTargetTenantId(loginUser, tenantId); + return ApiResponse.ok(hotWordGroupService.removeGroupById(id, targetTenantId)); + } + + @Operation(summary = "分页查询热词组") + @GetMapping("/page") + @PreAuthorize("isAuthenticated()") + public ApiResponse>> page( + @RequestParam(defaultValue = "1") Integer current, + @RequestParam(defaultValue = "10") Integer size, + @RequestParam(required = false) String name, + @RequestParam(required = false) Long tenantId) { + LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + Long targetTenantId = resolveTargetTenantId(loginUser, tenantId); + return ApiResponse.ok(hotWordGroupService.pageGroups(current, size, name, targetTenantId)); + } + + @Operation(summary = "查询热词组选项") + @GetMapping("/options") + @PreAuthorize("isAuthenticated()") + public ApiResponse> options(@RequestParam(required = false) Long tenantId) { + LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + Long targetTenantId = resolveTargetTenantId(loginUser, tenantId); + return ApiResponse.ok(hotWordGroupService.listVisibleOptions(targetTenantId)); + } +} diff --git a/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java b/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java index 548e6ac..5d84f06 100644 --- a/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java +++ b/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java @@ -25,13 +25,13 @@ import com.imeeting.service.biz.MeetingQueryService; import com.imeeting.service.biz.PromptTemplateService; import com.imeeting.service.biz.RealtimeMeetingSessionStateService; import com.imeeting.service.biz.RealtimeMeetingSocketSessionService; +import com.imeeting.service.biz.impl.MeetingAudioUploadSupport; import com.unisbase.common.ApiResponse; import com.unisbase.dto.PageResult; import com.unisbase.security.LoginUser; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; -import org.springframework.beans.factory.annotation.Value; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; @@ -49,7 +49,6 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; -import java.io.File; import java.io.IOException; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; @@ -57,7 +56,6 @@ import java.util.LinkedHashMap; import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.UUID; @Tag(name = "会议管理") @RestController @@ -71,9 +69,8 @@ public class MeetingController { private final PromptTemplateService promptTemplateService; private final RealtimeMeetingSocketSessionService realtimeMeetingSocketSessionService; private final RealtimeMeetingSessionStateService realtimeMeetingSessionStateService; + private final MeetingAudioUploadSupport meetingAudioUploadSupport; private final StringRedisTemplate redisTemplate; - private final String uploadPath; - private final String resourcePrefix; public MeetingController(MeetingQueryService meetingQueryService, MeetingCommandService meetingCommandService, @@ -82,9 +79,8 @@ public class MeetingController { PromptTemplateService promptTemplateService, RealtimeMeetingSocketSessionService realtimeMeetingSocketSessionService, RealtimeMeetingSessionStateService realtimeMeetingSessionStateService, - StringRedisTemplate redisTemplate, - @Value("${unisbase.app.upload-path}") String uploadPath, - @Value("${unisbase.app.resource-prefix}") String resourcePrefix) { + MeetingAudioUploadSupport meetingAudioUploadSupport, + StringRedisTemplate redisTemplate) { this.meetingQueryService = meetingQueryService; this.meetingCommandService = meetingCommandService; this.meetingAccessService = meetingAccessService; @@ -92,9 +88,8 @@ public class MeetingController { this.promptTemplateService = promptTemplateService; this.realtimeMeetingSocketSessionService = realtimeMeetingSocketSessionService; this.realtimeMeetingSessionStateService = realtimeMeetingSessionStateService; + this.meetingAudioUploadSupport = meetingAudioUploadSupport; this.redisTemplate = redisTemplate; - this.uploadPath = uploadPath; - this.resourcePrefix = resourcePrefix; } @Operation(summary = "查询会议处理进度") @@ -133,17 +128,7 @@ public class MeetingController { @PostMapping("/upload") @PreAuthorize("isAuthenticated()") public ApiResponse upload(@RequestParam("file") MultipartFile file) throws IOException { - String basePath = uploadPath.endsWith("/") ? uploadPath : uploadPath + "/"; - String uploadDir = basePath + "audio/"; - File dir = new File(uploadDir); - if (!dir.exists()) { - dir.mkdirs(); - } - - String fileName = UUID.randomUUID() + "_" + file.getOriginalFilename(); - file.transferTo(new File(uploadDir + fileName)); - String baseResourcePrefix = resourcePrefix.endsWith("/") ? resourcePrefix : resourcePrefix + "/"; - return ApiResponse.ok(baseResourcePrefix + "audio/" + fileName); + return ApiResponse.ok(meetingAudioUploadSupport.storeUploadedAudio(file)); } @Operation(summary = "创建离线会议") diff --git a/backend/src/main/java/com/imeeting/dto/biz/HotWordGroupDTO.java b/backend/src/main/java/com/imeeting/dto/biz/HotWordGroupDTO.java new file mode 100644 index 0000000..4e442ad --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/biz/HotWordGroupDTO.java @@ -0,0 +1,24 @@ +package com.imeeting.dto.biz; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "热词组请求参数") +public class HotWordGroupDTO { + + @Schema(description = "热词组 ID") + private Long id; + + @Schema(description = "租户 ID,平台管理员可传 0 表示平台范围") + private Long tenantId; + + @Schema(description = "热词组名称") + private String groupName; + + @Schema(description = "状态:1-启用,0-禁用") + private Integer status; + + @Schema(description = "备注") + private String remark; +} diff --git a/backend/src/main/java/com/imeeting/dto/biz/HotWordGroupVO.java b/backend/src/main/java/com/imeeting/dto/biz/HotWordGroupVO.java new file mode 100644 index 0000000..545780b --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/biz/HotWordGroupVO.java @@ -0,0 +1,38 @@ +package com.imeeting.dto.biz; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@Schema(description = "热词组信息") +public class HotWordGroupVO { + + @Schema(description = "热词组 ID") + private Long id; + + @Schema(description = "租户 ID") + private Long tenantId; + + @Schema(description = "热词组名称") + private String groupName; + + @Schema(description = "创建人 ID") + private Long creatorId; + + @Schema(description = "状态:1-启用,0-禁用") + private Integer status; + + @Schema(description = "组内热词数量") + private Long hotWordCount; + + @Schema(description = "备注") + private String remark; + + @Schema(description = "创建时间") + private LocalDateTime createdAt; + + @Schema(description = "更新时间") + private LocalDateTime updatedAt; +} diff --git a/backend/src/main/java/com/imeeting/entity/biz/HotWordGroup.java b/backend/src/main/java/com/imeeting/entity/biz/HotWordGroup.java new file mode 100644 index 0000000..333cde7 --- /dev/null +++ b/backend/src/main/java/com/imeeting/entity/biz/HotWordGroup.java @@ -0,0 +1,29 @@ +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 io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode(callSuper = true) +@Schema(description = "热词组实体") +@TableName("biz_hot_word_groups") +public class HotWordGroup extends BaseEntity { + + @TableId(value = "id", type = IdType.AUTO) + @Schema(description = "热词组 ID") + private Long id; + + @Schema(description = "热词组名称") + private String groupName; + + @Schema(description = "创建人 ID") + private Long creatorId; + + @Schema(description = "备注") + private String remark; +} diff --git a/backend/src/main/java/com/imeeting/mapper/biz/HotWordGroupMapper.java b/backend/src/main/java/com/imeeting/mapper/biz/HotWordGroupMapper.java new file mode 100644 index 0000000..36f5c7f --- /dev/null +++ b/backend/src/main/java/com/imeeting/mapper/biz/HotWordGroupMapper.java @@ -0,0 +1,28 @@ +package com.imeeting.mapper.biz; + +import com.baomidou.mybatisplus.annotation.InterceptorIgnore; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.imeeting.entity.biz.HotWordGroup; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +@Mapper +public interface HotWordGroupMapper extends BaseMapper { + + @InterceptorIgnore(tenantLine = "true") + @Select({ + "" + }) + List selectByIdsIgnoreTenant(@Param("ids") List ids); +} diff --git a/backend/src/main/java/com/imeeting/service/android/legacy/impl/LegacyMeetingAdapterServiceImpl.java b/backend/src/main/java/com/imeeting/service/android/legacy/impl/LegacyMeetingAdapterServiceImpl.java index 1eedf5d..0392c67 100644 --- a/backend/src/main/java/com/imeeting/service/android/legacy/impl/LegacyMeetingAdapterServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/android/legacy/impl/LegacyMeetingAdapterServiceImpl.java @@ -18,12 +18,12 @@ import com.imeeting.service.biz.MeetingAccessService; import com.imeeting.service.biz.MeetingRuntimeProfileResolver; import com.imeeting.service.biz.MeetingService; import com.imeeting.service.biz.PromptTemplateService; +import com.imeeting.service.biz.impl.MeetingAudioUploadSupport; import com.imeeting.service.biz.impl.MeetingDomainSupport; import com.imeeting.service.biz.impl.MeetingSummaryPromptAssembler; import com.imeeting.service.realtime.RealtimeMeetingAudioStorageService; import com.unisbase.security.LoginUser; import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionSynchronization; @@ -31,10 +31,6 @@ import org.springframework.transaction.support.TransactionSynchronizationManager import org.springframework.web.multipart.MultipartFile; import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.nio.file.StandardCopyOption; import java.time.LocalDateTime; import java.time.OffsetDateTime; import java.time.format.DateTimeFormatter; @@ -43,7 +39,6 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.UUID; import java.util.stream.Collectors; @Service @@ -59,12 +54,7 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ private final AiTaskService aiTaskService; private final MeetingTranscriptMapper transcriptMapper; private final LlmModelMapper llmModelMapper; - - @Value("${unisbase.app.upload-path}") - private String uploadPath; - - @Value("${unisbase.app.resource-prefix:/api/static/}") - private String resourcePrefix; + private final MeetingAudioUploadSupport meetingAudioUploadSupport; @Override @Transactional(rollbackFor = Exception.class) @@ -239,27 +229,7 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ } private String storeStagingAudio(MultipartFile audioFile) throws IOException { - String basePath = uploadPath.endsWith("/") ? uploadPath : uploadPath + "/"; - Path uploadDir = Paths.get(basePath, "audio"); - Files.createDirectories(uploadDir); - - String originalName = sanitizeFileName(audioFile.getOriginalFilename()); - Path target = uploadDir.resolve(UUID.randomUUID() + "_" + originalName); - Files.copy(audioFile.getInputStream(), target, StandardCopyOption.REPLACE_EXISTING); - - String baseResourcePrefix = resourcePrefix.endsWith("/") ? resourcePrefix : resourcePrefix + "/"; - return baseResourcePrefix + "audio/" + target.getFileName(); - } - - private String sanitizeFileName(String fileName) { - String value = fileName == null || fileName.isBlank() ? "audio" : fileName; - value = value.replace('\\', '/'); - int slashIndex = value.lastIndexOf('/'); - if (slashIndex >= 0) { - value = value.substring(slashIndex + 1); - } - value = value.replaceAll("[^A-Za-z0-9._-]", "_"); - return value.isBlank() ? "audio" : value; + return meetingAudioUploadSupport.storeUploadedAudio(audioFile); } private void resetOrCreateAsrTask(Long meetingId, RealtimeMeetingRuntimeProfile profile) { diff --git a/backend/src/main/java/com/imeeting/service/biz/HotWordGroupService.java b/backend/src/main/java/com/imeeting/service/biz/HotWordGroupService.java new file mode 100644 index 0000000..d5955a5 --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/biz/HotWordGroupService.java @@ -0,0 +1,22 @@ +package com.imeeting.service.biz; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.imeeting.dto.biz.HotWordGroupDTO; +import com.imeeting.dto.biz.HotWordGroupVO; +import com.imeeting.entity.biz.HotWordGroup; +import com.unisbase.dto.PageResult; + +import java.util.List; + +public interface HotWordGroupService extends IService { + + HotWordGroupVO saveGroup(HotWordGroupDTO dto, Long userId, Long tenantId); + + HotWordGroupVO updateGroup(HotWordGroupDTO dto); + + boolean removeGroupById(Long id, Long tenantId); + + PageResult> pageGroups(Integer current, Integer size, String name, Long tenantId); + + List listVisibleOptions(Long tenantId); +} diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/HotWordGroupServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/HotWordGroupServiceImpl.java new file mode 100644 index 0000000..1d37c2c --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/biz/impl/HotWordGroupServiceImpl.java @@ -0,0 +1,148 @@ +package com.imeeting.service.biz.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.imeeting.dto.biz.HotWordGroupDTO; +import com.imeeting.dto.biz.HotWordGroupVO; +import com.imeeting.entity.biz.HotWord; +import com.imeeting.entity.biz.HotWordGroup; +import com.imeeting.entity.biz.PromptTemplate; +import com.imeeting.mapper.biz.HotWordGroupMapper; +import com.imeeting.mapper.biz.HotWordMapper; +import com.imeeting.mapper.biz.PromptTemplateMapper; +import com.imeeting.service.biz.HotWordGroupService; +import com.unisbase.dto.PageResult; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class HotWordGroupServiceImpl extends ServiceImpl implements HotWordGroupService { + + private final HotWordMapper hotWordMapper; + private final PromptTemplateMapper promptTemplateMapper; + + @Override + @Transactional(rollbackFor = Exception.class) + public HotWordGroupVO saveGroup(HotWordGroupDTO dto, Long userId, Long tenantId) { + HotWordGroup entity = new HotWordGroup(); + if (tenantId != null) { + entity.setTenantId(tenantId); + } + entity.setCreatorId(userId); + copyProperties(dto, entity); + this.save(entity); + return toVO(entity, 0L); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public HotWordGroupVO updateGroup(HotWordGroupDTO dto) { + HotWordGroup entity = this.getById(dto.getId()); + if (entity == null) { + throw new IllegalArgumentException("热词组不存在"); + } + copyProperties(dto, entity); + this.updateById(entity); + return toVO(entity, countHotWords(entity.getId())); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean removeGroupById(Long id, Long tenantId) { + HotWordGroup group = this.getById(id); + if (group == null) { + return true; + } + if (tenantId != null && !tenantId.equals(group.getTenantId())) { + return true; + } + long referencedTemplateCount = promptTemplateMapper.selectCount(new LambdaQueryWrapper() + .eq(PromptTemplate::getHotWordGroupId, id)); + if (referencedTemplateCount > 0) { + throw new IllegalArgumentException("该热词组已被会议总结模板引用,无法删除"); + } + + long memberHotWordCount = hotWordMapper.selectCount(new LambdaQueryWrapper() + .eq(HotWord::getHotWordGroupId, id)); + if (memberHotWordCount > 0) { + throw new IllegalArgumentException("该热词组下仍有关联热词,无法删除"); + } + return this.removeById(id); + } + + @Override + public PageResult> pageGroups(Integer current, Integer size, String name, Long tenantId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .like(name != null && !name.isBlank(), HotWordGroup::getGroupName, name) + .orderByDesc(HotWordGroup::getCreatedAt); + wrapper.eq(tenantId != null, HotWordGroup::getTenantId, tenantId); + Page page = this.page(new Page<>(current, size), wrapper); + Map countMap = queryHotWordCountMap(page.getRecords().stream().map(HotWordGroup::getId).toList()); + + PageResult> result = new PageResult<>(); + result.setTotal(page.getTotal()); + result.setRecords(page.getRecords().stream() + .map(item -> toVO(item, countMap.getOrDefault(item.getId(), 0L))) + .toList()); + return result; + } + + @Override + public List listVisibleOptions(Long tenantId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(HotWordGroup::getStatus, 1) + .orderByDesc(HotWordGroup::getCreatedAt); + wrapper.eq(tenantId != null, HotWordGroup::getTenantId, tenantId); + List groups = this.list(wrapper); + Map countMap = queryHotWordCountMap(groups.stream().map(HotWordGroup::getId).toList()); + return groups.stream() + .map(item -> toVO(item, countMap.getOrDefault(item.getId(), 0L))) + .toList(); + } + + private Map queryHotWordCountMap(List groupIds) { + if (groupIds == null || groupIds.isEmpty()) { + return Collections.emptyMap(); + } + return hotWordMapper.selectList(new LambdaQueryWrapper() + .in(HotWord::getHotWordGroupId, groupIds) + .select(HotWord::getHotWordGroupId)) + .stream() + .map(HotWord::getHotWordGroupId) + .collect(Collectors.groupingBy(Function.identity(), Collectors.counting())); + } + + private long countHotWords(Long groupId) { + return hotWordMapper.selectCount(new LambdaQueryWrapper() + .eq(HotWord::getHotWordGroupId, groupId)); + } + + private void copyProperties(HotWordGroupDTO dto, HotWordGroup entity) { + entity.setGroupName(dto.getGroupName()); + entity.setStatus(dto.getStatus()); + entity.setRemark(dto.getRemark()); + } + + private HotWordGroupVO toVO(HotWordGroup entity, Long hotWordCount) { + HotWordGroupVO vo = new HotWordGroupVO(); + vo.setId(entity.getId()); + vo.setTenantId(entity.getTenantId()); + vo.setGroupName(entity.getGroupName()); + vo.setCreatorId(entity.getCreatorId()); + vo.setStatus(entity.getStatus()); + vo.setHotWordCount(hotWordCount); + vo.setRemark(entity.getRemark()); + vo.setCreatedAt(entity.getCreatedAt()); + vo.setUpdatedAt(entity.getUpdatedAt()); + return vo; + } +} diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingAudioUploadSupport.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingAudioUploadSupport.java new file mode 100644 index 0000000..168d24c --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingAudioUploadSupport.java @@ -0,0 +1,169 @@ +package com.imeeting.service.biz.impl; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.util.Locale; +import java.util.Set; +import java.util.UUID; + +@Component +public class MeetingAudioUploadSupport { + + public static final String STAGING_AUDIO_TOKEN_PREFIX = "staging:audio/"; + + private static final int HEADER_SIZE = 32; + private static final Set SUPPORTED_EXTENSIONS = Set.of("mp3", "wav", "m4a"); + private static final Set WAV_MIME_TYPES = Set.of("audio/wav", "audio/x-wav", "audio/wave", "audio/vnd.wave"); + private static final Set MP3_MIME_TYPES = Set.of("audio/mpeg", "audio/mp3", "audio/x-mp3", "audio/x-mpeg"); + private static final Set M4A_MIME_TYPES = Set.of("audio/mp4", "audio/m4a", "audio/x-m4a", "audio/aac", "video/mp4"); + + @Value("${unisbase.app.upload-path}") + private String uploadPath; + + public String storeUploadedAudio(MultipartFile file) throws IOException { + if (file == null || file.isEmpty()) { + throw new RuntimeException("音频文件不能为空"); + } + + String extension = resolveExtension(file.getOriginalFilename()); + validateContentType(file.getContentType(), extension); + validateFileHeader(file, extension); + + Path stagingDir = resolveStagingAudioDirectory(uploadPath); + Files.createDirectories(stagingDir); + + String storedFileName = UUID.randomUUID() + "." + extension; + Path targetPath = stagingDir.resolve(storedFileName); + try (InputStream inputStream = file.getInputStream()) { + Files.copy(inputStream, targetPath, StandardCopyOption.REPLACE_EXISTING); + } + return buildStagingAudioToken(storedFileName); + } + + public static boolean isStagingAudioToken(String audioUrl) { + return StringUtils.hasText(audioUrl) && audioUrl.startsWith(STAGING_AUDIO_TOKEN_PREFIX); + } + + public static String buildStagingAudioToken(String storedFileName) { + return STAGING_AUDIO_TOKEN_PREFIX + storedFileName; + } + + public static Path resolveStagingAudioDirectory(String uploadPath) { + Path uploadRoot = Paths.get(normalizeUploadPath(uploadPath)); + Path parent = uploadRoot.getParent(); + String uploadDirName = uploadRoot.getFileName() == null ? "uploads" : uploadRoot.getFileName().toString(); + Path stagingRoot = parent == null + ? Paths.get("." + uploadDirName + "-meeting-staging") + : parent.resolve("." + uploadDirName + "-meeting-staging"); + return stagingRoot.resolve("audio"); + } + + public static Path resolveStagingAudioPath(String uploadPath, String audioUrl) { + if (!isStagingAudioToken(audioUrl)) { + return null; + } + String storedFileName = audioUrl.substring(STAGING_AUDIO_TOKEN_PREFIX.length()).trim(); + if (!StringUtils.hasText(storedFileName)) { + return null; + } + return resolveStagingAudioDirectory(uploadPath).resolve(storedFileName); + } + + private static String normalizeUploadPath(String uploadPath) { + if (!StringUtils.hasText(uploadPath)) { + throw new IllegalArgumentException("uploadPath must not be blank"); + } + return uploadPath.endsWith("/") || uploadPath.endsWith("\\") ? uploadPath : uploadPath + "/"; + } + + private String resolveExtension(String originalFilename) { + if (!StringUtils.hasText(originalFilename)) { + throw new RuntimeException("音频文件名缺少扩展名,仅支持 mp3、wav、m4a"); + } + String normalized = originalFilename.replace('\\', '/'); + int slashIndex = normalized.lastIndexOf('/'); + if (slashIndex >= 0) { + normalized = normalized.substring(slashIndex + 1); + } + int dotIndex = normalized.lastIndexOf('.'); + if (dotIndex < 0 || dotIndex == normalized.length() - 1) { + throw new RuntimeException("音频文件名缺少扩展名,仅支持 mp3、wav、m4a"); + } + String extension = normalized.substring(dotIndex + 1).toLowerCase(Locale.ROOT); + if (!SUPPORTED_EXTENSIONS.contains(extension)) { + throw new RuntimeException("仅支持 mp3、wav、m4a 音频文件"); + } + return extension; + } + + private void validateContentType(String contentType, String extension) { + if (!StringUtils.hasText(contentType)) { + return; + } + String normalized = contentType.trim().toLowerCase(Locale.ROOT); + if ("application/octet-stream".equals(normalized)) { + return; + } + Set allowedMimeTypes = switch (extension) { + case "wav" -> WAV_MIME_TYPES; + case "mp3" -> MP3_MIME_TYPES; + case "m4a" -> M4A_MIME_TYPES; + default -> Set.of(); + }; + if (!allowedMimeTypes.contains(normalized)) { + throw new RuntimeException("上传文件不是受支持的音频格式"); + } + } + + private void validateFileHeader(MultipartFile file, String extension) throws IOException { + byte[] header; + try (InputStream inputStream = file.getInputStream()) { + header = inputStream.readNBytes(HEADER_SIZE); + } + boolean valid = switch (extension) { + case "wav" -> isWav(header); + case "mp3" -> isMp3(header); + case "m4a" -> isM4a(header); + default -> false; + }; + if (!valid) { + throw new RuntimeException("上传文件内容与音频格式不匹配,仅支持 mp3、wav、m4a"); + } + } + + private boolean isWav(byte[] header) { + return header.length >= 12 + && "RIFF".equals(ascii(header, 0, 4)) + && "WAVE".equals(ascii(header, 8, 12)); + } + + private boolean isMp3(byte[] header) { + if (header.length >= 3 && "ID3".equals(ascii(header, 0, 3))) { + return true; + } + return header.length >= 2 + && (header[0] & 0xFF) == 0xFF + && (header[1] & 0xE0) == 0xE0; + } + + private boolean isM4a(byte[] header) { + return header.length >= 12 && "ftyp".equals(ascii(header, 4, 8)); + } + + private String ascii(byte[] header, int startInclusive, int endExclusive) { + if (header.length < endExclusive) { + return ""; + } + return new String(header, startInclusive, endExclusive - startInclusive, StandardCharsets.US_ASCII); + } +} 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 688b240..9c9f43f 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 @@ -25,6 +25,7 @@ import com.imeeting.service.biz.MeetingService; import com.imeeting.service.biz.MeetingSummaryFileService; import com.imeeting.service.biz.RealtimeMeetingSessionStateService; import com.imeeting.service.realtime.RealtimeMeetingAudioStorageService; +import lombok.extern.slf4j.Slf4j; import lombok.RequiredArgsConstructor; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; @@ -41,6 +42,7 @@ import java.util.Objects; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; +@Slf4j @Service @RequiredArgsConstructor public class MeetingCommandServiceImpl implements MeetingCommandService { @@ -133,8 +135,14 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { @Override @Transactional(rollbackFor = Exception.class) public void deleteMeeting(Long id) { + transcriptMapper.delete(new LambdaQueryWrapper() + .eq(MeetingTranscript::getMeetingId, id)); + aiTaskService.remove(new LambdaQueryWrapper() + .eq(AiTask::getMeetingId, id)); meetingService.removeById(id); realtimeMeetingSessionStateService.clear(id); + redisTemplate.delete(RedisKeys.meetingProgressKey(id)); + deleteMeetingArtifactsAfterCommit(id); } @Override @@ -551,6 +559,26 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { }); } + private void deleteMeetingArtifactsAfterCommit(Long meetingId) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + meetingDomainSupport.deleteMeetingArtifacts(meetingId); + return; + } + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + meetingDomainSupport.deleteMeetingArtifacts(meetingId); + } + + @Override + public void afterCompletion(int status) { + if (status != STATUS_COMMITTED) { + log.debug("Skip meeting artifact cleanup because transaction rolled back, meetingId={}", meetingId); + } + } + }); + } + private void updateMeetingProgress(Long meetingId, int percent, String message, int eta) { try { Map progress = new HashMap<>(); diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingDomainSupport.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingDomainSupport.java index 0474a17..8f91750 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingDomainSupport.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingDomainSupport.java @@ -25,6 +25,7 @@ import java.nio.file.Paths; import java.nio.file.StandardCopyOption; import java.time.LocalDateTime; import java.util.Arrays; +import java.util.Comparator; import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -83,12 +84,13 @@ public class MeetingDomainSupport { if (audioUrl == null || audioUrl.isBlank()) { return audioUrl; } - if (!audioUrl.startsWith("/api/static/audio/")) { + Path sourcePath = resolveAudioSourcePath(audioUrl); + if (sourcePath == null) { return audioUrl; } try { - AudioRelocationPlan plan = buildAudioRelocationPlan(meetingId, audioUrl); + AudioRelocationPlan plan = buildAudioRelocationPlan(meetingId, sourcePath); if (plan == null || !Files.exists(plan.sourcePath())) { return audioUrl; } @@ -106,17 +108,38 @@ public class MeetingDomainSupport { } } - private AudioRelocationPlan buildAudioRelocationPlan(Long meetingId, String audioUrl) { - String fileName = audioUrl.substring(audioUrl.lastIndexOf("/") + 1); - String basePath = uploadPath.endsWith("/") ? uploadPath : uploadPath + "/"; - Path sourcePath = Paths.get(basePath, "audio", fileName); + public void deleteMeetingArtifacts(Long meetingId) { + if (meetingId == null) { + return; + } + Path meetingDirectory = Paths.get(normalizedUploadPath(), "meetings", String.valueOf(meetingId)); + if (!Files.exists(meetingDirectory)) { + return; + } + try (var paths = Files.walk(meetingDirectory)) { + paths.sorted(Comparator.reverseOrder()) + .forEach(path -> { + try { + Files.deleteIfExists(path); + } catch (Exception ex) { + throw new RuntimeException("Delete meeting artifact failed: " + path, ex); + } + }); + } catch (RuntimeException ex) { + throw ex; + } catch (Exception ex) { + throw new RuntimeException("Delete meeting artifacts failed", ex); + } + } + private AudioRelocationPlan buildAudioRelocationPlan(Long meetingId, Path sourcePath) { + String fileName = sourcePath.getFileName().toString(); String ext = ""; int dotIdx = fileName.lastIndexOf('.'); if (dotIdx > 0) { ext = fileName.substring(dotIdx); } - Path targetDir = Paths.get(basePath, "meetings", String.valueOf(meetingId)); + Path targetDir = Paths.get(normalizedUploadPath(), "meetings", String.valueOf(meetingId)); Path targetPath = targetDir.resolve("source_audio" + ext); Path backupPath = Files.exists(targetPath) ? targetDir.resolve("source_audio" + ext + ".rollback-" + UUID.randomUUID() + ".bak") @@ -129,6 +152,21 @@ public class MeetingDomainSupport { ); } + private Path resolveAudioSourcePath(String audioUrl) { + if (MeetingAudioUploadSupport.isStagingAudioToken(audioUrl)) { + return MeetingAudioUploadSupport.resolveStagingAudioPath(uploadPath, audioUrl); + } + if (!audioUrl.startsWith("/api/static/audio/")) { + return null; + } + String fileName = audioUrl.substring(audioUrl.lastIndexOf("/") + 1); + return Paths.get(normalizedUploadPath(), "audio", fileName); + } + + private String normalizedUploadPath() { + return uploadPath.endsWith("/") || uploadPath.endsWith("\\") ? uploadPath : uploadPath + "/"; + } + private void registerAudioRelocationCompensation(Long meetingId, AudioRelocationPlan plan) { if (!TransactionSynchronizationManager.isSynchronizationActive()) { log.warn("Audio relocation compensation skipped because transaction synchronization is inactive, meetingId={}", meetingId); diff --git a/backend/src/test/java/com/imeeting/service/android/legacy/LegacyMeetingAdapterServiceImplTest.java b/backend/src/test/java/com/imeeting/service/android/legacy/LegacyMeetingAdapterServiceImplTest.java index b35a9cd..79e5f35 100644 --- a/backend/src/test/java/com/imeeting/service/android/legacy/LegacyMeetingAdapterServiceImplTest.java +++ b/backend/src/test/java/com/imeeting/service/android/legacy/LegacyMeetingAdapterServiceImplTest.java @@ -11,6 +11,7 @@ import com.imeeting.service.biz.MeetingAccessService; import com.imeeting.service.biz.MeetingRuntimeProfileResolver; import com.imeeting.service.biz.MeetingService; import com.imeeting.service.biz.PromptTemplateService; +import com.imeeting.service.biz.impl.MeetingAudioUploadSupport; import com.imeeting.service.biz.impl.MeetingDomainSupport; import com.imeeting.service.biz.impl.MeetingSummaryPromptAssembler; import com.unisbase.security.LoginUser; @@ -67,7 +68,8 @@ class LegacyMeetingAdapterServiceImplTest { mock(MeetingSummaryPromptAssembler.class), mock(AiTaskService.class), mock(MeetingTranscriptMapper.class), - mock(LlmModelMapper.class) + mock(LlmModelMapper.class), + mock(MeetingAudioUploadSupport.class) ); LegacyMeetingCreateRequest request = new LegacyMeetingCreateRequest(); diff --git a/backend/src/test/java/com/imeeting/service/biz/impl/MeetingCommandServiceImplTest.java b/backend/src/test/java/com/imeeting/service/biz/impl/MeetingCommandServiceImplTest.java index 94d1780..d1d7a7a 100644 --- a/backend/src/test/java/com/imeeting/service/biz/impl/MeetingCommandServiceImplTest.java +++ b/backend/src/test/java/com/imeeting/service/biz/impl/MeetingCommandServiceImplTest.java @@ -1,6 +1,7 @@ package com.imeeting.service.biz.impl; import com.fasterxml.jackson.databind.ObjectMapper; +import com.imeeting.common.RedisKeys; import com.imeeting.dto.biz.CreateMeetingCommand; import com.imeeting.dto.biz.CreateRealtimeMeetingCommand; import com.imeeting.dto.biz.MeetingVO; @@ -182,6 +183,47 @@ class MeetingCommandServiceImplTest { assertNull(result.getHostName()); } + @Test + void deleteMeetingShouldCleanupRelatedDataAndArtifactsAfterCommit() { + MeetingService meetingService = mock(MeetingService.class); + MeetingDomainSupport meetingDomainSupport = mock(MeetingDomainSupport.class); + AiTaskService aiTaskService = mock(AiTaskService.class); + com.imeeting.mapper.biz.MeetingTranscriptMapper transcriptMapper = mock(com.imeeting.mapper.biz.MeetingTranscriptMapper.class); + RealtimeMeetingSessionStateService sessionStateService = mock(RealtimeMeetingSessionStateService.class); + StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class); + + MeetingCommandServiceImpl service = new MeetingCommandServiceImpl( + meetingService, + aiTaskService, + mock(HotWordService.class), + transcriptMapper, + mock(MeetingSummaryFileService.class), + meetingDomainSupport, + mockRuntimeProfileResolver(), + sessionStateService, + mock(RealtimeMeetingAudioStorageService.class), + redisTemplate, + new ObjectMapper() + ); + + TransactionSynchronizationManager.initSynchronization(); + try { + service.deleteMeeting(901L); + + verify(transcriptMapper).delete(any()); + verify(aiTaskService).remove(any()); + verify(meetingService).removeById(901L); + verify(sessionStateService).clear(901L); + verify(redisTemplate).delete(RedisKeys.meetingProgressKey(901L)); + verify(meetingDomainSupport, never()).deleteMeetingArtifacts(901L); + + TransactionSynchronizationUtils.triggerAfterCommit(); + + verify(meetingDomainSupport).deleteMeetingArtifacts(901L); + } finally { + TransactionSynchronizationManager.clearSynchronization(); + } + } @Test void completeRealtimeMeetingShouldBindFinalizedRealtimeAudio() { diff --git a/backend/src/test/java/com/imeeting/service/biz/impl/MeetingDomainSupportTest.java b/backend/src/test/java/com/imeeting/service/biz/impl/MeetingDomainSupportTest.java index cbcc2fc..68a44ea 100644 --- a/backend/src/test/java/com/imeeting/service/biz/impl/MeetingDomainSupportTest.java +++ b/backend/src/test/java/com/imeeting/service/biz/impl/MeetingDomainSupportTest.java @@ -80,6 +80,42 @@ class MeetingDomainSupportTest { assertFalse(hasBackupFile(tempDir.resolve("uploads/meetings/102"))); } + @Test + void shouldRelocatePrivateStagingAudioToken() throws Exception { + MeetingDomainSupport support = newSupport(); + Path source = writeFile( + tempDir.resolve(".uploads-meeting-staging/audio/private-upload.wav"), + "private-audio" + ); + + TransactionSynchronizationManager.initSynchronization(); + String relocatedUrl = support.relocateAudioUrl( + 103L, + MeetingAudioUploadSupport.buildStagingAudioToken(source.getFileName().toString()) + ); + triggerAfterCompletion(TransactionSynchronization.STATUS_COMMITTED); + + Path target = tempDir.resolve("uploads/meetings/103/source_audio.wav"); + assertEquals("/api/static/meetings/103/source_audio.wav", relocatedUrl); + assertFalse(Files.exists(source)); + assertTrue(Files.exists(target)); + assertEquals("private-audio", Files.readString(target, StandardCharsets.UTF_8)); + } + + @Test + void shouldDeleteMeetingArtifactsDirectory() throws Exception { + MeetingDomainSupport support = newSupport(); + Path summary = writeFile(tempDir.resolve("uploads/meetings/301/summaries/summary_1.md"), "summary"); + Path audio = writeFile(tempDir.resolve("uploads/meetings/301/source_audio.wav"), "audio"); + + assertTrue(Files.exists(summary)); + assertTrue(Files.exists(audio)); + + support.deleteMeetingArtifacts(301L); + + assertFalse(Files.exists(tempDir.resolve("uploads/meetings/301"))); + } + @Test void shouldPreferLatestSummaryTaskIdWhenResolvingLastUserPrompt() { AiTaskService aiTaskService = mock(AiTaskService.class);