Pārlūkot izejas kodu

新增播放内容转语音tts的实现和机器人前端大屏的对接

zmj 14 stundas atpakaļ
vecāks
revīzija
57d20badf7
28 mainītis faili ar 3118 papildinājumiem un 77 dzēšanām
  1. 11 3
      pom.xml
  2. 13 7
      ruoyi-admin/pom.xml
  3. 2 0
      ruoyi-admin/src/main/java/com/ruoyi/RuoYiApplication.java
  4. 94 3
      ruoyi-admin/src/main/java/com/ruoyi/web/controller/base/RobotOpsBroadcastContentController.java
  5. 468 0
      ruoyi-admin/src/main/java/com/ruoyi/web/controller/base/RobotOpsScreenApiController.java
  6. 90 0
      ruoyi-admin/src/main/java/com/ruoyi/websocket/listener/ScreenWebSocketEventListener.java
  7. 131 0
      ruoyi-admin/src/main/java/com/ruoyi/websocket/service/WebSocketPushService.java
  8. 10 1
      ruoyi-admin/src/main/resources/application.yml
  9. 20 0
      ruoyi-common/pom.xml
  10. 30 0
      ruoyi-common/src/main/java/com/ruoyi/common/config/DashScopeProperties.java
  11. 82 0
      ruoyi-common/src/main/java/com/ruoyi/common/event/BroadcastStartedEvent.java
  12. 62 0
      ruoyi-common/src/main/java/com/ruoyi/common/event/PlayPlanChangedEvent.java
  13. 27 0
      ruoyi-common/src/main/java/com/ruoyi/common/event/PlayPlanDisabledEvent.java
  14. 16 0
      ruoyi-common/src/main/java/com/ruoyi/common/event/ScreenEvent.java
  15. 679 0
      ruoyi-common/src/main/java/com/ruoyi/common/utils/TtsUtil.java
  16. 2 0
      ruoyi-framework/src/main/java/com/ruoyi/framework/config/SecurityConfig.java
  17. 66 0
      ruoyi-framework/src/main/java/com/ruoyi/framework/config/TtsThreadPoolConfig.java
  18. 171 24
      ruoyi-system/src/main/java/com/ruoyi/base/domain/RobotOpsBroadcastContent.java
  19. 14 0
      ruoyi-system/src/main/java/com/ruoyi/base/service/IBroadcastRecordService.java
  20. 31 8
      ruoyi-system/src/main/java/com/ruoyi/base/service/IRobotOpsBroadcastContentService.java
  21. 15 0
      ruoyi-system/src/main/java/com/ruoyi/base/service/IRobotOpsBroadcastTaskService.java
  22. 161 0
      ruoyi-system/src/main/java/com/ruoyi/base/service/impl/BroadcastRecordServiceImpl.java
  23. 273 11
      ruoyi-system/src/main/java/com/ruoyi/base/service/impl/RobotOpsBroadcastContentServiceImpl.java
  24. 364 8
      ruoyi-system/src/main/java/com/ruoyi/base/service/impl/RobotOpsBroadcastTaskServiceImpl.java
  25. 163 5
      ruoyi-system/src/main/java/com/ruoyi/base/service/impl/RobotOpsPlayPlanServiceImpl.java
  26. 55 0
      ruoyi-system/src/main/java/com/ruoyi/base/task/BroadcastScheduler.java
  27. 38 6
      ruoyi-system/src/main/resources/mapper/base/RobotOpsBroadcastContentMapper.xml
  28. 30 1
      sql/ry_20260417.sql

+ 11 - 3
pom.xml

@@ -3,7 +3,7 @@
          xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
          xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
 	<modelVersion>4.0.0</modelVersion>
-	
+
     <groupId>com.ruoyi</groupId>
     <artifactId>ruoyi</artifactId>
     <version>3.9.2</version>
@@ -11,7 +11,7 @@
     <name>ruoyi</name>
     <url>http://www.ruoyi.vip</url>
     <description>若依管理系统</description>
-    
+
     <properties>
         <ruoyi.version>3.9.2</ruoyi.version>
         <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
@@ -81,6 +81,14 @@
                 <version>${logback.version}</version>
             </dependency>
 
+            <!-- 强制排除slf4j-simple,避免与Logback冲突 -->
+            <dependency>
+                <groupId>org.slf4j</groupId>
+                <artifactId>slf4j-simple</artifactId>
+                <version>1.7.36</version>
+                <scope>provided</scope>
+            </dependency>
+
             <!-- 覆盖tomcat的依赖配置-->
             <dependency>
                 <groupId>org.apache.tomcat.embed</groupId>
@@ -285,4 +293,4 @@
         </pluginRepository>
     </pluginRepositories>
 
-</project>
+</project>

+ 13 - 7
ruoyi-admin/pom.xml

@@ -102,6 +102,12 @@
         <dependency>
             <groupId>org.springframework.boot</groupId>
             <artifactId>spring-boot-starter-webflux</artifactId>
+            <exclusions>
+                <exclusion>
+                    <groupId>org.slf4j</groupId>
+                    <artifactId>slf4j-simple</artifactId>
+                </exclusion>
+            </exclusions>
         </dependency>
 
     </dependencies>
@@ -123,17 +129,17 @@
                     </execution>
                 </executions>
             </plugin>
-            <plugin>   
-                <groupId>org.apache.maven.plugins</groupId>   
-                <artifactId>maven-war-plugin</artifactId>   
-                <version>3.1.0</version>   
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-war-plugin</artifactId>
+                <version>3.1.0</version>
                 <configuration>
                     <failOnMissingWebXml>false</failOnMissingWebXml>
                     <warName>${project.artifactId}</warName>
-                </configuration>   
-           </plugin>   
+                </configuration>
+           </plugin>
         </plugins>
         <finalName>${project.artifactId}</finalName>
     </build>
 
-</project>
+</project>

+ 2 - 0
ruoyi-admin/src/main/java/com/ruoyi/RuoYiApplication.java

@@ -5,6 +5,7 @@ import org.springframework.boot.SpringApplication;
 import org.springframework.boot.autoconfigure.SpringBootApplication;
 import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
 import org.springframework.context.annotation.ComponentScan;
+import org.springframework.scheduling.annotation.EnableAsync;
 
 /**
  * 启动程序
@@ -14,6 +15,7 @@ import org.springframework.context.annotation.ComponentScan;
 
 @SpringBootApplication(exclude = { DataSourceAutoConfiguration.class }, scanBasePackages = { "com.ruoyi.*", "com.test.*" })
 @MapperScan("com.ruoyi.**.mapper")
+@EnableAsync
 public class RuoYiApplication
 {
     public static void main(String[] args)

+ 94 - 3
ruoyi-admin/src/main/java/com/ruoyi/web/controller/base/RobotOpsBroadcastContentController.java

@@ -71,24 +71,49 @@ public class RobotOpsBroadcastContentController extends BaseController
 
     /**
      * 新增播报内容
+     * 新增时会自动触发音频合成
      */
     @PreAuthorize("@ss.hasPermi('base:broadcastContent:add')")
     @Log(title = "播报内容", businessType = BusinessType.INSERT)
     @PostMapping
     public AjaxResult add(@RequestBody RobotOpsBroadcastContent robotOpsBroadcastContent)
     {
-        return toAjax(robotOpsBroadcastContentService.insertRobotOpsBroadcastContent(robotOpsBroadcastContent));
+        // 如果传入了播报文本,设置默认状态
+        if (robotOpsBroadcastContent.getAudioStatus() == null)
+        {
+            robotOpsBroadcastContent.setAudioStatus("0");
+        }
+        int rows = robotOpsBroadcastContentService.insertRobotOpsBroadcastContent(robotOpsBroadcastContent);
+        if (rows > 0)
+        {
+            // 返回成功消息,提示音频合成将在后台进行
+            return success("播报内容新增成功,音频合成任务已提交")
+                    .put("data", robotOpsBroadcastContent);
+        }
+        return error("新增失败");
     }
 
     /**
      * 修改播报内容
+     * 修改播报文本时会自动触发重新合成
      */
     @PreAuthorize("@ss.hasPermi('base:broadcastContent:edit')")
     @Log(title = "播报内容", businessType = BusinessType.UPDATE)
     @PutMapping
     public AjaxResult edit(@RequestBody RobotOpsBroadcastContent robotOpsBroadcastContent)
     {
-        return toAjax(robotOpsBroadcastContentService.updateRobotOpsBroadcastContent(robotOpsBroadcastContent));
+        int rows = robotOpsBroadcastContentService.updateRobotOpsBroadcastContent(robotOpsBroadcastContent);
+        if (rows > 0)
+        {
+            // 检查是否正在合成
+            boolean synthesizing = robotOpsBroadcastContentService.isSynthesizing(robotOpsBroadcastContent.getId());
+            if (synthesizing)
+            {
+                return success("播报内容修改成功,音频正在重新合成中");
+            }
+            return success("播报内容修改成功");
+        }
+        return error("修改失败");
     }
 
     /**
@@ -96,9 +121,75 @@ public class RobotOpsBroadcastContentController extends BaseController
      */
     @PreAuthorize("@ss.hasPermi('base:broadcastContent:remove')")
     @Log(title = "播报内容", businessType = BusinessType.DELETE)
-	@DeleteMapping("/{ids}")
+    @DeleteMapping("/{ids}")
     public AjaxResult remove(@PathVariable Long[] ids)
     {
         return toAjax(robotOpsBroadcastContentService.deleteRobotOpsBroadcastContentByIds(ids));
     }
+
+    /**
+     * 合成音频(异步)
+     * POST /base/broadcastContent/synthesize/{id}
+     */
+    @PreAuthorize("@ss.hasPermi('base:broadcastContent:edit')")
+    @Log(title = "播报内容-合成音频", businessType = BusinessType.UPDATE)
+    @PostMapping("/synthesize/{id}")
+    public AjaxResult synthesizeAudio(@PathVariable Long id)
+    {
+        // 检查内容是否存在
+        RobotOpsBroadcastContent content = robotOpsBroadcastContentService.selectRobotOpsBroadcastContentById(id);
+        if (content == null)
+        {
+            return error("播报内容不存在");
+        }
+
+        // 检查是否正在合成中
+        if (robotOpsBroadcastContentService.isSynthesizing(id))
+        {
+            return warn("正在合成中,请稍后再试");
+        }
+
+        // 检查播报文本是否为空
+        if (content.getBroadcastText() == null || content.getBroadcastText().trim().isEmpty())
+        {
+            return error("播报文本不能为空");
+        }
+
+        // 触发异步合成
+        robotOpsBroadcastContentService.synthesizeAudio(id);
+        return success("音频合成任务已提交,请在列表中查看合成状态");
+    }
+
+    /**
+     * 重新合成音频(异步)
+     * POST /base/broadcastContent/regenerate/{id}
+     */
+    @PreAuthorize("@ss.hasPermi('base:broadcastContent:edit')")
+    @Log(title = "播报内容-重新合成音频", businessType = BusinessType.UPDATE)
+    @PostMapping("/regenerate/{id}")
+    public AjaxResult regenerateAudio(@PathVariable Long id)
+    {
+        // 检查内容是否存在
+        RobotOpsBroadcastContent content = robotOpsBroadcastContentService.selectRobotOpsBroadcastContentById(id);
+        if (content == null)
+        {
+            return error("播报内容不存在");
+        }
+
+        // 检查是否正在合成中
+        if (robotOpsBroadcastContentService.isSynthesizing(id))
+        {
+            return warn("正在合成中,请稍后再试");
+        }
+
+        // 检查播报文本是否为空
+        if (content.getBroadcastText() == null || content.getBroadcastText().trim().isEmpty())
+        {
+            return error("播报文本不能为空");
+        }
+
+        // 触发异步重新合成
+        robotOpsBroadcastContentService.regenerateAudio(id);
+        return success("音频重新合成任务已提交,请在列表中查看合成状态");
+    }
 }

+ 468 - 0
ruoyi-admin/src/main/java/com/ruoyi/web/controller/base/RobotOpsScreenApiController.java

