feat: 添加会议音频上传支持和热词组管理功能

- 添加 `MeetingAudioUploadSupport` 类,支持音频文件上传和验证
- 在 `MeetingCommandServiceImpl` 中添加删除会议时清理相关数据和工件的逻辑
- 添加热词组相关的实体、服务、控制器和映射器
- 更新测试类以包含新的依赖和测试用例
dev_na
chenhao 2026-04-22 16:38:45 +08:00
parent b36a08adc7
commit 29551dfbe2
15 changed files with 712 additions and 62 deletions

View File

@ -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<HotWordGroupVO> 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<HotWordGroupVO> 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<Boolean> 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<PageResult<List<HotWordGroupVO>>> 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<List<HotWordGroupVO>> options(@RequestParam(required = false) Long tenantId) {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
Long targetTenantId = resolveTargetTenantId(loginUser, tenantId);
return ApiResponse.ok(hotWordGroupService.listVisibleOptions(targetTenantId));
}
}

View File

@ -25,13 +25,13 @@ import com.imeeting.service.biz.MeetingQueryService;
import com.imeeting.service.biz.PromptTemplateService; 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.unisbase.common.ApiResponse; import com.unisbase.common.ApiResponse;
import com.unisbase.dto.PageResult; import com.unisbase.dto.PageResult;
import com.unisbase.security.LoginUser; import com.unisbase.security.LoginUser;
import io.swagger.v3.oas.annotations.Operation; 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.Value;
import org.springframework.data.redis.core.StringRedisTemplate; 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;
@ -49,7 +49,6 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.net.URLEncoder; import java.net.URLEncoder;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
@ -57,7 +56,6 @@ import java.util.LinkedHashMap;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.UUID;
@Tag(name = "会议管理") @Tag(name = "会议管理")
@RestController @RestController
@ -71,9 +69,8 @@ public class MeetingController {
private final PromptTemplateService promptTemplateService; private final PromptTemplateService promptTemplateService;
private final RealtimeMeetingSocketSessionService realtimeMeetingSocketSessionService; private final RealtimeMeetingSocketSessionService realtimeMeetingSocketSessionService;
private final RealtimeMeetingSessionStateService realtimeMeetingSessionStateService; private final RealtimeMeetingSessionStateService realtimeMeetingSessionStateService;
private final MeetingAudioUploadSupport meetingAudioUploadSupport;
private final StringRedisTemplate redisTemplate; private final StringRedisTemplate redisTemplate;
private final String uploadPath;
private final String resourcePrefix;
public MeetingController(MeetingQueryService meetingQueryService, public MeetingController(MeetingQueryService meetingQueryService,
MeetingCommandService meetingCommandService, MeetingCommandService meetingCommandService,
@ -82,9 +79,8 @@ public class MeetingController {
PromptTemplateService promptTemplateService, PromptTemplateService promptTemplateService,
RealtimeMeetingSocketSessionService realtimeMeetingSocketSessionService, RealtimeMeetingSocketSessionService realtimeMeetingSocketSessionService,
RealtimeMeetingSessionStateService realtimeMeetingSessionStateService, RealtimeMeetingSessionStateService realtimeMeetingSessionStateService,
StringRedisTemplate redisTemplate, MeetingAudioUploadSupport meetingAudioUploadSupport,
@Value("${unisbase.app.upload-path}") String uploadPath, StringRedisTemplate redisTemplate) {
@Value("${unisbase.app.resource-prefix}") String resourcePrefix) {
this.meetingQueryService = meetingQueryService; this.meetingQueryService = meetingQueryService;
this.meetingCommandService = meetingCommandService; this.meetingCommandService = meetingCommandService;
this.meetingAccessService = meetingAccessService; this.meetingAccessService = meetingAccessService;
@ -92,9 +88,8 @@ public class MeetingController {
this.promptTemplateService = promptTemplateService; this.promptTemplateService = promptTemplateService;
this.realtimeMeetingSocketSessionService = realtimeMeetingSocketSessionService; this.realtimeMeetingSocketSessionService = realtimeMeetingSocketSessionService;
this.realtimeMeetingSessionStateService = realtimeMeetingSessionStateService; this.realtimeMeetingSessionStateService = realtimeMeetingSessionStateService;
this.meetingAudioUploadSupport = meetingAudioUploadSupport;
this.redisTemplate = redisTemplate; this.redisTemplate = redisTemplate;
this.uploadPath = uploadPath;
this.resourcePrefix = resourcePrefix;
} }
@Operation(summary = "查询会议处理进度") @Operation(summary = "查询会议处理进度")
@ -133,17 +128,7 @@ public class MeetingController {
@PostMapping("/upload") @PostMapping("/upload")
@PreAuthorize("isAuthenticated()") @PreAuthorize("isAuthenticated()")
public ApiResponse<String> upload(@RequestParam("file") MultipartFile file) throws IOException { public ApiResponse<String> upload(@RequestParam("file") MultipartFile file) throws IOException {
String basePath = uploadPath.endsWith("/") ? uploadPath : uploadPath + "/"; return ApiResponse.ok(meetingAudioUploadSupport.storeUploadedAudio(file));
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);
} }
@Operation(summary = "创建离线会议") @Operation(summary = "创建离线会议")

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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<HotWordGroup> {
@InterceptorIgnore(tenantLine = "true")
@Select({
"<script>",
"SELECT id, tenant_id, group_name, creator_id, status, remark, created_at, updated_at, is_deleted",
"FROM biz_hot_word_groups",
"WHERE is_deleted = 0",
"AND id IN",
"<foreach collection='ids' item='id' open='(' separator=',' close=')'>",
"#{id}",
"</foreach>",
"</script>"
})
List<HotWordGroup> selectByIdsIgnoreTenant(@Param("ids") List<Long> ids);
}

View File

@ -18,12 +18,12 @@ import com.imeeting.service.biz.MeetingAccessService;
import com.imeeting.service.biz.MeetingRuntimeProfileResolver; import com.imeeting.service.biz.MeetingRuntimeProfileResolver;
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.MeetingAudioUploadSupport;
import com.imeeting.service.biz.impl.MeetingDomainSupport; import com.imeeting.service.biz.impl.MeetingDomainSupport;
import com.imeeting.service.biz.impl.MeetingSummaryPromptAssembler; import com.imeeting.service.biz.impl.MeetingSummaryPromptAssembler;
import com.imeeting.service.realtime.RealtimeMeetingAudioStorageService; import com.imeeting.service.realtime.RealtimeMeetingAudioStorageService;
import com.unisbase.security.LoginUser; import com.unisbase.security.LoginUser;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
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;
@ -31,10 +31,6 @@ import org.springframework.transaction.support.TransactionSynchronizationManager
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import java.io.IOException; 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.LocalDateTime;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
@ -43,7 +39,6 @@ import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.UUID;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@Service @Service
@ -59,12 +54,7 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ
private final AiTaskService aiTaskService; private final AiTaskService aiTaskService;
private final MeetingTranscriptMapper transcriptMapper; private final MeetingTranscriptMapper transcriptMapper;
private final LlmModelMapper llmModelMapper; private final LlmModelMapper llmModelMapper;
private final MeetingAudioUploadSupport meetingAudioUploadSupport;
@Value("${unisbase.app.upload-path}")
private String uploadPath;
@Value("${unisbase.app.resource-prefix:/api/static/}")
private String resourcePrefix;
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
@ -239,27 +229,7 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ
} }
private String storeStagingAudio(MultipartFile audioFile) throws IOException { private String storeStagingAudio(MultipartFile audioFile) throws IOException {
String basePath = uploadPath.endsWith("/") ? uploadPath : uploadPath + "/"; return meetingAudioUploadSupport.storeUploadedAudio(audioFile);
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;
} }
private void resetOrCreateAsrTask(Long meetingId, RealtimeMeetingRuntimeProfile profile) { private void resetOrCreateAsrTask(Long meetingId, RealtimeMeetingRuntimeProfile profile) {

View File

@ -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<HotWordGroup> {
HotWordGroupVO saveGroup(HotWordGroupDTO dto, Long userId, Long tenantId);
HotWordGroupVO updateGroup(HotWordGroupDTO dto);
boolean removeGroupById(Long id, Long tenantId);
PageResult<List<HotWordGroupVO>> pageGroups(Integer current, Integer size, String name, Long tenantId);
List<HotWordGroupVO> listVisibleOptions(Long tenantId);
}

View File

@ -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<HotWordGroupMapper, HotWordGroup> 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<PromptTemplate>()
.eq(PromptTemplate::getHotWordGroupId, id));
if (referencedTemplateCount > 0) {
throw new IllegalArgumentException("该热词组已被会议总结模板引用,无法删除");
}
long memberHotWordCount = hotWordMapper.selectCount(new LambdaQueryWrapper<HotWord>()
.eq(HotWord::getHotWordGroupId, id));
if (memberHotWordCount > 0) {
throw new IllegalArgumentException("该热词组下仍有关联热词,无法删除");
}
return this.removeById(id);
}
@Override
public PageResult<List<HotWordGroupVO>> pageGroups(Integer current, Integer size, String name, Long tenantId) {
LambdaQueryWrapper<HotWordGroup> wrapper = new LambdaQueryWrapper<HotWordGroup>()
.like(name != null && !name.isBlank(), HotWordGroup::getGroupName, name)
.orderByDesc(HotWordGroup::getCreatedAt);
wrapper.eq(tenantId != null, HotWordGroup::getTenantId, tenantId);
Page<HotWordGroup> page = this.page(new Page<>(current, size), wrapper);
Map<Long, Long> countMap = queryHotWordCountMap(page.getRecords().stream().map(HotWordGroup::getId).toList());
PageResult<List<HotWordGroupVO>> 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<HotWordGroupVO> listVisibleOptions(Long tenantId) {
LambdaQueryWrapper<HotWordGroup> wrapper = new LambdaQueryWrapper<HotWordGroup>()
.eq(HotWordGroup::getStatus, 1)
.orderByDesc(HotWordGroup::getCreatedAt);
wrapper.eq(tenantId != null, HotWordGroup::getTenantId, tenantId);
List<HotWordGroup> groups = this.list(wrapper);
Map<Long, Long> countMap = queryHotWordCountMap(groups.stream().map(HotWordGroup::getId).toList());
return groups.stream()
.map(item -> toVO(item, countMap.getOrDefault(item.getId(), 0L)))
.toList();
}
private Map<Long, Long> queryHotWordCountMap(List<Long> groupIds) {
if (groupIds == null || groupIds.isEmpty()) {
return Collections.emptyMap();
}
return hotWordMapper.selectList(new LambdaQueryWrapper<HotWord>()
.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<HotWord>()
.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;
}
}

View File

@ -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<String> SUPPORTED_EXTENSIONS = Set.of("mp3", "wav", "m4a");
private static final Set<String> WAV_MIME_TYPES = Set.of("audio/wav", "audio/x-wav", "audio/wave", "audio/vnd.wave");
private static final Set<String> MP3_MIME_TYPES = Set.of("audio/mpeg", "audio/mp3", "audio/x-mp3", "audio/x-mpeg");
private static final Set<String> 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<String> 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);
}
}

