修改通过SIP呼叫房间的相关逻辑,目前定时呼叫和临时呼叫都可以呼通。
This commit is contained in:
1 parent
83ece20a00
commit
d2df03f023
23 files changed
+702
-52
No files matched your search
+71
-1
@@ -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)
|
||||
{
|
||||
|
||||
+58
-9
@@ -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 裁决);</li>
|
||||
* <li>SIP 30s 无入会由扫描任务调用 {@link #callFailed(String)},
|
||||
* 仍是「提交命令」而非直写。</li>
|
||||
* <li><b>每次迁移后统一推值班台</b>(R4 / `L-11`):本类是四类触发源
|
||||
* (人工命令 / Quartz 扫描 / AMI 事件 / 平板上报)的唯一汇合点,
|
||||
* 推送放在这里,定时任务产生的报警才可能到达 `/ws/duty`。</li>
|
||||
* </ul>
|
||||
*
|
||||
* @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} 判报警起停。
|
||||
*
|
||||
* <p>推送在事务内登记、提交后发送({@code DutyPushServiceImpl} 的 afterCommit),
|
||||
* 回滚则不发;这里不回填 {@code actionId}——人工命令的幂等键在命令层,
|
||||
* 状态机不知道也不该知道(`04` 表 4-7 的 actionId 仍由命令响应给出)。</p>
|
||||
*/
|
||||
private void pushDuty(String jcid, AptStateSnapshot before)
|
||||
{
|
||||
dutyPush.afterTransition(jcid, before, snapshot(jcid), null);
|
||||
}
|
||||
|
||||
private int require(int rows, String jcid, String transition)
|
||||
|
||||
+12
@@ -468,6 +468,8 @@ public class AptProperties
|
||||
private java.util.List<String> 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;
|
||||
|
||||
+4
@@ -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<java.util.Map<String, Object>> selectActiveSessionsByJcid(
|
||||
@Param("gyid") String gyid, @Param("jcid") String jcid);
|
||||
|
||||
Map<String, Object> selectCallSession(@Param("gyid") String gyid,
|
||||
@Param("sessionId") String sessionId);
|
||||
|
||||
|
||||
+6
-2
@@ -21,7 +21,8 @@ import org.springframework.stereotype.Service;
|
||||
* <li><b>房间腿</b>的 {@code ConfbridgeJoin} 才算应答(提交 {@code ANSWERED}),
|
||||
* 值班台腿入会只记会话状态;</li>
|
||||
* <li>{@code OriginateResponse.Failure} 只有房间腿失败才提交 {@code CALL_FAILED};</li>
|
||||
* <li>{@code Hangup} 只归档会话,不回写叫乘终态;</li>
|
||||
* <li>房间腿 {@code Hangup}(HANGUP)提交 {@code ANSWERED}——房间挂断即通话完成,
|
||||
* 转待离寓;值班台/TTS 腿挂断只归档会话;</li>
|
||||
* <li>AMI 断连 → 在途会话标 {@code UNKNOWN} + 立即触发重连,不推定叫乘结论。</li>
|
||||
* </ul>
|
||||
*
|
||||
@@ -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,并通知连接管理器立刻重连 */
|
||||
|
||||
+186
-18
@@ -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;
|
||||
* <p>调用顺序(缺一不可):</p>
|
||||
* <pre>
|
||||
* 校验 ryzt=0 → 解析房间分机 → CALL_START(0→1) → 建腿(apt_call_session)
|
||||
* → AMI Originate(房间 + 值班台)→ 等 OriginateResponse 绑定真实通道
|
||||
* → AMI Originate(值班台进 <confNo>,房间进 <confNo>-admin)→ 等 OriginateResponse 绑定真实通道
|
||||
* → ConfbridgeJoin(房间腿) → ANSWERED(1→3) → TTS 播报
|
||||
* </pre>
|
||||
*
|
||||
* <p>失败语义:房间腿 Originate 失败 → {@code CALL_FAILED}(ryzt=2);
|
||||
* 值班台腿失败只告警,不影响房间侧结论;TTS 失败只告警,不改叫乘结论。</p>
|
||||
*
|
||||
* <p>会议收尾:值班员未主动挂断时,会议开始 {@code auto-hangup-seconds}(默认 45s)后
|
||||
* 由本类定时器强制挂断全部在会腿;值班员「结束叫乘」(FORCE_FINISH)同样挂断全部在会腿。
|
||||
* 房间腿挂断事件提交 ANSWERED → 待离寓。</p>
|
||||
*
|
||||
* @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<String, Object> roomEndpoint = requireRoomEndpoint(gyid, roomNo);
|
||||
|
||||
// 先落状态机再发起呼叫:即使 Originate 立即失败,也会经 CALL_FAILED 收敛到 ryzt=2
|
||||
stateService.callStart(jcid);
|
||||
|
||||
return originateLegs(ami, connectionManager.currentInstance(), gyid, jcid, roomNo, roomEndpoint);
|
||||
}
|
||||
|
||||
/**
|
||||
* 复叫重呼(RE_CALL)。
|
||||
*
|
||||
* <p>与首呼的区别:状态机已在 {@code recall} 中完成迁移(ryzt=1、清 stoped、新会话),
|
||||
* 这里不再走 CALL_START,直接重建房间腿 + 值班台腿;房间腿 Originate 失败仍经
|
||||
* CALL_FAILED 收敛到 ryzt=2(复叫 30s 无入会语义由 jcsj_s 重置后重新起算)。</p>
|
||||
*/
|
||||
@Override
|
||||
public Map<String, Object> redial(String jcid)
|
||||
{
|
||||
String gyid = gyid();
|
||||
Map<String, Object> 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<String, Object> 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<String, Object> requireRoomEndpoint(String gyid, String roomNo)
|
||||
{
|
||||
Map<String, Object> 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(先值班台,后房间)。
|
||||
*
|
||||
* <p>值班台腿邀请进 {@code <confNo>}(如 Conf-01,值班员接通前提示音由
|
||||
* Asterisk 拨号计划负责),房间腿进 {@code <confNo>-admin}(如 Conf-01-admin)。
|
||||
* 房间腿失败即 CALL_FAILED 并抛依赖不可用;值班台腿失败只告警,不影响房间侧结论。
|
||||
* 双腿发起成功后登记会议开始,到点未收尾由自动挂机兜底。</p>
|
||||
*/
|
||||
private Map<String, Object> originateLegs(AmiClient ami, AptAmiInstance instance, String gyid,
|
||||
String jcid, String roomNo, Map<String, Object> roomEndpoint)
|
||||
{
|
||||
String confNo = instance.getConference();
|
||||
String roomConfNo = confNo + "-admin";
|
||||
List<Map<String, Object>> 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) 先呼值班台腿 -> <confNo>(值班员先接入,再振铃房间)
|
||||
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) 再呼房间腿 -> <confNo>-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<String, Object> 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<Map<String, Object>> active = voiceMapper.selectActiveSessionsByJcid(gyid, jcid);
|
||||
int hungUp = 0;
|
||||
for (Map<String, Object> session : active == null ? List.<Map<String, Object>>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,
|
||||
|
||||
+15
-4
@@ -23,9 +23,11 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
* <p>事件映射(公寓端/05 表 5-13):</p>
|
||||
* <pre>
|
||||
* 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)
|
||||
* </pre>
|
||||
*
|
||||
@@ -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;
|
||||
|
||||
+6
@@ -15,6 +15,12 @@ public interface IAptCallFlowService
|
||||
/** 对单条叫乘发起 SIP 双路呼叫(要求 ryzt=0 且 AMI 已就绪) */
|
||||
Map<String, Object> dial(String jcid);
|
||||
|
||||
/** 复叫重呼:对已复叫(ryzt=1 且未停止)的记录重建房间腿 + 值班台腿,不再走 CALL_START */
|
||||
Map<String, Object> redial(String jcid);
|
||||
|
||||
/** 强制结束:挂断该叫乘所有在会(未结束)的呼叫腿,返回成功下发挂断的腿数 */
|
||||
int forceHangupAll(String jcid);
|
||||
|
||||
/** 扫描到点待叫乘并逐条发起呼叫,返回成功发起数 */
|
||||
int dialDue(int limit);
|
||||
|
||||
|
||||
@@ -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/<tts-extension>@<tts-context>,
|
||||
# Application=Playback、Data=<tts-media-prefix>(音频由 Asterisk 侧预先合成)
|
||||
tts-context: ${APT_VOICE_TTS_CONTEXT:apt-tts}
|
||||
|
||||
@@ -380,11 +380,16 @@
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<!--
|
||||
注意:apt_person_face_reg 没有 deleted 列(06_domain_06_cr02_extra.sql 建表即无此列),
|
||||
历史记录以 reg_state(APPLIED/CONFIRMED/REJECTED/ARCHIVED)表达生命周期。
|
||||
早先这里多带了一个 deleted = 0,任何入住申请都会 1054 Unknown column。
|
||||
-->
|
||||
<select id="selectConfirmedReg" resultType="java.util.LinkedHashMap">
|
||||
SELECT reg_id AS regId, reg_state AS regState, feature_version AS featureVersion,
|
||||
work_no AS workNo, name AS name
|
||||
FROM apt_person_face_reg
|
||||
WHERE gyid = #{gyid} AND reg_state = 'CONFIRMED' AND deleted = 0
|
||||
WHERE gyid = #{gyid} AND reg_state = 'CONFIRMED'
|
||||
<if test="regId != null and regId != ''">
|
||||
AND reg_id = #{regId}
|
||||
</if>
|
||||
|
||||
+16
-2
@@ -49,6 +49,16 @@
|
||||
</foreach>
|
||||
</update>
|
||||
|
||||
<!-- 房间入会(CONFBRIDGEJOIN):只补记呼叫响应时间,ryzt 保持 1 叫乘中。
|
||||
待离寓(ryzt=3)由房间挂断(HANGUP)触发,见 updateAnswered -->
|
||||
<update id="updateJoinConf">
|
||||
UPDATE gy_jcxx
|
||||
SET 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'
|
||||
</update>
|
||||
|
||||
<update id="updateCallFailed">
|
||||
UPDATE gy_jcxx
|
||||
SET ryzt = '2',
|
||||
@@ -72,7 +82,9 @@
|
||||
SET ryzt = '1',
|
||||
stoped = '0',
|
||||
fjsj_s = NOW(3),
|
||||
jcsj_s = COALESCE(jcsj_s, NOW(3)),
|
||||
<!-- 复叫即本次呼叫起点:重置 jcsj_s,作为重呼后 SIP 30s 超时判定基准,
|
||||
避免旧值导致复叫后立即被扫描误判无应答转报警 -->
|
||||
jcsj_s = NOW(3),
|
||||
biz_version = biz_version + 1
|
||||
WHERE gyid = #{gyid} AND jcid = #{jcid} AND deleted = 0
|
||||
AND (stoped = '1' OR ryzt = '2')
|
||||
@@ -118,7 +130,9 @@
|
||||
INSERT INTO gygl_ly_event
|
||||
(gyid, event_id, event_type, event_source, jcid, ryid, fjh,
|
||||
occurred_at, received_at, report_state, deleted)
|
||||
SELECT gyid, #{eventId}, 'CHECKOUT', 'DUTY', jcid, ryid, fjh,
|
||||
<!-- event_source 受 ck_lye_source 约束,只允许 DEVICE/FACE/MANUAL;
|
||||
值班台 MARK_LEAVE 属人工登记,固定 MANUAL(leaveType 只落 gy_jcxx.leave_type) -->
|
||||
SELECT gyid, #{eventId}, 'CHECKOUT', 'MANUAL', jcid, ryid, fjh,
|
||||
NOW(3), NOW(3), 'PENDING', 0
|
||||
FROM gy_jcxx
|
||||
WHERE gyid = #{gyid} AND jcid = #{jcid} AND deleted = 0
|
||||
|
||||
@@ -56,11 +56,14 @@
|
||||
|
||||
<!--- 公共 -->
|
||||
<select id="selectRoomByNo" resultType="java.util.LinkedHashMap">
|
||||
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
|
||||
<!-- gy_rooms 无床位列,容量只在平台基线 apt_master_room.bed_count(同 AptRoomMapper 口径) -->
|
||||
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
|
||||
</select>
|
||||
|
||||
|
||||
@@ -147,6 +147,16 @@
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<!-- 在会腿:未结束(INIT/CALLING/RINGING/JOINED_CONF),自动挂机与强制结束的挂断名单 -->
|
||||
<select id="selectActiveSessionsByJcid" resultType="java.util.LinkedHashMap">
|
||||
SELECT session_id AS sessionId, jcid, endpoint_id AS endpointId,
|
||||
leg_role AS legRole, channel, call_state AS callState
|
||||
FROM apt_call_session
|
||||
WHERE gyid = #{gyid} AND jcid = #{jcid} AND deleted = 0
|
||||
AND call_state IN ('INIT', 'CALLING', 'RINGING', 'JOINED_CONF')
|
||||
ORDER BY id ASC
|
||||
</select>
|
||||
|
||||
<select id="selectDueSipCalls" resultType="java.util.LinkedHashMap">
|
||||
SELECT j.jcid, j.jcsj, j.fjh, j.ryid, j.xm AS rymc
|
||||
FROM gy_jcxx j
|
||||
|
||||
+2
-1
@@ -42,7 +42,8 @@ class AptDutyCommandServiceTest
|
||||
stateService = mock(IAptDutyStateService.class);
|
||||
auditWriter = mock(AptAuditWriter.class);
|
||||
service = new AptDutyCommandServiceImpl(mapper, stateService, auditWriter,
|
||||
mock(com.ruoyi.apt.framework.realtime.IAptRealtimePublisher.class));
|
||||
mock(com.ruoyi.apt.framework.realtime.IAptRealtimePublisher.class),
|
||||
mock(com.ruoyi.apt.voice.service.IAptCallFlowService.class));
|
||||
AptContext.clear();
|
||||
AptContext.setGyId("APT-A");
|
||||
}
|
||||
|
||||
+82
-2
@@ -16,6 +16,9 @@ import com.ruoyi.apt.common.AptException;
|
||||
import com.ruoyi.apt.dispatch.domain.AptStateSnapshot;
|
||||
import com.ruoyi.apt.dispatch.mapper.AptDutyStateMapper;
|
||||
import com.ruoyi.apt.dispatch.service.AptDutyStateServiceImpl;
|
||||
import com.ruoyi.apt.duty.realtime.DutyTransitionPush;
|
||||
import com.ruoyi.apt.duty.realtime.IDutyPushService;
|
||||
import com.ruoyi.apt.framework.realtime.IAptRealtimePublisher;
|
||||
import com.ruoyi.apt.job.service.IAptJobService;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@@ -28,6 +31,8 @@ class AptDutyStateServiceTest
|
||||
{
|
||||
private AptDutyStateMapper mapper;
|
||||
private IAptJobService jobService;
|
||||
private IAptRealtimePublisher realtimePublisher;
|
||||
private IDutyPushService pushService;
|
||||
private AptDutyStateServiceImpl service;
|
||||
|
||||
@BeforeEach
|
||||
@@ -35,8 +40,11 @@ class AptDutyStateServiceTest
|
||||
{
|
||||
mapper = mock(AptDutyStateMapper.class);
|
||||
jobService = mock(IAptJobService.class);
|
||||
service = new AptDutyStateServiceImpl(mapper, jobService,
|
||||
mock(com.ruoyi.apt.framework.realtime.IAptRealtimePublisher.class));
|
||||
realtimePublisher = mock(IAptRealtimePublisher.class);
|
||||
pushService = mock(IDutyPushService.class);
|
||||
// 用真实迁移桥串起"状态机 → 值班台推送",L-11 就是在这一层被漏掉的
|
||||
service = new AptDutyStateServiceImpl(mapper, jobService, realtimePublisher,
|
||||
new DutyTransitionPush(pushService));
|
||||
AptContext.clear();
|
||||
AptContext.setGyId("APT-A");
|
||||
}
|
||||
@@ -56,6 +64,24 @@ class AptDutyStateServiceTest
|
||||
verify(mapper).updateAnswered("APT-A", "JC-1", List.of("0", "1", "2"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void joinConfOnlyRecordsResponseTime()
|
||||
{
|
||||
when(mapper.updateJoinConf(eq("APT-A"), eq("JC-1"))).thenReturn(1);
|
||||
|
||||
assertEquals(1, service.joinConf("JC-1"));
|
||||
verify(mapper).updateJoinConf("APT-A", "JC-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void joinConfMissedConditionalUpdateMapsTo40902()
|
||||
{
|
||||
when(mapper.updateJoinConf(eq("APT-A"), eq("JC-2"))).thenReturn(0);
|
||||
|
||||
AptException e = assertThrows(AptException.class, () -> service.joinConf("JC-2"));
|
||||
assertEquals(40902, e.getErrorCode().getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void missedConditionalUpdateMapsTo40902()
|
||||
{
|
||||
@@ -106,4 +132,58 @@ class AptDutyStateServiceTest
|
||||
AptException e = assertThrows(AptException.class, () -> service.callFailed("JC-6"));
|
||||
assertEquals(40902, e.getErrorCode().getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void callFailedPushesDutyAlarmEvenWithoutHttpContext()
|
||||
{
|
||||
// L-11:Quartz 叫乘扫描线程没有 HTTP 上下文,这条报警也必须到 /ws/duty
|
||||
when(mapper.selectSnapshot("APT-A", "JC-6"))
|
||||
.thenReturn(snapshot("1", "0", "0", "0"), snapshot("2", "0", "0", "0"));
|
||||
when(mapper.updateCallFailed("APT-A", "JC-6")).thenReturn(1);
|
||||
when(pushService.stateChanged(anyString(), any(), any(), anyString(), anyString(), any()))
|
||||
.thenReturn(7L);
|
||||
|
||||
assertEquals(1, service.callFailed("JC-6"));
|
||||
|
||||
verify(pushService).stateChanged("JC-6", 1, 2, "ALARM_CALL", "叫乘报警", null);
|
||||
verify(pushService).alarmRaised("JC-6", "CALL", "JC-6:CALL");
|
||||
// 公寓端通道不受影响:两条通道各推各的权限点
|
||||
verify(realtimePublisher).alarm("JC-6", "CALL_FAILED", "无应答转叫乘报警");
|
||||
}
|
||||
|
||||
@Test
|
||||
void stopOnAlarmRowClearsDutyAlarm()
|
||||
{
|
||||
when(mapper.selectSnapshot("APT-A", "JC-7"))
|
||||
.thenReturn(snapshot("2", "0", "0", "0"), snapshot("2", "0", "1", "0"));
|
||||
when(mapper.updateStop("APT-A", "JC-7")).thenReturn(1);
|
||||
when(pushService.stateChanged(anyString(), any(), any(), anyString(), anyString(), any()))
|
||||
.thenReturn(8L);
|
||||
|
||||
service.stop("JC-7");
|
||||
|
||||
verify(pushService).alarmCleared("JC-7", "CALL", "JC-7:CALL");
|
||||
verify(pushService, never()).alarmRaised(anyString(), anyString(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingSnapshotAfterTransitionDoesNotGuess()
|
||||
{
|
||||
// 读不到新态就不推:宁可漏一条,也不能用旧态冒充新态下发
|
||||
when(mapper.updateStop("APT-A", "JC-8")).thenReturn(1);
|
||||
|
||||
service.stop("JC-8");
|
||||
|
||||
verify(pushService, never()).stateChanged(anyString(), any(), any(), anyString(), anyString(), any());
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
package com.ruoyi.apt.voice;
|
||||
|
||||
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.when;
|
||||
import java.util.Date;
|
||||
import com.ruoyi.apt.common.AptContext;
|
||||
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.AptJcxxMapper;
|
||||
import com.ruoyi.apt.dispatch.service.IAptDutyStateService;
|
||||
import com.ruoyi.apt.dispatch.tablet.mapper.AptTabletMapper;
|
||||
import com.ruoyi.apt.framework.config.AptProperties;
|
||||
import com.ruoyi.apt.voice.domain.AptSipEventCommand;
|
||||
import com.ruoyi.apt.voice.service.AptVoiceEventServiceImpl;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* AMI 事件 -> 状态机命令映射回归(表 5-13 + DUTY_HANGUP 新语义)。
|
||||
*/
|
||||
class AptVoiceEventServiceTest
|
||||
{
|
||||
private AptJcxxMapper jcxxMapper;
|
||||
private IAptDutyStateService stateService;
|
||||
private AptVoiceEventServiceImpl service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp()
|
||||
{
|
||||
jcxxMapper = mock(AptJcxxMapper.class);
|
||||
stateService = mock(IAptDutyStateService.class);
|
||||
service = new AptVoiceEventServiceImpl(jcxxMapper, mock(AptTabletMapper.class),
|
||||
stateService, new AptProperties());
|
||||
AptContext.clear();
|
||||
AptContext.setGyId("APT-A");
|
||||
when(jcxxMapper.selectNextEventSeq(anyString(), anyString())).thenReturn(1L);
|
||||
when(stateService.snapshot("JC-1")).thenReturn(snapshot("1"));
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown()
|
||||
{
|
||||
AptContext.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void roomHangupSubmitsAnswered()
|
||||
{
|
||||
service.handle(command("HANGUP"));
|
||||
|
||||
verify(stateService).answered("JC-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void dutyHangupDoesNotTouchState()
|
||||
{
|
||||
service.handle(command("DUTY_HANGUP"));
|
||||
|
||||
verify(stateService, never()).answered(anyString());
|
||||
verify(stateService, never()).callFailed(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void roomConfbridgeJoinKeepsCallingState()
|
||||
{
|
||||
service.handle(command("CONFBRIDGEJOIN"));
|
||||
|
||||
verify(stateService).joinConf("JC-1");
|
||||
verify(stateService, never()).answered(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void ttsHangupDoesNotTouchState()
|
||||
{
|
||||
service.handle(command("TTS_HANGUP"));
|
||||
|
||||
verify(stateService, never()).answered(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void roomHangupAfterAnsweredIsIgnoredNotThrown()
|
||||
{
|
||||
when(stateService.answered("JC-1")).thenThrow(new AptException(
|
||||
AptErrorCode.STATE_ILLEGAL, "STATE_TRANSITION_ILLEGAL", "非法迁移"));
|
||||
|
||||
var result = service.handle(command("HANGUP"));
|
||||
|
||||
verify(stateService).answered("JC-1");
|
||||
org.junit.jupiter.api.Assertions.assertEquals(Boolean.TRUE, result.get("ignored"));
|
||||
}
|
||||
|
||||
private AptSipEventCommand command(String eventType)
|
||||
{
|
||||
AptSipEventCommand command = new AptSipEventCommand();
|
||||
command.setJcid("JC-1");
|
||||
command.setSessionId("CS-1");
|
||||
command.setEventType(eventType);
|
||||
command.setOccurredAt(new Date());
|
||||
return command;
|
||||
}
|
||||
|
||||
private AptStateSnapshot snapshot(String ryzt)
|
||||
{
|
||||
AptStateSnapshot snapshot = new AptStateSnapshot();
|
||||
snapshot.setJcid("JC-1");
|
||||
snapshot.setRyzt(ryzt);
|
||||
return snapshot;
|
||||
}
|
||||
}
|
||||
+3
@@ -124,6 +124,9 @@ public class SecurityConfig
|
||||
// 值班员 WebSocket 握手:浏览器无法设置 Authorization 头,令牌走 query,
|
||||
// 由 AptRealtimeHandshakeInterceptor 完成 JWT + 权限点校验(IT-6)
|
||||
.requestMatchers("/apt/ws", "/apt/ws/**").permitAll()
|
||||
// 应用端值班台推送:同样走 query 令牌,鉴权与 gyid 锁定由
|
||||
// DutyHandshakeInterceptor 完成(应用端/04 表 4-12 第 29 行,R3)
|
||||
.requestMatchers("/ws/duty", "/ws/duty/**").permitAll()
|
||||
// 静态资源,可匿名访问
|
||||
.requestMatchers(HttpMethod.GET, "/", "/*.html", "/**.html", "/**.css", "/**.js", "/profile/**").permitAll()
|
||||
.requestMatchers("/swagger-ui.html", "/v3/api-docs/**", "/swagger-ui/**", "/druid/**").permitAll()
|
||||
|
||||
@@ -11,12 +11,28 @@ NProgress.configure({ showSpinner: false })
|
||||
|
||||
const whiteList = ['/login', '/register']
|
||||
|
||||
/* 值班台(应用端/12):登录页独立,盯控台需 duty:jcxx:list */
|
||||
const DUTY_LOGIN = '/duty/login'
|
||||
const DUTY_HOME = '/duty/jcxx'
|
||||
const DUTY_PERMISSION = 'duty:jcxx:list'
|
||||
|
||||
const isWhiteList = (path) => {
|
||||
return whiteList.some(pattern => isPathMatch(pattern, path))
|
||||
}
|
||||
|
||||
/** /duty、/duty/xxx 都算值班台路径;/duty/login 由 dutyLogin 单独判断 */
|
||||
const isDutyPath = (path) => {
|
||||
return path === '/duty' || path.indexOf('/duty/') === 0
|
||||
}
|
||||
|
||||
const hasDutyPermission = () => {
|
||||
const permissions = store.getters.permissions || []
|
||||
return permissions.indexOf('*:*:*') >= 0 || permissions.indexOf(DUTY_PERMISSION) >= 0
|
||||
}
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
NProgress.start()
|
||||
const dutyLogin = to.path === DUTY_LOGIN
|
||||
if (getToken()) {
|
||||
to.meta.title && store.dispatch('settings/setTitle', to.meta.title)
|
||||
const isLock = store.getters.isLock
|
||||
@@ -24,6 +40,10 @@ router.beforeEach((to, from, next) => {
|
||||
if (to.path === '/login') {
|
||||
next({ path: '/' })
|
||||
NProgress.done()
|
||||
} else if (dutyLogin) {
|
||||
// 已登录再访问登录页:直接进盯控台,避免"登录后按后退又看到登录框"
|
||||
next({ path: DUTY_HOME })
|
||||
NProgress.done()
|
||||
} else if (isWhiteList(to.path)) {
|
||||
next()
|
||||
} else if (isLock && to.path !== '/lock') {
|
||||
@@ -38,6 +58,14 @@ router.beforeEach((to, from, next) => {
|
||||
// 判断当前用户是否已拉取完user_info信息
|
||||
store.dispatch('GetInfo').then(() => {
|
||||
isRelogin.show = false
|
||||
// 闸门 2:值班台路径必须有 duty:jcxx:list
|
||||
if (isDutyPath(to.path) && !hasDutyPermission()) {
|
||||
store.dispatch('LogOut').then(() => {
|
||||
next(DUTY_LOGIN + '?reason=noPermission')
|
||||
NProgress.done()
|
||||
})
|
||||
return
|
||||
}
|
||||
store.dispatch('GenerateRoutes').then(accessRoutes => {
|
||||
// 根据roles权限生成可访问的路由表
|
||||
router.addRoutes(accessRoutes) // 动态添加可访问路由表
|
||||
@@ -45,19 +73,33 @@ router.beforeEach((to, from, next) => {
|
||||
})
|
||||
}).catch(err => {
|
||||
store.dispatch('LogOut').then(() => {
|
||||
Message.error(err)
|
||||
next({ path: '/' })
|
||||
if (isDutyPath(to.path)) {
|
||||
next(DUTY_LOGIN + '?reason=expired&redirect=' + encodeURIComponent(to.fullPath))
|
||||
} else {
|
||||
Message.error(err)
|
||||
next({ path: '/' })
|
||||
}
|
||||
NProgress.done()
|
||||
})
|
||||
})
|
||||
} else {
|
||||
if (isDutyPath(to.path) && !hasDutyPermission()) {
|
||||
next(DUTY_LOGIN + '?reason=noPermission')
|
||||
NProgress.done()
|
||||
return
|
||||
}
|
||||
next()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 没有token
|
||||
if (isWhiteList(to.path)) {
|
||||
if (isWhiteList(to.path) || dutyLogin) {
|
||||
// 在免登录白名单,直接进入
|
||||
next()
|
||||
} else if (isDutyPath(to.path)) {
|
||||
// 值班台:回独立登录页,并记住原目标(深链)
|
||||
next(DUTY_LOGIN + '?redirect=' + encodeURIComponent(to.fullPath))
|
||||
NProgress.done()
|
||||
} else {
|
||||
next(`/login?redirect=${encodeURIComponent(to.fullPath)}`) // 否则全部重定向到登录页
|
||||
NProgress.done()
|
||||
|
||||
@@ -51,6 +51,31 @@ export const constantRoutes = [
|
||||
component: () => import('@/views/register'),
|
||||
hidden: true
|
||||
},
|
||||
{
|
||||
// 值班员登录页(应用端/12):独立整屏,不复用管理端 /login
|
||||
path: '/duty/login',
|
||||
component: () => import('@/views/duty/login'),
|
||||
hidden: true
|
||||
},
|
||||
{
|
||||
// 值班台盯控页(R1 静态路由;R2 改由 sys_menu 动态生成,见应用端/11 表 11-6 G-DOC-01)
|
||||
path: '/duty',
|
||||
redirect: '/duty/jcxx',
|
||||
hidden: true
|
||||
},
|
||||
{
|
||||
path: '/duty/jcxx',
|
||||
component: () => import('@/views/duty/console'),
|
||||
hidden: true,
|
||||
meta: { title: '叫乘值班台' }
|
||||
},
|
||||
{
|
||||
// 值班台测试配置(R5 / 应用端/17):房间信息 / 房间与分机绑定 / SIP 服务器
|
||||
path: '/duty/config',
|
||||
component: () => import('@/views/duty/config'),
|
||||
hidden: true,
|
||||
meta: { title: '值班台测试配置' }
|
||||
},
|
||||
{
|
||||
path: '/404',
|
||||
component: () => import('@/views/error/404'),
|
||||
|
||||
@@ -83,6 +83,17 @@ service.interceptors.response.use(res => {
|
||||
return res.data
|
||||
}
|
||||
if (code === 401) {
|
||||
// 值班台:会话失效必须直接回登录页,不提供"继续留在该页面"(应用端/12 表 12-8)
|
||||
if (window.location.pathname.indexOf('/duty') === 0) {
|
||||
if (!isRelogin.show) {
|
||||
isRelogin.show = true
|
||||
store.dispatch('FedLogOut').then(() => {
|
||||
const redirect = encodeURIComponent(window.location.pathname + window.location.search)
|
||||
window.location.href = '/duty/login?redirect=' + redirect + '&reason=expired'
|
||||
})
|
||||
}
|
||||
return Promise.reject('登录状态已过期,请重新登录。')
|
||||
}
|
||||
if (!isRelogin.show) {
|
||||
isRelogin.show = true
|
||||
MessageBox.confirm('登录状态已过期,您可以继续留在该页面,或者重新登录', '系统提示', { confirmButtonText: '重新登录', cancelButtonText: '取消', type: 'warning' }).then(() => {
|
||||
|
||||
@@ -46,6 +46,13 @@ module.exports = {
|
||||
'^/v3/api-docs/(.*)': {
|
||||
target: baseUrl,
|
||||
changeOrigin: true
|
||||
},
|
||||
// 值班台实时推送(应用端/04 表 4-12):浏览器直连 /ws/duty,dev 下必须开 ws 代理,
|
||||
// 否则握手会被 dev-server 当成普通 HTTP 请求。生产由 Nginx 直接 upgrade。
|
||||
'^/ws/duty': {
|
||||
target: baseUrl,
|
||||
changeOrigin: true,
|
||||
ws: true
|
||||
}
|
||||
},
|
||||
disableHostCheck: true
|
||||
|
||||
@@ -12,10 +12,10 @@
|
||||
|
||||
| 项 | 版本 / 选型 | 说明 | 依据 |
|
||||
| --- | --- | --- | --- |
|
||||
| JDK | 1.8(`maven.compiler.source/target=1.8`) | 与公寓端同构建产物,不允许本端用 11+ 语法 | `docs/公寓端/02` 表 2-2 |
|
||||
| Spring Boot | 2.5.15 | 同父 POM | 同上 |
|
||||
| 框架 | RuoYi-Vue 3.8.9(`apt-framework`/`apt-system`) | **不魔改框架、不引入代码生成器**(差-3) | `docs/公寓端/08` 差-3 |
|
||||
| ORM | MyBatis + XML(不用 MyBatis-Plus 动态表名) | 状态字段的条件更新必须是显式 SQL | `docs/10` §5.3 第 6 条 |
|
||||
| JDK | **17**(父 POM `java.version=17`) | 实际基线;用 1.8 编译会报"无效的目标发行版: 17"。构建用 JDK 17 或 21,不引入 21+ 专有语法 | 父 `pom.xml`、`13` L-5 |
|
||||
| Spring Boot | **4.1.0** | 同父 POM(`spring-boot.version`) | 父 `pom.xml`、`13` L-5 |
|
||||
| 框架 | RuoYi-Vue **3.9.2**(`ruoyi-framework`/`ruoyi-system`) | **不魔改框架、不引入代码生成器**(差-3) | 父 `pom.xml`、`docs/公寓端/08` 差-3 |
|
||||
| ORM | MyBatis + XML(`mybatis-spring-boot-starter` 4.1.0,不用 MyBatis-Plus 动态表名) | 状态字段的条件更新必须是显式 SQL | `docs/10` §5.3 第 6 条、父 `pom.xml` |
|
||||
| 数据库 | MySQL 8.0,`utf8mb4_general_ci`,`datetime(3)` | 与公寓本地库一致:v1.2 已建 44 张 + CR-01 1 张 + CR-02 新增 13 张 + §10 补遗 1 张 `apt_sip_reg_status`(设计定义,DDL 待阶段二评审),统一 59 张口径;不新建库、不新建 schema | `docs/公寓端/03` 表 3-8、CR-02 §6 |
|
||||
| 调度 | Quartz **单实例** | 叫乘触发在 `apt-dispatch`;本端**不注册任何 Job** | `docs/公寓端/02` 表 2-2 |
|
||||
| 缓存 / 会话 | Redis(共享公寓端实例) | 本端不新建 Redis 库;会话键前缀 `duty:` | `docs/10` §2.5 表 2-3 |
|
||||
@@ -24,6 +24,8 @@
|
||||
| 实时 | Spring `WebSocketHandler`(非 STOMP) | 收敛为 `/ws/duty` 一个端点 | `docs/10` §2.5 |
|
||||
| 构建 | Maven 多模块,产物 `apt-admin.jar` | 本端**不产出独立可执行 jar** | `docs/公寓端/02` 表 2-2 |
|
||||
|
||||
> **R2 修订(对应 `13` L-5)**:本表 JDK / Spring Boot / 框架 / ORM 四行原为早期规划值(1.8 / 2.5.15 / 3.8.9),2026-11-12 按 `dev/RuoYi-Vue-master/pom.xml` 的实际值修订为 **17 / 4.1.0 / 3.9.2 / MyBatis-SB 4.1.0**;前端 Vue 2.6.12 + element-ui 2.15.14 与 `package.json` 一致,无需改动。构建须用 JDK 17 或 21,`docs/公寓端/02` 表 2-2 的同名行如仍写 1.8,以本表为准。
|
||||
|
||||
三条"不引入":**不引入新的 MQ**(断网靠 outbox)、**不引入新的鉴权框架**(复用 RuoYi 会话 + 权限点)、**不引入前端状态持久化库**(本端状态都是服务端真源)。
|
||||
|
||||
## 2. 后端模块结构
|
||||
|
||||
@@ -33,6 +33,13 @@
|
||||
| `08-待决策与风险.md` | 决策点、新增 `Q-16`/`Q-17`、风险清单、与 `docs/10` 的差异、缺口登记、决策×批次矩阵 | 对照 `docs/公寓端/08` 表 8-1/表 8-10 |
|
||||
| `09-公寓端内部契约.md` | 进程内服务契约(**不是**跨端 20 接口)、方法签名、事件订阅、冻结清单 `F-D01~F-D10` | 区别于 `docs/公寓端/09-平台接口约定.md` |
|
||||
| `10-决策单-Q15至Q19.md` | 过会用决策单:`Q-15`~`Q-19` 五项的背景、可选项、各方影响、默认建议、拍板人、阻塞批次、决议记录与回填清单 | 是 `08` 表 8-1/表 8-6/表 8-7 的**执行版**;结论回填 `08`,不改其对端口径 |
|
||||
| `11-应用端UI开发计划.md` | 以 `docs/公寓端/ui/duty-console-v2.html` 为唯一视觉基线:区域盘点、UI 状态↔`ryzt` 映射、UI↔接口落点、与 `07` D-2 的范围差异、7 个契约缺口(`G-UI-01~06`/`G-DOC-01`)、前端/后端任务拆解、五轮排期、16 条验收 | 把 `07` 表 7-2 的 D-1/D-2 拆到工作项;不改批次编号、不新增阶段 |
|
||||
| `12-值班员登录与准入鉴权.md` | `/duty/login` 输入项、校验规则、11 类失败文案映射、目标页白名单跳转、四类失效处置、路由守卫、退出/切换用户/交接班、9 条安全约束、13 条验收 | 修订 `02` §6「不做独立登录页」为「独立页 + 复用 `/login` 认证」;登记 `差-D5`;认证接口零新增 |
|
||||
| `13-第一轮开发(R1)交付说明.md` | R1 阶段交付:目标、范围与优先级、角色与三个场景、7 个接口与 6 条约束、三级交付标准(可测试/可构建/可运行)、任务状态、验证记录、6 个问题与 2 处偏差、9 项遗留、R2 计划、追溯索引 | 实现 `11` 表 11-7/11-8 的 R1 部分;记录"未取得运行证据"与 `02` 技术基线不一致两项风险 |
|
||||
| `14-第二轮开发(R2)交付说明.md` | R2 阶段交付:命令通道(5 命令,双层权限 + `commandId` 幂等 + `expectedState`)、原因弹窗、动作矩阵、详情抽屉与时间线、技术基线修订、4 处偏差(`差-D6~D9`)、验证记录(280 用例)、遗留状态、R3 计划 | 关闭 `13` 的 L-2/L-5 与 L-8 一部分;动态菜单改口径(`差-D6`);运行证据 L-1/L-10 仍缺 |
|
||||
| `15-第三轮开发(R3)交付说明.md` | R3 阶段交付:`/ws/duty` 端点与握手鉴权、4 类业务消息与 `seq` 水位、前端 WS 客户端(心跳 30 s / 退避 1→30 s / 丢重)、报警音与 TTS、轮询收敛与 90 s 滞后提示、5 个设计决策、验证记录(297 用例) | 实现 `11` 表 11-9 的 R3 部分;登记 `L-11`(Quartz 自动报警不推送);运行证据 L-1 仍缺 |
|
||||
| `16-第四轮开发(R4)交付说明.md` | R4 阶段交付(第 1 项):把 `state.changed`/`alarm.*` 收敛到状态机唯一写入口(`DutyTransitionPush`),覆盖 Quartz/AMI/平板/命令四条触发源,删除命令层重复推送并保留 `pushSeq` 口径;修掉"前后态相同误发解除"缺陷;登记 `L-12`(`ryzt=4` 无写入口)、`L-13`(`session.changed` 无生产点) | 关闭 `15` 的 `L-11`;退出条件"停轮询后报警 2 s 内到达"因环境缺失未验证,`L-1` 仍未闭环 |
|
||||
| `17-测试配置与测试入住(R5).md` | R5 联调支撑:值班台「办理入住」与「测试配置」两处入口;房间信息、房间↔SIP 分机绑定、SIP 服务器(AMI)三块配置面;入住复用 `apply + review` 正式链路不绕校验;AMI 口令只存环境变量引用 | 解决"没有平板/平台推数据就无法验证叫乘链路";补 `33/34/35` 三支脚本;登记解绑号码不回收、探测只到 TCP 等边界 |
|
||||
|
||||
## 1. 编号与引用约定
|
||||
|
||||
|
||||
Reference in new issue
Block a user