@@ -0,0 +1,468 @@
+package com.ruoyi.web.controller.base;
+
+import com.ruoyi.base.domain.RobotOpsBroadcastContent;
+import com.ruoyi.base.domain.RobotOpsPlayPlan;
+import com.ruoyi.base.domain.RobotOpsPlayPlanItem;
+import com.ruoyi.base.domain.RobotOpsScreenThemeConfig;
+import com.ruoyi.base.domain.RobotOpsBroadcastTask;
+import com.ruoyi.base.service.IBroadcastRecordService;
+import com.ruoyi.base.service.IRobotOpsBroadcastContentService;
+import com.ruoyi.base.service.IRobotOpsBroadcastTaskService;
+import com.ruoyi.base.service.IRobotOpsPlayPlanService;
+import com.ruoyi.base.service.IRobotOpsScreenThemeConfigService;
+import com.ruoyi.common.annotation.Anonymous;
+import com.ruoyi.common.config.RuoYiConfig;
+import com.ruoyi.common.core.controller.BaseController;
+import com.ruoyi.common.core.domain.AjaxResult;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.web.bind.annotation.*;
+
+import java.text.SimpleDateFormat;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 机器人机身屏API接口
+ *
+ * 提供给 robot_screen 前端调用的接口
+ * 支持匿名访问,无需登录认证
+ *
+ * @author ruoyi
+ * @date 2026-05-14
+ */
+@Anonymous
+@RestController
+@RequestMapping("/robot-ops/screen")
+public class RobotOpsScreenApiController extends BaseController
+{
+    private static final Logger log = LoggerFactory.getLogger(RobotOpsScreenApiController.class);
+
+    @Autowired
+    private IRobotOpsPlayPlanService robotOpsPlayPlanService;
+
+    @Autowired
+    private IRobotOpsScreenThemeConfigService robotOpsScreenThemeConfigService;
+
+    @Autowired
+    private IRobotOpsBroadcastTaskService robotOpsBroadcastTaskService;
+
+    @Autowired
+    private IRobotOpsBroadcastContentService robotOpsBroadcastContentService;
+
+    @Autowired
+    private IBroadcastRecordService broadcastRecordService;
+
+    /**
+     * 资源访问基础URL,用于补全音频路径
+     */
+    @Value("${screen.resource-base-url:}")
+    private String resourceBaseUrl;
+
+    /**
+     * 获取当前播放方案
+     * 前端调用路径:/robot-ops/screen/play-plan/current
+     */
+    @GetMapping("/play-plan/current")
+    public AjaxResult getCurrentPlayPlan() {
+        try {
+            RobotOpsPlayPlan plan = robotOpsPlayPlanService.selectCurrentPlayingPlanWithItems();
+            if (plan == null) {
+                Map<String, Object> emptyResult = new HashMap<>();
+                emptyResult.put("enabled", false);
+                emptyResult.put("planId", null);
+                emptyResult.put("planName", null);
+                emptyResult.put("playMode", "loop");
+                emptyResult.put("defaultFitMode", "cover");
+                emptyResult.put("version", "");
+                emptyResult.put("items", new ArrayList<>());
+                return AjaxResult.success("当前没有正在播放的方案", emptyResult);
+            }
+
+            Map<String, Object> result = new HashMap<>();
+            result.put("enabled", true);
+            result.put("planId", plan.getId());
+            result.put("planName", plan.getPlanName());
+            result.put("playMode", plan.getLoopMode() != null ? plan.getLoopMode() : "loop");
+            result.put("defaultFitMode", "cover");
+
+            // 使用更新时间生成版本号,用于前端判断播放方案是否变化
+            // 格式:yyyyMMddHHmmss,如 20260515163000
+            String version = "";
+            if (plan.getUpdateTime() != null) {
+                java.text.SimpleDateFormat sdf = new java.text.SimpleDateFormat("yyyyMMddHHmmss");
+                version = sdf.format(plan.getUpdateTime());
+            }
+            result.put("version", version);
+
+            // 转换素材项
+            List<RobotOpsPlayPlanItem> items = plan.getRobotOpsPlayPlanItemList();
+            List<Map<String, Object>> convertedItems = new ArrayList<>();
+            if (items != null && !items.isEmpty()) {
+                items.stream()
+                        .sorted((a, b) -> {
+                            Long orderA = a.getPlayOrder() != null ? a.getPlayOrder() : 999L;
+                            Long orderB = b.getPlayOrder() != null ? b.getPlayOrder() : 999L;
+                            return orderA.compareTo(orderB);
+                        })
+                        .forEach(item -> {
+                            Map<String, Object> itemMap = new HashMap<>();
+                            itemMap.put("id", item.getId() != null ? String.valueOf(item.getId()) : "");
+                            itemMap.put("type", convertAssetType(item.getAssetType()));
+                            itemMap.put("title", item.getAssetName() != null ? item.getAssetName() : "");
+                            itemMap.put("subtitle", "");
+                            itemMap.put("url", item.getFileUrl() != null ? item.getFileUrl() : "");
+                            itemMap.put("duration", item.getStaySeconds() != null ? item.getStaySeconds() * 1000 : 8000);
+                            itemMap.put("fitMode", "cover");
+                            itemMap.put("showTitle", false);
+                            convertedItems.add(itemMap);
+                        });
+            }
+            result.put("items", convertedItems);
+
+            return AjaxResult.success(result);
+        } catch (Exception e) {
+            return AjaxResult.error("获取播放方案失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 获取屏幕配置
+     * 前端调用路径:/robot-ops/screen/config
+     */
+    @GetMapping("/config")
+    public AjaxResult getScreenConfig() {
+        Map<String, Object> config = new HashMap<>();
+        config.put("robotName", "智能迎宾机器人");
+        config.put("logoUrl", "");
+        config.put("idleTimeout", 60);
+        config.put("theme", "default");
+        config.put("version", "1.0.0");
+        return AjaxResult.success(config);
+    }
+
+    /**
+     * 获取机器人状态
+     * 前端调用路径:/robot-ops/robot/status
+     */
+    @GetMapping("/robot/status")
+    public AjaxResult getRobotStatus() {
+        Map<String, Object> status = new HashMap<>();
+        status.put("batteryLevel", 85);
+        status.put("networkStatus", "online");
+        status.put("workStatus", "idle");
+        status.put("chargeStatus", "not_charging");
+        status.put("faultFlag", false);
+        return AjaxResult.success(status);
+    }
+
+    /**
+     * 获取屏幕主题配置
+     * 前端调用路径:/robot-ops/screen/theme
+     */
+    @GetMapping("/theme")
+    public AjaxResult getScreenTheme() {
+        try {
+            // 从数据库查询默认主题配置(configKey 为 default)
+            RobotOpsScreenThemeConfig themeConfig = robotOpsScreenThemeConfigService.selectRobotOpsScreenThemeConfigByConfigKey("default");
+
+            Map<String, Object> theme = new HashMap<>();
+
+            if (themeConfig != null) {
+                // 使用数据库配置
+                theme.put("configKey", themeConfig.getConfigKey());
+                theme.put("logoUrl", themeConfig.getLogoUrl() != null ? themeConfig.getLogoUrl() : "");
+                theme.put("robotName", themeConfig.getRobotName() != null ? themeConfig.getRobotName() : "迎宾巡逻机器人");
+                theme.put("brandSubtitle", themeConfig.getBrandSubtitle() != null ? themeConfig.getBrandSubtitle() : "智能接待 · 路线引导 · 信息服务");
+                theme.put("backgroundImage", themeConfig.getBackgroundImage() != null ? themeConfig.getBackgroundImage() : "");
+                theme.put("welcomeTitle", themeConfig.getWelcomeTitle() != null ? themeConfig.getWelcomeTitle() : "您好,欢迎光临");
+                theme.put("welcomeSubtitle", themeConfig.getWelcomeSubtitle() != null ? themeConfig.getWelcomeSubtitle() : "我可以为您提供访客登记、路线引导、通知公告查询与现场帮助服务");
+                theme.put("touchText", themeConfig.getTouchText() != null ? themeConfig.getTouchText() : "触摸屏幕进入服务");
+                theme.put("buttonColor", themeConfig.getButtonColor() != null ? themeConfig.getButtonColor() : "#2f8ee5");
+            } else {
+                // 没有数据库配置,使用默认值兜底
+                theme.put("configKey", "default");
+                theme.put("logoUrl", "");
+                theme.put("robotName", "迎宾巡逻机器人");
+                theme.put("brandSubtitle", "智能接待 · 路线引导 · 信息服务");
+                theme.put("backgroundImage", "");
+                theme.put("welcomeTitle", "您好,欢迎光临");
+                theme.put("welcomeSubtitle", "我可以为您提供访客登记、路线引导、通知公告查询与现场帮助服务");
+                theme.put("touchText", "触摸屏幕进入服务");
+                theme.put("buttonColor", "#2f8ee5");
+            }
+
+            // 固定的配置项
+            theme.put("primaryColor", "#2f8ee5");
+            theme.put("secondaryColor", "#20b7c7");
+            theme.put("backgroundType", "image");
+            theme.put("backgroundFit", "stretch");
+            theme.put("backgroundOverlay", true);
+            theme.put("showDecorations", false);
+            theme.put("showServicePanel", false);
+            theme.put("showStatusBar", true);
+            theme.put("showTime", true);
+            theme.put("showDebugSwitch", true);
+
+            return AjaxResult.success(theme);
+        } catch (Exception e) {
+            return AjaxResult.error("获取屏幕主题配置失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 获取欢迎页兜底内容
+     * 前端调用路径:/robot-ops/screen/welcome-content
+     */
+    @GetMapping("/welcome-content")
+    public AjaxResult getWelcomeContent() {
+        Map<String, Object> content = new HashMap<>();
+        content.put("title", "欢迎光临");
+        content.put("subtitle", "您好,请问有什么可以帮您?");
+        content.put("hint", "触摸屏幕开启服务");
+        content.put("logo", "");
+        return AjaxResult.success(content);
+    }
+
+    /**
+     * 获取当前播报状态
+     * 前端调用路径:/robot-ops/screen/broadcast/current
+     * 
+     * 根据播报任务的循环类型、开始时间、结束时间判断当前是否需要播报
+     * 如果需要播报,返回播报内容和音频地址
+     */
+    @GetMapping("/broadcast/current")
+    public AjaxResult getCurrentBroadcast()
+    {
+        try
+        {
+            // 1. 获取当前命中的播报任务
+            RobotOpsBroadcastTask task = robotOpsBroadcastTaskService.selectCurrentBroadcastTask();
+            
+            // 构建返回结果
+            Map<String, Object> result = new HashMap<>();
+            result.put("broadcasting", false);
+            result.put("taskId", null);
+            result.put("contentId", null);
+            result.put("taskName", "");
+            result.put("title", "");
+            result.put("content", "");
+            result.put("contentType", "");
+            result.put("level", "normal");
+            result.put("audioUrl", "");
+            result.put("audioDuration", null);
+            result.put("playMode", "once");
+            result.put("startTime", null);
+            result.put("estimatedEndTime", null);
+            result.put("version", generateVersion());
+
+            // 如果没有命中的任务,返回无播报状态
+            if (task == null)
+            {
+                return AjaxResult.success("当前无播报", result);
+            }
+
+            // 2. 获取关联的播报内容
+            RobotOpsBroadcastContent content = robotOpsBroadcastContentService.selectRobotOpsBroadcastContentById(task.getContentId());
+            if (content == null)
+            {
+                log.warn("[Broadcast] 播报任务关联的内容不存在,taskId: {}", task.getId());
+                return AjaxResult.success("当前无播报", result);
+            }
+
+            // 3. 检查内容是否启用
+            if (!"1".equals(content.getStatus()))
+            {
+                log.info("[Broadcast] 播报内容未启用,contentId: {}", content.getId());
+                return AjaxResult.success("当前无播报", result);
+            }
+
+            // 4. 检查音频是否合成成功
+            if (!"1".equals(content.getAudioStatus()) || content.getAudioPath() == null || content.getAudioPath().isEmpty())
+            {
+                log.info("[Broadcast] 播报内容音频未就绪,contentId: {}, audioStatus: {}", 
+                        content.getId(), content.getAudioStatus());
+                return AjaxResult.success("当前无播报", result);
+            }
+
+            // 5. 构建播报结果
+            result.put("broadcasting", true);
+            result.put("taskId", task.getId());
+            result.put("contentId", content.getId());
+            result.put("taskName", task.getTaskName() != null ? task.getTaskName() : "");
+            result.put("title", content.getContentName() != null ? content.getContentName() : "");
+            result.put("content", content.getBroadcastText() != null ? content.getBroadcastText() : "");
+            result.put("contentType", convertContentType(content.getContentType()));
+            result.put("level", "normal");
+            
+            // 补全音频 URL
+            String audioUrl = completeAudioUrl(content.getAudioPath());
+            result.put("audioUrl", audioUrl);
+            result.put("audioDuration", content.getAudioDuration() != null ? content.getAudioDuration() : null);
+            result.put("startTime", formatDateTime(LocalDateTime.now()));
+            
+            // 计算预计结束时间
+            if (content.getAudioDuration() != null)
+            {
+                LocalDateTime estimatedEnd = LocalDateTime.now().plusSeconds(content.getAudioDuration());
+                result.put("estimatedEndTime", formatDateTime(estimatedEnd));
+            }
+
+            // 6. 记录播报时间(用于下次播报频率控制)
+            broadcastRecordService.recordBroadcast(task.getId());
+
+            log.info("[Broadcast] 返回播报状态,taskId: {}, contentId: {}", task.getId(), content.getId());
+            return AjaxResult.success("查询成功", result);
+        }
+        catch (Exception e)
+        {
+            log.error("[Broadcast] 获取当前播报状态异常: {}", e.getMessage(), e);
+            Map<String, Object> result = new HashMap<>();
+            result.put("broadcasting", false);
+            result.put("version", generateVersion());
+            return AjaxResult.success("当前无播报", result);
+        }
+    }
+
+    /**
+     * 播报播放完成回执
+     * 前端调用路径:/robot-ops/screen/broadcast/ack
+     */
+    @PostMapping("/broadcast/ack")
+    public AjaxResult broadcastAck(@RequestBody Map<String, Object> params)
+    {
+        try
+        {
+            String taskId = params.get("taskId") != null ? params.get("taskId").toString() : null;
+            String contentId = params.get("contentId") != null ? params.get("contentId").toString() : null;
+            String resultStatus = params.get("resultStatus") != null ? params.get("resultStatus").toString() : "success";
+            String playTime = params.get("playTime") != null ? params.get("playTime").toString() : null;
+
+            log.info("[Broadcast] 收到播报回执,taskId: {}, contentId: {}, status: {}, playTime: {}",
+                    taskId, contentId, resultStatus, playTime);
+
+            // 一期暂不处理回执逻辑,后续可扩展
+            return AjaxResult.success("回执已接收");
+        }
+        catch (Exception e)
+        {
+            log.error("[Broadcast] 处理播报回执异常: {}", e.getMessage(), e);
+            return AjaxResult.error("处理回执失败");
+        }
+    }
+
+    /**
+     * 补全音频 URL
+     * 如果 audioPath 已经是完整 URL 则直接返回
+     * 否则拼接资源基础 URL
+     */
+    private String completeAudioUrl(String audioPath)
+    {
+        if (audioPath == null || audioPath.isEmpty())
+        {
+            return "";
+        }
+
+        // 如果已经是完整 URL(包含 http 或 /profile)
+        if (audioPath.startsWith("http://") || audioPath.startsWith("https://"))
+        {
+            return audioPath;
+        }
+
+        if (audioPath.startsWith("/profile/"))
+        {
+            return audioPath;
+        }
+
+        // 如果配置了资源基础 URL
+        if (resourceBaseUrl != null && !resourceBaseUrl.isEmpty())
+        {
+            // 避免重复拼接
+            if (audioPath.startsWith("/"))
+            {
+                return resourceBaseUrl + audioPath;
+            }
+            else
+            {
+                return resourceBaseUrl + "/" + audioPath;
+            }
+        }
+
+        // 默认拼接 /profile/
+        if (audioPath.startsWith("/"))
+        {
+            return "/profile" + audioPath;
+        }
+        else
+        {
+            return "/profile/" + audioPath;
+        }
+    }
+
+    /**
+     * 转换内容类型
+     */
+    private String convertContentType(Long contentType)
+    {
+        if (contentType == null)
+        {
+            return "custom";
+        }
+        switch (contentType.intValue())
+        {
+            case 1:
+                return "notice";
+            case 2:
+                return "promotion";
+            case 3:
+                return "tip";
+            case 4:
+                return "security";
+            default:
+                return "custom";
+        }
+    }
+
+    /**
+     * 生成版本号
+     */
+    private String generateVersion()
+    {
+        SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMddHHmmss");
+        return sdf.format(new Date());
+    }
+
+    /**
+     * 格式化日期时间
+     */
+    private String formatDateTime(LocalDateTime dateTime)
+    {
+        if (dateTime == null)
+        {
+            return null;
+        }
+        DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+        return dateTime.format(formatter);
+    }
+
+    /**
+     * 转换素材类型
+     * 后端:image/video
+     * 前端期望:image/video
+     */
+    private String convertAssetType(String assetType)
+    {
+        if (assetType == null)
+        {
+            return "image";
+        }
+        return assetType;
+    }
+}

+ 90 - 0
ruoyi-admin/src/main/java/com/ruoyi/websocket/listener/ScreenWebSocketEventListener.java

@@ -0,0 +1,90 @@
+package com.ruoyi.websocket.listener;
+
+import com.ruoyi.common.event.BroadcastStartedEvent;
+import com.ruoyi.common.event.PlayPlanChangedEvent;
+import com.ruoyi.common.event.PlayPlanDisabledEvent;
+import com.ruoyi.websocket.service.WebSocketPushService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.event.EventListener;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Component;
+
+/**
+ * 屏幕端 WebSocket 事件监听器
+ * 负责将业务事件推送到 WebSocket 客户端
+ *
+ * @author ruoyi
+ */
+@Component
+public class ScreenWebSocketEventListener {
+
+    private static final Logger log = LoggerFactory.getLogger(ScreenWebSocketEventListener.class);
+
+    @Autowired(required = false)
+    private WebSocketPushService webSocketPushService;
+
+    /**
+     * 处理播放方案变化事件
+     */
+    @Async
+    @EventListener
+    public void onPlayPlanChanged(PlayPlanChangedEvent event) {
+        if (webSocketPushService == null) {
+            log.warn("[ScreenEvent] WebSocketPushService 未配置,忽略播放方案变化事件");
+            return;
+        }
+        try {
+            log.info("[ScreenEvent] 收到播放方案变化事件,推送 WebSocket");
+            webSocketPushService.pushPlayPlanChanged(event.getPlanData());
+        } catch (Exception e) {
+            log.error("[ScreenEvent] 推送播放方案变化失败", e);
+        }
+    }
+
+    /**
+     * 处理播放方案禁用事件
+     */
+    @Async
+    @EventListener
+    public void onPlayPlanDisabled(PlayPlanDisabledEvent event) {
+        if (webSocketPushService == null) {
+            log.warn("[ScreenEvent] WebSocketPushService 未配置,忽略播放方案禁用事件");
+            return;
+        }
+        try {
+            log.info("[ScreenEvent] 收到播放方案禁用事件,推送 WebSocket: planId={}", event.getPlanId());
+            webSocketPushService.pushPlayPlanDisabled(event.getPlanId());
+        } catch (Exception e) {
+            log.error("[ScreenEvent] 推送播放方案禁用失败", e);
+        }
+    }
+
+    /**
+     * 处理播报开始事件
+     */
+    @Async
+    @EventListener
+    public void onBroadcastStarted(BroadcastStartedEvent event) {
+        if (webSocketPushService == null) {
+            log.warn("[ScreenEvent] WebSocketPushService 未配置,忽略播报开始事件");
+            return;
+        }
+        try {
+            log.info("[ScreenEvent] 收到播报开始事件,推送 WebSocket: taskId={}", event.getTaskId());
+            webSocketPushService.pushBroadcastStarted(
+                    event.getTaskId(),
+                    event.getContentId(),
+                    event.getTaskName(),
+                    event.getTitle(),
+                    event.getContent(),
+                    event.getContentType(),
+                    event.getAudioUrl(),
+                    event.getAudioDuration()
+            );
+        } catch (Exception e) {
+            log.error("[ScreenEvent] 推送播报开始失败", e);
+        }
+    }
+}

+ 131 - 0
ruoyi-admin/src/main/java/com/ruoyi/websocket/service/WebSocketPushService.java

@@ -11,6 +11,8 @@ import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.messaging.simp.SimpMessagingTemplate;
 import org.springframework.stereotype.Service;
 
+import java.util.Map;
+
 /**
  * WebSocket消息推送服务
  * 将MQTT接收到的机器人数据实时推送给WebSocket客户端
@@ -381,4 +383,133 @@ public class WebSocketPushService {
             log.error("推送电池信息失败", e);
         }
     }
+
+    // ========================================
+    // 屏幕端播放方案和播报推送
+    // ========================================
+
+    /**
+     * 推送播放方案变化
+     * 推送到 /topic/screen/play-plan
+     * @param playPlanData 播放方案数据(Map格式,已转换好前端所需格式)
+     */
+    public void pushPlayPlanChanged(Map<String, Object> playPlanData) {
+        try {
+            JSONObject pushData = new JSONObject();
+            pushData.put("type", "play_plan_changed");
+            pushData.put("timestamp", System.currentTimeMillis());
+            pushData.put("data", playPlanData);
+
+            messagingTemplate.convertAndSend("/topic/screen/play-plan", pushData);
+            log.info("[WebSocket] 推送播放方案变化: {}", pushData.toJSONString());
+        } catch (Exception e) {
+            log.error("推送播放方案变化失败", e);
+        }
+    }
+
+    /**
+     * 推送播放方案禁用(无播放方案)
+     * @param planId 被禁用的方案ID
+     */
+    public void pushPlayPlanDisabled(Long planId) {
+        try {
+            JSONObject pushData = new JSONObject();
+            pushData.put("type", "play_plan_disabled");
+            pushData.put("timestamp", System.currentTimeMillis());
+
+            JSONObject data = new JSONObject();
+            data.put("planId", planId);
+            data.put("enabled", false);
+            pushData.put("data", data);
+
+            messagingTemplate.convertAndSend("/topic/screen/play-plan", pushData);
+            log.info("[WebSocket] 推送播放方案禁用: planId={}", planId);
+        } catch (Exception e) {
+            log.error("推送播放方案禁用失败", e);
+        }
+    }
+
+    /**
+     * 推送播报开始
+     * 推送到 /topic/screen/broadcast
+     * @param taskId 任务ID
+     * @param contentId 内容ID
+     * @param taskName 任务名称
+     * @param title 播报标题
+     * @param content 播报文本
+     * @param contentType 内容类型
+     * @param audioUrl 音频URL
+     * @param audioDuration 音频时长
+     */
+    public void pushBroadcastStarted(Long taskId, Long contentId, String taskName,
+                                     String title, String content, String contentType,
+                                     String audioUrl, Integer audioDuration) {
+        try {
+            JSONObject pushData = new JSONObject();
+            pushData.put("type", "broadcast_started");
+            pushData.put("timestamp", System.currentTimeMillis());
+
+            JSONObject data = new JSONObject();
+            data.put("broadcasting", true);
+            data.put("taskId", taskId);
+            data.put("contentId", contentId);
+            data.put("taskName", taskName != null ? taskName : "");
+            data.put("title", title != null ? title : "");
+            data.put("content", content != null ? content : "");
+            data.put("contentType", contentType != null ? contentType : "custom");
+            data.put("level", "normal");
+            data.put("audioUrl", audioUrl != null ? audioUrl : "");
+            data.put("audioDuration", audioDuration);
+            data.put("startTime", new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new java.util.Date()));
+
+            pushData.put("data", data);
+
+            messagingTemplate.convertAndSend("/topic/screen/broadcast", pushData);
+            log.info("[WebSocket] 推送播报开始: taskId={}, contentId={}", taskId, contentId);
+        } catch (Exception e) {
+            log.error("推送播报开始失败", e);
+        }
+    }
+
+    /**
+     * 推送播报结束
+     * 推送到 /topic/screen/broadcast
+     */
+    public void pushBroadcastEnded() {
+        try {
+            JSONObject pushData = new JSONObject();
+            pushData.put("type", "broadcast_ended");
+            pushData.put("timestamp", System.currentTimeMillis());
+
+            JSONObject data = new JSONObject();
+            data.put("broadcasting", false);
+            pushData.put("data", data);
+
+            messagingTemplate.convertAndSend("/topic/screen/broadcast", pushData);
+            log.info("[WebSocket] 推送播报结束");
+        } catch (Exception e) {
+            log.error("推送播报结束失败", e);
+        }
+    }
+
+    /**
+     * 推送屏幕端连接确认
+     */
+    public void pushConnected(String sessionId) {
+        try {
+            JSONObject pushData = new JSONObject();
+            pushData.put("type", "connected");
+            pushData.put("timestamp", System.currentTimeMillis());
+
+            JSONObject data = new JSONObject();
+            data.put("sessionId", sessionId);
+            data.put("status", "connected");
+            pushData.put("data", data);
+
+            messagingTemplate.convertAndSend("/topic/screen/connect", pushData);
+            log.info("[WebSocket] 推送连接确认: sessionId={}", sessionId);
+        } catch (Exception e) {
+            log.error("推送连接确认失败", e);
+        }
+    }
 }

+ 10 - 1
ruoyi-admin/src/main/resources/application.yml

@@ -135,6 +135,12 @@ xss:
   # 匹配链接
   urlPatterns: /system/*,/monitor/*,/tool/*
 
+# 阿里云百炼 TTS 配置
+alibaba:
+  dashscope:
+    # 阿里云百炼 API Key(从环境变量或直接配置)
+    api-key: sk-3b05bc8f1bdc40cc87bd05f864890bd8
+
 # MQTT 配置
 mqtt:
   # EMQX 连接地址
@@ -151,7 +157,7 @@ mqtt:
   deviceId: ld000001
   # 订阅主题(多个用逗号分隔,留空则使用LD导航系统默认订阅)
   # LD导航系统会自动订阅: /${productId}/${deviceId}/localization/pose, /${productId}/${deviceId}/task/target/event/arrive 等
-  subscribeTopics: 
+  subscribeTopics:
   # QoS 级别
   qos: 1
   # 心跳间隔(秒)
@@ -163,6 +169,9 @@ mqtt:
   # 清除会话
   cleanSession: false
 
+screen:
+  resource-base-url: http://192.168.0.30:8080/profile
+
 # LD导航系统 HTTP API 地址
 ld-nav:
   # LD导航系统后端地址(用于获取地图、路网等数据)

+ 20 - 0
ruoyi-common/pom.xml

@@ -124,6 +124,26 @@
             <groupId>org.bytedeco</groupId>
             <artifactId>javacv-platform</artifactId>
             <version>1.5.9</version>
+            <exclusions>
+                <exclusion>
+                    <groupId>org.slf4j</groupId>
+                    <artifactId>slf4j-simple</artifactId>
+                </exclusion>
+            </exclusions>
+        </dependency>
+
+
+        <!-- DashScope SDK 语音合成 -->
+        <dependency>
+            <groupId>com.alibaba</groupId>
+            <artifactId>dashscope-sdk-java</artifactId>
+            <version>2.16.7</version>
+            <exclusions>
+                <exclusion>
+                    <groupId>org.slf4j</groupId>
+                    <artifactId>slf4j-simple</artifactId>
+                </exclusion>
+            </exclusions>
         </dependency>
 
     </dependencies>

+ 30 - 0
ruoyi-common/src/main/java/com/ruoyi/common/config/DashScopeProperties.java

@@ -0,0 +1,30 @@
+package com.ruoyi.common.config;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+/**
+ * 阿里云 DashScope 配置类
+ *
+ * @author ruoyi
+ * @date 2026-05-19
+ */
+@Component
+@ConfigurationProperties(prefix = "alibaba.dashscope")
+public class DashScopeProperties
+{
+    /**
+     * API Key
+     */
+    private String apiKey;
+
+    public String getApiKey()
+    {
+        return apiKey;
+    }
+
+    public void setApiKey(String apiKey)
+    {
+        this.apiKey = apiKey;
+    }
+}

+ 82 - 0
ruoyi-common/src/main/java/com/ruoyi/common/event/BroadcastStartedEvent.java

@@ -0,0 +1,82 @@
+package com.ruoyi.common.event;
+
+/**
+ * 播报开始事件
+ *
+ * @author ruoyi
+ */
+public class BroadcastStartedEvent extends ScreenEvent {
+
+    /** 任务ID */
+    private final Long taskId;
+
+    /** 内容ID */
+    private final Long contentId;
+
+    /** 任务名称 */
+    private final String taskName;
+
+    /** 标题 */
+    private final String title;
+
+    /** 播报的内容 */
+    private final String content;
+
+    /** 内容类型 */
+    private final String contentType;
+
+    /** 音频URL */
+    private final String audioUrl;
+
+    /** 音频时长(毫秒) */
+    private final Integer audioDuration;
+
+    /**
+     * 构造函数
+     */
+    public BroadcastStartedEvent(Object source, Long taskId, Long contentId, String taskName,
+                                 String title, String content, String contentType,
+                                 String audioUrl, Integer audioDuration) {
+        super(source);
+        this.taskId = taskId;
+        this.contentId = contentId;
+        this.taskName = taskName;
+        this.title = title;
+        this.content = content;
+        this.contentType = contentType;
+        this.audioUrl = audioUrl;
+        this.audioDuration = audioDuration;
+    }
+
+    public Long getTaskId() {
+        return taskId;
+    }
+
+    public Long getContentId() {
+        return contentId;
+    }
+
+    public String getTaskName() {
+        return taskName;
+    }
+
+    public String getTitle() {
+        return title;
+    }
+
+    public String getContent() {
+        return content;
+    }
+
+    public String getContentType() {
+        return contentType;
+    }
+
+    public String getAudioUrl() {
+        return audioUrl;
+    }
+
+    public Integer getAudioDuration() {
+        return audioDuration;
+    }
+}

+ 62 - 0
ruoyi-common/src/main/java/com/ruoyi/common/event/PlayPlanChangedEvent.java

@@ -0,0 +1,62 @@
+package com.ruoyi.common.event;
+
+import java.util.Map;
+
+/**
+ * 播放方案变化事件
+ *
+ * @author ruoyi
+ */
+public class PlayPlanChangedEvent extends ScreenEvent {
+
+    /** 播放方案数据 */
+    private final Map<String, Object> planData;
+
+    /** 是否启用 */
+    private final boolean enabled;
+
+    /** 方案ID */
+    private final Long planId;
+
+    /**
+     * 构造函数
+     *
+     * @param source   事件源
+     * @param planData 播放方案数据
+     */
+    public PlayPlanChangedEvent(Object source, Map<String, Object> planData) {
+        super(source);
+        this.planData = planData;
+        this.enabled = planData != null && !"false".equals(String.valueOf(planData.get("enabled")));
+        this.planId = planData != null ? parsePlanId(planData.get("planId")) : null;
+    }
+
+    public Map<String, Object> getPlanData() {
+        return planData;
+    }
+
+    public boolean isEnabled() {
+        return enabled;
+    }
+
+    public Long getPlanId() {
+        return planId;
+    }
+
+    private Long parsePlanId(Object planId) {
+        if (planId == null) {
+            return null;
+        }
+        if (planId instanceof Long) {
+            return (Long) planId;
+        }
+        if (planId instanceof Integer) {
+            return ((Integer) planId).longValue();
+        }
+        try {
+            return Long.parseLong(String.valueOf(planId));
+        } catch (NumberFormatException e) {
+            return null;
+        }
+    }
+}

+ 27 - 0
ruoyi-common/src/main/java/com/ruoyi/common/event/PlayPlanDisabledEvent.java

@@ -0,0 +1,27 @@
+package com.ruoyi.common.event;
+
+/**
+ * 播放方案禁用事件
+ *
+ * @author ruoyi
+ */
+public class PlayPlanDisabledEvent extends ScreenEvent {
+
+    /** 被禁用的方案ID */
+    private final Long planId;
+
+    /**
+     * 构造函数
+     *
+     * @param source 事件源
+     * @param planId 被禁用的方案ID
+     */
+    public PlayPlanDisabledEvent(Object source, Long planId) {
+        super(source);
+        this.planId = planId;
+    }
+
+    public Long getPlanId() {
+        return planId;
+    }
+}

+ 16 - 0
ruoyi-common/src/main/java/com/ruoyi/common/event/ScreenEvent.java

@@ -0,0 +1,16 @@
+package com.ruoyi.common.event;
+
+import org.springframework.context.ApplicationEvent;
+
+/**
+ * 屏幕端事件基类
+ * 用于跨模块事件通知
+ *
+ * @author ruoyi
+ */
+public abstract class ScreenEvent extends ApplicationEvent {
+
+    public ScreenEvent(Object source) {
+        super(source);
+    }
+}

+ 679 - 0
ruoyi-common/src/main/java/com/ruoyi/common/utils/TtsUtil.java

@@ -0,0 +1,679 @@
+package com.ruoyi.common.utils;
+
+import com.alibaba.dashscope.utils.Constants;
+import com.alibaba.dashscope.utils.JsonUtils;
+
+import com.ruoyi.common.config.DashScopeProperties;
+import com.ruoyi.common.config.RuoYiConfig;
+import com.ruoyi.common.utils.spring.SpringUtils;
+import com.ruoyi.common.utils.uuid.IdUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.*;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 阿里云百炼 Qwen-TTS (Text-to-Speech) 工具类
+ * 使用 DashScope HTTP API 直接调用
+ *
+ * @author ruoyi
+ * @date 2026-05-19
+ */
+public class TtsUtil
+{
+    private static final Logger log = LoggerFactory.getLogger(TtsUtil.class);
+
+    /**
+     * 音频输出目录(相对于 profile 路径)
+     */
+    private static final String AUDIO_OUTPUT_DIR = "audio/tts";
+
+    /**
+     * 音频格式
+     */
+    private static final String FORMAT = "wav";
+
+    /**
+     * Qwen-TTS 模型名称
+     */
+    private static final String MODEL = "qwen3-tts-flash";
+
+    /**
+     * 默认音色
+     */
+    private static final String DEFAULT_VOICE = "Cherry";
+
+    /**
+     * 默认语种
+     */
+    private static final String LANGUAGE_TYPE = "Chinese";
+
+    /**
+     * API URL
+     */
+    private static final String API_URL = "https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation";
+
+    /**
+     * 音频合成结果
+     */
+    public static class SynthesisResult
+    {
+        private String audioPath;
+        private Integer audioDuration;
+        private String errorMessage;
+
+        public SynthesisResult() { }
+
+        public SynthesisResult(String audioPath, Integer audioDuration)
+        {
+            this.audioPath = audioPath;
+            this.audioDuration = audioDuration;
+        }
+
+        public SynthesisResult(String errorMessage)
+        {
+            this.errorMessage = errorMessage;
+        }
+
+        public String getAudioPath() { return audioPath; }
+        public void setAudioPath(String audioPath) { this.audioPath = audioPath; }
+        public Integer getAudioDuration() { return audioDuration; }
+        public void setAudioDuration(Integer audioDuration) { this.audioDuration = audioDuration; }
+        public String getErrorMessage() { return errorMessage; }
+        public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; }
+        public boolean isSuccess() { return audioPath != null && !audioPath.isEmpty(); }
+    }
+
+    /**
+     * 初始化 DashScope API URL
+     */
+    static
+    {
+        Constants.baseHttpApiUrl = "https://dashscope.aliyuncs.com/api/v1";
+    }
+
+    /**
+     * 获取 API Key
+     */
+    private static String getApiKey()
+    {
+        // 优先从环境变量获取
+        String envKey = System.getenv("DASHSCOPE_API_KEY");
+        if (StringUtils.isNotBlank(envKey))
+        {
+            return envKey;
+        }
+
+        // 从 Spring 配置获取
+        try
+        {
+            DashScopeProperties properties = SpringUtils.getBean(DashScopeProperties.class);
+            if (properties != null && StringUtils.isNotBlank(properties.getApiKey()))
+            {
+                return properties.getApiKey();
+            }
+        }
+        catch (Exception e)
+        {
+            // Spring 上下文不可用,忽略
+        }
+
+        // 从系统属性获取
+        try
+        {
+            return System.getProperty("alibaba.dashscope.api-key", "");
+        }
+        catch (Exception e)
+        {
+            return "";
+        }
+    }
+
+    /**
+     * 同步合成语音(使用 HTTP API)
+     *
+     * @param text 待合成的文本
+     * @return 音频文件相对路径(如 audio/tts/xxx.wav),失败返回 null
+     */
+    public static String synthesizeSpeech(String text)
+    {
+        SynthesisResult result = synthesize(text, DEFAULT_VOICE, LANGUAGE_TYPE);
+        return result.isSuccess() ? result.getAudioPath() : null;
+    }
+
+    /**
+     * 同步合成语音(指定音色)
+     *
+     * @param text 待合成的文本
+     * @param voice 音色名称
+     * @return 音频文件相对路径
+     */
+    public static String synthesizeSpeech(String text, String voice)
+    {
+        SynthesisResult result = synthesize(text, voice, LANGUAGE_TYPE);
+        return result.isSuccess() ? result.getAudioPath() : null;
+    }
+
+    /**
+     * 同步合成语音(指定音色和语种)
+     *
+     * @param text 待合成的文本
+     * @param voice 音色名称
+     * @param languageType 语种
+     * @return 音频文件相对路径
+     */
+    public static String synthesizeSpeech(String text, String voice, String languageType)
+    {
+        SynthesisResult result = synthesize(text, voice, languageType);
+        return result.isSuccess() ? result.getAudioPath() : null;
+    }
+
+    /**
+     * 同步合成语音,返回完整结果(包含音频路径和时长)
+     *
+     * @param text 待合成的文本
+     * @param voice 音色名称
+     * @param languageType 语种
+     * @return 合成结果
+     */
+    public static SynthesisResult synthesize(String text, String voice, String languageType)
+    {
+        if (StringUtils.isBlank(text))
+        {
+            log.error("[TtsUtil] 文本不能为空");
+            return new SynthesisResult("文本不能为空");
+        }
+
+        String apiKey = getApiKey();
+        if (StringUtils.isBlank(apiKey))
+        {
+            log.error("[TtsUtil] 阿里云百炼 API Key 未配置,请检查配置或设置 DASHSCOPE_API_KEY 环境变量");
+            return new SynthesisResult("API Key 未配置");
+        }
+
+        try
+        {
+            log.info("[TtsUtil] 开始语音合成,文本长度: {}, 音色: {}, 语种: {}", text.length(), voice, languageType);
+
+            // 构建请求体
+            Map<String, Object> input = new HashMap<>();
+            input.put("text", text);
+            input.put("voice", StringUtils.isNotBlank(voice) ? voice : DEFAULT_VOICE);
+            input.put("language_type", languageType);
+
+            Map<String, Object> requestBody = new HashMap<>();
+            requestBody.put("model", MODEL);
+            requestBody.put("input", input);
+
+            // 发送 HTTP 请求
+            String jsonResponse = httpPost(API_URL, apiKey, JsonUtils.toJson(requestBody));
+
+            if (StringUtils.isBlank(jsonResponse))
+            {
+                log.error("[TtsUtil] API 返回为空");
+                return new SynthesisResult("API 返回为空");
+            }
+
+            log.debug("[TtsUtil] API 响应: {}", jsonResponse);
+
+            // 解析响应获取音频 URL
+            String audioUrl = extractAudioUrl(jsonResponse);
+
+            if (StringUtils.isBlank(audioUrl))
+            {
+                log.error("[TtsUtil] 未能从响应中提取音频 URL,响应: {}", jsonResponse);
+                return new SynthesisResult("未能获取音频 URL");
+            }
+
+            // 下载音频文件并获取时长
+            String localPath = downloadAudio(audioUrl);
+            if (StringUtils.isBlank(localPath))
+            {
+                return new SynthesisResult("下载音频失败");
+            }
+
+            // 计算音频时长
+            int duration = calculateAudioDuration(localPath);
+
+            log.info("[TtsUtil] 语音合成成功,文件路径: {}, 时长: {} 秒", localPath, duration);
+
+            return new SynthesisResult(localPath, duration);
+        }
+        catch (Exception e)
+        {
+            log.error("[TtsUtil] 语音合成异常: {}", e.getMessage(), e);
+            return new SynthesisResult(e.getMessage());
+        }
+    }
+
+    /**
+     * 计算音频时长(秒)
+     * 根据 WAV 文件头计算时长
+     */
+    private static int calculateAudioDuration(String localPath)
+    {
+        FileInputStream fis = null;
+        try
+        {
+            File file = new File(RuoYiConfig.getProfile() + File.separator + localPath);
+            if (!file.exists())
+            {
+                return 0;
+            }
+
+            fis = new FileInputStream(file);
+            byte[] header = new byte[44];
+            if (fis.read(header) != 44)
+            {
+                log.warn("[TtsUtil] WAV 文件头读取失败");
+                return 0;
+            }
+
+            // 验证 RIFF 标识
+            if (header[0] != 'R' || header[1] != 'I' || header[2] != 'F' || header[3] != 'F')
+            {
+                log.warn("[TtsUtil] 不是有效的 WAV 文件");
+                return 0;
+            }
+
+            // 提取采样率(offset 24-27,小端序)
+            int sampleRate = ((header[27] & 0xFF) << 24) | ((header[26] & 0xFF) << 16) |
+                    ((header[25] & 0xFF) << 8) | (header[24] & 0xFF);
+
+            // 提取通道数(offset 22-23,小端序)
+            short numChannels = (short) (((header[23] & 0xFF) << 8) | (header[22] & 0xFF));
+
+            // 提取比特率(offset 34-35,小端序)
+            short bitsPerSample = (short) (((header[35] & 0xFF) << 8) | (header[34] & 0xFF));
+
+            // 提取数据大小(offset 40-43,小端序)
+            int dataSize = ((header[43] & 0xFF) << 24) | ((header[42] & 0xFF) << 16) |
+                    ((header[41] & 0xFF) << 8) | (header[40] & 0xFF);
+
+            if (sampleRate <= 0 || numChannels <= 0 || bitsPerSample <= 0 || dataSize <= 0)
+            {
+                log.warn("[TtsUtil] WAV 文件参数无效,sampleRate={}, channels={}, bits={}, dataSize={}",
+                        sampleRate, numChannels, bitsPerSample, dataSize);
+                return 0;
+            }
+
+            // 计算时长(秒)
+            int bytesPerSample = bitsPerSample / 8;
+            int bytesPerSecond = numChannels * sampleRate * bytesPerSample;
+            int duration = (int) Math.ceil((double) dataSize / bytesPerSecond);
+
+            log.debug("[TtsUtil] 音频时长计算:采样率={}, 通道数={}, 比特率={}, 数据大小={}, 时长={}秒",
+                    sampleRate, numChannels, bitsPerSample, dataSize, duration);
+
+            return duration > 0 ? duration : 0;
+        }
+        catch (Exception e)
+        {
+            log.warn("[TtsUtil] 计算音频时长失败: {}", e.getMessage());
+            return 0;
+        }
+        finally
+        {
+            if (fis != null)
+            {
+                try { fis.close(); } catch (IOException e) { }
+            }
+        }
+    }
+
+    /**
+     * 使用指令控制语音合成
+     *
+     * @param text 待合成的文本
+     * @param instruction 语音控制指令
+     * @return 音频文件相对路径
+     */
+    public static String synthesizeSpeechWithInstruction(String text, String instruction)
+    {
+        return synthesizeSpeechWithInstruction(text, instruction, false);
+    }
+
+    /**
+     * 使用指令控制语音合成
+     *
+     * @param text 待合成的文本
+     * @param instruction 语音控制指令
+     * @param optimizeInstructions 是否优化指令
+     * @return 音频文件相对路径
+     */
+    public static String synthesizeSpeechWithInstruction(String text, String instruction, boolean optimizeInstructions)
+    {
+        if (StringUtils.isBlank(text))
+        {
+            log.error("[TtsUtil] 文本不能为空");
+            return null;
+        }
+
+        String apiKey = getApiKey();
+        if (StringUtils.isBlank(apiKey))
+        {
+            log.error("[TtsUtil] 阿里云百炼 API Key 未配置");
+            return null;
+        }
+
+        log.info("[TtsUtil] 使用指令控制模式,模型: qwen3-tts-instruct-flash");
+
+        try
+        {
+            Map<String, Object> input = new HashMap<>();
+            input.put("text", text);
+            input.put("voice", DEFAULT_VOICE);
+            input.put("language_type", LANGUAGE_TYPE);
+
+            Map<String, Object> parameters = new HashMap<>();
+            parameters.put("instructions", instruction);
+            parameters.put("optimize_instructions", optimizeInstructions);
+
+            Map<String, Object> requestBody = new HashMap<>();
+            requestBody.put("model", "qwen3-tts-instruct-flash");
+            requestBody.put("input", input);
+            requestBody.put("parameters", parameters);
+
+            String jsonResponse = httpPost(API_URL, apiKey, JsonUtils.toJson(requestBody));
+
+            if (StringUtils.isBlank(jsonResponse))
+            {
+                log.error("[TtsUtil] API 返回为空");
+                return null;
+            }
+
+            String audioUrl = extractAudioUrl(jsonResponse);
+
+            if (StringUtils.isBlank(audioUrl))
+            {
+                log.error("[TtsUtil] 未能提取音频 URL");
+                return null;
+            }
+
+            return downloadAudio(audioUrl);
+        }
+        catch (Exception e)
+        {
+            log.error("[TtsUtil] 语音合成异常: {}", e.getMessage(), e);
+            return null;
+        }
+    }
+
+    /**
+     * 发送 HTTP POST 请求
+     */
+    private static String httpPost(String urlStr, String apiKey, String jsonBody) throws Exception
+    {
+        HttpURLConnection conn = null;
+        BufferedReader reader = null;
+        StringBuilder response = new StringBuilder();
+
+        try
+        {
+            URL url = new URL(urlStr);
+            conn = (HttpURLConnection) url.openConnection();
+
+            conn.setRequestMethod("POST");
+            conn.setDoOutput(true);
+            conn.setDoInput(true);
+            conn.setConnectTimeout(60000);
+            conn.setReadTimeout(120000);
+
+            // 设置请求头
+            conn.setRequestProperty("Authorization", "Bearer " + apiKey);
+            conn.setRequestProperty("Content-Type", "application/json");
+
+            // 发送请求体
+            try (OutputStream os = conn.getOutputStream())
+            {
+                byte[] input = jsonBody.getBytes(StandardCharsets.UTF_8);
+                os.write(input, 0, input.length);
+            }
+
+            // 读取响应
+            int responseCode = conn.getResponseCode();
+            if (responseCode != 200)
+            {
+                log.error("[TtsUtil] HTTP 请求失败,状态码: {}", responseCode);
+                try (BufferedReader errorReader = new BufferedReader(
+                        new InputStreamReader(conn.getErrorStream(), StandardCharsets.UTF_8)))
+                {
+                    String line;
+                    while ((line = errorReader.readLine()) != null)
+                    {
+                        response.append(line);
+                    }
+                }
+                log.error("[TtsUtil] 错误响应: {}", response);
+                return null;
+            }
+
+            reader = new BufferedReader(new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8));
+            String line;
+            while ((line = reader.readLine()) != null)
+            {
+                response.append(line);
+            }
+
+            return response.toString();
+        }
+        finally
+        {
+            if (reader != null)
+            {
+                try { reader.close(); } catch (IOException e) { }
+            }
+            if (conn != null)
+            {
+                conn.disconnect();
+            }
+        }
+    }
+
+    /**
+     * 从 JSON 响应中提取音频 URL
+     */
+    private static String extractAudioUrl(String jsonResponse)
+    {
+        try
+        {
+            // 简单解析 JSON(避免引入 Gson 依赖)
+            // 查找 "url": "..." 模式
+            int urlIndex = jsonResponse.indexOf("\"url\"");
+            if (urlIndex == -1)
+            {
+                return null;
+            }
+
+            int colonIndex = jsonResponse.indexOf(":", urlIndex);
+            if (colonIndex == -1)
+            {
+                return null;
+            }
+
+            // 找到值的开始位置(跳过引号和可能的空格)
+            int valueStart = colonIndex + 1;
+            while (valueStart < jsonResponse.length() &&
+                    (jsonResponse.charAt(valueStart) == ' ' ||
+                            jsonResponse.charAt(valueStart) == '"'))
+            {
+                valueStart++;
+            }
+
+            // 找到值的结束位置(下一个引号)
+            int valueEnd = valueStart;
+            while (valueEnd < jsonResponse.length() && jsonResponse.charAt(valueEnd) != '"')
+            {
+                valueEnd++;
+            }
+
+            return jsonResponse.substring(valueStart, valueEnd);
+        }
+        catch (Exception e)
+        {
+            log.error("[TtsUtil] 解析音频 URL 失败: {}", e.getMessage());
+            return null;
+        }
+    }
+
+    /**
+     * 下载音频文件到本地
+     */
+    private static String downloadAudio(String audioUrl)
+    {
+        InputStream inputStream = null;
+        OutputStream outputStream = null;
+        HttpURLConnection conn = null;
+
+        try
+        {
+            // 创建输出目录
+            String outputDir = RuoYiConfig.getProfile() + File.separator + AUDIO_OUTPUT_DIR;
+            Path dirPath = Paths.get(outputDir);
+            if (!Files.exists(dirPath))
+            {
+                Files.createDirectories(dirPath);
+            }
+
+            // 生成唯一的文件名
+            String fileName = IdUtils.fastSimpleUUID() + "." + FORMAT;
+            String localFilePath = outputDir + File.separator + fileName;
+
+            // 下载文件
+            URL url = new URL(audioUrl);
+            conn = (HttpURLConnection) url.openConnection();
+            conn.setConnectTimeout(60000);
+            conn.setReadTimeout(60000);
+
+            int responseCode = conn.getResponseCode();
+            if (responseCode != 200)
+            {
+                log.error("[TtsUtil] 下载音频失败,状态码: {}", responseCode);
+                return null;
+            }
+
+            inputStream = conn.getInputStream();
+            outputStream = new FileOutputStream(localFilePath);
+
+            byte[] buffer = new byte[8192];
+            int bytesRead;
+            while ((bytesRead = inputStream.read(buffer)) != -1)
+            {
+                outputStream.write(buffer, 0, bytesRead);
+            }
+            outputStream.flush();
+
+            return AUDIO_OUTPUT_DIR + File.separator + fileName;
+        }
+        catch (Exception e)
+        {
+            log.error("[TtsUtil] 下载音频异常: {}", e.getMessage(), e);
+            return null;
+        }
+        finally
+        {
+            if (inputStream != null)
+            {
+                try { inputStream.close(); } catch (IOException e) { }
+            }
+            if (outputStream != null)
+            {
+                try { outputStream.close(); } catch (IOException e) { }
+            }
+            if (conn != null)
+            {
+                conn.disconnect();
+            }
+        }
+    }
+
+    /**
+     * 删除本地音频文件
+     */
+    public static boolean deleteAudioFile(String audioPath)
+    {
+        if (StringUtils.isBlank(audioPath))
+        {
+            return false;
+        }
+
+        try
+        {
+            String fullPath = RuoYiConfig.getProfile() + File.separator + audioPath;
+            Path path = Paths.get(fullPath);
+            if (Files.exists(path))
+            {
+                Files.delete(path);
+                log.info("[TtsUtil] 音频文件已删除: {}", fullPath);
+                return true;
+            }
+            else
+            {
+                log.warn("[TtsUtil] 音频文件不存在: {}", fullPath);
+                return false;
+            }
+        }
+        catch (Exception e)
+        {
+            log.error("[TtsUtil] 删除音频文件失败: {}", e.getMessage(), e);
+            return false;
+        }
+    }
+
+    /**
+     * 生成输出文件路径
+     */
+    public static String generateOutputPath()
+    {
+        try
+        {
+            String outputDir = RuoYiConfig.getProfile() + File.separator + AUDIO_OUTPUT_DIR;
+            Path dirPath = Paths.get(outputDir);
+            if (!Files.exists(dirPath))
+            {
+                Files.createDirectories(dirPath);
+            }
+            String fileName = IdUtils.fastSimpleUUID() + "." + FORMAT;
+            return outputDir + File.separator + fileName;
+        }
+        catch (IOException e)
+        {
+            log.error("[TtsUtil] 生成输出路径失败: {}", e.getMessage());
+            return null;
+        }
+    }
+
+    /**
+     * 获取相对路径
+     */
+    public static String getRelativePath(String fullPath)
+    {
+        if (StringUtils.isBlank(fullPath))
+        {
+            return null;
+        }
+        String fileName = new File(fullPath).getName();
+        return AUDIO_OUTPUT_DIR + File.separator + fileName;
+    }
+
+    /**
+     * 获取音频访问 URL
+     */
+    public static String getAudioUrl(String relativePath)
+    {
+        if (StringUtils.isBlank(relativePath))
+        {
+            return null;
+        }
+        return "/profile/" + relativePath;
+    }
+}

+ 2 - 0
ruoyi-framework/src/main/java/com/ruoyi/framework/config/SecurityConfig.java

@@ -117,6 +117,8 @@ public class SecurityConfig
                     // 静态资源,可匿名访问
                     .antMatchers(HttpMethod.GET, "/", "/*.html", "/**/*.html", "/**/*.css", "/**/*.js", "/profile/**").permitAll()
                     .antMatchers("/swagger-ui.html", "/swagger-resources/**", "/webjars/**", "/*/api-docs", "/druid/**").permitAll()
+                    // 机器人机身屏API接口,允许匿名访问
+                    .antMatchers("/robot-ops/**").permitAll()
                     // 除上面外的所有请求全部需要鉴权认证
                     .anyRequest().authenticated();
             })

+ 66 - 0
ruoyi-framework/src/main/java/com/ruoyi/framework/config/TtsThreadPoolConfig.java

@@ -0,0 +1,66 @@
+package com.ruoyi.framework.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.annotation.EnableAsync;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
+
+import java.util.concurrent.Executor;
+import java.util.concurrent.ThreadPoolExecutor;
+
+/**
+ * TTS 异步任务线程池配置
+ *
+ * @author ruoyi
+ * @date 2026-05-19
+ */
+@Configuration
+@EnableAsync
+public class TtsThreadPoolConfig
+{
+    /**
+     * 核心线程池大小
+     */
+    private static final int CORE_POOL_SIZE = 5;
+
+    /**
+     * 最大创建线程数量
+     */
+    private static final int MAX_POOL_SIZE = 10;
+
+    /**
+     * 队列容量
+     */
+    private static final int QUEUE_CAPACITY = 20;
+
+    /**
+     * 线程池维护线程存活时间
+     */
+    private static final int KEEP_ALIVE_SECONDS = 60;
+
+    /**
+     * 线程名称前缀
+     */
+    private static final String THREAD_NAME_PREFIX = "tts-async-";
+
+    /**
+     * TTS 异步任务执行器
+     */
+    @Bean("ttsTaskExecutor")
+    public Executor ttsTaskExecutor()
+    {
+        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
+        executor.setCorePoolSize(CORE_POOL_SIZE);
+        executor.setMaxPoolSize(MAX_POOL_SIZE);
+        executor.setQueueCapacity(QUEUE_CAPACITY);
+        executor.setKeepAliveSeconds(KEEP_ALIVE_SECONDS);
+        executor.setThreadNamePrefix(THREAD_NAME_PREFIX);
+        // 拒绝策略:由调用线程执行
+        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
+        // 等待队列任务完成后再关闭线程池
+        executor.setWaitForTasksToCompleteOnShutdown(true);
+        executor.setAwaitTerminationSeconds(60);
+        executor.initialize();
+        return executor;
+    }
+}

