playSegmentUsingAudioFileUrl(AudioManagementSegments segment, String id) {
+ String audioFileUrl = segment.getAudioFileUrl();
+ if (audioFileUrl == null || audioFileUrl.trim().isEmpty()) {
+ log.warn("录音分段音频 URL 为空,ID: {}", id);
+ return ResponseEntity.badRequest().build();
+ }
+
+ String loc = audioFileUrl.trim();
+ String nameProbe = lastPathSegmentForAudioCheck(loc);
+ if (!isAudioFile(nameProbe)) {
+ log.warn("文件不是音频文件,location: {}", loc);
+ return ResponseEntity.badRequest().build();
+ }
+
+ String objectName = resolveMinioObjectName(loc);
+ if (objectName == null || objectName.isEmpty()) {
+ log.warn("无法从 URL 解析 MinIO 对象名,ID: {}, location: {}", id, loc);
+ return ResponseEntity.badRequest().build();
+ }
+
+ try {
+ InputStream inputStream = minIOService.downloadFile(objectName);
+ Resource resource = new InputStreamResource(inputStream);
+
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.parseMediaType(getContentType(nameProbe)));
+ headers.set("Accept-Ranges", "bytes");
+
+ return ResponseEntity.ok()
+ .headers(headers)
+ .body(resource);
+ } catch (Exception e) {
+ log.error("从 MinIO 读取分段音频失败,ID: {}, objectName: {}", id, objectName, e);
+ return ResponseEntity.notFound().build();
+ }
+ }
+
+ private static boolean isWindowsOs() {
+ String os = System.getProperty("os.name", "");
+ return os != null && os.toLowerCase(Locale.ROOT).contains("win");
+ }
+
+ /** 去掉查询串后取路径最后一段,避免 URL 中带 IP 时扩展名判断错误。 */
+ private static String lastPathSegmentForAudioCheck(String urlOrPath) {
+ if (urlOrPath == null) {
+ return "";
+ }
+ String s = urlOrPath.trim();
+ int q = s.indexOf('?');
+ if (q >= 0) {
+ s = s.substring(0, q);
+ }
+ int slash = Math.max(s.lastIndexOf('/'), s.lastIndexOf('\\'));
+ return slash >= 0 ? s.substring(slash + 1) : s;
+ }
@Autowired
private ITtsRequestLogService ttsRequestLogService;
@Autowired
private MinIOService minIOService;
+
+ @Autowired
+ private MinIOConfig minioConfig;
/**
* 转文本请求
@@ -341,6 +416,27 @@ public class AudioManagementSegmentsController {
}
}
+ /**
+ * 从分段表中的 MinIO 访问 URL 解析对象键:优先按 {@link MinIOConfig#getFileUrlPrefix()} 去掉前缀得到完整 object 路径
+ *(与 {@link MinIOService#getFileUrl(String)} 生成的 URL 一致),否则回退为 {@link #extractObjectNameFromUrl(String)}。
+ */
+ private String resolveMinioObjectName(String audioFileUrl) {
+ if (audioFileUrl == null || audioFileUrl.trim().isEmpty()) {
+ return null;
+ }
+ String trimmed = audioFileUrl.trim();
+ String withoutQuery = trimmed;
+ int q = trimmed.indexOf('?');
+ if (q >= 0) {
+ withoutQuery = trimmed.substring(0, q);
+ }
+ String prefix = minioConfig.getFileUrlPrefix();
+ if (prefix != null && !prefix.isEmpty() && withoutQuery.startsWith(prefix)) {
+ return withoutQuery.substring(prefix.length());
+ }
+ return extractObjectNameFromUrl(trimmed);
+ }
+
/**
* 从MinIO URL中提取对象名
* @param url MinIO文件URL
diff --git a/src/main/java/com/rj/mapper/AudioManagementSegmentsMapper.java b/src/main/java/com/rj/mapper/AudioManagementSegmentsMapper.java
index 466045f..ea5cb85 100644
--- a/src/main/java/com/rj/mapper/AudioManagementSegmentsMapper.java
+++ b/src/main/java/com/rj/mapper/AudioManagementSegmentsMapper.java
@@ -1,7 +1,13 @@
package com.rj.mapper;
+import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.rj.entity.AudioManagementSegments;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+import org.apache.ibatis.annotations.Update;
+
+import java.util.List;
/**
*
@@ -12,5 +18,20 @@ import com.rj.entity.AudioManagementSegments;
*/
public interface AudioManagementSegmentsMapper extends BaseMapper {
-}
+ /**
+ * 本地路径已填、MinIO URL 未填的分段。使用注解 SQL,避免 Lambda 条件生成 audio_file_url / audio_file_path 的多余占位符,
+ * 并在此方法上关闭租户行插件(否则会出现 tenant_id = null 等错误条件)。
+ */
+ @InterceptorIgnore(tenantLine = "true")
+ @Select("SELECT * FROM audio_management_segments WHERE (audio_file_url IS NULL OR audio_file_url = '') "
+ + "AND audio_file_path IS NOT NULL AND audio_file_path <> '' "
+ + "ORDER BY create_time ASC LIMIT #{limit}")
+ List listSegmentsNeedingMinioUpload(@Param("limit") int limit);
+ /**
+ * 仅按主键更新 MinIO URL,不拼接 tenant_id(供调度任务等无租户上下文场景使用)。
+ */
+ @InterceptorIgnore(tenantLine = "true")
+ @Update("UPDATE audio_management_segments SET audio_file_url = #{audioFileUrl} WHERE id = #{id}")
+ int updateAudioFileUrlByIdIgnoreTenant(@Param("id") String id, @Param("audioFileUrl") String audioFileUrl);
+}
diff --git a/src/main/java/com/rj/scheduler/AudioStatisticsScheduler.java b/src/main/java/com/rj/scheduler/AudioStatisticsScheduler.java
index ab6a053..6550937 100644
--- a/src/main/java/com/rj/scheduler/AudioStatisticsScheduler.java
+++ b/src/main/java/com/rj/scheduler/AudioStatisticsScheduler.java
@@ -1,13 +1,23 @@
package com.rj.scheduler;
import com.rj.config.AppConfig;
+import com.rj.entity.AudioManagementSegments;
+import com.rj.service.IAudioManagementSegmentsService;
import com.rj.service.IAudioManagementStatisticsService;
+import com.rj.service.MinIOService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+import java.io.FileInputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
import java.time.LocalDate;
+import java.util.List;
+import java.util.UUID;
/**
* 门店录音统计定时任务
@@ -25,6 +35,67 @@ public class AudioStatisticsScheduler {
@Autowired
private AppConfig appConfig;
+ @Autowired
+ private IAudioManagementSegmentsService audioManagementSegmentsService;
+
+ @Autowired
+ private MinIOService minIOService;
+
+ /**
+ * 每 5 秒:将仅有本地路径、尚未写入 MinIO URL 的录音分段补传到 Minio 并回写 audio_file_url。
+ */
+ @Scheduled(fixedRate = 50000)
+ public void uploadLocalSegmentFilesToMinio() {
+ if (!appConfig.getScheduler().isStart()) {
+ return;
+ }
+ try {
+ List pending = audioManagementSegmentsService.listSegmentsNeedingMinioUpload(50);
+ if (pending.isEmpty()) {
+ return;
+ }
+ for (AudioManagementSegments segment : pending) {
+ uploadOneSegmentFileToMinio(segment);
+ }
+ } catch (Exception e) {
+ log.error("录音分段补传 MinIO 定时任务执行失败", e);
+ }
+ }
+
+ private void uploadOneSegmentFileToMinio(AudioManagementSegments segment) {
+ String pathStr = segment.getAudioFilePath();
+ Path path = Paths.get(pathStr);
+ if (!Files.isRegularFile(path)) {
+ log.warn("分段本地文件不存在,跳过: id={}, path={}", segment.getId(), pathStr);
+ return;
+ }
+ String originalName = segment.getAudioFileOriginalName();
+ if (!StringUtils.hasText(originalName)) {
+ originalName = path.getFileName().toString();
+ }
+ String objectName = UUID.randomUUID().toString().replace("-", "") + originalName;
+ String contentType;
+ try {
+ contentType = Files.probeContentType(path);
+ } catch (Exception e) {
+ contentType = null;
+ }
+ if (!StringUtils.hasText(contentType)) {
+ contentType = "application/octet-stream";
+ }
+ try (FileInputStream in = new FileInputStream(path.toFile())) {
+ String minioUrl = minIOService.uploadFile(in, objectName, contentType);
+ boolean ok = audioManagementSegmentsService.updateAudioFileUrlByIdIgnoreTenant(segment.getId(), minioUrl);
+ if (!ok) {
+ log.warn("补传成功但更新 audio_file_url 未生效: id={}", segment.getId());
+ } else {
+ log.info("分段已补传 MinIO: id={}", segment.getId());
+ }
+ } catch (Exception e) {
+ log.error("分段补传 MinIO 失败: id={}, path={}", segment.getId(), pathStr, e);
+ }
+ }
+
/**
* 每天凌晨2点执行门店录音统计
* 统计昨天的录音数据
@@ -65,6 +136,7 @@ public class AudioStatisticsScheduler {
throw e;
}
}
+
}
diff --git a/src/main/java/com/rj/service/IAudioManagementSegmentsService.java b/src/main/java/com/rj/service/IAudioManagementSegmentsService.java
index 7dbbcb7..9579836 100644
--- a/src/main/java/com/rj/service/IAudioManagementSegmentsService.java
+++ b/src/main/java/com/rj/service/IAudioManagementSegmentsService.java
@@ -5,6 +5,8 @@ import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.service.IService;
import com.rj.entity.AudioManagementSegments;
+import java.util.List;
+
/**
*
* 录音分段管理表 服务类
@@ -17,5 +19,15 @@ public interface IAudioManagementSegmentsService extends IService updateWrapper) ;
+
+ /**
+ * 查询本地已有文件路径但未写入 MinIO URL 的分段(供定时补传任务使用;SQL 在 Mapper 注解中并忽略租户插件)。
+ */
+ List listSegmentsNeedingMinioUpload(int limit);
+
+ /**
+ * 按主键更新 audio_file_url,不附加租户条件(与 {@link #updateNoTenant} 不同,此方法走 Mapper 注解 SQL,租户插件确定被忽略)。
+ */
+ boolean updateAudioFileUrlByIdIgnoreTenant(String id, String audioFileUrl);
}
diff --git a/src/main/java/com/rj/service/impl/AudioManagementSegmentsServiceImpl.java b/src/main/java/com/rj/service/impl/AudioManagementSegmentsServiceImpl.java
index 1c7a640..11527c9 100644
--- a/src/main/java/com/rj/service/impl/AudioManagementSegmentsServiceImpl.java
+++ b/src/main/java/com/rj/service/impl/AudioManagementSegmentsServiceImpl.java
@@ -8,6 +8,8 @@ import com.rj.mapper.AudioManagementSegmentsMapper;
import com.rj.service.IAudioManagementSegmentsService;
import org.springframework.stereotype.Service;
+import java.util.List;
+
/**
*
* 录音分段管理表 服务实现类
@@ -26,5 +28,16 @@ public class AudioManagementSegmentsServiceImpl
return super.update(entity, updateWrapper);
}
+ @Override
+ public List listSegmentsNeedingMinioUpload(int limit) {
+ int safeLimit = Math.max(1, Math.min(limit, 500));
+ return baseMapper.listSegmentsNeedingMinioUpload(safeLimit);
+ }
+
+ @Override
+ public boolean updateAudioFileUrlByIdIgnoreTenant(String id, String audioFileUrl) {
+ return baseMapper.updateAudioFileUrlByIdIgnoreTenant(id, audioFileUrl) > 0;
+ }
+
}