View File

@ -25,6 +25,7 @@ import com.imeeting.service.biz.MeetingService;
import com.imeeting.service.biz.MeetingSummaryFileService; import com.imeeting.service.biz.MeetingSummaryFileService;
import com.imeeting.service.biz.RealtimeMeetingSessionStateService; import com.imeeting.service.biz.RealtimeMeetingSessionStateService;
import com.imeeting.service.realtime.RealtimeMeetingAudioStorageService; import com.imeeting.service.realtime.RealtimeMeetingAudioStorageService;
import lombok.extern.slf4j.Slf4j;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@ -41,6 +42,7 @@ import java.util.Objects;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@Slf4j
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class MeetingCommandServiceImpl implements MeetingCommandService { public class MeetingCommandServiceImpl implements MeetingCommandService {
@ -133,8 +135,14 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public void deleteMeeting(Long id) { public void deleteMeeting(Long id) {
transcriptMapper.delete(new LambdaQueryWrapper<MeetingTranscript>()
.eq(MeetingTranscript::getMeetingId, id));
aiTaskService.remove(new LambdaQueryWrapper<AiTask>()
.eq(AiTask::getMeetingId, id));
meetingService.removeById(id); meetingService.removeById(id);
realtimeMeetingSessionStateService.clear(id); realtimeMeetingSessionStateService.clear(id);
redisTemplate.delete(RedisKeys.meetingProgressKey(id));
deleteMeetingArtifactsAfterCommit(id);
} }
@Override @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) { private void updateMeetingProgress(Long meetingId, int percent, String message, int eta) {
try { try {
Map<String, Object> progress = new HashMap<>(); Map<String, Object> progress = new HashMap<>();

View File

@ -25,6 +25,7 @@ import java.nio.file.Paths;
import java.nio.file.StandardCopyOption; import java.nio.file.StandardCopyOption;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Arrays; import java.util.Arrays;
import java.util.Comparator;
import java.util.Collections; import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
@ -83,12 +84,13 @@ public class MeetingDomainSupport {
if (audioUrl == null || audioUrl.isBlank()) { if (audioUrl == null || audioUrl.isBlank()) {
return audioUrl; return audioUrl;
} }
if (!audioUrl.startsWith("/api/static/audio/")) { Path sourcePath = resolveAudioSourcePath(audioUrl);
if (sourcePath == null) {
return audioUrl; return audioUrl;
} }
try { try {
AudioRelocationPlan plan = buildAudioRelocationPlan(meetingId, audioUrl); AudioRelocationPlan plan = buildAudioRelocationPlan(meetingId, sourcePath);
if (plan == null || !Files.exists(plan.sourcePath())) { if (plan == null || !Files.exists(plan.sourcePath())) {
return audioUrl; return audioUrl;
} }
@ -106,17 +108,38 @@ public class MeetingDomainSupport {
} }
} }
private AudioRelocationPlan buildAudioRelocationPlan(Long meetingId, String audioUrl) { public void deleteMeetingArtifacts(Long meetingId) {
String fileName = audioUrl.substring(audioUrl.lastIndexOf("/") + 1); if (meetingId == null) {
String basePath = uploadPath.endsWith("/") ? uploadPath : uploadPath + "/"; return;
Path sourcePath = Paths.get(basePath, "audio", fileName); }
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 = ""; String ext = "";
int dotIdx = fileName.lastIndexOf('.'); int dotIdx = fileName.lastIndexOf('.');
if (dotIdx > 0) { if (dotIdx > 0) {
ext = fileName.substring(dotIdx); 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 targetPath = targetDir.resolve("source_audio" + ext);
Path backupPath = Files.exists(targetPath) Path backupPath = Files.exists(targetPath)
? targetDir.resolve("source_audio" + ext + ".rollback-" + UUID.randomUUID() + ".bak") ? 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) { private void registerAudioRelocationCompensation(Long meetingId, AudioRelocationPlan plan) {
if (!TransactionSynchronizationManager.isSynchronizationActive()) { if (!TransactionSynchronizationManager.isSynchronizationActive()) {
log.warn("Audio relocation compensation skipped because transaction synchronization is inactive, meetingId={}", meetingId); log.warn("Audio relocation compensation skipped because transaction synchronization is inactive, meetingId={}", meetingId);

View File

@ -11,6 +11,7 @@ import com.imeeting.service.biz.MeetingAccessService;
import com.imeeting.service.biz.MeetingRuntimeProfileResolver; import com.imeeting.service.biz.MeetingRuntimeProfileResolver;
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.MeetingAudioUploadSupport;
import com.imeeting.service.biz.impl.MeetingDomainSupport; import com.imeeting.service.biz.impl.MeetingDomainSupport;
import com.imeeting.service.biz.impl.MeetingSummaryPromptAssembler; import com.imeeting.service.biz.impl.MeetingSummaryPromptAssembler;
import com.unisbase.security.LoginUser; import com.unisbase.security.LoginUser;
@ -67,7 +68,8 @@ class LegacyMeetingAdapterServiceImplTest {
mock(MeetingSummaryPromptAssembler.class), mock(MeetingSummaryPromptAssembler.class),
mock(AiTaskService.class), mock(AiTaskService.class),
mock(MeetingTranscriptMapper.class), mock(MeetingTranscriptMapper.class),
mock(LlmModelMapper.class) mock(LlmModelMapper.class),
mock(MeetingAudioUploadSupport.class)
); );
LegacyMeetingCreateRequest request = new LegacyMeetingCreateRequest(); LegacyMeetingCreateRequest request = new LegacyMeetingCreateRequest();

View File

@ -1,6 +1,7 @@
package com.imeeting.service.biz.impl; package com.imeeting.service.biz.impl;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.imeeting.common.RedisKeys;
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.MeetingVO; import com.imeeting.dto.biz.MeetingVO;
@ -182,6 +183,47 @@ class MeetingCommandServiceImplTest {
assertNull(result.getHostName()); 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 @Test
void completeRealtimeMeetingShouldBindFinalizedRealtimeAudio() { void completeRealtimeMeetingShouldBindFinalizedRealtimeAudio() {

View File

@ -80,6 +80,42 @@ class MeetingDomainSupportTest {
assertFalse(hasBackupFile(tempDir.resolve("uploads/meetings/102"))); 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 @Test
void shouldPreferLatestSummaryTaskIdWhenResolvingLastUserPrompt() { void shouldPreferLatestSummaryTaskIdWhenResolvingLastUserPrompt() {
AiTaskService aiTaskService = mock(AiTaskService.class); AiTaskService aiTaskService = mock(AiTaskService.class);