+ 171 - 24
ruoyi-system/src/main/java/com/ruoyi/base/domain/RobotOpsBroadcastContent.java

@@ -7,7 +7,7 @@ import com.ruoyi.common.core.domain.BaseEntity;
 
 /**
  * 播报内容对象 robot_ops_broadcast_content
- * 
+ *
  * @author ruoyi
  * @date 2026-04-27
  */
@@ -22,7 +22,7 @@ public class RobotOpsBroadcastContent extends BaseEntity
     @Excel(name = "播报内容名称")
     private String contentName;
 
-    /** 内容分类 */
+    /** 内容分类(1=通知、2=宣传、3=提示、4=安防提醒、0=自定义) */
     @Excel(name = "内容分类")
     private Long contentType;
 
@@ -31,70 +31,217 @@ public class RobotOpsBroadcastContent extends BaseEntity
     private String broadcastText;
 
     /** 启用状态:0停用,1启用 */
-    @Excel(name = "启用状态:0停用,1启用")
+    @Excel(name = "启用状态", readConverterExp = "0=停用,1=启用")
     private String status;
 
-    public void setId(Long id) 
+    /** 备注 */
+    @Excel(name = "备注")
+    private String remark;
+
+    /** 合成音频URL */
+    @Excel(name = "音频路径")
+    private String audioPath;
+
+    /** 合成状态:0未合成,1成功,2失败,3合成中 */
+    @Excel(name = "合成状态", readConverterExp = "0=未合成,1=成功,2=失败,3=合成中")
+    private String audioStatus;
+
+    /** 阿里云百炼TTS任务ID */
+    private String audioTaskId;
+
+    /** 音频时长(秒) */
+    @Excel(name = "音频时长(秒)")
+    private Integer audioDuration;
+
+    /** 合成失败错误信息 */
+    @Excel(name = "错误信息")
+    private String audioErrorMsg;
+
+    /** 创建时间 */
+    @Excel(name = "创建时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
+    private java.util.Date createTime;
+
+    /** 更新时间 */
+    @Excel(name = "更新时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
+    private java.util.Date updateTime;
+
+    /** 创建者 */
+    @Excel(name = "创建者")
+    private String createBy;
+
+    /** 更新者 */
+    @Excel(name = "更新者")
+    private String updateBy;
+
+    public void setId(Long id)
     {
         this.id = id;
     }
 
-    public Long getId() 
+    public Long getId()
     {
         return id;
     }
 
-    public void setContentName(String contentName) 
+    public void setContentName(String contentName)
     {
         this.contentName = contentName;
     }
 
-    public String getContentName() 
+    public String getContentName()
     {
         return contentName;
     }
 
-    public void setContentType(Long contentType) 
+    public void setContentType(Long contentType)
     {
         this.contentType = contentType;
     }
 
-    public Long getContentType() 
+    public Long getContentType()
     {
         return contentType;
     }
 
-    public void setBroadcastText(String broadcastText) 
+    public void setBroadcastText(String broadcastText)
     {
         this.broadcastText = broadcastText;
     }
 
-    public String getBroadcastText() 
+    public String getBroadcastText()
     {
         return broadcastText;
     }
 
-    public void setStatus(String status) 
+    public void setStatus(String status)
     {
         this.status = status;
     }
 
-    public String getStatus() 
+    public String getStatus()
     {
         return status;
     }
 
+    public String getRemark()
+    {
+        return remark;
+    }
+
+    public void setRemark(String remark)
+    {
+        this.remark = remark;
+    }
+
+    public String getAudioPath()
+    {
+        return audioPath;
+    }
+
+    public void setAudioPath(String audioPath)
+    {
+        this.audioPath = audioPath;
+    }
+
+    public String getAudioStatus()
+    {
+        return audioStatus;
+    }
+
+    public void setAudioStatus(String audioStatus)
+    {
+        this.audioStatus = audioStatus;
+    }
+
+    public String getAudioTaskId()
+    {
+        return audioTaskId;
+    }
+
+    public void setAudioTaskId(String audioTaskId)
+    {
+        this.audioTaskId = audioTaskId;
+    }
+
+    public Integer getAudioDuration()
+    {
+        return audioDuration;
+    }
+
+    public void setAudioDuration(Integer audioDuration)
+    {
+        this.audioDuration = audioDuration;
+    }
+
+    public String getAudioErrorMsg()
+    {
+        return audioErrorMsg;
+    }
+
+    public void setAudioErrorMsg(String audioErrorMsg)
+    {
+        this.audioErrorMsg = audioErrorMsg;
+    }
+
+    public void setCreateTime(java.util.Date createTime)
+    {
+        this.createTime = createTime;
+    }
+
+    public java.util.Date getCreateTime()
+    {
+        return createTime;
+    }
+
+    public void setUpdateTime(java.util.Date updateTime)
+    {
+        this.updateTime = updateTime;
+    }
+
+    public java.util.Date getUpdateTime()
+    {
+        return updateTime;
+    }
+
+    public void setCreateBy(String createBy)
+    {
+        this.createBy = createBy;
+    }
+
+    public String getCreateBy()
+    {
+        return createBy;
+    }
+
+    public void setUpdateBy(String updateBy)
+    {
+        this.updateBy = updateBy;
+    }
+
+    public String getUpdateBy()
+    {
+        return updateBy;
+    }
+
     @Override
-    public String toString() {
-        return new ToStringBuilder(this,ToStringStyle.MULTI_LINE_STYLE)
-            .append("id", getId())
-            .append("contentName", getContentName())
-            .append("contentType", getContentType())
-            .append("broadcastText", getBroadcastText())
-            .append("status", getStatus())
-            .append("remark", getRemark())
-            .append("createTime", getCreateTime())
-            .append("updateTime", getUpdateTime())
-            .toString();
+    public String toString()
+    {
+        return new ToStringBuilder(this, ToStringStyle.MULTI_LINE_STYLE)
+                .append("id", getId())
+                .append("contentName", getContentName())
+                .append("contentType", getContentType())
+                .append("broadcastText", getBroadcastText())
+                .append("status", getStatus())
+                .append("remark", getRemark())
+                .append("audioPath", getAudioPath())
+                .append("audioStatus", getAudioStatus())
+                .append("audioTaskId", getAudioTaskId())
+                .append("audioDuration", getAudioDuration())
+                .append("audioErrorMsg", getAudioErrorMsg())
+                .append("createTime", getCreateTime())
+                .append("updateTime", getUpdateTime())
+                .append("createBy", getCreateBy())
+                .append("updateBy", getUpdateBy())
+                .toString();
     }
 }

+ 14 - 0
ruoyi-system/src/main/java/com/ruoyi/base/service/IBroadcastRecordService.java

@@ -0,0 +1,14 @@
+package com.ruoyi.base.service;
+
+public interface IBroadcastRecordService {
+
+    boolean shouldBroadcast(Long taskId, Integer frequencyMinutes);
+
+    void recordBroadcast(Long taskId);
+
+    void recordBroadcast(Long taskId, Integer frequencyMinutes);
+
+    void clearRecord(Long taskId);
+
+    long getRemainingSeconds(Long taskId, Integer frequencyMinutes);
+}

+ 31 - 8
ruoyi-system/src/main/java/com/ruoyi/base/service/IRobotOpsBroadcastContentService.java

@@ -5,15 +5,15 @@ import com.ruoyi.base.domain.RobotOpsBroadcastContent;
 
 /**
  * 播报内容Service接口
- * 
+ *
  * @author ruoyi
  * @date 2026-04-27
  */
-public interface IRobotOpsBroadcastContentService 
+public interface IRobotOpsBroadcastContentService
 {
     /**
      * 查询播报内容
-     * 
+     *
      * @param id 播报内容主键
      * @return 播报内容
      */
@@ -21,7 +21,7 @@ public interface IRobotOpsBroadcastContentService
 
     /**
      * 查询播报内容列表
-     * 
+     *
      * @param robotOpsBroadcastContent 播报内容
      * @return 播报内容集合
      */
@@ -29,7 +29,7 @@ public interface IRobotOpsBroadcastContentService
 
     /**
      * 新增播报内容
-     * 
+     *
      * @param robotOpsBroadcastContent 播报内容
      * @return 结果
      */
@@ -37,7 +37,7 @@ public interface IRobotOpsBroadcastContentService
 
     /**
      * 修改播报内容
-     * 
+     *
      * @param robotOpsBroadcastContent 播报内容
      * @return 结果
      */
@@ -45,7 +45,7 @@ public interface IRobotOpsBroadcastContentService
 
     /**
      * 批量删除播报内容
-     * 
+     *
      * @param ids 需要删除的播报内容主键集合
      * @return 结果
      */
@@ -53,9 +53,32 @@ public interface IRobotOpsBroadcastContentService
 
     /**
      * 删除播报内容信息
-     * 
+     *
      * @param id 播报内容主键
      * @return 结果
      */
     public int deleteRobotOpsBroadcastContentById(Long id);
+
+    /**
+     * 合成音频(异步)
+     *
+     * @param id 播报内容主键
+     */
+    public void synthesizeAudio(Long id);
+
+    /**
+     * 重新合成音频(异步)
+     * 先删除旧音频,再合成新的
+     *
+     * @param id 播报内容主键
+     */
+    public void regenerateAudio(Long id);
+
+    /**
+     * 检查是否正在合成中
+     *
+     * @param id 播报内容主键
+     * @return true=正在合成中,false=未在合成
+     */
+    public boolean isSynthesizing(Long id);
 }

+ 15 - 0
ruoyi-system/src/main/java/com/ruoyi/base/service/IRobotOpsBroadcastTaskService.java

@@ -1,6 +1,7 @@
 package com.ruoyi.base.service;
 
 import java.util.List;
+import java.util.Map;
 import com.ruoyi.base.domain.RobotOpsBroadcastTask;
 
 /**
@@ -58,4 +59,18 @@ public interface IRobotOpsBroadcastTaskService
      * @return 结果
      */
     public int deleteRobotOpsBroadcastTaskById(Long id);
+
+    /**
+     * 获取当前播报任务(根据时间和循环类型判断)
+     * 
+     * @return 当前命中的播报任务,如果没有则返回 null
+     */
+    RobotOpsBroadcastTask selectCurrentBroadcastTask();
+
+    /**
+     * 获取当前播报任务及其关联的播报内容
+     * 
+     * @return 包含播报内容的结果,如果没有则返回 null
+     */
+    Map<String, Object> selectCurrentBroadcastTaskWithContent();
 }

+ 161 - 0
ruoyi-system/src/main/java/com/ruoyi/base/service/impl/BroadcastRecordServiceImpl.java

@@ -0,0 +1,161 @@
+package com.ruoyi.base.service.impl;
+
+import com.ruoyi.base.service.IBroadcastRecordService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.stereotype.Service;
+
+import java.util.concurrent.TimeUnit;
+
+@Service
+public class BroadcastRecordServiceImpl implements IBroadcastRecordService {
+
+    @Autowired
+    private StringRedisTemplate redisTemplate;
+
+    private static final Logger log = LoggerFactory.getLogger(BroadcastRecordServiceImpl.class);
+
+    private static final String KEY_PREFIX = "broadcast:next:";
+    private static final int DEFAULT_EXPIRE_HOURS = 24;
+
+    /**
+     * 判断任务是否应该播报
+     *
+     * @param taskId 任务ID
+     * @param frequencyMinutes 播报频率(分钟)
+     * @return true-可以播报,false-未到播报时间
+     */
+    public boolean shouldBroadcast(Long taskId, Integer frequencyMinutes) {
+        // 频率为空或小于等于0,允许播报
+        if (frequencyMinutes == null || frequencyMinutes <= 0) {
+            return true;
+        }
+
+        String key = buildKey(taskId);
+        String nextTimeStr = redisTemplate.opsForValue().get(key);
+
+        long now = System.currentTimeMillis();
+        log.info("[播报记录] 检查是否可播报, 任务ID: {}, 频率: {}分钟, 下次播报时间: {}, 当前时间: {}",
+                taskId, frequencyMinutes, nextTimeStr, now);
+
+        // Redis中无记录,首次播报
+        if (nextTimeStr == null) {
+            return true;
+        }
+
+        try {
+            long nextTime = Long.parseLong(nextTimeStr);
+
+            // 健壮性检查:如果下次播报时间是过去的时间(可能是旧数据),立即播报
+            // 如果下次播报时间距离现在超过7天(异常数据),也视为无效
+            long sevenDaysMs = 7 * 24 * 60 * 60 * 1000L;
+            if (nextTime <= now || (nextTime - now) > sevenDaysMs) {
+                log.warn("[播报记录] 下次播报时间异常(已过期或太远),视为可播报: {}", nextTime);
+                return true;
+            }
+
+            // 比较当前时间是否到达下次播报时间
+            boolean result = now >= nextTime;
+            log.info("[播报记录] 播报检查结果, 当前时间: {} >= 下次播报时间: {}, 结果: {}", now, nextTime, result);
+            return result;
+        } catch (NumberFormatException e) {
+            // 解析失败,允许播报
+            return true;
+        }
+    }
+
+
+    /**
+     * 记录播报(仅记录当前时间,不计算下次时间)
+     * 注意:此方法未被使用,实际使用的是带频率参数的版本
+     *
+     * @param taskId 任务ID
+     */
+    public void recordBroadcast(Long taskId) {
+        // 记录的是当前播报时间戳
+        String key = buildKey(taskId);
+        long currentMs = System.currentTimeMillis();
+        System.out.println("zmj1下次可播报时间: ");
+        redisTemplate.opsForValue().set(
+                key,
+                String.valueOf(currentMs),
+                DEFAULT_EXPIRE_HOURS,
+                TimeUnit.HOURS
+        );
+        log.info("[播报记录] 记录播报时间, 任务ID: {}, 键: {}, 时间戳: {}", taskId, key, currentMs);
+    }
+
+    /**
+     * 记录播报并设置下次可播报时间
+     * 这是主要使用的方法,直接计算并存储下次可播报的时间点
+     *
+     * @param taskId 任务ID
+     * @param frequencyMinutes 播报频率(分钟)
+     */
+    public void recordBroadcast(Long taskId, Integer frequencyMinutes) {
+        String key = buildKey(taskId);
+        // 计算下次可播报时间 = 当前时间 + 频率分钟数
+        long nextTime = System.currentTimeMillis() + frequencyMinutes * 60 * 1000L;
+        System.out.println("zmj2下次可播报时间: " + nextTime);
+        redisTemplate.opsForValue().set(
+                key,
+                String.valueOf(nextTime),
+                DEFAULT_EXPIRE_HOURS,
+                TimeUnit.HOURS
+        );
+        log.info("[播报记录] 记录下次播报时间, 任务ID: {}, 键: {}, 下次播报时间: {}", taskId, key, nextTime);
+    }
+
+    /**
+     * 清除播报记录
+     *
+     * @param taskId 任务ID
+     */
+    public void clearRecord(Long taskId) {
+        String key = buildKey(taskId);
+        redisTemplate.delete(key);
+    }
+
+    /**
+     * 获取距离下次播报的剩余秒数
+     * 用于前端显示倒计时
+     *
+     * @param taskId 任务ID
+     * @param frequencyMinutes 播报频率(分钟)
+     * @return 剩余秒数,0表示可以立即播报
+     */
+    public long getRemainingSeconds(Long taskId, Integer frequencyMinutes) {
+        String key = buildKey(taskId);
+        String nextTimeStr = redisTemplate.opsForValue().get(key);
+
+        // 无记录,返回0
+        if (nextTimeStr == null) {
+            return 0;
+        }
+
+        try {
+            long nextTime = Long.parseLong(nextTimeStr);
+            long now = System.currentTimeMillis();
+
+            // 如果下次播报时间是过去的时间,返回0
+            // 如果下次播报时间距离现在超过7天,返回0
+            long sevenDaysMs = 7 * 24 * 60 * 60 * 1000L;
+            if (nextTime <= now || (nextTime - now) > sevenDaysMs) {
+                return 0;
+            }
+
+            // 计算剩余秒数
+            long remaining = nextTime - now;
+            return Math.max(0, remaining / 1000);
+        } catch (NumberFormatException e) {
+            // 解析失败,返回0
+            return 0;
+        }
+    }
+
+    private String buildKey(Long taskId) {
+        return KEY_PREFIX + (taskId != null ? taskId : "unknown");
+    }
+}

+ 273 - 11
ruoyi-system/src/main/java/com/ruoyi/base/service/impl/RobotOpsBroadcastContentServiceImpl.java

@@ -2,27 +2,47 @@ package com.ruoyi.base.service.impl;
 
 import java.util.List;
 import com.ruoyi.common.utils.DateUtils;
+import com.ruoyi.common.utils.StringUtils;
+import com.ruoyi.common.utils.TtsUtil;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.scheduling.annotation.Async;
 import org.springframework.stereotype.Service;
-import com.ruoyi.base.mapper.RobotOpsBroadcastContentMapper;
+import org.springframework.transaction.annotation.Transactional;
 import com.ruoyi.base.domain.RobotOpsBroadcastContent;
+import com.ruoyi.base.mapper.RobotOpsBroadcastContentMapper;
 import com.ruoyi.base.service.IRobotOpsBroadcastContentService;
 
 /**
  * 播报内容Service业务层处理
- * 
+ *
  * @author ruoyi
  * @date 2026-04-27
  */
 @Service
-public class RobotOpsBroadcastContentServiceImpl implements IRobotOpsBroadcastContentService 
+public class RobotOpsBroadcastContentServiceImpl implements IRobotOpsBroadcastContentService
 {
+    private static final Logger log = LoggerFactory.getLogger(RobotOpsBroadcastContentServiceImpl.class);
+
+    /** 音频状态:0=未合成,1=成功,2=失败,3=合成中 */
+    private static final String AUDIO_STATUS_NOT_SYNTHESIZED = "0";
+    private static final String AUDIO_STATUS_SUCCESS = "1";
+    private static final String AUDIO_STATUS_FAILED = "2";
+    private static final String AUDIO_STATUS_SYNTHESIZING = "3";
+
+    /** TTS 默认音色 */
+    private static final String DEFAULT_VOICE = "Cherry";
+
+    /** TTS 默认语种 */
+    private static final String LANGUAGE_TYPE = "Chinese";
+
     @Autowired
     private RobotOpsBroadcastContentMapper robotOpsBroadcastContentMapper;
 
     /**
      * 查询播报内容
-     * 
+     *
      * @param id 播报内容主键
      * @return 播报内容
      */
@@ -34,7 +54,7 @@ public class RobotOpsBroadcastContentServiceImpl implements IRobotOpsBroadcastCo
 
     /**
      * 查询播报内容列表
-     * 
+     *
      * @param robotOpsBroadcastContent 播报内容
      * @return 播报内容
      */
@@ -46,51 +66,293 @@ public class RobotOpsBroadcastContentServiceImpl implements IRobotOpsBroadcastCo
 
     /**
      * 新增播报内容
-     * 
+     *
      * @param robotOpsBroadcastContent 播报内容
      * @return 结果
      */
     @Override
+    @Transactional(rollbackFor = Exception.class)
     public int insertRobotOpsBroadcastContent(RobotOpsBroadcastContent robotOpsBroadcastContent)
     {
         robotOpsBroadcastContent.setCreateTime(DateUtils.getNowDate());
-        return robotOpsBroadcastContentMapper.insertRobotOpsBroadcastContent(robotOpsBroadcastContent);
+        robotOpsBroadcastContent.setUpdateTime(DateUtils.getNowDate());
+        // 默认音频状态为未合成
+        if (StringUtils.isBlank(robotOpsBroadcastContent.getAudioStatus()))
+        {
+            robotOpsBroadcastContent.setAudioStatus(AUDIO_STATUS_NOT_SYNTHESIZED);
+        }
+        int rows = robotOpsBroadcastContentMapper.insertRobotOpsBroadcastContent(robotOpsBroadcastContent);
+        // 新增成功后,自动触发音频合成
+        if (rows > 0 && StringUtils.isNotBlank(robotOpsBroadcastContent.getBroadcastText()))
+        {
+            synthesizeAudioAsync(robotOpsBroadcastContent.getId());
+        }
+        return rows;
     }
 
     /**
      * 修改播报内容
-     * 
+     *
      * @param robotOpsBroadcastContent 播报内容
      * @return 结果
      */
     @Override
+    @Transactional(rollbackFor = Exception.class)
     public int updateRobotOpsBroadcastContent(RobotOpsBroadcastContent robotOpsBroadcastContent)
     {
+        // 获取修改前的数据,用于判断是否需要重新合成
+        RobotOpsBroadcastContent oldContent = selectRobotOpsBroadcastContentById(robotOpsBroadcastContent.getId());
+        boolean needResynthesize = false;
+
+        // 判断是否需要重新合成音频
+        if (oldContent != null && StringUtils.isNotBlank(robotOpsBroadcastContent.getBroadcastText()))
+        {
+            // 文本发生变化,或之前合成失败
+            if (!robotOpsBroadcastContent.getBroadcastText().equals(oldContent.getBroadcastText()))
+            {
+                needResynthesize = true;
+            }
+            else if (AUDIO_STATUS_FAILED.equals(oldContent.getAudioStatus()))
+            {
+                // 之前合成失败,需要重新合成
+                needResynthesize = true;
+            }
+        }
+
         robotOpsBroadcastContent.setUpdateTime(DateUtils.getNowDate());
-        return robotOpsBroadcastContentMapper.updateRobotOpsBroadcastContent(robotOpsBroadcastContent);
+        int rows = robotOpsBroadcastContentMapper.updateRobotOpsBroadcastContent(robotOpsBroadcastContent);
+
+        // 如果需要重新合成,且不在合成中
+        if (rows > 0 && needResynthesize && !AUDIO_STATUS_SYNTHESIZING.equals(oldContent.getAudioStatus()))
+        {
+            synthesizeAudioAsync(robotOpsBroadcastContent.getId());
+        }
+
+        return rows;
     }
 
     /**
      * 批量删除播报内容
-     * 
+     *
      * @param ids 需要删除的播报内容主键
      * @return 结果
      */
     @Override
+    @Transactional(rollbackFor = Exception.class)
     public int deleteRobotOpsBroadcastContentByIds(Long[] ids)
     {
+        if (ids != null && ids.length > 0)
+        {
+            for (Long id : ids)
+            {
+                // 删除前先删除本地音频文件
+                RobotOpsBroadcastContent content = selectRobotOpsBroadcastContentById(id);
+                if (content != null && StringUtils.isNotBlank(content.getAudioPath()))
+                {
+                    TtsUtil.deleteAudioFile(content.getAudioPath());
+                }
+            }
+        }
         return robotOpsBroadcastContentMapper.deleteRobotOpsBroadcastContentByIds(ids);
     }
 
     /**
      * 删除播报内容信息
-     * 
+     *
      * @param id 播报内容主键
      * @return 结果
      */
     @Override
+    @Transactional(rollbackFor = Exception.class)
     public int deleteRobotOpsBroadcastContentById(Long id)
     {
+        // 删除前先删除本地音频文件
+        RobotOpsBroadcastContent content = selectRobotOpsBroadcastContentById(id);
+        if (content != null && StringUtils.isNotBlank(content.getAudioPath()))
+        {
+            TtsUtil.deleteAudioFile(content.getAudioPath());
+        }
         return robotOpsBroadcastContentMapper.deleteRobotOpsBroadcastContentById(id);
     }
+
+    /**
+     * 检查是否正在合成中
+     *
+     * @param id 播报内容主键
+     * @return true=正在合成中,false=未在合成
+     */
+    @Override
+    public boolean isSynthesizing(Long id)
+    {
+        RobotOpsBroadcastContent content = selectRobotOpsBroadcastContentById(id);
+        return content != null && AUDIO_STATUS_SYNTHESIZING.equals(content.getAudioStatus());
+    }
+
+    /**
+     * 合成音频(异步)
+     * 调用阿里云百炼 TTS 合成语音,下载返回的音频 URL,保存路径到 audio_path
+     *
+     * @param id 播报内容主键
+     */
+    @Override
+    @Async("ttsTaskExecutor")
+    public void synthesizeAudio(Long id)
+    {
+        synthesizeAudioAsync(id);
+    }
+
+    /**
+     * 异步合成音频的内部实现
+     *
+     * @param id 播报内容主键
+     */
+    private void synthesizeAudioAsync(Long id)
+    {
+        log.info("[BroadcastContent] 开始合成音频,id: {}", id);
+
+        try
+        {
+            // 1. 获取播报内容
+            RobotOpsBroadcastContent content = selectRobotOpsBroadcastContentById(id);
+            if (content == null)
+            {
+                log.error("[BroadcastContent] 播报内容不存在,id: {}", id);
+                return;
+            }
+
+            // 2. 检查文本是否为空
+            if (StringUtils.isBlank(content.getBroadcastText()))
+            {
+                log.warn("[BroadcastContent] 播报文本为空,id: {}", id);
+                updateAudioStatus(id, AUDIO_STATUS_FAILED, null, "播报文本为空");
+                return;
+            }
+
+            // 3. 更新状态为合成中
+            updateAudioStatus(id, AUDIO_STATUS_SYNTHESIZING, null, null);
+
+            // 4. 调用 TTS 合成
+            TtsUtil.SynthesisResult result = TtsUtil.synthesize(content.getBroadcastText(), DEFAULT_VOICE, LANGUAGE_TYPE);
+
+            // 5. 根据结果更新状态
+            if (result.isSuccess())
+            {
+                // 合成成功
+                updateAudioStatusWithDuration(id, AUDIO_STATUS_SUCCESS, result.getAudioPath(), result.getAudioDuration(), null);
+                log.info("[BroadcastContent] 音频合成成功,id: {},路径: {},时长: {}秒", id, result.getAudioPath(), result.getAudioDuration());
+            }
+            else
+            {
+                // 合成失败
+                String errorMsg = StringUtils.isNotBlank(result.getErrorMessage())
+                        ? result.getErrorMessage()
+                        : "TTS 合成失败,请检查阿里云百炼配置和网络连接";
+                updateAudioStatus(id, AUDIO_STATUS_FAILED, null, errorMsg);
+                log.error("[BroadcastContent] 音频合成失败,id: {},错误: {}", id, result.getErrorMessage());
+            }
+        }
+        catch (Exception e)
+        {
+            log.error("[BroadcastContent] 音频合成异常,id: {},错误: {}", id, e.getMessage(), e);
+            updateAudioStatus(id, AUDIO_STATUS_FAILED, null, e.getMessage());
+        }
+    }
+
+    /**
+     * 重新合成音频(异步)
+     * 先删除旧的本地音频文件,再合成新的
+     *
+     * @param id 播报内容主键
+     */
+    @Override
+    @Async("ttsTaskExecutor")
+    public void regenerateAudio(Long id)
+    {
+        log.info("[BroadcastContent] 开始重新合成音频,id: {}", id);
+
+        try
+        {
+            // 1. 获取播报内容
+            RobotOpsBroadcastContent content = selectRobotOpsBroadcastContentById(id);
+            if (content == null)
+            {
+                log.error("[BroadcastContent] 播报内容不存在,id: {}", id);
+                return;
+            }
+
+            // 2. 删除旧的本地音频文件
+            if (StringUtils.isNotBlank(content.getAudioPath()))
+            {
+                TtsUtil.deleteAudioFile(content.getAudioPath());
+            }
+
+            // 3. 调用合成(使用 synthesizeAudioAsync 的逻辑,但不需要再检查旧文件)
+            synthesizeAudioAsync(id);
+        }
+        catch (Exception e)
+        {
+            log.error("[BroadcastContent] 重新合成音频异常,id: {},错误: {}", id, e.getMessage(), e);
+            updateAudioStatus(id, AUDIO_STATUS_FAILED, null, e.getMessage());
+        }
+    }
+
+    /**
+     * 更新音频状态
+     *
+     * @param id          播报内容主键
+     * @param status      状态:0=未合成,1=成功,2=失败,3=合成中
+     * @param audioPath   音频路径(成功时传入)
+     * @param errorMsg    错误信息(失败时传入)
+     */
+    private void updateAudioStatus(Long id, String status, String audioPath, String errorMsg)
+    {
+        updateAudioStatusWithDuration(id, status, audioPath, null, errorMsg);
+    }
+
+    /**
+     * 更新音频状态(包含时长)
+     *
+     * @param id          播报内容主键
+     * @param status      状态:0=未合成,1=成功,2=失败,3=合成中
+     * @param audioPath   音频路径(成功时传入)
+     * @param duration    音频时长(秒)
+     * @param errorMsg    错误信息(失败时传入)
+     */
+    private void updateAudioStatusWithDuration(Long id, String status, String audioPath, Integer duration, String errorMsg)
+    {
+        try
+        {
+            RobotOpsBroadcastContent update = new RobotOpsBroadcastContent();
+            update.setId(id);
+            update.setAudioStatus(status);
+            update.setUpdateTime(DateUtils.getNowDate());
+
+            if (StringUtils.isNotBlank(audioPath))
+            {
+                update.setAudioPath(audioPath);
+            }
+
+            if (duration != null)
+            {
+                update.setAudioDuration(duration);
+            }
+
+            if (StringUtils.isNotBlank(errorMsg))
+            {
+                update.setAudioErrorMsg(errorMsg);
+            }
+
+            // 成功时清空错误信息
+            if (AUDIO_STATUS_SUCCESS.equals(status))
+            {
+                update.setAudioErrorMsg(null);
+            }
+
+            robotOpsBroadcastContentMapper.updateRobotOpsBroadcastContent(update);
+        }
+        catch (Exception e)
+        {
+            log.error("[BroadcastContent] 更新音频状态失败,id: {},错误: {}", id, e.getMessage(), e);
+        }
+    }
 }

+ 364 - 8
ruoyi-system/src/main/java/com/ruoyi/base/service/impl/RobotOpsBroadcastTaskServiceImpl.java

@@ -1,28 +1,66 @@
 package com.ruoyi.base.service.impl;
 
+import java.time.DayOfWeek;
+import java.time.LocalDate;
+import java.time.LocalTime;
+import java.time.format.DateTimeFormatter;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 import com.ruoyi.common.utils.DateUtils;
+import com.ruoyi.common.event.BroadcastStartedEvent;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.ApplicationEventPublisher;
 import org.springframework.stereotype.Service;
 import com.ruoyi.base.mapper.RobotOpsBroadcastTaskMapper;
 import com.ruoyi.base.domain.RobotOpsBroadcastTask;
+import com.ruoyi.base.domain.RobotOpsBroadcastContent;
+import com.ruoyi.base.service.IBroadcastRecordService;
+import com.ruoyi.base.service.IRobotOpsBroadcastContentService;
 import com.ruoyi.base.service.IRobotOpsBroadcastTaskService;
 
 /**
  * 播报任务Service业务层处理
- * 
+ *
  * @author ruoyi
  * @date 2026-04-27
  */
 @Service
-public class RobotOpsBroadcastTaskServiceImpl implements IRobotOpsBroadcastTaskService 
+public class RobotOpsBroadcastTaskServiceImpl implements IRobotOpsBroadcastTaskService
 {
+    private static final Logger log = LoggerFactory.getLogger(RobotOpsBroadcastTaskServiceImpl.class);
+
+    @Autowired
+    private IBroadcastRecordService broadcastRecordService;
+
+    @Autowired
+    private IRobotOpsBroadcastContentService broadcastContentService;
+
+    /** 事件发布器 */
+    @Autowired
+    private ApplicationEventPublisher eventPublisher;
+
+    /** 资源访问基础URL,用于补全音频路径 */
+    @Value("${screen.resource-base-url:}")
+    private String resourceBaseUrl;
+
+    /** 启用状态:0=停用,1=启用 */
+    private static final String STATUS_ENABLED = "1";
+
+    /** 循环类型:1=每天,2=工作日,3=指定日期 */
+    private static final Long CYCLE_TYPE_DAILY = 1L;
+    private static final Long CYCLE_TYPE_WEEKDAY = 2L;
+    private static final Long CYCLE_TYPE_SPECIFIC = 3L;
+
     @Autowired
     private RobotOpsBroadcastTaskMapper robotOpsBroadcastTaskMapper;
 
     /**
      * 查询播报任务
-     * 
+     *
      * @param id 播报任务主键
      * @return 播报任务
      */
@@ -34,7 +72,7 @@ public class RobotOpsBroadcastTaskServiceImpl implements IRobotOpsBroadcastTaskS
 
     /**
      * 查询播报任务列表
-     * 
+     *
      * @param robotOpsBroadcastTask 播报任务
      * @return 播报任务
      */
@@ -46,7 +84,7 @@ public class RobotOpsBroadcastTaskServiceImpl implements IRobotOpsBroadcastTaskS
 
     /**
      * 新增播报任务
-     * 
+     *
      * @param robotOpsBroadcastTask 播报任务
      * @return 结果
      */
@@ -59,7 +97,7 @@ public class RobotOpsBroadcastTaskServiceImpl implements IRobotOpsBroadcastTaskS
 
     /**
      * 修改播报任务
-     * 
+     *
      * @param robotOpsBroadcastTask 播报任务
      * @return 结果
      */
@@ -72,7 +110,7 @@ public class RobotOpsBroadcastTaskServiceImpl implements IRobotOpsBroadcastTaskS
 
     /**
      * 批量删除播报任务
-     * 
+     *
      * @param ids 需要删除的播报任务主键
      * @return 结果
      */
@@ -84,7 +122,7 @@ public class RobotOpsBroadcastTaskServiceImpl implements IRobotOpsBroadcastTaskS
 
     /**
      * 删除播报任务信息
-     * 
+     *
      * @param id 播报任务主键
      * @return 结果
      */
@@ -93,4 +131,322 @@ public class RobotOpsBroadcastTaskServiceImpl implements IRobotOpsBroadcastTaskS
     {
         return robotOpsBroadcastTaskMapper.deleteRobotOpsBroadcastTaskById(id);
     }
+
+    /**
+     * 获取当前播报任务(根据时间和循环类型判断)
+     */
+    @Override
+    public RobotOpsBroadcastTask selectCurrentBroadcastTask()
+    {
+        try
+        {
+            // 获取所有启用的任务
+            RobotOpsBroadcastTask query = new RobotOpsBroadcastTask();
+            query.setStatus(STATUS_ENABLED);
+            List<RobotOpsBroadcastTask> enabledTasks = robotOpsBroadcastTaskMapper.selectRobotOpsBroadcastTaskList(query);
+
+            if (enabledTasks == null || enabledTasks.isEmpty())
+            {
+                return null;
+            }
+
+            LocalTime now = LocalTime.now();
+            LocalDate today = LocalDate.now();
+
+            for (RobotOpsBroadcastTask task : enabledTasks)
+            {
+                // 检查时间范围
+                if (!isWithinTimeRange(task, now))
+                {
+                    continue;
+                }
+
+                // 检查循环类型
+                if (!isCycleMatch(task, today))
+                {
+                    continue;
+                }
+
+                // 检查播报频率(根据上次播报时间判断是否应该再次播报)
+                if (!broadcastRecordService.shouldBroadcast(task.getId(), Math.toIntExact(task.getFrequencyMinutes())))
+                {
+                    log.debug("[BroadcastTask] 播报任务频率未到,ID: {}, 名称: {}, 频率: {}分钟",
+                            task.getId(), task.getTaskName(), task.getFrequencyMinutes());
+                    continue;
+                }
+
+                log.info("[BroadcastTask] 命中播报任务,ID: {}, 名称: {}", task.getId(), task.getTaskName());
+
+                // 发布播报开始事件
+                publishBroadcastStartedEvent(task);
+
+                return task;
+            }
+
+            return null;
+        }
+        catch (Exception e)
+        {
+            log.error("[BroadcastTask] 获取当前播报任务异常: {}", e.getMessage(), e);
+            return null;
+        }
+    }
+
+    /**
+     * 发布播报开始事件
+     */
+    private void publishBroadcastStartedEvent(RobotOpsBroadcastTask task)
+    {
+        if (eventPublisher == null || task == null || task.getContentId() == null)
+        {
+            return;
+        }
+        try
+        {
+            // 获取播报内容
+            RobotOpsBroadcastContent content = broadcastContentService.selectRobotOpsBroadcastContentById(task.getContentId());
+            if (content == null)
+            {
+                log.warn("[BroadcastTask] 播报内容不存在,contentId: {}", task.getContentId());
+                return;
+            }
+
+            // 检查内容是否启用
+            if (!"1".equals(content.getStatus()))
+            {
+                log.info("[BroadcastTask] 播报内容未启用,contentId: {}", content.getId());
+                return;
+            }
+
+            // 检查音频是否合成成功
+            if (!"1".equals(content.getAudioStatus()) || content.getAudioPath() == null || content.getAudioPath().isEmpty())
+            {
+                log.info("[BroadcastTask] 播报内容音频未就绪,contentId: {}, audioStatus: {}",
+                        content.getId(), content.getAudioStatus());
+                return;
+            }
+
+            // 补全音频 URL
+            String audioUrl = completeAudioUrl(content.getAudioPath());
+
+            // 转换内容类型
+            String contentType = convertContentType(content.getContentType());
+
+            // 发布播报开始事件
+            BroadcastStartedEvent event = new BroadcastStartedEvent(
+                    this,
+                    task.getId(),
+                    content.getId(),
+                    task.getTaskName(),
+                    content.getContentName(),
+                    content.getBroadcastText(),
+                    contentType,
+                    audioUrl,
+                    content.getAudioDuration()
+            );
+            eventPublisher.publishEvent(event);
+
+            // 记录下次可播报时间 = 当前时间 + 频率
+            broadcastRecordService.recordBroadcast(task.getId(), task.getFrequencyMinutes().intValue());
+
+            log.info("[BroadcastTask] 发布播报开始事件,taskId: {}, 频率: {}分钟", task.getId(), task.getFrequencyMinutes());
+        }
+        catch (Exception e)
+        {
+            log.error("[BroadcastTask] 发布播报开始事件失败: {}", e.getMessage(), e);
+        }
+    }
+
+    /**
+     * 补全音频 URL
+     */
+    private String completeAudioUrl(String audioPath)
+    {
+        if (audioPath == null || audioPath.isEmpty())
+        {
+            return "";
+        }
+        // 如果已经是完整 URL(包含 http 或 /profile)
+        if (audioPath.startsWith("http://") || audioPath.startsWith("https://"))
+        {
+            return audioPath;
+        }
+        if (audioPath.startsWith("/profile/"))
+        {
+            return audioPath;
+        }
+        // 如果配置了资源基础 URL
+        if (resourceBaseUrl != null && !resourceBaseUrl.isEmpty())
+        {
+            if (audioPath.startsWith("/"))
+            {
+                return resourceBaseUrl + audioPath;
+            }
+            else
+            {
+                return resourceBaseUrl + "/" + audioPath;
+            }
+        }
+        // 默认拼接 /profile/
+        if (audioPath.startsWith("/"))
+        {
+            return "/profile" + audioPath;
+        }
+        else
+        {
+            return "/profile/" + audioPath;
+        }
+    }
+
+    /**
+     * 转换内容类型
+     */
+    private String convertContentType(Long contentType)
+    {
+        if (contentType == null)
+        {
+            return "custom";
+        }
+        switch (contentType.intValue())
+        {
+            case 1:
+                return "notice";
+            case 2:
+                return "promotion";
+            case 3:
+                return "tip";
+            case 4:
+                return "security";
+            default:
+                return "custom";
+        }
+    }
+
+    /**
+     * 获取当前播报任务及其关联的播报内容
+     */
+    @Override
+    public Map<String, Object> selectCurrentBroadcastTaskWithContent()
+    {
+        try
+        {
+            RobotOpsBroadcastTask task = selectCurrentBroadcastTask();
+            if (task == null)
+            {
+                return null;
+            }
+
+            Map<String, Object> result = new HashMap<>();
+            result.put("taskId", task.getId());
+            result.put("taskName", task.getTaskName());
+            result.put("contentId", task.getContentId());
+            result.put("startTime", task.getStartTime());
+            result.put("endTime", task.getEndTime());
+
+            return result;
+        }
+        catch (Exception e)
+        {
+            log.error("[BroadcastTask] 获取当前播报任务及内容异常: {}", e.getMessage(), e);
+            return null;
+        }
+    }
+
+    /**
+     * 检查当前时间是否在任务时间范围内
+     */
+    private boolean isWithinTimeRange(RobotOpsBroadcastTask task, LocalTime now)
+    {
+        DateTimeFormatter formatter = DateTimeFormatter.ofPattern("HH:mm:ss");
+
+        // 检查开始时间
+        if (task.getStartTime() != null && !task.getStartTime().isEmpty())
+        {
+            try
+            {
+                LocalTime startTime = LocalTime.parse(task.getStartTime(), formatter);
+                if (now.isBefore(startTime))
+                {
+                    return false;
+                }
+            }
+            catch (Exception e)
+            {
+                log.warn("[BroadcastTask] 解析开始时间失败: {}", task.getStartTime());
+            }
+        }
+
+        // 检查结束时间
+        if (task.getEndTime() != null && !task.getEndTime().isEmpty())
+        {
+            try
+            {
+                LocalTime endTime = LocalTime.parse(task.getEndTime(), formatter);
+                if (now.isAfter(endTime))
+                {
+                    return false;
+                }
+            }
+            catch (Exception e)
+            {
+                log.warn("[BroadcastTask] 解析结束时间失败: {}", task.getEndTime());
+            }
+        }
+
+        return true;
+    }
+
+    /**
+     * 检查当前日期是否匹配循环规则
+     */
+    private boolean isCycleMatch(RobotOpsBroadcastTask task, LocalDate today)
+    {
+        if (task.getCycleType() == null)
+        {
+            // 没有设置循环类型,默认每天
+            return true;
+        }
+
+        if (task.getCycleType() == CYCLE_TYPE_DAILY) {// 每天
+            return true;
+        } else if (task.getCycleType() == CYCLE_TYPE_WEEKDAY) {// 工作日(周一到周五)
+            DayOfWeek dayOfWeek = today.getDayOfWeek();
+            return dayOfWeek != DayOfWeek.SATURDAY && dayOfWeek != DayOfWeek.SUNDAY;
+        } else if (task.getCycleType() == CYCLE_TYPE_SPECIFIC) {// 指定日期,cycleValue 格式为 "1,2,3,4,5,6,7" 表示周一到周日
+            return isSpecificDayMatch(task.getCycleValue(), today);
+        }
+        return true;
+    }
+
+    /**
+     * 检查指定日期是否匹配
+     */
+    private boolean isSpecificDayMatch(String cycleValue, LocalDate today)
+    {
+        if (cycleValue == null || cycleValue.trim().isEmpty())
+        {
+            return true;
+        }
+
+        try
+        {
+            DayOfWeek dayOfWeek = today.getDayOfWeek();
+            int dayNum = dayOfWeek.getValue(); // 1=周一, 7=周日
+
+            String[] days = cycleValue.split(",");
+            for (String day : days)
+            {
+                if (day.trim().equals(String.valueOf(dayNum)))
+                {
+                    return true;
+                }
+            }
+            return false;
+        }
+        catch (Exception e)
+        {
+            log.warn("[BroadcastTask] 解析指定日期失败: {}, 错误: {}", cycleValue, e.getMessage());
+            return true; // 解析失败时默认匹配
+        }
+    }
 }

+ 163 - 5
ruoyi-system/src/main/java/com/ruoyi/base/service/impl/RobotOpsPlayPlanServiceImpl.java

@@ -1,10 +1,19 @@
 package com.ruoyi.base.service.impl;
 
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
 import com.ruoyi.common.utils.DateUtils;
+import com.ruoyi.common.event.PlayPlanChangedEvent;
+import com.ruoyi.common.event.PlayPlanDisabledEvent;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.ApplicationEventPublisher;
 import org.springframework.stereotype.Service;
-import java.util.ArrayList;
 import com.ruoyi.common.utils.StringUtils;
 import org.springframework.transaction.annotation.Transactional;
 import com.ruoyi.base.domain.RobotOpsPlayPlanItem;
@@ -24,6 +33,12 @@ public class RobotOpsPlayPlanServiceImpl implements IRobotOpsPlayPlanService
     @Autowired
     private RobotOpsPlayPlanMapper robotOpsPlayPlanMapper;
 
+    /** 事件发布器 */
+    @Autowired
+    private ApplicationEventPublisher eventPublisher;
+
+    private static final Logger log = LoggerFactory.getLogger(RobotOpsPlayPlanServiceImpl.class);
+
     /**
      * 查询播放方案
      *
@@ -59,16 +74,30 @@ public class RobotOpsPlayPlanServiceImpl implements IRobotOpsPlayPlanService
     public int insertRobotOpsPlayPlan(RobotOpsPlayPlan robotOpsPlayPlan)
     {
         robotOpsPlayPlan.setCreateTime(DateUtils.getNowDate());
+        Long previousPlanId = null;
         if ("1".equals(robotOpsPlayPlan.getStatus()))
         {
             RobotOpsPlayPlan currentPlayingPlan = robotOpsPlayPlanMapper.selectCurrentPlayingPlan();
             if (currentPlayingPlan != null)
             {
+                previousPlanId = currentPlayingPlan.getId();
                 robotOpsPlayPlanMapper.updatePlayPlanStatus(currentPlayingPlan.getId(), "0");
             }
         }
         int rows = robotOpsPlayPlanMapper.insertRobotOpsPlayPlan(robotOpsPlayPlan);
         insertRobotOpsPlayPlanItem(robotOpsPlayPlan);
+
+        // 如果是新方案启用为当前播放,发布播放方案变化事件
+        if ("1".equals(robotOpsPlayPlan.getStatus()) && eventPublisher != null)
+        {
+            // 如果之前有播放中的方案被停用,先发布禁用事件
+            if (previousPlanId != null && !previousPlanId.equals(robotOpsPlayPlan.getId()))
+            {
+                eventPublisher.publishEvent(new PlayPlanDisabledEvent(this, previousPlanId));
+            }
+            // 发布新方案变化事件
+            publishPlayPlanChangedEvent();
+        }
         return rows;
     }
 
@@ -84,18 +113,52 @@ public class RobotOpsPlayPlanServiceImpl implements IRobotOpsPlayPlanService
     {
         robotOpsPlayPlan.setUpdateTime(DateUtils.getNowDate());
 
+        Long previousPlanId = null;
+        boolean wasEnabled = false;
+        // 检查当前方案是否正在播放
+        RobotOpsPlayPlan currentPlayingPlan = robotOpsPlayPlanMapper.selectCurrentPlayingPlan();
+        boolean isCurrentPlayingPlan = currentPlayingPlan != null
+            && currentPlayingPlan.getId().equals(robotOpsPlayPlan.getId());
+
         if ("1".equals(robotOpsPlayPlan.getStatus()))
         {
-            RobotOpsPlayPlan currentPlayingPlan = robotOpsPlayPlanMapper.selectCurrentPlayingPlan();
             if (currentPlayingPlan != null && !currentPlayingPlan.getId().equals(robotOpsPlayPlan.getId()))
             {
+                previousPlanId = currentPlayingPlan.getId();
                 robotOpsPlayPlanMapper.updatePlayPlanStatus(currentPlayingPlan.getId(), "0");
+                wasEnabled = true;
             }
         }
+        else if (isCurrentPlayingPlan && currentPlayingPlan != null && "1".equals(currentPlayingPlan.getStatus()))
+        {
+            // 当前正在播放的方案被停用
+            wasEnabled = true;
+        }
 
         robotOpsPlayPlanMapper.deleteRobotOpsPlayPlanItemByPlanId(robotOpsPlayPlan.getId());
         insertRobotOpsPlayPlanItem(robotOpsPlayPlan);
-        return robotOpsPlayPlanMapper.updateRobotOpsPlayPlan(robotOpsPlayPlan);
+        int result = robotOpsPlayPlanMapper.updateRobotOpsPlayPlan(robotOpsPlayPlan);
+
+        // 如果状态变为启用,发布播放方案变化事件
+        if (eventPublisher != null)
+        {
+            if ("1".equals(robotOpsPlayPlan.getStatus()))
+            {
+                // 如果之前有播放中的方案被停用,先发布禁用事件
+                if (previousPlanId != null)
+                {
+                    eventPublisher.publishEvent(new PlayPlanDisabledEvent(this, previousPlanId));
+                }
+                // 发布新方案变化事件
+                publishPlayPlanChangedEvent();
+            }
+            else if (wasEnabled)
+            {
+                // 方案从启用变为停用,发布禁用事件
+                eventPublisher.publishEvent(new PlayPlanDisabledEvent(this, robotOpsPlayPlan.getId()));
+            }
+        }
+        return result;
     }
 
 
@@ -105,10 +168,28 @@ public class RobotOpsPlayPlanServiceImpl implements IRobotOpsPlayPlanService
      * @param ids 需要删除的播放方案主键
      * @return 结果
      */
-    @Transactional
+    @Transactional(rollbackFor = Exception.class)
     @Override
     public int deleteRobotOpsPlayPlanByIds(Long[] ids)
     {
+        if (ids == null || ids.length == 0) {
+            return 0;
+        }
+
+        // 检查是否有正在播放的方案被删除
+        if (eventPublisher != null) {
+            RobotOpsPlayPlan currentPlan = robotOpsPlayPlanMapper.selectCurrentPlayingPlan();
+            if (currentPlan != null) {
+                for (Long id : ids) {
+                    if (id.equals(currentPlan.getId())) {
+                        // 当前播放中的方案被删除,发布禁用事件
+                        eventPublisher.publishEvent(new PlayPlanDisabledEvent(this, id));
+                        break;
+                    }
+                }
+            }
+        }
+
         robotOpsPlayPlanMapper.deleteRobotOpsPlayPlanItemByPlanIds(ids);
         return robotOpsPlayPlanMapper.deleteRobotOpsPlayPlanByIds(ids);
     }
@@ -119,10 +200,19 @@ public class RobotOpsPlayPlanServiceImpl implements IRobotOpsPlayPlanService
      * @param id 播放方案主键
      * @return 结果
      */
-    @Transactional
+    @Transactional(rollbackFor = Exception.class)
     @Override
     public int deleteRobotOpsPlayPlanById(Long id)
     {
+        // 检查被删除的方案是否是当前播放中的方案
+        if (eventPublisher != null) {
+            RobotOpsPlayPlan currentPlan = robotOpsPlayPlanMapper.selectCurrentPlayingPlan();
+            if (currentPlan != null && id.equals(currentPlan.getId())) {
+                // 当前播放中的方案被删除,发布禁用事件
+                eventPublisher.publishEvent(new PlayPlanDisabledEvent(this, id));
+            }
+        }
+
         robotOpsPlayPlanMapper.deleteRobotOpsPlayPlanItemByPlanId(id);
         return robotOpsPlayPlanMapper.deleteRobotOpsPlayPlanById(id);
     }
@@ -166,4 +256,72 @@ public class RobotOpsPlayPlanServiceImpl implements IRobotOpsPlayPlanService
             }
         }
     }
