feat: 添加会议音频上传支持和热词组管理功能
- 添加 `MeetingAudioUploadSupport` 类,支持音频文件上传和验证 - 在 `MeetingCommandServiceImpl` 中添加删除会议时清理相关数据和工件的逻辑 - 添加热词组相关的实体、服务、控制器和映射器 - 更新测试类以包含新的依赖和测试用例dev_na
parent
b36a08adc7
commit
29551dfbe2
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String> 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 = "创建离线会议")
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<MeetingTranscript>()
|
||||
.eq(MeetingTranscript::getMeetingId, id));
|
||||
aiTaskService.remove(new LambdaQueryWrapper<AiTask>()
|
||||
.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<String, Object> progress = new HashMap<>();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue