From f583bb593226b4a267632850684b0053631c55eb Mon Sep 17 00:00:00 2001 From: jzf Date: Sat, 14 Nov 2026 12:59:44 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E5=8F=AB=E4=B9=98=E8=BF=87?= =?UTF-8?q?=E7=A8=8B=E4=B8=AD=E7=8A=B6=E6=80=81=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/ruoyi/RuoYiApplication.java | 15 +- .../dispatch/mapper/AptDutyStateMapper.java | 8 +- .../apt/dispatch/mapper/AptJcxxMapper.java | 9 + .../service/AptDispatchScanScheduler.java | 97 + .../service/AptDispatchScanServiceImpl.java | 32 +- .../service/AptDutyCommandServiceImpl.java | 17 +- .../service/AptDutyStateServiceImpl.java | 46 +- .../service/IAptDispatchScanService.java | 11 +- .../service/IAptDutyStateService.java | 10 +- .../duty/controller/DutyJcxxController.java | 103 + .../apt/duty/domain/DutyCommandRequest.java | 69 + .../apt/duty/domain/DutyCommandType.java | 85 + .../com/ruoyi/apt/duty/domain/DutyQuery.java | 54 + .../apt/duty/domain/DutyStateDeriver.java | 124 ++ .../ruoyi/apt/duty/domain/DutyTaskView.java | 176 ++ .../ruoyi/apt/duty/domain/DutyUiState.java | 57 + .../ruoyi/apt/duty/mapper/DutyJcxxMapper.java | 40 + .../realtime/DutyHandshakeInterceptor.java | 127 ++ .../apt/duty/realtime/DutyPushMessage.java | 27 + .../duty/realtime/DutyPushServiceImpl.java | 201 ++ .../apt/duty/realtime/DutyTransitionPush.java | 150 ++ .../duty/realtime/DutyWebSocketConfig.java | 44 + .../duty/realtime/DutyWebSocketHandler.java | 150 ++ .../apt/duty/realtime/IDutyPushService.java | 32 + .../duty/service/DutyCommandServiceImpl.java | 279 +++ .../service/DutyJcxxQueryServiceImpl.java | 150 ++ .../apt/duty/service/IDutyCommandService.java | 26 + .../duty/service/IDutyJcxxQueryService.java | 27 + .../test/controller/DutyTestController.java | 138 ++ .../duty/test/domain/DutySipBindCommand.java | 58 + .../test/domain/DutySipServerCommand.java | 120 ++ .../test/domain/DutyTestCheckinCommand.java | 121 ++ .../duty/test/domain/DutyTestRoomCommand.java | 119 ++ .../apt/duty/test/mapper/DutyTestMapper.java | 55 + .../duty/test/service/DutyTestService.java | 695 +++++++ .../apt/framework/config/AptProperties.java | 12 + .../apt/voice/mapper/AptVoiceMapper.java | 8 + .../voice/service/AptCallFlowServiceImpl.java | 39 + .../service/AptVoiceEventServiceImpl.java | 4 +- .../voice/service/IAptCallFlowService.java | 3 + .../ruoyi/quartz/task/AptDispatchTask.java | 5 +- .../src/main/resources/apt-defaults.yml | 4 + .../mapper/apt/AptDutyStateMapper.xml | 49 +- .../resources/mapper/apt/AptJcxxMapper.xml | 23 +- .../resources/mapper/apt/AptVoiceMapper.xml | 18 + .../resources/mapper/apt/DutyJcxxMapper.xml | 87 + .../resources/mapper/apt/DutyTestMapper.xml | 133 ++ .../dispatch/AptDispatchScanServiceTest.java | 90 + .../dispatch/AptDutyCommandServiceTest.java | 23 +- .../apt/dispatch/AptDutyStateServiceTest.java | 58 +- .../apt/duty/DutyCommandServiceTest.java | 316 +++ .../apt/duty/DutyJcxxQueryServiceTest.java | 204 ++ .../ruoyi/apt/duty/DutyPushServiceTest.java | 142 ++ .../apt/duty/DutyRealtimeContractTest.java | 70 + .../ruoyi/apt/duty/DutySqlContractTest.java | 95 + .../ruoyi/apt/duty/DutyStateDeriverTest.java | 120 ++ .../duty/realtime/DutyTransitionPushTest.java | 124 ++ .../realtime/DutyWebSocketHandlerTest.java | 125 ++ .../apt/voice/AptVoiceEventServiceTest.java | 3 +- dev/RuoYi-Vue2-master/src/api/duty/jcxx.js | 47 + dev/RuoYi-Vue2-master/src/api/duty/test.js | 100 + dev/RuoYi-Vue2-master/src/utils/dutyAlarm.js | 171 ++ .../src/utils/dutyRedirect.js | 68 + dev/RuoYi-Vue2-master/src/utils/dutySocket.js | 198 ++ .../src/views/duty/config.vue | 832 ++++++++ .../src/views/duty/console.vue | 1828 +++++++++++++++++ .../src/views/duty/login.vue | 392 ++++ dev/sql/apt_rev01/31_duty_permissions.sql | 61 + dev/sql/apt_rev01/33_duty_config_columns.sql | 29 + dev/sql/apt_rev01/34_duty_test_seed.sql | 95 + .../apt_rev01/35_duty_config_permissions.sql | 68 + dev/sql/local_seed/duty_demo_tasks.sql | 96 + docs/应用端/11-应用端UI开发计划.md | 227 ++ docs/应用端/12-值班员登录与准入鉴权.md | 279 +++ docs/应用端/13-第一轮开发(R1)交付说明.md | 233 +++ docs/应用端/14-第二轮开发(R2)交付说明.md | 163 ++ docs/应用端/15-第三轮开发(R3)交付说明.md | 162 ++ docs/应用端/16-第四轮开发(R4)交付说明.md | 183 ++ docs/应用端/17-测试配置与测试入住(R5).md | 103 + 79 files changed, 10521 insertions(+), 41 deletions(-) create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/AptDispatchScanScheduler.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/controller/DutyJcxxController.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/domain/DutyCommandRequest.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/domain/DutyCommandType.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/domain/DutyQuery.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/domain/DutyStateDeriver.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/domain/DutyTaskView.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/domain/DutyUiState.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/mapper/DutyJcxxMapper.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/realtime/DutyHandshakeInterceptor.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/realtime/DutyPushMessage.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/realtime/DutyPushServiceImpl.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/realtime/DutyTransitionPush.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/realtime/DutyWebSocketConfig.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/realtime/DutyWebSocketHandler.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/realtime/IDutyPushService.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/service/DutyCommandServiceImpl.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/service/DutyJcxxQueryServiceImpl.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/service/IDutyCommandService.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/service/IDutyJcxxQueryService.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/test/controller/DutyTestController.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/test/domain/DutySipBindCommand.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/test/domain/DutySipServerCommand.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/test/domain/DutyTestCheckinCommand.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/test/domain/DutyTestRoomCommand.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/test/mapper/DutyTestMapper.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/test/service/DutyTestService.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/DutyJcxxMapper.xml create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/DutyTestMapper.xml create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/dispatch/AptDispatchScanServiceTest.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/duty/DutyCommandServiceTest.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/duty/DutyJcxxQueryServiceTest.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/duty/DutyPushServiceTest.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/duty/DutyRealtimeContractTest.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/duty/DutySqlContractTest.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/duty/DutyStateDeriverTest.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/duty/realtime/DutyTransitionPushTest.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/duty/realtime/DutyWebSocketHandlerTest.java create mode 100644 dev/RuoYi-Vue2-master/src/api/duty/jcxx.js create mode 100644 dev/RuoYi-Vue2-master/src/api/duty/test.js create mode 100644 dev/RuoYi-Vue2-master/src/utils/dutyAlarm.js create mode 100644 dev/RuoYi-Vue2-master/src/utils/dutyRedirect.js create mode 100644 dev/RuoYi-Vue2-master/src/utils/dutySocket.js create mode 100644 dev/RuoYi-Vue2-master/src/views/duty/config.vue create mode 100644 dev/RuoYi-Vue2-master/src/views/duty/console.vue create mode 100644 dev/RuoYi-Vue2-master/src/views/duty/login.vue create mode 100644 dev/sql/apt_rev01/31_duty_permissions.sql create mode 100644 dev/sql/apt_rev01/33_duty_config_columns.sql create mode 100644 dev/sql/apt_rev01/34_duty_test_seed.sql create mode 100644 dev/sql/apt_rev01/35_duty_config_permissions.sql create mode 100644 dev/sql/local_seed/duty_demo_tasks.sql create mode 100644 docs/应用端/11-应用端UI开发计划.md create mode 100644 docs/应用端/12-值班员登录与准入鉴权.md create mode 100644 docs/应用端/13-第一轮开发(R1)交付说明.md create mode 100644 docs/应用端/14-第二轮开发(R2)交付说明.md create mode 100644 docs/应用端/15-第三轮开发(R3)交付说明.md create mode 100644 docs/应用端/16-第四轮开发(R4)交付说明.md create mode 100644 docs/应用端/17-测试配置与测试入住(R5).md diff --git a/dev/RuoYi-Vue-master/ruoyi-admin/src/main/java/com/ruoyi/RuoYiApplication.java b/dev/RuoYi-Vue-master/ruoyi-admin/src/main/java/com/ruoyi/RuoYiApplication.java index d6f9167..cfd0360 100644 --- a/dev/RuoYi-Vue-master/ruoyi-admin/src/main/java/com/ruoyi/RuoYiApplication.java +++ b/dev/RuoYi-Vue-master/ruoyi-admin/src/main/java/com/ruoyi/RuoYiApplication.java @@ -6,7 +6,7 @@ import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration; /** * 启动程序 - * + * * @author ruoyi */ @SpringBootApplication(exclude = { DataSourceAutoConfiguration.class }) @@ -14,17 +14,6 @@ public class RuoYiApplication { public static void main(String[] args) { - // System.setProperty("spring.devtools.restart.enabled", "false"); SpringApplication.run(RuoYiApplication.class, args); - System.out.println("(♥◠‿◠)ノ゙ 若依启动成功 ლ(´ڡ`ლ)゙ \n" + - " .-------. ____ __ \n" + - " | _ _ \\ \\ \\ / / \n" + - " | ( ' ) | \\ _. / ' \n" + - " |(_ o _) / _( )_ .' \n" + - " | (_,_).' __ ___(_ o _)' \n" + - " | |\\ \\ | || |(_,_)' \n" + - " | | \\ `' /| `-' / \n" + - " | | \\ / \\ / \n" + - " ''-' `'-' `-..-' "); } -} +} \ No newline at end of file diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/mapper/AptDutyStateMapper.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/mapper/AptDutyStateMapper.java index 250606f..009149c 100644 --- a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/mapper/AptDutyStateMapper.java +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/mapper/AptDutyStateMapper.java @@ -30,18 +30,24 @@ public interface AptDutyStateMapper int updateAnswered(@Param("gyid") String gyid, @Param("jcid") String jcid, @Param("fromStates") List fromStates); - /** 房间入会:仅补记 hjxysj,ryzt 保持 1 叫乘中(待离寓由挂断触发) */ + /** 房间入会:ryzt -> 5 通话中 + 补记 hjxysj(待离寓由挂断触发) */ int updateJoinConf(@Param("gyid") String gyid, @Param("jcid") String jcid); /** 1 叫乘中 -> 2 叫乘报警(30s 无入会 / 平板 NO_ANSWER / FAILED) */ int updateCallFailed(@Param("gyid") String gyid, @Param("jcid") String jcid); + /** -> 4 离寓报警:超过应离寓时间未离寓(is_lybj=1 + lybjsj,只报一次) */ + int updateLeaveAlarm(@Param("gyid") String gyid, @Param("jcid") String jcid); + /** 人工「停止叫乘」:ryzt 保持,stoped=1 */ int updateStop(@Param("gyid") String gyid, @Param("jcid") String jcid); /** 人工「复叫」:清 stoped、ryzt->1、记 fjsj_s */ int updateRecall(@Param("gyid") String gyid, @Param("jcid") String jcid); + /** 复叫时间到点自动复叫:不校验来源 ryzt,ryzt->1、重置 jcsj_s、记 fjsj_s */ + int updateAutoRecall(@Param("gyid") String gyid, @Param("jcid") String jcid); + /** 人工「强制结束」:finish_jc=1,不动 finish_ly(不置离寓) */ int updateForceFinish(@Param("gyid") String gyid, @Param("jcid") String jcid); diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/mapper/AptJcxxMapper.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/mapper/AptJcxxMapper.java index 9f78b4c..39daeb4 100644 --- a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/mapper/AptJcxxMapper.java +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/mapper/AptJcxxMapper.java @@ -53,6 +53,15 @@ public interface AptJcxxMapper @Param("deadline") Date deadline, @Param("limit") int limit); + /** + * 应离寓时间已到但未办理离寓(finish_ly=0 且未报过离寓报警)。 + * + *

与叫乘链路解耦:即使叫乘已完成/已报警,只要没离寓就参与判定。

+ */ + List> selectLeaveOverdue(@Param("gyid") String gyid, + @Param("now") Date now, + @Param("limit") int limit); + /** 命令幂等键查询(duty_manual_action) */ Map selectManualAction(@Param("gyid") String gyid, @Param("actionId") String actionId); diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/AptDispatchScanScheduler.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/AptDispatchScanScheduler.java new file mode 100644 index 0000000..bb2f5c0 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/AptDispatchScanScheduler.java @@ -0,0 +1,97 @@ +package com.ruoyi.apt.dispatch.service; + +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import com.ruoyi.apt.framework.config.AptProperties; +import jakarta.annotation.PreDestroy; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.ApplicationListener; +import org.springframework.stereotype.Component; + +/** + * 叫乘扫描自带调度器(与 AMI 心跳、会议自动挂机同一理由自建)。 + * + *

为什么不用 Quartz:本项目 {@code ScheduleConfig} 处于注释状态、 + * {@code sys_job} 中 {@code aptDispatchTask.scan()} 默认停用,实测 qrtz 无任何触发器; + * 而「叫乘时间/复叫时间/应离寓时间到点」是业务主链路的硬前提,不能依赖可选组件。

+ * + *

约束:

+ *
    + *
  • 单线程守护 + 固定延迟,单轮未结束不会并发叠加({@link AtomicBoolean} 互斥);
  • + *
  • 扫描内部已按「提交状态机命令」实现,重复执行幂等,异常只告警不中断后续轮次;
  • + *
  • {@code apt.dispatch.scan-interval-seconds} ≤ 0 时关闭,改由 sys_job 驱动(避免双跑)。
  • + *
+ * + * @author apt-rev01 + */ +@Component +public class AptDispatchScanScheduler implements ApplicationListener +{ + private static final Logger log = LoggerFactory.getLogger(AptDispatchScanScheduler.class); + + private final IAptDispatchScanService scanService; + private final AptProperties properties; + + private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(runnable -> { + Thread thread = new Thread(runnable, "apt-dispatch-scan"); + thread.setDaemon(true); + return thread; + }); + private final AtomicBoolean running = new AtomicBoolean(false); + + public AptDispatchScanScheduler(IAptDispatchScanService scanService, AptProperties properties) + { + this.scanService = scanService; + this.properties = properties; + } + + @Override + public void onApplicationEvent(ApplicationReadyEvent event) + { + start(); + } + + public void start() + { + int interval = properties.getDispatch().getScanIntervalSeconds(); + if (interval <= 0) + { + log.info("叫乘扫描自带调度已关闭(scan-interval-seconds<=0),改由 sys_job 的 aptDispatchTask.scan() 驱动"); + return; + } + scheduler.scheduleWithFixedDelay(this::tick, interval, interval, TimeUnit.SECONDS); + log.info("叫乘扫描调度已启动:每 {}s 一轮(叫乘/复叫到点发起、30s 无应答报警、应离寓超时报警)", interval); + } + + private void tick() + { + if (!running.compareAndSet(false, true)) + { + log.debug("上一轮叫乘扫描尚未结束,跳过本轮"); + return; + } + try + { + scanService.scan(); + } + catch (RuntimeException e) + { + // 单轮失败不得杀死调度线程:下一轮继续(时间触发靠幂等条件更新自愈) + log.warn("叫乘扫描执行失败,等待下一轮", e); + } + finally + { + running.set(false); + } + } + + @PreDestroy + void shutdown() + { + scheduler.shutdownNow(); + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/AptDispatchScanServiceImpl.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/AptDispatchScanServiceImpl.java index e136dc4..545033c 100644 --- a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/AptDispatchScanServiceImpl.java +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/AptDispatchScanServiceImpl.java @@ -84,21 +84,47 @@ public class AptDispatchScanServiceImpl implements IAptDispatchScanService } } - // SIP 到点发起(IT-7):房间腿 Originate + 值班台腿;AMI 未就绪时自动跳过 + // SIP 到点发起:复叫时间到点(不校验 ryzt,仅排除通话中,自动复叫 -> 重呼)优先于首轮 + int recalled = 0; int dialed = 0; if (properties.getVoice().isEnabled() && properties.getVoice().isSipCallEnabled()) { + recalled = callFlowService.redialDue(properties.getVoice().getMaxCallsPerScan()); dialed = callFlowService.dialDue(properties.getVoice().getMaxCallsPerScan()); } + // 应离寓时间到点且未办理离寓 -> 离寓报警(与叫乘链路解耦,AMI 未就绪也照常判定) + int leaveAlarms = 0; + List> overdue = jcxxMapper.selectLeaveOverdue( + AptContext.getGyId(), new Date(), properties.getDispatch().getScanBatchSize()); + for (Map row : overdue == null ? List.>of() : overdue) + { + String jcid = String.valueOf(row.get("jcid")); + try + { + stateService.leaveAlarm(jcid); + leaveAlarms++; + } + catch (AptException e) + { + if (e.getErrorCode() != AptErrorCode.STATE_ILLEGAL) + { + throw e; + } + log.debug("叫乘 {} 已离寓或已报过离寓报警,跳过离寓超时判定", jcid); + } + } + Map result = new LinkedHashMap<>(); result.put("expiredTasks", expired); result.put("generatedTasks", generated); result.put("sipTimeouts", timedOut); + result.put("recalledCalls", recalled); result.put("dialedCalls", dialed); + result.put("leaveAlarms", leaveAlarms); result.put("scannedAt", new Date()); - log.info("叫乘扫描完成:过期任务 {},新生成 {},SIP 超时 {},新发起呼叫 {}", - expired, generated, timedOut, dialed); + log.info("叫乘扫描完成:过期任务 {},新生成 {},SIP 超时 {},复叫发起 {},首呼发起 {},离寓报警 {}", + expired, generated, timedOut, recalled, dialed, leaveAlarms); return result; } finally diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/AptDutyCommandServiceImpl.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/AptDutyCommandServiceImpl.java index 0028730..9cabc3e 100644 --- a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/AptDutyCommandServiceImpl.java +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/AptDutyCommandServiceImpl.java @@ -31,7 +31,8 @@ import org.springframework.transaction.support.TransactionSynchronizationManager *

命令矩阵(与表 4-9 逐条对齐):

*
  * STOP          ryzt in (1,2) 且 finish_ly=0     -> stoped=1
- * RE_CALL       stoped=1 或 ryzt=2               -> 清 stoped、ryzt=1、新增会话
+ * RE_CALL       stoped=1 / ryzt=2 / ryzt=3      -> 清 stoped、ryzt=1、finish_jc=0、新增会话
+ *               (ryzt=3 已应答待离寓也允许复叫,重新开启一轮叫乘)
  * FORCE_FINISH  finish_ly=0                      -> finish_jc=1(不置离寓)
  * MARK_LEAVE    finish_ly=0                      -> finish_ly=1 + gygl_ly_event
  * CANCEL        ryzt=0                           -> stoped=1
@@ -241,9 +242,19 @@ public class AptDutyCommandServiceImpl implements IAptDutyCommandService
                 }
                 break;
             case RE_CALL:
-                if (!("1".equals(stoped) || "2".equals(ryzt)) || !inHouse)
+                if (!inHouse)
                 {
-                    throw stateIllegal("RE_CALL 仅允许已停止或叫乘报警的记录");
+                    throw stateIllegal("该人员已离寓,不可复叫");
+                }
+                // 已应答待离寓(ryzt=3,finish_jc 必然为 1):允许再叫一轮,
+                // 复叫会把 finish_jc 归 0 重新开启叫乘,因此不走下面的「已完成」拦截
+                if ("3".equals(ryzt) && "1".equals(finishJc))
+                {
+                    break;
+                }
+                if (!("1".equals(stoped) || "2".equals(ryzt)))
+                {
+                    throw stateIllegal("RE_CALL 仅允许已停止、叫乘报警或已应答待离寓的记录");
                 }
                 if ("1".equals(finishJc))
                 {
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/AptDutyStateServiceImpl.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/AptDutyStateServiceImpl.java
index 216d5fa..64ce97e 100644
--- a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/AptDutyStateServiceImpl.java
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/AptDutyStateServiceImpl.java
@@ -39,8 +39,8 @@ public class AptDutyStateServiceImpl implements IAptDutyStateService
 {
     private static final Logger log = LoggerFactory.getLogger(AptDutyStateServiceImpl.class);
 
-    /** 应答允许的来源态:0 待叫乘 / 1 叫乘中 / 2 叫乘报警(含复叫入会) */
-    private static final List ANSWERABLE = List.of("0", "1", "2");
+    /** 应答(房间挂断)允许的来源态:0 待叫乘 / 1 叫乘中 / 2 叫乘报警(含复叫入会) / 5 通话中 */
+    private static final List ANSWERABLE = List.of("0", "1", "2", "5");
 
     private final AptDutyStateMapper stateMapper;
     private final IAptJobService jobService;
@@ -76,9 +76,13 @@ public class AptDutyStateServiceImpl implements IAptDutyStateService
     @Transactional
     public int joinConf(String jcid)
     {
-        // 房间入会只补记呼叫响应时间:ryzt 仍是 1 叫乘中,值班台无需推送(状态未变);
+        // 房间入会 = 会议桥建立:ryzt 1 -> 5(通话中/表 11-2 TALKING),补记 hjxysj;
+        // 再未接通前的 30s 超时判定只针对 ryzt=1,因此入会后不会再被误判叫乘报警。
         // 待离寓迁移由房间挂断(HANGUP)触发,见 answered()
-        return require(stateMapper.updateJoinConf(gyid(), jcid), jcid, "JOIN_CONF");
+        AptStateSnapshot before = snapshot(jcid);
+        int rows = require(stateMapper.updateJoinConf(gyid(), jcid), jcid, "JOIN_CONF");
+        pushDuty(jcid, before);
+        return rows;
     }
 
     @Override
@@ -104,6 +108,19 @@ public class AptDutyStateServiceImpl implements IAptDutyStateService
         return rows;
     }
 
+    @Override
+    @Transactional
+    public int leaveAlarm(String jcid)
+    {
+        AptStateSnapshot before = snapshot(jcid);
+        int rows = require(stateMapper.updateLeaveAlarm(gyid(), jcid), jcid, "LEAVE_ALARM");
+        // 公寓端通道:管理台/平板立即看到离寓报警
+        realtimePublisher.alarm(jcid, "LEAVE_ALARM", "超过应离寓时间未办理离寓");
+        // 值班台通道:本方法只由扫描任务调用,调度线程没有 HTTP 上下文(L-11)
+        pushDuty(jcid, before);
+        return rows;
+    }
+
     @Override
     @Transactional
     public int stop(String jcid)
@@ -128,6 +145,27 @@ public class AptDutyStateServiceImpl implements IAptDutyStateService
         return rows;
     }
 
+    /**
+     * 复叫时间到点自动复叫。
+     *
+     * 

与人工 {@link #recall(String)} 的唯一区别:不校验来源 ryzt + * (待叫乘/叫乘中/叫乘报警都可能到点),只要未停止、未完成叫乘、未离寓即进入复叫; + * 30s 无应答基准一并重置,复叫后仍未接通由房间腿失败/超时回到叫乘报警。

+ */ + @Override + @Transactional + public int autoRecall(String jcid) + { + AptStateSnapshot before = snapshot(jcid); + int rows = require(stateMapper.updateAutoRecall(gyid(), jcid), jcid, "AUTO_RE_CALL"); + String callMode = before == null || before.getCallMode() == null ? "SIP" : before.getCallMode(); + // 表 3-10:复叫生成新会话,保证时间线与录音索引有独立 session_id + stateMapper.insertCallSession(gyid(), "CS-" + UUID.randomUUID().toString().replace("-", ""), + jcid, callMode); + pushDuty(jcid, before); + return rows; + } + @Override @Transactional public int forceFinish(String jcid) diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/IAptDispatchScanService.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/IAptDispatchScanService.java index 0d2a3f9..0ece083 100644 --- a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/IAptDispatchScanService.java +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/IAptDispatchScanService.java @@ -3,7 +3,16 @@ package com.ruoyi.apt.dispatch.service; import java.util.Map; /** - * 叫乘扫描任务(IT-5 / M3):平板任务过期回收、任务生成、SIP 30s 无入会转报警。 + * 叫乘扫描任务(IT-5 / M3):平板任务过期回收、任务生成、SIP 30s 无入会转报警、 + * 叫乘/复叫时间到点发起呼叫、应离寓时间到点转离寓报警。 + * + *

时间触发口径(三字段):

+ *
    + *
  • {@code jcsj} 叫乘时间到点(ryzt=0)→ 先值班台、后房间发起 SIP 呼叫;
  • + *
  • {@code fjsj} 复叫时间到点(不校验来源 ryzt,仅排除 5 通话中不打断会议) + * → 自动复叫后重呼,同一计划只执行一轮;
  • + *
  • {@code ylysj} 应离寓时间到点且 {@code finish_ly=0} → 离寓报警(ryzt=4)。
  • + *
* *

注意:扫描只「提交状态机命令」,不做任何直写; * 平板签到窗口超时不由本任务推定(P2-2 裁决:由平板上报 NO_ANSWER)。

diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/IAptDutyStateService.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/IAptDutyStateService.java index 9b3f731..8600237 100644 --- a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/IAptDutyStateService.java +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/IAptDutyStateService.java @@ -17,7 +17,7 @@ public interface IAptDutyStateService /** 0 待叫乘 -> 1 叫乘中 */ int callStart(String jcid); - /** 房间入会:补记 hjxysj,ryzt 保持 1 叫乘中(不变待离寓) */ + /** 房间入会(会议桥建立):ryzt -> 5 通话中 + 补记 hjxysj(不变待离寓) */ int joinConf(String jcid); /** -> 3 已应答待离寓 + finish_jc=1(房间挂断 / 平板 VERIFIED) */ @@ -26,12 +26,18 @@ public interface IAptDutyStateService /** 1 叫乘中 -> 2 叫乘报警 */ int callFailed(String jcid); + /** 超过应离寓时间未离寓 -> 4 离寓报警 + is_lybj=1 + lybjsj(只报一次) */ + int leaveAlarm(String jcid); + /** 人工停止叫乘:stoped=1 */ int stop(String jcid); - /** 人工复叫:清 stoped、ryzt->1、新增会话 */ + /** 人工复叫:清 stoped、ryzt->1、finish_jc->0、新增会话(来源:已停止 / 叫乘报警 / 已应答待离寓) */ int recall(String jcid); + /** 复叫时间到点自动复叫:不校验来源 ryzt(排除 5 通话中),ryzt->1、重置 jcsj_s、记 fjsj_s、新增会话 */ + int autoRecall(String jcid); + /** 人工强制结束:finish_jc=1,不置离寓 */ int forceFinish(String jcid); diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/controller/DutyJcxxController.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/controller/DutyJcxxController.java new file mode 100644 index 0000000..31d1817 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/controller/DutyJcxxController.java @@ -0,0 +1,103 @@ +package com.ruoyi.apt.duty.controller; + +import java.util.Date; +import java.util.List; +import java.util.Map; +import com.ruoyi.apt.common.page.AptTableDataInfo; +import com.ruoyi.apt.duty.domain.DutyCommandRequest; +import com.ruoyi.apt.duty.domain.DutyQuery; +import com.ruoyi.apt.duty.service.IDutyCommandService; +import com.ruoyi.apt.duty.service.IDutyJcxxQueryService; +import com.ruoyi.apt.framework.web.AptBaseController; +import com.ruoyi.common.core.domain.AjaxResult; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * 值班台叫乘盯控(应用端/04 接口 1、`11` 表 11-7 `T-BE-02`/`T-BE-04`/`T-BE-05`)。 + * + *

R1 只开两个只读接口;R2 增加命令通道、详情与时间线。命令本身不在这里实现, + * 一律委托 {@code apt.duty.state}(幂等 + 乐观锁 + 审计都在那一层),值班台只做 + * "权限粒度更细 + 入参更窄"的外壳。

+ * + *

列表不写审计:值班台 60 s 兜底轮询会持续刷新列表,若每次列表都落 + * {@code duty_operation_log},审计表会被展示流量淹没。审计只写命令(由 + * {@code apt.duty.state} 落 {@code duty_manual_action} + {@code duty_operation_log})。

+ * + * @author apt-rev01 + */ +@RestController +@RequestMapping(value = "/duty/jcxx", produces = "application/json;charset=UTF-8") +public class DutyJcxxController extends AptBaseController +{ + private final IDutyJcxxQueryService queryService; + + private final IDutyCommandService commandService; + + public DutyJcxxController(IDutyJcxxQueryService queryService, + IDutyCommandService commandService) + { + this.queryService = queryService; + this.commandService = commandService; + } + + /** 值班台列表:默认"当日 + 需处理",上限 100 条(`AptPageSupport` 硬上限) */ + @PreAuthorize("@ss.hasPermi('duty:jcxx:list')") + @GetMapping + public AptTableDataInfo list(@RequestParam(required = false) String scope, + @RequestParam(required = false) String keyword) + { + DutyQuery query = new DutyQuery(); + query.setScope(scope); + query.setKeyword(keyword); + + startAptPage(); + return getAptDataTable(queryService.list(query, new Date())); + } + + /** 吸底统计 + 紧急条数 + serverTime(同一口径,禁止前端自行 count 当前页) */ + @PreAuthorize("@ss.hasPermi('duty:jcxx:list')") + @GetMapping("/counts") + public AjaxResult counts() + { + return AjaxResult.success(queryService.counts(new Date())); + } + + /** + * 执行状态命令(R2)。 + * + *

注解只做粗粒度前置(5 个点任一);具体命令该不该给,在 + * {@code DutyCommandServiceImpl} 里按 {@link com.ruoyi.apt.duty.domain.DutyCommandType} + * 精确判一次,未授权返回 40301 而不是 500。

+ */ + @PreAuthorize("@ss.hasAnyPermi('duty:call:stop,duty:call:recall,duty:call:cancel,duty:call:forceFinish,duty:call:markLeave')") + @PostMapping("/{jcid}/commands") + public AjaxResult command(@PathVariable("jcid") String jcid, + @RequestBody DutyCommandRequest request) + { + return AjaxResult.success(commandService.execute(jcid, request)); + } + + /** 详情抽屉(`T-BE-03`):叫乘行 + 会话 + 最近人工动作,不含录音/照片路径 */ + @PreAuthorize("@ss.hasPermi('duty:jcxx:query')") + @GetMapping("/{jcid}") + public AjaxResult detail(@PathVariable("jcid") String jcid) + { + return AjaxResult.success(commandService.detail(jcid)); + } + + /** 时间线(`T-BE-03`):按时间倒序的关键节点 */ + @PreAuthorize("@ss.hasPermi('duty:jcxx:query')") + @GetMapping("/{jcid}/timeline") + public AjaxResult timeline(@PathVariable("jcid") String jcid) + { + List> timeline = commandService.timeline(jcid); + return AjaxResult.success(timeline); + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/domain/DutyCommandRequest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/domain/DutyCommandRequest.java new file mode 100644 index 0000000..77ae0df --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/domain/DutyCommandRequest.java @@ -0,0 +1,69 @@ +package com.ruoyi.apt.duty.domain; + +/** + * 值班台命令请求体(`POST /duty/jcxx/{jcid}/commands`)。 + * + *

{@code commandId} 是 L2 幂等键:前端生成一次、重试必须复用; + * {@code expectedState} 用于防"看到的和改的不是同一条"。

+ * + * @author apt-rev01 + */ +public class DutyCommandRequest +{ + private String commandId; + private String commandType; + private String reason; + private Integer expectedState; + /** MARK_LEAVE 专用,缺省 MANUAL */ + private String leaveType; + + public String getCommandId() + { + return commandId; + } + + public void setCommandId(String commandId) + { + this.commandId = commandId; + } + + public String getCommandType() + { + return commandType; + } + + public void setCommandType(String commandType) + { + this.commandType = commandType; + } + + public String getReason() + { + return reason; + } + + public void setReason(String reason) + { + this.reason = reason; + } + + public Integer getExpectedState() + { + return expectedState; + } + + public void setExpectedState(Integer expectedState) + { + this.expectedState = expectedState; + } + + public String getLeaveType() + { + return leaveType; + } + + public void setLeaveType(String leaveType) + { + this.leaveType = leaveType; + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/domain/DutyCommandType.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/domain/DutyCommandType.java new file mode 100644 index 0000000..5bb2f6f --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/domain/DutyCommandType.java @@ -0,0 +1,85 @@ +package com.ruoyi.apt.duty.domain; + +import java.util.Locale; + +/** + * 值班台可发的 5 个状态命令(应用端/02 表 2-9、`04` 表 4-6 第 1~5 行)。 + * + *

与 {@code apt:jcxx:changeStatus} 的区别不是能力,而是**权限粒度**: + * 管理端一个点管 5 个命令,值班台要能"只给停止、不给强制结束", + * 因此一命令一点(`duty:call:*`)。

+ * + * @author apt-rev01 + */ +public enum DutyCommandType +{ + /** 停止叫乘:ryzt in (1,2) 且 finish_ly=0;必须选原因 */ + STOP("duty:call:stop", true, "值班台停止叫乘"), + /** 复叫:stoped=1 / ryzt=2 / ryzt=3(已应答待离寓,允许再叫一轮) */ + RE_CALL("duty:call:recall", false, "值班台复叫"), + /** 强制结束:finish_ly=0;必须写原因 */ + FORCE_FINISH("duty:call:forceFinish", true, "值班台强制结束"), + /** 登记离寓:finish_ly=0 */ + MARK_LEAVE("duty:call:markLeave", false, "值班台登记离寓"), + /** 取消:ryzt=0;必须写原因 */ + CANCEL("duty:call:cancel", true, "值班台取消叫乘"); + + private final String permission; + private final boolean reasonRequired; + private final String defaultReason; + + DutyCommandType(String permission, boolean reasonRequired, String defaultReason) + { + this.permission = permission; + this.reasonRequired = reasonRequired; + this.defaultReason = defaultReason; + } + + public String getPermission() + { + return permission; + } + + public boolean isReasonRequired() + { + return reasonRequired; + } + + /** 一键动作(复叫/登记离寓)无原因输入时的默认留痕文本 */ + public String getDefaultReason() + { + return defaultReason; + } + + /** 全部命令名,用于 40001 的可读提示 */ + public static String allCommands() + { + StringBuilder sb = new StringBuilder(); + for (DutyCommandType value : values()) + { + if (sb.length() > 0) + { + sb.append(','); + } + sb.append(value.name()); + } + return sb.toString(); + } + + /** 未知命令返回 null(由调用方转 40001,不抛枚举异常) */ + public static DutyCommandType of(String raw) + { + if (raw == null || raw.trim().isEmpty()) + { + return null; + } + try + { + return valueOf(raw.trim().toUpperCase(Locale.ROOT)); + } + catch (IllegalArgumentException e) + { + return null; + } + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/domain/DutyQuery.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/domain/DutyQuery.java new file mode 100644 index 0000000..9c90d31 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/domain/DutyQuery.java @@ -0,0 +1,54 @@ +package com.ruoyi.apt.duty.domain; + +/** + * 值班台列表筛选条件(应用端/11 表 11-1 工具栏)。 + * + *

只有三个入参:口径(需处理/全部/已完成)、关键字、以及 PageHelper 的分页参数。 + * 时间窗由服务端固定为"当日",客户端不能传 —— 防止值班员误传一个跨月窗口把库拖垮。

+ * + * @author apt-rev01 + */ +public class DutyQuery +{ + public static final String SCOPE_NEED = "need"; + public static final String SCOPE_ALL = "all"; + public static final String SCOPE_DONE = "done"; + + private String scope = SCOPE_NEED; + private String keyword; + + /** 只接受 need/all/done,其余一律按 need 处理(不报错,保证首屏永远有数据) */ + public static String normalizeScope(String raw) + { + if (raw == null) + { + return SCOPE_NEED; + } + String value = raw.trim().toLowerCase(java.util.Locale.ROOT); + if (SCOPE_ALL.equals(value) || SCOPE_DONE.equals(value)) + { + return value; + } + return SCOPE_NEED; + } + + public String getScope() + { + return scope; + } + + public void setScope(String scope) + { + this.scope = normalizeScope(scope); + } + + public String getKeyword() + { + return keyword; + } + + public void setKeyword(String keyword) + { + this.keyword = keyword == null ? null : keyword.trim(); + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/domain/DutyStateDeriver.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/domain/DutyStateDeriver.java new file mode 100644 index 0000000..d5eb102 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/domain/DutyStateDeriver.java @@ -0,0 +1,124 @@ +package com.ruoyi.apt.duty.domain; + +import java.util.Date; + +/** + * 值班台状态派生(应用端/11 表 11-2 的唯一实现)。 + * + *

纯函数、无 Spring 依赖、无数据库访问:卡片、列表、紧急卡片、吸底统计 + * 都必须调用这里,避免"前端一套、后端一套"两位口径。判定顺序固定: + * {@code finish_ly=1 → stoped=1 → ryzt 六态 → IN}。

+ * + * @author apt-rev01 + */ +public final class DutyStateDeriver +{ + public static final String ALARM_CALL = "CALL"; + public static final String ALARM_LEAVE = "LEAVE"; + + private static final String YES = "1"; + + private DutyStateDeriver() + { + } + + /** + * @param ryzt 0~5,空/未知表示未进入叫乘链路 + * @param stoped "1" 表示已停止 + * @param finishLy "1" 表示已离寓(终态) + */ + public static DutyUiState derive(String ryzt, String stoped, String finishLy) + { + if (YES.equals(finishLy)) + { + return DutyUiState.DONE; + } + if (YES.equals(stoped)) + { + return DutyUiState.STOPPED; + } + if (ryzt == null || ryzt.isEmpty()) + { + return DutyUiState.IN; + } + switch (ryzt) + { + case "0": + return DutyUiState.TODO; + case "1": + return DutyUiState.CALLING; + case "2": + return DutyUiState.ALARM_CALL; + case "3": + return DutyUiState.LEAVE; + case "4": + return DutyUiState.ALARM_LEAVE; + case "5": + return DutyUiState.TALKING; + default: + return DutyUiState.IN; + } + } + + /** 报警类型:仅 ryzt=2/4 有值,便于前端做声音与文案分支 */ + public static String alarmType(DutyUiState state) + { + if (state == DutyUiState.ALARM_CALL) + { + return ALARM_CALL; + } + if (state == DutyUiState.ALARM_LEAVE) + { + return ALARM_LEAVE; + } + return null; + } + + /** 待叫乘已过点:只有 TODO 才谈超时 */ + public static boolean overdue(DutyUiState state, Date callTime, Date now) + { + return state == DutyUiState.TODO && callTime != null && now != null + && now.getTime() > callTime.getTime(); + } + + /** 进入「需要马上处理」:报警两类,或待叫乘已过点 */ + public static boolean urgent(DutyUiState state, boolean overdue) + { + return state.isAlarm() || (state == DutyUiState.TODO && overdue); + } + + /** + * 距叫乘还有多少分钟;已过点返回负值(前端据此显示"已过 N 分钟")。 + * 调用时间为空时返回 {@code null}(表示"未排任务")。 + */ + public static Long remainMinutes(Date callTime, Date now) + { + if (callTime == null || now == null) + { + return null; + } + long diff = callTime.getTime() - now.getTime(); + return Math.round(diff / 60000.0d); + } + + /** + * 姓名脱敏:保留首字,其余用 {@code *} 代替(最多 2 个)。 + * 与现状值班台一致,避免大屏被同公寓其他人看到全名。 + */ + public static String maskName(String name) + { + if (name == null || name.isEmpty()) + { + return name; + } + int keep = 1; + String head = name.substring(0, Math.min(keep, name.length())); + int stars = Math.min(2, Math.max(1, name.length() - keep)); + StringBuilder sb = new StringBuilder(head); + for (int i = 0; i < stars; i++) + { + sb.append('*'); + } + return sb.toString(); + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/domain/DutyTaskView.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/domain/DutyTaskView.java new file mode 100644 index 0000000..273175e --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/domain/DutyTaskView.java @@ -0,0 +1,176 @@ +package com.ruoyi.apt.duty.domain; + +import java.util.Date; + +/** + * 值班台一行的只读投影(应用端/11 表 11-2、表 11-4)。 + * + *

只含展示所需字段:不带 {@code id}(内部主键)、不带任何可写字段, + * 也不带 {@code gyid}(数据域由服务端固定,不向客户端回显)。

+ * + * @author apt-rev01 + */ +public class DutyTaskView +{ + private String jcid; + private String roomNo; + /** 已脱敏(保留首字 + 星号) */ + private String personName; + private String trainNo; + private String callTime; + private String departTime; + private String ryzt; + private String stateLabel; + private String uiState; + private String alarmType; + private String finishJc; + private String finishLy; + private String stoped; + private String matchLocked; + private String leaveRemark; + private boolean overdue; + private boolean urgent; + private Long remainMinutes; + + /** 由 mapper 原始行构造:派生口径全部走 {@link DutyStateDeriver} */ + public static DutyTaskView fromMap(java.util.Map row, Date now) + { + DutyTaskView view = new DutyTaskView(); + view.jcid = str(row.get("jcid")); + view.roomNo = str(row.get("roomNo")); + view.personName = DutyStateDeriver.maskName(str(row.get("personName"))); + view.trainNo = str(row.get("trainNo")); + Date callTime = date(row.get("callTime")); + Date departTime = date(row.get("departTime")); + view.callTime = format(callTime); + view.departTime = format(departTime); + view.ryzt = str(row.get("ryzt")); + view.finishJc = str(row.get("finishJc")); + view.finishLy = str(row.get("finishLy")); + view.stoped = str(row.get("stoped")); + view.matchLocked = str(row.get("matchLocked")); + view.leaveRemark = str(row.get("leaveRemark")); + + DutyUiState state = DutyStateDeriver.derive(view.ryzt, view.stoped, view.finishLy); + view.uiState = state.name(); + view.stateLabel = state.getLabel(); + view.alarmType = DutyStateDeriver.alarmType(state); + view.overdue = DutyStateDeriver.overdue(state, callTime, now); + view.urgent = DutyStateDeriver.urgent(state, view.overdue); + view.remainMinutes = DutyStateDeriver.remainMinutes(callTime, now); + return view; + } + + private static String str(Object value) + { + return value == null ? null : String.valueOf(value); + } + + private static Date date(Object value) + { + if (value instanceof Date) + { + return (Date) value; + } + return null; + } + + private static String format(Date value) + { + if (value == null) + { + return null; + } + return new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(value); + } + + public String getJcid() + { + return jcid; + } + + public String getRoomNo() + { + return roomNo; + } + + public String getPersonName() + { + return personName; + } + + public String getTrainNo() + { + return trainNo; + } + + public String getCallTime() + { + return callTime; + } + + public String getDepartTime() + { + return departTime; + } + + public String getRyzt() + { + return ryzt; + } + + public String getStateLabel() + { + return stateLabel; + } + + public String getUiState() + { + return uiState; + } + + public String getAlarmType() + { + return alarmType; + } + + public String getFinishJc() + { + return finishJc; + } + + public String getFinishLy() + { + return finishLy; + } + + public String getStoped() + { + return stoped; + } + + public String getMatchLocked() + { + return matchLocked; + } + + public String getLeaveRemark() + { + return leaveRemark; + } + + public boolean isOverdue() + { + return overdue; + } + + public boolean isUrgent() + { + return urgent; + } + + public Long getRemainMinutes() + { + return remainMinutes; + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/domain/DutyUiState.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/domain/DutyUiState.java new file mode 100644 index 0000000..33d8d35 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/domain/DutyUiState.java @@ -0,0 +1,57 @@ +package com.ruoyi.apt.duty.domain; + +/** + * 值班台行状态(应用端/11 表 11-2 的 9 个投影值)。 + * + *

这是 {@code ryzt} + {@code stoped} + {@code finish_ly} 的**组合投影**, + * 不是新状态机:不落库、不下发写接口、不允许前端自行拼装。 + * 派生逻辑唯一实现见 {@link DutyStateDeriver}。

+ * + * @author apt-rev01 + */ +public enum DutyUiState +{ + /** ryzt=0,未到/已过叫乘时间 */ + TODO("待叫乘"), + /** ryzt=1 */ + CALLING("呼叫中"), + /** ryzt=5 */ + TALKING("通话中"), + /** ryzt=2:30s 无入会 */ + ALARM_CALL("叫乘报警"), + /** ryzt=4:超时应离寓 */ + ALARM_LEAVE("离寓报警"), + /** ryzt=3:已应答待离寓 */ + LEAVE("待离寓"), + /** finish_ly=0 且无 ryzt(已入寓、等待排任务) */ + IN("已入寓"), + /** stoped=1 */ + STOPPED("已停止"), + /** finish_ly=1 */ + DONE("已完成"); + + private final String label; + + DutyUiState(String label) + { + this.label = label; + } + + public String getLabel() + { + return label; + } + + /** 需处理:值班台默认筛选口径 */ + public boolean isNeed() + { + return this == TODO || this == CALLING || this == TALKING + || this == ALARM_CALL || this == ALARM_LEAVE || this == LEAVE; + } + + /** 进入「需要马上处理」区:报警,或待叫乘已过点 */ + public boolean isAlarm() + { + return this == ALARM_CALL || this == ALARM_LEAVE; + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/mapper/DutyJcxxMapper.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/mapper/DutyJcxxMapper.java new file mode 100644 index 0000000..e2d18d2 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/mapper/DutyJcxxMapper.java @@ -0,0 +1,40 @@ +package com.ruoyi.apt.duty.mapper; + +import java.util.Date; +import java.util.List; +import java.util.Map; +import org.apache.ibatis.annotations.Param; + +/** + * 值班台只读投影(应用端/02 表 2-2「view 包不许有 insert/update/delete」)。 + * + *

刻意与 {@code AptJcxxMapper}(管理端)分开:管理端按"台账"口径排序、 + * 返回全字段;值班台按"紧急优先"排序、只返回展示字段、且强制当日时间窗。 + * 两者共用 {@code gy_jcxx},但**不共用一个 SQL**,避免改一处崩两端。

+ * + * @author apt-rev01 + */ +public interface DutyJcxxMapper +{ + /** 当日任务投影;scope 只接受 need/all/done(已由服务层归一化) */ + List> selectDutyTaskList(@Param("gyid") String gyid, + @Param("scope") String scope, + @Param("from") Date from, + @Param("to") Date to, + @Param("now") Date now); + + /** 吸底统计与紧急卡片计数(应用端/11 表 11-6 `G-UI-04` 方案 A) */ + Map selectDutyCounts(@Param("gyid") String gyid, + @Param("from") Date from, + @Param("to") Date to, + @Param("now") Date now); + + /** + * 按业务键 {@code jcid} 取最小引用(主键 + 三个状态标志)。 + * + *

管理端只按内部主键 {@code id} 取行;值班台拿到的是 {@code jcid}, + * 必须先在**本公寓内**解析成主键,再交给 {@code apt.duty.state} 执行, + * 不能把客户端给的字符串直接当主键用。

+ */ + Map selectDutyRefByJcid(@Param("gyid") String gyid, @Param("jcid") String jcid); +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/realtime/DutyHandshakeInterceptor.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/realtime/DutyHandshakeInterceptor.java new file mode 100644 index 0000000..d5d3405 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/realtime/DutyHandshakeInterceptor.java @@ -0,0 +1,127 @@ +package com.ruoyi.apt.duty.realtime; + +import java.util.Map; +import java.util.Set; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequestWrapper; +import com.ruoyi.apt.framework.config.AptProperties; +import com.ruoyi.common.core.domain.model.LoginUser; +import com.ruoyi.framework.web.service.TokenService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.http.server.ServletServerHttpRequest; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.server.HandshakeInterceptor; +import org.springframework.web.util.UriComponentsBuilder; + +/** + * `/ws/duty` 握手鉴权(应用端/04 表 4-12 第 29 行、`12` 表 12-1 闸门 2)。 + * + *

与 `/apt/ws` 的差别只有两点:必需权限点是 {@code duty:jcxx:list}, + * 且 不接受 {@code ?gyid=}(公寓域只取部署配置,防跨公寓)。

+ * + * @author apt-rev01 + */ +@Component +public class DutyHandshakeInterceptor implements HandshakeInterceptor +{ + private static final Logger log = LoggerFactory.getLogger(DutyHandshakeInterceptor.class); + + /** 值班台订阅权限:与 /duty/** 的准入闸门同码 */ + private static final String REQUIRED_PERMISSION = "duty:jcxx:list"; + + private final TokenService tokenService; + + private final AptProperties properties; + + public DutyHandshakeInterceptor(TokenService tokenService, AptProperties properties) + { + this.tokenService = tokenService; + this.properties = properties; + } + + @Override + public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, + WebSocketHandler wsHandler, Map attributes) + { + String token = resolveToken(request); + if (!StringUtils.hasText(token)) + { + response.setStatusCode(HttpStatus.UNAUTHORIZED); + return false; + } + LoginUser loginUser = tokenService.getLoginUser(withBearerToken(request, token)); + if (loginUser == null) + { + log.warn("值班台 WS 握手拒绝:令牌无效或已过期"); + response.setStatusCode(HttpStatus.UNAUTHORIZED); + return false; + } + Set permissions = loginUser.getPermissions(); + if (!permissions.contains("*:*:*") && !permissions.contains(REQUIRED_PERMISSION)) + { + log.warn("值班台 WS 握手拒绝:userId={} 缺少权限点 {}", loginUser.getUserId(), REQUIRED_PERMISSION); + response.setStatusCode(HttpStatus.FORBIDDEN); + return false; + } + String gyid = properties.getGyId(); + if (!StringUtils.hasText(gyid)) + { + log.warn("值班台 WS 握手拒绝:未配置 apt.gy-id"); + response.setStatusCode(HttpStatus.FORBIDDEN); + return false; + } + attributes.put("userId", loginUser.getUserId()); + attributes.put("username", loginUser.getUsername()); + attributes.put("gyid", gyid); + attributes.put("permissions", permissions); + return true; + } + + @Override + public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, + WebSocketHandler wsHandler, Exception exception) + { + // 无需处理 + } + + private String resolveToken(ServerHttpRequest request) + { + String token = UriComponentsBuilder.fromUri(request.getURI()).build() + .getQueryParams().getFirst("token"); + if (StringUtils.hasText(token)) + { + return token.trim(); + } + String header = request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION); + if (StringUtils.hasText(header) && header.startsWith("Bearer ")) + { + return header.substring(7).trim(); + } + return null; + } + + /** TokenService 只读 Header,这里把 query 令牌包装成 Authorization */ + private HttpServletRequest withBearerToken(ServerHttpRequest request, String token) + { + HttpServletRequest original = ((ServletServerHttpRequest) request).getServletRequest(); + return new HttpServletRequestWrapper(original) + { + @Override + public String getHeader(String name) + { + if (HttpHeaders.AUTHORIZATION.equalsIgnoreCase(name)) + { + return "Bearer " + token; + } + return super.getHeader(name); + } + }; + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/realtime/DutyPushMessage.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/realtime/DutyPushMessage.java new file mode 100644 index 0000000..f79a00b --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/realtime/DutyPushMessage.java @@ -0,0 +1,27 @@ +package com.ruoyi.apt.duty.realtime; + +import com.ruoyi.apt.framework.realtime.AptRealtimeMessage; + +/** + * `/ws/duty` 推送信封(应用端/04 表 4-12)。 + * + *

只在公寓端信封上多一个 {@code seq}:前端用它丢弃乱序/重复消息。 + * 其余字段(type/category/priority/gyid/occurredAt/traceId/targetPermission/data) + * 与 `/apt/ws` 完全一致,前端可以复用同一套分发代码。

+ * + * @author apt-rev01 + */ +public class DutyPushMessage extends AptRealtimeMessage +{ + private long seq; + + public long getSeq() + { + return seq; + } + + public void setSeq(long seq) + { + this.seq = seq; + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/realtime/DutyPushServiceImpl.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/realtime/DutyPushServiceImpl.java new file mode 100644 index 0000000..5268936 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/realtime/DutyPushServiceImpl.java @@ -0,0 +1,201 @@ +package com.ruoyi.apt.duty.realtime; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; +import com.alibaba.fastjson2.JSON; +import com.ruoyi.apt.common.AptContext; +import com.ruoyi.apt.framework.config.AptProperties; +import com.ruoyi.apt.framework.realtime.AptRealtimeMessage; +import com.ruoyi.apt.framework.realtime.AptRealtimeSessionRegistry; +import com.ruoyi.apt.framework.realtime.AptRealtimeTypes; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.util.StringUtils; + +/** + * 值班台实时推送实现(R3)。 + * + *

投递复用公寓端 {@link AptRealtimeSessionRegistry}:它已经做了 gyid 隔离、 + * 权限点过滤、单会话串行发送与失败摘除。本类只补三件事:

+ *
    + *
  1. seq:按 gyid 单调递增,连接建立时把 {@code currentSeq} 下发给前端做起点;
  2. + *
  3. 投递权限:固定 {@code duty:jcxx:list}(公寓端同一分类投给 {@code apt:jcxx:list}, + * 两边订阅者不重叠,避免值班员收到管理端消息);
  4. + *
  5. 事务:同步事务内登记 {@code afterCommit},回滚不发消息。
  6. + *
+ * + * @author apt-rev01 + */ +@Service +public class DutyPushServiceImpl implements IDutyPushService +{ + private static final Logger log = LoggerFactory.getLogger(DutyPushServiceImpl.class); + + /** 值班台订阅权限:与 `/duty/**` 的闸门 2 同码(应用端/12 表 12-1) */ + private static final String DUTY_PERMISSION = "duty:jcxx:list"; + + private static final String SCOPE_PERMISSION = "PERMISSION"; + + static final String TYPE_STATE_CHANGED = "state.changed"; + static final String TYPE_ALARM_RAISED = "alarm.raised"; + static final String TYPE_ALARM_CLEARED = "alarm.cleared"; + static final String TYPE_SESSION_CHANGED = "session.changed"; + + private final Map sequences = new ConcurrentHashMap<>(); + + private final AptRealtimeSessionRegistry registry; + + private final AptProperties properties; + + public DutyPushServiceImpl(AptRealtimeSessionRegistry registry, AptProperties properties) + { + this.registry = registry; + this.properties = properties; + } + + @Override + public long stateChanged(String jcid, Integer ryztBefore, Integer ryztAfter, String uiState, + String stateLabel, String actionId) + { + Map data = new LinkedHashMap<>(); + data.put("jcid", jcid); + data.put("ryztBefore", ryztBefore); + data.put("ryztAfter", ryztAfter); + data.put("uiState", uiState); + data.put("stateLabel", stateLabel); + data.put("actionId", actionId); + return dispatch(TYPE_STATE_CHANGED, AptRealtimeTypes.CATEGORY_CALL, data); + } + + @Override + public long alarmRaised(String jcid, String alarmType, String alarmEventKey) + { + Map data = new LinkedHashMap<>(); + data.put("jcid", jcid); + data.put("alarmType", alarmType); + data.put("alarmEventKey", alarmEventKey); + data.put("sound", true); + return dispatch(TYPE_ALARM_RAISED, AptRealtimeTypes.CATEGORY_ALARM, data); + } + + @Override + public long alarmCleared(String jcid, String alarmType, String alarmEventKey) + { + Map data = new LinkedHashMap<>(); + data.put("jcid", jcid); + data.put("alarmType", alarmType); + data.put("alarmEventKey", alarmEventKey); + return dispatch(TYPE_ALARM_CLEARED, AptRealtimeTypes.CATEGORY_ALARM, data); + } + + @Override + public long sessionChanged(String jcid, String sessionId, String state, String confNo) + { + Map data = new LinkedHashMap<>(); + data.put("jcid", jcid); + data.put("sessionId", sessionId); + data.put("state", state); + data.put("confNo", confNo); + return dispatch(TYPE_SESSION_CHANGED, AptRealtimeTypes.CATEGORY_CALL, data); + } + + @Override + public long currentSeq() + { + String gyid = resolveGyid(); + if (!StringUtils.hasText(gyid)) + { + return 0L; + } + AtomicLong counter = sequences.get(gyid); + return counter == null ? 0L : counter.get(); + } + + private long dispatch(String type, String category, Map data) + { + try + { + String gyid = resolveGyid(); + if (!StringUtils.hasText(gyid)) + { + // 无租户上下文不推送,避免跨公寓泄露 + return -1L; + } + DutyPushMessage message = new DutyPushMessage(); + message.setId(UUID.randomUUID().toString().replace("-", "")); + message.setType(type); + message.setCategory(category); + message.setPriority(AptRealtimeTypes.priorityOf(category)); + message.setTargetScope(SCOPE_PERMISSION); + message.setTargetPermission(DUTY_PERMISSION); + message.setGyid(gyid); + message.setOccurredAt(System.currentTimeMillis()); + message.setTraceId(AptContext.getTraceId()); + message.setData(data); + message.setSeq(nextSeq(gyid)); + + Runnable task = () -> send(message.getSeq(), message); + if (TransactionSynchronizationManager.isSynchronizationActive()) + { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() + { + @Override + public void afterCommit() + { + task.run(); + } + }); + } + else + { + task.run(); + } + return message.getSeq(); + } + catch (Exception e) + { + log.warn("值班台推送登记失败,type={}, cause={}", type, e.toString()); + return -1L; + } + } + + private void send(long seq, AptRealtimeMessage message) + { + try + { + String payload = JSON.toJSONString(message); + int sent = registry.sendToPermission(message.getGyid(), DUTY_PERMISSION, payload); + if (sent == 0) + { + log.debug("值班台推送无在线订阅者:type={}, seq={}", message.getType(), seq); + } + } + catch (Exception e) + { + // 推送失败不允许冒泡到命令调用方 + log.warn("值班台推送失败,type={}, seq={}, cause={}", message.getType(), seq, e.toString()); + } + } + + private long nextSeq(String gyid) + { + return sequences.computeIfAbsent(gyid, key -> new AtomicLong()).incrementAndGet(); + } + + /** 优先取租户上下文;调度线程没有上下文时退回部署配置(一公寓一进程 D5) */ + private String resolveGyid() + { + String gyid = AptContext.getGyIdOrNull(); + if (StringUtils.hasText(gyid)) + { + return gyid; + } + return properties.getGyId(); + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/realtime/DutyTransitionPush.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/realtime/DutyTransitionPush.java new file mode 100644 index 0000000..08f16d7 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/realtime/DutyTransitionPush.java @@ -0,0 +1,150 @@ +package com.ruoyi.apt.duty.realtime; + +import com.ruoyi.apt.dispatch.domain.AptStateSnapshot; +import com.ruoyi.apt.duty.domain.DutyStateDeriver; +import com.ruoyi.apt.duty.domain.DutyUiState; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +/** + * 状态机迁移 → 值班台推送桥(R4 / `L-11`)。 + * + *

为什么挂在状态机唯一写入口,而不是命令层:{@code gy_jcxx} 的 + * {@code ryzt/finish_jc/finish_ly/stoped} 只有 {@code AptDutyStateMapper} 能写, + * 而调用它的有四类路径——人工命令({@code AptDutyCommandServiceImpl})、 + * Quartz 叫乘扫描({@code AptDispatchScanServiceImpl})、AMI/呼叫事件({@code AptVoiceEventServiceImpl})、 + * 平板上报({@code AptTabletResultServiceImpl})。R3 只在第一类里推送,于是 + * 「定时任务自己产生的 {@code ryzt=2} 报警」在值班台只能靠 5 min 兜底轮询发现(`L-11`)。

+ * + *

挂到唯一写入口后四类路径全覆盖,且后续新增迁移点不必再记得补推送。

+ * + *

三条约束:

+ *
    + *
  1. 不猜:迁移后快照缺失({@code after == null})就不推,宁可漏一次,也不用旧态冒充新态;
  2. + *
  3. 只推变化:{@code alarm.cleared} 只在「前态有报警、后态没有」时发;前后态相同 + * (重复命令、只改 {@code finish_jc} 的 {@code FORCE_FINISH})不会再误发解除;
  4. + *
  5. 不影响业务:整段 try/catch,推送异常只记日志——迁移此时已经在同一事务里提交。
  6. + *
+ * + *

seq 回填:{@link #takeStateSeq(String)} 取本线程上一次迁移登记的 {@code state.changed} + * 序号,供命令响应的 {@code pushSeq} 使用(`04` 表 4-7)。单条命令一个线程只迁移一次, + * 取走即清;取不到返回 {@code -1}(与「未登记」同义)。

+ * + * @author apt-rev01 + */ +@Component +public class DutyTransitionPush +{ + private static final Logger log = LoggerFactory.getLogger(DutyTransitionPush.class); + + private final IDutyPushService pushService; + + /** 本线程最近一次迁移登记的 seq;只保留一条,避免线程池里堆积 */ + private final ThreadLocal lastPush = new ThreadLocal<>(); + + public DutyTransitionPush(IDutyPushService pushService) + { + this.pushService = pushService; + } + + /** + * 状态迁移完成后调用。 + * + * @param jcid 任务标识(外部可读 id,不是主键) + * @param before 迁移前快照,读不到可传 {@code null}(此时不判报警解除) + * @param after 迁移后快照;为 {@code null} 表示读不到新态,直接跳过推送 + * @param actionId 人工命令的幂等键;非命令触发(扫描任务/设备上报)传 {@code null} + */ + public void afterTransition(String jcid, AptStateSnapshot before, AptStateSnapshot after, String actionId) + { + // 先清上一次残留:本次即使失败也不允许把旧 seq 回填给命令响应 + lastPush.remove(); + if (jcid == null || after == null) + { + log.debug("迁移后快照缺失,跳过值班台推送:jcid={}", jcid); + return; + } + DutyUiState stateAfter = derive(after); + DutyUiState stateBefore = before == null ? null : derive(before); + try + { + long seq = pushService.stateChanged(jcid, ryzt(before), ryzt(after), stateAfter.name(), + stateAfter.getLabel(), actionId); + String alarmAfter = DutyStateDeriver.alarmType(stateAfter); + String alarmBefore = stateBefore == null ? null : DutyStateDeriver.alarmType(stateBefore); + if (alarmAfter != null && !alarmAfter.equals(alarmBefore)) + { + pushService.alarmRaised(jcid, alarmAfter, alarmKey(jcid, alarmAfter)); + } + else if (alarmBefore != null && !alarmBefore.equals(alarmAfter)) + { + pushService.alarmCleared(jcid, alarmBefore, alarmKey(jcid, alarmBefore)); + } + lastPush.set(new LastPush(jcid, seq)); + } + catch (Exception e) + { + // 迁移已经提交,推送失败不能反向影响业务 + log.warn("值班台迁移推送失败,迁移已生效:jcid={}, cause={}", jcid, e.toString()); + } + } + + /** + * 取走本线程该 {@code jcid} 上一次迁移的 {@code state.changed} 序号。 + * + * @return 序号;未登记/登记失败(含推送抛异常、jcid 不匹配)返回 {@code -1} + */ + public long takeStateSeq(String jcid) + { + LastPush last = lastPush.get(); + if (last == null || last.jcid == null || !last.jcid.equals(jcid)) + { + // jcid 不匹配不销毁登记:留给真正的那条命令去取(下一次迁移也会覆盖它) + return -1L; + } + lastPush.remove(); + return last.seq; + } + + private DutyUiState derive(AptStateSnapshot snapshot) + { + return DutyStateDeriver.derive(snapshot.getRyzt(), snapshot.getStoped(), snapshot.getFinishLy()); + } + + private Integer ryzt(AptStateSnapshot snapshot) + { + if (snapshot == null || snapshot.getRyzt() == null || snapshot.getRyzt().isBlank()) + { + return null; + } + try + { + return Integer.valueOf(snapshot.getRyzt().trim()); + } + catch (NumberFormatException e) + { + return null; + } + } + + /** 告警事件键:同一 jcid 同一类型稳定不变,前端据此「起/停」对应声音 */ + private String alarmKey(String jcid, String alarmType) + { + return jcid + ":" + alarmType; + } + + /** 单条迁移的登记结果 */ + private static final class LastPush + { + private final String jcid; + + private final long seq; + + private LastPush(String jcid, long seq) + { + this.jcid = jcid; + this.seq = seq; + } + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/realtime/DutyWebSocketConfig.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/realtime/DutyWebSocketConfig.java new file mode 100644 index 0000000..b0b1068 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/realtime/DutyWebSocketConfig.java @@ -0,0 +1,44 @@ +package com.ruoyi.apt.duty.realtime; + +import com.ruoyi.apt.framework.config.AptProperties; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.socket.config.annotation.WebSocketConfigurer; +import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; + +/** + * 值班台 WebSocket 注册:{@code GET /ws/duty?token=xxx}(R3)。 + * + *

不重复 {@code @EnableWebSocket}:公寓端 {@code AptRealtimeConfig} 已经开启, + * Spring 会把同一上下文里的所有 {@link WebSocketConfigurer} 合并执行。 + * 总开关沿用 {@code apt.realtime.enabled},关掉即退回轮询(前端本来就有兜底)。

+ * + * @author apt-rev01 + */ +@Configuration +@ConditionalOnProperty(prefix = "apt.realtime", name = "enabled", havingValue = "true", matchIfMissing = true) +public class DutyWebSocketConfig implements WebSocketConfigurer +{ + private final DutyWebSocketHandler handler; + + private final DutyHandshakeInterceptor interceptor; + + private final AptProperties properties; + + public DutyWebSocketConfig(DutyWebSocketHandler handler, DutyHandshakeInterceptor interceptor, + AptProperties properties) + { + this.handler = handler; + this.interceptor = interceptor; + this.properties = properties; + } + + @Override + public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) + { + registry.addHandler(handler, "/ws/duty") + .addInterceptors(interceptor) + .setAllowedOriginPatterns( + properties.getRealtime().getAllowedOrigins().toArray(new String[0])); + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/realtime/DutyWebSocketHandler.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/realtime/DutyWebSocketHandler.java new file mode 100644 index 0000000..1f8ad37 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/realtime/DutyWebSocketHandler.java @@ -0,0 +1,150 @@ +package com.ruoyi.apt.duty.realtime; + +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONObject; +import com.ruoyi.apt.framework.realtime.AptRealtimeSessionRegistry; +import com.ruoyi.apt.framework.realtime.AptRealtimeTypes; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.handler.TextWebSocketHandler; + +/** + * `/ws/duty` 推送端点(R3)。 + * + *

只做三件事:连接建立回 {@code CONNECTED}(含心跳间隔与当前 seq 水位)、 + * 心跳 {@code PING → PONG}、断开摘除会话。不接受业务命令:命令一律走 + * `POST /duty/jcxx/{jcid}/commands`,否则会绕过权限点与幂等。

+ * + * @author apt-rev01 + */ +@Component +public class DutyWebSocketHandler extends TextWebSocketHandler +{ + private static final Logger log = LoggerFactory.getLogger(DutyWebSocketHandler.class); + + /** 心跳 30 s:与 `04` 表 4-12 的 ping/pong 约定一致 */ + static final int HEARTBEAT_INTERVAL_SEC = 30; + + private static final String CONNECTED = "CONNECTED"; + + private static final String PONG = "PONG"; + + private final AptRealtimeSessionRegistry registry; + + private final IDutyPushService pushService; + + public DutyWebSocketHandler(AptRealtimeSessionRegistry registry, IDutyPushService pushService) + { + this.registry = registry; + this.pushService = pushService; + } + + @Override + public void afterConnectionEstablished(WebSocketSession session) throws Exception + { + Map attributes = session.getAttributes(); + Long userId = (Long) attributes.get("userId"); + String username = (String) attributes.get("username"); + String gyid = (String) attributes.get("gyid"); + @SuppressWarnings("unchecked") + Set permissions = (Set) attributes.get("permissions"); + if (userId == null || gyid == null) + { + session.close(CloseStatus.NOT_ACCEPTABLE); + return; + } + registry.register(session, userId, username, gyid, permissions); + + Map data = new LinkedHashMap<>(); + data.put("sessionId", session.getId()); + data.put("userId", userId); + data.put("username", username); + data.put("gyid", gyid); + data.put("heartbeatIntervalSec", HEARTBEAT_INTERVAL_SEC); + data.put("serverTime", System.currentTimeMillis()); + data.put("serverTimeText", new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date())); + data.put("lastSeq", pushService.currentSeq()); + data.put("messages", new String[] { "state.changed", "alarm.raised", "alarm.cleared", + "session.changed" }); + session.sendMessage(new TextMessage(JSON.toJSONString(envelope(CONNECTED, data)))); + log.info("值班台 WS 已连接:sessionId={}, userId={}, gyid={}", session.getId(), userId, gyid); + } + + @Override + protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception + { + String payload = message.getPayload(); + if (payload == null || payload.isEmpty()) + { + return; + } + registry.touch(session.getId()); + String type = null; + Object clientTs = null; + try + { + JSONObject json = JSON.parseObject(payload); + if (json != null) + { + type = json.getString("type"); + clientTs = json.get("ts"); + } + } + catch (Exception e) + { + if ("PING".equalsIgnoreCase(payload.trim())) + { + type = "PING"; + } + } + if ("PING".equalsIgnoreCase(type) || "PING".equalsIgnoreCase(payload.trim())) + { + Map data = new LinkedHashMap<>(); + data.put("serverTime", System.currentTimeMillis()); + data.put("lastSeq", pushService.currentSeq()); + if (clientTs != null) + { + data.put("ts", clientTs); + } + session.sendMessage(new TextMessage(JSON.toJSONString(envelope(PONG, data)))); + } + } + + @Override + public void afterConnectionClosed(WebSocketSession session, CloseStatus status) + { + registry.remove(session.getId()); + } + + @Override + public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception + { + log.warn("值班台 WS 传输异常:sessionId={}, cause={}", session.getId(), exception.toString()); + registry.remove(session.getId()); + if (session.isOpen()) + { + session.close(CloseStatus.SERVER_ERROR); + } + } + + private Map envelope(String type, Map data) + { + Map message = new LinkedHashMap<>(); + message.put("type", type); + message.put("category", AptRealtimeTypes.CATEGORY_SYSTEM); + message.put("priority", AptRealtimeTypes.PRIORITY_P2); + message.put("occurredAt", System.currentTimeMillis()); + message.put("seq", pushService.currentSeq()); + message.put("data", data); + return message; + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/realtime/IDutyPushService.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/realtime/IDutyPushService.java new file mode 100644 index 0000000..6dcdef1 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/realtime/IDutyPushService.java @@ -0,0 +1,32 @@ +package com.ruoyi.apt.duty.realtime; + +/** + * 值班台实时推送(`11` 表 11-9 R3、`04` 表 4-12)。 + * + *

三条铁律:①推送失败绝不影响命令;②事务内登记、提交后发送; + * ③方法返回 {@code seq} 供命令响应回给前端做丢重,{@code -1} 表示未登记 + * (无租户上下文或登记异常);通道发送失败只记日志,不回滚也不改返回值。

+ * + *

{@code state.changed} 直接带上 {@code uiState/stateLabel}:前端只打补丁、 + * 不再自己实现一遍派生表(`11` 表 11-2 的唯一实现仍在后端)。

+ * + * @author apt-rev01 + */ +public interface IDutyPushService +{ + /** 任务状态变化:前端只更新该行,不整表刷新 */ + long stateChanged(String jcid, Integer ryztBefore, Integer ryztAfter, String uiState, + String stateLabel, String actionId); + + /** 报警产生:入告警音状态机并把行置红 */ + long alarmRaised(String jcid, String alarmType, String alarmEventKey); + + /** 报警解除:停该行告警音 */ + long alarmCleared(String jcid, String alarmType, String alarmEventKey); + + /** 通话会话变化(会议桥占用、挂断原因) */ + long sessionChanged(String jcid, String sessionId, String state, String confNo); + + /** 当前 gyid 的推送水位;连接建立时下发给前端作为起点 */ + long currentSeq(); +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/service/DutyCommandServiceImpl.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/service/DutyCommandServiceImpl.java new file mode 100644 index 0000000..0aa1434 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/service/DutyCommandServiceImpl.java @@ -0,0 +1,279 @@ +package com.ruoyi.apt.duty.service; + +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import com.ruoyi.apt.common.AptContext; +import com.ruoyi.apt.common.AptErrorCode; +import com.ruoyi.apt.common.AptException; +import com.ruoyi.apt.dispatch.domain.AptDutyCommand; +import com.ruoyi.apt.dispatch.domain.AptDutyCommandResult; +import com.ruoyi.apt.dispatch.service.IAptDutyCommandService; +import com.ruoyi.apt.dispatch.service.IAptJcxxService; +import com.ruoyi.apt.duty.domain.DutyCommandRequest; +import com.ruoyi.apt.duty.domain.DutyCommandType; +import com.ruoyi.apt.duty.domain.DutyStateDeriver; +import com.ruoyi.apt.duty.domain.DutyUiState; +import com.ruoyi.apt.duty.mapper.DutyJcxxMapper; +import com.ruoyi.apt.duty.realtime.DutyTransitionPush; +import com.ruoyi.framework.web.service.PermissionService; +import org.springframework.stereotype.Service; + +/** + * 值班台命令实现(R2)。 + * + *

责任边界(应用端/04 表 4-6、`02` 表 2-2):

+ *
    + *
  1. 不写库:所有状态写入都走 {@link IAptDutyCommandService#execute(Long, AptDutyCommand)};
  2. + *
  3. 先解析再执行:客户端只给 {@code jcid},先在本公寓内解析出内部主键, + * 避免拿外部字符串当主键(也顺带挡住跨公寓猜测);
  4. + *
  5. 一命令一权限:{@code duty:call:stop}/{@code recall}/{@code cancel}/{@code forceFinish}/{@code markLeave}, + * 管理端只有一个 {@code apt:jcxx:changeStatus},值班台不能复用;
  6. + *
  7. 原因必填口径以既有实现为准:{@code apt.duty.state} 对 5 个命令都要求 + * {@code reason} 非空,因此一键动作(复叫/登记离寓)由服务端补默认原因, + * 而不是把"可不填"透传给状态机造成 40001。
  8. + *
+ * + * @author apt-rev01 + */ +@Service +public class DutyCommandServiceImpl implements IDutyCommandService +{ + private static final int REASON_MAX_LENGTH = 200; + + private static final String TIME_PATTERN = "yyyy-MM-dd HH:mm:ss"; + + private static final String DEFAULT_LEAVE_TYPE = "MANUAL"; + + private final DutyJcxxMapper dutyJcxxMapper; + + private final IAptDutyCommandService dutyCommandService; + + private final IAptJcxxService jcxxService; + + private final PermissionService permissionService; + + private final DutyTransitionPush dutyPush; + + public DutyCommandServiceImpl(DutyJcxxMapper dutyJcxxMapper, + IAptDutyCommandService dutyCommandService, + IAptJcxxService jcxxService, + PermissionService permissionService, + DutyTransitionPush dutyPush) + { + this.dutyJcxxMapper = dutyJcxxMapper; + this.dutyCommandService = dutyCommandService; + this.jcxxService = jcxxService; + this.permissionService = permissionService; + this.dutyPush = dutyPush; + } + + @Override + public Map execute(String jcid, DutyCommandRequest request) + { + DutyCommandType type = DutyCommandType.of(request == null ? null : request.getCommandType()); + if (type == null) + { + throw new AptException(AptErrorCode.BAD_REQUEST, "COMMAND_UNSUPPORTED", + "不支持的命令,可用:" + DutyCommandType.allCommands()); + } + if (!permissionService.hasPermi(type.getPermission())) + { + throw new AptException(AptErrorCode.FORBIDDEN_PERM, "DUTY_COMMAND_PERMISSION", + "当前账号无权执行" + type.name() + ",请联系管理员分配 " + type.getPermission()); + } + + Map before = requireRow(jcid); + Integer ryztBefore = toInteger(before.get("ryzt")); + if (request.getExpectedState() != null && !request.getExpectedState().equals(ryztBefore)) + { + throw new AptException(AptErrorCode.STATE_ILLEGAL, "EXPECTED_STATE_MISMATCH", + "该行已被他人处理,请刷新后重试"); + } + + String reason = normalizeReason(type, request.getReason()); + AptDutyCommand command = new AptDutyCommand(); + command.setCommandId(normalizeCommandId(request.getCommandId())); + command.setCommand(type.name()); + command.setReason(reason); + if (type == DutyCommandType.MARK_LEAVE) + { + command.setLeaveType(normalizeLeaveType(request.getLeaveType())); + } + + AptDutyCommandResult result = dutyCommandService.execute(toLong(before.get("id")), command); + Map after = requireRow(jcid); + + Integer ryztAfter = toInteger(after.get("ryzt")); + DutyUiState state = DutyStateDeriver.derive(str(after.get("ryzt")), str(after.get("stoped")), + str(after.get("finishLy"))); + String actionId = result == null ? null : result.getActionId(); + + // 状态与告警事件已由状态机唯一写入口统一推送(R4 / L-11): + // 命令层不再推一次,否则同一次迁移会发两条 state.changed、报警音会响两遍 + long pushSeq = dutyPush.takeStateSeq(jcid); + + Map view = new LinkedHashMap<>(); + view.put("jcid", jcid); + view.put("commandId", command.getCommandId()); + view.put("commandType", type.name()); + view.put("actionId", actionId); + view.put("state", result == null || result.isAccepted() ? "DONE" : "FAILED"); + view.put("duplicated", result != null && result.isDuplicated()); + view.put("stateAfter", result == null ? null : result.getStateAfter()); + view.put("reason", reason); + view.put("ryztBefore", ryztBefore); + view.put("ryztAfter", ryztAfter); + view.put("uiState", state.name()); + view.put("stateLabel", state.getLabel()); + view.put("pushSeq", pushSeq); + return view; + } + + @Override + public Map detail(String jcid) + { + Map row = requireRow(jcid); + Map source = jcxxService.detail(toLong(row.get("id"))); + + Map detail = new LinkedHashMap<>(); + detail.put("jcid", str(source.get("jcid"))); + detail.put("roomNo", str(source.get("fjh"))); + detail.put("personName", DutyStateDeriver.maskName(str(source.get("rymc")))); + detail.put("trainNo", str(source.get("cc"))); + detail.put("callTime", format(source.get("jcsj"))); + detail.put("departTime", format(source.get("kcsj"))); + detail.put("ryzt", str(source.get("ryzt"))); + detail.put("ryztLabel", str(source.get("ryztLabel"))); + detail.put("leaveRemark", str(source.get("leaveRemark"))); + detail.put("bizVersion", source.get("bizVersion")); + detail.put("receivedAt", format(source.get("receivedAt"))); + detail.put("session", source.get("session")); + detail.put("manualActions", limit(source.get("manualActions"), 10)); + + DutyUiState state = DutyStateDeriver.derive(str(source.get("ryzt")), str(source.get("stoped")), + str(source.get("finishLy"))); + detail.put("uiState", state.name()); + detail.put("stateLabel", state.getLabel()); + detail.put("alarmType", DutyStateDeriver.alarmType(state)); + return detail; + } + + @Override + public List> timeline(String jcid) + { + Map row = requireRow(jcid); + List> timeline = jcxxService.timeline(toLong(row.get("id"))); + return timeline == null ? new ArrayList<>() : timeline; + } + + /** 本公寓内按 jcid 取行;取不到即 40401(不区分"不存在"与"不属于本公寓",防枚举) */ + private Map requireRow(String jcid) + { + if (jcid == null || jcid.trim().isEmpty()) + { + throw new AptException(AptErrorCode.BAD_REQUEST, "JCID_REQUIRED", "缺少任务标识 jcid"); + } + Map row = dutyJcxxMapper.selectDutyRefByJcid(AptContext.getGyId(), jcid.trim()); + if (row == null || row.isEmpty()) + { + throw new AptException(AptErrorCode.NOT_FOUND, "JCXX_NOT_FOUND", "任务不存在或不属于本公寓"); + } + return row; + } + + private String normalizeReason(DutyCommandType type, String raw) + { + String reason = raw == null ? null : raw.trim(); + if (reason == null || reason.isEmpty()) + { + if (type.isReasonRequired()) + { + throw new AptException(AptErrorCode.BAD_REQUEST, "REASON_REQUIRED", + type.name() + " 必须填写原因(至少 2 个字)"); + } + return type.getDefaultReason(); + } + if (reason.length() < 2) + { + throw new AptException(AptErrorCode.BAD_REQUEST, "REASON_TOO_SHORT", "原因至少 2 个字"); + } + if (reason.length() > REASON_MAX_LENGTH) + { + throw new AptException(AptErrorCode.BAD_REQUEST, "REASON_TOO_LONG", + "原因不超过 " + REASON_MAX_LENGTH + " 个字"); + } + return reason; + } + + /** 前端未给幂等键时兜底生成:宁可偶发重复留痕,也不要 NPE */ + private String normalizeCommandId(String raw) + { + if (raw == null || raw.trim().isEmpty()) + { + return "DUTY-" + UUID.randomUUID().toString().replace("-", ""); + } + return raw.trim(); + } + + private String normalizeLeaveType(String raw) + { + if (raw == null || raw.trim().isEmpty()) + { + return DEFAULT_LEAVE_TYPE; + } + String value = raw.trim().toUpperCase(java.util.Locale.ROOT); + return "MANUAL".equals(value) ? value : DEFAULT_LEAVE_TYPE; + } + + private Object limit(Object value, int max) + { + if (!(value instanceof List)) + { + return value; + } + List list = (List) value; + return list.size() <= max ? list : new ArrayList<>(list.subList(0, max)); + } + + private String format(Object value) + { + if (!(value instanceof Date)) + { + return value == null ? null : String.valueOf(value); + } + return new SimpleDateFormat(TIME_PATTERN).format((Date) value); + } + + private String str(Object value) + { + return value == null ? null : String.valueOf(value); + } + + private Integer toInteger(Object value) + { + if (value == null) + { + return null; + } + if (value instanceof Number) + { + return ((Number) value).intValue(); + } + String text = String.valueOf(value).trim(); + return text.isEmpty() ? null : Integer.valueOf(text); + } + + private Long toLong(Object value) + { + if (value instanceof Number) + { + return ((Number) value).longValue(); + } + return Long.valueOf(String.valueOf(value)); + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/service/DutyJcxxQueryServiceImpl.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/service/DutyJcxxQueryServiceImpl.java new file mode 100644 index 0000000..d0aebc6 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/service/DutyJcxxQueryServiceImpl.java @@ -0,0 +1,150 @@ +package com.ruoyi.apt.duty.service; + +import java.math.BigDecimal; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import com.ruoyi.apt.common.AptContext; +import com.ruoyi.apt.duty.domain.DutyQuery; +import com.ruoyi.apt.duty.domain.DutyTaskView; +import com.ruoyi.apt.duty.mapper.DutyJcxxMapper; +import org.springframework.stereotype.Service; + +/** + * 值班台只读查询实现(R1)。 + * + *

三个口径决定:

+ *
    + *
  1. 时间窗固定当日([今日 00:00, 明日 00:00))。无 {@code jcsj} 的行始终保留, + * 因为"已入寓但还没排任务"的人必须在值班台可见;
  2. + *
  3. 关键字在内存过滤:需要同时匹配房间号/姓名/车次(OR), + * 而管理端 Mapper 是三条独立 AND 条件;当日数据量有限,内存过滤更安全;
  4. + *
  5. 姓名脱敏在服务层:SQL 出原始 {@code xm},脱敏只在 {@link DutyTaskView#fromMap} 做一次, + * 保证"关键字能搜到全名、返回体只有脱敏名"。
  6. + *
+ * + * @author apt-rev01 + */ +@Service +public class DutyJcxxQueryServiceImpl implements IDutyJcxxQueryService +{ + private static final String TIME_PATTERN = "yyyy-MM-dd HH:mm:ss"; + + private final DutyJcxxMapper dutyJcxxMapper; + + public DutyJcxxQueryServiceImpl(DutyJcxxMapper dutyJcxxMapper) + { + this.dutyJcxxMapper = dutyJcxxMapper; + } + + @Override + public List list(DutyQuery query, Date now) + { + DutyQuery effective = query == null ? new DutyQuery() : query; + Date base = now == null ? new Date() : now; + Date from = dayStart(base); + Date to = dayEnd(base); + + List> rows = dutyJcxxMapper.selectDutyTaskList( + AptContext.getGyId(), effective.getScope(), from, to, base); + if (rows == null || rows.isEmpty()) + { + return new ArrayList<>(); + } + + String keyword = effective.getKeyword(); + List views = new ArrayList<>(rows.size()); + for (Map row : rows) + { + if (!matchesKeyword(row, keyword)) + { + continue; + } + views.add(DutyTaskView.fromMap(row, base)); + } + return views; + } + + @Override + public Map counts(Date now) + { + Date base = now == null ? new Date() : now; + Map raw = dutyJcxxMapper.selectDutyCounts( + AptContext.getGyId(), dayStart(base), dayEnd(base), base); + + Map data = new LinkedHashMap<>(); + data.put("todoCount", toLong(raw, "todoCount")); + data.put("callCount", toLong(raw, "callCount")); + data.put("alarmCount", toLong(raw, "alarmCount")); + data.put("needCount", toLong(raw, "needCount")); + data.put("doneCount", toLong(raw, "doneCount")); + data.put("urgentCount", toLong(raw, "urgentCount")); + data.put("total", toLong(raw, "total")); + data.put("serverTime", new SimpleDateFormat(TIME_PATTERN).format(base)); + return data; + } + + /** 房间号 / 姓名 / 车次任一命中即保留;空关键字视为全命中 */ + private boolean matchesKeyword(Map row, String keyword) + { + if (keyword == null || keyword.isEmpty()) + { + return true; + } + String needle = keyword.toLowerCase(Locale.ROOT); + return contains(row.get("roomNo"), needle) + || contains(row.get("personName"), needle) + || contains(row.get("trainNo"), needle); + } + + private boolean contains(Object value, String needle) + { + return value != null && String.valueOf(value).toLowerCase(Locale.ROOT).contains(needle); + } + + private long toLong(Map raw, String key) + { + if (raw == null) + { + return 0L; + } + Object value = raw.get(key); + if (value == null) + { + return 0L; + } + if (value instanceof BigDecimal) + { + return ((BigDecimal) value).longValue(); + } + if (value instanceof Number) + { + return ((Number) value).longValue(); + } + return Long.parseLong(String.valueOf(value)); + } + + private Date dayStart(Date base) + { + Calendar calendar = Calendar.getInstance(); + calendar.setTime(base); + calendar.set(Calendar.HOUR_OF_DAY, 0); + calendar.set(Calendar.MINUTE, 0); + calendar.set(Calendar.SECOND, 0); + calendar.set(Calendar.MILLISECOND, 0); + return calendar.getTime(); + } + + private Date dayEnd(Date base) + { + Calendar calendar = Calendar.getInstance(); + calendar.setTime(dayStart(base)); + calendar.add(Calendar.DAY_OF_MONTH, 1); + return calendar.getTime(); + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/service/IDutyCommandService.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/service/IDutyCommandService.java new file mode 100644 index 0000000..4723c49 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/service/IDutyCommandService.java @@ -0,0 +1,26 @@ +package com.ruoyi.apt.duty.service; + +import java.util.List; +import java.util.Map; +import com.ruoyi.apt.duty.domain.DutyCommandRequest; + +/** + * 值班台命令与详情(`11` 表 11-8 `T-BE-03`/`T-BE-04`,R2)。 + * + *

本层只做三件事:jcid → 主键、权限与参数校验、结果整形。 + * 真正的状态机、幂等、审计仍然由 {@code apt.duty.state}(`IAptDutyCommandService`)执行, + * 值班台不得自己写 {@code gy_jcxx}。

+ * + * @author apt-rev01 + */ +public interface IDutyCommandService +{ + /** 执行一个状态命令;返回值含 {@code ryztBefore/ryztAfter/uiState},供前端只更新该行 */ + Map execute(String jcid, DutyCommandRequest request); + + /** 值班台详情:叫乘行 + 会话 + 最近人工动作(不含录音/刷脸照片路径) */ + Map detail(String jcid); + + /** 时间线:叫乘关键节点,按时间倒序 */ + List> timeline(String jcid); +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/service/IDutyJcxxQueryService.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/service/IDutyJcxxQueryService.java new file mode 100644 index 0000000..bbfe129 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/service/IDutyJcxxQueryService.java @@ -0,0 +1,27 @@ +package com.ruoyi.apt.duty.service; + +import java.util.Date; +import java.util.List; +import java.util.Map; +import com.ruoyi.apt.duty.domain.DutyQuery; +import com.ruoyi.apt.duty.domain.DutyTaskView; + +/** + * 值班台只读查询(应用端/02 图 2-1 的 view 包)。 + * + *

只有两个方法,且都无副作用:列表与统计。R1 不提供详情与时间线, + * 那是 `11` 的 `T-BE-03`,等抽屉出稿再补。

+ * + * @author apt-rev01 + */ +public interface IDutyJcxxQueryService +{ + /** + * @param query 口径与关键字(时间窗由实现固定为"当日",客户端不可传) + * @param now 服务端当前时间(显式传入,便于单测固定时钟) + */ + List list(DutyQuery query, Date now); + + /** 吸底统计 + 紧急条数 + serverTime(前端据此判断时钟偏差) */ + Map counts(Date now); +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/test/controller/DutyTestController.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/test/controller/DutyTestController.java new file mode 100644 index 0000000..3360b4b --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/test/controller/DutyTestController.java @@ -0,0 +1,138 @@ +package com.ruoyi.apt.duty.test.controller; + +import java.util.Map; +import com.ruoyi.apt.duty.test.domain.DutySipBindCommand; +import com.ruoyi.apt.duty.test.domain.DutySipServerCommand; +import com.ruoyi.apt.duty.test.domain.DutyTestCheckinCommand; +import com.ruoyi.apt.duty.test.domain.DutyTestRoomCommand; +import com.ruoyi.apt.duty.test.service.DutyTestService; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.utils.SecurityUtils; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 值班台测试配置(R5 / 应用端/17)。 + * + *

四组能力:房间信息、房间↔分机绑定、SIP 服务器(AMI)配置、测试办理入住。 + * 所有写操作都受 {@code apt.debug.enabled} 二次保护(关闭时返回 40940), + * 只读的 {@code /status} 始终可用,页面据此提示如何开启。

+ * + *

权限点比接口更细:查看({@code duty:config:read})与三类写操作分开, + * 便于把「能看配置」与「能改配置」发给不同角色。

+ * + * @author apt-rev01 + */ +@RestController +@RequestMapping(value = "/duty/test", produces = "application/json;charset=UTF-8") +public class DutyTestController +{ + private final DutyTestService dutyTestService; + + public DutyTestController(DutyTestService dutyTestService) + { + this.dutyTestService = dutyTestService; + } + + /** 面板状态:是否启用、AMI 配置、房间/人员概况(只读,不要求 debug 开关) */ + @PreAuthorize("@ss.hasPermi('duty:config:read')") + @GetMapping("/status") + public AjaxResult status() + { + return AjaxResult.success(dutyTestService.status()); + } + + // ---------------------------------------------------------------- 房间 + + @PreAuthorize("@ss.hasPermi('duty:config:read')") + @GetMapping("/rooms") + public AjaxResult rooms() + { + return AjaxResult.success(dutyTestService.rooms()); + } + + /** 录入测试房间(补平台基线 + 登记本端房间 + 建 SIP 分机) */ + @PreAuthorize("@ss.hasPermi('duty:config:room')") + @PostMapping("/rooms") + public AjaxResult createRoom(@Valid @RequestBody DutyTestRoomCommand command) + { + return AjaxResult.success(dutyTestService.createRoom(command)); + } + + // ------------------------------------------------------------ SIP 绑定 + + @PreAuthorize("@ss.hasPermi('duty:config:read')") + @GetMapping("/sip/extensions") + public AjaxResult extensions() + { + return AjaxResult.success(dutyTestService.extensions()); + } + + /** 绑定分机号(分机号创建后不可改,换号需先解绑) */ + @PreAuthorize("@ss.hasPermi('duty:config:sip')") + @PostMapping("/sip/bind") + public AjaxResult bindSip(@Valid @RequestBody DutySipBindCommand command) + { + return AjaxResult.success(dutyTestService.bindSip(command)); + } + + @PreAuthorize("@ss.hasPermi('duty:config:sip')") + @DeleteMapping("/sip/bind/{roomNo}") + public AjaxResult unbindSip(@PathVariable("roomNo") String roomNo) + { + return AjaxResult.success(dutyTestService.unbindSip(roomNo)); + } + + // ---------------------------------------------------------- SIP 服务器 + + /** 读配置放宽到 read:房间页要显示号段,写与探测才需要 sipServer 权限 */ + @PreAuthorize("@ss.hasPermi('duty:config:read')") + @GetMapping("/sip/server") + public AjaxResult sipServer() + { + return AjaxResult.success(dutyTestService.sipServer()); + } + + /** 保存 AMI 配置:口令只接受环境变量名,明文会被表单校验挡掉 */ + @PreAuthorize("@ss.hasPermi('duty:config:sipServer')") + @PutMapping("/sip/server") + public AjaxResult saveSipServer(@Valid @RequestBody DutySipServerCommand command) + { + return AjaxResult.success(dutyTestService.saveSipServer(command)); + } + + /** 连通性探测:TCP 端口 + 口令是否已注入(不做 AMI 登录握手) */ + @PreAuthorize("@ss.hasPermi('duty:config:sipServer')") + @PostMapping("/sip/server/probe") + public AjaxResult probeSipServer() + { + return AjaxResult.success(dutyTestService.testSipServer()); + } + + // ---------------------------------------------------------------- 入住 + + @PreAuthorize("@ss.hasPermi('duty:checkin:test')") + @GetMapping("/checkin/candidates") + public AjaxResult checkinCandidates() + { + return AjaxResult.success(dutyTestService.checkinCandidates()); + } + + /** 测试办理入住:apply + 自动通过(真实服务链路,不绕校验) */ + @PreAuthorize("@ss.hasPermi('duty:checkin:test')") + @PostMapping("/checkin") + public AjaxResult checkin(@Valid @RequestBody DutyTestCheckinCommand command) + { + String operator = SecurityUtils.getUsername(); + Map data = dutyTestService.registerCheckin(command, operator); + return AjaxResult.success(data); + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/test/domain/DutySipBindCommand.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/test/domain/DutySipBindCommand.java new file mode 100644 index 0000000..b6a7172 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/test/domain/DutySipBindCommand.java @@ -0,0 +1,58 @@ +package com.ruoyi.apt.duty.test.domain; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +/** + * 房间 ↔ SIP 分机绑定命令(R5 / 应用端/17)。 + * + *

分机号创建后不可改({@code 40906}):换号必须先解绑再绑定, + * 解绑的分机号不会被回收(号码不复用),界面上要如实提示。

+ * + * @author apt-rev01 + */ +public class DutySipBindCommand +{ + @NotBlank(message = "房号不能为空") + @Size(max = 32, message = "房号长度不能超过 32") + private String roomNo; + + @NotBlank(message = "分机号不能为空") + @Pattern(regexp = "^[0-9]{3,8}$", message = "分机号只能是 3~8 位数字") + private String extension; + + /** 话机展示名,空则沿用房间展示名 */ + @Size(max = 64, message = "展示名长度不能超过 64") + private String displayName; + + public String getRoomNo() + { + return roomNo; + } + + public void setRoomNo(String roomNo) + { + this.roomNo = roomNo; + } + + public String getExtension() + { + return extension; + } + + public void setExtension(String extension) + { + this.extension = extension; + } + + public String getDisplayName() + { + return displayName; + } + + public void setDisplayName(String displayName) + { + this.displayName = displayName; + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/test/domain/DutySipServerCommand.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/test/domain/DutySipServerCommand.java new file mode 100644 index 0000000..c8394c7 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/test/domain/DutySipServerCommand.java @@ -0,0 +1,120 @@ +package com.ruoyi.apt.duty.test.domain; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +/** + * SIP 服务器(AMI)配置命令(R5 / 应用端/17)。 + * + *

口令不落库(`21_ami_instance.sql` 的硬约束):{@code amiSecretRef} 只能是 + * 环境变量名,值由部署环境注入。字段用大写字母/数字/下划线约束, + * 直接粘贴明文口令(含小写字母或符号)会被表单挡掉并给出提示。

+ * + * @author apt-rev01 + */ +public class DutySipServerCommand +{ + @NotBlank(message = "AMI 地址不能为空") + @Pattern(regexp = "^[0-9A-Za-z.-]{1,64}$", message = "AMI 地址只能是 IP 或主机名") + private String amiHost; + + @NotNull(message = "AMI 端口不能为空") + @Min(value = 1, message = "端口范围 1~65535") + @Max(value = 65535, message = "端口范围 1~65535") + private Integer amiPort; + + @NotBlank(message = "AMI 用户名不能为空") + @Size(max = 64, message = "AMI 用户名长度不能超过 64") + private String amiUser; + + /** 口令所在环境变量名,例如 APT_AMI_SECRET(不存口令本身) */ + @NotBlank(message = "口令引用(环境变量名)不能为空") + @Pattern(regexp = "^[A-Z][A-Z0-9_]{2,63}$", + message = "口令引用必须是大写字母/数字/下划线组成的环境变量名,口令本身不落库") + private String amiSecretRef; + + /** 可用分机号段起,空=不限制 */ + @Pattern(regexp = "^$|^[0-9]{3,8}$", message = "号段起只能是 3~8 位数字") + private String extPoolFrom; + + /** 可用分机号段止,空=不限制 */ + @Pattern(regexp = "^$|^[0-9]{3,8}$", message = "号段止只能是 3~8 位数字") + private String extPoolTo; + + /** 是否启用 AMI 连接(仅本地测试可关) */ + private Boolean enabled = Boolean.TRUE; + + public String getAmiHost() + { + return amiHost; + } + + public void setAmiHost(String amiHost) + { + this.amiHost = amiHost; + } + + public Integer getAmiPort() + { + return amiPort; + } + + public void setAmiPort(Integer amiPort) + { + this.amiPort = amiPort; + } + + public String getAmiUser() + { + return amiUser; + } + + public void setAmiUser(String amiUser) + { + this.amiUser = amiUser; + } + + public String getAmiSecretRef() + { + return amiSecretRef; + } + + public void setAmiSecretRef(String amiSecretRef) + { + this.amiSecretRef = amiSecretRef; + } + + public String getExtPoolFrom() + { + return extPoolFrom; + } + + public void setExtPoolFrom(String extPoolFrom) + { + this.extPoolFrom = extPoolFrom; + } + + public String getExtPoolTo() + { + return extPoolTo; + } + + public void setExtPoolTo(String extPoolTo) + { + this.extPoolTo = extPoolTo; + } + + public Boolean getEnabled() + { + return enabled; + } + + public void setEnabled(Boolean enabled) + { + this.enabled = enabled; + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/test/domain/DutyTestCheckinCommand.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/test/domain/DutyTestCheckinCommand.java new file mode 100644 index 0000000..cfe2501 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/test/domain/DutyTestCheckinCommand.java @@ -0,0 +1,121 @@ +package com.ruoyi.apt.duty.test.domain; + +import java.util.Date; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +/** + * 测试办理入住命令(R5 / 应用端/17)。 + * + *

不是 N-40 的旁路:服务端仍然走 {@code IAptCheckinService.apply + review}, + * 人脸注册确认、房间可住、同一人不得重复在住等校验一条不减。 + * 这里只是把「设备编码 + 核验时刻 + 幂等键」由服务端补出,让值班员能手工造一条入住。

+ * + * @author apt-rev01 + */ +public class DutyTestCheckinCommand +{ + /** 系统内人员(apt_master_person.ryid) */ + @NotBlank(message = "人员不能为空") + @Size(max = 64, message = "ryid 长度不能超过 64") + private String ryid; + + /** 房号(必须已登记且可住) */ + @NotBlank(message = "房号不能为空") + @Size(max = 32, message = "房号长度不能超过 32") + private String fjh; + + /** 交路 id,可空 */ + @Size(max = 64, message = "jlid 长度不能超过 64") + private String jlid; + + /** 车次,可空 */ + @Size(max = 64, message = "车次长度不能超过 64") + private String cc; + + /** 机次,可空 */ + @Size(max = 32, message = "机次长度不能超过 32") + private String jch; + + /** + * 叫乘时刻;填了就写入 gy_jcxx,让到点扫描能立刻跑通整条链路。 + * + *

线上该值由 #7 匹配结论/叫乘规则算出,测试时允许手工指定—— + * 只写 jcsj/jcfs 两列,不碰任何状态列,不属于绕过状态机。

+ */ + private Date jcsj; + + /** 叫乘方式 SIP / TABLET,空则由房间 SIP 绑定情况推导 */ + @Size(max = 16, message = "叫乘方式长度不能超过 16") + private String jcfs; + + public String getRyid() + { + return ryid; + } + + public void setRyid(String ryid) + { + this.ryid = ryid; + } + + public String getFjh() + { + return fjh; + } + + public void setFjh(String fjh) + { + this.fjh = fjh; + } + + public String getJlid() + { + return jlid; + } + + public void setJlid(String jlid) + { + this.jlid = jlid; + } + + public String getCc() + { + return cc; + } + + public void setCc(String cc) + { + this.cc = cc; + } + + public String getJch() + { + return jch; + } + + public void setJch(String jch) + { + this.jch = jch; + } + + public Date getJcsj() + { + return jcsj; + } + + public void setJcsj(Date jcsj) + { + this.jcsj = jcsj; + } + + public String getJcfs() + { + return jcfs; + } + + public void setJcfs(String jcfs) + { + this.jcfs = jcfs; + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/test/domain/DutyTestRoomCommand.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/test/domain/DutyTestRoomCommand.java new file mode 100644 index 0000000..1ee1e36 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/test/domain/DutyTestRoomCommand.java @@ -0,0 +1,119 @@ +package com.ruoyi.apt.duty.test.domain; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +/** + * 测试房间录入命令(R5 / 应用端/17)。 + * + *

只服务本地联调:先在平台基线 {@code apt_master_room} 补一条 ACTIVE 房间, + * 再走正式的服务链路 {@code IAptRoomService.create} 落 {@code gy_rooms} 与 SIP 三件套。 + * 生产环境该接口被 {@code apt.debug.enabled} 关闭(返回 40940)。

+ * + * @author apt-rev01 + */ +public class DutyTestRoomCommand +{ + /** 房号,唯一;3~32 位数字/字母/短横线 */ + @NotBlank(message = "房号不能为空") + @Pattern(regexp = "^[0-9A-Za-z-]{1,32}$", message = "房号只能包含数字、字母与短横线,最长 32 位") + private String roomNo; + + /** 展示名(同时作为 SIP 分机 display_name),空则用「房间 + 房号」 */ + @Size(max = 64, message = "展示名长度不能超过 64") + private String displayName; + + /** 楼栋 */ + @Size(max = 16, message = "楼栋长度不能超过 16") + private String building; + + /** 楼层 */ + @Size(max = 16, message = "楼层长度不能超过 16") + private String roomFloor; + + /** 房型(单人/双人…,仅展示) */ + @Size(max = 16, message = "房型长度不能超过 16") + private String roomType; + + /** 床位数:入住可用名额的上限,1~20 */ + @Min(value = 1, message = "床位数至少 1") + @Max(value = 20, message = "床位数最多 20") + private Integer bedCount = 1; + + /** 分机号,可空(空则按房号派生);必须在号段内且未被占用 */ + @Pattern(regexp = "^$|^[0-9]{3,8}$", message = "分机号只能是 3~8 位数字") + private String extension; + + public String getRoomNo() + { + return roomNo; + } + + public void setRoomNo(String roomNo) + { + this.roomNo = roomNo; + } + + public String getDisplayName() + { + return displayName; + } + + public void setDisplayName(String displayName) + { + this.displayName = displayName; + } + + public String getBuilding() + { + return building; + } + + public void setBuilding(String building) + { + this.building = building; + } + + public String getRoomFloor() + { + return roomFloor; + } + + public void setRoomFloor(String roomFloor) + { + this.roomFloor = roomFloor; + } + + public String getRoomType() + { + return roomType; + } + + public void setRoomType(String roomType) + { + this.roomType = roomType; + } + + public Integer getBedCount() + { + return bedCount; + } + + public void setBedCount(Integer bedCount) + { + this.bedCount = bedCount; + } + + public String getExtension() + { + return extension; + } + + public void setExtension(String extension) + { + this.extension = extension; + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/test/mapper/DutyTestMapper.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/test/mapper/DutyTestMapper.java new file mode 100644 index 0000000..e5e986d --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/test/mapper/DutyTestMapper.java @@ -0,0 +1,55 @@ +package com.ruoyi.apt.duty.test.mapper; + +import java.util.List; +import java.util.Map; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +/** + * 值班台测试配置 Mapper(R5 / 应用端/17)。 + * + *

只读写三类配置面:AMI 实例(`apt_ast_instance`)、房间基线(`apt_master_room` + `gy_rooms`)、 + * SIP 分机(`apt_sip_endpoint` / `apt_sip_binding` / `apt_sip_auth`)。 + * 业务状态(`gy_jcxx.ryzt` 等)一律不在这里写,状态机仍只有 `AptDutyStateMapper`。

+ * + *

所有语句都带 gyid(`AptMapperGyidArchTest` 强制),不接受跨公寓参数。

+ * + * @author apt-rev01 + */ +@Mapper +public interface DutyTestMapper +{ + /** AMI 实例(一公寓一条;口令只回引用名) */ + Map selectSipServer(@Param("gyid") String gyid); + + /** 更新 AMI 配置;改了地址/端口则状态复位为 DISABLED(需重连才恢复 UP) */ + int updateSipServer(@Param("gyid") String gyid, @Param("amiHost") String amiHost, + @Param("amiPort") Integer amiPort, @Param("amiUser") String amiUser, + @Param("amiSecretRef") String amiSecretRef, + @Param("extPoolFrom") String extPoolFrom, @Param("extPoolTo") String extPoolTo, + @Param("enabled") boolean enabled); + + /** 房间看板:平台基线 + 本端登记 + 当前分机 + 在住人数 */ + List> selectRoomBoard(@Param("gyid") String gyid); + + /** 按房号取平台基线房间 */ + Map selectMasterRoomByNo(@Param("gyid") String gyid, @Param("roomNo") String roomNo); + + /** 补一条测试用平台房间基线(仅本地联调) */ + int insertMasterRoom(@Param("gyid") String gyid, @Param("roomId") String roomId, + @Param("roomNo") String roomNo, @Param("building") String building, + @Param("roomFloor") String roomFloor, @Param("roomType") String roomType, + @Param("bedCount") int bedCount); + + /** 是否已登记到本端 gy_rooms */ + int countLocalRoomByNo(@Param("gyid") String gyid, @Param("roomNo") String roomNo); + + /** 分机占用看板(含未绑定房间的孤儿分机) */ + List> selectExtensionUsage(@Param("gyid") String gyid); + + /** 分机号是否已被占用(含软删前的历史占用,号码不复用) */ + int countExtensionInUse(@Param("gyid") String gyid, @Param("extension") String extension); + + /** 可办理入住的人员:系统内人员 + 人脸注册确认态 + 是否在住 */ + List> selectCheckinCandidates(@Param("gyid") String gyid); +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/test/service/DutyTestService.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/test/service/DutyTestService.java new file mode 100644 index 0000000..2f0287e --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/test/service/DutyTestService.java @@ -0,0 +1,695 @@ +package com.ruoyi.apt.duty.test.service; + +import java.net.InetSocketAddress; +import java.net.Socket; +import java.util.ArrayList; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import com.ruoyi.apt.common.AptContext; +import com.ruoyi.apt.common.AptErrorCode; +import com.ruoyi.apt.common.AptException; +import com.ruoyi.apt.duty.test.domain.DutySipBindCommand; +import com.ruoyi.apt.duty.test.domain.DutySipServerCommand; +import com.ruoyi.apt.duty.test.domain.DutyTestCheckinCommand; +import com.ruoyi.apt.duty.test.domain.DutyTestRoomCommand; +import com.ruoyi.apt.duty.test.mapper.DutyTestMapper; +import com.ruoyi.apt.stay.checkin.domain.AptCheckinApplication; +import com.ruoyi.apt.stay.checkin.domain.AptCheckinApplyCommand; +import com.ruoyi.apt.stay.checkin.domain.AptCheckinApplyResult; +import com.ruoyi.apt.stay.checkin.domain.AptCheckinReviewCommand; +import com.ruoyi.apt.stay.checkin.domain.AptCheckinReviewResult; +import com.ruoyi.apt.stay.checkin.mapper.AptCheckinMapper; +import com.ruoyi.apt.stay.checkin.service.IAptCheckinService; +import com.ruoyi.apt.stay.domain.AptRoomCreateCommand; +import com.ruoyi.apt.stay.service.IAptRoomService; +import com.ruoyi.apt.voice.service.IAptSipEndpointService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 值班台测试配置服务(R5 / 应用端/17)。 + * + *

定位:让值班员在没有平板、没有平台推数据的环境里,把「房间 → 分机 → 入住 → 叫乘」 + * 这条链路自己铺出来。写作范围严格限制在两处:

+ *
    + *
  • 配置面:{@code apt_ast_instance} / {@code apt_master_room} / {@code gy_rooms} / SIP 三张表;
  • + *
  • 入住:不绕服务层,仍调用 {@code IAptCheckinService.apply + review}, + * 人脸注册确认、房间可住、同一人不得重复在住等校验一条不减。
  • + *
+ * + *

总开关:{@code apt.debug.enabled}(默认 false)。关闭时所有写操作返回 40940, + * 只读的 {@link #status()} 仍可用——页面据此提示「如何开启」,而不是给一个 404。

+ * + *

口令纪律:AMI 口令只走环境变量引用,不落库、不回显;SIP 分机口令由 + * {@code IAptSipEndpointService} 按既有 {@code secret_mode} 规则下发。

+ * + * @author apt-rev01 + */ +@Service +public class DutyTestService +{ + private static final Logger log = LoggerFactory.getLogger(DutyTestService.class); + + /** 测试入住使用的设备编码:与真实平板编码区分,便于审计排查 */ + private static final String TEST_DEVICE_CODE = "DUTY-TEST"; + + private static final int PROBE_TIMEOUT_MS = 2000; + + private final DutyTestMapper testMapper; + + private final IAptRoomService roomService; + + private final IAptSipEndpointService sipService; + + private final IAptCheckinService checkinService; + + private final AptCheckinMapper checkinMapper; + + @Value("${apt.debug.enabled:false}") + private boolean debugEnabled; + + @Value("${apt.voice.enabled:false}") + private boolean voiceEnabled; + + public DutyTestService(DutyTestMapper testMapper, IAptRoomService roomService, + IAptSipEndpointService sipService, IAptCheckinService checkinService, + AptCheckinMapper checkinMapper) + { + this.testMapper = testMapper; + this.roomService = roomService; + this.sipService = sipService; + this.checkinService = checkinService; + this.checkinMapper = checkinMapper; + } + + // ------------------------------------------------------------------ 状态 + + /** + * 面板状态:是否可用、当前配置、房间/分机概况。 + * + *

只读,不要求 {@code apt.debug.enabled}——关闭时页面要能给出开启方法。

+ */ + public Map status() + { + String gyid = gyid(); + Map data = new LinkedHashMap<>(); + data.put("debugEnabled", debugEnabled); + data.put("voiceEnabled", voiceEnabled); + data.put("gyid", gyid); + + Map server = sipServer(); + data.put("sipServer", server); + + List> rooms = testMapper.selectRoomBoard(gyid); + int bound = 0; + int registered = 0; + for (Map row : rooms) + { + if (row.get("extension") != null) + { + bound++; + } + if (row.get("localId") != null) + { + registered++; + } + } + data.put("roomCount", rooms.size()); + data.put("registeredRoomCount", registered); + data.put("boundRoomCount", bound); + data.put("personCount", testMapper.selectCheckinCandidates(gyid).size()); + return data; + } + + // ------------------------------------------------------------------ 房间 + + /** 房间看板:平台基线 + 本端登记 + 分机 + 在住人数 */ + public List> rooms() + { + return testMapper.selectRoomBoard(gyid()); + } + + /** + * 录入测试房间:先补平台基线({@code apt_master_room}),再走正式的服务链路登记本端房间。 + * + *

已登记的房间重复提交是幂等的:只同步展示名,不会重建房间或改分机号。

+ */ + public Map createRoom(DutyTestRoomCommand command) + { + requireDebug(); + String gyid = gyid(); + String roomNo = command.getRoomNo().trim(); + int bedCount = command.getBedCount() == null ? 1 : command.getBedCount(); + // 留空则从号段里取最小空闲号:按房号派生会得到「房间 201 → 分机 201」,落在号段外 + String extension = trimToNull(command.getExtension()); + if (extension == null) + { + extension = nextFreeExtension(); + } + + Map master = testMapper.selectMasterRoomByNo(gyid, roomNo); + boolean baselineCreated = false; + if (master == null) + { + // 分机号先校验再落库,避免「房间建好了但号不能用」的半成品 + validateExtension(extension, roomNo); + String roomId = "TEST-" + roomNo; + testMapper.insertMasterRoom(gyid, roomId, roomNo, trimToNull(command.getBuilding()), + trimToNull(command.getRoomFloor()), trimToNull(command.getRoomType()), bedCount); + master = testMapper.selectMasterRoomByNo(gyid, roomNo); + baselineCreated = true; + } + + Map data = new LinkedHashMap<>(); + data.put("roomNo", roomNo); + data.put("roomId", master.get("roomId")); + data.put("baselineCreated", baselineCreated); + + boolean localRegistered = testMapper.countLocalRoomByNo(gyid, roomNo) > 0; + if (!localRegistered) + { + validateExtension(extension, roomNo); + AptRoomCreateCommand create = new AptRoomCreateCommand(); + create.setRoomNo(roomNo); + create.setDisplayName(trimToNull(command.getDisplayName())); + create.setExtension(extension); + roomService.create(create); + } + else if (trimToNull(command.getDisplayName()) != null) + { + // 分机号不可改:只同步展示名与启用态 + sipService.updateForRoom(String.valueOf(master.get("roomId")), + command.getDisplayName().trim(), true); + } + data.put("localRegistered", true); + data.put("reused", localRegistered); + if (!localRegistered) + { + data.put("message", "房间已登记并创建 SIP 分机 " + (extension == null ? "" : extension)); + } + else if (trimToNull(command.getDisplayName()) != null) + { + data.put("message", "房间已登记,已同步展示名"); + } + else + { + data.put("message", "房间已登记,本次未做变更"); + } + return data; + } + + /** 分机占用看板(含未绑定房间的孤儿分机,便于排查历史遗留号码) */ + public List> extensions() + { + return testMapper.selectExtensionUsage(gyid()); + } + + /** + * 房间 ↔ 分机绑定。 + * + *

分机号创建后不可改(40906):请求号与现号不一致时直接报错并提示先解绑, + * 而不是悄悄忽略——值班员改号改不动却以为改成功了,是最难查的一类问题。

+ */ + public Map bindSip(DutySipBindCommand command) + { + requireDebug(); + String gyid = gyid(); + String roomNo = command.getRoomNo().trim(); + String extension = command.getExtension().trim(); + + Map current = findRoom(gyid, roomNo); + if (current == null) + { + throw new AptException(AptErrorCode.NOT_FOUND, "ROOM_NOT_FOUND", + "房号 " + roomNo + " 不存在,请先在「房间信息」录入"); + } + if (current.get("localId") == null) + { + throw new AptException(AptErrorCode.STATE_ILLEGAL, "ROOM_NOT_REGISTERED", + "房号 " + roomNo + " 尚未登记到本端,请先录入房间"); + } + Object existing = current.get("extension"); + if (existing != null && !extension.equals(String.valueOf(existing))) + { + throw new AptException(AptErrorCode.EXTENSION_IMMUTABLE, "EXTENSION_IMMUTABLE", + "房间 " + roomNo + " 已有分机 " + existing + ",分机号创建后不可改;换号请先解绑"); + } + if (existing == null) + { + // 只对「新绑定」校验占用;重复提交同一个号是幂等同步,不能判成冲突 + validateExtension(extension, roomNo); + } + + String displayName = trimToNull(command.getDisplayName()); + if (displayName == null) + { + displayName = current.get("displayName") == null + ? "房间 " + roomNo : String.valueOf(current.get("displayName")); + } + Map result = sipService.ensureForRoom(String.valueOf(current.get("roomId")), + roomNo, displayName, extension); + + Map data = new LinkedHashMap<>(result); + data.put("roomNo", roomNo); + data.put("extension", extension); + data.put("message", "分机 " + extension + " 已绑定到房间 " + roomNo); + return data; + } + + /** 解绑:关闭绑定并软删分机;号段里的号码不会回收(号码不复用) */ + public Map unbindSip(String roomNo) + { + requireDebug(); + String gyid = gyid(); + Map current = findRoom(gyid, roomNo); + if (current == null || current.get("localId") == null) + { + throw new AptException(AptErrorCode.NOT_FOUND, "ROOM_NOT_REGISTERED", + "房号 " + roomNo + " 未登记,无需解绑"); + } + Object extension = current.get("extension"); + sipService.removeForRoom(String.valueOf(current.get("roomId"))); + + Map data = new LinkedHashMap<>(); + data.put("roomNo", roomNo); + data.put("releasedExtension", extension == null ? null : String.valueOf(extension)); + data.put("message", "已解绑;分机号不会被回收,再次绑定请使用号段内的新号码"); + return data; + } + + // ------------------------------------------------------------ SIP 服务器 + + /** 当前 AMI 配置(口令只回引用名与「是否已注入」) */ + public Map sipServer() + { + String gyid = gyid(); + Map row = testMapper.selectSipServer(gyid); + Map data = new LinkedHashMap<>(); + if (row == null) + { + data.put("configured", false); + data.put("message", "未找到 AMI 实例配置,请先保存一次"); + return data; + } + data.put("configured", true); + data.put("amiHost", row.get("amiHost")); + data.put("amiPort", row.get("amiPort")); + data.put("amiUser", row.get("amiUser")); + data.put("amiSecretRef", row.get("amiSecretRef")); + data.put("extPoolFrom", row.get("extPoolFrom")); + data.put("extPoolTo", row.get("extPoolTo")); + data.put("enabled", row.get("enabled")); + data.put("state", row.get("state")); + data.put("lastError", row.get("lastError")); + data.put("lastConnectedAt", row.get("lastConnectedAt")); + + String ref = trimToNull(row.get("amiSecretRef") == null ? null : String.valueOf(row.get("amiSecretRef"))); + boolean secretReady = ref != null && System.getenv(ref) != null; + data.put("secretReady", secretReady); + if (ref != null && !secretReady) + { + data.put("secretHint", "环境变量 " + ref + " 未注入,AMI 无法登录;请在运行环境设置后重启"); + } + return data; + } + + /** 保存 AMI 配置;改完状态复位为 DISABLED,等重连结果 */ + public Map saveSipServer(DutySipServerCommand command) + { + requireDebug(); + String from = trimToNull(command.getExtPoolFrom()); + String to = trimToNull(command.getExtPoolTo()); + if ((from == null) != (to == null)) + { + throw new AptException(AptErrorCode.BAD_REQUEST, "EXT_POOL_INCOMPLETE", + "号段起止必须同时填写,或同时留空(留空=不限制)"); + } + if (from != null && from.compareTo(to) > 0) + { + throw new AptException(AptErrorCode.BAD_REQUEST, "EXT_POOL_INVALID", + "号段起始 " + from + " 不能大于结束 " + to); + } + int rows = testMapper.updateSipServer(gyid(), command.getAmiHost().trim(), command.getAmiPort(), + command.getAmiUser().trim(), command.getAmiSecretRef().trim(), from, to, + command.getEnabled() == null || command.getEnabled()); + if (rows == 0) + { + throw new AptException(AptErrorCode.NOT_FOUND, "AMI_INSTANCE_MISSING", + "未找到 AMI 实例配置行,请先执行 34_duty_test_seed.sql 落一条"); + } + Map data = sipServer(); + data.put("message", "已保存;AMI 状态复位为 DISABLED,重连成功后自动转 UP"); + return data; + } + + /** + * 连通性探测:只做 TCP 端口可达性 + 口令是否已注入。 + * + *

不做 AMI 登录握手——握手要占用真实连接且依赖 voice 开关, + * 这里要回答的是「网络通不通、口令有没有」,所以宁可说小一点: + * {@code reachable=true} 不等于 AMI 认证成功。

+ */ + public Map testSipServer() + { + requireDebug(); + Map server = sipServer(); + Map data = new LinkedHashMap<>(server); + if (!Boolean.TRUE.equals(server.get("configured"))) + { + throw new AptException(AptErrorCode.NOT_FOUND, "AMI_INSTANCE_MISSING", "未找到 AMI 实例配置"); + } + String host = String.valueOf(server.get("amiHost")); + Object portValue = server.get("amiPort"); + int port = portValue == null ? 5038 : Integer.parseInt(String.valueOf(portValue)); + + long started = System.currentTimeMillis(); + String error = null; + boolean reachable = false; + try (Socket socket = new Socket()) + { + socket.connect(new InetSocketAddress(host, port), PROBE_TIMEOUT_MS); + reachable = true; + } + catch (Exception e) + { + error = e.getClass().getSimpleName() + ": " + e.getMessage(); + } + long cost = System.currentTimeMillis() - started; + + data.put("reachable", reachable); + data.put("latencyMs", cost); + data.put("probeError", error); + data.put("probeNote", reachable + ? "端口可达(仅 TCP 探测,未做 AMI 登录握手)" + : "端口不可达:检查地址/端口/防火墙,或确认 Asterisk 已启动"); + data.put("message", reachable ? "AMI 端口可达" : "AMI 端口不可达"); + log.info("值班台测试配置:AMI 连通性探测 host={}, port={}, reachable={}, cost={}ms", host, port, + reachable, cost); + return data; + } + + // ---------------------------------------------------------------- 入住 + + /** 可入住人员 + 可住房间,供页面下拉 */ + public Map checkinCandidates() + { + String gyid = gyid(); + List> persons = testMapper.selectCheckinCandidates(gyid); + List> rooms = new ArrayList<>(); + for (Map room : testMapper.selectRoomBoard(gyid)) + { + if (room.get("localId") == null) + { + continue; + } + // 与 apply 的可用性判定保持一致:房态 4/6(停用/维修)即使有空床也不可住, + // 不然下拉里能选、提交后必然 40928 + String roomStatus = room.get("roomStatus") == null ? "1" + : String.valueOf(room.get("roomStatus")); + if ("4".equals(roomStatus) || "6".equals(roomStatus)) + { + continue; + } + int bedCount = toInt(room.get("bedCount"), 0); + int inPerson = toInt(room.get("inPersonCount"), 0); + if (bedCount > 0 && inPerson >= bedCount) + { + continue; + } + Map item = new LinkedHashMap<>(); + item.put("roomNo", room.get("roomNo")); + item.put("displayName", room.get("displayName") == null + ? "房间 " + room.get("roomNo") : room.get("displayName")); + item.put("bedCount", bedCount); + item.put("inPersonCount", inPerson); + item.put("freeBeds", Math.max(bedCount - inPerson, 0)); + item.put("extension", room.get("extension")); + rooms.add(item); + } + Map data = new LinkedHashMap<>(); + data.put("persons", persons); + data.put("rooms", rooms); + data.put("voiceEnabled", voiceEnabled); + return data; + } + + /** + * 测试办理入住:apply + 自动通过 + 可选写入叫乘时刻。 + * + *

为什么不是直接 INSERT `gy_jcxx`:入住要同时动人员主档、房间床位、住房事件、 + * 房态与上报队列,任何一步漏掉都会让后续叫乘/退宿对不上账。 + * 复用 {@code apply + review} 才能保证「测出来的链路」就是「线上的链路」。

+ */ + @Transactional(rollbackFor = Exception.class) + public Map registerCheckin(DutyTestCheckinCommand command, String operatorRef) + { + requireDebug(); + String gyid = gyid(); + String ryid = command.getRyid().trim(); + String fjh = command.getFjh().trim(); + + Map person = null; + for (Map row : testMapper.selectCheckinCandidates(gyid)) + { + if (ryid.equals(String.valueOf(row.get("ryid")))) + { + person = row; + break; + } + } + if (person == null) + { + throw new AptException(AptErrorCode.NOT_FOUND, "PERSON_NOT_FOUND", + "人员 " + ryid + " 不在系统内人员台账,请先完成人员注册"); + } + if (toInt(person.get("confirmedReg"), 0) == 0) + { + throw new AptException(AptErrorCode.FACE_REG_PENDING, "FACE_REG_NOT_CONFIRMED", + "该人员人脸注册未获确认,入住申请会被拒绝;请先在人员注册页确认,或执行 34_duty_test_seed.sql 补前置台账"); + } + if (toInt(person.get("activeStay"), 0) > 0) + { + throw new AptException(AptErrorCode.DUPLICATE_STAY, "DUPLICATE_STAY", + "该人员已有在住记录,请先办理离寓再测试"); + } + + Date now = new Date(); + AptCheckinApplyCommand apply = new AptCheckinApplyCommand(); + apply.setDeviceEventId("DUTY-TEST-" + UUID.randomUUID().toString().replace("-", "")); + apply.setRyid(ryid); + apply.setFjh(fjh); + apply.setJlid(trimToNull(command.getJlid())); + apply.setCc(trimToNull(command.getCc())); + apply.setJch(trimToNull(command.getJch())); + apply.setSource("DUTY"); + apply.setOccurredAt(now); + apply.setVerifiedAt(now); + apply.setRemark("值班台测试入住"); + + AptCheckinApplyResult applied = checkinService.apply(apply, TEST_DEVICE_CODE); + + // N-43 接单:apply 只落到 SUBMITTED,review 只接受 REVIEWING/APPROVED/ROOM_ADJUSTED。 + // 值班台真实操作是"点开详情"完成接单,这里替测试人员走这一步,否则必然 40926。 + AptCheckinApplication claimed = checkinService.dutyDetail(applied.getApplyId(), operatorRef); + + AptCheckinReviewCommand review = new AptCheckinReviewCommand(); + review.setVerdict("APPROVE"); + // 接单后 biz_version 已 +1,必须用接单后的版本做乐观锁 + review.setExpectedBizVersion(claimed.getBizVersion()); + review.setCommandId("RVT-" + UUID.randomUUID().toString().replace("-", "")); + review.setReviewNote("值班台测试入住自动通过"); + AptCheckinReviewResult reviewed = checkinService.review(applied.getApplyId(), review, operatorRef); + if (!reviewed.isCheckedIn()) + { + throw new AptException(AptErrorCode.DATA_ANOMALY, "CHECKIN_NOT_CREATED", + "审核已通过但未生成入住记录,请查看入住单 " + applied.getApplyId() + " 的状态"); + } + + List warnings = new ArrayList<>(reviewed.getWarnings()); + String jcfs = trimToNull(command.getJcfs()); + if (command.getJcsj() != null) + { + int rows = checkinMapper.updateJcxxCallTime(gyid, reviewed.getJcid(), command.getJcsj(), jcfs); + if (rows == 0) + { + warnings.add("叫乘时刻写入未命中,请检查叫乘任务 " + reviewed.getJcid() + " 是否已生成"); + } + } + else + { + warnings.add("未填叫乘时刻:任务已生成但到点扫描不会发起叫乘,可再次提交时补上"); + } + if (!voiceEnabled) + { + warnings.add("apt.voice.enabled=false:到点不会真正拨号,只验证到「叫乘中」为止"); + } + + Map data = new LinkedHashMap<>(); + data.put("applyId", reviewed.getApplyId()); + data.put("jcid", reviewed.getJcid()); + data.put("fjh", reviewed.getFjhFinal() == null ? fjh : reviewed.getFjhFinal()); + data.put("checkinState", reviewed.getCheckinState()); + data.put("checkedIn", reviewed.isCheckedIn()); + data.put("jcsj", command.getJcsj()); + data.put("jcfs", jcfs); + data.put("warnings", warnings); + data.put("message", "入住已办理,叫乘任务 " + reviewed.getJcid() + " 已生成"); + return data; + } + + // ------------------------------------------------------------------ 内部 + + private Map findRoom(String gyid, String roomNo) + { + for (Map row : testMapper.selectRoomBoard(gyid)) + { + if (roomNo.equals(String.valueOf(row.get("roomNo")))) + { + return row; + } + } + return null; + } + + /** + * 分机号校验:号段范围 + 是否已被占用。 + * + *

占用判断用「含历史软删」的计数——号码不复用,软删过的号再绑会让 Asterisk 上 + * 两条同名 endpoint 打架。

+ */ + private void validateExtension(String extension, String roomNo) + { + if (extension == null) + { + return; + } + if (!extension.matches("^[0-9]{3,8}$")) + { + throw new AptException(AptErrorCode.BAD_REQUEST, "EXTENSION_FORMAT", + "分机号只能是 3~8 位数字"); + } + Map server = sipServer(); + String from = trimToNull(server.get("extPoolFrom") == null ? null + : String.valueOf(server.get("extPoolFrom"))); + String to = trimToNull(server.get("extPoolTo") == null ? null + : String.valueOf(server.get("extPoolTo"))); + if (from != null && to != null && (toNumber(extension) < toNumber(from) + || toNumber(extension) > toNumber(to))) + { + throw new AptException(AptErrorCode.BAD_REQUEST, "EXTENSION_OUT_OF_POOL", + "分机号 " + extension + " 不在可用号段 " + from + "-" + to + " 内"); + } + if (testMapper.countExtensionInUse(gyid(), extension) > 0) + { + Map occupied = null; + for (Map row : testMapper.selectExtensionUsage(gyid())) + { + if (extension.equals(String.valueOf(row.get("extension")))) + { + occupied = row; + break; + } + } + String where = occupied == null || occupied.get("roomNo") == null + ? "已被占用" : "已绑定房间 " + occupied.get("roomNo"); + throw new AptException(AptErrorCode.DUPLICATE_KEY, "EXTENSION_IN_USE", + "分机号 " + extension + " " + where + ";号码不复用,请换一个(房间 " + roomNo + ")"); + } + } + + /** + * 号段内最小空闲分机号。 + * + *

逐个试而不是缓存全量清单:号码不复用,判断必须走 + * {@code countExtensionInUse}(含软删历史),否则会挑到一个「数据库看着空、 + * Asterisk 上还挂着同名 endpoint」的号码。

+ * + * @return 空闲号;未配置号段返回 {@code null}(由调用方回落到按房号派生) + */ + private String nextFreeExtension() + { + Map server = sipServer(); + String from = trimToNull(server.get("extPoolFrom") == null ? null + : String.valueOf(server.get("extPoolFrom"))); + String to = trimToNull(server.get("extPoolTo") == null ? null + : String.valueOf(server.get("extPoolTo"))); + if (from == null || to == null) + { + return null; + } + int start = toNumber(from); + int end = toNumber(to); + if (end < start || end - start > 2000) + { + return null; + } + for (int i = start; i <= end; i++) + { + String candidate = String.valueOf(i); + if (testMapper.countExtensionInUse(gyid(), candidate) == 0) + { + return candidate; + } + } + throw new AptException(AptErrorCode.NOT_SUPPORTED, "EXT_POOL_EXHAUSTED", + "号段 " + from + "-" + to + " 已无空闲分机号,请先在「SIP 绑定」里清理或放宽号段"); + } + + private void requireDebug() + { + if (!debugEnabled) + { + throw new AptException(AptErrorCode.FEATURE_DISABLED, "TEST_CONFIG_DISABLED", + "测试配置未启用:请在运行环境设置 APT_DEBUG_ENABLED=true 后重启后端"); + } + } + + private String gyid() + { + return AptContext.getGyId(); + } + + private String trimToNull(String value) + { + if (value == null) + { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private int toNumber(String value) + { + try + { + return Integer.parseInt(value.trim()); + } + catch (RuntimeException e) + { + return -1; + } + } + + private int toInt(Object value, int fallback) + { + if (value == null) + { + return fallback; + } + try + { + return Integer.parseInt(String.valueOf(value)); + } + catch (NumberFormatException e) + { + return fallback; + } + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/config/AptProperties.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/config/AptProperties.java index 25a74d8..1ea1b3e 100644 --- a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/config/AptProperties.java +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/config/AptProperties.java @@ -353,6 +353,8 @@ public class AptProperties private int tabletTaskTtlSeconds = 900; /** 单次扫描批大小 */ private int scanBatchSize = 50; + /** 自带扫描周期(秒);<=0 关闭自带调度,改用 sys_job(Quartz)*/ + private int scanIntervalSeconds = 60; /** TTS 模板编码(平板本地合成,文本不下发图像) */ private String ttsTemplate = "CALL_ROOM"; private String ttsText = "请到门口集合,准备乘车"; @@ -417,6 +419,16 @@ public class AptProperties this.scanBatchSize = scanBatchSize; } + public int getScanIntervalSeconds() + { + return scanIntervalSeconds; + } + + public void setScanIntervalSeconds(int scanIntervalSeconds) + { + this.scanIntervalSeconds = scanIntervalSeconds; + } + public String getTtsTemplate() { return ttsTemplate; diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/mapper/AptVoiceMapper.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/mapper/AptVoiceMapper.java index 5fe6187..fde4c7a 100644 --- a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/mapper/AptVoiceMapper.java +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/mapper/AptVoiceMapper.java @@ -76,6 +76,14 @@ public interface AptVoiceMapper @Param("now") Date now, @Param("limit") int limit); + /** + * 复叫时间到点:不校验来源 ryzt(唯一例外 ryzt=5 通话中,不打断会议), + * 只要求未停止、未完成叫乘、未离寓,且本轮复叫计划尚未执行(fjsj_s 为空或早于 fjsj)。 + */ + List> selectRecallDueSipCalls(@Param("gyid") String gyid, + @Param("now") Date now, + @Param("limit") int limit); + Map selectJcxxForCall(@Param("gyid") String gyid, @Param("jcid") String jcid); /** diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/service/AptCallFlowServiceImpl.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/service/AptCallFlowServiceImpl.java index b3a0471..79550f7 100644 --- a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/service/AptCallFlowServiceImpl.java +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/service/AptCallFlowServiceImpl.java @@ -392,6 +392,45 @@ public class AptCallFlowServiceImpl implements IAptCallFlowService return started; } + @Override + public int redialDue(int limit) + { + if (!properties.getVoice().isEnabled() || !properties.getVoice().isSipCallEnabled()) + { + return 0; + } + if (!connectionManager.isUp()) + { + log.debug("AMI 未就绪,跳过本轮复叫发起"); + return 0; + } + String gyid = gyid(); + List> due = voiceMapper.selectRecallDueSipCalls(gyid, new Date(), limit); + int started = 0; + for (Map row : due == null ? List.>of() : due) + { + String jcid = str(row.get("jcid")); + try + { + // 复叫时间到点:不校验来源 ryzt,直接走自动复叫(-> 1、重置 30s 基准、 + // 写 fjsj_s、生成新会话),再按「值班台 -> 房间」顺序重呼; + // 本轮失败仍由房间腿失败/30s 无入会回到 ryzt=2 叫乘报警 + stateService.autoRecall(jcid); + redial(jcid); + started++; + } + catch (AptException e) + { + log.warn("到点复叫 {} 发起失败:{}({})", jcid, e.getReason(), e.getMessage()); + } + catch (RuntimeException e) + { + log.warn("到点复叫 {} 发起异常", jcid, e); + } + } + return started; + } + @Override public Map playTts(String sessionId, String text) { diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/service/AptVoiceEventServiceImpl.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/service/AptVoiceEventServiceImpl.java index 3d6d736..36616f4 100644 --- a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/service/AptVoiceEventServiceImpl.java +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/service/AptVoiceEventServiceImpl.java @@ -23,7 +23,7 @@ import org.springframework.transaction.annotation.Transactional; *

事件映射(公寓端/05 表 5-13):

*
  * ORIGINATE_START / NEWSTATE   -> CALL_START   (ryzt 0 -> 1)
- * CONFBRIDGEJOIN               -> JOIN_CONF    (补记 hjxysj,ryzt 保持 1 叫乘中)
+ * CONFBRIDGEJOIN               -> JOIN_CONF    (ryzt -> 5 通话中,补记 hjxysj)
  * ORIGINATE_FAILURE / NO_ANSWER-> CALL_FAILED  (-> 2)
  * HANGUP(房间腿)             -> ANSWERED     (-> 3 待离寓:房间挂断即通话完成;
  *                                               已应答/已结束时为非法迁移,按口径忽略)
@@ -96,7 +96,7 @@ public class AptVoiceEventServiceImpl implements IAptVoiceEventService
                     result.put("submitted", true);
                     break;
                 case "CONFBRIDGEJOIN":
-                    // 房间入会 = 叫乘中(不迁移状态);待离寓由房间挂断触发
+                    // 房间入会 = 会议桥建立:1 呼叫中 -> 5 通话中;待离寓由房间挂断触发
                     stateService.joinConf(command.getJcid());
                     result.put("submitted", true);
                     break;
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/service/IAptCallFlowService.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/service/IAptCallFlowService.java
index 47db6af..11d558a 100644
--- a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/service/IAptCallFlowService.java
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/service/IAptCallFlowService.java
@@ -24,6 +24,9 @@ public interface IAptCallFlowService
     /** 扫描到点待叫乘并逐条发起呼叫,返回成功发起数 */
     int dialDue(int limit);
 
+    /** 扫描复叫时间到点的记录:自动复叫(不校验来源 ryzt,唯一排除 5 通话中)后重呼,返回成功发起数 */
+    int redialDue(int limit);
+
     /** 在会话内播放 TTS 提示语音;失败只告警,不改叫乘结论 */
     Map playTts(String sessionId, String text);
 
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/quartz/task/AptDispatchTask.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/quartz/task/AptDispatchTask.java
index 3cc78db..5a69fc8 100644
--- a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/quartz/task/AptDispatchTask.java
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/quartz/task/AptDispatchTask.java
@@ -12,8 +12,9 @@ import org.springframework.stereotype.Component;
  * 因此 Job bean 必须落在这个包内,由它委托 {@code ruoyi-apt} 的服务。
  * 这样既不扩白名单,也不把业务逻辑写进 quartz 模块。

* - *

建议 cron:{@code 0 * * * * ?}(每分钟),由 sys_job 登记、默认停用, - * 联调与试点时手工启用(见表 13-15 依赖:Quartz 须启用 ScheduleConfig 或显式登记)。

+ *

建议 cron:{@code 0 * * * * ?}(每分钟)。本项目默认由 + * {@code AptDispatchScanScheduler} 内置调度器驱动(Quartz 未启用), + * 若要改用本任务,须把 {@code apt.dispatch.scan-interval-seconds} 置 0 避免双跑。

* * @author apt-rev01 */ diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/apt-defaults.yml b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/apt-defaults.yml index 881c40a..337fc3b 100644 --- a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/apt-defaults.yml +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/apt-defaults.yml @@ -82,6 +82,10 @@ apt: tablet-task-ttl-seconds: ${APT_TABLET_TASK_TTL_SEC:900} # 单次扫描批大小 scan-batch-size: ${APT_SCAN_BATCH:50} + # 自带扫描周期(秒):叫乘/复叫时间到点发起、30s 无应答报警、应离寓超时报警 + # 本项目 Quartz 默认停用(ScheduleConfig 注释、sys_job 未启用),故由内置调度器驱动; + # 若改用 sys_job 的 aptDispatchTask.scan(),请把本项置 0 避免双跑 + scan-interval-seconds: ${APT_SCAN_INTERVAL_SEC:60} tts-template: ${APT_TTS_TEMPLATE:CALL_ROOM} tts-text: ${APT_TTS_TEXT:请到门口集合,准备乘车} # --------------------------------------------------------------------------- diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptDutyStateMapper.xml b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptDutyStateMapper.xml index 8b27919..43ededb 100644 --- a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptDutyStateMapper.xml +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptDutyStateMapper.xml @@ -49,14 +49,16 @@ - UPDATE gy_jcxx - SET hjxysj = COALESCE(hjxysj, NOW(3)), + SET ryzt = '5', + hjxysj = COALESCE(hjxysj, NOW(3)), biz_version = biz_version + 1 WHERE gyid = #{gyid} AND jcid = #{jcid} AND deleted = 0 - AND ryzt = '1' AND finish_jc = '0' AND finish_ly = '0' + AND ryzt IN ('0','1','2') AND finish_jc = '0' AND finish_ly = '0' @@ -69,6 +71,18 @@ AND ryzt = '1' AND finish_jc = '0' AND finish_ly = '0' + + + UPDATE gy_jcxx + SET ryzt = '4', + is_lybj = '1', + lybjsj = NOW(3), + biz_version = biz_version + 1 + WHERE gyid = #{gyid} AND jcid = #{jcid} AND deleted = 0 + AND finish_ly = '0' AND is_lybj = '0' + + UPDATE gy_jcxx SET stoped = '1', @@ -77,9 +91,15 @@ AND ryzt IN ('1','2') AND finish_ly = '0' + UPDATE gy_jcxx SET ryzt = '1', + finish_jc = '0', stoped = '0', fjsj_s = NOW(3), + + UPDATE gy_jcxx + SET ryzt = '1', + jcsj_s = NOW(3), + fjsj_s = NOW(3), + biz_version = biz_version + 1 + WHERE gyid = #{gyid} AND jcid = #{jcid} AND deleted = 0 + AND finish_jc = '0' AND finish_ly = '0' AND stoped = '0' + AND ryzt <> '5' diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptJcxxMapper.xml b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptJcxxMapper.xml index 127ee9a..b4be4c4 100644 --- a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptJcxxMapper.xml +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptJcxxMapper.xml @@ -14,7 +14,9 @@ j.id, j.jcid, j.jlid, j.cc, j.jch, j.ryid, j.xm AS rymc, j.fjh, j.kcsj, j.jcsj, j.jcsj_s AS jcsjS, j.hjxysj, j.jcbjsj_s AS jcbjsjS, - j.fjsj_s AS fjsjS, j.lybjsj, j.sjlysj, + + j.fjsj, j.fjsj_s AS fjsjS, j.ylysj, j.lybjsj, j.sjlysj, j.ryzt, CASE j.ryzt WHEN '0' THEN '待叫乘' @@ -151,10 +153,18 @@ 'CALL_FAILED', '无应答转叫乘报警' FROM gy_jcxx j WHERE j.gyid = #{gyid} AND j.jcid = #{jcid} AND j.deleted = 0 AND j.jcbjsj_s IS NOT NULL UNION ALL + SELECT CAST(UNIX_TIMESTAMP(j.fjsj) * 1000 AS SIGNED), j.fjsj, + 'RE_CALL_PLANNED', CONCAT('计划复叫时刻 ', DATE_FORMAT(j.fjsj, '%H:%i:%s')) + FROM gy_jcxx j WHERE j.gyid = #{gyid} AND j.jcid = #{jcid} AND j.deleted = 0 AND j.fjsj IS NOT NULL + UNION ALL SELECT CAST(UNIX_TIMESTAMP(j.fjsj_s) * 1000 AS SIGNED), j.fjsj_s, 'RE_CALL', '复叫' FROM gy_jcxx j WHERE j.gyid = #{gyid} AND j.jcid = #{jcid} AND j.deleted = 0 AND j.fjsj_s IS NOT NULL UNION ALL + SELECT CAST(UNIX_TIMESTAMP(j.ylysj) * 1000 AS SIGNED), j.ylysj, + 'LEAVE_DUE', CONCAT('应离寓时刻 ', DATE_FORMAT(j.ylysj, '%H:%i:%s')) + FROM gy_jcxx j WHERE j.gyid = #{gyid} AND j.jcid = #{jcid} AND j.deleted = 0 AND j.ylysj IS NOT NULL + UNION ALL SELECT CAST(UNIX_TIMESTAMP(j.hjxysj) * 1000 AS SIGNED), j.hjxysj, 'ANSWERED', '应答完成叫乘' FROM gy_jcxx j WHERE j.gyid = #{gyid} AND j.jcid = #{jcid} AND j.deleted = 0 AND j.hjxysj IS NOT NULL @@ -256,6 +266,17 @@ LIMIT #{limit} + + + + + + + SELECT + FROM gy_jcxx j + WHERE j.gyid = #{gyid} AND j.deleted = 0 + + + AND (j.finish_ly = '1' OR j.stoped = '1') + + + + + AND j.finish_ly = '0' AND j.stoped = '0' + + + + ORDER BY + CASE + WHEN j.ryzt IN ('2', '4') THEN 0 + WHEN j.ryzt = '0' AND j.jcsj IS NOT NULL AND j.jcsj < #{now} THEN 0 + WHEN j.finish_ly = '0' AND j.stoped = '0' THEN 1 + ELSE 2 + END, + j.jcsj ASC, j.id ASC + + + + + + + diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/DutyTestMapper.xml b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/DutyTestMapper.xml new file mode 100644 index 0000000..7831cea --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/DutyTestMapper.xml @@ -0,0 +1,133 @@ + + + + + + + + + + UPDATE apt_ast_instance + SET ami_host = #{amiHost}, + ami_port = #{amiPort}, + ami_user = #{amiUser}, + ami_secret_ref = #{amiSecretRef}, + ext_pool_from = #{extPoolFrom}, + ext_pool_to = #{extPoolTo}, + enabled = #{enabled}, + state = 'DISABLED', + last_error = NULL, + biz_version = biz_version + 1 + WHERE gyid = #{gyid} AND deleted = 0 + + + + + + + + INSERT INTO apt_master_room + (gyid, room_id, room_no, room_floor, building, bed_count, room_type, + state, state_version, source_version, deleted) + VALUES + (#{gyid}, #{roomId}, #{roomNo}, #{roomFloor}, #{building}, #{bedCount}, #{roomType}, + 'ACTIVE', 1, 1, 0) + + + + + + + + + + + diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/dispatch/AptDispatchScanServiceTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/dispatch/AptDispatchScanServiceTest.java new file mode 100644 index 0000000..f480fa0 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/dispatch/AptDispatchScanServiceTest.java @@ -0,0 +1,90 @@ +package com.ruoyi.apt.dispatch; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import com.ruoyi.apt.common.AptContext; +import com.ruoyi.apt.common.AptErrorCode; +import com.ruoyi.apt.common.AptException; +import com.ruoyi.apt.dispatch.mapper.AptJcxxMapper; +import com.ruoyi.apt.dispatch.service.AptDispatchScanServiceImpl; +import com.ruoyi.apt.dispatch.service.IAptDutyStateService; +import com.ruoyi.apt.dispatch.tablet.service.IAptTabletDispatchService; +import com.ruoyi.apt.framework.config.AptProperties; +import com.ruoyi.apt.voice.service.IAptCallFlowService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * 叫乘扫描编排回归:叫乘/复叫时间到点发起、应离寓时间到点转离寓报警。 + */ +class AptDispatchScanServiceTest +{ + private IAptDutyStateService stateService; + private AptJcxxMapper jcxxMapper; + private IAptCallFlowService callFlowService; + private AptDispatchScanServiceImpl service; + + @BeforeEach + void setUp() + { + IAptTabletDispatchService tabletDispatchService = mock(IAptTabletDispatchService.class); + stateService = mock(IAptDutyStateService.class); + jcxxMapper = mock(AptJcxxMapper.class); + callFlowService = mock(IAptCallFlowService.class); + AptProperties properties = new AptProperties(); + properties.setGyId("APT-A"); + properties.getVoice().setEnabled(true); + properties.getVoice().setSipCallEnabled(true); + service = new AptDispatchScanServiceImpl(tabletDispatchService, stateService, jcxxMapper, + properties, callFlowService); + AptContext.clear(); + } + + @AfterEach + void tearDown() + { + AptContext.clear(); + } + + @Test + void scanTriggersRecallThenDialThenLeaveAlarm() + { + when(jcxxMapper.selectLeaveOverdue(anyString(), any(), anyInt())).thenReturn(rows("JC-2")); + + Map result = service.scan(); + + verify(callFlowService).redialDue(anyInt()); + verify(callFlowService).dialDue(anyInt()); + verify(stateService).leaveAlarm("JC-2"); + assertEquals(1, result.get("leaveAlarms")); + } + + @Test + void leaveAlarmIllegalTransitionIsSwallowed() + { + when(jcxxMapper.selectLeaveOverdue(anyString(), any(), anyInt())).thenReturn(rows("JC-3")); + doThrow(new AptException(AptErrorCode.STATE_ILLEGAL, "STATE_TRANSITION_ILLEGAL", "已离寓")) + .when(stateService).leaveAlarm("JC-3"); + + Map result = service.scan(); + + assertEquals(0, result.get("leaveAlarms")); + } + + private List> rows(String jcid) + { + Map row = new HashMap<>(); + row.put("jcid", jcid); + return List.of(row); + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/dispatch/AptDutyCommandServiceTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/dispatch/AptDutyCommandServiceTest.java index 6d2c950..4558cf7 100644 --- a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/dispatch/AptDutyCommandServiceTest.java +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/dispatch/AptDutyCommandServiceTest.java @@ -33,6 +33,7 @@ class AptDutyCommandServiceTest private AptJcxxMapper mapper; private IAptDutyStateService stateService; private AptAuditWriter auditWriter; + private com.ruoyi.apt.voice.service.IAptCallFlowService callFlowService; private AptDutyCommandServiceImpl service; @BeforeEach @@ -41,13 +42,33 @@ class AptDutyCommandServiceTest mapper = mock(AptJcxxMapper.class); stateService = mock(IAptDutyStateService.class); auditWriter = mock(AptAuditWriter.class); + callFlowService = mock(com.ruoyi.apt.voice.service.IAptCallFlowService.class); service = new AptDutyCommandServiceImpl(mapper, stateService, auditWriter, mock(com.ruoyi.apt.framework.realtime.IAptRealtimePublisher.class), - mock(com.ruoyi.apt.voice.service.IAptCallFlowService.class)); + callFlowService); AptContext.clear(); AptContext.setGyId("APT-A"); } + @Test + void recallAllowedForAnsweredAwaitingLeave() + { + when(mapper.selectManualAction("APT-A", "CMD-9")).thenReturn(null); + // ryzt=3 已应答待离寓:叫乘已完成(finishJc=1),页面上仍允许触发复叫 + when(mapper.selectJcxxById("APT-A", 9L)).thenReturn(row("JC-9", "3", "1", "0", "0"), + row("JC-9", "1", "0", "0", "0")); + when(mapper.insertManualAction(eq("APT-A"), eq("CMD-9"), eq("JC-9"), eq("RE_CALL"), + anyString(), anyString(), anyString(), any())).thenReturn(1); + when(stateService.recall("JC-9")).thenReturn(1); + + AptDutyCommandResult result = service.execute(9L, command("CMD-9", "RE_CALL")); + + assertTrue(result.isAccepted()); + verify(stateService).recall("JC-9"); + // 复叫提交后触发 SIP 重呼(无事务时立即执行) + verify(callFlowService).redial("JC-9"); + } + @AfterEach void tearDown() { diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/dispatch/AptDutyStateServiceTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/dispatch/AptDutyStateServiceTest.java index 8f26cf8..9789fc3 100644 --- a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/dispatch/AptDutyStateServiceTest.java +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/dispatch/AptDutyStateServiceTest.java @@ -61,16 +61,26 @@ class AptDutyStateServiceTest when(mapper.updateAnswered(eq("APT-A"), eq("JC-1"), anyList())).thenReturn(1); assertEquals(1, service.answered("JC-1")); - verify(mapper).updateAnswered("APT-A", "JC-1", List.of("0", "1", "2")); + // 5 通话中:房间入会成功后挂断也要能提交应答 + verify(mapper).updateAnswered("APT-A", "JC-1", List.of("0", "1", "2", "5")); } @Test - void joinConfOnlyRecordsResponseTime() + void joinConfMovesCallingToTalking() { when(mapper.updateJoinConf(eq("APT-A"), eq("JC-1"))).thenReturn(1); + AptStateSnapshot calling = new AptStateSnapshot(); + calling.setJcid("JC-1"); + calling.setRyzt("1"); + AptStateSnapshot talking = new AptStateSnapshot(); + talking.setJcid("JC-1"); + talking.setRyzt("5"); + when(mapper.selectSnapshot("APT-A", "JC-1")).thenReturn(calling, talking); assertEquals(1, service.joinConf("JC-1")); verify(mapper).updateJoinConf("APT-A", "JC-1"); + // 入会 = 会议桥建立:值班台必须收到 1 呼叫中 -> 5 通话中 的状态变化 + verify(pushService).stateChanged(eq("JC-1"), eq(1), eq(5), eq("TALKING"), eq("通话中"), any()); } @Test @@ -82,6 +92,50 @@ class AptDutyStateServiceTest assertEquals(40902, e.getErrorCode().getCode()); } + @Test + void autoRecallIgnoresSourceStateAndCreatesNewSession() + { + when(mapper.updateAutoRecall("APT-A", "JC-6")).thenReturn(1); + AptStateSnapshot before = new AptStateSnapshot(); + before.setJcid("JC-6"); + // 来源态任意(这里用「叫乘中」),自动复叫不应因 ryzt 不符而失败 + before.setRyzt("1"); + before.setCallMode("SIP"); + when(mapper.selectSnapshot("APT-A", "JC-6")).thenReturn(before); + + assertEquals(1, service.autoRecall("JC-6")); + verify(mapper).updateAutoRecall("APT-A", "JC-6"); + verify(mapper).insertCallSession(eq("APT-A"), anyString(), eq("JC-6"), eq("SIP")); + } + + @Test + void autoRecallMissedConditionalUpdateMapsTo40902() + { + when(mapper.updateAutoRecall("APT-A", "JC-7")).thenReturn(0); + + AptException e = assertThrows(AptException.class, () -> service.autoRecall("JC-7")); + assertEquals(40902, e.getErrorCode().getCode()); + } + + @Test + void leaveAlarmRaisesRealtimeAlarmAndPushesDuty() + { + when(mapper.updateLeaveAlarm("APT-A", "JC-5")).thenReturn(1); + AptStateSnapshot before = new AptStateSnapshot(); + before.setJcid("JC-5"); + before.setRyzt("3"); + AptStateSnapshot after = new AptStateSnapshot(); + after.setJcid("JC-5"); + after.setRyzt("4"); + // 迁移前快照 + 迁移后快照(pushDuty 内部再取一次,与 answered 同口径) + when(mapper.selectSnapshot("APT-A", "JC-5")).thenReturn(before, after); + + assertEquals(1, service.leaveAlarm("JC-5")); + verify(mapper).updateLeaveAlarm("APT-A", "JC-5"); + verify(realtimePublisher).alarm("JC-5", "LEAVE_ALARM", "超过应离寓时间未办理离寓"); + verify(pushService).alarmRaised("JC-5", "LEAVE", "JC-5:LEAVE"); + } + @Test void missedConditionalUpdateMapsTo40902() { diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/duty/DutyCommandServiceTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/duty/DutyCommandServiceTest.java new file mode 100644 index 0000000..9f3d954 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/duty/DutyCommandServiceTest.java @@ -0,0 +1,316 @@ +package com.ruoyi.apt.duty; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.Map; +import com.ruoyi.apt.common.AptContext; +import com.ruoyi.apt.common.AptErrorCode; +import com.ruoyi.apt.common.AptException; +import com.ruoyi.apt.dispatch.domain.AptDutyCommand; +import com.ruoyi.apt.dispatch.domain.AptDutyCommandResult; +import com.ruoyi.apt.dispatch.service.IAptDutyCommandService; +import com.ruoyi.apt.dispatch.service.IAptJcxxService; +import com.ruoyi.apt.duty.domain.DutyCommandRequest; +import com.ruoyi.apt.duty.mapper.DutyJcxxMapper; +import com.ruoyi.apt.duty.realtime.DutyTransitionPush; +import com.ruoyi.apt.duty.service.DutyCommandServiceImpl; +import com.ruoyi.framework.web.service.PermissionService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +/** + * R2 命令通道行为断言(应用端/11 表 11-7 `T-BE-04`)。 + * + *

重点不在"能不能调通",而在四条边界:原因必填、权限逐命令、 + * 期望态不符不执行、jcid 解析不跨公寓。

+ * + * @author apt-rev01 + */ +class DutyCommandServiceTest +{ + private DutyJcxxMapper mapper; + + private IAptDutyCommandService stateService; + + private IAptJcxxService jcxxService; + + private PermissionService permissionService; + + private DutyTransitionPush dutyPush; + + private DutyCommandServiceImpl service; + + @BeforeEach + void setUp() + { + mapper = mock(DutyJcxxMapper.class); + stateService = mock(IAptDutyCommandService.class); + jcxxService = mock(IAptJcxxService.class); + permissionService = mock(PermissionService.class); + dutyPush = mock(DutyTransitionPush.class); + service = new DutyCommandServiceImpl(mapper, stateService, jcxxService, permissionService, + dutyPush); + AptContext.clear(); + AptContext.setGyId("APT-A"); + } + + @AfterEach + void tearDown() + { + AptContext.clear(); + } + + @Test + void stopWithoutReasonIsRejectedBeforeTouchingStateMachine() + { + when(permissionService.hasPermi("duty:call:stop")).thenReturn(true); + when(mapper.selectDutyRefByJcid("APT-A", "JC1")).thenReturn(ref("JC1", "1")); + + AptException error = assertThrows(AptException.class, + () -> service.execute("JC1", request("STOP", null, null))); + + assertEquals(AptErrorCode.BAD_REQUEST, error.getErrorCode()); + assertEquals("REASON_REQUIRED", error.getReason()); + verifyNoInteractions(stateService); + } + + @Test + void oneClickLeaveFillsDefaultReasonAndReportsNewState() + { + when(permissionService.hasPermi("duty:call:markLeave")).thenReturn(true); + // 第一次读 = 执行前(ryzt=3),第二次读 = 执行后(finish_ly=1) + when(mapper.selectDutyRefByJcid("APT-A", "JC2")) + .thenReturn(ref("JC2", "3"), finished("JC2")); + when(stateService.execute(anyLong(), any(AptDutyCommand.class))).thenReturn( + result("ACT-1", false)); + when(dutyPush.takeStateSeq("JC2")).thenReturn(42L); + + Map view = service.execute("JC2", request("MARK_LEAVE", null, null)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AptDutyCommand.class); + verify(stateService).execute(eq(88L), captor.capture()); + AptDutyCommand sent = captor.getValue(); + assertEquals("MARK_LEAVE", sent.getCommand(), "命令名必须与枚举一致"); + assertEquals("值班台登记离寓", sent.getReason(), "一键动作由服务端补默认原因"); + assertEquals("MANUAL", sent.getLeaveType(), "登记离寓必须带 leaveType"); + assertNotNull(sent.getCommandId(), "未给幂等键时必须兜底生成"); + + assertEquals("DONE", view.get("uiState"), "离寓后应为终态"); + assertEquals(3, view.get("ryztBefore")); + assertNull(view.get("ryztAfter")); + assertEquals(Boolean.FALSE, view.get("duplicated")); + assertEquals("ACT-1", view.get("actionId")); + assertEquals(42L, view.get("pushSeq"), "命令响应必须带 pushSeq 供前端丢重"); + verify(dutyPush).takeStateSeq("JC2"); + } + + @Test + void permissionIsCheckedPerCommandNotPerEndpoint() + { + when(permissionService.hasPermi("duty:call:forceFinish")).thenReturn(false); + + AptException error = assertThrows(AptException.class, + () -> service.execute("JC3", request("FORCE_FINISH", "旅客已自行离开", null))); + + assertEquals(AptErrorCode.FORBIDDEN_PERM, error.getErrorCode()); + assertTrue(error.getMessage().contains("duty:call:forceFinish"), "提示要给出缺失的权限点"); + verifyNoInteractions(stateService); + verify(mapper, never()).selectDutyRefByJcid(anyString(), anyString()); + } + + @Test + void expectedStateMismatchStopsBeforeExecution() + { + when(permissionService.hasPermi("duty:call:recall")).thenReturn(true); + when(mapper.selectDutyRefByJcid("APT-A", "JC4")).thenReturn(ref("JC4", "0")); + + AptException error = assertThrows(AptException.class, + () -> service.execute("JC4", request("RE_CALL", null, 2))); + + assertEquals(AptErrorCode.STATE_ILLEGAL, error.getErrorCode()); + assertEquals("EXPECTED_STATE_MISMATCH", error.getReason()); + verifyNoInteractions(stateService); + } + + @Test + void unknownJcidIs404NotAStackTrace() + { + when(permissionService.hasPermi("duty:call:stop")).thenReturn(true); + when(mapper.selectDutyRefByJcid("APT-A", "GHOST")).thenReturn(null); + + AptException error = assertThrows(AptException.class, + () -> service.execute("GHOST", request("STOP", "重复叫乘", null))); + + assertEquals(AptErrorCode.NOT_FOUND, error.getErrorCode()); + assertEquals("JCXX_NOT_FOUND", error.getReason()); + verifyNoInteractions(stateService); + } + + @Test + void unsupportedCommandListsAllowedOnes() + { + AptException error = assertThrows(AptException.class, + () -> service.execute("JC5", request("HANGUP", "挂断", null))); + + assertEquals(AptErrorCode.BAD_REQUEST, error.getErrorCode()); + assertEquals("COMMAND_UNSUPPORTED", error.getReason()); + assertTrue(error.getMessage().contains("MARK_LEAVE"), "错误信息应列出可用命令"); + verifyNoInteractions(stateService); + } + + @Test + void blankCommandIdIsGeneratedButGivenOneIsKept() + { + when(permissionService.hasPermi("duty:call:recall")).thenReturn(true); + when(mapper.selectDutyRefByJcid("APT-A", "JC6")).thenReturn(ref("JC6", "2"), ref("JC6", "1")); + when(stateService.execute(anyLong(), any(AptDutyCommand.class))).thenReturn(result("ACT-2", true)); + + DutyCommandRequest withId = request("RE_CALL", "值班台复叫", null); + withId.setCommandId("DUTY-FIXED-1"); + Map view = service.execute("JC6", withId); + + assertEquals("DUTY-FIXED-1", view.get("commandId")); + assertEquals(Boolean.TRUE, view.get("duplicated"), "重复提交要透传 duplicated"); + assertEquals("CALLING", view.get("uiState")); + } + + @Test + void reasonTooShortOrTooLongIsRejected() + { + when(permissionService.hasPermi("duty:call:stop")).thenReturn(true); + when(mapper.selectDutyRefByJcid("APT-A", "JC7")).thenReturn(ref("JC7", "1")); + + AptException shortError = assertThrows(AptException.class, + () -> service.execute("JC7", request("STOP", "急", null))); + assertEquals("REASON_TOO_SHORT", shortError.getReason()); + + StringBuilder tooLong = new StringBuilder(); + for (int i = 0; i < 201; i++) + { + tooLong.append('x'); + } + AptException longError = assertThrows(AptException.class, + () -> service.execute("JC7", request("STOP", tooLong.toString(), null))); + assertEquals("REASON_TOO_LONG", longError.getReason()); + + verifyNoInteractions(stateService); + } + + @Test + void detailMasksNameAndDropsMediaPaths() + { + when(mapper.selectDutyRefByJcid("APT-A", "JC8")).thenReturn(ref("JC8", "1")); + Map source = new LinkedHashMap<>(); + source.put("jcid", "JC8"); + source.put("fjh", "305"); + source.put("rymc", "李四"); + source.put("cc", "G5678"); + source.put("ryzt", "1"); + source.put("ryztLabel", "呼叫中"); + source.put("stoped", "0"); + source.put("finishLy", "0"); + source.put("recordings", Arrays.asList("r1.wav")); + source.put("facePhotos", Arrays.asList("p1.jpg")); + Map manualActions = new LinkedHashMap<>(); + manualActions.put("total", 12); + source.put("manualActions", manualActions); + when(jcxxService.detail(88L)).thenReturn(source); + + Map detail = service.detail("JC8"); + + assertEquals("李*", detail.get("personName"), "详情也必须脱敏"); + assertEquals("CALLING", detail.get("uiState")); + assertFalse(detail.containsKey("recordings"), "值班台不返回录音路径"); + assertFalse(detail.containsKey("facePhotos"), "值班台不返回刷脸照片路径"); + assertNotNull(detail.get("manualActions")); + } + + @Test + void timelineOfUnknownJcidIs404() + { + when(mapper.selectDutyRefByJcid("APT-A", "GHOST")).thenReturn(null); + AptException error = assertThrows(AptException.class, () -> service.timeline("GHOST")); + assertEquals(AptErrorCode.NOT_FOUND, error.getErrorCode()); + verifyNoInteractions(jcxxService); + } + + @Test + void timelinePassesThroughAsListNeverNull() + { + when(mapper.selectDutyRefByJcid("APT-A", "JC9")).thenReturn(ref("JC9", "0")); + when(jcxxService.timeline(88L)).thenReturn(null); + assertTrue(service.timeline("JC9").isEmpty(), "无时间线时返回空数组,避免前端判空"); + } + + @Test + void pushSeqIsMinusOneWhenTransitionDidNotRegister() + { + when(permissionService.hasPermi("duty:call:stop")).thenReturn(true); + when(mapper.selectDutyRefByJcid("APT-A", "JC13")).thenReturn(ref("JC13", "1"), ref("JC13", "1")); + when(stateService.execute(anyLong(), any(AptDutyCommand.class))).thenReturn(result("ACT-13", false)); + when(dutyPush.takeStateSeq("JC13")).thenReturn(-1L); + + Map view = service.execute("JC13", request("STOP", "旅客已自行离寓", null)); + + assertEquals("CALLING", view.get("uiState"), "推送未登记不影响命令结果"); + assertEquals(-1L, view.get("pushSeq"), "未登记时 pushSeq 返回 -1"); + verify(stateService).execute(eq(88L), any(AptDutyCommand.class)); + } + + private DutyCommandRequest request(String type, String reason, Integer expectedState) + { + DutyCommandRequest request = new DutyCommandRequest(); + request.setCommandType(type); + request.setReason(reason); + request.setExpectedState(expectedState); + return request; + } + + private Map ref(String jcid, String ryzt) + { + Map row = new LinkedHashMap<>(); + row.put("id", 88L); + row.put("jcid", jcid); + row.put("ryzt", ryzt); + row.put("finishJc", "0"); + row.put("finishLy", "0"); + row.put("stoped", "0"); + return row; + } + + private Map finished(String jcid) + { + Map row = ref(jcid, null); + row.put("finishLy", "1"); + return row; + } + + private AptDutyCommandResult result(String actionId, boolean duplicated) + { + AptDutyCommandResult result = new AptDutyCommandResult(); + result.setCommandId("DUTY-REQ"); + result.setActionId(actionId); + result.setAccepted(true); + result.setDuplicated(duplicated); + result.setStateAfter("CALLING"); + return result; + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/duty/DutyJcxxQueryServiceTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/duty/DutyJcxxQueryServiceTest.java new file mode 100644 index 0000000..5f16c19 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/duty/DutyJcxxQueryServiceTest.java @@ -0,0 +1,204 @@ +package com.ruoyi.apt.duty; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import java.math.BigDecimal; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import com.ruoyi.apt.common.AptContext; +import com.ruoyi.apt.duty.domain.DutyQuery; +import com.ruoyi.apt.duty.domain.DutyTaskView; +import com.ruoyi.apt.duty.mapper.DutyJcxxMapper; +import com.ruoyi.apt.duty.service.DutyJcxxQueryServiceImpl; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * 值班台只读查询(R1)行为断言:口径归一、关键字过滤、脱敏、统计兜底。 + * + * @author apt-rev01 + */ +class DutyJcxxQueryServiceTest +{ + private DutyJcxxMapper mapper; + private DutyJcxxQueryServiceImpl service; + + @BeforeEach + void setUp() + { + mapper = mock(DutyJcxxMapper.class); + service = new DutyJcxxQueryServiceImpl(mapper); + AptContext.clear(); + AptContext.setGyId("APT-A"); + } + + @AfterEach + void tearDown() + { + AptContext.clear(); + } + + @Test + void listDerivesStateAndMasksName() throws ParseException + { + when(mapper.selectDutyTaskList(anyString(), anyString(), any(Date.class), any(Date.class), + any(Date.class))).thenReturn(rows()); + + List views = service.list(new DutyQuery(), at("2026-11-12 08:36:00")); + + assertEquals(4, views.size(), "当日 4 条应全部返回"); + DutyTaskView overdueTodo = views.get(0); + assertEquals("TODO", overdueTodo.getUiState()); + assertTrue(overdueTodo.isOverdue(), "08:30 的叫乘在 08:36 应判超时"); + assertEquals(-6L, overdueTodo.getRemainMinutes().longValue()); + assertTrue(overdueTodo.isUrgent(), "超时待叫乘必须进紧急区"); + assertEquals("李*", overdueTodo.getPersonName(), "姓名必须脱敏"); + assertEquals("2026-11-12 08:30:00", overdueTodo.getCallTime(), "时间格式统一到秒"); + + assertEquals("CALLING", views.get(1).getUiState()); + assertFalse(views.get(1).isUrgent(), "呼叫中不属紧急"); + + assertEquals("ALARM_CALL", views.get(2).getUiState()); + assertEquals("CALL", views.get(2).getAlarmType()); + assertTrue(views.get(2).isUrgent()); + + assertEquals("DONE", views.get(3).getUiState()); + } + + @Test + void keywordMatchesRoomFullNameAndTrainThenKeepsMaskedOutput() throws ParseException + { + when(mapper.selectDutyTaskList(anyString(), anyString(), any(Date.class), any(Date.class), + any(Date.class))).thenReturn(rows()); + + DutyQuery query = new DutyQuery(); + query.setKeyword("李四"); + List byFullName = service.list(query, at("2026-11-12 08:36:00")); + assertEquals(1, byFullName.size(), "全名应能搜到(脱敏只作用于返回体)"); + assertEquals("李*", byFullName.get(0).getPersonName(), "搜到后仍返回脱敏名"); + + DutyQuery byRoom = new DutyQuery(); + byRoom.setKeyword("302"); + assertEquals(1, service.list(byRoom, at("2026-11-12 08:36:00")).size()); + + DutyQuery byTrain = new DutyQuery(); + byTrain.setKeyword("g5678"); + assertEquals(1, service.list(byTrain, at("2026-11-12 08:36:00")).size(), + "车次匹配应忽略大小写"); + } + + @Test + void emptyKeywordKeepsAllRows() throws ParseException + { + when(mapper.selectDutyTaskList(anyString(), anyString(), any(Date.class), any(Date.class), + any(Date.class))).thenReturn(rows()); + + DutyQuery query = new DutyQuery(); + query.setKeyword(" "); + assertEquals(4, service.list(query, at("2026-11-12 08:36:00")).size()); + } + + @Test + void scopeIsNormalizedToNeedByDefault() throws ParseException + { + when(mapper.selectDutyTaskList(eq("APT-A"), eq("need"), any(Date.class), any(Date.class), + any(Date.class))).thenReturn(new ArrayList<>()); + + DutyQuery query = new DutyQuery(); + query.setScope("HACK"); + assertTrue(service.list(query, at("2026-11-12 08:36:00")).isEmpty()); + + DutyQuery done = new DutyQuery(); + done.setScope("done"); + assertEquals("done", done.getScope()); + } + + @Test + void countsTolerateNullAndBigDecimal() + { + Map raw = new LinkedHashMap<>(); + raw.put("todoCount", new BigDecimal("7")); + raw.put("callCount", 2L); + raw.put("alarmCount", null); + raw.put("urgentCount", null); + when(mapper.selectDutyCounts(anyString(), any(Date.class), any(Date.class), any(Date.class))) + .thenReturn(raw); + + Map counts = service.counts(atQuiet("2026-11-12 08:36:00")); + + assertEquals(7L, counts.get("todoCount"), "BigDecimal 必须转 long"); + assertEquals(2L, counts.get("callCount")); + assertEquals(0L, counts.get("alarmCount"), "SUM 无行时为 null,必须兜底 0"); + assertEquals(0L, counts.get("urgentCount")); + assertEquals(0L, counts.get("total")); + assertEquals("2026-11-12 08:36:00", counts.get("serverTime"), "serverTime 供前端判时钟偏差"); + assertNotNull(counts.get("serverTime")); + } + + @Test + void emptyRowsReturnEmptyListNotException() + { + when(mapper.selectDutyTaskList(anyString(), anyString(), any(Date.class), any(Date.class), + any(Date.class))).thenReturn(null); + assertTrue(service.list(new DutyQuery(), new Date()).isEmpty()); + } + + private List> rows() throws ParseException + { + List> rows = new ArrayList<>(); + rows.add(row("JC1", "305", "李四", "G5678", at("2026-11-12 08:30:00"), "0", "0", "0")); + rows.add(row("JC2", "302", "周八", "G2210", at("2026-11-12 08:40:00"), "1", "0", "0")); + rows.add(row("JC3", "310", "郑九", "K1524", at("2026-11-12 08:52:00"), "2", "0", "0")); + rows.add(row("JC4", "316", "朱十", "G1122", at("2026-11-12 08:50:00"), "0", "0", "1")); + return rows; + } + + private Map row(String jcid, String roomNo, String name, String train, + Date callTime, String ryzt, String stoped, String finishLy) + { + Map row = new LinkedHashMap<>(); + row.put("jcid", jcid); + row.put("roomNo", roomNo); + row.put("personName", name); + row.put("trainNo", train); + row.put("callTime", callTime); + row.put("departTime", null); + row.put("ryzt", ryzt); + row.put("finishJc", "0"); + row.put("finishLy", finishLy); + row.put("stoped", stoped); + row.put("matchLocked", "0"); + row.put("leaveRemark", null); + return row; + } + + private Date at(String text) throws ParseException + { + return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").parse(text); + } + + private Date atQuiet(String text) + { + try + { + return at(text); + } + catch (ParseException e) + { + throw new IllegalStateException(e); + } + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/duty/DutyPushServiceTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/duty/DutyPushServiceTest.java new file mode 100644 index 0000000..4a3bbce --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/duty/DutyPushServiceTest.java @@ -0,0 +1,142 @@ +package com.ruoyi.apt.duty; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONObject; +import com.ruoyi.apt.common.AptContext; +import com.ruoyi.apt.duty.realtime.DutyPushServiceImpl; +import com.ruoyi.apt.framework.config.AptProperties; +import com.ruoyi.apt.framework.realtime.AptRealtimeSessionRegistry; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +/** + * `/ws/duty` 推送实现断言(R3):seq、投递权限、事务时机、失败隔离。 + * + * @author apt-rev01 + */ +class DutyPushServiceTest +{ + private AptRealtimeSessionRegistry registry; + + private AptProperties properties; + + private DutyPushServiceImpl service; + + @BeforeEach + void setUp() + { + registry = mock(AptRealtimeSessionRegistry.class); + properties = mock(AptProperties.class); + when(properties.getGyId()).thenReturn("APT-A"); + service = new DutyPushServiceImpl(registry, properties); + AptContext.clear(); + } + + @AfterEach + void tearDown() + { + AptContext.clear(); + if (TransactionSynchronizationManager.isSynchronizationActive()) + { + TransactionSynchronizationManager.clearSynchronization(); + } + } + + @Test + void seqIsMonotonicPerApartment() + { + assertEquals(1L, service.stateChanged("JC1", 1, 3, "LEAVE", "待离寓", "ACT-1")); + assertEquals(2L, service.alarmRaised("JC1", "CALL", "JC1:CALL")); + assertEquals(3L, service.alarmCleared("JC1", "CALL", "JC1:CALL")); + assertEquals(4L, service.sessionChanged("JC1", "SESS-1", "JOINED_CONF", "8001")); + assertEquals(4L, service.currentSeq(), "连接建立时下发的 lastSeq 应等于当前水位"); + } + + @Test + void payloadCarriesDutyPermissionAndDerivedState() + { + service.stateChanged("JC2", 1, 2, "ALARM_CALL", "叫乘报警", "ACT-2"); + + ArgumentCaptor payload = ArgumentCaptor.forClass(String.class); + verify(registry).sendToPermission(eq("APT-A"), eq("duty:jcxx:list"), payload.capture()); + + JSONObject json = JSON.parseObject(payload.getValue()); + assertEquals("state.changed", json.getString("type")); + assertEquals("CALL", json.getString("category")); + assertEquals("P1", json.getString("priority")); + assertEquals("duty:jcxx:list", json.getString("targetPermission")); + assertEquals("APT-A", json.getString("gyid")); + assertNotNull(json.getString("id")); + assertEquals(1L, json.getLongValue("seq")); + JSONObject data = json.getJSONObject("data"); + assertEquals("JC2", data.getString("jcid")); + assertEquals(1, data.getIntValue("ryztBefore")); + assertEquals(2, data.getIntValue("ryztAfter")); + assertEquals("ALARM_CALL", data.getString("uiState")); + assertEquals("叫乘报警", data.getString("stateLabel")); + } + + @Test + void alarmMessageAsksClientToPlaySound() + { + service.alarmRaised("JC3", "LEAVE", "JC3:LEAVE"); + + ArgumentCaptor payload = ArgumentCaptor.forClass(String.class); + verify(registry).sendToPermission(anyString(), anyString(), payload.capture()); + JSONObject json = JSON.parseObject(payload.getValue()); + assertEquals("alarm.raised", json.getString("type")); + assertEquals("ALARM", json.getString("category")); + assertEquals("P0", json.getString("priority"), "报警必须是 P0"); + assertTrue(json.getJSONObject("data").getBooleanValue("sound")); + assertEquals("JC3:LEAVE", json.getJSONObject("data").getString("alarmEventKey")); + } + + @Test + void insideTransactionNothingIsSentBeforeCommit() + { + TransactionSynchronizationManager.initSynchronization(); + + long seq = service.stateChanged("JC4", 0, 1, "CALLING", "呼叫中", "ACT-4"); + + assertEquals(1L, seq, "seq 在登记时就分配,命令响应才能回给前端"); + verify(registry, never()).sendToPermission(anyString(), anyString(), anyString()); + + for (TransactionSynchronization synchronization : TransactionSynchronizationManager.getSynchronizations()) + { + synchronization.afterCommit(); + } + verify(registry).sendToPermission(eq("APT-A"), eq("duty:jcxx:list"), anyString()); + } + + @Test + void registryFailureIsSwallowedAndSeqStillReturned() + { + when(registry.sendToPermission(anyString(), anyString(), anyString())) + .thenThrow(new IllegalStateException("channel down")); + + // 通道炸了只影响"送达",不影响"已分配水位";绝不能把异常抛给命令调用方 + assertEquals(1L, service.alarmRaised("JC5", "CALL", "JC5:CALL")); + } + + @Test + void withoutApartmentContextNothingIsPushed() + { + when(properties.getGyId()).thenReturn(" "); + + assertEquals(-1L, service.stateChanged("JC6", 0, 1, "CALLING", "呼叫中", "ACT-6")); + verify(registry, never()).sendToPermission(anyString(), anyString(), anyString()); + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/duty/DutyRealtimeContractTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/duty/DutyRealtimeContractTest.java new file mode 100644 index 0000000..8e11ed5 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/duty/DutyRealtimeContractTest.java @@ -0,0 +1,70 @@ +package com.ruoyi.apt.duty; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import org.junit.jupiter.api.Test; + +/** + * `/ws/duty` 的三条配置契约(无运行时也能守住)。 + * + *

WebSocket 最容易出的坑不是业务,而是"路径没放行 → 握手 401"、 + * "权限点写错 → 订阅了收不到"。这三条用源码断言钉住。

+ * + * @author apt-rev01 + */ +class DutyRealtimeContractTest +{ + @Test + void securityConfigPermitsDutyHandshake() throws IOException + { + String source = read("dev/RuoYi-Vue-master/ruoyi-framework/src/main/java/com/ruoyi/framework/config/SecurityConfig.java"); + assertTrue(source.contains("\"/ws/duty\""), + "SecurityConfig 必须放行 /ws/duty,否则握手会被 anyRequest().authenticated() 拦成 401"); + } + + @Test + void websocketConfigRegistersDutyPath() throws IOException + { + String source = read("dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/realtime/DutyWebSocketConfig.java"); + assertTrue(source.contains("\"/ws/duty\""), "必须注册 /ws/duty 端点"); + assertTrue(source.contains("apt.realtime"), "实时总开关必须复用 apt.realtime.enabled,便于整体降级"); + } + + @Test + void pushUsesDutyPermissionNotApartmentOne() throws IOException + { + String source = read("dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/realtime/DutyPushServiceImpl.java"); + assertTrue(source.contains("\"duty:jcxx:list\""), "值班台推送必须投给 duty:jcxx:list"); + assertTrue(source.contains("afterCommit"), "事务内必须延迟到提交后发送"); + } + + private String read(String relative) throws IOException + { + Path root = locateRepoRoot(); + return Files.readString(root.resolve(relative), StandardCharsets.UTF_8); + } + + private Path locateRepoRoot() + { + Path current = Paths.get(System.getProperty("user.dir")).toAbsolutePath(); + int depth = 0; + while (current != null && depth < 8) + { + if (Files.isDirectory(current.resolve("dev/RuoYi-Vue-master"))) + { + return current; + } + if (Files.isDirectory(current.resolve("RuoYi-Vue-master"))) + { + return current.getParent(); + } + current = current.getParent(); + depth++; + } + throw new IllegalStateException("未找到仓库根目录"); + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/duty/DutySqlContractTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/duty/DutySqlContractTest.java new file mode 100644 index 0000000..8a64df1 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/duty/DutySqlContractTest.java @@ -0,0 +1,95 @@ +package com.ruoyi.apt.duty; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import org.junit.jupiter.api.Test; + +/** + * R1 值班台脚本契约(对齐 `AptIt5SqlContractTest` 的做法)。 + * + * @author apt-rev01 + */ +class DutySqlContractTest +{ + private static final String[] DUTY_PERMS = { "duty:jcxx:list", "duty:jcxx:query", + "duty:call:stop", "duty:call:recall", "duty:call:cancel", "duty:call:forceFinish", + "duty:call:markLeave" }; + + @Test + void permissionScriptRegistersSevenDutyPoints() throws IOException + { + String sql = read("31_duty_permissions.sql"); + for (String perm : DUTY_PERMS) + { + assertTrue(sql.contains(perm), "缺少权限点:" + perm); + } + assertTrue(sql.contains("ON DUPLICATE KEY UPDATE"), "权限脚本必须可重放"); + assertTrue(sql.contains("sys_role_menu"), "缺少超管绑定"); + assertTrue(sql.contains("'F', '0', '0'"), "权限点应为可见的 F 类型"); + } + + @Test + void permissionScriptDoesNotCreatePageMenu() throws IOException + { + String sql = read("31_duty_permissions.sql"); + assertFalse(sql.contains("'C', '0', '0'"), + "R1 不得插 C 页面菜单,否则与前端静态路由 /duty/jcxx 冲突"); + } + + @Test + void permissionScriptNeverWritesBusinessTables() throws IOException + { + String sql = read("31_duty_permissions.sql"); + for (String table : new String[] { "gy_jcxx", "gy_bjxx", "duty_manual_action", + "duty_operation_log" }) + { + assertFalse(sql.contains("INSERT INTO " + table) || sql.contains("UPDATE " + table), + "权限脚本不得写业务表:" + table); + } + } + + @Test + void scriptNeverGrantsAptPrefixedPointsToDuty() throws IOException + { + String sql = read("31_duty_permissions.sql"); + assertFalse(sql.contains("'apt:jcxx:lockMatch'"), + "值班台不注册管理端锁定权限点(应用端/02 表 2-9 命名纪律)"); + assertFalse(sql.contains("'apt:jcxx:changeStatus'"), + "值班台命令走 duty:call:*,不复用管理端 changeStatus"); + } + + private String read(String name) throws IOException + { + Path dir = locateSqlDir(); + assertNotNull(dir, "未找到 dev/sql/apt_rev01"); + return Files.readString(dir.resolve(name), StandardCharsets.UTF_8); + } + + private Path locateSqlDir() + { + Path current = Paths.get(System.getProperty("user.dir")).toAbsolutePath(); + int depth = 0; + while (current != null && depth < 8) + { + Path direct = current.resolve("dev/sql/apt_rev01"); + if (Files.isDirectory(direct)) + { + return direct; + } + Path sibling = current.resolve("sql/apt_rev01"); + if (Files.isDirectory(sibling)) + { + return sibling; + } + current = current.getParent(); + depth++; + } + return null; + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/duty/DutyStateDeriverTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/duty/DutyStateDeriverTest.java new file mode 100644 index 0000000..dad0acd --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/duty/DutyStateDeriverTest.java @@ -0,0 +1,120 @@ +package com.ruoyi.apt.duty; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Date; +import com.ruoyi.apt.duty.domain.DutyStateDeriver; +import com.ruoyi.apt.duty.domain.DutyUiState; +import org.junit.jupiter.api.Test; + +/** + * 值班台状态派生口径(应用端/11 表 11-2)的逐行断言。 + * + *

这张表是前后端唯一口径,任何一行变了都必须先改文档再改这里。

+ * + * @author apt-rev01 + */ +class DutyStateDeriverTest +{ + private static final String[] RYZT_ALL = { "0", "1", "2", "3", "4", "5" }; + private static final DutyUiState[] EXPECTED = { DutyUiState.TODO, DutyUiState.CALLING, + DutyUiState.ALARM_CALL, DutyUiState.LEAVE, DutyUiState.ALARM_LEAVE, DutyUiState.TALKING }; + + @Test + void ryztMapsToSixStates() + { + for (int i = 0; i < RYZT_ALL.length; i++) + { + assertEquals(EXPECTED[i], DutyStateDeriver.derive(RYZT_ALL[i], "0", "0"), + "ryzt=" + RYZT_ALL[i] + " 派生错误"); + } + } + + @Test + void terminalFlagsWinOverRyzt() + { + // 已离寓是终态:即使 ryzt 还是 2,也判 DONE + assertEquals(DutyUiState.DONE, DutyStateDeriver.derive("2", "0", "1")); + // 已停止优先于 ryzt=1 + assertEquals(DutyUiState.STOPPED, DutyStateDeriver.derive("1", "1", "0")); + // 无 ryzt 但有未完成在寓事实 → 已入寓(等待排任务) + assertEquals(DutyUiState.IN, DutyStateDeriver.derive(null, "0", "0")); + assertEquals(DutyUiState.IN, DutyStateDeriver.derive("", "0", "0")); + assertEquals(DutyUiState.IN, DutyStateDeriver.derive("9", "0", "0")); + } + + @Test + void alarmTypeOnlyForAlarmStates() + { + assertEquals("CALL", DutyStateDeriver.alarmType(DutyUiState.ALARM_CALL)); + assertEquals("LEAVE", DutyStateDeriver.alarmType(DutyUiState.ALARM_LEAVE)); + assertNull(DutyStateDeriver.alarmType(DutyUiState.CALLING)); + assertNull(DutyStateDeriver.alarmType(DutyUiState.TODO)); + } + + @Test + void overdueOnlyAppliesToTodo() throws ParseException + { + Date call = at("2026-11-12 08:30:00"); + Date now = at("2026-11-12 08:36:00"); + + assertTrue(DutyStateDeriver.overdue(DutyUiState.TODO, call, now)); + assertFalse(DutyStateDeriver.overdue(DutyUiState.CALLING, call, now)); + // 没有 jcsj(未排任务)不算超时 + assertFalse(DutyStateDeriver.overdue(DutyUiState.TODO, null, now)); + // 未到点不算超时 + assertFalse(DutyStateDeriver.overdue(DutyUiState.TODO, at("2026-11-12 09:00:00"), now)); + } + + @Test + void remainMinutesIsNegativeWhenOverdue() throws ParseException + { + Date now = at("2026-11-12 08:36:00"); + assertEquals(-6L, DutyStateDeriver.remainMinutes(at("2026-11-12 08:30:00"), now).longValue()); + assertEquals(24L, DutyStateDeriver.remainMinutes(at("2026-11-12 09:00:00"), now).longValue()); + assertNull(DutyStateDeriver.remainMinutes(null, now)); + } + + @Test + void urgentCoversBothAlarmsAndOverdueTodo() throws ParseException + { + Date now = at("2026-11-12 08:36:00"); + assertTrue(DutyStateDeriver.urgent(DutyUiState.ALARM_CALL, false)); + assertTrue(DutyStateDeriver.urgent(DutyUiState.ALARM_LEAVE, false)); + assertTrue(DutyStateDeriver.urgent(DutyUiState.TODO, true)); + assertFalse(DutyStateDeriver.urgent(DutyUiState.TODO, false)); + assertFalse(DutyStateDeriver.urgent(DutyUiState.TALKING, false)); + assertFalse(DutyStateDeriver.urgent(DutyUiState.TODO, + DutyStateDeriver.overdue(DutyUiState.TODO, at("2026-11-12 09:30:00"), now))); + } + + @Test + void namesAreMaskedButStillSearchableByFullName() + { + assertEquals("李*", DutyStateDeriver.maskName("李四")); + assertEquals("欧**", DutyStateDeriver.maskName("欧阳锋")); + assertEquals("张*", DutyStateDeriver.maskName("张")); + assertNull(DutyStateDeriver.maskName(null)); + assertEquals("", DutyStateDeriver.maskName("")); + } + + @Test + void needCoversSixStatesButNotTerminal() + { + assertTrue(DutyUiState.TODO.isNeed()); + assertTrue(DutyUiState.ALARM_CALL.isNeed()); + assertTrue(DutyUiState.LEAVE.isNeed()); + assertFalse(DutyUiState.IN.isNeed()); + assertFalse(DutyUiState.DONE.isNeed()); + assertFalse(DutyUiState.STOPPED.isNeed()); + } + + private Date at(String text) throws ParseException + { + return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").parse(text); + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/duty/realtime/DutyTransitionPushTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/duty/realtime/DutyTransitionPushTest.java new file mode 100644 index 0000000..e72ecb2 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/duty/realtime/DutyTransitionPushTest.java @@ -0,0 +1,124 @@ +package com.ruoyi.apt.duty.realtime; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; +import com.ruoyi.apt.dispatch.domain.AptStateSnapshot; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * 状态机迁移推送桥断言(R4 / `L-11`、`04` 表 4-12)。 + * + *

四条边界:报警起停只在真变化时发、读不到新态不猜、推送异常不影响业务、 + * {@code pushSeq} 只回填本线程本次迁移的序号。

+ * + * @author apt-rev01 + */ +class DutyTransitionPushTest +{ + private IDutyPushService pushService; + + private DutyTransitionPush bridge; + + @BeforeEach + void setUp() + { + pushService = mock(IDutyPushService.class); + bridge = new DutyTransitionPush(pushService); + } + + @Test + void turnIntoCallAlarmRaisesSoundWithStableKey() + { + when(pushService.stateChanged(anyString(), any(), any(), anyString(), anyString(), any())) + .thenReturn(11L); + + bridge.afterTransition("JC1", snapshot("1", "0", "0", "0"), snapshot("2", "0", "0", "0"), null); + + verify(pushService).stateChanged("JC1", 1, 2, "ALARM_CALL", "叫乘报警", null); + verify(pushService).alarmRaised("JC1", "CALL", "JC1:CALL"); + verify(pushService, never()).alarmCleared(anyString(), anyString(), anyString()); + } + + @Test + void sameStateTwiceDoesNotEmitFakeClear() + { + // 只改 finish_jc 的 FORCE_FINISH:前后都是 ALARM_CALL,声音必须继续响 + bridge.afterTransition("JC2", snapshot("2", "0", "0", "0"), snapshot("2", "1", "0", "0"), "ACT-2"); + + verify(pushService).stateChanged("JC2", 2, 2, "ALARM_CALL", "叫乘报警", "ACT-2"); + verify(pushService, never()).alarmRaised(anyString(), anyString(), anyString()); + verify(pushService, never()).alarmCleared(anyString(), anyString(), anyString()); + } + + @Test + void resolvedCallAlarmStopsSound() + { + bridge.afterTransition("JC3", snapshot("2", "0", "0", "0"), snapshot("3", "1", "0", "0"), null); + + verify(pushService).stateChanged("JC3", 2, 3, "LEAVE", "待离寓", null); + verify(pushService).alarmCleared("JC3", "CALL", "JC3:CALL"); + verify(pushService, never()).alarmRaised(anyString(), anyString(), anyString()); + } + + @Test + void leaveAlarmUsesLeaveTypeAndKey() + { + bridge.afterTransition("JC4", null, snapshot("4", "0", "0", "0"), null); + + verify(pushService).stateChanged("JC4", null, 4, "ALARM_LEAVE", "离寓报警", null); + verify(pushService).alarmRaised("JC4", "LEAVE", "JC4:LEAVE"); + } + + @Test + void missingSnapshotAfterTransitionSkipsPushAndSeq() + { + bridge.afterTransition("JC5", snapshot("1", "0", "0", "0"), null, null); + + verifyNoInteractions(pushService); + assertEquals(-1L, bridge.takeStateSeq("JC5"), "读不到新态就不该有 pushSeq"); + } + + @Test + void pushFailureIsSwallowedAndSeqIsMinusOne() + { + when(pushService.stateChanged(anyString(), any(), any(), anyString(), anyString(), any())) + .thenThrow(new IllegalStateException("推送通道炸了")); + + assertDoesNotThrow(() -> bridge.afterTransition("JC6", + snapshot("1", "0", "0", "0"), snapshot("2", "0", "0", "0"), null)); + assertEquals(-1L, bridge.takeStateSeq("JC6"), "未登记时 pushSeq 为 -1"); + } + + @Test + void seqIsOneShotAndBoundToJcid() + { + when(pushService.stateChanged(anyString(), any(), any(), anyString(), anyString(), any())) + .thenReturn(21L, 22L); + + bridge.afterTransition("JC7", snapshot("1", "0", "0", "0"), snapshot("2", "0", "0", "0"), null); + assertEquals(21L, bridge.takeStateSeq("JC7")); + assertEquals(-1L, bridge.takeStateSeq("JC7"), "取过一次就必须清掉,避免回填到下一次命令"); + + bridge.afterTransition("JC8", snapshot("1", "0", "0", "0"), snapshot("2", "0", "0", "0"), null); + assertEquals(-1L, bridge.takeStateSeq("JC7"), "jcid 不匹配不能回填"); + assertEquals(22L, bridge.takeStateSeq("JC8")); + } + + private AptStateSnapshot snapshot(String ryzt, String finishJc, String finishLy, String stoped) + { + AptStateSnapshot snapshot = new AptStateSnapshot(); + snapshot.setRyzt(ryzt); + snapshot.setFinishJc(finishJc); + snapshot.setFinishLy(finishLy); + snapshot.setStoped(stoped); + return snapshot; + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/duty/realtime/DutyWebSocketHandlerTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/duty/realtime/DutyWebSocketHandlerTest.java new file mode 100644 index 0000000..2a54d32 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/duty/realtime/DutyWebSocketHandlerTest.java @@ -0,0 +1,125 @@ +package com.ruoyi.apt.duty.realtime; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONObject; +import com.ruoyi.apt.framework.realtime.AptRealtimeSessionRegistry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; + +/** + * `/ws/duty` 端点行为(R3):CONNECTED 握手回执、PING/PONG、非法连接直接关。 + * + *

放在 realtime 包内,才能直接调用 {@code protected handleTextMessage} + * (不为测试把生产方法放宽成 public)。

+ * + * @author apt-rev01 + */ +class DutyWebSocketHandlerTest +{ + private AptRealtimeSessionRegistry registry; + + private IDutyPushService pushService; + + private DutyWebSocketHandler handler; + + private WebSocketSession session; + + private Map attributes; + + @BeforeEach + void setUp() + { + registry = mock(AptRealtimeSessionRegistry.class); + pushService = mock(IDutyPushService.class); + handler = new DutyWebSocketHandler(registry, pushService); + when(pushService.currentSeq()).thenReturn(7L); + + attributes = new HashMap<>(); + attributes.put("userId", 7L); + attributes.put("username", "duty1"); + attributes.put("gyid", "APT-A"); + attributes.put("permissions", Set.of("duty:jcxx:list")); + + session = mock(WebSocketSession.class); + when(session.getId()).thenReturn("S1"); + when(session.getAttributes()).thenReturn(attributes); + when(session.isOpen()).thenReturn(true); + } + + @Test + void connectReturnsHeartbeatAndWatermark() throws Exception + { + handler.afterConnectionEstablished(session); + + verify(registry).register(session, 7L, "duty1", "APT-A", Set.of("duty:jcxx:list")); + + JSONObject message = lastSent(); + assertEquals("CONNECTED", message.getString("type")); + JSONObject data = message.getJSONObject("data"); + assertEquals(30, data.getIntValue("heartbeatIntervalSec"), "心跳间隔必须是 30 s"); + assertEquals(7L, data.getLongValue("lastSeq"), "必须下发当前水位,前端据此丢重"); + assertEquals("S1", data.getString("sessionId")); + assertEquals("APT-A", data.getString("gyid")); + assertTrue(data.getJSONArray("messages").contains("alarm.raised")); + } + + @Test + void pingGetsPongWithEchoedTimestamp() throws Exception + { + handler.afterConnectionEstablished(session); + handler.handleTextMessage(session, new TextMessage("{\"type\":\"PING\",\"ts\":123456}")); + + JSONObject message = lastSent(); + assertEquals("PONG", message.getString("type")); + assertEquals(123456L, message.getJSONObject("data").getLongValue("ts")); + assertEquals(7L, message.getJSONObject("data").getLongValue("lastSeq")); + verify(registry).touch("S1"); + } + + @Test + void plainTextPingIsAccepted() throws Exception + { + handler.afterConnectionEstablished(session); + handler.handleTextMessage(session, new TextMessage("PING")); + assertEquals("PONG", lastSent().getString("type")); + } + + @Test + void connectionWithoutIdentityIsRejected() throws Exception + { + attributes.remove("userId"); + + handler.afterConnectionEstablished(session); + + verify(session).close(CloseStatus.NOT_ACCEPTABLE); + } + + @Test + void closedConnectionIsRemovedFromRegistry() + { + handler.afterConnectionClosed(session, CloseStatus.NORMAL); + verify(registry).remove("S1"); + } + + private JSONObject lastSent() throws Exception + { + ArgumentCaptor captor = ArgumentCaptor.forClass(TextMessage.class); + verify(session, atLeastOnce()).sendMessage(captor.capture()); + List all = captor.getAllValues(); + return JSON.parseObject(all.get(all.size() - 1).getPayload()); + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/voice/AptVoiceEventServiceTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/voice/AptVoiceEventServiceTest.java index 56b7354..4cde813 100644 --- a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/voice/AptVoiceEventServiceTest.java +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/voice/AptVoiceEventServiceTest.java @@ -66,10 +66,11 @@ class AptVoiceEventServiceTest } @Test - void roomConfbridgeJoinKeepsCallingState() + void roomConfbridgeJoinMovesToTalking() { service.handle(command("CONFBRIDGEJOIN")); + // 入会 -> 5 通话中;不能直接跳到待离寓(3) verify(stateService).joinConf("JC-1"); verify(stateService, never()).answered(anyString()); } diff --git a/dev/RuoYi-Vue2-master/src/api/duty/jcxx.js b/dev/RuoYi-Vue2-master/src/api/duty/jcxx.js new file mode 100644 index 0000000..6acf25f --- /dev/null +++ b/dev/RuoYi-Vue2-master/src/api/duty/jcxx.js @@ -0,0 +1,47 @@ +import request from '@/utils/request' + +/** 值班台列表(默认"当日 + 需处理") */ +export function listDutyTasks(params) { + return request({ + url: '/duty/jcxx', + method: 'get', + params: params + }) +} + +/** 吸底统计 + 紧急条数 + serverTime(用于时钟偏差提示) */ +export function getDutyCounts() { + return request({ + url: '/duty/jcxx/counts', + method: 'get' + }) +} + +/** 详情(抽屉):叫乘行 + 会话 + 最近人工动作 */ +export function getDutyTask(jcid) { + return request({ + url: '/duty/jcxx/' + encodeURIComponent(jcid), + method: 'get' + }) +} + +/** 时间线(抽屉):按时间倒序的关键节点 */ +export function getDutyTimeline(jcid) { + return request({ + url: '/duty/jcxx/' + encodeURIComponent(jcid) + '/timeline', + method: 'get' + }) +} + +/** + * 执行状态命令。 + * @param {String} jcid 任务业务键 + * @param {Object} data { commandType, commandId, reason, expectedState } + */ +export function executeDutyCommand(jcid, data) { + return request({ + url: '/duty/jcxx/' + encodeURIComponent(jcid) + '/commands', + method: 'post', + data: data + }) +} diff --git a/dev/RuoYi-Vue2-master/src/api/duty/test.js b/dev/RuoYi-Vue2-master/src/api/duty/test.js new file mode 100644 index 0000000..7252be0 --- /dev/null +++ b/dev/RuoYi-Vue2-master/src/api/duty/test.js @@ -0,0 +1,100 @@ +import request from '@/utils/request' + +/** + * 值班台测试配置(应用端/17)。 + * + * 四组能力:房间信息、房间↔分机绑定、SIP 服务器(AMI)、测试办理入住。 + * 写接口在后端受 apt.debug.enabled 二次保护,关闭时返回 40940。 + */ + +/** 面板状态:debug/voice 开关、AMI 配置、房间与人员概况 */ +export function getTestStatus() { + return request({ + url: '/duty/test/status', + method: 'get' + }) +} + +/** 房间看板:平台基线 + 本端登记 + 当前分机 + 在住人数 */ +export function listTestRooms() { + return request({ + url: '/duty/test/rooms', + method: 'get' + }) +} + +/** 录入房间(留空分机号则按号段自动取号) */ +export function createTestRoom(data) { + return request({ + url: '/duty/test/rooms', + method: 'post', + data: data + }) +} + +/** 分机占用看板 */ +export function listExtensions() { + return request({ + url: '/duty/test/sip/extensions', + method: 'get' + }) +} + +/** 绑定分机号(分机号创建后不可改) */ +export function bindSip(data) { + return request({ + url: '/duty/test/sip/bind', + method: 'post', + data: data + }) +} + +/** 解绑(分机号不回收) */ +export function unbindSip(roomNo) { + return request({ + url: '/duty/test/sip/bind/' + encodeURIComponent(roomNo), + method: 'delete' + }) +} + +/** 当前 AMI 配置(口令只回引用名) */ +export function getSipServer() { + return request({ + url: '/duty/test/sip/server', + method: 'get' + }) +} + +/** 保存 AMI 配置 */ +export function saveSipServer(data) { + return request({ + url: '/duty/test/sip/server', + method: 'put', + data: data + }) +} + +/** 连通性探测(TCP 端口 + 口令是否已注入) */ +export function probeSipServer() { + return request({ + url: '/duty/test/sip/server/probe', + method: 'post' + }) +} + +/** 可办理入住的人员与可住房间 */ +export function getCheckinCandidates() { + return request({ + url: '/duty/test/checkin/candidates', + method: 'get' + }) +} + +/** 测试办理入住(服务端走 apply + 自动通过) */ +export function testCheckin(data) { + return request({ + url: '/duty/test/checkin', + method: 'post', + data: data + }) +} diff --git a/dev/RuoYi-Vue2-master/src/utils/dutyAlarm.js b/dev/RuoYi-Vue2-master/src/utils/dutyAlarm.js new file mode 100644 index 0000000..9cb74f9 --- /dev/null +++ b/dev/RuoYi-Vue2-master/src/utils/dutyAlarm.js @@ -0,0 +1,171 @@ +/** + * 值班台告警音(应用端/11 表 11-9 R3)。 + * + * 三个必须做对的点: + * 1. **单例**:多条报警同时来只播一路声音,否则三张卡片叠在一起像故障; + * 2. **按 key 停**:`alarmEventKey` 对应一条报警,消警只停那一条; + * 3. **首次交互解锁**:浏览器自动播放策略会让"进页面就响"失败, + * 未解锁时先挂起,等值班员点一下页面再补响(不能静默失败)。 + * + * 声音用 Web Audio 合成(不引入音频文件),TTS 用浏览器 speechSynthesis。 + */ + +const state = { + ctx: null, + unlocked: false, + rings: new Map(), + tts: [] +} + +function audioContext() { + if (state.ctx) { + return state.ctx + } + const Ctx = window.AudioContext || window.webkitAudioContext + if (!Ctx) { + return null + } + state.ctx = new Ctx() + return state.ctx +} + +/** 由第一次用户手势调用;成功后把挂起的报警补响 */ +export function unlockAudio() { + if (state.unlocked) { + return true + } + const ctx = audioContext() + if (!ctx) { + return false + } + try { + if (ctx.state === 'suspended') { + ctx.resume() + } + // 播放一个几乎无声的帧,真正把音频通道解锁 + const osc = ctx.createOscillator() + const gain = ctx.createGain() + gain.gain.value = 0.0001 + osc.connect(gain) + gain.connect(ctx.destination) + osc.start() + osc.stop(ctx.currentTime + 0.02) + state.unlocked = true + flushPending() + return true + } catch (e) { + return false + } +} + +export function isUnlocked() { + return state.unlocked +} + +function beep(freq, duration, volume) { + const ctx = audioContext() + if (!ctx) { + return + } + const osc = ctx.createOscillator() + const gain = ctx.createGain() + osc.type = 'triangle' + osc.frequency.value = freq + gain.gain.value = volume + osc.connect(gain) + gain.connect(ctx.destination) + const now = ctx.currentTime + gain.gain.setValueAtTime(volume, now) + gain.gain.exponentialRampToValueAtTime(0.0001, now + duration) + osc.start(now) + osc.stop(now + duration) +} + +function pattern() { + beep(880, 0.18, 0.12) + setTimeout(() => beep(660, 0.18, 0.12), 220) +} + +/** + * 开始一条报警的声音。 + * @returns {Boolean} 是否真的开始播(false 表示被自动播放策略拦住,已挂起) + */ +export function ring(key) { + if (!key) { + return false + } + if (state.rings.has(key)) { + return true + } + if (!state.unlocked) { + state.rings.set(key, { pending: true, timer: null }) + return false + } + pattern() + const timer = setInterval(pattern, 1600) + state.rings.set(key, { pending: false, timer: timer }) + return true +} + +/** 停止某条报警的声音;未知 key 静默忽略 */ +export function stopRing(key) { + const entry = state.rings.get(key) + if (!entry) { + return + } + if (entry.timer) { + clearInterval(entry.timer) + } + state.rings.delete(key) +} + +export function stopAll() { + state.rings.forEach(entry => { + if (entry.timer) { + clearInterval(entry.timer) + } + }) + state.rings.clear() +} + +export function activeKeys() { + return Array.from(state.rings.keys()) +} + +function flushPending() { + const pending = [] + state.rings.forEach((entry, key) => { + if (entry.pending) { + pending.push(key) + } + }) + pending.forEach(key => { + state.rings.delete(key) + ring(key) + }) +} + +/** 语音播报:单队列,避免多条报警同时说话 */ +export function speak(text) { + if (!text || !state.unlocked || !window.speechSynthesis) { + return false + } + state.tts.push(String(text)) + if (state.tts.length > 5) { + state.tts.splice(0, state.tts.length - 5) + } + drain() + return true +} + +function drain() { + if (!window.speechSynthesis || window.speechSynthesis.speaking || !state.tts.length) { + return + } + const utterance = new SpeechSynthesisUtterance(state.tts.shift()) + utterance.lang = 'zh-CN' + utterance.rate = 0.95 + utterance.onend = drain + utterance.onerror = drain + window.speechSynthesis.speak(utterance) +} diff --git a/dev/RuoYi-Vue2-master/src/utils/dutyRedirect.js b/dev/RuoYi-Vue2-master/src/utils/dutyRedirect.js new file mode 100644 index 0000000..a1fbf97 --- /dev/null +++ b/dev/RuoYi-Vue2-master/src/utils/dutyRedirect.js @@ -0,0 +1,68 @@ +/** + * 值班台跳转白名单(应用端/12 表 12-6)。 + * + * 登录页的 ?redirect= 来自 URL,属于不可信输入:不校验就能被打成 + * 开放重定向(?redirect=//evil.com)或死循环(?redirect=/duty/login)。 + * 规则固定为:只允许本站 /duty/ 下的绝对路径。 + */ + +export const DUTY_HOME = '/duty/jcxx' +export const DUTY_LOGIN = '/duty/login' + +/** + * @param {String} raw 原始 redirect(可能被 encodeURIComponent 处理过) + * @returns {String} 安全的目标路径,非法一律回 /duty/jcxx + */ +export function safeRedirect(raw) { + if (!raw) { + return DUTY_HOME + } + let value = String(raw) + try { + value = decodeURIComponent(value) + } catch (e) { + return DUTY_HOME + } + // 协议相对地址(//evil.com)与带协议的绝对地址(http:、javascript:)一律拒绝 + if (value.indexOf('//') >= 0) { + return DUTY_HOME + } + if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(value)) { + return DUTY_HOME + } + if (value.charAt(0) !== '/') { + return DUTY_HOME + } + // 只允许 /duty/ 段;/duty/login 自指会死循环 + if (value.indexOf('/duty/') !== 0) { + return DUTY_HOME + } + if (value.indexOf(DUTY_LOGIN) === 0) { + return DUTY_HOME + } + return value +} + +/** 生成登录页地址(带 redirect 与失效原因),供守卫与 401 处置复用 */ +export function toLoginLocation(fullPath, reason) { + const params = [] + if (fullPath) { + params.push('redirect=' + encodeURIComponent(fullPath)) + } + if (reason) { + params.push('reason=' + encodeURIComponent(reason)) + } + return DUTY_LOGIN + (params.length ? '?' + params.join('&') : '') +} + +/** 登录页 reason 参数对应的提示文案 */ +export function reasonText(reason) { + const map = { + expired: '登录状态已过期,请重新登录', + forceLogout: '管理员已将您强制退出,请重新登录', + noPermission: '当前账号没有值班台权限,请联系管理员开通 duty:*', + blocked: '该账号已停用或已删除,请联系公寓管理员', + pwdChanged: '密码已变更,请用新密码重新登录' + } + return map[reason] || '' +} diff --git a/dev/RuoYi-Vue2-master/src/utils/dutySocket.js b/dev/RuoYi-Vue2-master/src/utils/dutySocket.js new file mode 100644 index 0000000..77b7a03 --- /dev/null +++ b/dev/RuoYi-Vue2-master/src/utils/dutySocket.js @@ -0,0 +1,198 @@ +/** + * 值班台实时通道客户端(应用端/04 表 4-12、11 表 11-9 R3)。 + * + * 四条约束,缺一条都会在生产上出事故: + * 1. 令牌只能走 query(浏览器 WebSocket 不能设 Authorization 头); + * 2. 心跳 30 s,连续 2 个周期没收到 PONG 就主动重连(半开连接靠这个兜); + * 3. 重连指数退避 1/2/4/8/16/30 s + ±20% 抖动,**禁止 location.reload()**; + * 4. 按 seq 丢重与丢乱序:只处理 seq 更大的业务消息。 + * + * 推送只是"更快看到",事实仍以 HTTP 快照为准:每次连上都要回调 onOpen 拉一次列表。 + */ + +import { getToken } from '@/utils/auth' + +const WS_PATH = '/ws/duty' +const HEARTBEAT_MS = 30000 +const PONG_TIMEOUT_MS = 65000 +const BACKOFF_MS = [1000, 2000, 4000, 8000, 16000, 30000] +/** 这些消息不参与 seq 丢重(控制帧) */ +const CONTROL_TYPES = ['CONNECTED', 'PONG'] + +export default class DutySocket { + constructor(options) { + const opts = options || {} + this.onMessage = opts.onMessage || function () {} + this.onStatus = opts.onStatus || function () {} + this.onOpen = opts.onOpen || function () {} + + this.socket = null + this.seq = 0 + this.attempt = 0 + this.manualClosed = false + this.status = 'idle' + this.lastMessageAt = 0 + this.lastPongAt = 0 + this.heartbeatTimer = null + this.watchdogTimer = null + this.reconnectTimer = null + } + + connect() { + const token = getToken() + if (!token) { + this.setStatus('offline') + return + } + this.manualClosed = false + this.clearReconnect() + this.setStatus(this.attempt === 0 ? 'connecting' : 'reconnecting') + + const base = process.env.VUE_APP_WS_BASE || (window.location.protocol === 'https:' ? 'wss://' : 'ws://') + window.location.host + const url = base.replace(/\/$/, '') + WS_PATH + '?token=' + encodeURIComponent(token) + let socket + try { + socket = new WebSocket(url) + } catch (e) { + this.scheduleReconnect() + return + } + this.socket = socket + + socket.onopen = () => { + this.attempt = 0 + this.lastPongAt = Date.now() + this.setStatus('online') + this.startHeartbeat() + this.onOpen() + } + + socket.onmessage = event => { + this.lastMessageAt = Date.now() + let message + try { + message = JSON.parse(event.data) + } catch (e) { + return + } + if (!message || !message.type) { + return + } + if (CONTROL_TYPES.indexOf(message.type) < 0) { + if (typeof message.seq === 'number') { + if (message.seq <= this.seq) { + return // 重复或乱序,丢弃 + } + this.seq = message.seq + } + } else if (message.type === 'CONNECTED' && message.data && typeof message.data.lastSeq === 'number') { + this.seq = Math.max(this.seq, message.data.lastSeq) + } + if (message.type === 'PONG') { + this.lastPongAt = Date.now() + } + this.onMessage(message) + } + + socket.onclose = () => { + this.stopHeartbeat() + if (this.manualClosed) { + this.setStatus('offline') + return + } + this.setStatus(this.attempt >= 3 ? 'degraded' : 'reconnecting') + this.scheduleReconnect() + } + + socket.onerror = () => { + // onclose 一定会随后触发,这里不重复重连 + } + } + + close() { + this.manualClosed = true + this.clearReconnect() + this.stopHeartbeat() + if (this.socket) { + try { + this.socket.close() + } catch (e) { + // 忽略关闭异常 + } + this.socket = null + } + this.setStatus('offline') + } + + startHeartbeat() { + this.stopHeartbeat() + this.heartbeatTimer = setInterval(() => { + if (!this.socket || this.socket.readyState !== 1) { + return + } + try { + this.socket.send(JSON.stringify({ type: 'PING', ts: Date.now() })) + } catch (e) { + this.forceReconnect() + } + }, HEARTBEAT_MS) + + this.watchdogTimer = setInterval(() => { + if (Date.now() - this.lastPongAt > PONG_TIMEOUT_MS) { + this.forceReconnect() + } + }, 15000) + } + + stopHeartbeat() { + if (this.heartbeatTimer) { + clearInterval(this.heartbeatTimer) + this.heartbeatTimer = null + } + if (this.watchdogTimer) { + clearInterval(this.watchdogTimer) + this.watchdogTimer = null + } + } + + forceReconnect() { + this.stopHeartbeat() + if (this.socket) { + try { + this.socket.close() + } catch (e) { + // 忽略 + } + this.socket = null + } + // 手动 close 会走 onclose,但已经 stopHeartbeat,直接把重连排上更稳 + if (!this.manualClosed) { + this.setStatus('reconnecting') + this.scheduleReconnect() + } + } + + scheduleReconnect() { + this.clearReconnect() + const base = BACKOFF_MS[Math.min(this.attempt, BACKOFF_MS.length - 1)] + const jitter = 0.8 + Math.random() * 0.4 + const delay = Math.round(base * jitter) + this.attempt += 1 + this.reconnectTimer = setTimeout(() => this.connect(), delay) + } + + clearReconnect() { + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer) + this.reconnectTimer = null + } + } + + setStatus(status) { + if (this.status === status) { + return + } + this.status = status + this.onStatus(status) + } +} diff --git a/dev/RuoYi-Vue2-master/src/views/duty/config.vue b/dev/RuoYi-Vue2-master/src/views/duty/config.vue new file mode 100644 index 0000000..3f333be --- /dev/null +++ b/dev/RuoYi-Vue2-master/src/views/duty/config.vue @@ -0,0 +1,832 @@ + + + + + diff --git a/dev/RuoYi-Vue2-master/src/views/duty/console.vue b/dev/RuoYi-Vue2-master/src/views/duty/console.vue new file mode 100644 index 0000000..03f6a5d --- /dev/null +++ b/dev/RuoYi-Vue2-master/src/views/duty/console.vue @@ -0,0 +1,1828 @@ + + + + + diff --git a/dev/RuoYi-Vue2-master/src/views/duty/login.vue b/dev/RuoYi-Vue2-master/src/views/duty/login.vue new file mode 100644 index 0000000..9d8abfa --- /dev/null +++ b/dev/RuoYi-Vue2-master/src/views/duty/login.vue @@ -0,0 +1,392 @@ + + + + + diff --git a/dev/sql/apt_rev01/31_duty_permissions.sql b/dev/sql/apt_rev01/31_duty_permissions.sql new file mode 100644 index 0000000..fe32730 --- /dev/null +++ b/dev/sql/apt_rev01/31_duty_permissions.sql @@ -0,0 +1,61 @@ +-- ============================================================================= +-- R1 值班台权限点(可重放)。执行顺序:30_realtime.sql → 本文件。 +-- 口径(应用端/02 表 2-9、应用端/12 表 12-1、应用端/11 表 11-7): +-- 1) 本端前缀统一 duty:,不复用 apt:;不注册 apt:jcxx:lockMatch 等管理端点。 +-- 2) R1 只登记「只读 + 状态命令」共 7 个 F 点:值班台列表/详情读取与 5 个状态命令。 +-- 语音(duty:call:voice/broadcast/hangup)、审计/交接/房态等点随对应批次再登记。 +-- 3) **不建 C 页面菜单**:R1 的 /duty/login 与 /duty/jcxx 走前端静态路由, +-- 若此处再插同名动态路由会出现两条 /duty/jcxx(Layout 版与整屏版)冲突。 +-- C 菜单在 R2 与 entry-duty 动态路由(sys_menu 双载体)一起落。 +-- 4) 不写任何业务表(仅 sys_menu / sys_role_menu)。 +-- ============================================================================= +SET NAMES utf8mb4; + +INSERT INTO sys_menu + (menu_id, menu_name, parent_id, order_num, path, component, query, route_name, + is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +VALUES + (3260, '值班台盯控列表', 3000, 60, '', '', '', '', 1, 0, 'F', '0', '0', + 'duty:jcxx:list', '#', 'R1', NOW(3), + 'R1 GET /duty/jcxx 与 /duty/jcxx/counts;同时是登录准入闸门 2 的判据'), + (3261, '值班台任务详情', 3000, 61, '', '', '', '', 1, 0, 'F', '0', '0', + 'duty:jcxx:query', '#', 'R1', NOW(3), + 'R1 预留(详情抽屉随 T-BE-03 在 R2 启用),用于与列表权限分离'), + (3262, '停止叫乘', 3000, 62, '', '', '', '', 1, 0, 'F', '0', '0', + 'duty:call:stop', '#', 'R1', NOW(3), + 'commands{STOP};前置 ryzt in (1,2) 且 finish_ly=0,reason 必填'), + (3263, '复叫', 3000, 63, '', '', '', '', 1, 0, 'F', '0', '0', + 'duty:call:recall', '#', 'R1', NOW(3), + 'commands{RE_CALL};前置 stoped=1 或 ryzt=2'), + (3264, '取消叫乘', 3000, 64, '', '', '', '', 1, 0, 'F', '0', '0', + 'duty:call:cancel', '#', 'R1', NOW(3), + 'commands{CANCEL};前置 ryzt=0,仅置 stoped=1 不删行'), + (3265, '强制结束', 3000, 65, '', '', '', '', 1, 0, 'F', '0', '0', + 'duty:call:forceFinish', '#', 'R1', NOW(3), + 'commands{FORCE_FINISH};前置 finish_ly=0'), + (3266, '登记离寓', 3000, 66, '', '', '', '', 1, 0, 'F', '0', '0', + 'duty:call:markLeave', '#', 'R1', NOW(3), + 'commands{MARK_LEAVE};写 gygl_ly_event(leave_type=MANUAL) + finish_ly=1') +ON DUPLICATE KEY UPDATE menu_name = VALUES(menu_name), parent_id = VALUES(parent_id), + order_num = VALUES(order_num), menu_type = VALUES(menu_type), visible = VALUES(visible), + status = VALUES(status), perms = VALUES(perms), remark = VALUES(remark); + +-- 超管绑定(其余角色由公寓管理员按岗位授权) +INSERT INTO sys_role_menu (role_id, menu_id) +SELECT 1, m.menu_id +FROM sys_menu m +WHERE m.menu_id IN (3260, 3261, 3262, 3263, 3264, 3265, 3266) + AND NOT EXISTS (SELECT 1 FROM sys_role_menu rm WHERE rm.role_id = 1 AND rm.menu_id = m.menu_id); + +-- 校验 +SELECT '=== 68. 值班台权限点(应 7)===' AS section; +SELECT menu_id, perms, menu_name, status FROM sys_menu +WHERE perms LIKE 'duty:%' AND menu_id BETWEEN 3260 AND 3266 +ORDER BY menu_id; + +SELECT '=== 69. 超管绑定(应 7)===' AS section; +SELECT COUNT(*) AS admin_bound FROM sys_role_menu +WHERE role_id = 1 AND menu_id BETWEEN 3260 AND 3266; + +SELECT '=== 70. 不得出现 duty C 菜单(应 0)===' AS section; +SELECT COUNT(*) AS duty_c_menu FROM sys_menu WHERE perms LIKE 'duty:%' AND menu_type = 'C'; diff --git a/dev/sql/apt_rev01/33_duty_config_columns.sql b/dev/sql/apt_rev01/33_duty_config_columns.sql new file mode 100644 index 0000000..118626e --- /dev/null +++ b/dev/sql/apt_rev01/33_duty_config_columns.sql @@ -0,0 +1,29 @@ +-- ============================================================================= +-- 33 值班台测试配置增量列(R5 / 应用端/17) +-- +-- 目的:SIP 分机可用号码段(如 1010-1050)需要可在值班台配置页录入, +-- 原先只存在于运维口头约定,`apt_ast_instance` 没有承载字段。 +-- 约束: +-- 1) 只做幂等补列,不改既有列、不动任何 CHECK 约束; +-- 2) 分机号本身仍逐条登记在 `apt_sip_endpoint.extension`(号码不复用), +-- 号段只用于「录入时的取值范围校验与空闲号推荐」; +-- 3) 号段为空表示不限制(兼容既有部署)。 +-- ============================================================================= +SET NAMES utf8mb4; +SET SESSION sql_mode = 'STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION'; + +USE apt_rev01; + +CALL apt_add_column_if_missing('apt_ast_instance', 'ext_pool_from', + 'ext_pool_from VARCHAR(8) NULL COMMENT ''可用分机号段起(含),空=不限制'''); + +CALL apt_add_column_if_missing('apt_ast_instance', 'ext_pool_to', + 'ext_pool_to VARCHAR(8) NULL COMMENT ''可用分机号段止(含),空=不限制'''); + +-- 校验 1:两列都存在 +SELECT '=== 33. apt_ast_instance 号段列(应 2)===' AS section; +SELECT COUNT(*) AS ext_pool_columns +FROM information_schema.COLUMNS +WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'apt_ast_instance' + AND COLUMN_NAME IN ('ext_pool_from', 'ext_pool_to'); diff --git a/dev/sql/apt_rev01/34_duty_test_seed.sql b/dev/sql/apt_rev01/34_duty_test_seed.sql new file mode 100644 index 0000000..b556c99 --- /dev/null +++ b/dev/sql/apt_rev01/34_duty_test_seed.sql @@ -0,0 +1,95 @@ +-- ============================================================================= +-- 34 值班台测试前置数据(R5 / 应用端/17) +-- +-- 两件事: +-- ① SIP 服务器(AMI)配置落库:地址 / 端口 / 用户名 / 口令引用 / 号码段。 +-- **口令不落库**:`ami_secret_ref` 只存环境变量名,实际口令由部署环境注入 +-- (21_ami_instance.sql 已断言「口令不落明文」,此处不改口径)。 +-- 本机联调请在 `dev/.env` 或 IDEA 运行配置里设置: +-- APT_AMI_SECRET= +-- ② 为「系统内人员」补齐一条 CONFIRMED 人脸注册记录: +-- 入住申请(N-40)在服务层强制校验「人脸注册已获值班员确认」, +-- 没有这条记录时任何入住都会被 40922 拒绝,叫乘链路无从验证。 +-- 这里只是把前置台账补齐,不绕过任何服务层校验。 +-- ============================================================================= +SET NAMES utf8mb4; +SET SESSION sql_mode = 'STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION'; + +USE apt_rev01; + +-- --------------------------------------------------------------------------- +-- ① AMI / SIP 服务器配置(一公寓一实例,uk_astinstance_gyid) +-- --------------------------------------------------------------------------- +UPDATE apt_ast_instance +SET ami_host = '192.168.1.179', + ami_port = 7058, + ami_user = 'manager01', + ami_secret_ref = 'APT_AMI_SECRET', + ext_pool_from = '1010', + ext_pool_to = '1050', + enabled = 1, + biz_version = biz_version + 1 +WHERE gyid = 'APT-REV01-001' AND deleted = 0; + +-- 若该 gyid 尚无实例行则补一条(同一公寓只允许一条) +INSERT INTO apt_ast_instance + (gyid, instance_code, ami_host, ami_port, ami_user, ami_secret_ref, + tls_enabled, channel_tech, state, enabled, biz_version, ext_pool_from, ext_pool_to, deleted) +SELECT 'APT-REV01-001', 'AST-REV01-001', '192.168.1.179', 7058, 'manager01', 'APT_AMI_SECRET', + 0, 'PJSIP', 'DISABLED', 1, 1, '1010', '1050', 0 +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM apt_ast_instance WHERE gyid = 'APT-REV01-001'); + +-- --------------------------------------------------------------------------- +-- ② 人脸注册确认台账(仅为缺记录的人员补一条,幂等) +-- --------------------------------------------------------------------------- +INSERT INTO apt_person_face_reg + (gyid, reg_id, work_no, name, unit_code, face_feature_ref, feature_version, + person_category, reg_state, local_only, verdict, confirmed_ryid, confirmed_by, confirmed_at, + upload_state, dispatch_state, report_state, face_sync_state, platform_version, + occurred_at, created_at) +SELECT p.gyid, + CONCAT('REG-TEST-', p.ryid), + p.work_no, + p.xm, + NULL, + CONCAT('test://feature/', p.ryid), + COALESCE(p.feature_version, 1), + COALESCE(p.person_category, 'TRANSIENT'), + 'CONFIRMED', + 1, + 'ACCEPTED', + p.ryid, + 'LOCAL-SEED', + NOW(3), + 'PENDING', + 'PENDING', + 'PENDING', + 'SYNCED', + 1, + NOW(3), + NOW(3) +FROM apt_master_person p +WHERE p.gyid = 'APT-REV01-001' + AND COALESCE(p.deleted, 0) = 0 + AND NOT EXISTS ( + SELECT 1 FROM apt_person_face_reg r + WHERE r.gyid = p.gyid AND r.reg_state = 'CONFIRMED' AND r.confirmed_ryid = p.ryid); + +-- --------------------------------------------------------------------------- +-- 自检 +-- --------------------------------------------------------------------------- +SELECT '=== 34. AMI 配置(口令只显示引用名)===' AS section; +SELECT ami_host, ami_port, ami_user, ami_secret_ref, ext_pool_from, ext_pool_to, enabled, state +FROM apt_ast_instance WHERE gyid = 'APT-REV01-001'; + +SELECT '=== 34. 可办理入住的人员数(有 CONFIRMED 注册且当前未在住)===' AS section; +SELECT COUNT(*) AS candidate_person +FROM apt_master_person p +WHERE p.gyid = 'APT-REV01-001' AND COALESCE(p.deleted, 0) = 0 + AND EXISTS (SELECT 1 FROM apt_person_face_reg r + WHERE r.gyid = p.gyid + AND r.reg_state = 'CONFIRMED' AND r.confirmed_ryid = p.ryid) + AND NOT EXISTS (SELECT 1 FROM gy_jcxx j + WHERE j.gyid = p.gyid AND j.ryid = p.ryid AND j.deleted = 0 + AND j.finish_ly = '0' AND j.sjlysj IS NULL); diff --git a/dev/sql/apt_rev01/35_duty_config_permissions.sql b/dev/sql/apt_rev01/35_duty_config_permissions.sql new file mode 100644 index 0000000..fa09f88 --- /dev/null +++ b/dev/sql/apt_rev01/35_duty_config_permissions.sql @@ -0,0 +1,68 @@ +-- ============================================================================= +-- 35 值班台测试配置与测试入住权限点(R5 / 应用端/17) +-- +-- 五个权限点: +-- duty:config:read 查看测试配置面板(房间/绑定/服务器现状) +-- duty:config:room 录入测试房间(补平台基线 + 登记本端房间) +-- duty:config:sip 房间与 SIP 分机绑定/解绑 +-- duty:config:sipServer SIP 服务器(AMI)地址/端口/口令引用/号段 +-- duty:checkin:test 测试办理入住(造入住记录 + 生成叫乘任务) +-- +-- 发放:超级管理员天然拥有 *:*:*;另外发给「公寓值班员」 +-- (role_key = apartmentduty),测试配置由值班员自己维护。 +-- 幂等:按 menu_id 与 (role_id, menu_id) 双重判重。 +-- ============================================================================= +SET NAMES utf8mb4; + +USE apt_rev01; + +-- --------------------------------------------------------------------------- +-- 权限点 +-- --------------------------------------------------------------------------- +INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, + is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT 3270, '测试配置查看', 0, 20, '', NULL, 1, 0, 'F', '0', '0', 'duty:config:read', '#', 'apt-rev01', NOW(), '值班台测试配置面板只读' +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE menu_id = 3270); + +INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, + is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT 3271, '测试房间录入', 0, 21, '', NULL, 1, 0, 'F', '0', '0', 'duty:config:room', '#', 'apt-rev01', NOW(), '补平台房间基线并登记本端房间(仅测试环境)' +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE menu_id = 3271); + +INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, + is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT 3272, '房间SIP绑定', 0, 22, '', NULL, 1, 0, 'F', '0', '0', 'duty:config:sip', '#', 'apt-rev01', NOW(), '房间与 SIP 分机绑定/解绑' +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE menu_id = 3272); + +INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, + is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT 3273, 'SIP服务器配置', 0, 23, '', NULL, 1, 0, 'F', '0', '0', 'duty:config:sipServer', '#', 'apt-rev01', NOW(), 'AMI 地址/端口/用户名/口令引用/号段' +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE menu_id = 3273); + +INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, + is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT 3274, '测试办理入住', 0, 24, '', NULL, 1, 0, 'F', '0', '0', 'duty:checkin:test', '#', 'apt-rev01', NOW(), '为系统内人员造入住记录并生成叫乘任务(仅测试环境)' +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE menu_id = 3274); + +-- --------------------------------------------------------------------------- +-- 发放给「公寓值班员」 +-- --------------------------------------------------------------------------- +INSERT INTO sys_role_menu (role_id, menu_id) +SELECT r.role_id, m.menu_id +FROM sys_role r +JOIN sys_menu m ON m.menu_id IN (3270, 3271, 3272, 3273, 3274) +WHERE r.role_key = 'apartmentduty' + AND NOT EXISTS (SELECT 1 FROM sys_role_menu rm + WHERE rm.role_id = r.role_id AND rm.menu_id = m.menu_id); + +-- --------------------------------------------------------------------------- +-- 自检 +-- --------------------------------------------------------------------------- +SELECT '=== 35. 新增权限点(应 5)===' AS section; +SELECT menu_id, menu_name, perms FROM sys_menu WHERE menu_id BETWEEN 3270 AND 3274 ORDER BY menu_id; + +SELECT '=== 35. 值班员已获授权(应 5)===' AS section; +SELECT COUNT(*) AS granted +FROM sys_role_menu rm +JOIN sys_role r ON r.role_id = rm.role_id +WHERE r.role_key = 'apartmentduty' AND rm.menu_id BETWEEN 3270 AND 3274; diff --git a/dev/sql/local_seed/duty_demo_tasks.sql b/dev/sql/local_seed/duty_demo_tasks.sql new file mode 100644 index 0000000..e081c30 --- /dev/null +++ b/dev/sql/local_seed/duty_demo_tasks.sql @@ -0,0 +1,96 @@ +-- ============================================================================ +-- 值班台(应用端)演示用叫乘任务 —— 本地联调数据,不是迁移脚本 +-- +-- 用途:在本地库造几条覆盖全部 UI 状态的 gy_jcxx 行,用来打开 /duty/jcxx 看效果。 +-- 特征:jcid 一律以 JC-DEMO- 开头,可重放(先按前缀删除再插入), +-- 时刻全部基于 NOW() 推算,保证落在值班台「今日 [00:00, 明日 00:00)」窗口内。 +-- 注意:这是唯一写入口之外的手工造数,只允许出现在本地联调库; +-- 生产/测试环境的叫乘数据只能由签到与叫乘链路产生。 +-- 库名与 gyid 对齐 dev/.env 的 APT_DB_NAME / APT_GY_ID。 +-- ============================================================================ + +USE apt_rev01; + +SET @gyid = 'APT-REV01-001'; + +DELETE FROM gy_jcxx +WHERE gyid = @gyid AND jcid LIKE 'JC-DEMO-%'; + +INSERT INTO gy_jcxx + (gyid, jcid, cc, xm, fjh, kcsj, jcsj, ryzt, finish_jc, finish_ly, stoped, + call_mode, biz_version, occurred_at, received_at, deleted, + jcsj_s, hjxysj, jcbjsj_s, fjsj_s, lybjsj, sjlysj, + is_jcbj, is_lybj, is_temp_call, leave_remark) +VALUES + -- 1 待叫乘 + 已过点(应进入「紧急」区,urgent=true) + (@gyid, 'JC-DEMO-001', 'G1234', '张伟', '201', + NOW(3) + INTERVAL 3 HOUR, NOW(3) - INTERVAL 30 MINUTE, '0', '0', '0', '0', + 'SIP', 1, NOW(3), NOW(3), 0, + NULL, NULL, NULL, NULL, NULL, NULL, + '0', '0', '0', NULL), + + -- 2 待叫乘 + 未到点(普通待办,remainMinutes 为正) + (@gyid, 'JC-DEMO-002', 'D5678', '李强', '202', + NOW(3) + INTERVAL 4 HOUR, NOW(3) + INTERVAL 2 HOUR, '0', '0', '0', '0', + 'SIP', 1, NOW(3), NOW(3), 0, + NULL, NULL, NULL, NULL, NULL, NULL, + '0', '0', '0', NULL), + + -- 3 呼叫中(ryzt=1,平板链路) + (@gyid, 'JC-DEMO-003', 'K9012', '王芳', '301', + NOW(3) + INTERVAL 3 HOUR, NOW(3) - INTERVAL 3 MINUTE, '1', '0', '0', '0', + 'TABLET', 1, NOW(3), NOW(3), 0, + NOW(3) - INTERVAL 3 MINUTE, NULL, NULL, NULL, NULL, NULL, + '0', '0', '0', NULL), + + -- 4 叫乘报警(ryzt=2,SIP 30s 无入会;值班台应置红 + 起报警音) + (@gyid, 'JC-DEMO-004', 'Z3456', '赵敏', '302', + NOW(3) + INTERVAL 3 HOUR, NOW(3) - INTERVAL 8 MINUTE, '2', '0', '0', '0', + 'SIP', 1, NOW(3), NOW(3), 0, + NOW(3) - INTERVAL 8 MINUTE, NULL, NOW(3) - INTERVAL 3 MINUTE, NULL, NULL, NULL, + '1', '0', '0', NULL), + + -- 5 待离寓(ryzt=3,叫乘已完成) + (@gyid, 'JC-DEMO-005', 'G7890', '陈刚', '303', + NOW(3) + INTERVAL 1 HOUR, NOW(3) - INTERVAL 1 HOUR, '3', '1', '0', '0', + 'SIP', 1, NOW(3), NOW(3), 0, + NOW(3) - INTERVAL 1 HOUR, NOW(3) - INTERVAL 50 MINUTE, NULL, NULL, NULL, NULL, + '0', '0', '0', NULL), + + -- 6 离寓报警(ryzt=4,超时应离寓) + (@gyid, 'JC-DEMO-006', 'D1122', '刘晓', '201', + NOW(3) - INTERVAL 1 HOUR, NOW(3) - INTERVAL 2 HOUR, '4', '1', '0', '0', + 'SIP', 1, NOW(3), NOW(3), 0, + NOW(3) - INTERVAL 2 HOUR, NOW(3) - INTERVAL 110 MINUTE, NULL, NULL, + NOW(3) - INTERVAL 5 MINUTE, NULL, + '0', '1', '0', NULL), + + -- 7 已停止(stoped=1) + (@gyid, 'JC-DEMO-007', 'G3344', '孙悦', '202', + NOW(3) + INTERVAL 2 HOUR, NOW(3) - INTERVAL 20 MINUTE, '1', '0', '0', '1', + 'SIP', 1, NOW(3), NOW(3), 0, + NOW(3) - INTERVAL 20 MINUTE, NULL, NULL, NULL, NULL, NULL, + '0', '0', '0', NULL), + + -- 8 已完成并离寓(finish_ly=1,应落在「已完成」筛选) + (@gyid, 'JC-DEMO-008', 'T5566', '周涛', '301', + NOW(3) - INTERVAL 2 HOUR, NOW(3) - INTERVAL 3 HOUR, '3', '1', '1', '0', + 'TABLET', 1, NOW(3), NOW(3), 0, + NOW(3) - INTERVAL 3 HOUR, NOW(3) - INTERVAL 170 MINUTE, NULL, NULL, NULL, + NOW(3) - INTERVAL 30 MINUTE, + '0', '0', '0', '演示数据:已完成并登记离寓'), + + -- 9 已入寓但未排任务(jcsj 为空,任何时间窗都必须可见) + -- 注意:ck_jcxx_ryzt 只允许 '0'~'5',所以这里不能写空串(DutyStateDeriver 的 + -- "ryzt 空 = IN 已入寓" 在库里不可达,见交付说明 L-14) + (@gyid, 'JC-DEMO-009', 'G7788', '吴磊', '302', + NOW(3) + INTERVAL 5 HOUR, NULL, '0', '0', '0', '0', + 'SIP', 1, NOW(3), NOW(3), 0, + NULL, NULL, NULL, NULL, NULL, NULL, + '0', '0', '0', NULL); + +-- 自检:各 UI 状态各 1 条 + 1 条未排任务 +SELECT jcid, fjh, xm, ryzt, finish_jc, finish_ly, stoped, jcsj, call_mode +FROM gy_jcxx +WHERE gyid = @gyid AND jcid LIKE 'JC-DEMO-%' +ORDER BY jcid; diff --git a/docs/应用端/11-应用端UI开发计划.md b/docs/应用端/11-应用端UI开发计划.md new file mode 100644 index 0000000..efa4dce --- /dev/null +++ b/docs/应用端/11-应用端UI开发计划.md @@ -0,0 +1,227 @@ +# 11 应用端 UI 开发计划 + +> **唯一视觉与交互基线**:`docs/公寓端/ui/duty-console-v2.html`(单文件、零依赖、780 行;下文行号均指该文件)。 +> 追溯:`docs/10` §9 表 9-1~9-6(阶段)、§11.2 表 11-1(优先级);`docs/应用端/07` 表 7-2(D-1~D-3 子批次)、表 7-3(验证项)、表 7-4(页面顺序)。 +> 契约依据:`docs/应用端/02` 表 2-5(`entry-duty` 目录)、表 2-7(D1~D10 路由)、表 2-9(`duty:*` 权限点);`04` 表 4-2(`ryzt` 呈现)、表 4-6(命令矩阵)、表 4-7(命令请求响应)、表 4-12(`/ws/duty` 消息)。 +> 流程依据:`docs/应用端/05` 图 5-1(任务到期 → 值班员处置)、图 5-4(临叫三路径)、表 5-5(告警判据)。 +> **本文只做计划,不写业务代码、不建表改表**;任务编号 `T-FE-*` / `T-BE-*` 为工程内部编号,**不新增批次、不改 07 的 D-1~D-3 口径**。 + +## 0. 本文定位 + +三句话: + +1. **范围**:只覆盖 UI 稿已经画出来的那一页 —— `D1 叫乘盯控台`(含 `D2` 抽屉里被 UI 吸收的 4 个行内动作)。`D3~D10` 本期不排,等出稿。 +2. **粒度**:`07` 是批次级(D-1/D-2/D-3 与验收项),本文是任务级(每个组件、每个接口、每个人日),两者是"表 7-2 拆到工作项"的关系。 +3. **口径**:UI 稿与 `04` 契约有 6 处对不上(§4),**先定缺口再开工**;缺口未定前对应任务标 `阻塞`,不允许"先按 UI 写着看"。 + +## 1. UI 基线盘点 + +### 1.1 六个区域 + +表 11-1 UI 稿的区域、组件与代码行号 + +| # | 区域 | 内容 | 行号 | 落地形态 | +| --- | --- | --- | --- | --- | +| 1 | 顶栏 `header.hd` | 标题+一句话副标题、系统状态徽标、时钟+班次、值班员、`刷新列表`/`临时叫乘`/`交接班`、界面版本切换 | `276~298` | `components/DutyHeader.vue` | +| 2 | 需马上处理 | 最多 3 张紧急卡片 + 计数 + 空态"当前没有需要马上处理的事项" | `300~305`、`531~547` | `components/DutyAlerts.vue` | +| 3 | 工具栏 | 筛选段 `需处理/全部/已完成`(带计数)、搜索框(房间/姓名/车次)、清空、`卡片显示/列表显示` | `307~321`、`516~530` | `components/DutyToolbar.vue` | +| 4 | 任务区 | 卡片布局(房间+徽标+姓名+车次+两个时刻+提示+主操作)与列表布局(6 列) | `559~584` | `DutyTaskCard.vue` / `DutyTaskRow.vue` | +| 5 | 吸底条 | `待叫乘/呼叫与通话/报警` 三个数 + "最近操作"一行 | `325~333` | `components/DutyFooter.vue` | +| 6 | 三个弹窗 + Toast | 停止叫乘(原因 4 选 1)、交接班(汇总未处理数)、临时叫乘(房间/时刻/车次)、4.5 s Toast | `335~365`、`639~721` | `components/dialogs/*`、`shared/Toast` | + +### 1.2 状态与主操作 + +UI 用 8 个 `st` + 1 个 `alarm` 标志表达行状态(`370~371`、`446~468`),必须换算成 `04` 表 4-2 的 `ryzt` 组合,否则前后端各判一套,超时提示与按钮会漂移。 + +表 11-2 UI 状态 → 后端字段 → 主操作(**前端派生的唯一口径**) + +| UI 状态 | 判定(后端字段) | 显示文字 | 主操作 | 对应命令 | 备注 | +| --- | --- | --- | --- | --- | --- | +| `todo` | `ryzt=0` 且 `jcsj` 非空 | ● 待叫乘 | 立即叫乘 | **无(`G-UI-01`)** | 提示"距叫乘还有 N 分钟" | +| `todo+overdue` | 同上且 `serverTime > jcsj` | ● 待叫乘(红) | 立即叫乘 | **无(`G-UI-01`)** | 提示"已过叫乘时间 N 分钟" | +| `calling` | `ryzt=1` | ● 呼叫中 | 确认接通 | **无(`G-UI-02`)** | 提示"正在呼叫,请等待对方接听" | +| `talking` | `ryzt=5` | ● 通话中 | 登记离寓 | `MARK_LEAVE` | 会议桥已建立 | +| `alarm`(`jc`) | `ryzt=2` | ! 叫乘报警 | 复叫 | `RE_CALL` | 30 s 无入会 | +| `alarm`(`ly`) | `ryzt=4` | ! 离寓报警 | 登记离寓 | `MARK_LEAVE` | `is_lybj=1` | +| `leave` | `ryzt=3` | ● 待离寓 | 登记离寓 | `MARK_LEAVE` | 已应答待离寓 | +| `in` | `finish_ly=0` 且 `ryzt` 为空/无 `jcsj` | ○ 已入寓 | 无 | — | "等待生成叫乘任务" | +| `stop` | `stoped=1` | × 已停止 | 重新叫乘 | `RE_CALL` | 主操作是"重新叫乘"而非"取消" | +| `done` | `finish_ly=1` | ✓ 已完成 | 无 | — | 主操作区显示"已结束,无需操作" | + +两条纪律:①这张表只允许有**一份实现**(`utils/dutyState.js` 纯函数),卡片、列表、紧急卡片、吸底统计全部调用它;②`overdue` 必须用服务端时间基准(`serverTime` + 本地单调时钟补偿),**不得**直接用浏览器 `Date.now()` 判超时。 + +### 1.3 技术换算 + +UI 稿是原生 JS 单文件原型,落地要换三样东西,其余原样保留: + +表 11-3 原型 → 工程的换算规则 + +| 原型做法 | 工程做法 | 保留不变 | +| --- | --- | --- | +| `:root` CSS 变量 + `body[data-mode]` 覆盖密度 | `styles/duty-tokens.scss` 同名变量,`` | 变量名、两版数值(正文 18px / 按钮 56px / 3px 边) | +| `localStorage` 键 `duty.mode`、`duty.layout` | 同名同键,禁止改名(跨版本记忆) | 键名与取值 `senior/normal`、`card/list` | +| `TASKS` 数组 + `render()` 全量重绘 | Vue 组件 + 行级响应式更新(`state.changed` 只改单行) | 派生函数(`stats`/`isUrgent`/`noteOf`/`primaryOf`)逐条照搬 | +| `toast()` 4.5 s 自动消失 | `shared/Toast` 单例 | 4.5 s 与"最近操作"不覆盖业务记录 | + +## 2. UI → 接口 → 代码落点 + +表 11-4 每个可见交互的接口与落点(**空白单元格 = 契约缺口,见 §4**) + +| UI 交互 | 行号 | 接口 / 命令 | 权限点 | 幂等 | 后端落点 | 前端落点 | +| --- | --- | --- | --- | --- | --- | --- | +| 首屏列表 | `604~607` | `GET /duty/jcxx` | `duty:jcxx:list` | L0 | `DutyJcxxController` → `apt.duty.view` | `api/duty/jcxx.js#list` | +| 刷新列表 | `748` | 同上(复用,不新增接口) | 同上 | L0 | 同上 | `useDutyList#refresh` | +| 筛选/搜索 | `746~747`、`768` | 无(本地过滤 + `pageSize≤100`) | — | — | — | 同上(`computed`) | +| 停止叫乘 | `754` → `676~687` | `commands{STOP,reason}` | `duty:call:stop` | L2 | `apt.duty.state` | `useCommand` | +| 复叫 / 重新叫乘 | `753` | `commands{RE_CALL}` | `duty:call:recall` | L2 | 同上 | 同上 | +| 登记离寓 | `753` | `commands{MARK_LEAVE}` | `duty:call:markLeave` | L2 | 同上 | 同上 | +| 立即叫乘 | `632` | **缺口 `G-UI-01`** | `duty:call:*`? | L2 | — | — | +| 确认接通 | `634` | **缺口 `G-UI-02`** | — | — | — | — | +| 临时叫乘登记 | `697~721` | **缺口 `G-UI-03`** | `duty:jcxx:assignElement`? | L2 | — | — | +| 交接班 | `752` → `661~670` | `POST /duty/handover`(本期置灰) | `duty:handover:*` | L2 | `apt-duty` | `DutyHandoverDialog` | +| 吸底统计 | `592~603` | 缺口 `G-UI-04`(或本地派生) | — | — | — | `utils/dutyState.js#stats` | +| 紧急卡片 | `531~547` | 无(前端派生 `isUrgent`) | — | — | — | 同上 | +| 实时行更新 | 全页 | `GET /ws/duty`:`state.changed`/`alarm.raised`/`alarm.cleared`/`session.changed` | 会话即可 | — | `DutyWebSocketHandler` | `shared/ws/duty.js` | +| 界面版本 / 布局 | `728~745` | 无(纯前端) | — | — | — | `useUiPref` | + +**一条硬约束**:UI 的"最近操作"只记业务动作(叫乘/离寓/停止/交接),**界面版本与布局切换不得写进去**(原型 `732`、`741` 已按此实现),否则值班员看不到自己刚才的业务动作。 + +## 3. 与 `07` 的差异 + +UI 稿是"去掉干扰项"的极简版(文件头 `13~14` 行自述:去掉时间线、录音、排序、分页、批量、快捷键、下拉筛选)。这与 `07` 表 7-2 的 D-2 全量范围**不等价**,必须先认账,否则 D-2 的验收项会凭空失效。 + +表 11-5 UI 稿与 D-2 范围的差异及处理 + +| 差异 | UI 稿 | `07` D-2 要求 | 处理 | +| --- | --- | --- | --- | +| 任务详情抽屉 D2 | 无(行内不展开) | `GET /duty/jcxx/{jcid}` + timeline | 接口照做(详情可从列表行数据 + 时间线抽屉直达),**UI 二期加"详情"文字按钮**,不进一期 | +| 语音临叫 / 喊话 / 多房间 / 挂断 | 无按钮 | 4 个接口 + `40908` 校验 | 二期;一期顶栏留"语音"占位不可见,`APT_VOICE_ENABLED=false` 时不渲染 | +| 要素指定 `ASSIGN_ELEMENT` / 时刻调整 `ADJUST_TIME` | 无入口 | D-2 必做 | 二期;一期的"临时叫乘"若按 `G-UI-03` 方案 A 落地,则等同 `ASSIGN_ELEMENT` 的一个子集 | +| 导出 Excel | 无 | `POST /duty/jcxx/export` | 二期,且**不做自动下载**(`04` 表 4-14) | +| 报警音 / TTS / 降级横幅 / 时钟偏差黄条 | UI 无(原型不发声) | D-2 必做 | **进一期**:无声音的报警台等于没有报警,`T-FE-08/09` 不后置 | +| 二次确认阈值(`ADJUST_TIME` ≤/>30 min) | 无 | `07` 表 7-3 有 2 条验收 | 随二期 `ADJUST_TIME` 一起做,一期不占验收项 | +| **新增**:双版本密度(适老/正常) | 有 | `07` 未提 | 进一期(老值班员是本页主要用户),新增 `T-FE-02`、`T-FE-12` | +| **新增**:卡片/列表双布局 | 有 | `07` 未提 | 进一期,新增 `T-FE-04` | +| **新增**:紧急卡片"最多 3 条、处理完自动消失" | 有 | `07` 只要求"行置红 + 报警音" | 进一期,纯前端派生 | + +结论:**一期 = UI 稿 + 声音 + 实时 + 幂等**(`07` 表 7-3 中 D-1 全部 + D-2 的 6 条推送/降级项);**二期 = 抽屉/语音/要素/时刻/导出**(D-2 剩余项)。`07` 表 7-2 的批次划分不因此改动,只在批次内部拆"一期/二期"。 + +## 4. 缺口与待决策(开工前必须定) + +表 11-6 UI 与契约的 7 个缺口 + +| # | 缺口 | 现象 | 为什么不能直接做 | 选项 | 默认建议 | 阻塞任务 | +| --- | --- | --- | --- | --- | --- | --- | +| `G-UI-01` | 立即叫乘(人工触发 `ryzt=0 → 1`) | UI 主按钮 `respond`,`04` 表 4-6 无此命令 | `RE_CALL` 前置是 `stoped=1` 或 `ryzt=2`,对 `ryzt=0` 直接 `40902`;用 `ADJUST_TIME` 把 `jcsj` 改成现在再等 Quartz,也要 30 s 以上 | A 新增 `CALL_NOW` 命令(新枚举 + 新权限点 `duty:call:now`);B 前端只"提示即将自动叫乘",删掉按钮;C 复用临叫接口(`04` 表 4-10 #8)但会 `ryzt` 语义分裂(`Q-17` 未决) | **B 先落地一期**(UI 按钮改为"等待自动叫乘,剩余 N 分钟"),A 单独立 `Q-20` 决策后再做 | `T-FE-07`、`T-BE-04` | +| `G-UI-02` | 确认接通(人工置 `talking`) | UI 主按钮 `confirm`,原型直接把行改 `talking` | 接通判据是 AMI `ConfbridgeJoin`(`05` 图 5-1),人工置 `talking` 会让状态机与真实会议桥脱节;且 `HANGUP` 回退逻辑依赖"是否已应答" | A 删除该按钮,`calling` 行主操作改为"挂断";B 保留但改调 `HANGUP`;C 新增"人工确认接通"命令(需同步改 `ConfbridgeJoin` 判据,风险高) | **A**:`calling` 主操作 = 挂断(`HANGUP`),接通由系统判定,值班员只看状态 | `T-FE-05`、`T-FE-07` | +| `G-UI-03` | 临时叫乘"登记一条新任务" | UI 输房间/时刻/车次后 `TASKS.unshift()`,并"立即叫乘" | `40909` 明确"**不存在为某人建 jcxx 行的接口**"(`docs/设计变更/02` R-26);新行还需要 `ryid`/`jlid`,否则永远 `UNMATCHED` | A 只对**已入寓且有行**的人补要素(= `ASSIGN_ELEMENT` 子集:房间→`jcid`→`kcsj`/`cc`,`jcsj` 由服务端按规则算);B 新增临时加开建单接口(`apt_local_task.origin=LOCAL_TEMP_EXTRA`),需改 CR-01 结论与错误码;C 本期移除该按钮 | **A**,且服务端**不信任**前端算的"开车=叫乘+30 分钟"(`04` §3.3:派生值只允许服务端算) | `T-FE-10`、`T-BE-04` | +| `G-UI-04` | 吸底统计与紧急卡片计数 | UI 由全量 `TASKS` 派生 | 列表 `pageSize` 上限 100 且默认"当日+未完成",分页后前端算不全;跨时间窗会漏 | A 由后端给 `GET /duty/jcxx/counts`(`todo/call/alarm/need/done/urgent` 六个整数);B 前端只统计当前页并在数字后标"(本页)" | **A**(约 0.5 人日,避免"报警 3 条"实际 13 条) | `T-BE-05`、`T-FE-05` | +| `G-UI-05` | "停止叫乘"按钮显隐过宽 | UI 在 `calling/talking/alarm/leave` 都显示停止(`468`) | `STOP` 只允许 `ryzt in (1,2)`;`talking/leave` 点了必 `40902`,值班员会以为系统坏了 | A 按 `ryzt` 收缩:仅 `ryzt∈{1,2}` 显示 `STOP`;`ryzt∈{3,4,5}` 显示"登记离寓";B 保留按钮并靠错误提示兜底 | **A**,并在 `utils/dutyState.js` 用同一张表驱动显隐 | `T-FE-05` | +| `G-UI-06` | 交接班按钮本期置灰 | UI 是可用主流程(含汇总未处理数) | `07` 表 7-2:D7 本期置灰,`APT_DUTY_HANDOVER_ENABLED=false` | A 按开关置灰 + `title`"本期未开放" + 点击只 Toast 不发请求;B 直接隐藏 | **A**(保留入口便于培训与二期开闸) | `T-FE-11`、`T-BE-08` | +| `G-DOC-01` | UI 目录无说明文件 | `docs/应用端/README` 与 `07` 均引用 `docs/公寓端/ui/README` 表 1.1(D1~D10 出稿状态),该文件**不存在** | 实施方按图施工时找不到"哪些页有稿" | A 补一页 `ui/README.md`(10 行:D1~D10 出稿状态 + 本文链接);B 改引用指向本文 §1 | **A**(10 分钟,避免二次误引) | 文档任务 | + +**缺口处理的纪律**:`G-UI-01`/`G-UI-02`/`G-UI-03` 未定前,对应 UI 按钮**要么删、要么置灰**,不允许"先做成可点、后端日后补"。理由是这三处一旦先上线,值班员会形成"点了就该叫"的预期,而实际情况是没叫或叫了两次。 + +## 5. 开发任务拆解 + +人日为"1 名熟练开发的有效工时",不含评审与联调返工。 + +表 11-7 前端任务(`RuoYi-Vue2` 工程,`src/entries/duty/**`) + +| # | 任务 | 主要文件 | 依赖 | 人日 | +| --- | --- | --- | --- | --- | +| `T-FE-01` | 入口与路由骨架:`/duty/jcxx` 页面、`sys_menu` 兜底、`/duty/:pathMatch(.*)*` 重定向 | `entries/duty/router/index.js`、`views/jcxx/DutyConsole.vue` | `T-BE-01` | 2 | +| `T-FE-02` | 设计令牌与两版密度:`scss` 变量、`data-mode`、`localStorage duty.mode` | `styles/duty-tokens.scss`、`useUiPref.js` | — | 1.5 | +| `T-FE-03` | 顶栏:系统状态徽标(接 `GET /duty/health` 的 `callReady`/`degraded`)、时钟+班次、值班员、三按钮 | `components/DutyHeader.vue` | `T-FE-01` | 1.5 | +| `T-FE-04` | 任务卡片 + 列表双布局 + 布局记忆(`duty.layout`),切换保持筛选/搜索/滚动位置 | `DutyTaskCard.vue`、`DutyTaskRow.vue` | `T-FE-02` | 2.5 | +| `T-FE-05` | 状态派生纯函数与单测:`deriveState/primaryAction/visibleActions/noteOf/isUrgent/stats`(按表 11-2 + `G-UI-05`) | `utils/dutyState.js`、`tests/dutyState.spec.js` | — | 2 | +| `T-FE-06` | 筛选段(含计数)+ 搜索 + 空态 + 紧急卡片(≤3 条、自动消失、空态文案) | `DutyToolbar.vue`、`DutyAlerts.vue` | `T-FE-05` | 1.5 | +| `T-FE-07` | 命令通道:`commandId`(uuid,重试复用)、`expectedState`、`duplicated` 处理、`409xx` 人话文案(`40902`→"该行已被他人处理,请刷新") | `api/duty/jcxx.js`、`useCommand.js` | `T-BE-04` | 2 | +| `T-FE-08` | WS 客户端:握手、`ping/pong` 30 s、指数退避 1→30 s、`seq` 乱序丢弃、重连后拉一次列表快照、断流 60 s 兜底轮询 | `shared/ws/duty.js`、`useDutySocket.js` | `T-BE-06` | 2.5 | +| `T-FE-09` | 告警音单例 + TTS 单队列 + 时钟偏差黄条(>5 s) | `shared/audio/AlarmVoiceController.js` | `T-FE-08` | 2 | +| `T-FE-10` | 三个弹窗:停止(原因必选,"其他"≥2 字)、交接班(汇总数,置灰)、临时叫乘(`G-UI-03` 方案 A 字段);默认焦点在"取消"、`Esc` 关闭不提交 | `components/dialogs/*` | `T-FE-07` | 2 | +| `T-FE-11` | 降级与置灰:`callReady=false`、`APT_VOICE_ENABLED`、`APT_DUTY_HANDOVER_ENABLED`、`outbox.backlog` 横幅 | `useFeatureFlags.js` | `T-FE-03` | 1 | +| `T-FE-12` | 适老与无障碍走查:点击区 ≥56px、焦点环 4px、对比度 AA、1024×768 无横向滚动、纯键盘可完成"停止叫乘" | 全局 | `T-FE-04`/`06`/`10` | 1.5 | +| `T-FE-13` | 值班员登录页 `/duty/login`:账号/密码/验证码/记住账号、字段级校验、失败文案映射与验证码刷新(详见 `12` 表 12-2/12-3/12-5) | `views/login/DutyLogin.vue`、`api/duty/login.js` | `T-BE-09` | 1.5 | +| `T-FE-14` | 准入守卫与失效重定向:`guard.js`、`safeRedirect()`、401 单飞跳登录、退出清 WS 与告警音(详见 `12` §5/§6/§7) | `entries/duty/router/guard.js`、`shared/request/duty401.js` | `T-FE-13` | 1 | +| | **合计** | | | **24.5** | + +表 11-8 后端任务(`apt-duty` 模块,包结构见 `02` 表 2-2) + +| # | 任务 | 主要类 / 文件 | 依赖 | 人日 | +| --- | --- | --- | --- | --- | +| `T-BE-01` | 模块骨架 + `duty:*` 权限点 SQL + `@DutyLog` 切面 + 6 条 ArchUnit 断言 | `apt-duty/pom.xml`、`sys_menu` 初始化、`DutyOperationAspect` | — | 2 | +| `T-BE-02` | `GET /duty/jcxx`:默认"当日+未完成"、`pageSize≤100`、排序(紧急→未完成→`jcsj` 升序)、`jcsj/kcsj` 与 `alarmFlag` 出参 | `DutyJcxxController`、`DutyJcxxQueryService`、`DutyJcxxMapper.xml` | `T-BE-01` | 2 | +| `T-BE-03` | `GET /duty/jcxx/{jcid}` + `.../timeline`(供二期抽屉,一期不阻塞) | 同上 | `T-BE-02` | 1.5 | +| `T-BE-04` | `POST /duty/jcxx/{jcid}/commands`:8 命令门面、`commandId` 幂等、`expectedState` 乐观校验、`ryztBefore/After` | `DutyCommandService`、`ManualActionRepository` | `T-BE-01`、`apt.duty.state` | 2.5 | +| `T-BE-05` | `GET /duty/jcxx/counts`(`G-UI-04` 方案 A) | `DutyJcxxQueryService#counts` | `T-BE-02` | 0.5 | +| `T-BE-06` | `/ws/duty`:握手校验 `gyid`、`seq` 单调、`state.changed`/`alarm.*`/`session.changed` 三类必需消息、心跳 30 s | `DutyWebSocketHandler`、`DutyPushService` | `T-BE-04` | 2.5 | +| `T-BE-07` | 报警事件:`alarmEventKey` 生成、`alarm.raised`/`alarm.cleared` 成对、停叫后不再重发 | `DutyPushService` + `apt.dispatch` 事件订阅 | `T-BE-06` | 1 | +| `T-BE-08` | `POST/GET /duty/handover` 占位:开关关闭返回 `50102`,开关打开才写 `duty_shift_handover` | `DutyHandoverController` | `T-BE-01` | 0.5 | +| `T-BE-09` | `/duty/**` 准入拦截器:权限点 `duty:jcxx:list` + `gyid` 绑定校验,失败 `40301`/`40302`(详见 `12` §1) | `apt-duty/web/DutyAccessInterceptor` | `T-BE-01` | 0.5 | +| | **合计** | | | **13** | + +## 6. 迭代排期 + +五轮,每轮结束必须**能演示**(不是"代码提交了")。总工作量 ≈ 37.5 人日 ≈ 3 人 × 13 个工作日(含 1 天缓冲)。 + +表 11-9 五轮迭代与退出条件 + +| 轮 | 主题 | 任务 | 演示脚本(评审当场跑) | 退出条件 | 人日 | +| --- | --- | --- | --- | --- | --- | +| R1 | 骨架 + 只读 | `T-BE-01~03`、`T-BE-09`、`T-FE-01~04`、`T-FE-06`、`T-FE-13~14` | 未登录直达 `/duty/jcxx` 被重定向到 `/duty/login`;登录后回跳原深链;用真实 `gy_jcxx` 数据打开列表,切两版密度与卡片/列表,搜 305 命中 | 无 404/白屏;1024×768 无横向滚动;字号与点击区达标;`12` 表 12-11 第 1~5、9~11 项通过 | 14 | +| R2 | 状态 + 命令 | `T-BE-04`、`T-FE-05`、`T-FE-07`、`T-FE-10`(停止/交接班) | 对一条 `ryzt=1` 发停止(选原因)→ 行变"已停止";同 `commandId` 连点 3 次只有 1 条 `duty_manual_action` | 4 条命令可达且留痕;非法态给 `40902` 人话;`G-UI-01/02/05` 已按建议处置 | 9 | +| R3 | 实时 + 告警 | `T-BE-06~07`、`T-FE-08~09` | 拔网线 30 s → 5 s 内重连、页面不刷新;制造一条 `NO_ANSWER` → 行置红 + 报警音响 + TTS | `seq` 倒退被丢弃;断流 90 s 由兜底轮询补齐;停叫后 10 min 不重响 | 8 | +| R4 | 弹窗补齐 + 降级 | `T-BE-05`、`T-BE-08`、`T-FE-11`、`T-FE-10`(临时叫乘,按 `G-UI-03` A) | 停 Asterisk → 顶栏状态变"降级"且语音类置灰;交接班按钮置灰且无写请求;临时叫乘补要素成功 | 降级不静默、不禁用整页;置灰按钮点击无网络请求 | 4 | +| R5 | 无障碍 + 演练 | `T-FE-12` + 全量验收 | 老值班员盲操作 5 条任务;断网 2 h 演练一次 | 表 11-10 全部通过;操作步骤增幅 ≤10% | 2.5 | + +**为什么 R2 在 R3 之前**:命令通道是"能被信任"的前提,实时只是"更快看见"。顺序反过来会出现"推送很顺但点了没反应"的最差体验,且 R3 的验收(停叫后不重响)本身依赖 R2 的 `commandId` 与 `duty_manual_action`。 + +## 7. 验收清单 + +表 11-10 一期 16 条可复现验收(每条都要能在评审现场跑出来) + +| # | 验收项 | 通过标准 | +| --- | --- | --- | +| 1 | 首屏 | 骨架 ≤1 s、数据 ≤2 s;空数据显示"这里暂时没有任务"且可一键查看全部 | +| 2 | 超时提示 | `ryzt=0` 且过点:行红色 + "已过叫乘时间 N 分钟",`N` 与 `serverTime` 一致(浏览器时钟改慢 10 min 不影响) | +| 3 | 时钟偏差 | 偏差 >5 s 时顶部黄条,且**不阻塞**任何操作 | +| 4 | 状态映射 | 8 个 UI 状态与表 11-2 逐行一致(造 8 条数据一次看全) | +| 5 | 按钮显隐 | 仅 `ryzt∈{1,2}` 出现"停止叫乘";`ryzt∈{3,4,5}` 出现"登记离寓"(`G-UI-05`) | +| 6 | 命令幂等 | 同 `commandId` 连发 3 次 → `duty_manual_action` 1 行,第 2/3 次 `duplicated=true` | +| 7 | 乐观校验 | `expectedState` 不符 → `40902` 且提示"该行已被他人处理,请刷新",行状态不本地跳变 | +| 8 | 停止原因 | 未选原因不能提交;选"其他原因"且 <2 字给出字段级错误 | +| 9 | 弹窗焦点 | 打开即在"取消";`Esc` 关闭且不发请求;回车不误确认 | +| 10 | 布局切换 | 卡片/列表互切后筛选、搜索词、滚动位置、数据完全不变;刷新页面仍记住 | +| 11 | 断线重连 | 断网 30 s:5 s 内重连、页面不刷新、`commandId` 不重置、未提交的弹窗内容不丢 | +| 12 | 推送可靠性 | 构造 `seq` 倒退被丢弃;断流 90 s 出现"数据可能滞后"并可手动刷新 | +| 13 | 报警音 | 3 行同时报警只播 1 条(单例);逐行 dismiss 各自停;停叫后列表回包不触发重响 | +| 14 | 降级 | 停 Asterisk:顶栏"降级"、语音入口置灰 + `title` 说明;`STOP`/`MARK_LEAVE` 仍可用 | +| 15 | 交接班置灰 | 开关关闭时按钮置灰、点击仅提示"本期未开放",网络面板**无** `/duty/handover` 请求 | +| 16 | 适老与无障碍 | 适老版正文 ≥18px、点击区 ≥56px、对比度 AA;纯键盘可完成"搜索 305 → 停止叫乘 → 选原因 → 确认" | + +## 8. 风险 + +表 11-11 五项风险与缓解 + +| # | 风险 | 触发信号 | 缓解 | +| --- | --- | --- | --- | +| 1 | `G-UI-01/02/03` 未定就开工 | 前端按原型做出"可点但无效"的按钮 | 三个按钮先删/置灰(§4 纪律);`O-UI` 决策单 3 项挂 R1 评审 | +| 2 | 双版本密度维护成本翻倍 | 两版样式各自打补丁,出现"某按钮只在一版对齐" | 只允许覆盖**变量**(`--fs-*`/`--tap`/`--bd`),禁止写版本专属选择器;R1 后冻结变量表 | +| 3 | 派生口径前后端各写一套 | 同一行在卡片显示"待叫乘"、超时区显示"报警" | 表 11-2 为唯一口径,前端 `dutyState.js` 有单测;后端出参补 `derivedState` 便于对账 | +| 4 | 报警音被浏览器自动播放策略拦截 | 首次进入无声音,值班员以为没报警 | 首次交互解锁(`T-FE-09`);未解锁时顶栏显示"音频未就绪"并可点击重试 | +| 5 | 旧页 `JcXx.vue` 与新页并存期间口径漂移 | 同一任务两页状态不一致 | 新页上线期间旧页只读保留(`07` §8 切换顺序);差异以 `gy_jcxx` 为准,不做双写 | + +## 9. 交付物与提交纪律 + +表 11-12 每轮交付物 + +| 轮 | 代码 | 文档 | 验证记录 | +| --- | --- | --- | --- | +| R1 | `entries/duty` 页面骨架、`DutyLogin.vue`、`router/guard.js`、`duty-tokens.scss`、卡片/列表组件 | 两版密度变量表、登录准入四闸门说明(`12` 表 12-1) | 1024/1366/1920 三张截图 + 未登录重定向截图 | +| R2 | `useCommand`、`dutyState.js` + 单测、停止/交接班弹窗 | 表 11-2 落地说明、`G-UI-05` 收缩依据 | 表 11-10 第 4~9 项输出 | +| R3 | `shared/ws/duty.js`、`AlarmVoiceController`、`/ws/duty` 后端 | WS 消息清单与 `seq` 规则 | 断网录像 + `seq` 乱序用例日志 | +| R4 | 计数接口、临时叫乘、降级开关 | 缺口决策回填(`G-UI-01~04` 结论) | 停 Asterisk 演练记录 | +| R5 | 无障碍修正 | 走查问题清单与修复对照 | 老值班员盲操作录像 + 表 11-10 全项 | + +**提交纪律**:本轮只产出文档;实施阶段每轮一个 PR,PR 描述必须引用本文件表号(例:`表 11-6 G-UI-05`),便于回溯"这个按钮为什么只有两种状态"。不执行 `git commit` / `git push`。 diff --git a/docs/应用端/12-值班员登录与准入鉴权.md b/docs/应用端/12-值班员登录与准入鉴权.md new file mode 100644 index 0000000..7ca7067 --- /dev/null +++ b/docs/应用端/12-值班员登录与准入鉴权.md @@ -0,0 +1,279 @@ +# 12 值班员登录与准入鉴权 + +> **目标**:值班员必须先通过 `/duty/login` 完成身份验证,才能进入 `D1 叫乘盯控台`;未登录、会话失效、权限不足三种情况一律不渲染盯控台,并自动回到登录页。 +> 追溯:`docs/应用端/02` §6 表 2-8(`/duty/**` 拦截器链)、表 2-9(`duty:*` 权限点);`04` 表 4-4(错误码);`06` 表 6-2(Nginx 两条 location);`11` 表 11-7/11-8(前端/后端任务)。 +> 现状依据:`RuoYi-Vue-master` 的 `SysLoginService`/`UserDetailsServiceImpl`/`SysPasswordService`、`i18n/messages.properties`;`RuoYi-Vue2-master` 的 `src/permission.js`、`src/utils/request.js`、`src/views/login.vue`。 +> **本文只做设计,不改 RuoYi 认证实现**(`docs/公寓端/08` 差-3:不魔改框架);不新增后端认证接口,不新增错误码。 + +## 0. 一条需要登记的修订 + +`02` §6 与 `README` 表 0-3 结论 8 写的是"**不做独立登录页**(复用 `/login`)"。本次要求"值班员登录页面",与之冲突,处理方式拆成两句: + +- **页面**:新增独立页 `/duty/login`(`entry-duty` 内),理由是值班台要大字大按钮、要显示公寓名与班次、且不能让值班员落到管理端 `/index` 的业务首页。 +- **认证**:**仍然复用** RuoYi 的 `/login`、`/captchaImage`、`/getInfo`、`/getRouters`、`/logout`,**不做独立 token 签发、不新建认证服务、不改 `TokenService`**。所以 §6 的另外两条"不做"继续成立。 + +本次修订登记为 `差-D5`(接在 `08` 表 8-4 的 `差-D1~差-D4` 之后),并回改 `02` §6 第一条表述;`06` 的 Nginx 配置**不需要改**(`/duty` 与 `/apt` 已指向同一 SPA,`try_files … /index.html` 天然覆盖 `/duty/login`)。 + +## 1. 准入的四个闸门 + +登录成功不等于能进盯控台。四个闸门全部通过才渲染,任一失败都回到登录页并给出具体原因。 + +表 12-1 准入闸门(顺序固定,不可合并) + +| # | 闸门 | 判定 | 失败表现 | 谁负责 | +| --- | --- | --- | --- | --- | +| 1 | 会话 | `getToken()` 存在且 `/getInfo` 返回 200 | 跳 `/duty/login?redirect=…&reason=expired` | 前端守卫 + 后端 `JwtAuthenticationTokenFilter` | +| 2 | 权限 | `permissions` 含 `duty:jcxx:list`(或角色含 `duty_operator`) | 登录页提示"当前账号无值班台权限,请联系管理员开通 `duty:*`";后端 `40301` | 前端守卫 + `@PreAuthorize` | +| 3 | 数据域 | `gyid` 非空且等于 `sys_user` 绑定的公寓 | 提示"账号未绑定公寓或绑定不一致";后端 `40302` | 后端 `AptContextInterceptor` + 新增 `DutyAccessInterceptor` | +| 4 | 路由 | `getRouters` 已加载且包含 `/duty/jcxx` | 提示"菜单未授权",不白屏 | 前端守卫 | + +**为什么第 2 闸门要用 `duty:jcxx:list` 而不是角色名**:`sys_menu.perms` 是权限点真源(`02` §5 差-7),角色是授权容器;用权限点判定可与 `@PreAuthorize` 同码,避免"前端放行、后端拒绝"。 + +后端新增一个轻量拦截器就够,**不写业务分支**: + +```java +// apt-duty/web/DutyAccessInterceptor.java(新增,约 40 行) +// 只做三件事:会话是否有效(框架已做,此处兜底)→ 是否具备 duty:* 任一查询权限 → gyid 是否绑定 +// 不通过时抛 AptException(40301 / 40302),不返回页面、不写审计 +``` + +## 2. 登录页面 + +### 2.1 输入项 + +表 12-2 `/duty/login` 的字段与默认值 + +| # | 字段 | 控件 | 必填 | 默认值 | 说明 | +| --- | --- | --- | --- | --- | --- | +| 1 | 账号 `username` | 文本框,`autocomplete="username"` | 是 | **空** | 值班员工号或值班员账号;**禁止预填**(现状 `login.vue:78~79` 预填 `admin/admin123`,值班台不得沿用) | +| 2 | 密码 `password` | 密码框 + 显示/隐藏切换,`autocomplete="current-password"` | 是 | 空 | 不允许粘贴被禁用(值班员需要从密码条粘贴) | +| 3 | 验证码 `code` | 4 位文本框 + 图片,点击图片刷新 | 是 | 空 | 受 `captchaEnabled` 开关控制,**生产必须开启** | +| 4 | 记住账号 `rememberMe` | 复选框 | 否 | 上次值 | **只记账号**(30 天),不记密码;现状 `login.vue:137~145` 把密码加密后写 Cookie,本页**不采用** | +| 5 | 公寓与班次 | 只读文本 | — | 登录前隐藏 | 登录成功后由 `/getInfo` 返回的公寓名 + 当前班次填充 | +| 6 | 登录按钮 | 主按钮(56px,适老版 64px) | — | — | 提交中 `loading` 且禁用,防重复提交 | + +页面另外两条:①**不提供"注册"入口**(`register=false`),值班员账号由公寓管理员在管理端创建;②**提供"忘记密码请联系管理员"** 的一行静态说明,不做自助重置(`sys_user` 改密归管理端)。 + +### 2.2 校验规则 + +前端即时校验只为了少一次往返,**后端仍是权威**(规则来自 `messages.properties` 的 `user.username.not.valid` / `user.password.not.valid`)。 + +表 12-3 校验规则 + +| 字段 | 前端规则 | 触发时机 | 提示文案 | 后端权威规则 | +| --- | --- | --- | --- | --- | +| 账号 | 必填;`trim` 后 2–20 字符;汉字/字母/数字/下划线;**非数字开头** | `blur` | "账号为 2–20 位字母、数字或下划线,且不能以数字开头" | `user.username.not.valid` | +| 密码 | 必填;5–50 字符;不含空格 | `blur` | "密码为 5–50 个字符" | `user.password.not.valid` | +| 验证码 | 必填;恰好 4 位;字母大小写不敏感 | `change` | "请输入 4 位验证码" | Redis 比对,`Constants.CAPTCHA_EXPIRATION = 2` 分钟,**一次一用** | +| 提交 | 三字段全部通过才发请求;请求中禁止重复点击;连续失败后按钮进入 3 s 冷却 | `click` | — | — | + +三条实现要求:①`trim` 只作用于账号,密码**不做任何 trim 或大小写处理**;②校验失败时焦点落到第一个错误字段(键盘可达);③错误提示显示在字段下方,**不用全局 Toast 代替字段级错误**。 + +## 3. 登录接口与反馈 + +### 3.1 请求与响应 + +表 12-4 登录相关接口(全部为既有接口,无新增) + +| 步 | 接口 | 入参 | 出参 | 说明 | +| --- | --- | --- | --- | --- | +| 1 | `GET /captchaImage` | — | `captchaEnabled`、`uuid`、`img`(base64) | 进页面即拉;刷新图片只换 `uuid` + 图 | +| 2 | `POST /login` | `username`、`password`、`code`、`uuid` | `token` | 成功同时写 `sys_logininfor`(框架内建) | +| 3 | `GET /getInfo` | — | `user`、`roles`、`permissions` | 取 `gyid`、公寓名、班次 | +| 4 | `GET /getRouters` | — | 菜单树 | 生成 `/duty/**` 动态路由 | +| 5 | `POST /logout` | — | — | 见 §7 | + +`/login` 失败时框架返回 `code=500` + 中文 `msg`(不是 `401xx`)。**不魔改控制器**:前端按 `msg` 关键词映射成值班台文案;若后续要结构化,只在 `GlobalExceptionHandler` 增加映射,不改 `SysLoginService`。 + +### 3.2 失败反馈 + +表 12-5 失败场景 → 文案 → 页面动作(文案与 `messages.properties` 一一对应) + +| # | 后端 `msg`(原文) | 触发条件 | 值班台文案 | 页面动作 | +| --- | --- | --- | --- | --- | +| 1 | `验证码错误` | 验证码不匹配 | "验证码不正确,已刷新,请重新输入" | 清空验证码 + 刷新图片 + 焦点回验证码框;**保留账号与密码** | +| 2 | `验证码已失效` | 超过 2 分钟 | "验证码已过期,已刷新" | 同上 | +| 3 | `用户不存在/密码错误` | 账号不存在或密码错 | "账号或密码错误(今日还可尝试 N 次)" | 清空密码 + 焦点回密码框 + 刷新验证码;**保留账号**;`N` 由前端按本地失败次数推算,仅作提示 | +| 4 | `密码输入错误5次,帐户锁定10分钟` | 触发 `maxRetryCount=5` | "密码连续错误 5 次,账号已锁定 10 分钟,请 10 分钟后再试或联系管理员" | 禁用登录按钮并显示倒计时;刷新验证码 | +| 5 | `用户已封禁,请联系管理员` | `status='1'` | "该账号已停用,请联系公寓管理员" | 禁用登录按钮(需改配置才能恢复) | +| 6 | `对不起,您的账号已被删除` | `del_flag='2'` | "该账号不存在,请联系公寓管理员" | 同上 | +| 7 | `很遗憾,访问IP已被列入系统黑名单` | IP 黑名单 | "当前设备网络被限制访问,请联系管理员" | 禁用登录按钮 | +| 8 | `角色已封禁,请联系管理员` | `sys_role.status='1'` | "账号角色已停用,请联系公寓管理员" | 同上 | +| 9 | 网络错误 / 超时 / `50301` | 网关或服务不可用 | "系统暂时不可用,请稍后重试" | 保留全部输入,可重试;**不清验证码** | +| 10 | 登录成功但无 `duty:jcxx:list` | 闸门 2 失败 | "登录成功,但当前账号没有值班台权限,请联系管理员开通 `duty:*`" | 调 `/logout` 清 token,**留在登录页**(不跳 `/duty`) | +| 11 | 登录成功但 `gyid` 为空 | 闸门 3 失败 | "账号未绑定公寓,请联系管理员" | 同上 | + +三条纪律:①**密码错误与账号不存在必须给同一句文案**(防账号枚举,框架原文已是"用户不存在/密码错误",前端不得拆开);②**任何失败都要刷新验证码**(除第 9 条网络错误),否则同一张图可被反复试;③**失败不写"最近操作"**(那是业务动作区),只写 `sys_logininfor`。 + +### 3.3 成功反馈 + +- 按钮变 `loading` → 成功后 Toast "登录成功"(1.5 s,不阻塞)。 +- 依次完成 `getInfo` → `getRouters` → 闸门 2/3 判定,**全部通过才跳转**。 +- 跳转用 `router.replace()`,使浏览器"后退"不能回到登录页(避免误以为已退出)。 + +## 4. 跳转逻辑 + +表 12-6 目标页解析规则(`redirect` 必须过白名单) + +| `redirect` 取值 | 判定 | 目标 | +| --- | --- | --- | +| 空 / `undefined` | — | `/duty/jcxx` | +| 以 `/duty/` 开头且不含 `//`、`://`、`/duty/login` | 合法 | 原值(例如 `/duty/jcxx?jcid=JC…`) | +| `/apt/**`、`/index`、`/platform/**` 等其它前缀 | 越界 | `/duty/jcxx` | +| 以 `//`、`http://`、`https://` 开头 | 开放重定向攻击 | `/duty/jcxx`,并记一条前端 warn | +| `/duty/login` | 自指,会死循环 | `/duty/jcxx` | + +**现状缺陷必改**:`login.vue:147` 直接 `this.$router.push({ path: this.redirect })`,未做任何校验,`?redirect=//evil.com` 可被打成外部跳转;值班台必须用表 12-6 的白名单函数 `safeRedirect(raw)`。 + +跳转前还要满足两个条件,否则会出现"进入 `/duty/jcxx` 却 404 或被踢回登录":①`getRouters` 已 `addRoutes` 完成;②`gyid` 已写入 store。守卫的写法见 §6。 + +## 5. 会话维持与失效 + +表 12-7 会话参数(沿用后端配置,不新增) + +| 项 | 值 | 来源 | +| --- | --- | --- | +| token 有效期 | 30 分钟 | `application.yml` `token.expireTime: 30` | +| 自动续期窗口 | 剩余 < 20 分钟时,任何 `/duty/**` 请求触发续期(框架 `TokenService` 行为) | 同上 | +| 验证码有效期 | 2 分钟,一次一用 | `Constants.CAPTCHA_EXPIRATION` | +| 密码错误锁定 | 5 次 / 锁 10 分钟 | `user.password.maxRetryCount`、`lockTime` | +| 存储位置 | Cookie(`Admin-Token`),与公寓端同键 | `utils/auth.js` | + +表 12-8 失效场景与统一处置 + +| 场景 | 触发 | 处置 | 是否保留页面 | +| --- | --- | --- | --- | +| 会话过期 | 任一接口 `401` | 清 store + 关 WS + **直接** `replace('/duty/login?redirect=&reason=expired')` | 否 | +| 被强制退出 | `/getInfo` 返回 `user.forcelogout` | 同上,`reason=forceLogout`,文案"管理员已将您强制退出,请重新登录" | 否 | +| 账号被停用/删除 | `40301` 且 `msg` 含"封禁"/"删除" | 清 token,`reason=blocked` | 否 | +| 密码被管理员重置 | 后端拒绝旧 token(框架 `sys_user.pwd_update_date` 校验) | 同"会话过期",`reason=pwdChanged` | 否 | +| 网络中断 | 请求超时(非 401) | **不跳登录页**,顶部显示"网络中断,正在重试" | 是 | +| 多标签页 | A 页退出 | 其他标签下次请求 `401` 后同样跳登录,`redirect` 各自记住 | 否 | + +**现状缺陷必改**(三条,直接违反本次需求): + +1. `request.js:88` 弹"继续留在该页面?"确认框,选"取消"就留在过期页 —— 改为**不给取消选项**,`401` 无条件跳登录。 +2. `request.js:91` 跳的是 `location.href = '/index'`(管理端首页),应为 `/duty/login` 且带 `redirect`。 +3. 12 个并发请求同时 `401` 会弹 12 次 —— 用 `isRelogin` 单飞标志 + 一个待跳转队列,由守卫统一执行一次跳转。 + +## 6. 路由守卫 + +```js +// src/entries/duty/router/guard.js(新增) +const WHITE = ['/duty/login'] +router.beforeEach(async (to, from, next) => { + if (WHITE.includes(to.path)) { + return getToken() ? next('/duty/jcxx') : next() // 已登录访问登录页 → 直接进盯控台 + } + if (!getToken()) { + return next(`/duty/login?redirect=${encodeURIComponent(to.fullPath)}`) + } + if (store.getters.roles.length === 0) { + try { + await store.dispatch('GetInfo') // 闸门 1 + 3 + if (!store.getters.permissions.includes('duty:jcxx:list')) { + await store.dispatch('LogOut') // 闸门 2 + return next('/duty/login?reason=noPermission') + } + const routes = await store.dispatch('GenerateRoutes') + router.addRoutes(routes) + return next({ ...to, replace: true }) // 确保 addRoutes 完成再进 + } catch (e) { + return next(`/duty/login?reason=expired&redirect=${encodeURIComponent(to.fullPath)}`) + } + } + next() +}) +``` + +图 12-1 登录到进入盯控台的完整链路 + +```mermaid +sequenceDiagram + autonumber + participant U as 值班员 + participant L as /duty/login + participant API as /login /getInfo /getRouters + participant G as 守卫 guard.js + participant I as DutyAccessInterceptor + participant D as D1 盯控台 + U->>L: 账号 + 密码 + 验证码 + L->>API: POST /login + alt 失败 + API-->>L: code=500 + msg + L-->>U: 字段级提示 + 刷新验证码 + else 成功 + API-->>L: token + L->>API: GET /getInfo(闸门 1/3) + L->>API: GET /getRouters + L->>L: 闸门 2 权限点校验 + L->>G: replace(safeRedirect) + G->>I: GET /duty/jcxx + I-->>D: 放行(或 40301/40302) + end +``` + +## 7. 退出、切换用户与交接班 + +表 12-9 三种"离开"的区别 + +| 动作 | 入口 | 触发 | 必须清理 | 不清理 | +| --- | --- | --- | --- | --- | +| 退出登录 | 顶栏"退出"(新增,放在值班员名字下拉里) | 二次确认 | token、store(roles/permissions/user)、WS 连接、告警音单例、`alarmEventKey` 集合、页面级筛选 | `duty.mode`、`duty.layout`、`rememberMe` 账号 | +| 切换用户 | 退出后再登录 | 同上 | 同上 + 上一位值班员的"最近操作"文案 | 同上 | +| 交接班 | 顶栏"交接班"(本期置灰,`G-UI-06`) | 二次确认 | **不登出、不换 token**,只写 `duty_shift_handover` | 全部保持不变 | + +**为什么交接班不等于退出**:同一台工作站、同一个人继续值班是常态;交接班是业务动作(把未处理事项交给下一班),不是身份变化。若当成退出,会让 `duty_operation_log` 的操作人断裂。 + +**切换用户必须清 WS 与告警音**:否则上一位值班员的报警音会在新用户会话里继续响,且 `alarm.cleared` 对应不上(`05` 表 5-5 的 `alarmEventKey` 是会话级状态)。 + +## 8. 安全约束 + +表 12-10 九条硬约束 + +| # | 约束 | 理由 | 落地 | +| --- | --- | --- | --- | +| 1 | 不预填演示账号 | `login.vue` 现状 `admin/admin123`,值班台直接暴露默认口令 | 删掉默认值,评审时检查 DOM | +| 2 | 不存明文或可逆密码 | 现状 `rememberMe` 用 `encrypt()` 写 Cookie 30 天,前端可逆 | 只记账号;密码框 `autocomplete` 交给浏览器密码管理器 | +| 3 | 生产必须开验证码 | 共享工作站,无验证码等于可暴力猜 | `sys_config` 的 `sys.account.captchaEnabled=true`,列入 `06` 部署检查项 | +| 4 | 失败刷新验证码 | 一张图反复试 | 表 12-5 第 1~5 条 | +| 5 | 凭据不进 URL | `redirect` 只带路径,不带 `token`/`password` | 表 12-6 白名单 | +| 6 | 防开放重定向 | `?redirect=//evil.com` | `safeRedirect()` 单测覆盖 6 种取值 | +| 7 | 日志脱敏 | 失败日志会带 `username`,**不得**带密码或验证码 | 后端已有;前端 `console` 不得打印 `loginForm` | +| 8 | 强制 HTTPS | token 走 Cookie | Nginx 301 + `Secure`;内网试点至少禁用明文外网 | +| 9 | 无人值守自动锁屏 | 值班台 24 h 开机,人离开后无保护 | 复用现成 `/lock`(`store/modules/lock.js`,15 分钟无操作触发),解锁需重输密码 | + +## 9. 验收清单 + +表 12-11 十三条可复现验收 + +| # | 验收项 | 通过标准 | +| --- | --- | --- | +| 1 | 未登录直达 | 浏览器直接开 `/duty/jcxx?jcid=JC1` → 落到 `/duty/login?redirect=%2Fduty%2Fjcxx%3Fjcid%3DJC1`,**不出现**盯控台骨架 | +| 2 | 登录成功回跳 | 输入正确后回到原 `jcid` 深链页,且浏览器后退不回登录页 | +| 3 | 默认跳转 | 直接开 `/duty/login` 登录成功 → `/duty/jcxx` | +| 4 | 开放重定向 | `?redirect=//evil.com`、`?redirect=/apt/room`、`?redirect=/duty/login` 三种 → 均落 `/duty/jcxx` | +| 5 | 字段校验 | 账号 1 位、账号数字开头、密码 4 位、验证码 3 位 → 各自字段级提示,且**不发请求**(网络面板为空) | +| 6 | 密码错误 | 提示"账号或密码错误",账号保留、密码清空、验证码刷新;连续 5 次后提示锁定 10 分钟且按钮禁用 | +| 7 | 验证码错误/过期 | 提示对应文案并自动刷新图片,密码不清空 | +| 8 | 账号停用 | 提示"该账号已停用,请联系公寓管理员",不跳转 | +| 9 | 无值班台权限 | 登录成功但无 `duty:jcxx:list` → 提示无权限、token 被清、停在登录页 | +| 10 | 未绑定公寓 | `gyid` 为空 → 提示未绑定公寓,停在登录页 | +| 11 | 会话过期 | 手改 Cookie 或等 30 分钟 → 任一请求 `401` → 直接回登录页(**无确认框**),`reason=expired` 文案正确 | +| 12 | 并发 401 单飞 | 同时打 12 个 `/duty/**` 请求全部 `401` → 只弹 1 次提示、只跳 1 次 | +| 13 | 网络中断 | 拔网线(非 401)→ **不跳登录页**,顶部显示"网络中断,正在重试",恢复后自动继续 | + +## 10. 对既有文档与计划的改动 + +表 12-12 需要同步的四处 + +| 文档 | 改动 | 幅度 | +| --- | --- | --- | +| `02` §6 | 第一条"不做独立登录页"改为"做 `/duty/login` 独立页,但复用 `/login` 认证、不独立签发 token";表 2-8 第 2/3 环节补"登录页白名单 `/duty/login`" | 1 段 | +| `08` 表 8-4 | 新增 `差-D5`(独立登录页) | 1 行 | +| `07` 表 7-8 | 第 2 步"登录可通"细化为四个闸门(表 12-1) | 1 行 | +| `11` 表 11-7/11-8/11-9 | 新增 `T-FE-13`(登录页,1.5 人日)、`T-FE-14`(守卫与失效重定向,1 人日)、`T-BE-09`(`DutyAccessInterceptor`,0.5 人日),并入 R1 | 3 行 + 合计 34.5→37.5 | + +**不新增的东西**:不新增后端认证接口、不新增错误码(§3 全部映射既有 `msg`)、不新建 `duty_login` 表、不引入 OAuth/SSO、不做密码自助重置。 + +**提交纪律**:本轮只产出文档,不改代码、不执行 DDL、不 `git commit`。 diff --git a/docs/应用端/13-第一轮开发(R1)交付说明.md b/docs/应用端/13-第一轮开发(R1)交付说明.md new file mode 100644 index 0000000..74027b7 --- /dev/null +++ b/docs/应用端/13-第一轮开发(R1)交付说明.md @@ -0,0 +1,233 @@ +# 13 第一轮开发(R1)交付说明 + +> **后续**:R2 已交付,关闭了本文的 L-2(命令通道)与 L-5(技术基线),并完成 L-8 的详情部分,见 `14`。 + +> **本轮代码基线**:`dev/RuoYi-Vue-master`(后端 `ruoyi-apt` 模块,包 `com.ruoyi.apt.duty`)、`dev/RuoYi-Vue2-master`(前端 `src/views/duty/**`)、`dev/sql/apt_rev01/31_duty_permissions.sql`。 +> **设计依据**:`11` 表 11-7/11-8/11-9(R1 任务与退出条件)、`12` 表 12-1~12-11(登录与准入)、`02` 表 2-2/2-9(包职责与权限点)、`04` 表 4-2(`ryzt` 呈现)。 +> **本轮不新增**:不新增表、不新增跨端接口编号、不引入新依赖、不改 RuoYi 认证实现。 + +## 1. 本轮目标 + +一句话:**让值班员能登录、能看到自己公寓当日的叫乘任务,并且看得懂**。 + +三条可判定目标: + +| # | 目标 | 判定方式 | +| --- | --- | --- | +| 1 | 未登录进不去盯控台 | 直接访问 `/duty/jcxx?jcid=…` 必须落到 `/duty/login?redirect=…`,登录后回到原深链 | +| 2 | 列表与统计口径唯一 | 行状态由服务端 `DutyStateDeriver` 派生并下发 `uiState/stateLabel/alarmType/overdue/urgent`,前端不再自行拼装 | +| 3 | 每行都能解释"为什么这样显示" | 同一份 `gy_jcxx` 数据在接口、单测、页面三处表现一致(表 13-3 的 8 条断言) | + +**本轮明确不做**(防止"半成品可点"):命令通道(STOP/RE_CALL/MARK_LEAVE)、WebSocket 实时推送、任务详情抽屉、语音临叫、导出、动态菜单。 + +## 2. 功能范围与优先级 + +表 13-1 R1 范围(P0 = 不做完不能演示;P1 = 本轮做但可降级) + +| # | 功能 | 优先级 | 本轮交付 | 说明 | +| --- | --- | --- | --- | --- | +| 1 | 值班员登录页 `/duty/login` | P0 | ✅ | 账号/密码/验证码、字段级校验、11 类失败文案、记住账号(不记密码) | +| 2 | 准入四闸门 | P0 | ✅ | 会话 → `duty:jcxx:list` → `gyid`(后端 `AptContext`)→ 路由 | +| 3 | 未登录/失效重定向 | P0 | ✅ | 守卫 + 401 分支;带 `redirect` 与 `reason` | +| 4 | 盯控台列表 `/duty/jcxx` | P0 | ✅ | 默认"当日 + 需处理",紧急优先排序,上限 100 | +| 5 | 状态派生与脱敏 | P0 | ✅ | 后端唯一实现,9 个投影态;姓名保留首字 | +| 6 | 吸底统计与紧急卡片 | P0 | ✅ | 服务端 `counts`,避免分页后前端算不准 | +| 7 | 双密度(适老/正常)+ 双布局(卡片/列表) | P1 | ✅ | `localStorage` 记忆,切换不改数据与筛选 | +| 8 | 筛选与搜索 | P1 | ✅ | 需处理/全部/已完成;房间号/姓名/车次,300 ms 防抖 | +| 9 | 时钟偏差提示 | P1 | ✅ | 以 `counts.serverTime` 为基准,>5 s 黄条 | +| 10 | 退出登录 | P1 | ✅ | 清 token 后回登录页 | +| 11 | 60 s 兜底轮询 | P1 | ✅ | 实时推送在 R3 接入,本轮先用轮询保证数据不僵死 | +| — | 命令、实时、详情、语音、导出、动态菜单 | — | ❌ | 见 §9 遗留 | + +## 3. 角色与核心使用场景 + +表 13-2 三个角色 + +| 角色 | 谁 | 本轮能做什么 | 不能做什么 | +| --- | --- | --- | --- | +| 值班员 | 公寓值班台操作员 | 登录、看当日任务与统计、切换密度/布局、搜索、退出 | 任何写操作(本轮无写接口,按钮不渲染) | +| 公寓管理员 | 管理端用户 | 创建/停用值班员账号、分配 `duty:*` 权限、按需授予角色 | 不能代替值班员登录(无代登录入口) | +| 运维 | 部署与排障 | 校时、看 `sys_logininfor`、执行 `31_duty_permissions.sql` | 无业务页面入口 | + +三个核心场景: + +1. **早班开场**:值班员登录 → 落 `/duty/jcxx` → 先看"需要马上处理"是否为空 → 按"需处理"逐条盯。 +2. **深链回到原处**:会话过期时正在看某条任务 → 401 → 登录页(`reason=expired`)→ 重新登录 → 回到原 `jcid` 深链,筛选与布局不变。 +3. **无权限账号**:管理端误把普通账号指到值班台 → 登录成功但无 `duty:jcxx:list` → 提示"没有值班台权限",token 被清,停在登录页(不出现半渲染的盯控台)。 + +## 4. 对接接口与约束 + +表 13-3 本轮对接的接口(全部沿用既有约定,无新增编号) + +| # | 接口 | 权限点 | 入参 | 出参 | 落点 | +| --- | --- | --- | --- | --- | --- | +| 1 | `GET /captchaImage` | 免登录 | — | `captchaEnabled`/`uuid`/`img` | 登录页 | +| 2 | `POST /login` | 免登录 | `username`/`password`/`code`/`uuid` | `token` | 登录页 | +| 3 | `GET /getInfo` | 会话 | — | `user`/`roles`/`permissions` | 登录页 + 守卫 | +| 4 | `GET /getRouters` | 会话 | — | 菜单树 | 守卫 | +| 5 | `GET /duty/jcxx` | `duty:jcxx:list` | `scope`(need/all/done)、`keyword`、`pageNum`、`pageSize≤100` | `rows[]` + `total` | 盯控台 | +| 6 | `GET /duty/jcxx/counts` | `duty:jcxx:list` | — | 6 个计数 + `serverTime` | 盯控台 | +| 7 | `POST /logout` | 会话 | — | — | 退出 | + +表 13-4 六条外部约束(违反即返工) + +| # | 约束 | 本轮怎么守住的 | +| --- | --- | --- | +| 1 | `gyid` 只从部署环境注入,绝不接受请求参数 | 服务层统一 `AptContext.getGyId()`;`/duty/**` 无任何 `gyid` 入参 | +| 2 | 状态字段(`ryzt`/`finish_jc`/`finish_ly`/`stoped`)只有 `apt.duty.state` 能写 | `duty` 包**只有 SELECT**,无 `insert/update/delete` 语句,ArchUnit 与 SQL 契约测试双保险 | +| 3 | 列表 `pageSize` 硬上限 100 | 复用 `AptBaseController.startAptPage()` | +| 4 | 列表只读不写审计 | 值班台 60 s 轮询若逐次落 `duty_operation_log` 会淹没审计表;审计只留给命令与导出 | +| 5 | 时间窗由服务端定,客户端不可传 | 只暴露 `scope/keyword`;窗口在服务层固定"当日",无 `jcsj` 的行始终保留 | +| 6 | 认证不魔改框架 | 复用 `/login`;失败按 `i18n/messages.properties` 原文映射,不新增错误码 | + +## 5. 交付标准与达成情况 + +表 13-5 三级交付标准(**如实标注**,未达成不写成达成) + +| 级别 | 定义 | 本轮状态 | 证据 | +| --- | --- | --- | --- | +| **可测试** | 自动化用例可复现跑通 | ✅ 达成 | `mvn -o -pl ruoyi-apt -am test` → **269 passed**(251 基线 + 18 新增);前端跳转白名单 10 例脚本通过 | +| **可编译/可构建** | 后端 `mvn compile` 通过;前端 SFC 模板可编译、脚本可解析 | ✅ 达成 | 后端 `BUILD SUCCESS`;前端 `vue-template-compiler` + `@babel/parser` 校验 `ALL OK` | +| **可运行** | 起服务、真实登录、看到真实数据 | ⚠️ **未验证** | 本环境无 MySQL/Redis/Asterisk,未启动 `apt-admin`;未执行 `31_duty_permissions.sql` | +| **可演示** | 现场按脚本演一遍 | ⚠️ **待执行** | 演示脚本见 §7,需在有库的环境跑一次并录像 | + +一句话结论:**代码与单测这一层已交付;"能跑起来"依赖部署环境,本轮未取得运行证据,不能对外宣称"已上线可用"。** + +## 6. 任务分解与完成状态 + +表 13-6 任务状态(编号沿用 `11` 表 11-7/11-8) + +| # | 任务 | 状态 | 交付物 | 备注 | +| --- | --- | --- | --- | --- | +| `T-BE-01` | 模块骨架 + 权限点脚本 + ArchUnit 断言 | 🟡 部分 | `duty/` 包结构、`31_duty_permissions.sql` | 权限脚本已出;`DutyAccessInterceptor` 未新建(见 §8 偏差 1);ArchUnit 断言沿用既有 6 条 | +| `T-BE-02` | `GET /duty/jcxx` | ✅ 完成 | `DutyJcxxController`、`DutyJcxxQueryServiceImpl`、`DutyJcxxMapper(+XML)` | 默认当日 + 需处理;紧急优先;≤100 | +| `T-BE-03` | 详情 + 时间线 | ⬜ 未开始 | — | 抽屉未出稿,按 `11` 表 11-5 推至 R2 | +| `T-BE-04` | 命令通道 8 命令 | ⬜ 未开始 | — | R2 与 `apt.duty.state` 幂等门面同批次 | +| `T-BE-05` | `GET /duty/jcxx/counts` | ✅ 完成 | 同上 | `G-UI-04` 方案 A 落地 | +| `T-BE-06` | `/ws/duty` | ⬜ 未开始 | — | R3 | +| `T-BE-07` | 报警事件 | ⬜ 未开始 | — | R3 | +| `T-BE-08` | 交接班占位 | ⬜ 未开始 | — | R4 | +| `T-BE-09` | 准入拦截器 | 🟡 替代实现 | `@PreAuthorize('duty:jcxx:list')` + `AptContext` | 见 §8 偏差 1 | +| `T-FE-01` | 入口与路由骨架 | 🟡 部分 | `router/index.js` 两条静态路由 | 动态菜单(`sys_menu`)未落,属 R2 | +| `T-FE-02` | 设计令牌与两版密度 | ✅ 完成 | `console.vue` 内 `--fs-*/--tap/--bd` + `localStorage duty.mode` | 未抽成独立 scss(单页内联,R2 抽 `styles/duty-tokens.scss`) | +| `T-FE-03` | 顶栏 | ✅ 完成 | 状态徽标、时钟、值班员、刷新/退出、版本切换 | 系统状态先按 `counts.alarmCount` 简化,`/duty/health` 接 R2 | +| `T-FE-04` | 卡片 + 列表双布局 | ✅ 完成 | `console.vue` | 切换保留筛选/搜索/滚动位置(未做滚动位置恢复) | +| `T-FE-05` | 状态派生纯函数 | 🟡 后端实现 | `DutyStateDeriver` + 8 条单测 | 前端不再重复实现,直接消费 `uiState`(口径更稳) | +| `T-FE-06` | 筛选 + 搜索 + 空态 + 紧急卡片 | ✅ 完成 | 同上 | 搜索走后端(300 ms 防抖),非纯本地过滤 | +| `T-FE-07` | 命令通道 | ⬜ 未开始 | — | R2 | +| `T-FE-08`~`09` | WS 客户端 / 告警音 | ⬜ 未开始 | — | R3 | +| `T-FE-10` | 三个弹窗 | ⬜ 未开始 | — | R4(停止弹窗依赖 R2 命令) | +| `T-FE-11` | 降级与置灰 | ⬜ 未开始 | — | R4 | +| `T-FE-12` | 无障碍走查 | 🟡 部分 | 56 px 点击区、4 px 焦点环、1024 断点 | 未做真实键盘全流程与对比度测量 | +| `T-FE-13` | 登录页 | ✅ 完成 | `views/duty/login.vue` | 11 类文案映射、验证码刷新、记住账号 | +| `T-FE-14` | 准入守卫与失效重定向 | ✅ 完成 | `permission.js`、`request.js`(401 duty 分支)、`utils/dutyRedirect.js` | 管理端 401 行为保持不变 | + +图 13-1 本轮落点(文件级) + +```mermaid +flowchart LR + subgraph BE["ruoyi-apt(后端)"] + C["duty/controller
DutyJcxxController"] + S["duty/service
DutyJcxxQueryServiceImpl"] + D["duty/domain
DutyStateDeriver / DutyTaskView"] + M["duty/mapper
DutyJcxxMapper(+XML)"] + end + subgraph FE["RuoYi-Vue2(前端)"] + L["views/duty/login.vue"] + P["views/duty/console.vue"] + G["permission.js / dutyRedirect.js"] + end + DB[("gy_jcxx(只读)")] + C --> S --> M --> DB + S --> D + L -->|"POST /login"| C + G -->|"未登录 → /duty/login"| L + P -->|"GET /duty/jcxx(/counts)"| C +``` + +## 7. 验证记录 + +表 13-7 已执行的验证 + +| # | 验证 | 命令 / 方法 | 结果 | +| --- | --- | --- | --- | +| 1 | 新增 18 条单测 | `mvn -o -pl ruoyi-apt -am "-Dtest=Duty*Test" "-Dsurefire.failIfNoSpecifiedTests=false" test` | Tests run: 18, Failures: 0, Errors: 0 | +| 2 | 全量回归(防踩他人) | `mvn -o -pl ruoyi-apt -am test` | **Tests run: 269**, Failures: 0, Errors: 0, BUILD SUCCESS | +| 3 | 前端 SFC 语法 | `vue-template-compiler` 编译模板 + `@babel/parser` 解析脚本(7 个文件) | ALL OK | +| 4 | 跳转白名单 | Node 脚本 10 个用例(空值/编码/`//evil.com`/`http://`/`/apt/**`/自指/`javascript:`) | 10/10 通过 | +| 5 | 权限脚本契约 | `DutySqlContractTest` 4 条 | 通过(7 个 `duty:*`、可重放、无 C 菜单、不写业务表) | + +**未执行**(必须写清,避免误读为已验):`apt-admin` 启动、`/login` 真实登录、`/duty/jcxx` 联调、SQL 执行、浏览器走查、断网/时钟偏差实测、跨公寓越权实测。 + +演示脚本(待在有库环境执行并录像): + +1. 浏览器开 `http:///duty/jcxx?jcid=JC-DEMO-1` → 应落到 `/duty/login?redirect=%2Fduty%2Fjcxx%3Fjcid%3DJC-DEMO-1`; +2. 输错误验证码 → 提示"验证码不正确,已刷新",图片变化、账号保留; +3. 正确登录 → 回跳到 `?jcid=JC-DEMO-1`,顶栏显示值班员与时钟; +4. 切"正常版"与"列表显示" → 数据不变,刷新页面后仍记住; +5. 搜索 `305` → 只剩 305 房;清空 → 恢复; +6. 退出登录 → 回登录页,按浏览器后退不能回到盯控台。 + +## 8. 遇到的问题与解决方案 + +表 13-8 六个真实问题(含两次"文档与代码不一致") + +| # | 问题 | 现象 | 解决 | 后续影响 | +| --- | --- | --- | --- | --- | +| 1 | `mvn` 不在 PATH | 直接执行 `mvn` 报"无法识别" | 用本机 Maven Wrapper 分发(`~/.m2/wrapper/dists/apache-maven-3.9.12`)以绝对路径调用 | 交付命令写入 §7,评审可复现 | +| 2 | **技术基线文档与代码不一致** | 文档写 JDK 1.8 + Spring Boot 2.5.15,实际 `pom.xml` 是 `java.version=17` + Spring Boot 4.1.0;用 JDK 8 编译报"无效的目标发行版: 17" | 构建改用 JDK 21(兼容 17 目标);**文档待修订**,本轮不改 `02` 表 2-1 | 已登记为遗留 L-5,须在 R2 前统一口径 | +| 3 | 管理端 Mapper 无法表达值班台口径 | `AptJcxxMapper` 的筛选是三条独立 AND(房间/姓名/车次),无法表达"任一项命中";也无法表达"需处理 vs 已完成"这种 OR 口径 | 新建 `duty/mapper/DutyJcxxMapper`(只读投影),不动 IT-5 的管理端 SQL | 两端各一份 SQL,改一端不影响另一端 | +| 4 | 时间窗把"刚入住的人"过滤掉 | 默认"当日"窗口用 `jcsj between from and to`,而刚入寓、还没排任务的行 `jcsj is null` | SQL 写成 `(jcsj IS NULL OR (jcsj >= from AND jcsj < to))`,并在 `DutyJcxxQueryServiceImpl` 注释写明理由 | 值班台不会再"看不到人" | +| 5 | 脱敏与搜索冲突 | 姓名返回脱敏后,值班员按全名搜不到 | SQL 出原始 `xm`;**过滤用原始值,返回体用脱敏值**(`DutyTaskView.fromMap` 统一脱敏) | 单测 `keywordMatchesRoomFullNameAndTrainThenKeepsMaskedOutput` 固定该行为 | +| 6 | 现有 401 处置违反需求 | `request.js` 弹"继续留在该页面?",选取消就留在过期页;且跳 `/index` | 新增"路径以 `/duty` 开头"分支:`FedLogOut` 后直跳 `/duty/login?redirect=…&reason=expired`;管理端分支保持原样 | 值班台不再出现"过期还能看"的假象 | + +**两处主动偏差**(设计与实现不完全一致,需评审确认): + +1. **未新建 `DutyAccessInterceptor`(`12` §1 曾写)**:`/duty/**` 的 Controller 已用 `@PreAuthorize("@ss.hasPermi('duty:jcxx:list')")`,`gyid` 由既有 `AptContextInterceptor` 注入,再加一层拦截器只是重复校验。**结论**:以"注解 + `AptContext`"为准,`12` §1 的拦截器描述在 R2 评审时同步删除或改为"可选增强"。 +2. **列表与统计不写审计**(`04` 表 4-14 只要求导出必写):理由见 §4 第 4 条。 + +## 9. 遗留问题 + +表 13-9 遗留(按"是否阻塞 R2"排序) + +| # | 遗留 | 影响 | 计划 | +| --- | --- | --- | --- | +| L-1 | 无运行证据(未起服务、未连库) | 不能宣称"可运行" | R2 开始在联调环境跑通 §7 脚本 | +| L-2 | 命令通道未开(STOP/RE_CALL/MARK_LEAVE) | 值班员只能看不能动,业务未闭环 | R2 第一优先 | +| L-3 | 无实时推送(60 s 轮询兜底) | 报警最大 60 s 延迟,不满足 P0 报警 | R3 | +| L-4 | 菜单仍为静态路由 | 换角色/换菜单需改代码,与 `02` 表 2-6 的"`sys_menu` 双载体"不符 | R2 与 C 菜单一起落 | +| L-5 | **`02` 表 2-1 技术基线未修订**(JDK 17 / Spring Boot 4.1 实际值) | 新人按文档会装错 JDK | 文档修订,R2 前完成 | +| L-6 | 未做 E2E/浏览器走查;无前端单测框架(项目未装 eslint/jest) | 交互回归只能靠人 | R2 引入最小 E2E(Playwright 或手工脚本化) | +| L-7 | 密码锁定倒计时、验证码失败次数提示为前端推算 | 与后端锁定窗口可能差几十秒 | R2 视需要改为服务端返回 `lockRemainSeconds` | +| L-8 | 详情/时间线、导出、语音、交接班 | 功能缺口 | R2~R4 按 `11` 表 11-9 | +| L-9 | 跨公寓越权、并发 100 条、SSE 断流未压测 | 容量与隔离未证 | R3 与压测一起做 | + +## 10. 下一轮计划(R2) + +表 13-10 R2 范围与退出条件 + +| # | 内容 | 依赖 | 退出条件 | +| --- | --- | --- | --- | +| 1 | 命令通道 `POST /duty/jcxx/{jcid}/commands`:`STOP`/`RE_CALL`/`MARK_LEAVE`/`CANCEL`/`FORCE_FINISH` | `apt.duty.state`(已具备) | 同 `commandId` 连发 3 次只有 1 条 `duty_manual_action`;非法态 `40902`;`409xx` 有中文文案 | +| 2 | 停止叫乘二次确认(原因必选,"其他"≥2 字) | 上文 1 | 未选原因不能提交,`Esc` 关闭不提交 | +| 3 | 按钮显隐按 `ryzt` 收缩(`G-UI-05`) | 上文 1 | `ryzt∈{1,2}` 才有"停止",`ryzt∈{3,4,5}` 只有"登记离寓" | +| 4 | 任务详情抽屉 + 时间线(`T-BE-03`) | 抽屉出稿 | `GET /duty/jcxx/{jcid}`+`timeline` 可用,`40401` 正确 | +| 5 | 动态菜单与 `sys_menu`(`L-4`) | 上文 1 | 换角色后菜单变化无需改代码 | +| 6 | 技术基线文档修订(`L-5`) | — | `02` 表 2-1 与实际 `pom.xml` 一致 | +| 7 | 联调环境跑通 §7 演示脚本并录像(`L-1`) | 环境 | 6 步全过 | + +## 11. 可追溯索引 + +表 13-11 文档 → 代码 → 用例 + +| 设计条目 | 代码文件 | 对应用例 | +| --- | --- | --- | +| `11` 表 11-2(状态派生) | `duty/domain/DutyStateDeriver.java`、`DutyUiState.java` | `DutyStateDeriverTest`(8 例) | +| `11` 表 11-4(列表/统计落点) | `duty/controller/DutyJcxxController.java` | `DutyJcxxQueryServiceTest`(6 例) | +| `11` 表 11-6 `G-UI-04`(计数不准) | `resources/mapper/apt/DutyJcxxMapper.xml#selectDutyCounts` | `DutyJcxxQueryServiceTest#countsTolerateNullAndBigDecimal` | +| `12` 表 12-3(字段校验) | `src/views/duty/login.vue` | 前端脚本(未自动化) | +| `12` 表 12-6(跳转白名单) | `src/utils/dutyRedirect.js` | Node 脚本 10 例 | +| `12` 表 12-8(失效处置) | `src/permission.js`、`src/utils/request.js` | 待 R2 E2E | +| `02` 表 2-9(权限点) | `sql/apt_rev01/31_duty_permissions.sql` | `DutySqlContractTest`(4 例) | + +**提交纪律**:本轮未执行 `git commit` / `git push`;代码改动集中在 `duty` 包、`src/views/duty`、两条守卫相关文件与一个 SQL 脚本,清单见 §6。 diff --git a/docs/应用端/14-第二轮开发(R2)交付说明.md b/docs/应用端/14-第二轮开发(R2)交付说明.md new file mode 100644 index 0000000..2fa04db --- /dev/null +++ b/docs/应用端/14-第二轮开发(R2)交付说明.md @@ -0,0 +1,163 @@ +# 14 第二轮开发(R2)交付说明 + +> **执行阶段**:R2 已完成代码与用例层交付;R1 基线见 `13`。本轮把值班台从"只能看"推到"能操作且留痕",并把 R1 遗留的两项(技术基线、详情抽屉)一并关掉;**唯一未关闭的是"运行证据"**(无 DB/Redis/AMI 环境)。 +> **设计依据**:`11` 表 11-9(R1~R5 排期)、表 11-6(`G-UI-01/02/05`)、`04` 表 4-6(命令矩阵)、表 4-7(幂等与响应)、`02` 表 2-9(`duty:call:*`)、`12` 表 12-1(准入)。 +> **本轮不新增**:不新增表、不新增错误码、不改 `apt.duty.state` 状态机、不引入前端新依赖。 + +## 1. 本次完成内容(对 `13` 表 13-10 的逐项交付) + +表 14-1 R2 七项承诺的达成情况 + +| # | R2 承诺(`13` 表 13-10) | 本次交付 | 状态 | +| --- | --- | --- | --- | +| 1 | 命令通道 `POST /duty/jcxx/{jcid}/commands`:`STOP`/`RE_CALL`/`MARK_LEAVE`/`CANCEL`/`FORCE_FINISH` | 新增 `DutyCommandType`、`DutyCommandRequest`、`IDutyCommandService`/`DutyCommandServiceImpl`、控制器 1 个端点;委托 `IAptDutyCommandService` 执行 | ✅ | +| 2 | 停止叫乘二次确认(原因必选,"其他"≥2 字) | 前端原因弹窗(4 选项 + 其他需 ≥2 字)、默认焦点在"取消"、`Esc` 关闭不提交 | ✅ | +| 3 | 按钮显隐按 `ryzt` 收缩(`G-UI-05`) | 前端 `actionsOf(row)` 单一映射;`TODO/CALLING/ALARM_CALL/LEAVE/ALARM_LEAVE/TALKING/STOPPED` 各自动作见 §4 | ✅ | +| 4 | 任务详情抽屉 + 时间线(`T-BE-03`) | `GET /duty/jcxx/{jcid}`、`GET /duty/jcxx/{jcid}/timeline` + 前端右侧抽屉(基本信息/会话/时间线/最近人工动作 + 强制结束入口) | ✅ | +| 5 | 动态菜单与 `sys_menu`(`13` L-4) | **不采用**:值班台是整屏单页,菜单无增量价值;权限点已承载准入。登记为 `差-D6`(§7) | ⚠️ 决策变更 | +| 6 | 技术基线文档修订(`13` L-5) | `02` 表 2-1 四行改为实际值(JDK 17 / Spring Boot 4.1.0 / RuoYi 3.9.2 / MyBatis-SB 4.1.0)+ 修订说明 | ✅ | +| 7 | 联调环境跑通演示脚本并录像(`13` L-1) | **未执行**:本环境无 MySQL/Redis/Asterisk,无法启动 `apt-admin` | ❌ 保留 | + +**一句话状态**:R2 功能面 6/7 完成、1 项经决策改口径、运行证据仍缺;R3(实时推送)可以开始,但评审前必须有一个人在有库环境跑一遍 §8 的脚本。 + +## 2. 命令接口契约 + +表 14-2 本轮新增的三个端点(均在 `DutyJcxxController`) + +| # | 方法与路径 | 权限点 | 入参 | 出参要点 | 失败码 | +| --- | --- | --- | --- | --- | --- | +| 1 | `POST /duty/jcxx/{jcid}/commands` | 5 个 `duty:call:*` 任一(粗判)+ 命令对应点(精判) | `commandType`、`commandId`、`reason`、`expectedState`(可选)、`leaveType`(可选) | `jcid`、`commandId`、`actionId`、`accepted`、`duplicated`、`stateAfter`、`ryztBefore`、`ryztAfter`、`uiState`、`stateLabel` | `40001`/`40301`/`40401`/`40902`/`40907` | +| 2 | `GET /duty/jcxx/{jcid}` | `duty:jcxx:query` | — | 基本信息(脱敏姓名)+ `session` + `manualActions`(≤10) + `uiState/stateLabel/alarmType` | `40401` | +| 3 | `GET /duty/jcxx/{jcid}/timeline` | `duty:jcxx:query` | — | `at`/`type`/`source`/`summary` 列表(倒序) | `40401` | + +**双层权限**的必要性:注解 `hasAnyPermi` 只能回答"是不是值班员",回答不了"这个值班员能不能强制结束"。精确判定放在服务层,返回 `40301` 并带上缺失的权限点名称,避免值班员看到"500 未知错误"。 + +## 3. 命令-状态-权限矩阵 + +表 14-3 每个命令的前置态、权限与原因要求 + +| 命令 | 权限点 | 服务端允许的前置(由 `apt.duty.state` 判) | 原因 | 前端出现位置 | +| --- | --- | --- | --- | --- | +| `STOP` | `duty:call:stop` | `ryzt ∈ {1,2}` 且 `finish_ly=0` | **必填**(≥2 字) | `CALLING` 主按钮、`ALARM_CALL` 次按钮 | +| `RE_CALL` | `duty:call:recall` | `stoped=1` 或 `ryzt=2` | 可空(服务端补默认) | `ALARM_CALL` 主按钮、`STOPPED` 主按钮 | +| `MARK_LEAVE` | `duty:call:markLeave` | `finish_ly=0` | 可空(服务端补默认) | `LEAVE`/`ALARM_LEAVE`/`TALKING` 主按钮、详情抽屉 | +| `CANCEL` | `duty:call:cancel` | `ryzt=0` | **必填** | `TODO` 次按钮 | +| `FORCE_FINISH` | `duty:call:forceFinish` | `finish_ly=0` | **必填** | 仅详情抽屉(`LEAVE`/`ALARM_LEAVE`/`TALKING`) | + +**原因口径的一处现实妥协**:`04` 表 4-7 写"`RE_CALL`/`MARK_LEAVE` 可不填原因",但既有 `AptDutyCommandServiceImpl.validate` 对 5 个命令**都**要求 `reason` 非空。为避免值班员点"登记离寓"直接吃 `40001`,值班台在服务层为可不填的命令补默认文案("值班台登记离寓"/"值班台复叫"),**不改状态机**。该差异已登记为 `差-D7`。 + +## 4. 前端动作矩阵(`G-UI-01/02/05` 的落地) + +表 14-4 行状态 → 可见动作(前端唯一实现 `actionsOf`) + +| UI 状态 | 主按钮 | 次按钮 | 详情 | 说明 | +| --- | --- | --- | --- | --- | +| `TODO` | — | 取消叫乘 | ✅ | `G-UI-01` 方案 B:不给"立即叫乘",提示"距叫乘还有 N 分钟" | +| `CALLING` | 停止叫乘 | — | ✅ | `G-UI-02` 方案 A:不给"确认接通",接通由 AMI `ConfbridgeJoin` 判定 | +| `ALARM_CALL` | 复叫 | 停止叫乘 | ✅ | 报警行同时保留"停止",避免只能复叫不能弃叫 | +| `LEAVE` | 登记离寓 | — | ✅ | | +| `ALARM_LEAVE` | 登记离寓 | — | ✅ | 详情抽屉内额外提供"强制结束" | +| `TALKING` | 登记离寓 | — | ✅ | | +| `STOPPED` | 重新叫乘 | — | ✅ | 同一命令 `RE_CALL`,文案按状态区分 | +| `IN` / `DONE` | — | — | ✅ | 只读 | + +## 5. 幂等与并发 + +表 14-5 三条保证 + +| 机制 | 实现 | 失败时的行为 | +| --- | --- | --- | +| **命令幂等** | 前端按 `jcid:命令` 生成一次 `commandId` 并缓存,**成功后才删除**;网络失败重试复用同一键 | 服务端返回 `duplicated=true` 与首次 `actionId`,前端当成功处理(`duty_manual_action` 只有 1 行) | +| **乐观校验** | 前端带 `expectedState`(当前 `ryzt`);服务端先比对再执行 | 不一致返回 `40902 EXPECTED_STATE_MISMATCH`(文案"该行已被他人处理,请刷新后重试"),并立即刷新列表 | +| **本地兜底** | 命令成功后先用返回的 `ryztAfter/uiState` 打补丁,再拉一次列表以服务端为准 | 补丁只影响视觉 200~500 ms,不产生第二份事实 | + +**为什么不用按钮禁用当幂等**:值班员会连点两下、也会在 3 秒超时后重试;`commandId` 是唯一能跨请求生效的键。 + +## 6. 详情与时间线 + +- **数据来源**:`IAptJcxxService.detail(id)` / `timeline(id)`,只换外壳不改查询;`jcid → id` 由 `DutyJcxxMapper.selectDutyRefByJcid` 在本公寓内解析。 +- **裁剪**:详情**不返回** `recordings` 与 `facePhotos`(录音与刷脸照片路径只在管理端可见);姓名仍脱敏(`李四 → 李*`)。 +- **字段对齐**:抽屉按真实列名渲染 —— 会话 `callState/startedAt/answeredAt/endedAt/confNo`、人工动作 `occurredAt/actionType/state/reason/requestedBy`、时间线 `at/type/source/summary`。 + +## 7. 偏差与决策登记 + +表 14-6 本轮需要评审确认的四项 + +| # | 偏差 | 原设计 | 实际 | 理由 | +| --- | --- | --- | --- | --- | +| `差-D6` | 值班台不建 `sys_menu` 的 C 菜单 | `02` 表 2-6 / `08` 差-7:两载体动态菜单 | 只用 F 权限点 + 前端静态路由 | 单页整屏,无二级导航;静态路由与动态路由同名会生成两条 `/duty/jcxx`(一条带 Layout、一条整屏),无运行时环境无法验证谁生效,**风险大于收益**。R3 若要接 D3~D10 多页,再统一切动态 | +| `差-D7` | 一键动作也写 `reason` | `04` 表 4-7:`RE_CALL`/`MARK_LEAVE` 原因可空 | 服务层补默认原因文本 | 状态机对 5 命令统一 `reason` 非空;改状态机风险更高 | +| `差-D8` | 不新建 `DutyAccessInterceptor` | `12` §1 曾写新增拦截器 | `@PreAuthorize` + `AptContext.getGyId()` | 同一效果少一层;`gyid` 已由既有 `AptContextInterceptor` 注入 | +| `差-D9` | `FORCE_FINISH` 只放在详情抽屉 | `G-UI-05` 只规定主操作 | 主操作区不出现,抽屉内提供 | 卡片上放"强制结束"太容易误点,且它必须写原因 | + +## 8. 验证记录 + +表 14-7 已执行 + +| # | 验证 | 命令 / 方法 | 结果 | +| --- | --- | --- | --- | +| 1 | 新增命令用例 | `mvn -o -pl ruoyi-apt -am "-Dtest=Duty*Test" "-Dsurefire.failIfNoSpecifiedTests=false" test` | Tests run: 29(命令 11 + 查询 6 + 派生 8 + 脚本 4),0 失败 | +| 2 | 全量回归 | `mvn -o -pl ruoyi-apt -am test` | **Tests run: 280**, Failures: 0, Errors: 0, BUILD SUCCESS | +| 3 | 前端语法 | `vue-template-compiler` 编译模板 + `@babel/parser` 解析(5 文件) | ALL OK(`console.vue` 模板 243 行) | + +**新增 11 条用例覆盖的边界**:原因缺失/过短/过长、逐命令权限(`FORCE_FINISH` 无权限必须 40301 且不触库)、`expectedState` 不符不执行、`jcid` 不存在 40401、不支持的命令列出可用项、`commandId` 缺省兜底与给定复用、`duplicated` 透传、详情脱敏且不带媒体路径、未知 `jcid` 取时间线 404、时间线为空返回空数组。 + +**未执行**:起服务、真实登录、命令落库、抽屉渲染、断网重试、`40902` 真实并发验证。 + +## 9. 遇到的问题与解决方案 + +表 14-8 六个实现期问题 + +| # | 问题 | 现象 | 解决 | +| --- | --- | --- | --- | +| 1 | 状态机返回值不是 Map | 首次编译报"`AptDutyCommandResult` 无法转换为 `Map`" | 改为按 `getActionId()/isDuplicated()/isAccepted()/getStateAfter()` 取值;**没有为了省事去改状态机签名** | +| 2 | 40001 提示里命令名大小写不一致 | 单测期望 `MARK_LEAVE`,实际输出 `markLeave`(当时用的是权限点截断) | 枚举新增 `allCommands()`,直接列命令名,提示更可读 | +| 3 | 详情要的 `jllx`/`update_time` 在管理端查询里不存在 | 两个字段永远是 `null`,抽屉显示空 | 改为真实存在的 `bizVersion` 与 `receivedAt`;不硬造字段 | +| 4 | 会话/动作/时间线键名与我第一版猜的不一致 | 抽屉"会话状态"空白 | 按 `AptJcxxMapper.xml` 真实别名改为 `callState/startedAt/...`、`occurredAt/actionType/...`、`at/type/summary` | +| 5 | 注解无法表达"逐命令权限" | 只写 `hasAnyPermi` 时,有"停止"权限的人也能"强制结束" | 注解做粗判 + 服务层精判,两条都在,且精判先于任何读写 | +| 6 | 命令成功后行状态短暂不一致 | 本地补丁与列表刷新之间可能看到旧排序 | 先补丁(>200 ms 内有反馈)再强制刷新列表,以服务端快照为最终事实 | + +## 10. R2 整体状态与遗留 + +表 14-9 遗留项状态变化(编号沿用 `13` 表 13-9) + +| # | 遗留 | 状态 | 说明 | +| --- | --- | --- | --- | +| L-1 | 无运行证据 | 🔴 保留 | 仍是唯一阻塞"可演示"的项 | +| L-2 | 命令通道未开 | 🟢 关闭 | 5 命令全通,含幂等与乐观校验 | +| L-3 | 无实时推送 | 🔴 保留 | R3 主任务;当前仍 60 s 轮询 | +| L-4 | 菜单为静态路由 | 🟡 改口径 | 见 `差-D6`:本期不做动态菜单,不是遗漏 | +| L-5 | 技术基线未修订 | 🟢 关闭 | `02` 表 2-1 已改为实际值 | +| L-6 | 无 E2E/浏览器走查 | 🔴 保留 | 依赖 L-1 | +| L-7 | 锁定倒计时为前端推算 | 🟡 保留 | 影响小,随登录页打磨 | +| L-8 | 详情/导出/语音/交接班 | 🟡 部分关闭 | 详情与时间线已完成;导出、语音、交接班仍在 R3~R4 | +| L-9 | 越权/并发/断流未压测 | 🔴 保留 | R3 与实时推送一起做 | +| **L-10** | 详情字段未与真实库联调 | 🔴 新增 | 字段名已按 XML 对齐,但未跑过真实数据;§8 脚本必须覆盖 | + +## 11. 下一轮(R3)计划 + +表 14-10 R3 范围与退出条件 + +| # | 内容 | 依赖 | 退出条件 | +| --- | --- | --- | --- | +| 1 | `/ws/duty` 端点:握手校验 `gyid`、`seq` 单调、`state.changed`/`alarm.raised`/`alarm.cleared`/`session.changed` | `apt.duty.state` 事件 | 本机造一条叫乘,2 s 内行状态变化,页面不刷新 | +| 2 | 前端 WS 客户端:心跳 30 s、退避 1→30 s、乱序丢弃、重连后拉一次快照 | 上文 1 | 拔网线 30 s,5 s 内自动重连,`commandId` 与筛选不丢 | +| 3 | 报警音单例 + TTS 队列 + 首次交互解锁 | 上文 1 | 3 行同时报警只响 1 条,逐行消警各自停 | +| 4 | 轮询收敛:有 WS 时 60 s → 5 min 兜底,断流 90 s 出"数据可能滞后" | 上文 2 | 双通道不重复刷新,不同时打两个请求 | +| 5 | 联调跑通 R1+R2 演示脚本并录像(`L-1`/`L-10`) | 环境 | 脚本 6+4 步全过 | +| 6 | 越权与并发压测(`L-9`) | 上文 1 | 跨公寓读取 40302;500 连接不掉线 | + +## 12. 追溯索引 + +表 14-11 设计 → 代码 → 用例 + +| 设计条目 | 代码 | 用例 | +| --- | --- | --- | +| `04` 表 4-6(5 命令)/ `02` 表 2-9 | `duty/domain/DutyCommandType.java`、`DutyCommandServiceImpl.java` | `DutyCommandServiceTest`(11 例) | +| `04` 表 4-7(幂等) | `DutyCommandServiceImpl#normalizeCommandId`、`console.vue#commandIdFor` | `blankCommandIdIsGeneratedButGivenOneIsKept` | +| `11` 表 11-6 `G-UI-05` | `console.vue#actionsOf` | 前端脚本(未自动化) | +| `11` 表 11-6 `G-UI-01/02` | `console.vue#actionsOf`(`TODO`/`CALLING` 不给按钮) | 同上 | +| `13` 表 13-10 #4(详情) | `DutyJcxxController#detail/#timeline` | `detailMasksNameAndDropsMediaPaths`、`timelineOfUnknownJcidIs404` | +| `13` 表 13-10 #6(基线修订) | `docs/应用端/02` 表 2-1 | 文档核对(无自动化) | + +**提交纪律**:未执行 `git commit` / `git push`;本轮改动 = 后端 `duty` 包 6 个文件 + 1 个 XML + 1 个测试类,前端 2 个文件(`api/duty/jcxx.js`、`views/duty/console.vue`),文档 2 处(`02` 表 2-1、本文)。 diff --git a/docs/应用端/15-第三轮开发(R3)交付说明.md b/docs/应用端/15-第三轮开发(R3)交付说明.md new file mode 100644 index 0000000..347b0d8 --- /dev/null +++ b/docs/应用端/15-第三轮开发(R3)交付说明.md @@ -0,0 +1,162 @@ +# 15 第三轮开发(R3)交付说明 + +> **执行阶段**:R3 代码与用例层交付完成(实时推送 + 报警音 + 轮询收敛);仍未取得运行证据。 +> **设计依据**:`04` 表 4-12(`/ws/duty` 12 类消息与三条硬约束)、表 4-7(`pushSeq`)、`11` 表 11-9(R3 范围)、`02` 表 2-9(`duty:*`)。 +> **本轮不新增**:不新增表、不新增错误码、不改 `apt.duty.state`、不引入前端依赖(音频用 Web Audio 合成,无音频资源文件)。 + +## 1. 本次完成内容(对 `14` 表 14-10 的六项) + +表 15-1 R3 六项承诺的达成情况 + +| # | R3 承诺(`14` 表 14-10) | 本次交付 | 状态 | +| --- | --- | --- | --- | +| 1 | `/ws/duty` 端点:握手校验 `gyid`、`seq` 单调、4 类业务消息 | `DutyWebSocketConfig` + `DutyHandshakeInterceptor` + `DutyWebSocketHandler` + `DutyPushServiceImpl`;消息 `state.changed`/`alarm.raised`/`alarm.cleared`/`session.changed` | ✅ | +| 2 | 前端 WS 客户端:心跳 30 s、退避 1→30 s、乱序丢弃、重连拉快照 | `src/utils/dutySocket.js`(含 ±20% 抖动、60 s 无 PONG 主动重连) | ✅ | +| 3 | 报警音单例 + TTS 队列 + 首次交互解锁 | `src/utils/dutyAlarm.js`(Web Audio 合成,按 `alarmEventKey` 起停);`console.vue` 首次点击解锁并显示提示 | ✅ | +| 4 | 轮询收敛:有 WS 时 5 min 兜底、断流 90 s 提示 | `applyPollInterval()` 60 s ↔ 5 min 自动切换;`stale` 90 s 黄条 | ✅ | +| 5 | 联调跑通 R1+R2 演示脚本并录像(`L-1`/`L-10`) | 环境无 MySQL/Redis/Asterisk | ❌ | +| 6 | 越权与并发压测(`L-9`) | 需运行环境与压测脚本 | ❌ | + +**一句话状态**:值班台从"要点刷新才动"变成"自动更新 + 会响",但**没有在任何真实连接上跑过一次**——这是本轮唯一的硬缺口。 + +## 2. `/ws/duty` 协议 + +表 15-2 端点与握手 + +| 项 | 约定 | +| --- | --- | +| 端点 | `GET /ws/duty`(`ws(s):///ws/duty`,`server.servlet.context-path` 不变) | +| 令牌 | `?token=`(浏览器不能设 Header);也接受 `Authorization: Bearer` | +| 鉴权 | JWT + Redis 会话(`TokenService`)→ 权限点 `duty:jcxx:list` 或 `*:*:*` | +| 公寓域 | **只取部署配置 `apt.gy-id`**,不接受 `?gyid=`;未配置直接 403 | +| 失败码 | 无令牌/令牌失效 `401`;无权限点/未配置公寓 `403` | +| 开关 | `apt.realtime.enabled=false` 时不注册该端点,前端自动退回轮询 | + +表 15-3 服务端 → 前端消息 + +| `type` | `category` / `priority` | `data` | 前端行为 | +| --- | --- | --- | --- | +| `CONNECTED` | SYSTEM / P2 | `sessionId`、`userId`、`gyid`、`heartbeatIntervalSec=30`、`serverTime`、`lastSeq`、`messages[]` | 置"实时已连接";以 `lastSeq` 作为丢重起点 | +| `state.changed` | CALL / P1 | `jcid`、`ryztBefore`、`ryztAfter`、`uiState`、`stateLabel`、`actionId` | 只更新该行(不整表刷新);本地没有该行时拉一次快照 | +| `alarm.raised` | ALARM / **P0** | `jcid`、`alarmType`(`CALL`/`LEAVE`)、`alarmEventKey`、`sound=true` | 行置红 + 起报警音 + TTS | +| `alarm.cleared` | ALARM / P0 | `jcid`、`alarmType`、`alarmEventKey` | 停该条声音;1 s 后拉一次快照校正真实状态 | +| `session.changed` | CALL / P1 | `jcid`、`sessionId`、`state`、`confNo` | 详情抽屉开着且同 `jcid` 时刷新详情 | +| `PONG` | SYSTEM / P2 | `serverTime`、`lastSeq`、`ts`(回显) | 刷新心跳计时;连续 2 个周期没有则主动重连 | + +三条与 `04` 表 4-12 对齐的硬约束: + +1. **心跳 30 s**,服务端只回 `PONG`,不主动造业务消息; +2. **退避重连** 1/2/4/8/16/30 s + ±20% 抖动,**不 reload 页面**; +3. **推送只是加速**:连上立刻拉一次列表与统计,断流 90 s 出"数据可能滞后",轮询兜底永远在。 + +## 3. 后端落点 + +表 15-4 新增文件与职责 + +| 文件 | 职责 | 关键约束 | +| --- | --- | --- | +| `duty/realtime/DutyWebSocketConfig` | 注册 `/ws/duty`、Origin 白名单、总开关 | 不重复 `@EnableWebSocket`(公寓端已开启,同上下文合并) | +| `duty/realtime/DutyHandshakeInterceptor` | 从 query/Header 取令牌、校验权限点、锁定 `gyid` | 属性快照:角色变更需重连生效 | +| `duty/realtime/DutyWebSocketHandler` | `CONNECTED`、`PING→PONG`、断开摘除 | **不接受业务命令**(防绕过权限与幂等) | +| `duty/realtime/DutyPushMessage` | 在公寓端信封上加 `seq` | 其余字段与 `/apt/ws` 一致,前端可复用分发代码 | +| `duty/realtime/IDutyPushService` / `DutyPushServiceImpl` | 4 类消息 + seq 水位 + 事务后发送 | 投递复用 `AptRealtimeSessionRegistry`;固定投给 `duty:jcxx:list` | +| `ruoyi-framework/config/SecurityConfig` | 放行 `/ws/duty` 握手 | 不放行会被 `anyRequest().authenticated()` 拦成 401 | +| `duty/service/DutyCommandServiceImpl` | 命令成功后推 `state.changed` + 告警事件,响应带 `pushSeq` | 推送异常整体 catch,命令结果不受影响 | + +**为什么复用公寓端的 `AptRealtimeSessionRegistry`**:它已经做了 gyid 隔离、权限点过滤、单会话串行发送、失败摘除、`sweepIdle`。再写一份只会让"两个通道的摘除策略"慢慢漂移。差异化通过 `targetPermission` 完成: + +表 15-5 两个端点的订阅隔离 + +| 端点 | 握手权限点 | 消息 `targetPermission` | 结果 | +| --- | --- | --- | --- | +| `/apt/ws`(公寓端) | `apt:realtime:connect` | `apt:jcxx:list` 等 | 管理端值班员收不到 `/ws/duty` 的消息 | +| `/ws/duty`(应用端) | `duty:jcxx:list` | `duty:jcxx:list` | 值班员收不到 `/apt/ws` 的消息 | + +## 4. 前端落点 + +表 15-6 三个文件与集成点 + +| 文件 | 内容 | +| --- | --- | +| `src/utils/dutySocket.js` | 连接/心跳/退避/`seq` 丢重/状态回调;`CONNECTED.lastSeq` 作为起点;`VUE_APP_WS_BASE` 可覆盖地址 | +| `src/utils/dutyAlarm.js` | Web Audio 双音报警(880/660 Hz,1.6 s 循环)、`ring/stopRing/stopAll`、TTS 单队列、`unlockAudio` 挂起补响 | +| `src/views/duty/console.vue` | 顶栏实时状态与"点击页面开启报警声音"提示;`state.changed` 打行补丁;`alarm.*` 起停声音;90 s 滞后黄条;轮询 60 s ↔ 5 min | +| `vue.config.js` | dev 代理 `^/ws/duty` 开 `ws: true`(否则本地握手被当普通 HTTP) | + +**为什么报警音要自己做而不是用 `