+
+    /**
+     * 发布播放方案变化事件
+     */
+    private void publishPlayPlanChangedEvent()
+    {
+        if (eventPublisher == null)
+        {
+            return;
+        }
+        try
+        {
+            RobotOpsPlayPlan plan = selectCurrentPlayingPlanWithItems();
+            if (plan == null)
+            {
+                return;
+            }
+
+            Map<String, Object> result = new HashMap<>();
+            result.put("enabled", true);
+            result.put("planId", plan.getId());
+            result.put("planName", plan.getPlanName());
+            result.put("playMode", plan.getLoopMode() != null ? plan.getLoopMode() : "loop");
+            result.put("defaultFitMode", "cover");
+
+            // 使用更新时间生成版本号
+            String version = "";
+            if (plan.getUpdateTime() != null)
+            {
+                SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMddHHmmss");
+                version = sdf.format(plan.getUpdateTime());
+            }
+            result.put("version", version);
+
+            // 转换素材项
+            List<RobotOpsPlayPlanItem> items = plan.getRobotOpsPlayPlanItemList();
+            List<Map<String, Object>> convertedItems = new ArrayList<>();
+            if (items != null && !items.isEmpty())
+            {
+                items.stream()
+                        .sorted((a, b) -> {
+                            Long orderA = a.getPlayOrder() != null ? a.getPlayOrder() : 999L;
+                            Long orderB = b.getPlayOrder() != null ? b.getPlayOrder() : 999L;
+                            return orderA.compareTo(orderB);
+                        })
+                        .forEach(item -> {
+                            Map<String, Object> itemMap = new HashMap<>();
+                            itemMap.put("id", item.getId() != null ? String.valueOf(item.getId()) : "");
+                            itemMap.put("type", item.getAssetType() != null ? item.getAssetType() : "image");
+                            itemMap.put("title", item.getAssetName() != null ? item.getAssetName() : "");
+                            itemMap.put("subtitle", "");
+                            itemMap.put("url", item.getFileUrl() != null ? item.getFileUrl() : "");
+                            itemMap.put("duration", item.getStaySeconds() != null ? item.getStaySeconds() * 1000 : 8000);
+                            itemMap.put("fitMode", "cover");
+                            itemMap.put("showTitle", false);
+                            convertedItems.add(itemMap);
+                        });
+            }
+            result.put("items", convertedItems);
+
+            eventPublisher.publishEvent(new PlayPlanChangedEvent(this, result));
+            log.info("[PlayPlan] 发布播放方案变化事件: {}", plan.getPlanName());
+        }
+        catch (Exception e)
+        {
+            log.error("[PlayPlan] 发布播放方案变化事件失败: {}", e.getMessage(), e);
+        }
+    }
 }

