修改通过SIP呼叫房间的相关逻辑,目前定时呼叫和临时呼叫都可以呼通。

This commit is contained in:
zfjia committed 2026-11-13 11:18:42 +08:00
1 parent 83ece20a00
commit d2df03f023
23 files changed
+702 -52

No files matched your search

@@ -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)
{
@@ -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)
@@ -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;
@@ -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);
@@ -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,并通知连接管理器立刻重连 */
@@ -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,
@@ -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;
@@ -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>
@@ -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
@@ -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");
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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()
+45 -3
View File
@@ -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()
+25
View File
@@ -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(() => {
+7
View File
@@ -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