From d2df03f023d625ae833b0b6f957ca3880b0174fc Mon Sep 17 00:00:00 2001 From: jzf Date: Fri, 13 Nov 2026 11:18:42 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E9=80=9A=E8=BF=87SIP?= =?UTF-8?q?=E5=91=BC=E5=8F=AB=E6=88=BF=E9=97=B4=E7=9A=84=E7=9B=B8=E5=85=B3?= =?UTF-8?q?=E9=80=BB=E8=BE=91=EF=BC=8C=E7=9B=AE=E5=89=8D=E5=AE=9A=E6=97=B6?= =?UTF-8?q?=E5=91=BC=E5=8F=AB=E5=92=8C=E4=B8=B4=E6=97=B6=E5=91=BC=E5=8F=AB?= =?UTF-8?q?=E9=83=BD=E5=8F=AF=E4=BB=A5=E5=91=BC=E9=80=9A=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/AptDutyCommandServiceImpl.java | 72 ++++++- .../service/AptDutyStateServiceImpl.java | 67 +++++- .../apt/framework/config/AptProperties.java | 12 ++ .../apt/voice/mapper/AptVoiceMapper.java | 4 + .../apt/voice/service/AptAmiEventHandler.java | 8 +- .../voice/service/AptCallFlowServiceImpl.java | 204 ++++++++++++++++-- .../service/AptVoiceEventServiceImpl.java | 19 +- .../voice/service/IAptCallFlowService.java | 6 + .../src/main/resources/apt-defaults.yml | 2 + .../resources/mapper/apt/AptCheckinMapper.xml | 7 +- .../mapper/apt/AptDutyStateMapper.xml | 18 +- .../resources/mapper/apt/AptJobMapper.xml | 13 +- .../resources/mapper/apt/AptVoiceMapper.xml | 10 + .../dispatch/AptDutyCommandServiceTest.java | 3 +- .../apt/dispatch/AptDutyStateServiceTest.java | 84 +++++++- .../apt/voice/AptVoiceEventServiceTest.java | 114 ++++++++++ .../framework/config/SecurityConfig.java | 3 + dev/RuoYi-Vue2-master/src/permission.js | 48 ++++- dev/RuoYi-Vue2-master/src/router/index.js | 25 +++ dev/RuoYi-Vue2-master/src/utils/request.js | 11 + dev/RuoYi-Vue2-master/vue.config.js | 7 + docs/应用端/02-技术基线与工程结构.md | 10 +- docs/应用端/README.md | 7 + 23 files changed, 702 insertions(+), 52 deletions(-) create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/voice/AptVoiceEventServiceTest.java 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 f2b32e9..0028730 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 @@ -15,10 +15,15 @@ import com.ruoyi.apt.dispatch.mapper.AptJcxxMapper; import com.ruoyi.apt.framework.audit.AptAuditWriter; import com.ruoyi.apt.framework.realtime.IAptRealtimePublisher; import com.ruoyi.apt.ops.domain.AptOperationLog; +import com.ruoyi.apt.voice.service.IAptCallFlowService; import com.ruoyi.common.utils.SecurityUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; /** * 受控命令实现(IT-5 / M3,公寓端/04 表 4-9 命令语义)。 @@ -40,6 +45,8 @@ import org.springframework.transaction.annotation.Transactional; @Service public class AptDutyCommandServiceImpl implements IAptDutyCommandService { + private static final Logger log = LoggerFactory.getLogger(AptDutyCommandServiceImpl.class); + private static final String STOP = "STOP"; private static final String RE_CALL = "RE_CALL"; private static final String FORCE_FINISH = "FORCE_FINISH"; @@ -50,14 +57,17 @@ public class AptDutyCommandServiceImpl implements IAptDutyCommandService private final IAptDutyStateService stateService; private final AptAuditWriter auditWriter; private final IAptRealtimePublisher realtimePublisher; + private final IAptCallFlowService callFlowService; public AptDutyCommandServiceImpl(AptJcxxMapper jcxxMapper, IAptDutyStateService stateService, - AptAuditWriter auditWriter, IAptRealtimePublisher realtimePublisher) + AptAuditWriter auditWriter, IAptRealtimePublisher realtimePublisher, + IAptCallFlowService callFlowService) { this.jcxxMapper = jcxxMapper; this.stateService = stateService; this.auditWriter = auditWriter; this.realtimePublisher = realtimePublisher; + this.callFlowService = callFlowService; } @Override @@ -135,9 +145,11 @@ public class AptDutyCommandServiceImpl implements IAptDutyCommandService break; case RE_CALL: stateService.recall(jcid); + scheduleRedialAfterCommit(jcid); break; case FORCE_FINISH: stateService.forceFinish(jcid); + scheduleForceHangupAfterCommit(jcid); break; case MARK_LEAVE: String leaveType = command.getLeaveType() == null || command.getLeaveType().isBlank() @@ -153,6 +165,64 @@ public class AptDutyCommandServiceImpl implements IAptDutyCommandService } } + /** + * RE_CALL 提交后再发起 SIP 重呼:复叫事实(状态迁移)先落库, + * 呼叫失败(AMI 未就绪 / Originate 失败)只告警,不回滚已提交的复叫; + * 失败后由 30s 超时扫描经 CALL_FAILED 收敛到叫乘报警。 + */ + private void scheduleRedialAfterCommit(String jcid) + { + runAfterCommit(() -> { + try + { + callFlowService.redial(jcid); + } + catch (Exception e) + { + log.warn("复叫 {} SIP 重呼发起失败:{}", jcid, e.toString()); + } + }); + } + + /** + * FORCE_FINISH(值班员「结束叫乘」)提交后强制挂断全部在会腿: + * 终态(finish_jc=1)先落库,踢人失败只告警;房间腿挂断事件随后到, + * 其 ANSWERED 提交因终态已置而被状态机条件更新拦截(STATE_ILLEGAL 忽略)。 + */ + private void scheduleForceHangupAfterCommit(String jcid) + { + runAfterCommit(() -> { + try + { + callFlowService.forceHangupAll(jcid); + } + catch (Exception e) + { + log.warn("强制结束 {} 挂断在会腿失败:{}", jcid, e.toString()); + } + }); + } + + /** 事务提交后执行外部副作用(AMI 调用),避免把外部调用包进数据库事务 */ + private void runAfterCommit(Runnable task) + { + if (TransactionSynchronizationManager.isSynchronizationActive()) + { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() + { + @Override + public void afterCommit() + { + task.run(); + } + }); + } + else + { + task.run(); + } + } + /** 前置状态校验(表 4-9「允许的当前状态」列,逐条对应) */ private void checkAllowed(String action, String ryzt, String finishJc, String finishLy, String stoped) { 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 6ad3e62..216d5fa 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 @@ -7,6 +7,7 @@ import com.ruoyi.apt.common.AptErrorCode; import com.ruoyi.apt.common.AptException; import com.ruoyi.apt.dispatch.domain.AptStateSnapshot; import com.ruoyi.apt.dispatch.mapper.AptDutyStateMapper; +import com.ruoyi.apt.duty.realtime.DutyTransitionPush; import com.ruoyi.apt.framework.realtime.IAptRealtimePublisher; import com.ruoyi.apt.job.service.IAptJobService; import org.slf4j.Logger; @@ -26,6 +27,9 @@ import org.springframework.transaction.support.TransactionSynchronizationManager * 签到窗口超时同样由平板上报 NO_ANSWER(P2-2 裁决); *
  • SIP 30s 无入会由扫描任务调用 {@link #callFailed(String)}, * 仍是「提交命令」而非直写。
  • + *
  • 每次迁移后统一推值班台(R4 / `L-11`):本类是四类触发源 + * (人工命令 / Quartz 扫描 / AMI 事件 / 平板上报)的唯一汇合点, + * 推送放在这里,定时任务产生的报警才可能到达 `/ws/duty`。
  • * * * @author apt-rev01 @@ -41,13 +45,15 @@ public class AptDutyStateServiceImpl implements IAptDutyStateService private final AptDutyStateMapper stateMapper; private final IAptJobService jobService; private final IAptRealtimePublisher realtimePublisher; + private final DutyTransitionPush dutyPush; public AptDutyStateServiceImpl(AptDutyStateMapper stateMapper, IAptJobService jobService, - IAptRealtimePublisher realtimePublisher) + IAptRealtimePublisher realtimePublisher, DutyTransitionPush dutyPush) { this.stateMapper = stateMapper; this.jobService = jobService; this.realtimePublisher = realtimePublisher; + this.dutyPush = dutyPush; } @Override @@ -60,23 +66,41 @@ public class AptDutyStateServiceImpl implements IAptDutyStateService @Transactional public int callStart(String jcid) { - return require(stateMapper.updateCallStart(gyid(), jcid, false), jcid, "CALL_START"); + AptStateSnapshot before = snapshot(jcid); + int rows = require(stateMapper.updateCallStart(gyid(), jcid, false), jcid, "CALL_START"); + pushDuty(jcid, before); + return rows; + } + + @Override + @Transactional + public int joinConf(String jcid) + { + // 房间入会只补记呼叫响应时间:ryzt 仍是 1 叫乘中,值班台无需推送(状态未变); + // 待离寓迁移由房间挂断(HANGUP)触发,见 answered() + return require(stateMapper.updateJoinConf(gyid(), jcid), jcid, "JOIN_CONF"); } @Override @Transactional public int answered(String jcid) { - return require(stateMapper.updateAnswered(gyid(), jcid, ANSWERABLE), jcid, "ANSWERED"); + AptStateSnapshot before = snapshot(jcid); + int rows = require(stateMapper.updateAnswered(gyid(), jcid, ANSWERABLE), jcid, "ANSWERED"); + pushDuty(jcid, before); + return rows; } @Override @Transactional public int callFailed(String jcid) { + AptStateSnapshot before = snapshot(jcid); int rows = require(stateMapper.updateCallFailed(gyid(), jcid), jcid, "CALL_FAILED"); - // 无应答 / 超时转叫乘报警:P0,值班台需立即打断 + // 公寓端通道(/apt/ws):管理台与平板看到叫乘报警 realtimePublisher.alarm(jcid, "CALL_FAILED", "无应答转叫乘报警"); + // 值班台通道(/ws/duty):本方法也被 Quartz 扫描调用,调度线程没有 HTTP 上下文(L-11) + pushDuty(jcid, before); return rows; } @@ -84,19 +108,23 @@ public class AptDutyStateServiceImpl implements IAptDutyStateService @Transactional public int stop(String jcid) { - return require(stateMapper.updateStop(gyid(), jcid), jcid, "STOP"); + AptStateSnapshot before = snapshot(jcid); + int rows = require(stateMapper.updateStop(gyid(), jcid), jcid, "STOP"); + pushDuty(jcid, before); + return rows; } @Override @Transactional public int recall(String jcid) { + AptStateSnapshot before = snapshot(jcid); int rows = require(stateMapper.updateRecall(gyid(), jcid), jcid, "RE_CALL"); - AptStateSnapshot snapshot = stateMapper.selectSnapshot(gyid(), jcid); - String callMode = snapshot == null || snapshot.getCallMode() == null ? "SIP" : snapshot.getCallMode(); + String callMode = before == null || before.getCallMode() == null ? "SIP" : before.getCallMode(); // 表 3-10:RE_CALL 生成新会话,保证时间线与录音索引有独立 session_id stateMapper.insertCallSession(gyid(), "CS-" + UUID.randomUUID().toString().replace("-", ""), jcid, callMode); + pushDuty(jcid, before); return rows; } @@ -104,7 +132,10 @@ public class AptDutyStateServiceImpl implements IAptDutyStateService @Transactional public int forceFinish(String jcid) { - return require(stateMapper.updateForceFinish(gyid(), jcid), jcid, "FORCE_FINISH"); + AptStateSnapshot before = snapshot(jcid); + int rows = require(stateMapper.updateForceFinish(gyid(), jcid), jcid, "FORCE_FINISH"); + pushDuty(jcid, before); + return rows; } /** @@ -118,10 +149,13 @@ public class AptDutyStateServiceImpl implements IAptDutyStateService @Transactional public int markLeave(String jcid, String leaveType, String remark) { + AptStateSnapshot before = snapshot(jcid); String eventId = "LY-" + UUID.randomUUID().toString().replace("-", ""); int rows = require(stateMapper.updateMarkLeave(gyid(), jcid, leaveType, eventId, remark), jcid, "MARK_LEAVE"); stateMapper.insertLeaveEvent(gyid(), eventId, jcid, leaveType); + // 先推:下面房间号缺失会提前 return,推送不能跟着一起丢 + pushDuty(jcid, before); String roomNo = stateMapper.selectRoomNoByJcid(gyid(), jcid); if (roomNo == null || roomNo.isBlank()) @@ -168,7 +202,22 @@ public class AptDutyStateServiceImpl implements IAptDutyStateService @Transactional public int cancel(String jcid) { - return require(stateMapper.updateCancel(gyid(), jcid), jcid, "CANCEL"); + AptStateSnapshot before = snapshot(jcid); + int rows = require(stateMapper.updateCancel(gyid(), jcid), jcid, "CANCEL"); + pushDuty(jcid, before); + return rows; + } + + /** + * 迁移后推送值班台:重读一次快照拿新态,交给 {@link DutyTransitionPush} 判报警起停。 + * + *

    推送在事务内登记、提交后发送({@code DutyPushServiceImpl} 的 afterCommit), + * 回滚则不发;这里不回填 {@code actionId}——人工命令的幂等键在命令层, + * 状态机不知道也不该知道(`04` 表 4-7 的 actionId 仍由命令响应给出)。

    + */ + private void pushDuty(String jcid, AptStateSnapshot before) + { + dutyPush.afterTransition(jcid, before, snapshot(jcid), null); } private int require(int rows, String jcid, String transition) 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 ad1c88a..25a74d8 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 @@ -468,6 +468,8 @@ public class AptProperties private java.util.List dutyExtensions = new java.util.ArrayList<>(); /** 单次扫描最多发起的 SIP 呼叫数,避免补跑时瞬间打爆 Asterisk */ private int maxCallsPerScan = 10; + /** 会议开始后自动挂机秒数:值班员未主动挂断时到点强制挂断全部在会腿;<=0 关闭 */ + private int autoHangupSeconds = 45; public boolean isEnabled() { @@ -569,6 +571,16 @@ public class AptProperties this.dutyExtensions = dutyExtensions; } + public int getAutoHangupSeconds() + { + return autoHangupSeconds; + } + + public void setAutoHangupSeconds(int autoHangupSeconds) + { + this.autoHangupSeconds = autoHangupSeconds; + } + public int getMaxCallsPerScan() { return maxCallsPerScan; 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 38ad9c9..5fe6187 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 @@ -57,6 +57,10 @@ public interface AptVoiceMapper int markSessionsUnknown(@Param("gyid") String gyid, @Param("amiSessionId") String amiSessionId, @Param("cause") String cause); + /** 按 jcid 取在会(未结束)的呼叫腿:自动挂机与强制结束共用 */ + java.util.List> selectActiveSessionsByJcid( + @Param("gyid") String gyid, @Param("jcid") String jcid); + Map selectCallSession(@Param("gyid") String gyid, @Param("sessionId") String sessionId); diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/service/AptAmiEventHandler.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/service/AptAmiEventHandler.java index 1c3292b..36c3cd7 100644 --- a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/service/AptAmiEventHandler.java +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/service/AptAmiEventHandler.java @@ -21,7 +21,8 @@ import org.springframework.stereotype.Service; *
  • 房间腿的 {@code ConfbridgeJoin} 才算应答(提交 {@code ANSWERED}), * 值班台腿入会只记会话状态;
  • *
  • {@code OriginateResponse.Failure} 只有房间腿失败才提交 {@code CALL_FAILED};
  • - *
  • {@code Hangup} 只归档会话,不回写叫乘终态;
  • + *
  • 房间腿 {@code Hangup}(HANGUP)提交 {@code ANSWERED}——房间挂断即通话完成, + * 转待离寓;值班台/TTS 腿挂断只归档会话;
  • *
  • AMI 断连 → 在途会话标 {@code UNKNOWN} + 立即触发重连,不推定叫乘结论。
  • * * @@ -180,7 +181,10 @@ public class AptAmiEventHandler markTtsPlayed(gyid, session); } voiceMapper.updateCallSessionState(gyid, sessionId, "HANGUP", false, true, cause); - record(gyid, session, LEG_ROOM.equals(legRole) ? "HANGUP" : "DUTY_HANGUP", frame); + // 事件类型按腿角色区分:值班台挂断(DUTY_HANGUP)由事件服务提交 ANSWERED, + // 房间/TTS 挂断只归档,绝不推定叫乘结论 + record(gyid, session, LEG_ROOM.equals(legRole) ? "HANGUP" + : LEG_TTS.equals(legRole) ? "TTS_HANGUP" : "DUTY_HANGUP", frame); } /** AMI 断连:在途会话标 UNKNOWN,并通知连接管理器立刻重连 */ 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 bdfc7c1..b3a0471 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 @@ -6,6 +6,9 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.UUID; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; import com.ruoyi.apt.common.AptContext; import com.ruoyi.apt.common.AptErrorCode; import com.ruoyi.apt.common.AptException; @@ -18,6 +21,7 @@ import com.ruoyi.apt.voice.ami.AmiConnectionManager; import com.ruoyi.apt.voice.ami.AmiException; import com.ruoyi.apt.voice.domain.AptAmiInstance; import com.ruoyi.apt.voice.mapper.AptVoiceMapper; +import jakarta.annotation.PreDestroy; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; @@ -28,13 +32,17 @@ import org.springframework.stereotype.Service; *

    调用顺序(缺一不可):

    *
      * 校验 ryzt=0 → 解析房间分机 → CALL_START(0→1) → 建腿(apt_call_session)
    - *   → AMI Originate(房间 + 值班台)→ 等 OriginateResponse 绑定真实通道
    + *   → AMI Originate(值班台进 ,房间进 -admin)→ 等 OriginateResponse 绑定真实通道
      *   → ConfbridgeJoin(房间腿) → ANSWERED(1→3) → TTS 播报
      * 
    * *

    失败语义:房间腿 Originate 失败 → {@code CALL_FAILED}(ryzt=2); * 值班台腿失败只告警,不影响房间侧结论;TTS 失败只告警,不改叫乘结论。

    * + *

    会议收尾:值班员未主动挂断时,会议开始 {@code auto-hangup-seconds}(默认 45s)后 + * 由本类定时器强制挂断全部在会腿;值班员「结束叫乘」(FORCE_FINISH)同样挂断全部在会腿。 + * 房间腿挂断事件提交 ANSWERED → 待离寓。

    + * * @author apt-rev01 */ @Service @@ -52,6 +60,14 @@ public class AptCallFlowServiceImpl implements IAptCallFlowService private final AmiConnectionManager connectionManager; private final AptProperties properties; + /** 会议自动挂机定时器(单线程守护;Quartz 当前未启用,与连接心跳同一理由自建) */ + private final ScheduledExecutorService autoHangupScheduler = + Executors.newSingleThreadScheduledExecutor(runnable -> { + Thread thread = new Thread(runnable, "apt-voice-auto-hangup"); + thread.setDaemon(true); + return thread; + }); + public AptCallFlowServiceImpl(AptVoiceMapper voiceMapper, IAptDutyStateService stateService, IAptVoiceEventService voiceEventService, AmiConnectionManager connectionManager, AptProperties properties) @@ -92,20 +108,78 @@ public class AptCallFlowServiceImpl implements IAptCallFlowService throw new AptException(AptErrorCode.STATE_ILLEGAL, "CALL_ALREADY_STARTED", "仅 ryzt=0 且未停止的叫乘可发起呼叫,当前 ryzt=" + snapshot.getRyzt()); } + AmiClient ami = requireAmi(); + // gy_jcxx.fjh 是房号(room_no),不是 room_id;换算与关联统一在 Mapper 内完成 + String roomNo = str(jcxx.get("fjh")); + Map roomEndpoint = requireRoomEndpoint(gyid, roomNo); + + // 先落状态机再发起呼叫:即使 Originate 立即失败,也会经 CALL_FAILED 收敛到 ryzt=2 + stateService.callStart(jcid); + + return originateLegs(ami, connectionManager.currentInstance(), gyid, jcid, roomNo, roomEndpoint); + } + + /** + * 复叫重呼(RE_CALL)。 + * + *

    与首呼的区别:状态机已在 {@code recall} 中完成迁移(ryzt=1、清 stoped、新会话), + * 这里不再走 CALL_START,直接重建房间腿 + 值班台腿;房间腿 Originate 失败仍经 + * CALL_FAILED 收敛到 ryzt=2(复叫 30s 无入会语义由 jcsj_s 重置后重新起算)。

    + */ + @Override + public Map redial(String jcid) + { + String gyid = gyid(); + Map jcxx = voiceMapper.selectJcxxForCall(gyid, jcid); + if (jcxx == null) + { + throw new AptException(AptErrorCode.NOT_FOUND, "JCXX_NOT_FOUND", "叫乘记录不存在:" + jcid); + } + if (!"SIP".equals(str(jcxx.get("callMode")))) + { + throw new AptException(AptErrorCode.BAD_REQUEST, "CALL_MODE_NOT_SIP", + "该叫乘为平板链路,不可走 SIP 呼叫"); + } + if ("1".equals(str(jcxx.get("finishJc"))) || "1".equals(str(jcxx.get("finishLy")))) + { + throw new AptException(AptErrorCode.STATE_ILLEGAL, "CALL_ALREADY_FINISHED", + "该叫乘已完成或已离寓,不可再呼叫"); + } + AptStateSnapshot snapshot = stateService.snapshot(jcid); + if (snapshot == null) + { + throw new AptException(AptErrorCode.NOT_FOUND, "JCXX_NOT_FOUND", "叫乘记录不存在:" + jcid); + } + if (!"1".equals(snapshot.getRyzt()) || "1".equals(snapshot.getStoped())) + { + throw new AptException(AptErrorCode.STATE_ILLEGAL, "RECALL_STATE_ILLEGAL", + "仅复叫后(ryzt=1 且未停止)的叫乘可重呼,当前 ryzt=" + snapshot.getRyzt()); + } + AmiClient ami = requireAmi(); + String roomNo = str(jcxx.get("fjh")); + Map roomEndpoint = requireRoomEndpoint(gyid, roomNo); + return originateLegs(ami, connectionManager.currentInstance(), gyid, jcid, roomNo, roomEndpoint); + } + + /** AMI 连接与实例校验,任一缺失即抛依赖不可用 */ + private AmiClient requireAmi() + { if (!connectionManager.isUp()) { throw new AptException(AptErrorCode.DEPENDENCY_UNAVAILABLE, "AMI_NOT_CONNECTED", "AMI 连接未就绪,无法发起语音叫乘"); } AmiClient ami = connectionManager.current(); - AptAmiInstance instance = connectionManager.currentInstance(); - if (instance == null) + if (ami == null || connectionManager.currentInstance() == null) { throw new AptException(AptErrorCode.DEPENDENCY_UNAVAILABLE, "AMI_INSTANCE_MISSING", "公寓未配置 Asterisk 实例"); } - // gy_jcxx.fjh 是房号(room_no),不是 room_id;换算与关联统一在 Mapper 内完成 - String roomNo = str(jcxx.get("fjh")); + return ami; + } + + private Map requireRoomEndpoint(String gyid, String roomNo) + { Map roomEndpoint = voiceMapper.selectRoomExtension(gyid, roomNo); if (roomEndpoint == null) { @@ -113,23 +187,25 @@ public class AptCallFlowServiceImpl implements IAptCallFlowService "房间 " + roomNo + " 未绑定可用分机(apt_master_room.room_no → " + "apt_sip_binding.room_id → apt_sip_endpoint)"); } + return roomEndpoint; + } - // 先落状态机再发起呼叫:即使 Originate 立即失败,也会经 CALL_FAILED 收敛到 ryzt=2 - stateService.callStart(jcid); - + /** + * 值班台腿 + 房间腿 Originate(先值班台,后房间)。 + * + *

    值班台腿邀请进 {@code }(如 Conf-01,值班员接通前提示音由 + * Asterisk 拨号计划负责),房间腿进 {@code -admin}(如 Conf-01-admin)。 + * 房间腿失败即 CALL_FAILED 并抛依赖不可用;值班台腿失败只告警,不影响房间侧结论。 + * 双腿发起成功后登记会议开始,到点未收尾由自动挂机兜底。

    + */ + private Map originateLegs(AmiClient ami, AptAmiInstance instance, String gyid, + String jcid, String roomNo, Map roomEndpoint) + { String confNo = instance.getConference(); + String roomConfNo = confNo + "-admin"; List> legs = new ArrayList<>(); - String roomSessionId = newSessionId(); - boolean roomStarted = originateLeg(ami, instance, gyid, roomSessionId, jcid, confNo, - LEG_ROOM, str(roomEndpoint.get("endpointId")), str(roomEndpoint.get("extension"))); - legs.add(legSummary(roomSessionId, LEG_ROOM, str(roomEndpoint.get("extension")), roomStarted)); - if (!roomStarted) - { - stateService.callFailed(jcid); - throw new AptException(AptErrorCode.DEPENDENCY_UNAVAILABLE, "AMI_ORIGINATE_FAILED", - "房间分机呼叫发起失败:" + roomNo); - } + // 1) 先呼值班台腿 -> (值班员先接入,再振铃房间) int index = 0; for (String dutyExtension : properties.getVoice().getDutyExtensions()) { @@ -148,9 +224,24 @@ public class AptCallFlowServiceImpl implements IAptCallFlowService legs.add(legSummary(dutySessionId, LEG_DUTY, dutyExtension.trim(), dutyStarted)); } + // 2) 再呼房间腿 -> -admin + String roomSessionId = newSessionId(); + boolean roomStarted = originateLeg(ami, instance, gyid, roomSessionId, jcid, roomConfNo, + LEG_ROOM, str(roomEndpoint.get("endpointId")), str(roomEndpoint.get("extension"))); + legs.add(legSummary(roomSessionId, LEG_ROOM, str(roomEndpoint.get("extension")), roomStarted)); + if (!roomStarted) + { + stateService.callFailed(jcid); + throw new AptException(AptErrorCode.DEPENDENCY_UNAVAILABLE, "AMI_ORIGINATE_FAILED", + "房间分机呼叫发起失败:" + roomNo); + } + + scheduleAutoHangup(gyid, jcid); + Map result = new LinkedHashMap<>(); result.put("jcid", jcid); result.put("confNo", confNo); + result.put("roomConfNo", roomConfNo); result.put("instanceCode", instance.getInstanceCode()); result.put("legs", legs); result.put("state", "CALLING"); @@ -158,6 +249,83 @@ public class AptCallFlowServiceImpl implements IAptCallFlowService return result; } + /** + * 会议开始 auto-hangup-seconds(默认 45s)后,若值班员仍未主动挂断 + * (该 jcid 还有在会腿),强制挂断全部在会腿;房间腿挂断事件随后提交 + * ANSWERED → 待离寓,防止「房间一直不挂、会议悬挂」。 + */ + private void scheduleAutoHangup(String gyid, String jcid) + { + int delaySeconds = properties.getVoice().getAutoHangupSeconds(); + if (delaySeconds <= 0) + { + return; + } + autoHangupScheduler.schedule(() -> { + try + { + int hungUp = hangupActiveLegs(gyid, jcid, "AUTO_HANGUP_" + delaySeconds + "S"); + if (hungUp > 0) + { + log.info("会议到点自动挂机:jcid={},挂断 {} 条在会腿", jcid, hungUp); + } + } + catch (Exception e) + { + log.warn("会议自动挂机失败 jcid={}:{}", jcid, e.toString()); + } + }, delaySeconds, TimeUnit.SECONDS); + } + + /** + * 强制结束(值班员「结束叫乘」/ FORCE_FINISH):挂断该叫乘全部在会腿。 + * AMI 未就绪时只告警不抛错——状态迁移已由命令层提交,挂断是收尾动作。 + */ + @Override + public int forceHangupAll(String jcid) + { + String gyid = gyid(); + return hangupActiveLegs(gyid, jcid, "FORCE_FINISH"); + } + + /** 挂断该 jcid 全部在会腿并归档会话,返回下发挂断的腿数 */ + private int hangupActiveLegs(String gyid, String jcid, String cause) + { + if (!connectionManager.isUp() || connectionManager.current() == null) + { + log.warn("AMI 未就绪,跳过挂断 gyid={} jcid={} cause={}", gyid, jcid, cause); + return 0; + } + List> active = voiceMapper.selectActiveSessionsByJcid(gyid, jcid); + int hungUp = 0; + for (Map session : active == null ? List.>of() : active) + { + String sessionId = str(session.get("sessionId")); + String channel = str(session.get("channel")); + if (!channel.isEmpty()) + { + try + { + connectionManager.current().sendAction(AmiAction.hangup(channel, cause)); + } + catch (AmiException e) + { + log.warn("挂断失败 session={} channel={}:{}", sessionId, channel, e.getReason()); + } + } + // 未绑定真实通道(尚未收到 OriginateResponse)也归档,防止腿悬挂 + voiceMapper.updateCallSessionState(gyid, sessionId, "HANGUP", false, true, cause); + hungUp++; + } + return hungUp; + } + + @PreDestroy + void shutdownAutoHangupScheduler() + { + autoHangupScheduler.shutdownNow(); + } + /** 发起一条呼叫腿:先落 apt_call_session(INIT),再发 AMI Originate */ private boolean originateLeg(AmiClient ami, AptAmiInstance instance, String gyid, String sessionId, String jcid, String confNo, String legRole, String endpointId, 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 55f36a1..3d6d736 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,9 +23,11 @@ import org.springframework.transaction.annotation.Transactional; *

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

    *
      * ORIGINATE_START / NEWSTATE   -> CALL_START   (ryzt 0 -> 1)
    - * CONFBRIDGEJOIN               -> ANSWERED     (-> 3, 判据是房间侧入会,不是 SIP Answer)
    + * CONFBRIDGEJOIN               -> JOIN_CONF    (补记 hjxysj,ryzt 保持 1 叫乘中)
      * ORIGINATE_FAILURE / NO_ANSWER-> CALL_FAILED  (-> 2)
    - * HANGUP                       -> 不回写(已结束不回写)
    + * HANGUP(房间腿)             -> ANSWERED     (-> 3 待离寓:房间挂断即通话完成;
    + *                                               已应答/已结束时为非法迁移,按口径忽略)
    + * DUTY_HANGUP / TTS_HANGUP     -> 不回写(值班员挂断、TTS 结束只归档会话)
      * AMI_DISCONNECT               -> 不推定终态(5 分钟巡检兜底在 IT-7)
      * 
    * @@ -94,7 +96,8 @@ public class AptVoiceEventServiceImpl implements IAptVoiceEventService result.put("submitted", true); break; case "CONFBRIDGEJOIN": - stateService.answered(command.getJcid()); + // 房间入会 = 叫乘中(不迁移状态);待离寓由房间挂断触发 + stateService.joinConf(command.getJcid()); result.put("submitted", true); break; case "ORIGINATE_FAILURE": @@ -104,8 +107,16 @@ public class AptVoiceEventServiceImpl implements IAptVoiceEventService result.put("submitted", true); break; case "HANGUP": + // 房间腿挂断 = 通话完成(值班员侧会议随之收尾), + // 提交 ANSWERED:ryzt -> 3 已应答待离寓、finish_jc = 1; + // 已应答/已强制结束时迁移非法,走下方 STATE_ILLEGAL 忽略口径 + stateService.answered(command.getJcid()); + result.put("submitted", true); + break; + case "DUTY_HANGUP": + case "TTS_HANGUP": case "AMI_DISCONNECT": - // 已结束不回写;AMI 断连不推定终态(表 5-13) + // 值班员挂断、TTS 结束只归档;AMI 断连不推定终态(表 5-13) break; default: 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 0d01b66..47db6af 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 @@ -15,6 +15,12 @@ public interface IAptCallFlowService /** 对单条叫乘发起 SIP 双路呼叫(要求 ryzt=0 且 AMI 已就绪) */ Map dial(String jcid); + /** 复叫重呼:对已复叫(ryzt=1 且未停止)的记录重建房间腿 + 值班台腿,不再走 CALL_START */ + Map redial(String jcid); + + /** 强制结束:挂断该叫乘所有在会(未结束)的呼叫腿,返回成功下发挂断的腿数 */ + int forceHangupAll(String jcid); + /** 扫描到点待叫乘并逐条发起呼叫,返回成功发起数 */ int dialDue(int limit); 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 716f574..881c40a 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 @@ -100,6 +100,8 @@ apt: sip-call-enabled: ${APT_VOICE_SIP_CALL_ENABLED:true} # 值班台分机(双腿呼叫的第二腿),逗号分隔,例:1002,1003;空=只呼房间腿 duty-extensions: ${APT_VOICE_DUTY_EXTENSIONS:} + # 会议开始后自动挂机秒数:值班员未主动挂断时到点强制挂断全部在会腿;<=0 关闭 + auto-hangup-seconds: ${APT_VOICE_AUTO_HANGUP_SEC:45} # TTS 播报:Originate 到 Local/@, # Application=Playback、Data=(音频由 Asterisk 侧预先合成) tts-context: ${APT_VOICE_TTS_CONTEXT:apt-tts} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptCheckinMapper.xml b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptCheckinMapper.xml index 32d9069..7c1499d 100644 --- a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptCheckinMapper.xml +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptCheckinMapper.xml @@ -380,11 +380,16 @@ LIMIT 1 + - SELECT id, room_id AS roomId, room_no AS roomNo, room_status AS roomStatus, - state_version AS stateVersion, in_person_count AS inPersonCount, - bed_count AS bedCount - FROM gy_rooms - WHERE gyid = #{gyid} AND room_no = #{roomNo} AND deleted = 0 + + SELECT r.id, r.room_id AS roomId, r.room_no AS roomNo, r.room_status AS roomStatus, + r.state_version AS stateVersion, r.in_person_count AS inPersonCount, + COALESCE(m.bed_count, 0) AS bedCount + FROM gy_rooms r + LEFT JOIN apt_master_room m + ON m.gyid = r.gyid AND m.room_id = r.room_id AND m.deleted = 0 + WHERE r.gyid = #{gyid} AND r.room_no = #{roomNo} AND r.deleted = 0 LIMIT 1 diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptVoiceMapper.xml b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptVoiceMapper.xml index 898622c..c85eb70 100644 --- a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptVoiceMapper.xml +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptVoiceMapper.xml @@ -147,6 +147,16 @@ LIMIT 1 + + +