+ 55 - 0
ruoyi-system/src/main/java/com/ruoyi/base/task/BroadcastScheduler.java

@@ -0,0 +1,55 @@
+package com.ruoyi.base.task;
+
+import com.ruoyi.base.service.IRobotOpsBroadcastTaskService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.PostConstruct;
+import javax.annotation.PreDestroy;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 播报任务调度器
+ * 定时检查并触发播报任务
+ */
+@Component
+public class BroadcastScheduler {
+
+    private static final Logger log = LoggerFactory.getLogger(BroadcastScheduler.class);
+
+    private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
+
+    @Autowired
+    private IRobotOpsBroadcastTaskService robotOpsBroadcastTaskService;
+
+    @PostConstruct
+    public void init() {
+        scheduler.scheduleAtFixedRate(() -> {
+            try {
+                checkAndTriggerBroadcast();
+            } catch (Exception e) {
+                log.error("[BroadcastScheduler] 检查播报任务异常", e);
+            }
+        }, 0, 10, TimeUnit.SECONDS);
+
+        log.info("[BroadcastScheduler] 播报任务调度器已启动");
+    }
+
+    private void checkAndTriggerBroadcast() {
+        try {
+            robotOpsBroadcastTaskService.selectCurrentBroadcastTask();
+        } catch (Exception e) {
+            log.error("[BroadcastScheduler] 触发播报任务异常", e);
+        }
+    }
+
+    @PreDestroy
+    public void destroy() {
+        scheduler.shutdown();
+        log.info("[BroadcastScheduler] 播报任务调度器已停止");
+    }
+}

+ 38 - 6
ruoyi-system/src/main/resources/mapper/base/RobotOpsBroadcastContentMapper.xml

@@ -3,7 +3,7 @@
 PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
 "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
 <mapper namespace="com.ruoyi.base.mapper.RobotOpsBroadcastContentMapper">
-    
+
     <resultMap type="RobotOpsBroadcastContent" id="RobotOpsBroadcastContentResult">
         <result property="id"    column="id"    />
         <result property="contentName"    column="content_name"    />
@@ -11,24 +11,35 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         <result property="broadcastText"    column="broadcast_text"    />
         <result property="status"    column="status"    />
         <result property="remark"    column="remark"    />
+        <result property="audioPath"    column="audio_path"    />
+        <result property="audioStatus"    column="audio_status"    />
+        <result property="audioTaskId"    column="audio_task_id"    />
+        <result property="audioDuration"    column="audio_duration"    />
+        <result property="audioErrorMsg"    column="audio_error_msg"    />
         <result property="createTime"    column="create_time"    />
         <result property="updateTime"    column="update_time"    />
+        <result property="createBy"    column="create_by"    />
+        <result property="updateBy"    column="update_by"    />
     </resultMap>
 
     <sql id="selectRobotOpsBroadcastContentVo">
-        select id, content_name, content_type, broadcast_text, status, remark, create_time, update_time from robot_ops_broadcast_content
+        select id, content_name, content_type, broadcast_text, status, remark,
+               audio_path, audio_status, audio_task_id, audio_duration, audio_error_msg,
+               create_time, update_time, create_by, update_by
+        from robot_ops_broadcast_content
     </sql>
 
     <select id="selectRobotOpsBroadcastContentList" parameterType="RobotOpsBroadcastContent" resultMap="RobotOpsBroadcastContentResult">
         <include refid="selectRobotOpsBroadcastContentVo"/>
-        <where>  
+        <where>
             <if test="contentName != null  and contentName != ''"> and content_name like concat('%', #{contentName}, '%')</if>
             <if test="contentType != null "> and content_type = #{contentType}</if>
             <if test="broadcastText != null  and broadcastText != ''"> and broadcast_text = #{broadcastText}</if>
             <if test="status != null  and status != ''"> and status = #{status}</if>
+            <if test="audioStatus != null and audioStatus != ''"> and audio_status = #{audioStatus}</if>
         </where>
     </select>
-    
+
     <select id="selectRobotOpsBroadcastContentById" parameterType="Long" resultMap="RobotOpsBroadcastContentResult">
         <include refid="selectRobotOpsBroadcastContentVo"/>
         where id = #{id}
@@ -42,8 +53,15 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="broadcastText != null and broadcastText != ''">broadcast_text,</if>
             <if test="status != null and status != ''">status,</if>
             <if test="remark != null">remark,</if>
+            <if test="audioPath != null">audio_path,</if>
+            <if test="audioStatus != null">audio_status,</if>
+            <if test="audioTaskId != null">audio_task_id,</if>
+            <if test="audioDuration != null">audio_duration,</if>
+            <if test="audioErrorMsg != null">audio_error_msg,</if>
             <if test="createTime != null">create_time,</if>
             <if test="updateTime != null">update_time,</if>
+            <if test="createBy != null and createBy != ''">create_by,</if>
+            <if test="updateBy != null">update_by,</if>
          </trim>
         <trim prefix="values (" suffix=")" suffixOverrides=",">
             <if test="contentName != null and contentName != ''">#{contentName},</if>
@@ -51,8 +69,15 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="broadcastText != null and broadcastText != ''">#{broadcastText},</if>
             <if test="status != null and status != ''">#{status},</if>
             <if test="remark != null">#{remark},</if>
+            <if test="audioPath != null">#{audioPath},</if>
+            <if test="audioStatus != null">#{audioStatus},</if>
+            <if test="audioTaskId != null">#{audioTaskId},</if>
+            <if test="audioDuration != null">#{audioDuration},</if>
+            <if test="audioErrorMsg != null">#{audioErrorMsg},</if>
             <if test="createTime != null">#{createTime},</if>
             <if test="updateTime != null">#{updateTime},</if>
+            <if test="createBy != null and createBy != ''">#{createBy},</if>
+            <if test="updateBy != null">#{updateBy},</if>
          </trim>
     </insert>
 
@@ -64,8 +89,15 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="broadcastText != null and broadcastText != ''">broadcast_text = #{broadcastText},</if>
             <if test="status != null and status != ''">status = #{status},</if>
             <if test="remark != null">remark = #{remark},</if>
+            <if test="audioPath != null">audio_path = #{audioPath},</if>
+            <if test="audioStatus != null">audio_status = #{audioStatus},</if>
+            <if test="audioTaskId != null">audio_task_id = #{audioTaskId},</if>
+            <if test="audioDuration != null">audio_duration = #{audioDuration},</if>
+            <if test="audioErrorMsg != null">audio_error_msg = #{audioErrorMsg},</if>
             <if test="createTime != null">create_time = #{createTime},</if>
             <if test="updateTime != null">update_time = #{updateTime},</if>
+            <if test="createBy != null and createBy != ''">create_by = #{createBy},</if>
+            <if test="updateBy != null">update_by = #{updateBy},</if>
         </trim>
         where id = #{id}
     </update>
@@ -75,9 +107,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
     </delete>
 
     <delete id="deleteRobotOpsBroadcastContentByIds" parameterType="String">
-        delete from robot_ops_broadcast_content where id in 
+        delete from robot_ops_broadcast_content where id in
         <foreach item="id" collection="array" open="(" separator="," close=")">
             #{id}
         </foreach>
     </delete>
-</mapper>
+</mapper>

+ 30 - 1
sql/ry_20260417.sql

@@ -720,4 +720,33 @@ create table gen_table_column (
   update_by         varchar(64)     default ''                 comment '更新者',
   update_time       datetime                                   comment '更新时间',
   primary key (column_id)
-) engine=innodb auto_increment=1 comment = '代码生成业务表字段';
+) engine=innodb auto_increment=1 comment = '代码生成业务表字段';
+
+
+-- ----------------------------
+-- 21、屏幕主题配置表
+-- ----------------------------
+drop table if exists robot_ops_screen_theme_config;
+create table robot_ops_screen_theme_config (
+  id                bigint(20)      not null auto_increment    comment '主键ID',
+  config_key        varchar(50)     default 'default'         comment '配置标识,默认配置固定为default',
+  logo_url         varchar(500)    default null               comment 'Logo图片地址',
+  robot_name       varchar(100)    default null               comment '机器人名称',
+  brand_subtitle   varchar(200)    default null               comment '品牌副标题',
+  background_image varchar(500)    default null               comment '待机页背景图地址',
+  welcome_title    varchar(200)    default null               comment '欢迎主标题',
+  welcome_subtitle varchar(500)    default null               comment '欢迎副标题',
+  touch_text       varchar(100)    default null               comment '主按钮文案',
+  button_color     varchar(20)     default null               comment '主按钮颜色',
+  remark           varchar(500)    default null               comment '备注',
+  create_by        varchar(64)     default ''                 comment '创建者',
+  create_time      datetime                                   comment '创建时间',
+  update_by        varchar(64)     default ''                 comment '更新者',
+  update_time      datetime                                   comment '更新时间',
+  primary key (id)
+) engine=innodb auto_increment=1 comment = '屏幕主题配置表';
+
+-- ----------------------------
+-- 初始化-屏幕主题配置表数据
+-- ----------------------------
+insert into robot_ops_screen_theme_config values(1, 'default', '', '迎宾巡逻机器人', '智能接待 · 路线引导 · 信息服务', '', '您好,欢迎光临', '我可以为您提供访客登记、路线引导、通知公告查询与现场帮助服务', '触摸屏幕进入服务', '#2f8ee5', '默认主题配置', 'admin', sysdate(), '', null);