更新叫乘过程中状态。
This commit is contained in:
1 parent
ba77386575
commit
f583bb5932
79 files changed
+10521
-41
No files matched your search
@@ -6,7 +6,7 @@ import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration;
|
||||
|
||||
/**
|
||||
* 启动程序
|
||||
*
|
||||
*
|
||||
* @author ruoyi
|
||||
*/
|
||||
@SpringBootApplication(exclude = { DataSourceAutoConfiguration.class })
|
||||
@@ -14,17 +14,6 @@ public class RuoYiApplication
|
||||
{
|
||||
public static void main(String[] args)
|
||||
{
|
||||
// System.setProperty("spring.devtools.restart.enabled", "false");
|
||||
SpringApplication.run(RuoYiApplication.class, args);
|
||||
System.out.println("(♥◠‿◠)ノ゙ 若依启动成功 ლ(´ڡ`ლ)゙ \n" +
|
||||
" .-------. ____ __ \n" +
|
||||
" | _ _ \\ \\ \\ / / \n" +
|
||||
" | ( ' ) | \\ _. / ' \n" +
|
||||
" |(_ o _) / _( )_ .' \n" +
|
||||
" | (_,_).' __ ___(_ o _)' \n" +
|
||||
" | |\\ \\ | || |(_,_)' \n" +
|
||||
" | | \\ `' /| `-' / \n" +
|
||||
" | | \\ / \\ / \n" +
|
||||
" ''-' `'-' `-..-' ");
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
-1
@@ -30,18 +30,24 @@ public interface AptDutyStateMapper
|
||||
int updateAnswered(@Param("gyid") String gyid, @Param("jcid") String jcid,
|
||||
@Param("fromStates") List<String> fromStates);
|
||||
|
||||
/** 房间入会:仅补记 hjxysj,ryzt 保持 1 叫乘中(待离寓由挂断触发) */
|
||||
/** 房间入会:ryzt -> 5 通话中 + 补记 hjxysj(待离寓由挂断触发) */
|
||||
int updateJoinConf(@Param("gyid") String gyid, @Param("jcid") String jcid);
|
||||
|
||||
/** 1 叫乘中 -> 2 叫乘报警(30s 无入会 / 平板 NO_ANSWER / FAILED) */
|
||||
int updateCallFailed(@Param("gyid") String gyid, @Param("jcid") String jcid);
|
||||
|
||||
/** -> 4 离寓报警:超过应离寓时间未离寓(is_lybj=1 + lybjsj,只报一次) */
|
||||
int updateLeaveAlarm(@Param("gyid") String gyid, @Param("jcid") String jcid);
|
||||
|
||||
/** 人工「停止叫乘」:ryzt 保持,stoped=1 */
|
||||
int updateStop(@Param("gyid") String gyid, @Param("jcid") String jcid);
|
||||
|
||||
/** 人工「复叫」:清 stoped、ryzt->1、记 fjsj_s */
|
||||
int updateRecall(@Param("gyid") String gyid, @Param("jcid") String jcid);
|
||||
|
||||
/** 复叫时间到点自动复叫:不校验来源 ryzt,ryzt->1、重置 jcsj_s、记 fjsj_s */
|
||||
int updateAutoRecall(@Param("gyid") String gyid, @Param("jcid") String jcid);
|
||||
|
||||
/** 人工「强制结束」:finish_jc=1,不动 finish_ly(不置离寓) */
|
||||
int updateForceFinish(@Param("gyid") String gyid, @Param("jcid") String jcid);
|
||||
|
||||
|
||||
+9
@@ -53,6 +53,15 @@ public interface AptJcxxMapper
|
||||
@Param("deadline") Date deadline,
|
||||
@Param("limit") int limit);
|
||||
|
||||
/**
|
||||
* 应离寓时间已到但未办理离寓(finish_ly=0 且未报过离寓报警)。
|
||||
*
|
||||
* <p>与叫乘链路解耦:即使叫乘已完成/已报警,只要没离寓就参与判定。</p>
|
||||
*/
|
||||
List<Map<String, Object>> selectLeaveOverdue(@Param("gyid") String gyid,
|
||||
@Param("now") Date now,
|
||||
@Param("limit") int limit);
|
||||
|
||||
/** 命令幂等键查询(duty_manual_action) */
|
||||
Map<String, Object> selectManualAction(@Param("gyid") String gyid, @Param("actionId") String actionId);
|
||||
|
||||
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
package com.ruoyi.apt.dispatch.service;
|
||||
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import com.ruoyi.apt.framework.config.AptProperties;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.ApplicationListener;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 叫乘扫描自带调度器(与 AMI 心跳、会议自动挂机同一理由自建)。
|
||||
*
|
||||
* <p><b>为什么不用 Quartz</b>:本项目 {@code ScheduleConfig} 处于注释状态、
|
||||
* {@code sys_job} 中 {@code aptDispatchTask.scan()} 默认停用,实测 qrtz 无任何触发器;
|
||||
* 而「叫乘时间/复叫时间/应离寓时间到点」是业务主链路的硬前提,不能依赖可选组件。</p>
|
||||
*
|
||||
* <p>约束:</p>
|
||||
* <ul>
|
||||
* <li>单线程守护 + 固定延迟,单轮未结束不会并发叠加({@link AtomicBoolean} 互斥);</li>
|
||||
* <li>扫描内部已按「提交状态机命令」实现,重复执行幂等,异常只告警不中断后续轮次;</li>
|
||||
* <li>{@code apt.dispatch.scan-interval-seconds} ≤ 0 时关闭,改由 sys_job 驱动(避免双跑)。</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
@Component
|
||||
public class AptDispatchScanScheduler implements ApplicationListener<ApplicationReadyEvent>
|
||||
{
|
||||
private static final Logger log = LoggerFactory.getLogger(AptDispatchScanScheduler.class);
|
||||
|
||||
private final IAptDispatchScanService scanService;
|
||||
private final AptProperties properties;
|
||||
|
||||
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(runnable -> {
|
||||
Thread thread = new Thread(runnable, "apt-dispatch-scan");
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
});
|
||||
private final AtomicBoolean running = new AtomicBoolean(false);
|
||||
|
||||
public AptDispatchScanScheduler(IAptDispatchScanService scanService, AptProperties properties)
|
||||
{
|
||||
this.scanService = scanService;
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onApplicationEvent(ApplicationReadyEvent event)
|
||||
{
|
||||
start();
|
||||
}
|
||||
|
||||
public void start()
|
||||
{
|
||||
int interval = properties.getDispatch().getScanIntervalSeconds();
|
||||
if (interval <= 0)
|
||||
{
|
||||
log.info("叫乘扫描自带调度已关闭(scan-interval-seconds<=0),改由 sys_job 的 aptDispatchTask.scan() 驱动");
|
||||
return;
|
||||
}
|
||||
scheduler.scheduleWithFixedDelay(this::tick, interval, interval, TimeUnit.SECONDS);
|
||||
log.info("叫乘扫描调度已启动:每 {}s 一轮(叫乘/复叫到点发起、30s 无应答报警、应离寓超时报警)", interval);
|
||||
}
|
||||
|
||||
private void tick()
|
||||
{
|
||||
if (!running.compareAndSet(false, true))
|
||||
{
|
||||
log.debug("上一轮叫乘扫描尚未结束,跳过本轮");
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
scanService.scan();
|
||||
}
|
||||
catch (RuntimeException e)
|
||||
{
|
||||
// 单轮失败不得杀死调度线程:下一轮继续(时间触发靠幂等条件更新自愈)
|
||||
log.warn("叫乘扫描执行失败,等待下一轮", e);
|
||||
}
|
||||
finally
|
||||
{
|
||||
running.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
void shutdown()
|
||||
{
|
||||
scheduler.shutdownNow();
|
||||
}
|
||||
}
|
||||
+29
-3
@@ -84,21 +84,47 @@ public class AptDispatchScanServiceImpl implements IAptDispatchScanService
|
||||
}
|
||||
}
|
||||
|
||||
// SIP 到点发起(IT-7):房间腿 Originate + 值班台腿;AMI 未就绪时自动跳过
|
||||
// SIP 到点发起:复叫时间到点(不校验 ryzt,仅排除通话中,自动复叫 -> 重呼)优先于首轮
|
||||
int recalled = 0;
|
||||
int dialed = 0;
|
||||
if (properties.getVoice().isEnabled() && properties.getVoice().isSipCallEnabled())
|
||||
{
|
||||
recalled = callFlowService.redialDue(properties.getVoice().getMaxCallsPerScan());
|
||||
dialed = callFlowService.dialDue(properties.getVoice().getMaxCallsPerScan());
|
||||
}
|
||||
|
||||
// 应离寓时间到点且未办理离寓 -> 离寓报警(与叫乘链路解耦,AMI 未就绪也照常判定)
|
||||
int leaveAlarms = 0;
|
||||
List<Map<String, Object>> overdue = jcxxMapper.selectLeaveOverdue(
|
||||
AptContext.getGyId(), new Date(), properties.getDispatch().getScanBatchSize());
|
||||
for (Map<String, Object> row : overdue == null ? List.<Map<String, Object>>of() : overdue)
|
||||
{
|
||||
String jcid = String.valueOf(row.get("jcid"));
|
||||
try
|
||||
{
|
||||
stateService.leaveAlarm(jcid);
|
||||
leaveAlarms++;
|
||||
}
|
||||
catch (AptException e)
|
||||
{
|
||||
if (e.getErrorCode() != AptErrorCode.STATE_ILLEGAL)
|
||||
{
|
||||
throw e;
|
||||
}
|
||||
log.debug("叫乘 {} 已离寓或已报过离寓报警,跳过离寓超时判定", jcid);
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("expiredTasks", expired);
|
||||
result.put("generatedTasks", generated);
|
||||
result.put("sipTimeouts", timedOut);
|
||||
result.put("recalledCalls", recalled);
|
||||
result.put("dialedCalls", dialed);
|
||||
result.put("leaveAlarms", leaveAlarms);
|
||||
result.put("scannedAt", new Date());
|
||||
log.info("叫乘扫描完成:过期任务 {},新生成 {},SIP 超时 {},新发起呼叫 {}",
|
||||
expired, generated, timedOut, dialed);
|
||||
log.info("叫乘扫描完成:过期任务 {},新生成 {},SIP 超时 {},复叫发起 {},首呼发起 {},离寓报警 {}",
|
||||
expired, generated, timedOut, recalled, dialed, leaveAlarms);
|
||||
return result;
|
||||
}
|
||||
finally
|
||||
|
||||
+14
-3
@@ -31,7 +31,8 @@ import org.springframework.transaction.support.TransactionSynchronizationManager
|
||||
* <p>命令矩阵(与表 4-9 逐条对齐):</p>
|
||||
* <pre>
|
||||
* STOP ryzt in (1,2) 且 finish_ly=0 -> stoped=1
|
||||
* RE_CALL stoped=1 或 ryzt=2 -> 清 stoped、ryzt=1、新增会话
|
||||
* RE_CALL stoped=1 / ryzt=2 / ryzt=3 -> 清 stoped、ryzt=1、finish_jc=0、新增会话
|
||||
* (ryzt=3 已应答待离寓也允许复叫,重新开启一轮叫乘)
|
||||
* FORCE_FINISH finish_ly=0 -> finish_jc=1(不置离寓)
|
||||
* MARK_LEAVE finish_ly=0 -> finish_ly=1 + gygl_ly_event
|
||||
* CANCEL ryzt=0 -> stoped=1
|
||||
@@ -241,9 +242,19 @@ public class AptDutyCommandServiceImpl implements IAptDutyCommandService
|
||||
}
|
||||
break;
|
||||
case RE_CALL:
|
||||
if (!("1".equals(stoped) || "2".equals(ryzt)) || !inHouse)
|
||||
if (!inHouse)
|
||||
{
|
||||
throw stateIllegal("RE_CALL 仅允许已停止或叫乘报警的记录");
|
||||
throw stateIllegal("该人员已离寓,不可复叫");
|
||||
}
|
||||
// 已应答待离寓(ryzt=3,finish_jc 必然为 1):允许再叫一轮,
|
||||
// 复叫会把 finish_jc 归 0 重新开启叫乘,因此不走下面的「已完成」拦截
|
||||
if ("3".equals(ryzt) && "1".equals(finishJc))
|
||||
{
|
||||
break;
|
||||
}
|
||||
if (!("1".equals(stoped) || "2".equals(ryzt)))
|
||||
{
|
||||
throw stateIllegal("RE_CALL 仅允许已停止、叫乘报警或已应答待离寓的记录");
|
||||
}
|
||||
if ("1".equals(finishJc))
|
||||
{
|
||||
|
||||
+42
-4
@@ -39,8 +39,8 @@ public class AptDutyStateServiceImpl implements IAptDutyStateService
|
||||
{
|
||||
private static final Logger log = LoggerFactory.getLogger(AptDutyStateServiceImpl.class);
|
||||
|
||||
/** 应答允许的来源态:0 待叫乘 / 1 叫乘中 / 2 叫乘报警(含复叫入会) */
|
||||
private static final List<String> ANSWERABLE = List.of("0", "1", "2");
|
||||
/** 应答(房间挂断)允许的来源态:0 待叫乘 / 1 叫乘中 / 2 叫乘报警(含复叫入会) / 5 通话中 */
|
||||
private static final List<String> ANSWERABLE = List.of("0", "1", "2", "5");
|
||||
|
||||
private final AptDutyStateMapper stateMapper;
|
||||
private final IAptJobService jobService;
|
||||
@@ -76,9 +76,13 @@ public class AptDutyStateServiceImpl implements IAptDutyStateService
|
||||
@Transactional
|
||||
public int joinConf(String jcid)
|
||||
{
|
||||
// 房间入会只补记呼叫响应时间:ryzt 仍是 1 叫乘中,值班台无需推送(状态未变);
|
||||
// 房间入会 = 会议桥建立:ryzt 1 -> 5(通话中/表 11-2 TALKING),补记 hjxysj;
|
||||
// 再未接通前的 30s 超时判定只针对 ryzt=1,因此入会后不会再被误判叫乘报警。
|
||||
// 待离寓迁移由房间挂断(HANGUP)触发,见 answered()
|
||||
return require(stateMapper.updateJoinConf(gyid(), jcid), jcid, "JOIN_CONF");
|
||||
AptStateSnapshot before = snapshot(jcid);
|
||||
int rows = require(stateMapper.updateJoinConf(gyid(), jcid), jcid, "JOIN_CONF");
|
||||
pushDuty(jcid, before);
|
||||
return rows;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -104,6 +108,19 @@ public class AptDutyStateServiceImpl implements IAptDutyStateService
|
||||
return rows;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public int leaveAlarm(String jcid)
|
||||
{
|
||||
AptStateSnapshot before = snapshot(jcid);
|
||||
int rows = require(stateMapper.updateLeaveAlarm(gyid(), jcid), jcid, "LEAVE_ALARM");
|
||||
// 公寓端通道:管理台/平板立即看到离寓报警
|
||||
realtimePublisher.alarm(jcid, "LEAVE_ALARM", "超过应离寓时间未办理离寓");
|
||||
// 值班台通道:本方法只由扫描任务调用,调度线程没有 HTTP 上下文(L-11)
|
||||
pushDuty(jcid, before);
|
||||
return rows;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public int stop(String jcid)
|
||||
@@ -128,6 +145,27 @@ public class AptDutyStateServiceImpl implements IAptDutyStateService
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 复叫时间到点自动复叫。
|
||||
*
|
||||
* <p>与人工 {@link #recall(String)} 的唯一区别:<b>不校验来源 ryzt</b>
|
||||
* (待叫乘/叫乘中/叫乘报警都可能到点),只要未停止、未完成叫乘、未离寓即进入复叫;
|
||||
* 30s 无应答基准一并重置,复叫后仍未接通由房间腿失败/超时回到叫乘报警。</p>
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public int autoRecall(String jcid)
|
||||
{
|
||||
AptStateSnapshot before = snapshot(jcid);
|
||||
int rows = require(stateMapper.updateAutoRecall(gyid(), jcid), jcid, "AUTO_RE_CALL");
|
||||
String callMode = before == null || before.getCallMode() == null ? "SIP" : before.getCallMode();
|
||||
// 表 3-10:复叫生成新会话,保证时间线与录音索引有独立 session_id
|
||||
stateMapper.insertCallSession(gyid(), "CS-" + UUID.randomUUID().toString().replace("-", ""),
|
||||
jcid, callMode);
|
||||
pushDuty(jcid, before);
|
||||
return rows;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public int forceFinish(String jcid)
|
||||
|
||||
+10
-1
@@ -3,7 +3,16 @@ package com.ruoyi.apt.dispatch.service;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 叫乘扫描任务(IT-5 / M3):平板任务过期回收、任务生成、SIP 30s 无入会转报警。
|
||||
* 叫乘扫描任务(IT-5 / M3):平板任务过期回收、任务生成、SIP 30s 无入会转报警、
|
||||
* 叫乘/复叫时间到点发起呼叫、应离寓时间到点转离寓报警。
|
||||
*
|
||||
* <p>时间触发口径(三字段):</p>
|
||||
* <ul>
|
||||
* <li>{@code jcsj} 叫乘时间到点(ryzt=0)→ 先值班台、后房间发起 SIP 呼叫;</li>
|
||||
* <li>{@code fjsj} 复叫时间到点(不校验来源 ryzt,仅排除 5 通话中不打断会议)
|
||||
* → 自动复叫后重呼,同一计划只执行一轮;</li>
|
||||
* <li>{@code ylysj} 应离寓时间到点且 {@code finish_ly=0} → 离寓报警(ryzt=4)。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>注意:扫描只「提交状态机命令」,不做任何直写;
|
||||
* 平板签到窗口超时<b>不</b>由本任务推定(P2-2 裁决:由平板上报 NO_ANSWER)。</p>
|
||||
|
||||
+8
-2
@@ -17,7 +17,7 @@ public interface IAptDutyStateService
|
||||
/** 0 待叫乘 -> 1 叫乘中 */
|
||||
int callStart(String jcid);
|
||||
|
||||
/** 房间入会:补记 hjxysj,ryzt 保持 1 叫乘中(不变待离寓) */
|
||||
/** 房间入会(会议桥建立):ryzt -> 5 通话中 + 补记 hjxysj(不变待离寓) */
|
||||
int joinConf(String jcid);
|
||||
|
||||
/** -> 3 已应答待离寓 + finish_jc=1(房间挂断 / 平板 VERIFIED) */
|
||||
@@ -26,12 +26,18 @@ public interface IAptDutyStateService
|
||||
/** 1 叫乘中 -> 2 叫乘报警 */
|
||||
int callFailed(String jcid);
|
||||
|
||||
/** 超过应离寓时间未离寓 -> 4 离寓报警 + is_lybj=1 + lybjsj(只报一次) */
|
||||
int leaveAlarm(String jcid);
|
||||
|
||||
/** 人工停止叫乘:stoped=1 */
|
||||
int stop(String jcid);
|
||||
|
||||
/** 人工复叫:清 stoped、ryzt->1、新增会话 */
|
||||
/** 人工复叫:清 stoped、ryzt->1、finish_jc->0、新增会话(来源:已停止 / 叫乘报警 / 已应答待离寓) */
|
||||
int recall(String jcid);
|
||||
|
||||
/** 复叫时间到点自动复叫:不校验来源 ryzt(排除 5 通话中),ryzt->1、重置 jcsj_s、记 fjsj_s、新增会话 */
|
||||
int autoRecall(String jcid);
|
||||
|
||||
/** 人工强制结束:finish_jc=1,不置离寓 */
|
||||
int forceFinish(String jcid);
|
||||
|
||||
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
package com.ruoyi.apt.duty.controller;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.ruoyi.apt.common.page.AptTableDataInfo;
|
||||
import com.ruoyi.apt.duty.domain.DutyCommandRequest;
|
||||
import com.ruoyi.apt.duty.domain.DutyQuery;
|
||||
import com.ruoyi.apt.duty.service.IDutyCommandService;
|
||||
import com.ruoyi.apt.duty.service.IDutyJcxxQueryService;
|
||||
import com.ruoyi.apt.framework.web.AptBaseController;
|
||||
import com.ruoyi.common.core.domain.AjaxResult;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 值班台叫乘盯控(应用端/04 接口 1、`11` 表 11-7 `T-BE-02`/`T-BE-04`/`T-BE-05`)。
|
||||
*
|
||||
* <p>R1 只开两个只读接口;R2 增加命令通道、详情与时间线。命令本身不在这里实现,
|
||||
* 一律委托 {@code apt.duty.state}(幂等 + 乐观锁 + 审计都在那一层),值班台只做
|
||||
* "权限粒度更细 + 入参更窄"的外壳。</p>
|
||||
*
|
||||
* <p>列表不写审计:值班台 60 s 兜底轮询会持续刷新列表,若每次列表都落
|
||||
* {@code duty_operation_log},审计表会被展示流量淹没。审计只写命令(由
|
||||
* {@code apt.duty.state} 落 {@code duty_manual_action} + {@code duty_operation_log})。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping(value = "/duty/jcxx", produces = "application/json;charset=UTF-8")
|
||||
public class DutyJcxxController extends AptBaseController
|
||||
{
|
||||
private final IDutyJcxxQueryService queryService;
|
||||
|
||||
private final IDutyCommandService commandService;
|
||||
|
||||
public DutyJcxxController(IDutyJcxxQueryService queryService,
|
||||
IDutyCommandService commandService)
|
||||
{
|
||||
this.queryService = queryService;
|
||||
this.commandService = commandService;
|
||||
}
|
||||
|
||||
/** 值班台列表:默认"当日 + 需处理",上限 100 条(`AptPageSupport` 硬上限) */
|
||||
@PreAuthorize("@ss.hasPermi('duty:jcxx:list')")
|
||||
@GetMapping
|
||||
public AptTableDataInfo list(@RequestParam(required = false) String scope,
|
||||
@RequestParam(required = false) String keyword)
|
||||
{
|
||||
DutyQuery query = new DutyQuery();
|
||||
query.setScope(scope);
|
||||
query.setKeyword(keyword);
|
||||
|
||||
startAptPage();
|
||||
return getAptDataTable(queryService.list(query, new Date()));
|
||||
}
|
||||
|
||||
/** 吸底统计 + 紧急条数 + serverTime(同一口径,禁止前端自行 count 当前页) */
|
||||
@PreAuthorize("@ss.hasPermi('duty:jcxx:list')")
|
||||
@GetMapping("/counts")
|
||||
public AjaxResult counts()
|
||||
{
|
||||
return AjaxResult.success(queryService.counts(new Date()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行状态命令(R2)。
|
||||
*
|
||||
* <p>注解只做粗粒度前置(5 个点任一);具体命令该不该给,在
|
||||
* {@code DutyCommandServiceImpl} 里按 {@link com.ruoyi.apt.duty.domain.DutyCommandType}
|
||||
* 精确判一次,未授权返回 40301 而不是 500。</p>
|
||||
*/
|
||||
@PreAuthorize("@ss.hasAnyPermi('duty:call:stop,duty:call:recall,duty:call:cancel,duty:call:forceFinish,duty:call:markLeave')")
|
||||
@PostMapping("/{jcid}/commands")
|
||||
public AjaxResult command(@PathVariable("jcid") String jcid,
|
||||
@RequestBody DutyCommandRequest request)
|
||||
{
|
||||
return AjaxResult.success(commandService.execute(jcid, request));
|
||||
}
|
||||
|
||||
/** 详情抽屉(`T-BE-03`):叫乘行 + 会话 + 最近人工动作,不含录音/照片路径 */
|
||||
@PreAuthorize("@ss.hasPermi('duty:jcxx:query')")
|
||||
@GetMapping("/{jcid}")
|
||||
public AjaxResult detail(@PathVariable("jcid") String jcid)
|
||||
{
|
||||
return AjaxResult.success(commandService.detail(jcid));
|
||||
}
|
||||
|
||||
/** 时间线(`T-BE-03`):按时间倒序的关键节点 */
|
||||
@PreAuthorize("@ss.hasPermi('duty:jcxx:query')")
|
||||
@GetMapping("/{jcid}/timeline")
|
||||
public AjaxResult timeline(@PathVariable("jcid") String jcid)
|
||||
{
|
||||
List<Map<String, Object>> timeline = commandService.timeline(jcid);
|
||||
return AjaxResult.success(timeline);
|
||||
}
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
package com.ruoyi.apt.duty.domain;
|
||||
|
||||
/**
|
||||
* 值班台命令请求体(`POST /duty/jcxx/{jcid}/commands`)。
|
||||
*
|
||||
* <p>{@code commandId} 是 L2 幂等键:前端生成一次、重试必须复用;
|
||||
* {@code expectedState} 用于防"看到的和改的不是同一条"。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
public class DutyCommandRequest
|
||||
{
|
||||
private String commandId;
|
||||
private String commandType;
|
||||
private String reason;
|
||||
private Integer expectedState;
|
||||
/** MARK_LEAVE 专用,缺省 MANUAL */
|
||||
private String leaveType;
|
||||
|
||||
public String getCommandId()
|
||||
{
|
||||
return commandId;
|
||||
}
|
||||
|
||||
public void setCommandId(String commandId)
|
||||
{
|
||||
this.commandId = commandId;
|
||||
}
|
||||
|
||||
public String getCommandType()
|
||||
{
|
||||
return commandType;
|
||||
}
|
||||
|
||||
public void setCommandType(String commandType)
|
||||
{
|
||||
this.commandType = commandType;
|
||||
}
|
||||
|
||||
public String getReason()
|
||||
{
|
||||
return reason;
|
||||
}
|
||||
|
||||
public void setReason(String reason)
|
||||
{
|
||||
this.reason = reason;
|
||||
}
|
||||
|
||||
public Integer getExpectedState()
|
||||
{
|
||||
return expectedState;
|
||||
}
|
||||
|
||||
public void setExpectedState(Integer expectedState)
|
||||
{
|
||||
this.expectedState = expectedState;
|
||||
}
|
||||
|
||||
public String getLeaveType()
|
||||
{
|
||||
return leaveType;
|
||||
}
|
||||
|
||||
public void setLeaveType(String leaveType)
|
||||
{
|
||||
this.leaveType = leaveType;
|
||||
}
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
package com.ruoyi.apt.duty.domain;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* 值班台可发的 5 个状态命令(应用端/02 表 2-9、`04` 表 4-6 第 1~5 行)。
|
||||
*
|
||||
* <p>与 {@code apt:jcxx:changeStatus} 的区别不是能力,而是**权限粒度**:
|
||||
* 管理端一个点管 5 个命令,值班台要能"只给停止、不给强制结束",
|
||||
* 因此一命令一点(`duty:call:*`)。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
public enum DutyCommandType
|
||||
{
|
||||
/** 停止叫乘:ryzt in (1,2) 且 finish_ly=0;必须选原因 */
|
||||
STOP("duty:call:stop", true, "值班台停止叫乘"),
|
||||
/** 复叫:stoped=1 / ryzt=2 / ryzt=3(已应答待离寓,允许再叫一轮) */
|
||||
RE_CALL("duty:call:recall", false, "值班台复叫"),
|
||||
/** 强制结束:finish_ly=0;必须写原因 */
|
||||
FORCE_FINISH("duty:call:forceFinish", true, "值班台强制结束"),
|
||||
/** 登记离寓:finish_ly=0 */
|
||||
MARK_LEAVE("duty:call:markLeave", false, "值班台登记离寓"),
|
||||
/** 取消:ryzt=0;必须写原因 */
|
||||
CANCEL("duty:call:cancel", true, "值班台取消叫乘");
|
||||
|
||||
private final String permission;
|
||||
private final boolean reasonRequired;
|
||||
private final String defaultReason;
|
||||
|
||||
DutyCommandType(String permission, boolean reasonRequired, String defaultReason)
|
||||
{
|
||||
this.permission = permission;
|
||||
this.reasonRequired = reasonRequired;
|
||||
this.defaultReason = defaultReason;
|
||||
}
|
||||
|
||||
public String getPermission()
|
||||
{
|
||||
return permission;
|
||||
}
|
||||
|
||||
public boolean isReasonRequired()
|
||||
{
|
||||
return reasonRequired;
|
||||
}
|
||||
|
||||
/** 一键动作(复叫/登记离寓)无原因输入时的默认留痕文本 */
|
||||
public String getDefaultReason()
|
||||
{
|
||||
return defaultReason;
|
||||
}
|
||||
|
||||
/** 全部命令名,用于 40001 的可读提示 */
|
||||
public static String allCommands()
|
||||
{
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (DutyCommandType value : values())
|
||||
{
|
||||
if (sb.length() > 0)
|
||||
{
|
||||
sb.append(',');
|
||||
}
|
||||
sb.append(value.name());
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/** 未知命令返回 null(由调用方转 40001,不抛枚举异常) */
|
||||
public static DutyCommandType of(String raw)
|
||||
{
|
||||
if (raw == null || raw.trim().isEmpty())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
try
|
||||
{
|
||||
return valueOf(raw.trim().toUpperCase(Locale.ROOT));
|
||||
}
|
||||
catch (IllegalArgumentException e)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.ruoyi.apt.duty.domain;
|
||||
|
||||
/**
|
||||
* 值班台列表筛选条件(应用端/11 表 11-1 工具栏)。
|
||||
*
|
||||
* <p>只有三个入参:口径(需处理/全部/已完成)、关键字、以及 PageHelper 的分页参数。
|
||||
* 时间窗由服务端固定为"当日",客户端不能传 —— 防止值班员误传一个跨月窗口把库拖垮。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
public class DutyQuery
|
||||
{
|
||||
public static final String SCOPE_NEED = "need";
|
||||
public static final String SCOPE_ALL = "all";
|
||||
public static final String SCOPE_DONE = "done";
|
||||
|
||||
private String scope = SCOPE_NEED;
|
||||
private String keyword;
|
||||
|
||||
/** 只接受 need/all/done,其余一律按 need 处理(不报错,保证首屏永远有数据) */
|
||||
public static String normalizeScope(String raw)
|
||||
{
|
||||
if (raw == null)
|
||||
{
|
||||
return SCOPE_NEED;
|
||||
}
|
||||
String value = raw.trim().toLowerCase(java.util.Locale.ROOT);
|
||||
if (SCOPE_ALL.equals(value) || SCOPE_DONE.equals(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
return SCOPE_NEED;
|
||||
}
|
||||
|
||||
public String getScope()
|
||||
{
|
||||
return scope;
|
||||
}
|
||||
|
||||
public void setScope(String scope)
|
||||
{
|
||||
this.scope = normalizeScope(scope);
|
||||
}
|
||||
|
||||
public String getKeyword()
|
||||
{
|
||||
return keyword;
|
||||
}
|
||||
|
||||
public void setKeyword(String keyword)
|
||||
{
|
||||
this.keyword = keyword == null ? null : keyword.trim();
|
||||
}
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
package com.ruoyi.apt.duty.domain;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 值班台状态派生(应用端/11 表 11-2 的唯一实现)。
|
||||
*
|
||||
* <p>纯函数、无 Spring 依赖、无数据库访问:卡片、列表、紧急卡片、吸底统计
|
||||
* 都必须调用这里,避免"前端一套、后端一套"两位口径。判定顺序固定:
|
||||
* {@code finish_ly=1 → stoped=1 → ryzt 六态 → IN}。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
public final class DutyStateDeriver
|
||||
{
|
||||
public static final String ALARM_CALL = "CALL";
|
||||
public static final String ALARM_LEAVE = "LEAVE";
|
||||
|
||||
private static final String YES = "1";
|
||||
|
||||
private DutyStateDeriver()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ryzt 0~5,空/未知表示未进入叫乘链路
|
||||
* @param stoped "1" 表示已停止
|
||||
* @param finishLy "1" 表示已离寓(终态)
|
||||
*/
|
||||
public static DutyUiState derive(String ryzt, String stoped, String finishLy)
|
||||
{
|
||||
if (YES.equals(finishLy))
|
||||
{
|
||||
return DutyUiState.DONE;
|
||||
}
|
||||
if (YES.equals(stoped))
|
||||
{
|
||||
return DutyUiState.STOPPED;
|
||||
}
|
||||
if (ryzt == null || ryzt.isEmpty())
|
||||
{
|
||||
return DutyUiState.IN;
|
||||
}
|
||||
switch (ryzt)
|
||||
{
|
||||
case "0":
|
||||
return DutyUiState.TODO;
|
||||
case "1":
|
||||
return DutyUiState.CALLING;
|
||||
case "2":
|
||||
return DutyUiState.ALARM_CALL;
|
||||
case "3":
|
||||
return DutyUiState.LEAVE;
|
||||
case "4":
|
||||
return DutyUiState.ALARM_LEAVE;
|
||||
case "5":
|
||||
return DutyUiState.TALKING;
|
||||
default:
|
||||
return DutyUiState.IN;
|
||||
}
|
||||
}
|
||||
|
||||
/** 报警类型:仅 ryzt=2/4 有值,便于前端做声音与文案分支 */
|
||||
public static String alarmType(DutyUiState state)
|
||||
{
|
||||
if (state == DutyUiState.ALARM_CALL)
|
||||
{
|
||||
return ALARM_CALL;
|
||||
}
|
||||
if (state == DutyUiState.ALARM_LEAVE)
|
||||
{
|
||||
return ALARM_LEAVE;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 待叫乘已过点:只有 TODO 才谈超时 */
|
||||
public static boolean overdue(DutyUiState state, Date callTime, Date now)
|
||||
{
|
||||
return state == DutyUiState.TODO && callTime != null && now != null
|
||||
&& now.getTime() > callTime.getTime();
|
||||
}
|
||||
|
||||
/** 进入「需要马上处理」:报警两类,或待叫乘已过点 */
|
||||
public static boolean urgent(DutyUiState state, boolean overdue)
|
||||
{
|
||||
return state.isAlarm() || (state == DutyUiState.TODO && overdue);
|
||||
}
|
||||
|
||||
/**
|
||||
* 距叫乘还有多少分钟;已过点返回负值(前端据此显示"已过 N 分钟")。
|
||||
* 调用时间为空时返回 {@code null}(表示"未排任务")。
|
||||
*/
|
||||
public static Long remainMinutes(Date callTime, Date now)
|
||||
{
|
||||
if (callTime == null || now == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
long diff = callTime.getTime() - now.getTime();
|
||||
return Math.round(diff / 60000.0d);
|
||||
}
|
||||
|
||||
/**
|
||||
* 姓名脱敏:保留首字,其余用 {@code *} 代替(最多 2 个)。
|
||||
* 与现状值班台一致,避免大屏被同公寓其他人看到全名。
|
||||
*/
|
||||
public static String maskName(String name)
|
||||
{
|
||||
if (name == null || name.isEmpty())
|
||||
{
|
||||
return name;
|
||||
}
|
||||
int keep = 1;
|
||||
String head = name.substring(0, Math.min(keep, name.length()));
|
||||
int stars = Math.min(2, Math.max(1, name.length() - keep));
|
||||
StringBuilder sb = new StringBuilder(head);
|
||||
for (int i = 0; i < stars; i++)
|
||||
{
|
||||
sb.append('*');
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
package com.ruoyi.apt.duty.domain;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 值班台一行的只读投影(应用端/11 表 11-2、表 11-4)。
|
||||
*
|
||||
* <p>只含展示所需字段:不带 {@code id}(内部主键)、不带任何可写字段,
|
||||
* 也不带 {@code gyid}(数据域由服务端固定,不向客户端回显)。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
public class DutyTaskView
|
||||
{
|
||||
private String jcid;
|
||||
private String roomNo;
|
||||
/** 已脱敏(保留首字 + 星号) */
|
||||
private String personName;
|
||||
private String trainNo;
|
||||
private String callTime;
|
||||
private String departTime;
|
||||
private String ryzt;
|
||||
private String stateLabel;
|
||||
private String uiState;
|
||||
private String alarmType;
|
||||
private String finishJc;
|
||||
private String finishLy;
|
||||
private String stoped;
|
||||
private String matchLocked;
|
||||
private String leaveRemark;
|
||||
private boolean overdue;
|
||||
private boolean urgent;
|
||||
private Long remainMinutes;
|
||||
|
||||
/** 由 mapper 原始行构造:派生口径全部走 {@link DutyStateDeriver} */
|
||||
public static DutyTaskView fromMap(java.util.Map<String, Object> row, Date now)
|
||||
{
|
||||
DutyTaskView view = new DutyTaskView();
|
||||
view.jcid = str(row.get("jcid"));
|
||||
view.roomNo = str(row.get("roomNo"));
|
||||
view.personName = DutyStateDeriver.maskName(str(row.get("personName")));
|
||||
view.trainNo = str(row.get("trainNo"));
|
||||
Date callTime = date(row.get("callTime"));
|
||||
Date departTime = date(row.get("departTime"));
|
||||
view.callTime = format(callTime);
|
||||
view.departTime = format(departTime);
|
||||
view.ryzt = str(row.get("ryzt"));
|
||||
view.finishJc = str(row.get("finishJc"));
|
||||
view.finishLy = str(row.get("finishLy"));
|
||||
view.stoped = str(row.get("stoped"));
|
||||
view.matchLocked = str(row.get("matchLocked"));
|
||||
view.leaveRemark = str(row.get("leaveRemark"));
|
||||
|
||||
DutyUiState state = DutyStateDeriver.derive(view.ryzt, view.stoped, view.finishLy);
|
||||
view.uiState = state.name();
|
||||
view.stateLabel = state.getLabel();
|
||||
view.alarmType = DutyStateDeriver.alarmType(state);
|
||||
view.overdue = DutyStateDeriver.overdue(state, callTime, now);
|
||||
view.urgent = DutyStateDeriver.urgent(state, view.overdue);
|
||||
view.remainMinutes = DutyStateDeriver.remainMinutes(callTime, now);
|
||||
return view;
|
||||
}
|
||||
|
||||
private static String str(Object value)
|
||||
{
|
||||
return value == null ? null : String.valueOf(value);
|
||||
}
|
||||
|
||||
private static Date date(Object value)
|
||||
{
|
||||
if (value instanceof Date)
|
||||
{
|
||||
return (Date) value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String format(Date value)
|
||||
{
|
||||
if (value == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(value);
|
||||
}
|
||||
|
||||
public String getJcid()
|
||||
{
|
||||
return jcid;
|
||||
}
|
||||
|
||||
public String getRoomNo()
|
||||
{
|
||||
return roomNo;
|
||||
}
|
||||
|
||||
public String getPersonName()
|
||||
{
|
||||
return personName;
|
||||
}
|
||||
|
||||
public String getTrainNo()
|
||||
{
|
||||
return trainNo;
|
||||
}
|
||||
|
||||
public String getCallTime()
|
||||
{
|
||||
return callTime;
|
||||
}
|
||||
|
||||
public String getDepartTime()
|
||||
{
|
||||
return departTime;
|
||||
}
|
||||
|
||||
public String getRyzt()
|
||||
{
|
||||
return ryzt;
|
||||
}
|
||||
|
||||
public String getStateLabel()
|
||||
{
|
||||
return stateLabel;
|
||||
}
|
||||
|
||||
public String getUiState()
|
||||
{
|
||||
return uiState;
|
||||
}
|
||||
|
||||
public String getAlarmType()
|
||||
{
|
||||
return alarmType;
|
||||
}
|
||||
|
||||
public String getFinishJc()
|
||||
{
|
||||
return finishJc;
|
||||
}
|
||||
|
||||
public String getFinishLy()
|
||||
{
|
||||
return finishLy;
|
||||
}
|
||||
|
||||
public String getStoped()
|
||||
{
|
||||
return stoped;
|
||||
}
|
||||
|
||||
public String getMatchLocked()
|
||||
{
|
||||
return matchLocked;
|
||||
}
|
||||
|
||||
public String getLeaveRemark()
|
||||
{
|
||||
return leaveRemark;
|
||||
}
|
||||
|
||||
public boolean isOverdue()
|
||||
{
|
||||
return overdue;
|
||||
}
|
||||
|
||||
public boolean isUrgent()
|
||||
{
|
||||
return urgent;
|
||||
}
|
||||
|
||||
public Long getRemainMinutes()
|
||||
{
|
||||
return remainMinutes;
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package com.ruoyi.apt.duty.domain;
|
||||
|
||||
/**
|
||||
* 值班台行状态(应用端/11 表 11-2 的 9 个投影值)。
|
||||
*
|
||||
* <p>这是 {@code ryzt} + {@code stoped} + {@code finish_ly} 的**组合投影**,
|
||||
* 不是新状态机:不落库、不下发写接口、不允许前端自行拼装。
|
||||
* 派生逻辑唯一实现见 {@link DutyStateDeriver}。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
public enum DutyUiState
|
||||
{
|
||||
/** ryzt=0,未到/已过叫乘时间 */
|
||||
TODO("待叫乘"),
|
||||
/** ryzt=1 */
|
||||
CALLING("呼叫中"),
|
||||
/** ryzt=5 */
|
||||
TALKING("通话中"),
|
||||
/** ryzt=2:30s 无入会 */
|
||||
ALARM_CALL("叫乘报警"),
|
||||
/** ryzt=4:超时应离寓 */
|
||||
ALARM_LEAVE("离寓报警"),
|
||||
/** ryzt=3:已应答待离寓 */
|
||||
LEAVE("待离寓"),
|
||||
/** finish_ly=0 且无 ryzt(已入寓、等待排任务) */
|
||||
IN("已入寓"),
|
||||
/** stoped=1 */
|
||||
STOPPED("已停止"),
|
||||
/** finish_ly=1 */
|
||||
DONE("已完成");
|
||||
|
||||
private final String label;
|
||||
|
||||
DutyUiState(String label)
|
||||
{
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
public String getLabel()
|
||||
{
|
||||
return label;
|
||||
}
|
||||
|
||||
/** 需处理:值班台默认筛选口径 */
|
||||
public boolean isNeed()
|
||||
{
|
||||
return this == TODO || this == CALLING || this == TALKING
|
||||
|| this == ALARM_CALL || this == ALARM_LEAVE || this == LEAVE;
|
||||
}
|
||||
|
||||
/** 进入「需要马上处理」区:报警,或待叫乘已过点 */
|
||||
public boolean isAlarm()
|
||||
{
|
||||
return this == ALARM_CALL || this == ALARM_LEAVE;
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package com.ruoyi.apt.duty.mapper;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
/**
|
||||
* 值班台只读投影(应用端/02 表 2-2「view 包不许有 insert/update/delete」)。
|
||||
*
|
||||
* <p>刻意与 {@code AptJcxxMapper}(管理端)分开:管理端按"台账"口径排序、
|
||||
* 返回全字段;值班台按"紧急优先"排序、只返回展示字段、且强制当日时间窗。
|
||||
* 两者共用 {@code gy_jcxx},但**不共用一个 SQL**,避免改一处崩两端。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
public interface DutyJcxxMapper
|
||||
{
|
||||
/** 当日任务投影;scope 只接受 need/all/done(已由服务层归一化) */
|
||||
List<Map<String, Object>> selectDutyTaskList(@Param("gyid") String gyid,
|
||||
@Param("scope") String scope,
|
||||
@Param("from") Date from,
|
||||
@Param("to") Date to,
|
||||
@Param("now") Date now);
|
||||
|
||||
/** 吸底统计与紧急卡片计数(应用端/11 表 11-6 `G-UI-04` 方案 A) */
|
||||
Map<String, Object> selectDutyCounts(@Param("gyid") String gyid,
|
||||
@Param("from") Date from,
|
||||
@Param("to") Date to,
|
||||
@Param("now") Date now);
|
||||
|
||||
/**
|
||||
* 按业务键 {@code jcid} 取最小引用(主键 + 三个状态标志)。
|
||||
*
|
||||
* <p>管理端只按内部主键 {@code id} 取行;值班台拿到的是 {@code jcid},
|
||||
* 必须先在**本公寓内**解析成主键,再交给 {@code apt.duty.state} 执行,
|
||||
* 不能把客户端给的字符串直接当主键用。</p>
|
||||
*/
|
||||
Map<String, Object> selectDutyRefByJcid(@Param("gyid") String gyid, @Param("jcid") String jcid);
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
package com.ruoyi.apt.duty.realtime;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletRequestWrapper;
|
||||
import com.ruoyi.apt.framework.config.AptProperties;
|
||||
import com.ruoyi.common.core.domain.model.LoginUser;
|
||||
import com.ruoyi.framework.web.service.TokenService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.server.ServerHttpRequest;
|
||||
import org.springframework.http.server.ServerHttpResponse;
|
||||
import org.springframework.http.server.ServletServerHttpRequest;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.socket.WebSocketHandler;
|
||||
import org.springframework.web.socket.server.HandshakeInterceptor;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
||||
/**
|
||||
* `/ws/duty` 握手鉴权(应用端/04 表 4-12 第 29 行、`12` 表 12-1 闸门 2)。
|
||||
*
|
||||
* <p>与 `/apt/ws` 的差别只有两点:<b>必需权限点</b>是 {@code duty:jcxx:list},
|
||||
* 且 <b>不接受 {@code ?gyid=}</b>(公寓域只取部署配置,防跨公寓)。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
@Component
|
||||
public class DutyHandshakeInterceptor implements HandshakeInterceptor
|
||||
{
|
||||
private static final Logger log = LoggerFactory.getLogger(DutyHandshakeInterceptor.class);
|
||||
|
||||
/** 值班台订阅权限:与 /duty/** 的准入闸门同码 */
|
||||
private static final String REQUIRED_PERMISSION = "duty:jcxx:list";
|
||||
|
||||
private final TokenService tokenService;
|
||||
|
||||
private final AptProperties properties;
|
||||
|
||||
public DutyHandshakeInterceptor(TokenService tokenService, AptProperties properties)
|
||||
{
|
||||
this.tokenService = tokenService;
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response,
|
||||
WebSocketHandler wsHandler, Map<String, Object> attributes)
|
||||
{
|
||||
String token = resolveToken(request);
|
||||
if (!StringUtils.hasText(token))
|
||||
{
|
||||
response.setStatusCode(HttpStatus.UNAUTHORIZED);
|
||||
return false;
|
||||
}
|
||||
LoginUser loginUser = tokenService.getLoginUser(withBearerToken(request, token));
|
||||
if (loginUser == null)
|
||||
{
|
||||
log.warn("值班台 WS 握手拒绝:令牌无效或已过期");
|
||||
response.setStatusCode(HttpStatus.UNAUTHORIZED);
|
||||
return false;
|
||||
}
|
||||
Set<String> permissions = loginUser.getPermissions();
|
||||
if (!permissions.contains("*:*:*") && !permissions.contains(REQUIRED_PERMISSION))
|
||||
{
|
||||
log.warn("值班台 WS 握手拒绝:userId={} 缺少权限点 {}", loginUser.getUserId(), REQUIRED_PERMISSION);
|
||||
response.setStatusCode(HttpStatus.FORBIDDEN);
|
||||
return false;
|
||||
}
|
||||
String gyid = properties.getGyId();
|
||||
if (!StringUtils.hasText(gyid))
|
||||
{
|
||||
log.warn("值班台 WS 握手拒绝:未配置 apt.gy-id");
|
||||
response.setStatusCode(HttpStatus.FORBIDDEN);
|
||||
return false;
|
||||
}
|
||||
attributes.put("userId", loginUser.getUserId());
|
||||
attributes.put("username", loginUser.getUsername());
|
||||
attributes.put("gyid", gyid);
|
||||
attributes.put("permissions", permissions);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response,
|
||||
WebSocketHandler wsHandler, Exception exception)
|
||||
{
|
||||
// 无需处理
|
||||
}
|
||||
|
||||
private String resolveToken(ServerHttpRequest request)
|
||||
{
|
||||
String token = UriComponentsBuilder.fromUri(request.getURI()).build()
|
||||
.getQueryParams().getFirst("token");
|
||||
if (StringUtils.hasText(token))
|
||||
{
|
||||
return token.trim();
|
||||
}
|
||||
String header = request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
|
||||
if (StringUtils.hasText(header) && header.startsWith("Bearer "))
|
||||
{
|
||||
return header.substring(7).trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** TokenService 只读 Header,这里把 query 令牌包装成 Authorization */
|
||||
private HttpServletRequest withBearerToken(ServerHttpRequest request, String token)
|
||||
{
|
||||
HttpServletRequest original = ((ServletServerHttpRequest) request).getServletRequest();
|
||||
return new HttpServletRequestWrapper(original)
|
||||
{
|
||||
@Override
|
||||
public String getHeader(String name)
|
||||
{
|
||||
if (HttpHeaders.AUTHORIZATION.equalsIgnoreCase(name))
|
||||
{
|
||||
return "Bearer " + token;
|
||||
}
|
||||
return super.getHeader(name);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package com.ruoyi.apt.duty.realtime;
|
||||
|
||||
import com.ruoyi.apt.framework.realtime.AptRealtimeMessage;
|
||||
|
||||
/**
|
||||
* `/ws/duty` 推送信封(应用端/04 表 4-12)。
|
||||
*
|
||||
* <p>只在公寓端信封上多一个 {@code seq}:前端用它丢弃乱序/重复消息。
|
||||
* 其余字段(type/category/priority/gyid/occurredAt/traceId/targetPermission/data)
|
||||
* 与 `/apt/ws` 完全一致,前端可以复用同一套分发代码。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
public class DutyPushMessage extends AptRealtimeMessage
|
||||
{
|
||||
private long seq;
|
||||
|
||||
public long getSeq()
|
||||
{
|
||||
return seq;
|
||||
}
|
||||
|
||||
public void setSeq(long seq)
|
||||
{
|
||||
this.seq = seq;
|
||||
}
|
||||
}
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
package com.ruoyi.apt.duty.realtime;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.ruoyi.apt.common.AptContext;
|
||||
import com.ruoyi.apt.framework.config.AptProperties;
|
||||
import com.ruoyi.apt.framework.realtime.AptRealtimeMessage;
|
||||
import com.ruoyi.apt.framework.realtime.AptRealtimeSessionRegistry;
|
||||
import com.ruoyi.apt.framework.realtime.AptRealtimeTypes;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/**
|
||||
* 值班台实时推送实现(R3)。
|
||||
*
|
||||
* <p>投递复用公寓端 {@link AptRealtimeSessionRegistry}:它已经做了 gyid 隔离、
|
||||
* 权限点过滤、单会话串行发送与失败摘除。本类只补三件事:</p>
|
||||
* <ol>
|
||||
* <li><b>seq</b>:按 gyid 单调递增,连接建立时把 {@code currentSeq} 下发给前端做起点;</li>
|
||||
* <li><b>投递权限</b>:固定 {@code duty:jcxx:list}(公寓端同一分类投给 {@code apt:jcxx:list},
|
||||
* 两边订阅者不重叠,避免值班员收到管理端消息);</li>
|
||||
* <li><b>事务</b>:同步事务内登记 {@code afterCommit},回滚不发消息。</li>
|
||||
* </ol>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
@Service
|
||||
public class DutyPushServiceImpl implements IDutyPushService
|
||||
{
|
||||
private static final Logger log = LoggerFactory.getLogger(DutyPushServiceImpl.class);
|
||||
|
||||
/** 值班台订阅权限:与 `/duty/**` 的闸门 2 同码(应用端/12 表 12-1) */
|
||||
private static final String DUTY_PERMISSION = "duty:jcxx:list";
|
||||
|
||||
private static final String SCOPE_PERMISSION = "PERMISSION";
|
||||
|
||||
static final String TYPE_STATE_CHANGED = "state.changed";
|
||||
static final String TYPE_ALARM_RAISED = "alarm.raised";
|
||||
static final String TYPE_ALARM_CLEARED = "alarm.cleared";
|
||||
static final String TYPE_SESSION_CHANGED = "session.changed";
|
||||
|
||||
private final Map<String, AtomicLong> sequences = new ConcurrentHashMap<>();
|
||||
|
||||
private final AptRealtimeSessionRegistry registry;
|
||||
|
||||
private final AptProperties properties;
|
||||
|
||||
public DutyPushServiceImpl(AptRealtimeSessionRegistry registry, AptProperties properties)
|
||||
{
|
||||
this.registry = registry;
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long stateChanged(String jcid, Integer ryztBefore, Integer ryztAfter, String uiState,
|
||||
String stateLabel, String actionId)
|
||||
{
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("jcid", jcid);
|
||||
data.put("ryztBefore", ryztBefore);
|
||||
data.put("ryztAfter", ryztAfter);
|
||||
data.put("uiState", uiState);
|
||||
data.put("stateLabel", stateLabel);
|
||||
data.put("actionId", actionId);
|
||||
return dispatch(TYPE_STATE_CHANGED, AptRealtimeTypes.CATEGORY_CALL, data);
|
||||
}
|
||||
|
||||
@Override
|
||||
public long alarmRaised(String jcid, String alarmType, String alarmEventKey)
|
||||
{
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("jcid", jcid);
|
||||
data.put("alarmType", alarmType);
|
||||
data.put("alarmEventKey", alarmEventKey);
|
||||
data.put("sound", true);
|
||||
return dispatch(TYPE_ALARM_RAISED, AptRealtimeTypes.CATEGORY_ALARM, data);
|
||||
}
|
||||
|
||||
@Override
|
||||
public long alarmCleared(String jcid, String alarmType, String alarmEventKey)
|
||||
{
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("jcid", jcid);
|
||||
data.put("alarmType", alarmType);
|
||||
data.put("alarmEventKey", alarmEventKey);
|
||||
return dispatch(TYPE_ALARM_CLEARED, AptRealtimeTypes.CATEGORY_ALARM, data);
|
||||
}
|
||||
|
||||
@Override
|
||||
public long sessionChanged(String jcid, String sessionId, String state, String confNo)
|
||||
{
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("jcid", jcid);
|
||||
data.put("sessionId", sessionId);
|
||||
data.put("state", state);
|
||||
data.put("confNo", confNo);
|
||||
return dispatch(TYPE_SESSION_CHANGED, AptRealtimeTypes.CATEGORY_CALL, data);
|
||||
}
|
||||
|
||||
@Override
|
||||
public long currentSeq()
|
||||
{
|
||||
String gyid = resolveGyid();
|
||||
if (!StringUtils.hasText(gyid))
|
||||
{
|
||||
return 0L;
|
||||
}
|
||||
AtomicLong counter = sequences.get(gyid);
|
||||
return counter == null ? 0L : counter.get();
|
||||
}
|
||||
|
||||
private long dispatch(String type, String category, Map<String, Object> data)
|
||||
{
|
||||
try
|
||||
{
|
||||
String gyid = resolveGyid();
|
||||
if (!StringUtils.hasText(gyid))
|
||||
{
|
||||
// 无租户上下文不推送,避免跨公寓泄露
|
||||
return -1L;
|
||||
}
|
||||
DutyPushMessage message = new DutyPushMessage();
|
||||
message.setId(UUID.randomUUID().toString().replace("-", ""));
|
||||
message.setType(type);
|
||||
message.setCategory(category);
|
||||
message.setPriority(AptRealtimeTypes.priorityOf(category));
|
||||
message.setTargetScope(SCOPE_PERMISSION);
|
||||
message.setTargetPermission(DUTY_PERMISSION);
|
||||
message.setGyid(gyid);
|
||||
message.setOccurredAt(System.currentTimeMillis());
|
||||
message.setTraceId(AptContext.getTraceId());
|
||||
message.setData(data);
|
||||
message.setSeq(nextSeq(gyid));
|
||||
|
||||
Runnable task = () -> send(message.getSeq(), message);
|
||||
if (TransactionSynchronizationManager.isSynchronizationActive())
|
||||
{
|
||||
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization()
|
||||
{
|
||||
@Override
|
||||
public void afterCommit()
|
||||
{
|
||||
task.run();
|
||||
}
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
task.run();
|
||||
}
|
||||
return message.getSeq();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
log.warn("值班台推送登记失败,type={}, cause={}", type, e.toString());
|
||||
return -1L;
|
||||
}
|
||||
}
|
||||
|
||||
private void send(long seq, AptRealtimeMessage message)
|
||||
{
|
||||
try
|
||||
{
|
||||
String payload = JSON.toJSONString(message);
|
||||
int sent = registry.sendToPermission(message.getGyid(), DUTY_PERMISSION, payload);
|
||||
if (sent == 0)
|
||||
{
|
||||
log.debug("值班台推送无在线订阅者:type={}, seq={}", message.getType(), seq);
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
// 推送失败不允许冒泡到命令调用方
|
||||
log.warn("值班台推送失败,type={}, seq={}, cause={}", message.getType(), seq, e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
private long nextSeq(String gyid)
|
||||
{
|
||||
return sequences.computeIfAbsent(gyid, key -> new AtomicLong()).incrementAndGet();
|
||||
}
|
||||
|
||||
/** 优先取租户上下文;调度线程没有上下文时退回部署配置(一公寓一进程 D5) */
|
||||
private String resolveGyid()
|
||||
{
|
||||
String gyid = AptContext.getGyIdOrNull();
|
||||
if (StringUtils.hasText(gyid))
|
||||
{
|
||||
return gyid;
|
||||
}
|
||||
return properties.getGyId();
|
||||
}
|
||||
}
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
package com.ruoyi.apt.duty.realtime;
|
||||
|
||||
import com.ruoyi.apt.dispatch.domain.AptStateSnapshot;
|
||||
import com.ruoyi.apt.duty.domain.DutyStateDeriver;
|
||||
import com.ruoyi.apt.duty.domain.DutyUiState;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 状态机迁移 → 值班台推送桥(R4 / `L-11`)。
|
||||
*
|
||||
* <p><b>为什么挂在状态机唯一写入口,而不是命令层</b>:{@code gy_jcxx} 的
|
||||
* {@code ryzt/finish_jc/finish_ly/stoped} 只有 {@code AptDutyStateMapper} 能写,
|
||||
* 而调用它的有四类路径——人工命令({@code AptDutyCommandServiceImpl})、
|
||||
* Quartz 叫乘扫描({@code AptDispatchScanServiceImpl})、AMI/呼叫事件({@code AptVoiceEventServiceImpl})、
|
||||
* 平板上报({@code AptTabletResultServiceImpl})。R3 只在第一类里推送,于是
|
||||
* 「定时任务自己产生的 {@code ryzt=2} 报警」在值班台只能靠 5 min 兜底轮询发现(`L-11`)。</p>
|
||||
*
|
||||
* <p>挂到唯一写入口后四类路径全覆盖,且后续新增迁移点不必再记得补推送。</p>
|
||||
*
|
||||
* <p><b>三条约束</b>:</p>
|
||||
* <ol>
|
||||
* <li><b>不猜</b>:迁移后快照缺失({@code after == null})就不推,宁可漏一次,也不用旧态冒充新态;</li>
|
||||
* <li><b>只推变化</b>:{@code alarm.cleared} 只在「前态有报警、后态没有」时发;前后态相同
|
||||
* (重复命令、只改 {@code finish_jc} 的 {@code FORCE_FINISH})不会再误发解除;</li>
|
||||
* <li><b>不影响业务</b>:整段 try/catch,推送异常只记日志——迁移此时已经在同一事务里提交。</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p><b>seq 回填</b>:{@link #takeStateSeq(String)} 取本线程上一次迁移登记的 {@code state.changed}
|
||||
* 序号,供命令响应的 {@code pushSeq} 使用(`04` 表 4-7)。单条命令一个线程只迁移一次,
|
||||
* 取走即清;取不到返回 {@code -1}(与「未登记」同义)。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
@Component
|
||||
public class DutyTransitionPush
|
||||
{
|
||||
private static final Logger log = LoggerFactory.getLogger(DutyTransitionPush.class);
|
||||
|
||||
private final IDutyPushService pushService;
|
||||
|
||||
/** 本线程最近一次迁移登记的 seq;只保留一条,避免线程池里堆积 */
|
||||
private final ThreadLocal<LastPush> lastPush = new ThreadLocal<>();
|
||||
|
||||
public DutyTransitionPush(IDutyPushService pushService)
|
||||
{
|
||||
this.pushService = pushService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 状态迁移完成后调用。
|
||||
*
|
||||
* @param jcid 任务标识(外部可读 id,不是主键)
|
||||
* @param before 迁移前快照,读不到可传 {@code null}(此时不判报警解除)
|
||||
* @param after 迁移后快照;为 {@code null} 表示读不到新态,直接跳过推送
|
||||
* @param actionId 人工命令的幂等键;非命令触发(扫描任务/设备上报)传 {@code null}
|
||||
*/
|
||||
public void afterTransition(String jcid, AptStateSnapshot before, AptStateSnapshot after, String actionId)
|
||||
{
|
||||
// 先清上一次残留:本次即使失败也不允许把旧 seq 回填给命令响应
|
||||
lastPush.remove();
|
||||
if (jcid == null || after == null)
|
||||
{
|
||||
log.debug("迁移后快照缺失,跳过值班台推送:jcid={}", jcid);
|
||||
return;
|
||||
}
|
||||
DutyUiState stateAfter = derive(after);
|
||||
DutyUiState stateBefore = before == null ? null : derive(before);
|
||||
try
|
||||
{
|
||||
long seq = pushService.stateChanged(jcid, ryzt(before), ryzt(after), stateAfter.name(),
|
||||
stateAfter.getLabel(), actionId);
|
||||
String alarmAfter = DutyStateDeriver.alarmType(stateAfter);
|
||||
String alarmBefore = stateBefore == null ? null : DutyStateDeriver.alarmType(stateBefore);
|
||||
if (alarmAfter != null && !alarmAfter.equals(alarmBefore))
|
||||
{
|
||||
pushService.alarmRaised(jcid, alarmAfter, alarmKey(jcid, alarmAfter));
|
||||
}
|
||||
else if (alarmBefore != null && !alarmBefore.equals(alarmAfter))
|
||||
{
|
||||
pushService.alarmCleared(jcid, alarmBefore, alarmKey(jcid, alarmBefore));
|
||||
}
|
||||
lastPush.set(new LastPush(jcid, seq));
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
// 迁移已经提交,推送失败不能反向影响业务
|
||||
log.warn("值班台迁移推送失败,迁移已生效:jcid={}, cause={}", jcid, e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取走本线程该 {@code jcid} 上一次迁移的 {@code state.changed} 序号。
|
||||
*
|
||||
* @return 序号;未登记/登记失败(含推送抛异常、jcid 不匹配)返回 {@code -1}
|
||||
*/
|
||||
public long takeStateSeq(String jcid)
|
||||
{
|
||||
LastPush last = lastPush.get();
|
||||
if (last == null || last.jcid == null || !last.jcid.equals(jcid))
|
||||
{
|
||||
// jcid 不匹配不销毁登记:留给真正的那条命令去取(下一次迁移也会覆盖它)
|
||||
return -1L;
|
||||
}
|
||||
lastPush.remove();
|
||||
return last.seq;
|
||||
}
|
||||
|
||||
private DutyUiState derive(AptStateSnapshot snapshot)
|
||||
{
|
||||
return DutyStateDeriver.derive(snapshot.getRyzt(), snapshot.getStoped(), snapshot.getFinishLy());
|
||||
}
|
||||
|
||||
private Integer ryzt(AptStateSnapshot snapshot)
|
||||
{
|
||||
if (snapshot == null || snapshot.getRyzt() == null || snapshot.getRyzt().isBlank())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
try
|
||||
{
|
||||
return Integer.valueOf(snapshot.getRyzt().trim());
|
||||
}
|
||||
catch (NumberFormatException e)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 告警事件键:同一 jcid 同一类型稳定不变,前端据此「起/停」对应声音 */
|
||||
private String alarmKey(String jcid, String alarmType)
|
||||
{
|
||||
return jcid + ":" + alarmType;
|
||||
}
|
||||
|
||||
/** 单条迁移的登记结果 */
|
||||
private static final class LastPush
|
||||
{
|
||||
private final String jcid;
|
||||
|
||||
private final long seq;
|
||||
|
||||
private LastPush(String jcid, long seq)
|
||||
{
|
||||
this.jcid = jcid;
|
||||
this.seq = seq;
|
||||
}
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package com.ruoyi.apt.duty.realtime;
|
||||
|
||||
import com.ruoyi.apt.framework.config.AptProperties;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
|
||||
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
|
||||
|
||||
/**
|
||||
* 值班台 WebSocket 注册:{@code GET /ws/duty?token=xxx}(R3)。
|
||||
*
|
||||
* <p>不重复 {@code @EnableWebSocket}:公寓端 {@code AptRealtimeConfig} 已经开启,
|
||||
* Spring 会把同一上下文里的所有 {@link WebSocketConfigurer} 合并执行。
|
||||
* 总开关沿用 {@code apt.realtime.enabled},关掉即退回轮询(前端本来就有兜底)。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
@Configuration
|
||||
@ConditionalOnProperty(prefix = "apt.realtime", name = "enabled", havingValue = "true", matchIfMissing = true)
|
||||
public class DutyWebSocketConfig implements WebSocketConfigurer
|
||||
{
|
||||
private final DutyWebSocketHandler handler;
|
||||
|
||||
private final DutyHandshakeInterceptor interceptor;
|
||||
|
||||
private final AptProperties properties;
|
||||
|
||||
public DutyWebSocketConfig(DutyWebSocketHandler handler, DutyHandshakeInterceptor interceptor,
|
||||
AptProperties properties)
|
||||
{
|
||||
this.handler = handler;
|
||||
this.interceptor = interceptor;
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry)
|
||||
{
|
||||
registry.addHandler(handler, "/ws/duty")
|
||||
.addInterceptors(interceptor)
|
||||
.setAllowedOriginPatterns(
|
||||
properties.getRealtime().getAllowedOrigins().toArray(new String[0]));
|
||||
}
|
||||
}
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
package com.ruoyi.apt.duty.realtime;
|
||||
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.ruoyi.apt.framework.realtime.AptRealtimeSessionRegistry;
|
||||
import com.ruoyi.apt.framework.realtime.AptRealtimeTypes;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.socket.CloseStatus;
|
||||
import org.springframework.web.socket.TextMessage;
|
||||
import org.springframework.web.socket.WebSocketSession;
|
||||
import org.springframework.web.socket.handler.TextWebSocketHandler;
|
||||
|
||||
/**
|
||||
* `/ws/duty` 推送端点(R3)。
|
||||
*
|
||||
* <p>只做三件事:连接建立回 {@code CONNECTED}(含心跳间隔与当前 seq 水位)、
|
||||
* 心跳 {@code PING → PONG}、断开摘除会话。<b>不接受业务命令</b>:命令一律走
|
||||
* `POST /duty/jcxx/{jcid}/commands`,否则会绕过权限点与幂等。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
@Component
|
||||
public class DutyWebSocketHandler extends TextWebSocketHandler
|
||||
{
|
||||
private static final Logger log = LoggerFactory.getLogger(DutyWebSocketHandler.class);
|
||||
|
||||
/** 心跳 30 s:与 `04` 表 4-12 的 ping/pong 约定一致 */
|
||||
static final int HEARTBEAT_INTERVAL_SEC = 30;
|
||||
|
||||
private static final String CONNECTED = "CONNECTED";
|
||||
|
||||
private static final String PONG = "PONG";
|
||||
|
||||
private final AptRealtimeSessionRegistry registry;
|
||||
|
||||
private final IDutyPushService pushService;
|
||||
|
||||
public DutyWebSocketHandler(AptRealtimeSessionRegistry registry, IDutyPushService pushService)
|
||||
{
|
||||
this.registry = registry;
|
||||
this.pushService = pushService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterConnectionEstablished(WebSocketSession session) throws Exception
|
||||
{
|
||||
Map<String, Object> attributes = session.getAttributes();
|
||||
Long userId = (Long) attributes.get("userId");
|
||||
String username = (String) attributes.get("username");
|
||||
String gyid = (String) attributes.get("gyid");
|
||||
@SuppressWarnings("unchecked")
|
||||
Set<String> permissions = (Set<String>) attributes.get("permissions");
|
||||
if (userId == null || gyid == null)
|
||||
{
|
||||
session.close(CloseStatus.NOT_ACCEPTABLE);
|
||||
return;
|
||||
}
|
||||
registry.register(session, userId, username, gyid, permissions);
|
||||
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("sessionId", session.getId());
|
||||
data.put("userId", userId);
|
||||
data.put("username", username);
|
||||
data.put("gyid", gyid);
|
||||
data.put("heartbeatIntervalSec", HEARTBEAT_INTERVAL_SEC);
|
||||
data.put("serverTime", System.currentTimeMillis());
|
||||
data.put("serverTimeText", new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()));
|
||||
data.put("lastSeq", pushService.currentSeq());
|
||||
data.put("messages", new String[] { "state.changed", "alarm.raised", "alarm.cleared",
|
||||
"session.changed" });
|
||||
session.sendMessage(new TextMessage(JSON.toJSONString(envelope(CONNECTED, data))));
|
||||
log.info("值班台 WS 已连接:sessionId={}, userId={}, gyid={}", session.getId(), userId, gyid);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception
|
||||
{
|
||||
String payload = message.getPayload();
|
||||
if (payload == null || payload.isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
registry.touch(session.getId());
|
||||
String type = null;
|
||||
Object clientTs = null;
|
||||
try
|
||||
{
|
||||
JSONObject json = JSON.parseObject(payload);
|
||||
if (json != null)
|
||||
{
|
||||
type = json.getString("type");
|
||||
clientTs = json.get("ts");
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
if ("PING".equalsIgnoreCase(payload.trim()))
|
||||
{
|
||||
type = "PING";
|
||||
}
|
||||
}
|
||||
if ("PING".equalsIgnoreCase(type) || "PING".equalsIgnoreCase(payload.trim()))
|
||||
{
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("serverTime", System.currentTimeMillis());
|
||||
data.put("lastSeq", pushService.currentSeq());
|
||||
if (clientTs != null)
|
||||
{
|
||||
data.put("ts", clientTs);
|
||||
}
|
||||
session.sendMessage(new TextMessage(JSON.toJSONString(envelope(PONG, data))));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterConnectionClosed(WebSocketSession session, CloseStatus status)
|
||||
{
|
||||
registry.remove(session.getId());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception
|
||||
{
|
||||
log.warn("值班台 WS 传输异常:sessionId={}, cause={}", session.getId(), exception.toString());
|
||||
registry.remove(session.getId());
|
||||
if (session.isOpen())
|
||||
{
|
||||
session.close(CloseStatus.SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> envelope(String type, Map<String, Object> data)
|
||||
{
|
||||
Map<String, Object> message = new LinkedHashMap<>();
|
||||
message.put("type", type);
|
||||
message.put("category", AptRealtimeTypes.CATEGORY_SYSTEM);
|
||||
message.put("priority", AptRealtimeTypes.PRIORITY_P2);
|
||||
message.put("occurredAt", System.currentTimeMillis());
|
||||
message.put("seq", pushService.currentSeq());
|
||||
message.put("data", data);
|
||||
return message;
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package com.ruoyi.apt.duty.realtime;
|
||||
|
||||
/**
|
||||
* 值班台实时推送(`11` 表 11-9 R3、`04` 表 4-12)。
|
||||
*
|
||||
* <p>三条铁律:①推送失败绝不影响命令;②事务内登记、提交后发送;
|
||||
* ③方法返回 {@code seq} 供命令响应回给前端做丢重,{@code -1} 表示<b>未登记</b>
|
||||
* (无租户上下文或登记异常);通道发送失败只记日志,不回滚也不改返回值。</p>
|
||||
*
|
||||
* <p>{@code state.changed} 直接带上 {@code uiState/stateLabel}:前端只打补丁、
|
||||
* 不再自己实现一遍派生表(`11` 表 11-2 的唯一实现仍在后端)。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
public interface IDutyPushService
|
||||
{
|
||||
/** 任务状态变化:前端只更新该行,不整表刷新 */
|
||||
long stateChanged(String jcid, Integer ryztBefore, Integer ryztAfter, String uiState,
|
||||
String stateLabel, String actionId);
|
||||
|
||||
/** 报警产生:入告警音状态机并把行置红 */
|
||||
long alarmRaised(String jcid, String alarmType, String alarmEventKey);
|
||||
|
||||
/** 报警解除:停该行告警音 */
|
||||
long alarmCleared(String jcid, String alarmType, String alarmEventKey);
|
||||
|
||||
/** 通话会话变化(会议桥占用、挂断原因) */
|
||||
long sessionChanged(String jcid, String sessionId, String state, String confNo);
|
||||
|
||||
/** 当前 gyid 的推送水位;连接建立时下发给前端作为起点 */
|
||||
long currentSeq();
|
||||
}
|
||||
+279
@@ -0,0 +1,279 @@
|
||||
package com.ruoyi.apt.duty.service;
|
||||
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import com.ruoyi.apt.common.AptContext;
|
||||
import com.ruoyi.apt.common.AptErrorCode;
|
||||
import com.ruoyi.apt.common.AptException;
|
||||
import com.ruoyi.apt.dispatch.domain.AptDutyCommand;
|
||||
import com.ruoyi.apt.dispatch.domain.AptDutyCommandResult;
|
||||
import com.ruoyi.apt.dispatch.service.IAptDutyCommandService;
|
||||
import com.ruoyi.apt.dispatch.service.IAptJcxxService;
|
||||
import com.ruoyi.apt.duty.domain.DutyCommandRequest;
|
||||
import com.ruoyi.apt.duty.domain.DutyCommandType;
|
||||
import com.ruoyi.apt.duty.domain.DutyStateDeriver;
|
||||
import com.ruoyi.apt.duty.domain.DutyUiState;
|
||||
import com.ruoyi.apt.duty.mapper.DutyJcxxMapper;
|
||||
import com.ruoyi.apt.duty.realtime.DutyTransitionPush;
|
||||
import com.ruoyi.framework.web.service.PermissionService;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* 值班台命令实现(R2)。
|
||||
*
|
||||
* <p>责任边界(应用端/04 表 4-6、`02` 表 2-2):</p>
|
||||
* <ol>
|
||||
* <li><b>不写库</b>:所有状态写入都走 {@link IAptDutyCommandService#execute(Long, AptDutyCommand)};</li>
|
||||
* <li><b>先解析再执行</b>:客户端只给 {@code jcid},先在本公寓内解析出内部主键,
|
||||
* 避免拿外部字符串当主键(也顺带挡住跨公寓猜测);</li>
|
||||
* <li><b>一命令一权限</b>:{@code duty:call:stop}/{@code recall}/{@code cancel}/{@code forceFinish}/{@code markLeave},
|
||||
* 管理端只有一个 {@code apt:jcxx:changeStatus},值班台不能复用;</li>
|
||||
* <li><b>原因必填口径以既有实现为准</b>:{@code apt.duty.state} 对 5 个命令都要求
|
||||
* {@code reason} 非空,因此一键动作(复叫/登记离寓)由服务端补默认原因,
|
||||
* 而不是把"可不填"透传给状态机造成 40001。</li>
|
||||
* </ol>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
@Service
|
||||
public class DutyCommandServiceImpl implements IDutyCommandService
|
||||
{
|
||||
private static final int REASON_MAX_LENGTH = 200;
|
||||
|
||||
private static final String TIME_PATTERN = "yyyy-MM-dd HH:mm:ss";
|
||||
|
||||
private static final String DEFAULT_LEAVE_TYPE = "MANUAL";
|
||||
|
||||
private final DutyJcxxMapper dutyJcxxMapper;
|
||||
|
||||
private final IAptDutyCommandService dutyCommandService;
|
||||
|
||||
private final IAptJcxxService jcxxService;
|
||||
|
||||
private final PermissionService permissionService;
|
||||
|
||||
private final DutyTransitionPush dutyPush;
|
||||
|
||||
public DutyCommandServiceImpl(DutyJcxxMapper dutyJcxxMapper,
|
||||
IAptDutyCommandService dutyCommandService,
|
||||
IAptJcxxService jcxxService,
|
||||
PermissionService permissionService,
|
||||
DutyTransitionPush dutyPush)
|
||||
{
|
||||
this.dutyJcxxMapper = dutyJcxxMapper;
|
||||
this.dutyCommandService = dutyCommandService;
|
||||
this.jcxxService = jcxxService;
|
||||
this.permissionService = permissionService;
|
||||
this.dutyPush = dutyPush;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> execute(String jcid, DutyCommandRequest request)
|
||||
{
|
||||
DutyCommandType type = DutyCommandType.of(request == null ? null : request.getCommandType());
|
||||
if (type == null)
|
||||
{
|
||||
throw new AptException(AptErrorCode.BAD_REQUEST, "COMMAND_UNSUPPORTED",
|
||||
"不支持的命令,可用:" + DutyCommandType.allCommands());
|
||||
}
|
||||
if (!permissionService.hasPermi(type.getPermission()))
|
||||
{
|
||||
throw new AptException(AptErrorCode.FORBIDDEN_PERM, "DUTY_COMMAND_PERMISSION",
|
||||
"当前账号无权执行" + type.name() + ",请联系管理员分配 " + type.getPermission());
|
||||
}
|
||||
|
||||
Map<String, Object> before = requireRow(jcid);
|
||||
Integer ryztBefore = toInteger(before.get("ryzt"));
|
||||
if (request.getExpectedState() != null && !request.getExpectedState().equals(ryztBefore))
|
||||
{
|
||||
throw new AptException(AptErrorCode.STATE_ILLEGAL, "EXPECTED_STATE_MISMATCH",
|
||||
"该行已被他人处理,请刷新后重试");
|
||||
}
|
||||
|
||||
String reason = normalizeReason(type, request.getReason());
|
||||
AptDutyCommand command = new AptDutyCommand();
|
||||
command.setCommandId(normalizeCommandId(request.getCommandId()));
|
||||
command.setCommand(type.name());
|
||||
command.setReason(reason);
|
||||
if (type == DutyCommandType.MARK_LEAVE)
|
||||
{
|
||||
command.setLeaveType(normalizeLeaveType(request.getLeaveType()));
|
||||
}
|
||||
|
||||
AptDutyCommandResult result = dutyCommandService.execute(toLong(before.get("id")), command);
|
||||
Map<String, Object> after = requireRow(jcid);
|
||||
|
||||
Integer ryztAfter = toInteger(after.get("ryzt"));
|
||||
DutyUiState state = DutyStateDeriver.derive(str(after.get("ryzt")), str(after.get("stoped")),
|
||||
str(after.get("finishLy")));
|
||||
String actionId = result == null ? null : result.getActionId();
|
||||
|
||||
// 状态与告警事件已由状态机唯一写入口统一推送(R4 / L-11):
|
||||
// 命令层不再推一次,否则同一次迁移会发两条 state.changed、报警音会响两遍
|
||||
long pushSeq = dutyPush.takeStateSeq(jcid);
|
||||
|
||||
Map<String, Object> view = new LinkedHashMap<>();
|
||||
view.put("jcid", jcid);
|
||||
view.put("commandId", command.getCommandId());
|
||||
view.put("commandType", type.name());
|
||||
view.put("actionId", actionId);
|
||||
view.put("state", result == null || result.isAccepted() ? "DONE" : "FAILED");
|
||||
view.put("duplicated", result != null && result.isDuplicated());
|
||||
view.put("stateAfter", result == null ? null : result.getStateAfter());
|
||||
view.put("reason", reason);
|
||||
view.put("ryztBefore", ryztBefore);
|
||||
view.put("ryztAfter", ryztAfter);
|
||||
view.put("uiState", state.name());
|
||||
view.put("stateLabel", state.getLabel());
|
||||
view.put("pushSeq", pushSeq);
|
||||
return view;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> detail(String jcid)
|
||||
{
|
||||
Map<String, Object> row = requireRow(jcid);
|
||||
Map<String, Object> source = jcxxService.detail(toLong(row.get("id")));
|
||||
|
||||
Map<String, Object> detail = new LinkedHashMap<>();
|
||||
detail.put("jcid", str(source.get("jcid")));
|
||||
detail.put("roomNo", str(source.get("fjh")));
|
||||
detail.put("personName", DutyStateDeriver.maskName(str(source.get("rymc"))));
|
||||
detail.put("trainNo", str(source.get("cc")));
|
||||
detail.put("callTime", format(source.get("jcsj")));
|
||||
detail.put("departTime", format(source.get("kcsj")));
|
||||
detail.put("ryzt", str(source.get("ryzt")));
|
||||
detail.put("ryztLabel", str(source.get("ryztLabel")));
|
||||
detail.put("leaveRemark", str(source.get("leaveRemark")));
|
||||
detail.put("bizVersion", source.get("bizVersion"));
|
||||
detail.put("receivedAt", format(source.get("receivedAt")));
|
||||
detail.put("session", source.get("session"));
|
||||
detail.put("manualActions", limit(source.get("manualActions"), 10));
|
||||
|
||||
DutyUiState state = DutyStateDeriver.derive(str(source.get("ryzt")), str(source.get("stoped")),
|
||||
str(source.get("finishLy")));
|
||||
detail.put("uiState", state.name());
|
||||
detail.put("stateLabel", state.getLabel());
|
||||
detail.put("alarmType", DutyStateDeriver.alarmType(state));
|
||||
return detail;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Map<String, Object>> timeline(String jcid)
|
||||
{
|
||||
Map<String, Object> row = requireRow(jcid);
|
||||
List<Map<String, Object>> timeline = jcxxService.timeline(toLong(row.get("id")));
|
||||
return timeline == null ? new ArrayList<>() : timeline;
|
||||
}
|
||||
|
||||
/** 本公寓内按 jcid 取行;取不到即 40401(不区分"不存在"与"不属于本公寓",防枚举) */
|
||||
private Map<String, Object> requireRow(String jcid)
|
||||
{
|
||||
if (jcid == null || jcid.trim().isEmpty())
|
||||
{
|
||||
throw new AptException(AptErrorCode.BAD_REQUEST, "JCID_REQUIRED", "缺少任务标识 jcid");
|
||||
}
|
||||
Map<String, Object> row = dutyJcxxMapper.selectDutyRefByJcid(AptContext.getGyId(), jcid.trim());
|
||||
if (row == null || row.isEmpty())
|
||||
{
|
||||
throw new AptException(AptErrorCode.NOT_FOUND, "JCXX_NOT_FOUND", "任务不存在或不属于本公寓");
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
private String normalizeReason(DutyCommandType type, String raw)
|
||||
{
|
||||
String reason = raw == null ? null : raw.trim();
|
||||
if (reason == null || reason.isEmpty())
|
||||
{
|
||||
if (type.isReasonRequired())
|
||||
{
|
||||
throw new AptException(AptErrorCode.BAD_REQUEST, "REASON_REQUIRED",
|
||||
type.name() + " 必须填写原因(至少 2 个字)");
|
||||
}
|
||||
return type.getDefaultReason();
|
||||
}
|
||||
if (reason.length() < 2)
|
||||
{
|
||||
throw new AptException(AptErrorCode.BAD_REQUEST, "REASON_TOO_SHORT", "原因至少 2 个字");
|
||||
}
|
||||
if (reason.length() > REASON_MAX_LENGTH)
|
||||
{
|
||||
throw new AptException(AptErrorCode.BAD_REQUEST, "REASON_TOO_LONG",
|
||||
"原因不超过 " + REASON_MAX_LENGTH + " 个字");
|
||||
}
|
||||
return reason;
|
||||
}
|
||||
|
||||
/** 前端未给幂等键时兜底生成:宁可偶发重复留痕,也不要 NPE */
|
||||
private String normalizeCommandId(String raw)
|
||||
{
|
||||
if (raw == null || raw.trim().isEmpty())
|
||||
{
|
||||
return "DUTY-" + UUID.randomUUID().toString().replace("-", "");
|
||||
}
|
||||
return raw.trim();
|
||||
}
|
||||
|
||||
private String normalizeLeaveType(String raw)
|
||||
{
|
||||
if (raw == null || raw.trim().isEmpty())
|
||||
{
|
||||
return DEFAULT_LEAVE_TYPE;
|
||||
}
|
||||
String value = raw.trim().toUpperCase(java.util.Locale.ROOT);
|
||||
return "MANUAL".equals(value) ? value : DEFAULT_LEAVE_TYPE;
|
||||
}
|
||||
|
||||
private Object limit(Object value, int max)
|
||||
{
|
||||
if (!(value instanceof List))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
List<?> list = (List<?>) value;
|
||||
return list.size() <= max ? list : new ArrayList<>(list.subList(0, max));
|
||||
}
|
||||
|
||||
private String format(Object value)
|
||||
{
|
||||
if (!(value instanceof Date))
|
||||
{
|
||||
return value == null ? null : String.valueOf(value);
|
||||
}
|
||||
return new SimpleDateFormat(TIME_PATTERN).format((Date) value);
|
||||
}
|
||||
|
||||
private String str(Object value)
|
||||
{
|
||||
return value == null ? null : String.valueOf(value);
|
||||
}
|
||||
|
||||
private Integer toInteger(Object value)
|
||||
{
|
||||
if (value == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (value instanceof Number)
|
||||
{
|
||||
return ((Number) value).intValue();
|
||||
}
|
||||
String text = String.valueOf(value).trim();
|
||||
return text.isEmpty() ? null : Integer.valueOf(text);
|
||||
}
|
||||
|
||||
private Long toLong(Object value)
|
||||
{
|
||||
if (value instanceof Number)
|
||||
{
|
||||
return ((Number) value).longValue();
|
||||
}
|
||||
return Long.valueOf(String.valueOf(value));
|
||||
}
|
||||
}
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
package com.ruoyi.apt.duty.service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Calendar;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import com.ruoyi.apt.common.AptContext;
|
||||
import com.ruoyi.apt.duty.domain.DutyQuery;
|
||||
import com.ruoyi.apt.duty.domain.DutyTaskView;
|
||||
import com.ruoyi.apt.duty.mapper.DutyJcxxMapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* 值班台只读查询实现(R1)。
|
||||
*
|
||||
* <p>三个口径决定:</p>
|
||||
* <ol>
|
||||
* <li><b>时间窗固定当日</b>([今日 00:00, 明日 00:00))。无 {@code jcsj} 的行始终保留,
|
||||
* 因为"已入寓但还没排任务"的人必须在值班台可见;</li>
|
||||
* <li><b>关键字在内存过滤</b>:需要同时匹配房间号/姓名/车次(OR),
|
||||
* 而管理端 Mapper 是三条独立 AND 条件;当日数据量有限,内存过滤更安全;</li>
|
||||
* <li><b>姓名脱敏在服务层</b>:SQL 出原始 {@code xm},脱敏只在 {@link DutyTaskView#fromMap} 做一次,
|
||||
* 保证"关键字能搜到全名、返回体只有脱敏名"。</li>
|
||||
* </ol>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
@Service
|
||||
public class DutyJcxxQueryServiceImpl implements IDutyJcxxQueryService
|
||||
{
|
||||
private static final String TIME_PATTERN = "yyyy-MM-dd HH:mm:ss";
|
||||
|
||||
private final DutyJcxxMapper dutyJcxxMapper;
|
||||
|
||||
public DutyJcxxQueryServiceImpl(DutyJcxxMapper dutyJcxxMapper)
|
||||
{
|
||||
this.dutyJcxxMapper = dutyJcxxMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<DutyTaskView> list(DutyQuery query, Date now)
|
||||
{
|
||||
DutyQuery effective = query == null ? new DutyQuery() : query;
|
||||
Date base = now == null ? new Date() : now;
|
||||
Date from = dayStart(base);
|
||||
Date to = dayEnd(base);
|
||||
|
||||
List<Map<String, Object>> rows = dutyJcxxMapper.selectDutyTaskList(
|
||||
AptContext.getGyId(), effective.getScope(), from, to, base);
|
||||
if (rows == null || rows.isEmpty())
|
||||
{
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
String keyword = effective.getKeyword();
|
||||
List<DutyTaskView> views = new ArrayList<>(rows.size());
|
||||
for (Map<String, Object> row : rows)
|
||||
{
|
||||
if (!matchesKeyword(row, keyword))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
views.add(DutyTaskView.fromMap(row, base));
|
||||
}
|
||||
return views;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> counts(Date now)
|
||||
{
|
||||
Date base = now == null ? new Date() : now;
|
||||
Map<String, Object> raw = dutyJcxxMapper.selectDutyCounts(
|
||||
AptContext.getGyId(), dayStart(base), dayEnd(base), base);
|
||||
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("todoCount", toLong(raw, "todoCount"));
|
||||
data.put("callCount", toLong(raw, "callCount"));
|
||||
data.put("alarmCount", toLong(raw, "alarmCount"));
|
||||
data.put("needCount", toLong(raw, "needCount"));
|
||||
data.put("doneCount", toLong(raw, "doneCount"));
|
||||
data.put("urgentCount", toLong(raw, "urgentCount"));
|
||||
data.put("total", toLong(raw, "total"));
|
||||
data.put("serverTime", new SimpleDateFormat(TIME_PATTERN).format(base));
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 房间号 / 姓名 / 车次任一命中即保留;空关键字视为全命中 */
|
||||
private boolean matchesKeyword(Map<String, Object> row, String keyword)
|
||||
{
|
||||
if (keyword == null || keyword.isEmpty())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
String needle = keyword.toLowerCase(Locale.ROOT);
|
||||
return contains(row.get("roomNo"), needle)
|
||||
|| contains(row.get("personName"), needle)
|
||||
|| contains(row.get("trainNo"), needle);
|
||||
}
|
||||
|
||||
private boolean contains(Object value, String needle)
|
||||
{
|
||||
return value != null && String.valueOf(value).toLowerCase(Locale.ROOT).contains(needle);
|
||||
}
|
||||
|
||||
private long toLong(Map<String, Object> raw, String key)
|
||||
{
|
||||
if (raw == null)
|
||||
{
|
||||
return 0L;
|
||||
}
|
||||
Object value = raw.get(key);
|
||||
if (value == null)
|
||||
{
|
||||
return 0L;
|
||||
}
|
||||
if (value instanceof BigDecimal)
|
||||
{
|
||||
return ((BigDecimal) value).longValue();
|
||||
}
|
||||
if (value instanceof Number)
|
||||
{
|
||||
return ((Number) value).longValue();
|
||||
}
|
||||
return Long.parseLong(String.valueOf(value));
|
||||
}
|
||||
|
||||
private Date dayStart(Date base)
|
||||
{
|
||||
Calendar calendar = Calendar.getInstance();
|
||||
calendar.setTime(base);
|
||||
calendar.set(Calendar.HOUR_OF_DAY, 0);
|
||||
calendar.set(Calendar.MINUTE, 0);
|
||||
calendar.set(Calendar.SECOND, 0);
|
||||
calendar.set(Calendar.MILLISECOND, 0);
|
||||
return calendar.getTime();
|
||||
}
|
||||
|
||||
private Date dayEnd(Date base)
|
||||
{
|
||||
Calendar calendar = Calendar.getInstance();
|
||||
calendar.setTime(dayStart(base));
|
||||
calendar.add(Calendar.DAY_OF_MONTH, 1);
|
||||
return calendar.getTime();
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package com.ruoyi.apt.duty.service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.ruoyi.apt.duty.domain.DutyCommandRequest;
|
||||
|
||||
/**
|
||||
* 值班台命令与详情(`11` 表 11-8 `T-BE-03`/`T-BE-04`,R2)。
|
||||
*
|
||||
* <p>本层只做三件事:<b>jcid → 主键</b>、<b>权限与参数校验</b>、<b>结果整形</b>。
|
||||
* 真正的状态机、幂等、审计仍然由 {@code apt.duty.state}(`IAptDutyCommandService`)执行,
|
||||
* 值班台不得自己写 {@code gy_jcxx}。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
public interface IDutyCommandService
|
||||
{
|
||||
/** 执行一个状态命令;返回值含 {@code ryztBefore/ryztAfter/uiState},供前端只更新该行 */
|
||||
Map<String, Object> execute(String jcid, DutyCommandRequest request);
|
||||
|
||||
/** 值班台详情:叫乘行 + 会话 + 最近人工动作(不含录音/刷脸照片路径) */
|
||||
Map<String, Object> detail(String jcid);
|
||||
|
||||
/** 时间线:叫乘关键节点,按时间倒序 */
|
||||
List<Map<String, Object>> timeline(String jcid);
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package com.ruoyi.apt.duty.service;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.ruoyi.apt.duty.domain.DutyQuery;
|
||||
import com.ruoyi.apt.duty.domain.DutyTaskView;
|
||||
|
||||
/**
|
||||
* 值班台只读查询(应用端/02 图 2-1 的 view 包)。
|
||||
*
|
||||
* <p>只有两个方法,且都无副作用:列表与统计。R1 不提供详情与时间线,
|
||||
* 那是 `11` 的 `T-BE-03`,等抽屉出稿再补。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
public interface IDutyJcxxQueryService
|
||||
{
|
||||
/**
|
||||
* @param query 口径与关键字(时间窗由实现固定为"当日",客户端不可传)
|
||||
* @param now 服务端当前时间(显式传入,便于单测固定时钟)
|
||||
*/
|
||||
List<DutyTaskView> list(DutyQuery query, Date now);
|
||||
|
||||
/** 吸底统计 + 紧急条数 + serverTime(前端据此判断时钟偏差) */
|
||||
Map<String, Object> counts(Date now);
|
||||
}
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
package com.ruoyi.apt.duty.test.controller;
|
||||
|
||||
import java.util.Map;
|
||||
import com.ruoyi.apt.duty.test.domain.DutySipBindCommand;
|
||||
import com.ruoyi.apt.duty.test.domain.DutySipServerCommand;
|
||||
import com.ruoyi.apt.duty.test.domain.DutyTestCheckinCommand;
|
||||
import com.ruoyi.apt.duty.test.domain.DutyTestRoomCommand;
|
||||
import com.ruoyi.apt.duty.test.service.DutyTestService;
|
||||
import com.ruoyi.common.core.domain.AjaxResult;
|
||||
import com.ruoyi.common.utils.SecurityUtils;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 值班台测试配置(R5 / 应用端/17)。
|
||||
*
|
||||
* <p>四组能力:房间信息、房间↔分机绑定、SIP 服务器(AMI)配置、测试办理入住。
|
||||
* 所有写操作都受 {@code apt.debug.enabled} 二次保护(关闭时返回 40940),
|
||||
* 只读的 {@code /status} 始终可用,页面据此提示如何开启。</p>
|
||||
*
|
||||
* <p>权限点比接口更细:查看({@code duty:config:read})与三类写操作分开,
|
||||
* 便于把「能看配置」与「能改配置」发给不同角色。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping(value = "/duty/test", produces = "application/json;charset=UTF-8")
|
||||
public class DutyTestController
|
||||
{
|
||||
private final DutyTestService dutyTestService;
|
||||
|
||||
public DutyTestController(DutyTestService dutyTestService)
|
||||
{
|
||||
this.dutyTestService = dutyTestService;
|
||||
}
|
||||
|
||||
/** 面板状态:是否启用、AMI 配置、房间/人员概况(只读,不要求 debug 开关) */
|
||||
@PreAuthorize("@ss.hasPermi('duty:config:read')")
|
||||
@GetMapping("/status")
|
||||
public AjaxResult status()
|
||||
{
|
||||
return AjaxResult.success(dutyTestService.status());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 房间
|
||||
|
||||
@PreAuthorize("@ss.hasPermi('duty:config:read')")
|
||||
@GetMapping("/rooms")
|
||||
public AjaxResult rooms()
|
||||
{
|
||||
return AjaxResult.success(dutyTestService.rooms());
|
||||
}
|
||||
|
||||
/** 录入测试房间(补平台基线 + 登记本端房间 + 建 SIP 分机) */
|
||||
@PreAuthorize("@ss.hasPermi('duty:config:room')")
|
||||
@PostMapping("/rooms")
|
||||
public AjaxResult createRoom(@Valid @RequestBody DutyTestRoomCommand command)
|
||||
{
|
||||
return AjaxResult.success(dutyTestService.createRoom(command));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ SIP 绑定
|
||||
|
||||
@PreAuthorize("@ss.hasPermi('duty:config:read')")
|
||||
@GetMapping("/sip/extensions")
|
||||
public AjaxResult extensions()
|
||||
{
|
||||
return AjaxResult.success(dutyTestService.extensions());
|
||||
}
|
||||
|
||||
/** 绑定分机号(分机号创建后不可改,换号需先解绑) */
|
||||
@PreAuthorize("@ss.hasPermi('duty:config:sip')")
|
||||
@PostMapping("/sip/bind")
|
||||
public AjaxResult bindSip(@Valid @RequestBody DutySipBindCommand command)
|
||||
{
|
||||
return AjaxResult.success(dutyTestService.bindSip(command));
|
||||
}
|
||||
|
||||
@PreAuthorize("@ss.hasPermi('duty:config:sip')")
|
||||
@DeleteMapping("/sip/bind/{roomNo}")
|
||||
public AjaxResult unbindSip(@PathVariable("roomNo") String roomNo)
|
||||
{
|
||||
return AjaxResult.success(dutyTestService.unbindSip(roomNo));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- SIP 服务器
|
||||
|
||||
/** 读配置放宽到 read:房间页要显示号段,写与探测才需要 sipServer 权限 */
|
||||
@PreAuthorize("@ss.hasPermi('duty:config:read')")
|
||||
@GetMapping("/sip/server")
|
||||
public AjaxResult sipServer()
|
||||
{
|
||||
return AjaxResult.success(dutyTestService.sipServer());
|
||||
}
|
||||
|
||||
/** 保存 AMI 配置:口令只接受环境变量名,明文会被表单校验挡掉 */
|
||||
@PreAuthorize("@ss.hasPermi('duty:config:sipServer')")
|
||||
@PutMapping("/sip/server")
|
||||
public AjaxResult saveSipServer(@Valid @RequestBody DutySipServerCommand command)
|
||||
{
|
||||
return AjaxResult.success(dutyTestService.saveSipServer(command));
|
||||
}
|
||||
|
||||
/** 连通性探测:TCP 端口 + 口令是否已注入(不做 AMI 登录握手) */
|
||||
@PreAuthorize("@ss.hasPermi('duty:config:sipServer')")
|
||||
@PostMapping("/sip/server/probe")
|
||||
public AjaxResult probeSipServer()
|
||||
{
|
||||
return AjaxResult.success(dutyTestService.testSipServer());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 入住
|
||||
|
||||
@PreAuthorize("@ss.hasPermi('duty:checkin:test')")
|
||||
@GetMapping("/checkin/candidates")
|
||||
public AjaxResult checkinCandidates()
|
||||
{
|
||||
return AjaxResult.success(dutyTestService.checkinCandidates());
|
||||
}
|
||||
|
||||
/** 测试办理入住:apply + 自动通过(真实服务链路,不绕校验) */
|
||||
@PreAuthorize("@ss.hasPermi('duty:checkin:test')")
|
||||
@PostMapping("/checkin")
|
||||
public AjaxResult checkin(@Valid @RequestBody DutyTestCheckinCommand command)
|
||||
{
|
||||
String operator = SecurityUtils.getUsername();
|
||||
Map<String, Object> data = dutyTestService.registerCheckin(command, operator);
|
||||
return AjaxResult.success(data);
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
package com.ruoyi.apt.duty.test.domain;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
/**
|
||||
* 房间 ↔ SIP 分机绑定命令(R5 / 应用端/17)。
|
||||
*
|
||||
* <p>分机号创建后不可改({@code 40906}):换号必须先解绑再绑定,
|
||||
* 解绑的分机号不会被回收(号码不复用),界面上要如实提示。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
public class DutySipBindCommand
|
||||
{
|
||||
@NotBlank(message = "房号不能为空")
|
||||
@Size(max = 32, message = "房号长度不能超过 32")
|
||||
private String roomNo;
|
||||
|
||||
@NotBlank(message = "分机号不能为空")
|
||||
@Pattern(regexp = "^[0-9]{3,8}$", message = "分机号只能是 3~8 位数字")
|
||||
private String extension;
|
||||
|
||||
/** 话机展示名,空则沿用房间展示名 */
|
||||
@Size(max = 64, message = "展示名长度不能超过 64")
|
||||
private String displayName;
|
||||
|
||||
public String getRoomNo()
|
||||
{
|
||||
return roomNo;
|
||||
}
|
||||
|
||||
public void setRoomNo(String roomNo)
|
||||
{
|
||||
this.roomNo = roomNo;
|
||||
}
|
||||
|
||||
public String getExtension()
|
||||
{
|
||||
return extension;
|
||||
}
|
||||
|
||||
public void setExtension(String extension)
|
||||
{
|
||||
this.extension = extension;
|
||||
}
|
||||
|
||||
public String getDisplayName()
|
||||
{
|
||||
return displayName;
|
||||
}
|
||||
|
||||
public void setDisplayName(String displayName)
|
||||
{
|
||||
this.displayName = displayName;
|
||||
}
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
package com.ruoyi.apt.duty.test.domain;
|
||||
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
/**
|
||||
* SIP 服务器(AMI)配置命令(R5 / 应用端/17)。
|
||||
*
|
||||
* <p><b>口令不落库</b>(`21_ami_instance.sql` 的硬约束):{@code amiSecretRef} 只能是
|
||||
* 环境变量名,值由部署环境注入。字段用大写字母/数字/下划线约束,
|
||||
* 直接粘贴明文口令(含小写字母或符号)会被表单挡掉并给出提示。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
public class DutySipServerCommand
|
||||
{
|
||||
@NotBlank(message = "AMI 地址不能为空")
|
||||
@Pattern(regexp = "^[0-9A-Za-z.-]{1,64}$", message = "AMI 地址只能是 IP 或主机名")
|
||||
private String amiHost;
|
||||
|
||||
@NotNull(message = "AMI 端口不能为空")
|
||||
@Min(value = 1, message = "端口范围 1~65535")
|
||||
@Max(value = 65535, message = "端口范围 1~65535")
|
||||
private Integer amiPort;
|
||||
|
||||
@NotBlank(message = "AMI 用户名不能为空")
|
||||
@Size(max = 64, message = "AMI 用户名长度不能超过 64")
|
||||
private String amiUser;
|
||||
|
||||
/** 口令所在环境变量名,例如 APT_AMI_SECRET(不存口令本身) */
|
||||
@NotBlank(message = "口令引用(环境变量名)不能为空")
|
||||
@Pattern(regexp = "^[A-Z][A-Z0-9_]{2,63}$",
|
||||
message = "口令引用必须是大写字母/数字/下划线组成的环境变量名,口令本身不落库")
|
||||
private String amiSecretRef;
|
||||
|
||||
/** 可用分机号段起,空=不限制 */
|
||||
@Pattern(regexp = "^$|^[0-9]{3,8}$", message = "号段起只能是 3~8 位数字")
|
||||
private String extPoolFrom;
|
||||
|
||||
/** 可用分机号段止,空=不限制 */
|
||||
@Pattern(regexp = "^$|^[0-9]{3,8}$", message = "号段止只能是 3~8 位数字")
|
||||
private String extPoolTo;
|
||||
|
||||
/** 是否启用 AMI 连接(仅本地测试可关) */
|
||||
private Boolean enabled = Boolean.TRUE;
|
||||
|
||||
public String getAmiHost()
|
||||
{
|
||||
return amiHost;
|
||||
}
|
||||
|
||||
public void setAmiHost(String amiHost)
|
||||
{
|
||||
this.amiHost = amiHost;
|
||||
}
|
||||
|
||||
public Integer getAmiPort()
|
||||
{
|
||||
return amiPort;
|
||||
}
|
||||
|
||||
public void setAmiPort(Integer amiPort)
|
||||
{
|
||||
this.amiPort = amiPort;
|
||||
}
|
||||
|
||||
public String getAmiUser()
|
||||
{
|
||||
return amiUser;
|
||||
}
|
||||
|
||||
public void setAmiUser(String amiUser)
|
||||
{
|
||||
this.amiUser = amiUser;
|
||||
}
|
||||
|
||||
public String getAmiSecretRef()
|
||||
{
|
||||
return amiSecretRef;
|
||||
}
|
||||
|
||||
public void setAmiSecretRef(String amiSecretRef)
|
||||
{
|
||||
this.amiSecretRef = amiSecretRef;
|
||||
}
|
||||
|
||||
public String getExtPoolFrom()
|
||||
{
|
||||
return extPoolFrom;
|
||||
}
|
||||
|
||||
public void setExtPoolFrom(String extPoolFrom)
|
||||
{
|
||||
this.extPoolFrom = extPoolFrom;
|
||||
}
|
||||
|
||||
public String getExtPoolTo()
|
||||
{
|
||||
return extPoolTo;
|
||||
}
|
||||
|
||||
public void setExtPoolTo(String extPoolTo)
|
||||
{
|
||||
this.extPoolTo = extPoolTo;
|
||||
}
|
||||
|
||||
public Boolean getEnabled()
|
||||
{
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(Boolean enabled)
|
||||
{
|
||||
this.enabled = enabled;
|
||||
}
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
package com.ruoyi.apt.duty.test.domain;
|
||||
|
||||
import java.util.Date;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
/**
|
||||
* 测试办理入住命令(R5 / 应用端/17)。
|
||||
*
|
||||
* <p>不是 N-40 的旁路:服务端仍然走 {@code IAptCheckinService.apply + review},
|
||||
* 人脸注册确认、房间可住、同一人不得重复在住等校验一条不减。
|
||||
* 这里只是把「设备编码 + 核验时刻 + 幂等键」由服务端补出,让值班员能手工造一条入住。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
public class DutyTestCheckinCommand
|
||||
{
|
||||
/** 系统内人员(apt_master_person.ryid) */
|
||||
@NotBlank(message = "人员不能为空")
|
||||
@Size(max = 64, message = "ryid 长度不能超过 64")
|
||||
private String ryid;
|
||||
|
||||
/** 房号(必须已登记且可住) */
|
||||
@NotBlank(message = "房号不能为空")
|
||||
@Size(max = 32, message = "房号长度不能超过 32")
|
||||
private String fjh;
|
||||
|
||||
/** 交路 id,可空 */
|
||||
@Size(max = 64, message = "jlid 长度不能超过 64")
|
||||
private String jlid;
|
||||
|
||||
/** 车次,可空 */
|
||||
@Size(max = 64, message = "车次长度不能超过 64")
|
||||
private String cc;
|
||||
|
||||
/** 机次,可空 */
|
||||
@Size(max = 32, message = "机次长度不能超过 32")
|
||||
private String jch;
|
||||
|
||||
/**
|
||||
* 叫乘时刻;填了就写入 gy_jcxx,让到点扫描能立刻跑通整条链路。
|
||||
*
|
||||
* <p>线上该值由 #7 匹配结论/叫乘规则算出,测试时允许手工指定——
|
||||
* 只写 jcsj/jcfs 两列,不碰任何状态列,不属于绕过状态机。</p>
|
||||
*/
|
||||
private Date jcsj;
|
||||
|
||||
/** 叫乘方式 SIP / TABLET,空则由房间 SIP 绑定情况推导 */
|
||||
@Size(max = 16, message = "叫乘方式长度不能超过 16")
|
||||
private String jcfs;
|
||||
|
||||
public String getRyid()
|
||||
{
|
||||
return ryid;
|
||||
}
|
||||
|
||||
public void setRyid(String ryid)
|
||||
{
|
||||
this.ryid = ryid;
|
||||
}
|
||||
|
||||
public String getFjh()
|
||||
{
|
||||
return fjh;
|
||||
}
|
||||
|
||||
public void setFjh(String fjh)
|
||||
{
|
||||
this.fjh = fjh;
|
||||
}
|
||||
|
||||
public String getJlid()
|
||||
{
|
||||
return jlid;
|
||||
}
|
||||
|
||||
public void setJlid(String jlid)
|
||||
{
|
||||
this.jlid = jlid;
|
||||
}
|
||||
|
||||
public String getCc()
|
||||
{
|
||||
return cc;
|
||||
}
|
||||
|
||||
public void setCc(String cc)
|
||||
{
|
||||
this.cc = cc;
|
||||
}
|
||||
|
||||
public String getJch()
|
||||
{
|
||||
return jch;
|
||||
}
|
||||
|
||||
public void setJch(String jch)
|
||||
{
|
||||
this.jch = jch;
|
||||
}
|
||||
|
||||
public Date getJcsj()
|
||||
{
|
||||
return jcsj;
|
||||
}
|
||||
|
||||
public void setJcsj(Date jcsj)
|
||||
{
|
||||
this.jcsj = jcsj;
|
||||
}
|
||||
|
||||
public String getJcfs()
|
||||
{
|
||||
return jcfs;
|
||||
}
|
||||
|
||||
public void setJcfs(String jcfs)
|
||||
{
|
||||
this.jcfs = jcfs;
|
||||
}
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
package com.ruoyi.apt.duty.test.domain;
|
||||
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
/**
|
||||
* 测试房间录入命令(R5 / 应用端/17)。
|
||||
*
|
||||
* <p>只服务本地联调:先在平台基线 {@code apt_master_room} 补一条 ACTIVE 房间,
|
||||
* 再走正式的服务链路 {@code IAptRoomService.create} 落 {@code gy_rooms} 与 SIP 三件套。
|
||||
* 生产环境该接口被 {@code apt.debug.enabled} 关闭(返回 40940)。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
public class DutyTestRoomCommand
|
||||
{
|
||||
/** 房号,唯一;3~32 位数字/字母/短横线 */
|
||||
@NotBlank(message = "房号不能为空")
|
||||
@Pattern(regexp = "^[0-9A-Za-z-]{1,32}$", message = "房号只能包含数字、字母与短横线,最长 32 位")
|
||||
private String roomNo;
|
||||
|
||||
/** 展示名(同时作为 SIP 分机 display_name),空则用「房间 + 房号」 */
|
||||
@Size(max = 64, message = "展示名长度不能超过 64")
|
||||
private String displayName;
|
||||
|
||||
/** 楼栋 */
|
||||
@Size(max = 16, message = "楼栋长度不能超过 16")
|
||||
private String building;
|
||||
|
||||
/** 楼层 */
|
||||
@Size(max = 16, message = "楼层长度不能超过 16")
|
||||
private String roomFloor;
|
||||
|
||||
/** 房型(单人/双人…,仅展示) */
|
||||
@Size(max = 16, message = "房型长度不能超过 16")
|
||||
private String roomType;
|
||||
|
||||
/** 床位数:入住可用名额的上限,1~20 */
|
||||
@Min(value = 1, message = "床位数至少 1")
|
||||
@Max(value = 20, message = "床位数最多 20")
|
||||
private Integer bedCount = 1;
|
||||
|
||||
/** 分机号,可空(空则按房号派生);必须在号段内且未被占用 */
|
||||
@Pattern(regexp = "^$|^[0-9]{3,8}$", message = "分机号只能是 3~8 位数字")
|
||||
private String extension;
|
||||
|
||||
public String getRoomNo()
|
||||
{
|
||||
return roomNo;
|
||||
}
|
||||
|
||||
public void setRoomNo(String roomNo)
|
||||
{
|
||||
this.roomNo = roomNo;
|
||||
}
|
||||
|
||||
public String getDisplayName()
|
||||
{
|
||||
return displayName;
|
||||
}
|
||||
|
||||
public void setDisplayName(String displayName)
|
||||
{
|
||||
this.displayName = displayName;
|
||||
}
|
||||
|
||||
public String getBuilding()
|
||||
{
|
||||
return building;
|
||||
}
|
||||
|
||||
public void setBuilding(String building)
|
||||
{
|
||||
this.building = building;
|
||||
}
|
||||
|
||||
public String getRoomFloor()
|
||||
{
|
||||
return roomFloor;
|
||||
}
|
||||
|
||||
public void setRoomFloor(String roomFloor)
|
||||
{
|
||||
this.roomFloor = roomFloor;
|
||||
}
|
||||
|
||||
public String getRoomType()
|
||||
{
|
||||
return roomType;
|
||||
}
|
||||
|
||||
public void setRoomType(String roomType)
|
||||
{
|
||||
this.roomType = roomType;
|
||||
}
|
||||
|
||||
public Integer getBedCount()
|
||||
{
|
||||
return bedCount;
|
||||
}
|
||||
|
||||
public void setBedCount(Integer bedCount)
|
||||
{
|
||||
this.bedCount = bedCount;
|
||||
}
|
||||
|
||||
public String getExtension()
|
||||
{
|
||||
return extension;
|
||||
}
|
||||
|
||||
public void setExtension(String extension)
|
||||
{
|
||||
this.extension = extension;
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package com.ruoyi.apt.duty.test.mapper;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
/**
|
||||
* 值班台测试配置 Mapper(R5 / 应用端/17)。
|
||||
*
|
||||
* <p>只读写三类配置面:AMI 实例(`apt_ast_instance`)、房间基线(`apt_master_room` + `gy_rooms`)、
|
||||
* SIP 分机(`apt_sip_endpoint` / `apt_sip_binding` / `apt_sip_auth`)。
|
||||
* 业务状态(`gy_jcxx.ryzt` 等)一律不在这里写,状态机仍只有 `AptDutyStateMapper`。</p>
|
||||
*
|
||||
* <p>所有语句都带 gyid(`AptMapperGyidArchTest` 强制),不接受跨公寓参数。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
@Mapper
|
||||
public interface DutyTestMapper
|
||||
{
|
||||
/** AMI 实例(一公寓一条;口令只回引用名) */
|
||||
Map<String, Object> selectSipServer(@Param("gyid") String gyid);
|
||||
|
||||
/** 更新 AMI 配置;改了地址/端口则状态复位为 DISABLED(需重连才恢复 UP) */
|
||||
int updateSipServer(@Param("gyid") String gyid, @Param("amiHost") String amiHost,
|
||||
@Param("amiPort") Integer amiPort, @Param("amiUser") String amiUser,
|
||||
@Param("amiSecretRef") String amiSecretRef,
|
||||
@Param("extPoolFrom") String extPoolFrom, @Param("extPoolTo") String extPoolTo,
|
||||
@Param("enabled") boolean enabled);
|
||||
|
||||
/** 房间看板:平台基线 + 本端登记 + 当前分机 + 在住人数 */
|
||||
List<Map<String, Object>> selectRoomBoard(@Param("gyid") String gyid);
|
||||
|
||||
/** 按房号取平台基线房间 */
|
||||
Map<String, Object> selectMasterRoomByNo(@Param("gyid") String gyid, @Param("roomNo") String roomNo);
|
||||
|
||||
/** 补一条测试用平台房间基线(仅本地联调) */
|
||||
int insertMasterRoom(@Param("gyid") String gyid, @Param("roomId") String roomId,
|
||||
@Param("roomNo") String roomNo, @Param("building") String building,
|
||||
@Param("roomFloor") String roomFloor, @Param("roomType") String roomType,
|
||||
@Param("bedCount") int bedCount);
|
||||
|
||||
/** 是否已登记到本端 gy_rooms */
|
||||
int countLocalRoomByNo(@Param("gyid") String gyid, @Param("roomNo") String roomNo);
|
||||
|
||||
/** 分机占用看板(含未绑定房间的孤儿分机) */
|
||||
List<Map<String, Object>> selectExtensionUsage(@Param("gyid") String gyid);
|
||||
|
||||
/** 分机号是否已被占用(含软删前的历史占用,号码不复用) */
|
||||
int countExtensionInUse(@Param("gyid") String gyid, @Param("extension") String extension);
|
||||
|
||||
/** 可办理入住的人员:系统内人员 + 人脸注册确认态 + 是否在住 */
|
||||
List<Map<String, Object>> selectCheckinCandidates(@Param("gyid") String gyid);
|
||||
}
|
||||
+695
@@ -0,0 +1,695 @@
|
||||
package com.ruoyi.apt.duty.test.service;
|
||||
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.Socket;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import com.ruoyi.apt.common.AptContext;
|
||||
import com.ruoyi.apt.common.AptErrorCode;
|
||||
import com.ruoyi.apt.common.AptException;
|
||||
import com.ruoyi.apt.duty.test.domain.DutySipBindCommand;
|
||||
import com.ruoyi.apt.duty.test.domain.DutySipServerCommand;
|
||||
import com.ruoyi.apt.duty.test.domain.DutyTestCheckinCommand;
|
||||
import com.ruoyi.apt.duty.test.domain.DutyTestRoomCommand;
|
||||
import com.ruoyi.apt.duty.test.mapper.DutyTestMapper;
|
||||
import com.ruoyi.apt.stay.checkin.domain.AptCheckinApplication;
|
||||
import com.ruoyi.apt.stay.checkin.domain.AptCheckinApplyCommand;
|
||||
import com.ruoyi.apt.stay.checkin.domain.AptCheckinApplyResult;
|
||||
import com.ruoyi.apt.stay.checkin.domain.AptCheckinReviewCommand;
|
||||
import com.ruoyi.apt.stay.checkin.domain.AptCheckinReviewResult;
|
||||
import com.ruoyi.apt.stay.checkin.mapper.AptCheckinMapper;
|
||||
import com.ruoyi.apt.stay.checkin.service.IAptCheckinService;
|
||||
import com.ruoyi.apt.stay.domain.AptRoomCreateCommand;
|
||||
import com.ruoyi.apt.stay.service.IAptRoomService;
|
||||
import com.ruoyi.apt.voice.service.IAptSipEndpointService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* 值班台测试配置服务(R5 / 应用端/17)。
|
||||
*
|
||||
* <p><b>定位</b>:让值班员在没有平板、没有平台推数据的环境里,把「房间 → 分机 → 入住 → 叫乘」
|
||||
* 这条链路自己铺出来。写作范围严格限制在两处:</p>
|
||||
* <ul>
|
||||
* <li>配置面:{@code apt_ast_instance} / {@code apt_master_room} / {@code gy_rooms} / SIP 三张表;</li>
|
||||
* <li>入住:<b>不绕服务层</b>,仍调用 {@code IAptCheckinService.apply + review},
|
||||
* 人脸注册确认、房间可住、同一人不得重复在住等校验一条不减。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p><b>总开关</b>:{@code apt.debug.enabled}(默认 false)。关闭时所有写操作返回 40940,
|
||||
* 只读的 {@link #status()} 仍可用——页面据此提示「如何开启」,而不是给一个 404。</p>
|
||||
*
|
||||
* <p><b>口令纪律</b>:AMI 口令只走环境变量引用,不落库、不回显;SIP 分机口令由
|
||||
* {@code IAptSipEndpointService} 按既有 {@code secret_mode} 规则下发。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
@Service
|
||||
public class DutyTestService
|
||||
{
|
||||
private static final Logger log = LoggerFactory.getLogger(DutyTestService.class);
|
||||
|
||||
/** 测试入住使用的设备编码:与真实平板编码区分,便于审计排查 */
|
||||
private static final String TEST_DEVICE_CODE = "DUTY-TEST";
|
||||
|
||||
private static final int PROBE_TIMEOUT_MS = 2000;
|
||||
|
||||
private final DutyTestMapper testMapper;
|
||||
|
||||
private final IAptRoomService roomService;
|
||||
|
||||
private final IAptSipEndpointService sipService;
|
||||
|
||||
private final IAptCheckinService checkinService;
|
||||
|
||||
private final AptCheckinMapper checkinMapper;
|
||||
|
||||
@Value("${apt.debug.enabled:false}")
|
||||
private boolean debugEnabled;
|
||||
|
||||
@Value("${apt.voice.enabled:false}")
|
||||
private boolean voiceEnabled;
|
||||
|
||||
public DutyTestService(DutyTestMapper testMapper, IAptRoomService roomService,
|
||||
IAptSipEndpointService sipService, IAptCheckinService checkinService,
|
||||
AptCheckinMapper checkinMapper)
|
||||
{
|
||||
this.testMapper = testMapper;
|
||||
this.roomService = roomService;
|
||||
this.sipService = sipService;
|
||||
this.checkinService = checkinService;
|
||||
this.checkinMapper = checkinMapper;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 状态
|
||||
|
||||
/**
|
||||
* 面板状态:是否可用、当前配置、房间/分机概况。
|
||||
*
|
||||
* <p>只读,不要求 {@code apt.debug.enabled}——关闭时页面要能给出开启方法。</p>
|
||||
*/
|
||||
public Map<String, Object> status()
|
||||
{
|
||||
String gyid = gyid();
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("debugEnabled", debugEnabled);
|
||||
data.put("voiceEnabled", voiceEnabled);
|
||||
data.put("gyid", gyid);
|
||||
|
||||
Map<String, Object> server = sipServer();
|
||||
data.put("sipServer", server);
|
||||
|
||||
List<Map<String, Object>> rooms = testMapper.selectRoomBoard(gyid);
|
||||
int bound = 0;
|
||||
int registered = 0;
|
||||
for (Map<String, Object> row : rooms)
|
||||
{
|
||||
if (row.get("extension") != null)
|
||||
{
|
||||
bound++;
|
||||
}
|
||||
if (row.get("localId") != null)
|
||||
{
|
||||
registered++;
|
||||
}
|
||||
}
|
||||
data.put("roomCount", rooms.size());
|
||||
data.put("registeredRoomCount", registered);
|
||||
data.put("boundRoomCount", bound);
|
||||
data.put("personCount", testMapper.selectCheckinCandidates(gyid).size());
|
||||
return data;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 房间
|
||||
|
||||
/** 房间看板:平台基线 + 本端登记 + 分机 + 在住人数 */
|
||||
public List<Map<String, Object>> rooms()
|
||||
{
|
||||
return testMapper.selectRoomBoard(gyid());
|
||||
}
|
||||
|
||||
/**
|
||||
* 录入测试房间:先补平台基线({@code apt_master_room}),再走正式的服务链路登记本端房间。
|
||||
*
|
||||
* <p>已登记的房间重复提交是幂等的:只同步展示名,不会重建房间或改分机号。</p>
|
||||
*/
|
||||
public Map<String, Object> createRoom(DutyTestRoomCommand command)
|
||||
{
|
||||
requireDebug();
|
||||
String gyid = gyid();
|
||||
String roomNo = command.getRoomNo().trim();
|
||||
int bedCount = command.getBedCount() == null ? 1 : command.getBedCount();
|
||||
// 留空则从号段里取最小空闲号:按房号派生会得到「房间 201 → 分机 201」,落在号段外
|
||||
String extension = trimToNull(command.getExtension());
|
||||
if (extension == null)
|
||||
{
|
||||
extension = nextFreeExtension();
|
||||
}
|
||||
|
||||
Map<String, Object> master = testMapper.selectMasterRoomByNo(gyid, roomNo);
|
||||
boolean baselineCreated = false;
|
||||
if (master == null)
|
||||
{
|
||||
// 分机号先校验再落库,避免「房间建好了但号不能用」的半成品
|
||||
validateExtension(extension, roomNo);
|
||||
String roomId = "TEST-" + roomNo;
|
||||
testMapper.insertMasterRoom(gyid, roomId, roomNo, trimToNull(command.getBuilding()),
|
||||
trimToNull(command.getRoomFloor()), trimToNull(command.getRoomType()), bedCount);
|
||||
master = testMapper.selectMasterRoomByNo(gyid, roomNo);
|
||||
baselineCreated = true;
|
||||
}
|
||||
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("roomNo", roomNo);
|
||||
data.put("roomId", master.get("roomId"));
|
||||
data.put("baselineCreated", baselineCreated);
|
||||
|
||||
boolean localRegistered = testMapper.countLocalRoomByNo(gyid, roomNo) > 0;
|
||||
if (!localRegistered)
|
||||
{
|
||||
validateExtension(extension, roomNo);
|
||||
AptRoomCreateCommand create = new AptRoomCreateCommand();
|
||||
create.setRoomNo(roomNo);
|
||||
create.setDisplayName(trimToNull(command.getDisplayName()));
|
||||
create.setExtension(extension);
|
||||
roomService.create(create);
|
||||
}
|
||||
else if (trimToNull(command.getDisplayName()) != null)
|
||||
{
|
||||
// 分机号不可改:只同步展示名与启用态
|
||||
sipService.updateForRoom(String.valueOf(master.get("roomId")),
|
||||
command.getDisplayName().trim(), true);
|
||||
}
|
||||
data.put("localRegistered", true);
|
||||
data.put("reused", localRegistered);
|
||||
if (!localRegistered)
|
||||
{
|
||||
data.put("message", "房间已登记并创建 SIP 分机 " + (extension == null ? "" : extension));
|
||||
}
|
||||
else if (trimToNull(command.getDisplayName()) != null)
|
||||
{
|
||||
data.put("message", "房间已登记,已同步展示名");
|
||||
}
|
||||
else
|
||||
{
|
||||
data.put("message", "房间已登记,本次未做变更");
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 分机占用看板(含未绑定房间的孤儿分机,便于排查历史遗留号码) */
|
||||
public List<Map<String, Object>> extensions()
|
||||
{
|
||||
return testMapper.selectExtensionUsage(gyid());
|
||||
}
|
||||
|
||||
/**
|
||||
* 房间 ↔ 分机绑定。
|
||||
*
|
||||
* <p>分机号创建后不可改(40906):请求号与现号不一致时直接报错并提示先解绑,
|
||||
* 而不是悄悄忽略——值班员改号改不动却以为改成功了,是最难查的一类问题。</p>
|
||||
*/
|
||||
public Map<String, Object> bindSip(DutySipBindCommand command)
|
||||
{
|
||||
requireDebug();
|
||||
String gyid = gyid();
|
||||
String roomNo = command.getRoomNo().trim();
|
||||
String extension = command.getExtension().trim();
|
||||
|
||||
Map<String, Object> current = findRoom(gyid, roomNo);
|
||||
if (current == null)
|
||||
{
|
||||
throw new AptException(AptErrorCode.NOT_FOUND, "ROOM_NOT_FOUND",
|
||||
"房号 " + roomNo + " 不存在,请先在「房间信息」录入");
|
||||
}
|
||||
if (current.get("localId") == null)
|
||||
{
|
||||
throw new AptException(AptErrorCode.STATE_ILLEGAL, "ROOM_NOT_REGISTERED",
|
||||
"房号 " + roomNo + " 尚未登记到本端,请先录入房间");
|
||||
}
|
||||
Object existing = current.get("extension");
|
||||
if (existing != null && !extension.equals(String.valueOf(existing)))
|
||||
{
|
||||
throw new AptException(AptErrorCode.EXTENSION_IMMUTABLE, "EXTENSION_IMMUTABLE",
|
||||
"房间 " + roomNo + " 已有分机 " + existing + ",分机号创建后不可改;换号请先解绑");
|
||||
}
|
||||
if (existing == null)
|
||||
{
|
||||
// 只对「新绑定」校验占用;重复提交同一个号是幂等同步,不能判成冲突
|
||||
validateExtension(extension, roomNo);
|
||||
}
|
||||
|
||||
String displayName = trimToNull(command.getDisplayName());
|
||||
if (displayName == null)
|
||||
{
|
||||
displayName = current.get("displayName") == null
|
||||
? "房间 " + roomNo : String.valueOf(current.get("displayName"));
|
||||
}
|
||||
Map<String, Object> result = sipService.ensureForRoom(String.valueOf(current.get("roomId")),
|
||||
roomNo, displayName, extension);
|
||||
|
||||
Map<String, Object> data = new LinkedHashMap<>(result);
|
||||
data.put("roomNo", roomNo);
|
||||
data.put("extension", extension);
|
||||
data.put("message", "分机 " + extension + " 已绑定到房间 " + roomNo);
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 解绑:关闭绑定并软删分机;号段里的号码不会回收(号码不复用) */
|
||||
public Map<String, Object> unbindSip(String roomNo)
|
||||
{
|
||||
requireDebug();
|
||||
String gyid = gyid();
|
||||
Map<String, Object> current = findRoom(gyid, roomNo);
|
||||
if (current == null || current.get("localId") == null)
|
||||
{
|
||||
throw new AptException(AptErrorCode.NOT_FOUND, "ROOM_NOT_REGISTERED",
|
||||
"房号 " + roomNo + " 未登记,无需解绑");
|
||||
}
|
||||
Object extension = current.get("extension");
|
||||
sipService.removeForRoom(String.valueOf(current.get("roomId")));
|
||||
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("roomNo", roomNo);
|
||||
data.put("releasedExtension", extension == null ? null : String.valueOf(extension));
|
||||
data.put("message", "已解绑;分机号不会被回收,再次绑定请使用号段内的新号码");
|
||||
return data;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ SIP 服务器
|
||||
|
||||
/** 当前 AMI 配置(口令只回引用名与「是否已注入」) */
|
||||
public Map<String, Object> sipServer()
|
||||
{
|
||||
String gyid = gyid();
|
||||
Map<String, Object> row = testMapper.selectSipServer(gyid);
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
if (row == null)
|
||||
{
|
||||
data.put("configured", false);
|
||||
data.put("message", "未找到 AMI 实例配置,请先保存一次");
|
||||
return data;
|
||||
}
|
||||
data.put("configured", true);
|
||||
data.put("amiHost", row.get("amiHost"));
|
||||
data.put("amiPort", row.get("amiPort"));
|
||||
data.put("amiUser", row.get("amiUser"));
|
||||
data.put("amiSecretRef", row.get("amiSecretRef"));
|
||||
data.put("extPoolFrom", row.get("extPoolFrom"));
|
||||
data.put("extPoolTo", row.get("extPoolTo"));
|
||||
data.put("enabled", row.get("enabled"));
|
||||
data.put("state", row.get("state"));
|
||||
data.put("lastError", row.get("lastError"));
|
||||
data.put("lastConnectedAt", row.get("lastConnectedAt"));
|
||||
|
||||
String ref = trimToNull(row.get("amiSecretRef") == null ? null : String.valueOf(row.get("amiSecretRef")));
|
||||
boolean secretReady = ref != null && System.getenv(ref) != null;
|
||||
data.put("secretReady", secretReady);
|
||||
if (ref != null && !secretReady)
|
||||
{
|
||||
data.put("secretHint", "环境变量 " + ref + " 未注入,AMI 无法登录;请在运行环境设置后重启");
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 保存 AMI 配置;改完状态复位为 DISABLED,等重连结果 */
|
||||
public Map<String, Object> saveSipServer(DutySipServerCommand command)
|
||||
{
|
||||
requireDebug();
|
||||
String from = trimToNull(command.getExtPoolFrom());
|
||||
String to = trimToNull(command.getExtPoolTo());
|
||||
if ((from == null) != (to == null))
|
||||
{
|
||||
throw new AptException(AptErrorCode.BAD_REQUEST, "EXT_POOL_INCOMPLETE",
|
||||
"号段起止必须同时填写,或同时留空(留空=不限制)");
|
||||
}
|
||||
if (from != null && from.compareTo(to) > 0)
|
||||
{
|
||||
throw new AptException(AptErrorCode.BAD_REQUEST, "EXT_POOL_INVALID",
|
||||
"号段起始 " + from + " 不能大于结束 " + to);
|
||||
}
|
||||
int rows = testMapper.updateSipServer(gyid(), command.getAmiHost().trim(), command.getAmiPort(),
|
||||
command.getAmiUser().trim(), command.getAmiSecretRef().trim(), from, to,
|
||||
command.getEnabled() == null || command.getEnabled());
|
||||
if (rows == 0)
|
||||
{
|
||||
throw new AptException(AptErrorCode.NOT_FOUND, "AMI_INSTANCE_MISSING",
|
||||
"未找到 AMI 实例配置行,请先执行 34_duty_test_seed.sql 落一条");
|
||||
}
|
||||
Map<String, Object> data = sipServer();
|
||||
data.put("message", "已保存;AMI 状态复位为 DISABLED,重连成功后自动转 UP");
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 连通性探测:只做 TCP 端口可达性 + 口令是否已注入。
|
||||
*
|
||||
* <p>不做 AMI 登录握手——握手要占用真实连接且依赖 voice 开关,
|
||||
* 这里要回答的是「网络通不通、口令有没有」,所以宁可说小一点:
|
||||
* {@code reachable=true} 不等于 AMI 认证成功。</p>
|
||||
*/
|
||||
public Map<String, Object> testSipServer()
|
||||
{
|
||||
requireDebug();
|
||||
Map<String, Object> server = sipServer();
|
||||
Map<String, Object> data = new LinkedHashMap<>(server);
|
||||
if (!Boolean.TRUE.equals(server.get("configured")))
|
||||
{
|
||||
throw new AptException(AptErrorCode.NOT_FOUND, "AMI_INSTANCE_MISSING", "未找到 AMI 实例配置");
|
||||
}
|
||||
String host = String.valueOf(server.get("amiHost"));
|
||||
Object portValue = server.get("amiPort");
|
||||
int port = portValue == null ? 5038 : Integer.parseInt(String.valueOf(portValue));
|
||||
|
||||
long started = System.currentTimeMillis();
|
||||
String error = null;
|
||||
boolean reachable = false;
|
||||
try (Socket socket = new Socket())
|
||||
{
|
||||
socket.connect(new InetSocketAddress(host, port), PROBE_TIMEOUT_MS);
|
||||
reachable = true;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
error = e.getClass().getSimpleName() + ": " + e.getMessage();
|
||||
}
|
||||
long cost = System.currentTimeMillis() - started;
|
||||
|
||||
data.put("reachable", reachable);
|
||||
data.put("latencyMs", cost);
|
||||
data.put("probeError", error);
|
||||
data.put("probeNote", reachable
|
||||
? "端口可达(仅 TCP 探测,未做 AMI 登录握手)"
|
||||
: "端口不可达:检查地址/端口/防火墙,或确认 Asterisk 已启动");
|
||||
data.put("message", reachable ? "AMI 端口可达" : "AMI 端口不可达");
|
||||
log.info("值班台测试配置:AMI 连通性探测 host={}, port={}, reachable={}, cost={}ms", host, port,
|
||||
reachable, cost);
|
||||
return data;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 入住
|
||||
|
||||
/** 可入住人员 + 可住房间,供页面下拉 */
|
||||
public Map<String, Object> checkinCandidates()
|
||||
{
|
||||
String gyid = gyid();
|
||||
List<Map<String, Object>> persons = testMapper.selectCheckinCandidates(gyid);
|
||||
List<Map<String, Object>> rooms = new ArrayList<>();
|
||||
for (Map<String, Object> room : testMapper.selectRoomBoard(gyid))
|
||||
{
|
||||
if (room.get("localId") == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// 与 apply 的可用性判定保持一致:房态 4/6(停用/维修)即使有空床也不可住,
|
||||
// 不然下拉里能选、提交后必然 40928
|
||||
String roomStatus = room.get("roomStatus") == null ? "1"
|
||||
: String.valueOf(room.get("roomStatus"));
|
||||
if ("4".equals(roomStatus) || "6".equals(roomStatus))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
int bedCount = toInt(room.get("bedCount"), 0);
|
||||
int inPerson = toInt(room.get("inPersonCount"), 0);
|
||||
if (bedCount > 0 && inPerson >= bedCount)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Map<String, Object> item = new LinkedHashMap<>();
|
||||
item.put("roomNo", room.get("roomNo"));
|
||||
item.put("displayName", room.get("displayName") == null
|
||||
? "房间 " + room.get("roomNo") : room.get("displayName"));
|
||||
item.put("bedCount", bedCount);
|
||||
item.put("inPersonCount", inPerson);
|
||||
item.put("freeBeds", Math.max(bedCount - inPerson, 0));
|
||||
item.put("extension", room.get("extension"));
|
||||
rooms.add(item);
|
||||
}
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("persons", persons);
|
||||
data.put("rooms", rooms);
|
||||
data.put("voiceEnabled", voiceEnabled);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试办理入住:apply + 自动通过 + 可选写入叫乘时刻。
|
||||
*
|
||||
* <p>为什么不是直接 INSERT `gy_jcxx`:入住要同时动人员主档、房间床位、住房事件、
|
||||
* 房态与上报队列,任何一步漏掉都会让后续叫乘/退宿对不上账。
|
||||
* 复用 {@code apply + review} 才能保证「测出来的链路」就是「线上的链路」。</p>
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Map<String, Object> registerCheckin(DutyTestCheckinCommand command, String operatorRef)
|
||||
{
|
||||
requireDebug();
|
||||
String gyid = gyid();
|
||||
String ryid = command.getRyid().trim();
|
||||
String fjh = command.getFjh().trim();
|
||||
|
||||
Map<String, Object> person = null;
|
||||
for (Map<String, Object> row : testMapper.selectCheckinCandidates(gyid))
|
||||
{
|
||||
if (ryid.equals(String.valueOf(row.get("ryid"))))
|
||||
{
|
||||
person = row;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (person == null)
|
||||
{
|
||||
throw new AptException(AptErrorCode.NOT_FOUND, "PERSON_NOT_FOUND",
|
||||
"人员 " + ryid + " 不在系统内人员台账,请先完成人员注册");
|
||||
}
|
||||
if (toInt(person.get("confirmedReg"), 0) == 0)
|
||||
{
|
||||
throw new AptException(AptErrorCode.FACE_REG_PENDING, "FACE_REG_NOT_CONFIRMED",
|
||||
"该人员人脸注册未获确认,入住申请会被拒绝;请先在人员注册页确认,或执行 34_duty_test_seed.sql 补前置台账");
|
||||
}
|
||||
if (toInt(person.get("activeStay"), 0) > 0)
|
||||
{
|
||||
throw new AptException(AptErrorCode.DUPLICATE_STAY, "DUPLICATE_STAY",
|
||||
"该人员已有在住记录,请先办理离寓再测试");
|
||||
}
|
||||
|
||||
Date now = new Date();
|
||||
AptCheckinApplyCommand apply = new AptCheckinApplyCommand();
|
||||
apply.setDeviceEventId("DUTY-TEST-" + UUID.randomUUID().toString().replace("-", ""));
|
||||
apply.setRyid(ryid);
|
||||
apply.setFjh(fjh);
|
||||
apply.setJlid(trimToNull(command.getJlid()));
|
||||
apply.setCc(trimToNull(command.getCc()));
|
||||
apply.setJch(trimToNull(command.getJch()));
|
||||
apply.setSource("DUTY");
|
||||
apply.setOccurredAt(now);
|
||||
apply.setVerifiedAt(now);
|
||||
apply.setRemark("值班台测试入住");
|
||||
|
||||
AptCheckinApplyResult applied = checkinService.apply(apply, TEST_DEVICE_CODE);
|
||||
|
||||
// N-43 接单:apply 只落到 SUBMITTED,review 只接受 REVIEWING/APPROVED/ROOM_ADJUSTED。
|
||||
// 值班台真实操作是"点开详情"完成接单,这里替测试人员走这一步,否则必然 40926。
|
||||
AptCheckinApplication claimed = checkinService.dutyDetail(applied.getApplyId(), operatorRef);
|
||||
|
||||
AptCheckinReviewCommand review = new AptCheckinReviewCommand();
|
||||
review.setVerdict("APPROVE");
|
||||
// 接单后 biz_version 已 +1,必须用接单后的版本做乐观锁
|
||||
review.setExpectedBizVersion(claimed.getBizVersion());
|
||||
review.setCommandId("RVT-" + UUID.randomUUID().toString().replace("-", ""));
|
||||
review.setReviewNote("值班台测试入住自动通过");
|
||||
AptCheckinReviewResult reviewed = checkinService.review(applied.getApplyId(), review, operatorRef);
|
||||
if (!reviewed.isCheckedIn())
|
||||
{
|
||||
throw new AptException(AptErrorCode.DATA_ANOMALY, "CHECKIN_NOT_CREATED",
|
||||
"审核已通过但未生成入住记录,请查看入住单 " + applied.getApplyId() + " 的状态");
|
||||
}
|
||||
|
||||
List<String> warnings = new ArrayList<>(reviewed.getWarnings());
|
||||
String jcfs = trimToNull(command.getJcfs());
|
||||
if (command.getJcsj() != null)
|
||||
{
|
||||
int rows = checkinMapper.updateJcxxCallTime(gyid, reviewed.getJcid(), command.getJcsj(), jcfs);
|
||||
if (rows == 0)
|
||||
{
|
||||
warnings.add("叫乘时刻写入未命中,请检查叫乘任务 " + reviewed.getJcid() + " 是否已生成");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
warnings.add("未填叫乘时刻:任务已生成但到点扫描不会发起叫乘,可再次提交时补上");
|
||||
}
|
||||
if (!voiceEnabled)
|
||||
{
|
||||
warnings.add("apt.voice.enabled=false:到点不会真正拨号,只验证到「叫乘中」为止");
|
||||
}
|
||||
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("applyId", reviewed.getApplyId());
|
||||
data.put("jcid", reviewed.getJcid());
|
||||
data.put("fjh", reviewed.getFjhFinal() == null ? fjh : reviewed.getFjhFinal());
|
||||
data.put("checkinState", reviewed.getCheckinState());
|
||||
data.put("checkedIn", reviewed.isCheckedIn());
|
||||
data.put("jcsj", command.getJcsj());
|
||||
data.put("jcfs", jcfs);
|
||||
data.put("warnings", warnings);
|
||||
data.put("message", "入住已办理,叫乘任务 " + reviewed.getJcid() + " 已生成");
|
||||
return data;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 内部
|
||||
|
||||
private Map<String, Object> findRoom(String gyid, String roomNo)
|
||||
{
|
||||
for (Map<String, Object> row : testMapper.selectRoomBoard(gyid))
|
||||
{
|
||||
if (roomNo.equals(String.valueOf(row.get("roomNo"))))
|
||||
{
|
||||
return row;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分机号校验:号段范围 + 是否已被占用。
|
||||
*
|
||||
* <p>占用判断用「含历史软删」的计数——号码不复用,软删过的号再绑会让 Asterisk 上
|
||||
* 两条同名 endpoint 打架。</p>
|
||||
*/
|
||||
private void validateExtension(String extension, String roomNo)
|
||||
{
|
||||
if (extension == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!extension.matches("^[0-9]{3,8}$"))
|
||||
{
|
||||
throw new AptException(AptErrorCode.BAD_REQUEST, "EXTENSION_FORMAT",
|
||||
"分机号只能是 3~8 位数字");
|
||||
}
|
||||
Map<String, Object> server = sipServer();
|
||||
String from = trimToNull(server.get("extPoolFrom") == null ? null
|
||||
: String.valueOf(server.get("extPoolFrom")));
|
||||
String to = trimToNull(server.get("extPoolTo") == null ? null
|
||||
: String.valueOf(server.get("extPoolTo")));
|
||||
if (from != null && to != null && (toNumber(extension) < toNumber(from)
|
||||
|| toNumber(extension) > toNumber(to)))
|
||||
{
|
||||
throw new AptException(AptErrorCode.BAD_REQUEST, "EXTENSION_OUT_OF_POOL",
|
||||
"分机号 " + extension + " 不在可用号段 " + from + "-" + to + " 内");
|
||||
}
|
||||
if (testMapper.countExtensionInUse(gyid(), extension) > 0)
|
||||
{
|
||||
Map<String, Object> occupied = null;
|
||||
for (Map<String, Object> row : testMapper.selectExtensionUsage(gyid()))
|
||||
{
|
||||
if (extension.equals(String.valueOf(row.get("extension"))))
|
||||
{
|
||||
occupied = row;
|
||||
break;
|
||||
}
|
||||
}
|
||||
String where = occupied == null || occupied.get("roomNo") == null
|
||||
? "已被占用" : "已绑定房间 " + occupied.get("roomNo");
|
||||
throw new AptException(AptErrorCode.DUPLICATE_KEY, "EXTENSION_IN_USE",
|
||||
"分机号 " + extension + " " + where + ";号码不复用,请换一个(房间 " + roomNo + ")");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 号段内最小空闲分机号。
|
||||
*
|
||||
* <p>逐个试而不是缓存全量清单:号码不复用,判断必须走
|
||||
* {@code countExtensionInUse}(含软删历史),否则会挑到一个「数据库看着空、
|
||||
* Asterisk 上还挂着同名 endpoint」的号码。</p>
|
||||
*
|
||||
* @return 空闲号;未配置号段返回 {@code null}(由调用方回落到按房号派生)
|
||||
*/
|
||||
private String nextFreeExtension()
|
||||
{
|
||||
Map<String, Object> server = sipServer();
|
||||
String from = trimToNull(server.get("extPoolFrom") == null ? null
|
||||
: String.valueOf(server.get("extPoolFrom")));
|
||||
String to = trimToNull(server.get("extPoolTo") == null ? null
|
||||
: String.valueOf(server.get("extPoolTo")));
|
||||
if (from == null || to == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
int start = toNumber(from);
|
||||
int end = toNumber(to);
|
||||
if (end < start || end - start > 2000)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
for (int i = start; i <= end; i++)
|
||||
{
|
||||
String candidate = String.valueOf(i);
|
||||
if (testMapper.countExtensionInUse(gyid(), candidate) == 0)
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
throw new AptException(AptErrorCode.NOT_SUPPORTED, "EXT_POOL_EXHAUSTED",
|
||||
"号段 " + from + "-" + to + " 已无空闲分机号,请先在「SIP 绑定」里清理或放宽号段");
|
||||
}
|
||||
|
||||
private void requireDebug()
|
||||
{
|
||||
if (!debugEnabled)
|
||||
{
|
||||
throw new AptException(AptErrorCode.FEATURE_DISABLED, "TEST_CONFIG_DISABLED",
|
||||
"测试配置未启用:请在运行环境设置 APT_DEBUG_ENABLED=true 后重启后端");
|
||||
}
|
||||
}
|
||||
|
||||
private String gyid()
|
||||
{
|
||||
return AptContext.getGyId();
|
||||
}
|
||||
|
||||
private String trimToNull(String value)
|
||||
{
|
||||
if (value == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
String trimmed = value.trim();
|
||||
return trimmed.isEmpty() ? null : trimmed;
|
||||
}
|
||||
|
||||
private int toNumber(String value)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Integer.parseInt(value.trim());
|
||||
}
|
||||
catch (RuntimeException e)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
private int toInt(Object value, int fallback)
|
||||
{
|
||||
if (value == null)
|
||||
{
|
||||
return fallback;
|
||||
}
|
||||
try
|
||||
{
|
||||
return Integer.parseInt(String.valueOf(value));
|
||||
}
|
||||
catch (NumberFormatException e)
|
||||
{
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
@@ -353,6 +353,8 @@ public class AptProperties
|
||||
private int tabletTaskTtlSeconds = 900;
|
||||
/** 单次扫描批大小 */
|
||||
private int scanBatchSize = 50;
|
||||
/** 自带扫描周期(秒);<=0 关闭自带调度,改用 sys_job(Quartz)*/
|
||||
private int scanIntervalSeconds = 60;
|
||||
/** TTS 模板编码(平板本地合成,文本不下发图像) */
|
||||
private String ttsTemplate = "CALL_ROOM";
|
||||
private String ttsText = "请到门口集合,准备乘车";
|
||||
@@ -417,6 +419,16 @@ public class AptProperties
|
||||
this.scanBatchSize = scanBatchSize;
|
||||
}
|
||||
|
||||
public int getScanIntervalSeconds()
|
||||
{
|
||||
return scanIntervalSeconds;
|
||||
}
|
||||
|
||||
public void setScanIntervalSeconds(int scanIntervalSeconds)
|
||||
{
|
||||
this.scanIntervalSeconds = scanIntervalSeconds;
|
||||
}
|
||||
|
||||
public String getTtsTemplate()
|
||||
{
|
||||
return ttsTemplate;
|
||||
|
||||
+8
@@ -76,6 +76,14 @@ public interface AptVoiceMapper
|
||||
@Param("now") Date now,
|
||||
@Param("limit") int limit);
|
||||
|
||||
/**
|
||||
* 复叫时间到点:<b>不校验来源 ryzt</b>(唯一例外 ryzt=5 通话中,不打断会议),
|
||||
* 只要求未停止、未完成叫乘、未离寓,且本轮复叫计划尚未执行(fjsj_s 为空或早于 fjsj)。
|
||||
*/
|
||||
List<Map<String, Object>> selectRecallDueSipCalls(@Param("gyid") String gyid,
|
||||
@Param("now") Date now,
|
||||
@Param("limit") int limit);
|
||||
|
||||
Map<String, Object> selectJcxxForCall(@Param("gyid") String gyid, @Param("jcid") String jcid);
|
||||
|
||||
/**
|
||||
|
||||
+39
@@ -392,6 +392,45 @@ public class AptCallFlowServiceImpl implements IAptCallFlowService
|
||||
return started;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int redialDue(int limit)
|
||||
{
|
||||
if (!properties.getVoice().isEnabled() || !properties.getVoice().isSipCallEnabled())
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
if (!connectionManager.isUp())
|
||||
{
|
||||
log.debug("AMI 未就绪,跳过本轮复叫发起");
|
||||
return 0;
|
||||
}
|
||||
String gyid = gyid();
|
||||
List<Map<String, Object>> due = voiceMapper.selectRecallDueSipCalls(gyid, new Date(), limit);
|
||||
int started = 0;
|
||||
for (Map<String, Object> row : due == null ? List.<Map<String, Object>>of() : due)
|
||||
{
|
||||
String jcid = str(row.get("jcid"));
|
||||
try
|
||||
{
|
||||
// 复叫时间到点:不校验来源 ryzt,直接走自动复叫(-> 1、重置 30s 基准、
|
||||
// 写 fjsj_s、生成新会话),再按「值班台 -> 房间」顺序重呼;
|
||||
// 本轮失败仍由房间腿失败/30s 无入会回到 ryzt=2 叫乘报警
|
||||
stateService.autoRecall(jcid);
|
||||
redial(jcid);
|
||||
started++;
|
||||
}
|
||||
catch (AptException e)
|
||||
{
|
||||
log.warn("到点复叫 {} 发起失败:{}({})", jcid, e.getReason(), e.getMessage());
|
||||
}
|
||||
catch (RuntimeException e)
|
||||
{
|
||||
log.warn("到点复叫 {} 发起异常", jcid, e);
|
||||
}
|
||||
}
|
||||
return started;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> playTts(String sessionId, String text)
|
||||
{
|
||||
|
||||
+2
-2
@@ -23,7 +23,7 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
* <p>事件映射(公寓端/05 表 5-13):</p>
|
||||
* <pre>
|
||||
* ORIGINATE_START / NEWSTATE -> CALL_START (ryzt 0 -> 1)
|
||||
* CONFBRIDGEJOIN -> JOIN_CONF (补记 hjxysj,ryzt 保持 1 叫乘中)
|
||||
* CONFBRIDGEJOIN -> JOIN_CONF (ryzt -> 5 通话中,补记 hjxysj)
|
||||
* ORIGINATE_FAILURE / NO_ANSWER-> CALL_FAILED (-> 2)
|
||||
* HANGUP(房间腿) -> ANSWERED (-> 3 待离寓:房间挂断即通话完成;
|
||||
* 已应答/已结束时为非法迁移,按口径忽略)
|
||||
@@ -96,7 +96,7 @@ public class AptVoiceEventServiceImpl implements IAptVoiceEventService
|
||||
result.put("submitted", true);
|
||||
break;
|
||||
case "CONFBRIDGEJOIN":
|
||||
// 房间入会 = 叫乘中(不迁移状态);待离寓由房间挂断触发
|
||||
// 房间入会 = 会议桥建立:1 呼叫中 -> 5 通话中;待离寓由房间挂断触发
|
||||
stateService.joinConf(command.getJcid());
|
||||
result.put("submitted", true);
|
||||
break;
|
||||
|
||||
+3
@@ -24,6 +24,9 @@ public interface IAptCallFlowService
|
||||
/** 扫描到点待叫乘并逐条发起呼叫,返回成功发起数 */
|
||||
int dialDue(int limit);
|
||||
|
||||
/** 扫描复叫时间到点的记录:自动复叫(不校验来源 ryzt,唯一排除 5 通话中)后重呼,返回成功发起数 */
|
||||
int redialDue(int limit);
|
||||
|
||||
/** 在会话内播放 TTS 提示语音;失败只告警,不改叫乘结论 */
|
||||
Map<String, Object> playTts(String sessionId, String text);
|
||||
|
||||
|
||||
+3
-2
@@ -12,8 +12,9 @@ import org.springframework.stereotype.Component;
|
||||
* 因此 Job bean 必须落在这个包内,由它委托 {@code ruoyi-apt} 的服务。
|
||||
* 这样既不扩白名单,也不把业务逻辑写进 quartz 模块。</p>
|
||||
*
|
||||
* <p>建议 cron:{@code 0 * * * * ?}(每分钟),由 sys_job 登记、默认停用,
|
||||
* 联调与试点时手工启用(见表 13-15 依赖:Quartz 须启用 ScheduleConfig 或显式登记)。</p>
|
||||
* <p>建议 cron:{@code 0 * * * * ?}(每分钟)。本项目默认由
|
||||
* {@code AptDispatchScanScheduler} 内置调度器驱动(Quartz 未启用),
|
||||
* 若要改用本任务,须把 {@code apt.dispatch.scan-interval-seconds} 置 0 避免双跑。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
|
||||
@@ -82,6 +82,10 @@ apt:
|
||||
tablet-task-ttl-seconds: ${APT_TABLET_TASK_TTL_SEC:900}
|
||||
# 单次扫描批大小
|
||||
scan-batch-size: ${APT_SCAN_BATCH:50}
|
||||
# 自带扫描周期(秒):叫乘/复叫时间到点发起、30s 无应答报警、应离寓超时报警
|
||||
# 本项目 Quartz 默认停用(ScheduleConfig 注释、sys_job 未启用),故由内置调度器驱动;
|
||||
# 若改用 sys_job 的 aptDispatchTask.scan(),请把本项置 0 避免双跑
|
||||
scan-interval-seconds: ${APT_SCAN_INTERVAL_SEC:60}
|
||||
tts-template: ${APT_TTS_TEMPLATE:CALL_ROOM}
|
||||
tts-text: ${APT_TTS_TEXT:请到门口集合,准备乘车}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
+44
-5
@@ -49,14 +49,16 @@
|
||||
</foreach>
|
||||
</update>
|
||||
|
||||
<!-- 房间入会(CONFBRIDGEJOIN):只补记呼叫响应时间,ryzt 保持 1 叫乘中。
|
||||
<!-- 房间入会(CONFBRIDGEJOIN):会议桥建立 -> ryzt=5 通话中(表 11-2 TALKING),
|
||||
并补记呼叫响应时间。来源含 0/1/2:首轮叫乘(1)、复叫入会(1)、报警后补入会(2)。
|
||||
待离寓(ryzt=3)由房间挂断(HANGUP)触发,见 updateAnswered -->
|
||||
<update id="updateJoinConf">
|
||||
UPDATE gy_jcxx
|
||||
SET hjxysj = COALESCE(hjxysj, NOW(3)),
|
||||
SET ryzt = '5',
|
||||
hjxysj = COALESCE(hjxysj, NOW(3)),
|
||||
biz_version = biz_version + 1
|
||||
WHERE gyid = #{gyid} AND jcid = #{jcid} AND deleted = 0
|
||||
AND ryzt = '1' AND finish_jc = '0' AND finish_ly = '0'
|
||||
AND ryzt IN ('0','1','2') AND finish_jc = '0' AND finish_ly = '0'
|
||||
</update>
|
||||
|
||||
<update id="updateCallFailed">
|
||||
@@ -69,6 +71,18 @@
|
||||
AND ryzt = '1' AND finish_jc = '0' AND finish_ly = '0'
|
||||
</update>
|
||||
|
||||
<!-- 超过应离寓时间仍未办理离寓 -> 4 离寓报警;is_lybj=0 保证幂等(只报一次)。
|
||||
与叫乘状态无关:只要 finish_ly=0 就参与判定 -->
|
||||
<update id="updateLeaveAlarm">
|
||||
UPDATE gy_jcxx
|
||||
SET ryzt = '4',
|
||||
is_lybj = '1',
|
||||
lybjsj = NOW(3),
|
||||
biz_version = biz_version + 1
|
||||
WHERE gyid = #{gyid} AND jcid = #{jcid} AND deleted = 0
|
||||
AND finish_ly = '0' AND is_lybj = '0'
|
||||
</update>
|
||||
|
||||
<update id="updateStop">
|
||||
UPDATE gy_jcxx
|
||||
SET stoped = '1',
|
||||
@@ -77,9 +91,15 @@
|
||||
AND ryzt IN ('1','2') AND finish_ly = '0'
|
||||
</update>
|
||||
|
||||
<!-- 人工复叫来源态:
|
||||
1) 已停止(stoped=1) 或 叫乘报警(ryzt=2),且叫乘未完成;
|
||||
2) 已应答待离寓(ryzt=3,finish_jc 必然为 1)——页面上允许对这类记录再叫一轮,
|
||||
此时把 finish_jc 归 0 重新开启叫乘,复叫后未接通仍可回到叫乘报警。
|
||||
已强制结束/已离寓(finish_ly=1)不允许复叫。 -->
|
||||
<update id="updateRecall">
|
||||
UPDATE gy_jcxx
|
||||
SET ryzt = '1',
|
||||
finish_jc = '0',
|
||||
stoped = '0',
|
||||
fjsj_s = NOW(3),
|
||||
<!-- 复叫即本次呼叫起点:重置 jcsj_s,作为重呼后 SIP 30s 超时判定基准,
|
||||
@@ -87,8 +107,27 @@
|
||||
jcsj_s = NOW(3),
|
||||
biz_version = biz_version + 1
|
||||
WHERE gyid = #{gyid} AND jcid = #{jcid} AND deleted = 0
|
||||
AND (stoped = '1' OR ryzt = '2')
|
||||
AND finish_jc = '0' AND finish_ly = '0'
|
||||
AND finish_ly = '0'
|
||||
AND (
|
||||
(finish_jc = '0' AND (stoped = '1' OR ryzt = '2'))
|
||||
OR (ryzt = '3' AND finish_jc = '1')
|
||||
)
|
||||
</update>
|
||||
|
||||
<!-- 复叫时间到点:不限制来源 ryzt(待叫乘/叫乘中/叫乘报警都可能到点),
|
||||
只要「未停止、未完成叫乘、未离寓」就直接进入复叫:
|
||||
ryzt -> 1、重置 jcsj_s(30s 无应答重新起算)、fjsj_s 记本轮执行时刻。
|
||||
唯一例外 ryzt=5 通话中:会议已建立,复叫到点不得打断通话(此处二次兜底,
|
||||
防扫描选中后房间恰好在同一轮入会的竞态) -->
|
||||
<update id="updateAutoRecall">
|
||||
UPDATE gy_jcxx
|
||||
SET ryzt = '1',
|
||||
jcsj_s = NOW(3),
|
||||
fjsj_s = NOW(3),
|
||||
biz_version = biz_version + 1
|
||||
WHERE gyid = #{gyid} AND jcid = #{jcid} AND deleted = 0
|
||||
AND finish_jc = '0' AND finish_ly = '0' AND stoped = '0'
|
||||
AND ryzt <> '5'
|
||||
</update>
|
||||
|
||||
<update id="updateForceFinish">
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
<sql id="jcxxColumns">
|
||||
j.id, j.jcid, j.jlid, j.cc, j.jch, j.ryid, j.xm AS rymc, j.fjh,
|
||||
j.kcsj, j.jcsj, j.jcsj_s AS jcsjS, j.hjxysj, j.jcbjsj_s AS jcbjsjS,
|
||||
j.fjsj_s AS fjsjS, j.lybjsj, j.sjlysj,
|
||||
<!-- 三个时间字段:叫乘时间 jcsj / 复叫时间 fjsj(计划) / 应离寓时间 ylysj;
|
||||
fjsjS 为复叫执行时刻,lybjsj 为离寓报警时刻 -->
|
||||
j.fjsj, j.fjsj_s AS fjsjS, j.ylysj, j.lybjsj, j.sjlysj,
|
||||
j.ryzt,
|
||||
CASE j.ryzt
|
||||
WHEN '0' THEN '待叫乘'
|
||||
@@ -151,10 +153,18 @@
|
||||
'CALL_FAILED', '无应答转叫乘报警'
|
||||
FROM gy_jcxx j WHERE j.gyid = #{gyid} AND j.jcid = #{jcid} AND j.deleted = 0 AND j.jcbjsj_s IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT CAST(UNIX_TIMESTAMP(j.fjsj) * 1000 AS SIGNED), j.fjsj,
|
||||
'RE_CALL_PLANNED', CONCAT('计划复叫时刻 ', DATE_FORMAT(j.fjsj, '%H:%i:%s'))
|
||||
FROM gy_jcxx j WHERE j.gyid = #{gyid} AND j.jcid = #{jcid} AND j.deleted = 0 AND j.fjsj IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT CAST(UNIX_TIMESTAMP(j.fjsj_s) * 1000 AS SIGNED), j.fjsj_s,
|
||||
'RE_CALL', '复叫'
|
||||
FROM gy_jcxx j WHERE j.gyid = #{gyid} AND j.jcid = #{jcid} AND j.deleted = 0 AND j.fjsj_s IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT CAST(UNIX_TIMESTAMP(j.ylysj) * 1000 AS SIGNED), j.ylysj,
|
||||
'LEAVE_DUE', CONCAT('应离寓时刻 ', DATE_FORMAT(j.ylysj, '%H:%i:%s'))
|
||||
FROM gy_jcxx j WHERE j.gyid = #{gyid} AND j.jcid = #{jcid} AND j.deleted = 0 AND j.ylysj IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT CAST(UNIX_TIMESTAMP(j.hjxysj) * 1000 AS SIGNED), j.hjxysj,
|
||||
'ANSWERED', '应答完成叫乘'
|
||||
FROM gy_jcxx j WHERE j.gyid = #{gyid} AND j.jcid = #{jcid} AND j.deleted = 0 AND j.hjxysj IS NOT NULL
|
||||
@@ -256,6 +266,17 @@
|
||||
LIMIT #{limit}
|
||||
</select>
|
||||
|
||||
<!-- 应离寓时间到点且未办理离寓(is_lybj=0 保证只报一次):判定与叫乘状态无关 -->
|
||||
<select id="selectLeaveOverdue" resultType="java.util.LinkedHashMap">
|
||||
SELECT j.jcid, j.fjh, j.xm AS rymc, j.ylysj
|
||||
FROM gy_jcxx j
|
||||
WHERE j.gyid = #{gyid} AND j.deleted = 0
|
||||
AND j.finish_ly = '0' AND j.is_lybj = '0'
|
||||
AND j.ylysj IS NOT NULL AND j.ylysj <= #{now}
|
||||
ORDER BY j.ylysj ASC
|
||||
LIMIT #{limit}
|
||||
</select>
|
||||
|
||||
<select id="selectManualAction" resultType="java.util.LinkedHashMap">
|
||||
SELECT action_id AS actionId, action_type AS actionType, state,
|
||||
duplicated, result_code AS resultCode, reason
|
||||
|
||||
@@ -169,6 +169,24 @@
|
||||
LIMIT #{limit}
|
||||
</select>
|
||||
|
||||
<!-- 复叫时间到点:不校验 ryzt(待叫乘/叫乘中/叫乘报警都可能到点),
|
||||
唯一例外是 ryzt=5 通话中——会议已建立,复叫到点不得打断当前通话;
|
||||
其余仍只要求未停止、未完成叫乘、未离寓,且本轮计划未执行过
|
||||
(fjsj_s 不早于 fjsj 说明已执行,避免重复触发) -->
|
||||
<select id="selectRecallDueSipCalls" resultType="java.util.LinkedHashMap">
|
||||
SELECT j.jcid, j.jcsj, j.fjsj, j.fjh, j.ryid, j.xm AS rymc
|
||||
FROM gy_jcxx j
|
||||
WHERE j.gyid = #{gyid} AND j.deleted = 0
|
||||
AND j.call_mode = 'SIP'
|
||||
AND j.stoped = '0'
|
||||
AND j.finish_jc = '0' AND j.finish_ly = '0'
|
||||
AND j.ryzt <> '5'
|
||||
AND j.fjsj IS NOT NULL AND j.fjsj <= #{now}
|
||||
AND (j.fjsj_s IS NULL OR j.fjsj_s < j.fjsj)
|
||||
ORDER BY j.fjsj ASC
|
||||
LIMIT #{limit}
|
||||
</select>
|
||||
|
||||
<select id="selectJcxxForCall" resultType="java.util.LinkedHashMap">
|
||||
SELECT id, jcid, jlid, cc, jch, ryid, xm AS rymc, fjh,
|
||||
ryzt, stoped, finish_jc AS finishJc, finish_ly AS finishLy,
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.ruoyi.apt.duty.mapper.DutyJcxxMapper">
|
||||
|
||||
<!--
|
||||
R1 值班台只读投影(应用端/11 表 11-4)。
|
||||
硬约束:
|
||||
1) 只读 gy_jcxx,不含任何写语句;
|
||||
2) 每条 SQL 必带 gyid(一进程一公寓 D5);
|
||||
3) 时间窗由服务端给 from/to,SQL 不接受客户端传窗口;
|
||||
4) 无 jcsj 的行(已入寓未排任务)必须保留,否则值班台看不到刚入住的人。
|
||||
-->
|
||||
|
||||
<!--
|
||||
注意:jllx(交路要素锁定)在 apt_local_task 上,gy_jcxx 没有这一列。
|
||||
早先由 j.jllx 取 matchLocked 的写法在本库执行即 1054 Unknown column,
|
||||
已改为不下发(值班台锁定功能尚未落地,前端也不消费该字段);
|
||||
锁定功能上线时从 apt_local_task 关联,别再加回 j.jllx。
|
||||
-->
|
||||
<sql id="dutyColumns">
|
||||
j.jcid,
|
||||
j.fjh AS roomNo,
|
||||
j.xm AS personName,
|
||||
j.cc AS trainNo,
|
||||
j.jcsj AS callTime,
|
||||
j.kcsj AS departTime,
|
||||
j.ryzt,
|
||||
j.finish_jc AS finishJc,
|
||||
j.finish_ly AS finishLy,
|
||||
j.stoped,
|
||||
j.leave_remark AS leaveRemark
|
||||
</sql>
|
||||
|
||||
<sql id="dutyWindow">
|
||||
AND (j.jcsj IS NULL OR (j.jcsj >= #{from} AND j.jcsj < #{to}))
|
||||
</sql>
|
||||
|
||||
<select id="selectDutyTaskList" resultType="java.util.LinkedHashMap">
|
||||
SELECT <include refid="dutyColumns"/>
|
||||
FROM gy_jcxx j
|
||||
WHERE j.gyid = #{gyid} AND j.deleted = 0
|
||||
<choose>
|
||||
<when test="scope == 'done'">
|
||||
AND (j.finish_ly = '1' OR j.stoped = '1')
|
||||
</when>
|
||||
<when test="scope == 'all'">
|
||||
</when>
|
||||
<otherwise>
|
||||
AND j.finish_ly = '0' AND j.stoped = '0'
|
||||
</otherwise>
|
||||
</choose>
|
||||
<include refid="dutyWindow"/>
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN j.ryzt IN ('2', '4') THEN 0
|
||||
WHEN j.ryzt = '0' AND j.jcsj IS NOT NULL AND j.jcsj < #{now} THEN 0
|
||||
WHEN j.finish_ly = '0' AND j.stoped = '0' THEN 1
|
||||
ELSE 2
|
||||
END,
|
||||
j.jcsj ASC, j.id ASC
|
||||
</select>
|
||||
|
||||
<select id="selectDutyRefByJcid" resultType="java.util.LinkedHashMap">
|
||||
SELECT j.id, j.jcid, j.ryzt,
|
||||
j.finish_jc AS finishJc, j.finish_ly AS finishLy, j.stoped
|
||||
FROM gy_jcxx j
|
||||
WHERE j.gyid = #{gyid} AND j.jcid = #{jcid} AND j.deleted = 0
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<select id="selectDutyCounts" resultType="java.util.LinkedHashMap">
|
||||
SELECT
|
||||
SUM(CASE WHEN j.ryzt = '0' AND j.finish_ly = '0' AND j.stoped = '0' THEN 1 ELSE 0 END) AS todoCount,
|
||||
SUM(CASE WHEN j.ryzt IN ('1', '5') THEN 1 ELSE 0 END) AS callCount,
|
||||
SUM(CASE WHEN j.ryzt IN ('2', '4') THEN 1 ELSE 0 END) AS alarmCount,
|
||||
SUM(CASE WHEN j.finish_ly = '0' AND j.stoped = '0' THEN 1 ELSE 0 END) AS needCount,
|
||||
SUM(CASE WHEN j.finish_ly = '1' OR j.stoped = '1' THEN 1 ELSE 0 END) AS doneCount,
|
||||
SUM(CASE WHEN j.ryzt IN ('2', '4')
|
||||
OR (j.ryzt = '0' AND j.jcsj IS NOT NULL AND j.jcsj < #{now})
|
||||
THEN 1 ELSE 0 END) AS urgentCount,
|
||||
COUNT(*) AS total
|
||||
FROM gy_jcxx j
|
||||
WHERE j.gyid = #{gyid} AND j.deleted = 0
|
||||
<include refid="dutyWindow"/>
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
@@ -0,0 +1,133 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.ruoyi.apt.duty.test.mapper.DutyTestMapper">
|
||||
|
||||
<!--
|
||||
值班台测试配置(R5 / 应用端/17)。
|
||||
只碰配置面;gy_jcxx 的 ryzt/finish_jc/finish_ly/stoped 仍只由 AptDutyStateMapper 写。
|
||||
-->
|
||||
|
||||
<select id="selectSipServer" resultType="java.util.LinkedHashMap">
|
||||
SELECT a.ami_host AS amiHost,
|
||||
a.ami_port AS amiPort,
|
||||
a.ami_user AS amiUser,
|
||||
a.ami_secret_ref AS amiSecretRef,
|
||||
a.ext_pool_from AS extPoolFrom,
|
||||
a.ext_pool_to AS extPoolTo,
|
||||
a.enabled AS enabled,
|
||||
a.state AS state,
|
||||
a.last_error AS lastError,
|
||||
a.last_connected_at AS lastConnectedAt,
|
||||
a.biz_version AS bizVersion
|
||||
FROM apt_ast_instance a
|
||||
WHERE a.gyid = #{gyid} AND a.deleted = 0
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<update id="updateSipServer">
|
||||
UPDATE apt_ast_instance
|
||||
SET ami_host = #{amiHost},
|
||||
ami_port = #{amiPort},
|
||||
ami_user = #{amiUser},
|
||||
ami_secret_ref = #{amiSecretRef},
|
||||
ext_pool_from = #{extPoolFrom},
|
||||
ext_pool_to = #{extPoolTo},
|
||||
enabled = #{enabled},
|
||||
state = 'DISABLED',
|
||||
last_error = NULL,
|
||||
biz_version = biz_version + 1
|
||||
WHERE gyid = #{gyid} AND deleted = 0
|
||||
</update>
|
||||
|
||||
<select id="selectRoomBoard" resultType="java.util.LinkedHashMap">
|
||||
SELECT m.room_no AS roomNo,
|
||||
m.room_id AS roomId,
|
||||
m.building AS building,
|
||||
m.room_floor AS roomFloor,
|
||||
m.room_type AS roomType,
|
||||
m.bed_count AS bedCount,
|
||||
m.state AS masterState,
|
||||
r.id AS localId,
|
||||
r.room_status AS roomStatus,
|
||||
r.in_person_count AS inPersonCount,
|
||||
r.state_version AS stateVersion,
|
||||
e.extension AS extension,
|
||||
e.display_name AS displayName,
|
||||
e.enabled AS sipEnabled,
|
||||
e.sync_state AS sipSyncState
|
||||
FROM apt_master_room m
|
||||
LEFT JOIN gy_rooms r
|
||||
ON r.gyid = m.gyid AND r.room_id = m.room_id AND r.deleted = 0
|
||||
LEFT JOIN apt_sip_binding b
|
||||
ON b.gyid = m.gyid AND b.room_id = r.room_id AND b.deleted = 0 AND b.state = 'ACTIVE'
|
||||
LEFT JOIN apt_sip_endpoint e
|
||||
ON e.gyid = b.gyid AND e.endpoint_id = b.endpoint_id AND e.deleted = 0
|
||||
WHERE m.gyid = #{gyid} AND m.deleted = 0
|
||||
ORDER BY m.room_no
|
||||
</select>
|
||||
|
||||
<select id="selectMasterRoomByNo" resultType="java.util.LinkedHashMap">
|
||||
SELECT m.room_id AS roomId,
|
||||
m.room_no AS roomNo,
|
||||
m.bed_count AS bedCount,
|
||||
m.state AS state
|
||||
FROM apt_master_room m
|
||||
WHERE m.gyid = #{gyid} AND m.room_no = #{roomNo} AND m.deleted = 0
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<insert id="insertMasterRoom">
|
||||
INSERT INTO apt_master_room
|
||||
(gyid, room_id, room_no, room_floor, building, bed_count, room_type,
|
||||
state, state_version, source_version, deleted)
|
||||
VALUES
|
||||
(#{gyid}, #{roomId}, #{roomNo}, #{roomFloor}, #{building}, #{bedCount}, #{roomType},
|
||||
'ACTIVE', 1, 1, 0)
|
||||
</insert>
|
||||
|
||||
<select id="countLocalRoomByNo" resultType="int">
|
||||
SELECT COUNT(1) FROM gy_rooms
|
||||
WHERE gyid = #{gyid} AND room_no = #{roomNo} AND deleted = 0
|
||||
</select>
|
||||
|
||||
<select id="selectExtensionUsage" resultType="java.util.LinkedHashMap">
|
||||
SELECT e.extension AS extension,
|
||||
e.endpoint_id AS endpointId,
|
||||
e.room_id AS roomId,
|
||||
e.display_name AS displayName,
|
||||
e.enabled AS enabled,
|
||||
e.sync_state AS syncState,
|
||||
e.ip_address AS ipAddress,
|
||||
r.room_no AS roomNo,
|
||||
a.state AS authState
|
||||
FROM apt_sip_endpoint e
|
||||
LEFT JOIN gy_rooms r
|
||||
ON r.gyid = e.gyid AND r.room_id = e.room_id AND r.deleted = 0
|
||||
LEFT JOIN apt_sip_auth a
|
||||
ON a.gyid = e.gyid AND a.endpoint_id = e.endpoint_id AND a.deleted = 0
|
||||
WHERE e.gyid = #{gyid} AND e.deleted = 0
|
||||
ORDER BY e.extension
|
||||
</select>
|
||||
|
||||
<select id="countExtensionInUse" resultType="int">
|
||||
SELECT COUNT(1) FROM apt_sip_endpoint
|
||||
WHERE gyid = #{gyid} AND extension = #{extension}
|
||||
</select>
|
||||
|
||||
<select id="selectCheckinCandidates" resultType="java.util.LinkedHashMap">
|
||||
SELECT p.ryid AS ryid,
|
||||
p.xm AS personName,
|
||||
p.work_no AS workNo,
|
||||
COALESCE(p.person_category, 'TRANSIENT') AS personCategory,
|
||||
(SELECT COUNT(1) FROM apt_person_face_reg f
|
||||
WHERE f.gyid = p.gyid AND f.reg_state = 'CONFIRMED'
|
||||
AND f.confirmed_ryid = p.ryid) AS confirmedReg,
|
||||
(SELECT COUNT(1) FROM gy_jcxx j
|
||||
WHERE j.gyid = p.gyid AND j.ryid = p.ryid AND j.deleted = 0
|
||||
AND j.finish_ly = '0' AND j.sjlysj IS NULL) AS activeStay
|
||||
FROM apt_master_person p
|
||||
WHERE p.gyid = #{gyid} AND COALESCE(p.deleted, 0) = 0
|
||||
ORDER BY p.work_no
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
package com.ruoyi.apt.dispatch;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.ruoyi.apt.common.AptContext;
|
||||
import com.ruoyi.apt.common.AptErrorCode;
|
||||
import com.ruoyi.apt.common.AptException;
|
||||
import com.ruoyi.apt.dispatch.mapper.AptJcxxMapper;
|
||||
import com.ruoyi.apt.dispatch.service.AptDispatchScanServiceImpl;
|
||||
import com.ruoyi.apt.dispatch.service.IAptDutyStateService;
|
||||
import com.ruoyi.apt.dispatch.tablet.service.IAptTabletDispatchService;
|
||||
import com.ruoyi.apt.framework.config.AptProperties;
|
||||
import com.ruoyi.apt.voice.service.IAptCallFlowService;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* 叫乘扫描编排回归:叫乘/复叫时间到点发起、应离寓时间到点转离寓报警。
|
||||
*/
|
||||
class AptDispatchScanServiceTest
|
||||
{
|
||||
private IAptDutyStateService stateService;
|
||||
private AptJcxxMapper jcxxMapper;
|
||||
private IAptCallFlowService callFlowService;
|
||||
private AptDispatchScanServiceImpl service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp()
|
||||
{
|
||||
IAptTabletDispatchService tabletDispatchService = mock(IAptTabletDispatchService.class);
|
||||
stateService = mock(IAptDutyStateService.class);
|
||||
jcxxMapper = mock(AptJcxxMapper.class);
|
||||
callFlowService = mock(IAptCallFlowService.class);
|
||||
AptProperties properties = new AptProperties();
|
||||
properties.setGyId("APT-A");
|
||||
properties.getVoice().setEnabled(true);
|
||||
properties.getVoice().setSipCallEnabled(true);
|
||||
service = new AptDispatchScanServiceImpl(tabletDispatchService, stateService, jcxxMapper,
|
||||
properties, callFlowService);
|
||||
AptContext.clear();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown()
|
||||
{
|
||||
AptContext.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void scanTriggersRecallThenDialThenLeaveAlarm()
|
||||
{
|
||||
when(jcxxMapper.selectLeaveOverdue(anyString(), any(), anyInt())).thenReturn(rows("JC-2"));
|
||||
|
||||
Map<String, Object> result = service.scan();
|
||||
|
||||
verify(callFlowService).redialDue(anyInt());
|
||||
verify(callFlowService).dialDue(anyInt());
|
||||
verify(stateService).leaveAlarm("JC-2");
|
||||
assertEquals(1, result.get("leaveAlarms"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void leaveAlarmIllegalTransitionIsSwallowed()
|
||||
{
|
||||
when(jcxxMapper.selectLeaveOverdue(anyString(), any(), anyInt())).thenReturn(rows("JC-3"));
|
||||
doThrow(new AptException(AptErrorCode.STATE_ILLEGAL, "STATE_TRANSITION_ILLEGAL", "已离寓"))
|
||||
.when(stateService).leaveAlarm("JC-3");
|
||||
|
||||
Map<String, Object> result = service.scan();
|
||||
|
||||
assertEquals(0, result.get("leaveAlarms"));
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> rows(String jcid)
|
||||
{
|
||||
Map<String, Object> row = new HashMap<>();
|
||||
row.put("jcid", jcid);
|
||||
return List.of(row);
|
||||
}
|
||||
}
|
||||
+22
-1
@@ -33,6 +33,7 @@ class AptDutyCommandServiceTest
|
||||
private AptJcxxMapper mapper;
|
||||
private IAptDutyStateService stateService;
|
||||
private AptAuditWriter auditWriter;
|
||||
private com.ruoyi.apt.voice.service.IAptCallFlowService callFlowService;
|
||||
private AptDutyCommandServiceImpl service;
|
||||
|
||||
@BeforeEach
|
||||
@@ -41,13 +42,33 @@ class AptDutyCommandServiceTest
|
||||
mapper = mock(AptJcxxMapper.class);
|
||||
stateService = mock(IAptDutyStateService.class);
|
||||
auditWriter = mock(AptAuditWriter.class);
|
||||
callFlowService = mock(com.ruoyi.apt.voice.service.IAptCallFlowService.class);
|
||||
service = new AptDutyCommandServiceImpl(mapper, stateService, auditWriter,
|
||||
mock(com.ruoyi.apt.framework.realtime.IAptRealtimePublisher.class),
|
||||
mock(com.ruoyi.apt.voice.service.IAptCallFlowService.class));
|
||||
callFlowService);
|
||||
AptContext.clear();
|
||||
AptContext.setGyId("APT-A");
|
||||
}
|
||||
|
||||
@Test
|
||||
void recallAllowedForAnsweredAwaitingLeave()
|
||||
{
|
||||
when(mapper.selectManualAction("APT-A", "CMD-9")).thenReturn(null);
|
||||
// ryzt=3 已应答待离寓:叫乘已完成(finishJc=1),页面上仍允许触发复叫
|
||||
when(mapper.selectJcxxById("APT-A", 9L)).thenReturn(row("JC-9", "3", "1", "0", "0"),
|
||||
row("JC-9", "1", "0", "0", "0"));
|
||||
when(mapper.insertManualAction(eq("APT-A"), eq("CMD-9"), eq("JC-9"), eq("RE_CALL"),
|
||||
anyString(), anyString(), anyString(), any())).thenReturn(1);
|
||||
when(stateService.recall("JC-9")).thenReturn(1);
|
||||
|
||||
AptDutyCommandResult result = service.execute(9L, command("CMD-9", "RE_CALL"));
|
||||
|
||||
assertTrue(result.isAccepted());
|
||||
verify(stateService).recall("JC-9");
|
||||
// 复叫提交后触发 SIP 重呼(无事务时立即执行)
|
||||
verify(callFlowService).redial("JC-9");
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown()
|
||||
{
|
||||
|
||||
+56
-2
@@ -61,16 +61,26 @@ class AptDutyStateServiceTest
|
||||
when(mapper.updateAnswered(eq("APT-A"), eq("JC-1"), anyList())).thenReturn(1);
|
||||
|
||||
assertEquals(1, service.answered("JC-1"));
|
||||
verify(mapper).updateAnswered("APT-A", "JC-1", List.of("0", "1", "2"));
|
||||
// 5 通话中:房间入会成功后挂断也要能提交应答
|
||||
verify(mapper).updateAnswered("APT-A", "JC-1", List.of("0", "1", "2", "5"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void joinConfOnlyRecordsResponseTime()
|
||||
void joinConfMovesCallingToTalking()
|
||||
{
|
||||
when(mapper.updateJoinConf(eq("APT-A"), eq("JC-1"))).thenReturn(1);
|
||||
AptStateSnapshot calling = new AptStateSnapshot();
|
||||
calling.setJcid("JC-1");
|
||||
calling.setRyzt("1");
|
||||
AptStateSnapshot talking = new AptStateSnapshot();
|
||||
talking.setJcid("JC-1");
|
||||
talking.setRyzt("5");
|
||||
when(mapper.selectSnapshot("APT-A", "JC-1")).thenReturn(calling, talking);
|
||||
|
||||
assertEquals(1, service.joinConf("JC-1"));
|
||||
verify(mapper).updateJoinConf("APT-A", "JC-1");
|
||||
// 入会 = 会议桥建立:值班台必须收到 1 呼叫中 -> 5 通话中 的状态变化
|
||||
verify(pushService).stateChanged(eq("JC-1"), eq(1), eq(5), eq("TALKING"), eq("通话中"), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -82,6 +92,50 @@ class AptDutyStateServiceTest
|
||||
assertEquals(40902, e.getErrorCode().getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void autoRecallIgnoresSourceStateAndCreatesNewSession()
|
||||
{
|
||||
when(mapper.updateAutoRecall("APT-A", "JC-6")).thenReturn(1);
|
||||
AptStateSnapshot before = new AptStateSnapshot();
|
||||
before.setJcid("JC-6");
|
||||
// 来源态任意(这里用「叫乘中」),自动复叫不应因 ryzt 不符而失败
|
||||
before.setRyzt("1");
|
||||
before.setCallMode("SIP");
|
||||
when(mapper.selectSnapshot("APT-A", "JC-6")).thenReturn(before);
|
||||
|
||||
assertEquals(1, service.autoRecall("JC-6"));
|
||||
verify(mapper).updateAutoRecall("APT-A", "JC-6");
|
||||
verify(mapper).insertCallSession(eq("APT-A"), anyString(), eq("JC-6"), eq("SIP"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void autoRecallMissedConditionalUpdateMapsTo40902()
|
||||
{
|
||||
when(mapper.updateAutoRecall("APT-A", "JC-7")).thenReturn(0);
|
||||
|
||||
AptException e = assertThrows(AptException.class, () -> service.autoRecall("JC-7"));
|
||||
assertEquals(40902, e.getErrorCode().getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void leaveAlarmRaisesRealtimeAlarmAndPushesDuty()
|
||||
{
|
||||
when(mapper.updateLeaveAlarm("APT-A", "JC-5")).thenReturn(1);
|
||||
AptStateSnapshot before = new AptStateSnapshot();
|
||||
before.setJcid("JC-5");
|
||||
before.setRyzt("3");
|
||||
AptStateSnapshot after = new AptStateSnapshot();
|
||||
after.setJcid("JC-5");
|
||||
after.setRyzt("4");
|
||||
// 迁移前快照 + 迁移后快照(pushDuty 内部再取一次,与 answered 同口径)
|
||||
when(mapper.selectSnapshot("APT-A", "JC-5")).thenReturn(before, after);
|
||||
|
||||
assertEquals(1, service.leaveAlarm("JC-5"));
|
||||
verify(mapper).updateLeaveAlarm("APT-A", "JC-5");
|
||||
verify(realtimePublisher).alarm("JC-5", "LEAVE_ALARM", "超过应离寓时间未办理离寓");
|
||||
verify(pushService).alarmRaised("JC-5", "LEAVE", "JC-5:LEAVE");
|
||||
}
|
||||
|
||||
@Test
|
||||
void missedConditionalUpdateMapsTo40902()
|
||||
{
|
||||
|
||||
+316
@@ -0,0 +1,316 @@
|
||||
package com.ruoyi.apt.duty;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import com.ruoyi.apt.common.AptContext;
|
||||
import com.ruoyi.apt.common.AptErrorCode;
|
||||
import com.ruoyi.apt.common.AptException;
|
||||
import com.ruoyi.apt.dispatch.domain.AptDutyCommand;
|
||||
import com.ruoyi.apt.dispatch.domain.AptDutyCommandResult;
|
||||
import com.ruoyi.apt.dispatch.service.IAptDutyCommandService;
|
||||
import com.ruoyi.apt.dispatch.service.IAptJcxxService;
|
||||
import com.ruoyi.apt.duty.domain.DutyCommandRequest;
|
||||
import com.ruoyi.apt.duty.mapper.DutyJcxxMapper;
|
||||
import com.ruoyi.apt.duty.realtime.DutyTransitionPush;
|
||||
import com.ruoyi.apt.duty.service.DutyCommandServiceImpl;
|
||||
import com.ruoyi.framework.web.service.PermissionService;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
/**
|
||||
* R2 命令通道行为断言(应用端/11 表 11-7 `T-BE-04`)。
|
||||
*
|
||||
* <p>重点不在"能不能调通",而在四条边界:原因必填、权限逐命令、
|
||||
* 期望态不符不执行、jcid 解析不跨公寓。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
class DutyCommandServiceTest
|
||||
{
|
||||
private DutyJcxxMapper mapper;
|
||||
|
||||
private IAptDutyCommandService stateService;
|
||||
|
||||
private IAptJcxxService jcxxService;
|
||||
|
||||
private PermissionService permissionService;
|
||||
|
||||
private DutyTransitionPush dutyPush;
|
||||
|
||||
private DutyCommandServiceImpl service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp()
|
||||
{
|
||||
mapper = mock(DutyJcxxMapper.class);
|
||||
stateService = mock(IAptDutyCommandService.class);
|
||||
jcxxService = mock(IAptJcxxService.class);
|
||||
permissionService = mock(PermissionService.class);
|
||||
dutyPush = mock(DutyTransitionPush.class);
|
||||
service = new DutyCommandServiceImpl(mapper, stateService, jcxxService, permissionService,
|
||||
dutyPush);
|
||||
AptContext.clear();
|
||||
AptContext.setGyId("APT-A");
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown()
|
||||
{
|
||||
AptContext.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void stopWithoutReasonIsRejectedBeforeTouchingStateMachine()
|
||||
{
|
||||
when(permissionService.hasPermi("duty:call:stop")).thenReturn(true);
|
||||
when(mapper.selectDutyRefByJcid("APT-A", "JC1")).thenReturn(ref("JC1", "1"));
|
||||
|
||||
AptException error = assertThrows(AptException.class,
|
||||
() -> service.execute("JC1", request("STOP", null, null)));
|
||||
|
||||
assertEquals(AptErrorCode.BAD_REQUEST, error.getErrorCode());
|
||||
assertEquals("REASON_REQUIRED", error.getReason());
|
||||
verifyNoInteractions(stateService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void oneClickLeaveFillsDefaultReasonAndReportsNewState()
|
||||
{
|
||||
when(permissionService.hasPermi("duty:call:markLeave")).thenReturn(true);
|
||||
// 第一次读 = 执行前(ryzt=3),第二次读 = 执行后(finish_ly=1)
|
||||
when(mapper.selectDutyRefByJcid("APT-A", "JC2"))
|
||||
.thenReturn(ref("JC2", "3"), finished("JC2"));
|
||||
when(stateService.execute(anyLong(), any(AptDutyCommand.class))).thenReturn(
|
||||
result("ACT-1", false));
|
||||
when(dutyPush.takeStateSeq("JC2")).thenReturn(42L);
|
||||
|
||||
Map<String, Object> view = service.execute("JC2", request("MARK_LEAVE", null, null));
|
||||
|
||||
ArgumentCaptor<AptDutyCommand> captor = ArgumentCaptor.forClass(AptDutyCommand.class);
|
||||
verify(stateService).execute(eq(88L), captor.capture());
|
||||
AptDutyCommand sent = captor.getValue();
|
||||
assertEquals("MARK_LEAVE", sent.getCommand(), "命令名必须与枚举一致");
|
||||
assertEquals("值班台登记离寓", sent.getReason(), "一键动作由服务端补默认原因");
|
||||
assertEquals("MANUAL", sent.getLeaveType(), "登记离寓必须带 leaveType");
|
||||
assertNotNull(sent.getCommandId(), "未给幂等键时必须兜底生成");
|
||||
|
||||
assertEquals("DONE", view.get("uiState"), "离寓后应为终态");
|
||||
assertEquals(3, view.get("ryztBefore"));
|
||||
assertNull(view.get("ryztAfter"));
|
||||
assertEquals(Boolean.FALSE, view.get("duplicated"));
|
||||
assertEquals("ACT-1", view.get("actionId"));
|
||||
assertEquals(42L, view.get("pushSeq"), "命令响应必须带 pushSeq 供前端丢重");
|
||||
verify(dutyPush).takeStateSeq("JC2");
|
||||
}
|
||||
|
||||
@Test
|
||||
void permissionIsCheckedPerCommandNotPerEndpoint()
|
||||
{
|
||||
when(permissionService.hasPermi("duty:call:forceFinish")).thenReturn(false);
|
||||
|
||||
AptException error = assertThrows(AptException.class,
|
||||
() -> service.execute("JC3", request("FORCE_FINISH", "旅客已自行离开", null)));
|
||||
|
||||
assertEquals(AptErrorCode.FORBIDDEN_PERM, error.getErrorCode());
|
||||
assertTrue(error.getMessage().contains("duty:call:forceFinish"), "提示要给出缺失的权限点");
|
||||
verifyNoInteractions(stateService);
|
||||
verify(mapper, never()).selectDutyRefByJcid(anyString(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void expectedStateMismatchStopsBeforeExecution()
|
||||
{
|
||||
when(permissionService.hasPermi("duty:call:recall")).thenReturn(true);
|
||||
when(mapper.selectDutyRefByJcid("APT-A", "JC4")).thenReturn(ref("JC4", "0"));
|
||||
|
||||
AptException error = assertThrows(AptException.class,
|
||||
() -> service.execute("JC4", request("RE_CALL", null, 2)));
|
||||
|
||||
assertEquals(AptErrorCode.STATE_ILLEGAL, error.getErrorCode());
|
||||
assertEquals("EXPECTED_STATE_MISMATCH", error.getReason());
|
||||
verifyNoInteractions(stateService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownJcidIs404NotAStackTrace()
|
||||
{
|
||||
when(permissionService.hasPermi("duty:call:stop")).thenReturn(true);
|
||||
when(mapper.selectDutyRefByJcid("APT-A", "GHOST")).thenReturn(null);
|
||||
|
||||
AptException error = assertThrows(AptException.class,
|
||||
() -> service.execute("GHOST", request("STOP", "重复叫乘", null)));
|
||||
|
||||
assertEquals(AptErrorCode.NOT_FOUND, error.getErrorCode());
|
||||
assertEquals("JCXX_NOT_FOUND", error.getReason());
|
||||
verifyNoInteractions(stateService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unsupportedCommandListsAllowedOnes()
|
||||
{
|
||||
AptException error = assertThrows(AptException.class,
|
||||
() -> service.execute("JC5", request("HANGUP", "挂断", null)));
|
||||
|
||||
assertEquals(AptErrorCode.BAD_REQUEST, error.getErrorCode());
|
||||
assertEquals("COMMAND_UNSUPPORTED", error.getReason());
|
||||
assertTrue(error.getMessage().contains("MARK_LEAVE"), "错误信息应列出可用命令");
|
||||
verifyNoInteractions(stateService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void blankCommandIdIsGeneratedButGivenOneIsKept()
|
||||
{
|
||||
when(permissionService.hasPermi("duty:call:recall")).thenReturn(true);
|
||||
when(mapper.selectDutyRefByJcid("APT-A", "JC6")).thenReturn(ref("JC6", "2"), ref("JC6", "1"));
|
||||
when(stateService.execute(anyLong(), any(AptDutyCommand.class))).thenReturn(result("ACT-2", true));
|
||||
|
||||
DutyCommandRequest withId = request("RE_CALL", "值班台复叫", null);
|
||||
withId.setCommandId("DUTY-FIXED-1");
|
||||
Map<String, Object> view = service.execute("JC6", withId);
|
||||
|
||||
assertEquals("DUTY-FIXED-1", view.get("commandId"));
|
||||
assertEquals(Boolean.TRUE, view.get("duplicated"), "重复提交要透传 duplicated");
|
||||
assertEquals("CALLING", view.get("uiState"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void reasonTooShortOrTooLongIsRejected()
|
||||
{
|
||||
when(permissionService.hasPermi("duty:call:stop")).thenReturn(true);
|
||||
when(mapper.selectDutyRefByJcid("APT-A", "JC7")).thenReturn(ref("JC7", "1"));
|
||||
|
||||
AptException shortError = assertThrows(AptException.class,
|
||||
() -> service.execute("JC7", request("STOP", "急", null)));
|
||||
assertEquals("REASON_TOO_SHORT", shortError.getReason());
|
||||
|
||||
StringBuilder tooLong = new StringBuilder();
|
||||
for (int i = 0; i < 201; i++)
|
||||
{
|
||||
tooLong.append('x');
|
||||
}
|
||||
AptException longError = assertThrows(AptException.class,
|
||||
() -> service.execute("JC7", request("STOP", tooLong.toString(), null)));
|
||||
assertEquals("REASON_TOO_LONG", longError.getReason());
|
||||
|
||||
verifyNoInteractions(stateService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void detailMasksNameAndDropsMediaPaths()
|
||||
{
|
||||
when(mapper.selectDutyRefByJcid("APT-A", "JC8")).thenReturn(ref("JC8", "1"));
|
||||
Map<String, Object> source = new LinkedHashMap<>();
|
||||
source.put("jcid", "JC8");
|
||||
source.put("fjh", "305");
|
||||
source.put("rymc", "李四");
|
||||
source.put("cc", "G5678");
|
||||
source.put("ryzt", "1");
|
||||
source.put("ryztLabel", "呼叫中");
|
||||
source.put("stoped", "0");
|
||||
source.put("finishLy", "0");
|
||||
source.put("recordings", Arrays.asList("r1.wav"));
|
||||
source.put("facePhotos", Arrays.asList("p1.jpg"));
|
||||
Map<String, Object> manualActions = new LinkedHashMap<>();
|
||||
manualActions.put("total", 12);
|
||||
source.put("manualActions", manualActions);
|
||||
when(jcxxService.detail(88L)).thenReturn(source);
|
||||
|
||||
Map<String, Object> detail = service.detail("JC8");
|
||||
|
||||
assertEquals("李*", detail.get("personName"), "详情也必须脱敏");
|
||||
assertEquals("CALLING", detail.get("uiState"));
|
||||
assertFalse(detail.containsKey("recordings"), "值班台不返回录音路径");
|
||||
assertFalse(detail.containsKey("facePhotos"), "值班台不返回刷脸照片路径");
|
||||
assertNotNull(detail.get("manualActions"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void timelineOfUnknownJcidIs404()
|
||||
{
|
||||
when(mapper.selectDutyRefByJcid("APT-A", "GHOST")).thenReturn(null);
|
||||
AptException error = assertThrows(AptException.class, () -> service.timeline("GHOST"));
|
||||
assertEquals(AptErrorCode.NOT_FOUND, error.getErrorCode());
|
||||
verifyNoInteractions(jcxxService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void timelinePassesThroughAsListNeverNull()
|
||||
{
|
||||
when(mapper.selectDutyRefByJcid("APT-A", "JC9")).thenReturn(ref("JC9", "0"));
|
||||
when(jcxxService.timeline(88L)).thenReturn(null);
|
||||
assertTrue(service.timeline("JC9").isEmpty(), "无时间线时返回空数组,避免前端判空");
|
||||
}
|
||||
|
||||
@Test
|
||||
void pushSeqIsMinusOneWhenTransitionDidNotRegister()
|
||||
{
|
||||
when(permissionService.hasPermi("duty:call:stop")).thenReturn(true);
|
||||
when(mapper.selectDutyRefByJcid("APT-A", "JC13")).thenReturn(ref("JC13", "1"), ref("JC13", "1"));
|
||||
when(stateService.execute(anyLong(), any(AptDutyCommand.class))).thenReturn(result("ACT-13", false));
|
||||
when(dutyPush.takeStateSeq("JC13")).thenReturn(-1L);
|
||||
|
||||
Map<String, Object> view = service.execute("JC13", request("STOP", "旅客已自行离寓", null));
|
||||
|
||||
assertEquals("CALLING", view.get("uiState"), "推送未登记不影响命令结果");
|
||||
assertEquals(-1L, view.get("pushSeq"), "未登记时 pushSeq 返回 -1");
|
||||
verify(stateService).execute(eq(88L), any(AptDutyCommand.class));
|
||||
}
|
||||
|
||||
private DutyCommandRequest request(String type, String reason, Integer expectedState)
|
||||
{
|
||||
DutyCommandRequest request = new DutyCommandRequest();
|
||||
request.setCommandType(type);
|
||||
request.setReason(reason);
|
||||
request.setExpectedState(expectedState);
|
||||
return request;
|
||||
}
|
||||
|
||||
private Map<String, Object> ref(String jcid, String ryzt)
|
||||
{
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("id", 88L);
|
||||
row.put("jcid", jcid);
|
||||
row.put("ryzt", ryzt);
|
||||
row.put("finishJc", "0");
|
||||
row.put("finishLy", "0");
|
||||
row.put("stoped", "0");
|
||||
return row;
|
||||
}
|
||||
|
||||
private Map<String, Object> finished(String jcid)
|
||||
{
|
||||
Map<String, Object> row = ref(jcid, null);
|
||||
row.put("finishLy", "1");
|
||||
return row;
|
||||
}
|
||||
|
||||
private AptDutyCommandResult result(String actionId, boolean duplicated)
|
||||
{
|
||||
AptDutyCommandResult result = new AptDutyCommandResult();
|
||||
result.setCommandId("DUTY-REQ");
|
||||
result.setActionId(actionId);
|
||||
result.setAccepted(true);
|
||||
result.setDuplicated(duplicated);
|
||||
result.setStateAfter("CALLING");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
package com.ruoyi.apt.duty;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
import java.math.BigDecimal;
|
||||
import java.text.ParseException;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.ruoyi.apt.common.AptContext;
|
||||
import com.ruoyi.apt.duty.domain.DutyQuery;
|
||||
import com.ruoyi.apt.duty.domain.DutyTaskView;
|
||||
import com.ruoyi.apt.duty.mapper.DutyJcxxMapper;
|
||||
import com.ruoyi.apt.duty.service.DutyJcxxQueryServiceImpl;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* 值班台只读查询(R1)行为断言:口径归一、关键字过滤、脱敏、统计兜底。
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
class DutyJcxxQueryServiceTest
|
||||
{
|
||||
private DutyJcxxMapper mapper;
|
||||
private DutyJcxxQueryServiceImpl service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp()
|
||||
{
|
||||
mapper = mock(DutyJcxxMapper.class);
|
||||
service = new DutyJcxxQueryServiceImpl(mapper);
|
||||
AptContext.clear();
|
||||
AptContext.setGyId("APT-A");
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown()
|
||||
{
|
||||
AptContext.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void listDerivesStateAndMasksName() throws ParseException
|
||||
{
|
||||
when(mapper.selectDutyTaskList(anyString(), anyString(), any(Date.class), any(Date.class),
|
||||
any(Date.class))).thenReturn(rows());
|
||||
|
||||
List<DutyTaskView> views = service.list(new DutyQuery(), at("2026-11-12 08:36:00"));
|
||||
|
||||
assertEquals(4, views.size(), "当日 4 条应全部返回");
|
||||
DutyTaskView overdueTodo = views.get(0);
|
||||
assertEquals("TODO", overdueTodo.getUiState());
|
||||
assertTrue(overdueTodo.isOverdue(), "08:30 的叫乘在 08:36 应判超时");
|
||||
assertEquals(-6L, overdueTodo.getRemainMinutes().longValue());
|
||||
assertTrue(overdueTodo.isUrgent(), "超时待叫乘必须进紧急区");
|
||||
assertEquals("李*", overdueTodo.getPersonName(), "姓名必须脱敏");
|
||||
assertEquals("2026-11-12 08:30:00", overdueTodo.getCallTime(), "时间格式统一到秒");
|
||||
|
||||
assertEquals("CALLING", views.get(1).getUiState());
|
||||
assertFalse(views.get(1).isUrgent(), "呼叫中不属紧急");
|
||||
|
||||
assertEquals("ALARM_CALL", views.get(2).getUiState());
|
||||
assertEquals("CALL", views.get(2).getAlarmType());
|
||||
assertTrue(views.get(2).isUrgent());
|
||||
|
||||
assertEquals("DONE", views.get(3).getUiState());
|
||||
}
|
||||
|
||||
@Test
|
||||
void keywordMatchesRoomFullNameAndTrainThenKeepsMaskedOutput() throws ParseException
|
||||
{
|
||||
when(mapper.selectDutyTaskList(anyString(), anyString(), any(Date.class), any(Date.class),
|
||||
any(Date.class))).thenReturn(rows());
|
||||
|
||||
DutyQuery query = new DutyQuery();
|
||||
query.setKeyword("李四");
|
||||
List<DutyTaskView> byFullName = service.list(query, at("2026-11-12 08:36:00"));
|
||||
assertEquals(1, byFullName.size(), "全名应能搜到(脱敏只作用于返回体)");
|
||||
assertEquals("李*", byFullName.get(0).getPersonName(), "搜到后仍返回脱敏名");
|
||||
|
||||
DutyQuery byRoom = new DutyQuery();
|
||||
byRoom.setKeyword("302");
|
||||
assertEquals(1, service.list(byRoom, at("2026-11-12 08:36:00")).size());
|
||||
|
||||
DutyQuery byTrain = new DutyQuery();
|
||||
byTrain.setKeyword("g5678");
|
||||
assertEquals(1, service.list(byTrain, at("2026-11-12 08:36:00")).size(),
|
||||
"车次匹配应忽略大小写");
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyKeywordKeepsAllRows() throws ParseException
|
||||
{
|
||||
when(mapper.selectDutyTaskList(anyString(), anyString(), any(Date.class), any(Date.class),
|
||||
any(Date.class))).thenReturn(rows());
|
||||
|
||||
DutyQuery query = new DutyQuery();
|
||||
query.setKeyword(" ");
|
||||
assertEquals(4, service.list(query, at("2026-11-12 08:36:00")).size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void scopeIsNormalizedToNeedByDefault() throws ParseException
|
||||
{
|
||||
when(mapper.selectDutyTaskList(eq("APT-A"), eq("need"), any(Date.class), any(Date.class),
|
||||
any(Date.class))).thenReturn(new ArrayList<>());
|
||||
|
||||
DutyQuery query = new DutyQuery();
|
||||
query.setScope("HACK");
|
||||
assertTrue(service.list(query, at("2026-11-12 08:36:00")).isEmpty());
|
||||
|
||||
DutyQuery done = new DutyQuery();
|
||||
done.setScope("done");
|
||||
assertEquals("done", done.getScope());
|
||||
}
|
||||
|
||||
@Test
|
||||
void countsTolerateNullAndBigDecimal()
|
||||
{
|
||||
Map<String, Object> raw = new LinkedHashMap<>();
|
||||
raw.put("todoCount", new BigDecimal("7"));
|
||||
raw.put("callCount", 2L);
|
||||
raw.put("alarmCount", null);
|
||||
raw.put("urgentCount", null);
|
||||
when(mapper.selectDutyCounts(anyString(), any(Date.class), any(Date.class), any(Date.class)))
|
||||
.thenReturn(raw);
|
||||
|
||||
Map<String, Object> counts = service.counts(atQuiet("2026-11-12 08:36:00"));
|
||||
|
||||
assertEquals(7L, counts.get("todoCount"), "BigDecimal 必须转 long");
|
||||
assertEquals(2L, counts.get("callCount"));
|
||||
assertEquals(0L, counts.get("alarmCount"), "SUM 无行时为 null,必须兜底 0");
|
||||
assertEquals(0L, counts.get("urgentCount"));
|
||||
assertEquals(0L, counts.get("total"));
|
||||
assertEquals("2026-11-12 08:36:00", counts.get("serverTime"), "serverTime 供前端判时钟偏差");
|
||||
assertNotNull(counts.get("serverTime"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyRowsReturnEmptyListNotException()
|
||||
{
|
||||
when(mapper.selectDutyTaskList(anyString(), anyString(), any(Date.class), any(Date.class),
|
||||
any(Date.class))).thenReturn(null);
|
||||
assertTrue(service.list(new DutyQuery(), new Date()).isEmpty());
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> rows() throws ParseException
|
||||
{
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
rows.add(row("JC1", "305", "李四", "G5678", at("2026-11-12 08:30:00"), "0", "0", "0"));
|
||||
rows.add(row("JC2", "302", "周八", "G2210", at("2026-11-12 08:40:00"), "1", "0", "0"));
|
||||
rows.add(row("JC3", "310", "郑九", "K1524", at("2026-11-12 08:52:00"), "2", "0", "0"));
|
||||
rows.add(row("JC4", "316", "朱十", "G1122", at("2026-11-12 08:50:00"), "0", "0", "1"));
|
||||
return rows;
|
||||
}
|
||||
|
||||
private Map<String, Object> row(String jcid, String roomNo, String name, String train,
|
||||
Date callTime, String ryzt, String stoped, String finishLy)
|
||||
{
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("jcid", jcid);
|
||||
row.put("roomNo", roomNo);
|
||||
row.put("personName", name);
|
||||
row.put("trainNo", train);
|
||||
row.put("callTime", callTime);
|
||||
row.put("departTime", null);
|
||||
row.put("ryzt", ryzt);
|
||||
row.put("finishJc", "0");
|
||||
row.put("finishLy", finishLy);
|
||||
row.put("stoped", stoped);
|
||||
row.put("matchLocked", "0");
|
||||
row.put("leaveRemark", null);
|
||||
return row;
|
||||
}
|
||||
|
||||
private Date at(String text) throws ParseException
|
||||
{
|
||||
return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").parse(text);
|
||||
}
|
||||
|
||||
private Date atQuiet(String text)
|
||||
{
|
||||
try
|
||||
{
|
||||
return at(text);
|
||||
}
|
||||
catch (ParseException e)
|
||||
{
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
package com.ruoyi.apt.duty;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.ruoyi.apt.common.AptContext;
|
||||
import com.ruoyi.apt.duty.realtime.DutyPushServiceImpl;
|
||||
import com.ruoyi.apt.framework.config.AptProperties;
|
||||
import com.ruoyi.apt.framework.realtime.AptRealtimeSessionRegistry;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
|
||||
/**
|
||||
* `/ws/duty` 推送实现断言(R3):seq、投递权限、事务时机、失败隔离。
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
class DutyPushServiceTest
|
||||
{
|
||||
private AptRealtimeSessionRegistry registry;
|
||||
|
||||
private AptProperties properties;
|
||||
|
||||
private DutyPushServiceImpl service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp()
|
||||
{
|
||||
registry = mock(AptRealtimeSessionRegistry.class);
|
||||
properties = mock(AptProperties.class);
|
||||
when(properties.getGyId()).thenReturn("APT-A");
|
||||
service = new DutyPushServiceImpl(registry, properties);
|
||||
AptContext.clear();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown()
|
||||
{
|
||||
AptContext.clear();
|
||||
if (TransactionSynchronizationManager.isSynchronizationActive())
|
||||
{
|
||||
TransactionSynchronizationManager.clearSynchronization();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void seqIsMonotonicPerApartment()
|
||||
{
|
||||
assertEquals(1L, service.stateChanged("JC1", 1, 3, "LEAVE", "待离寓", "ACT-1"));
|
||||
assertEquals(2L, service.alarmRaised("JC1", "CALL", "JC1:CALL"));
|
||||
assertEquals(3L, service.alarmCleared("JC1", "CALL", "JC1:CALL"));
|
||||
assertEquals(4L, service.sessionChanged("JC1", "SESS-1", "JOINED_CONF", "8001"));
|
||||
assertEquals(4L, service.currentSeq(), "连接建立时下发的 lastSeq 应等于当前水位");
|
||||
}
|
||||
|
||||
@Test
|
||||
void payloadCarriesDutyPermissionAndDerivedState()
|
||||
{
|
||||
service.stateChanged("JC2", 1, 2, "ALARM_CALL", "叫乘报警", "ACT-2");
|
||||
|
||||
ArgumentCaptor<String> payload = ArgumentCaptor.forClass(String.class);
|
||||
verify(registry).sendToPermission(eq("APT-A"), eq("duty:jcxx:list"), payload.capture());
|
||||
|
||||
JSONObject json = JSON.parseObject(payload.getValue());
|
||||
assertEquals("state.changed", json.getString("type"));
|
||||
assertEquals("CALL", json.getString("category"));
|
||||
assertEquals("P1", json.getString("priority"));
|
||||
assertEquals("duty:jcxx:list", json.getString("targetPermission"));
|
||||
assertEquals("APT-A", json.getString("gyid"));
|
||||
assertNotNull(json.getString("id"));
|
||||
assertEquals(1L, json.getLongValue("seq"));
|
||||
JSONObject data = json.getJSONObject("data");
|
||||
assertEquals("JC2", data.getString("jcid"));
|
||||
assertEquals(1, data.getIntValue("ryztBefore"));
|
||||
assertEquals(2, data.getIntValue("ryztAfter"));
|
||||
assertEquals("ALARM_CALL", data.getString("uiState"));
|
||||
assertEquals("叫乘报警", data.getString("stateLabel"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void alarmMessageAsksClientToPlaySound()
|
||||
{
|
||||
service.alarmRaised("JC3", "LEAVE", "JC3:LEAVE");
|
||||
|
||||
ArgumentCaptor<String> payload = ArgumentCaptor.forClass(String.class);
|
||||
verify(registry).sendToPermission(anyString(), anyString(), payload.capture());
|
||||
JSONObject json = JSON.parseObject(payload.getValue());
|
||||
assertEquals("alarm.raised", json.getString("type"));
|
||||
assertEquals("ALARM", json.getString("category"));
|
||||
assertEquals("P0", json.getString("priority"), "报警必须是 P0");
|
||||
assertTrue(json.getJSONObject("data").getBooleanValue("sound"));
|
||||
assertEquals("JC3:LEAVE", json.getJSONObject("data").getString("alarmEventKey"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void insideTransactionNothingIsSentBeforeCommit()
|
||||
{
|
||||
TransactionSynchronizationManager.initSynchronization();
|
||||
|
||||
long seq = service.stateChanged("JC4", 0, 1, "CALLING", "呼叫中", "ACT-4");
|
||||
|
||||
assertEquals(1L, seq, "seq 在登记时就分配,命令响应才能回给前端");
|
||||
verify(registry, never()).sendToPermission(anyString(), anyString(), anyString());
|
||||
|
||||
for (TransactionSynchronization synchronization : TransactionSynchronizationManager.getSynchronizations())
|
||||
{
|
||||
synchronization.afterCommit();
|
||||
}
|
||||
verify(registry).sendToPermission(eq("APT-A"), eq("duty:jcxx:list"), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void registryFailureIsSwallowedAndSeqStillReturned()
|
||||
{
|
||||
when(registry.sendToPermission(anyString(), anyString(), anyString()))
|
||||
.thenThrow(new IllegalStateException("channel down"));
|
||||
|
||||
// 通道炸了只影响"送达",不影响"已分配水位";绝不能把异常抛给命令调用方
|
||||
assertEquals(1L, service.alarmRaised("JC5", "CALL", "JC5:CALL"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void withoutApartmentContextNothingIsPushed()
|
||||
{
|
||||
when(properties.getGyId()).thenReturn(" ");
|
||||
|
||||
assertEquals(-1L, service.stateChanged("JC6", 0, 1, "CALLING", "呼叫中", "ACT-6"));
|
||||
verify(registry, never()).sendToPermission(anyString(), anyString(), anyString());
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
package com.ruoyi.apt.duty;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* `/ws/duty` 的三条配置契约(无运行时也能守住)。
|
||||
*
|
||||
* <p>WebSocket 最容易出的坑不是业务,而是"路径没放行 → 握手 401"、
|
||||
* "权限点写错 → 订阅了收不到"。这三条用源码断言钉住。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
class DutyRealtimeContractTest
|
||||
{
|
||||
@Test
|
||||
void securityConfigPermitsDutyHandshake() throws IOException
|
||||
{
|
||||
String source = read("dev/RuoYi-Vue-master/ruoyi-framework/src/main/java/com/ruoyi/framework/config/SecurityConfig.java");
|
||||
assertTrue(source.contains("\"/ws/duty\""),
|
||||
"SecurityConfig 必须放行 /ws/duty,否则握手会被 anyRequest().authenticated() 拦成 401");
|
||||
}
|
||||
|
||||
@Test
|
||||
void websocketConfigRegistersDutyPath() throws IOException
|
||||
{
|
||||
String source = read("dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/realtime/DutyWebSocketConfig.java");
|
||||
assertTrue(source.contains("\"/ws/duty\""), "必须注册 /ws/duty 端点");
|
||||
assertTrue(source.contains("apt.realtime"), "实时总开关必须复用 apt.realtime.enabled,便于整体降级");
|
||||
}
|
||||
|
||||
@Test
|
||||
void pushUsesDutyPermissionNotApartmentOne() throws IOException
|
||||
{
|
||||
String source = read("dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/duty/realtime/DutyPushServiceImpl.java");
|
||||
assertTrue(source.contains("\"duty:jcxx:list\""), "值班台推送必须投给 duty:jcxx:list");
|
||||
assertTrue(source.contains("afterCommit"), "事务内必须延迟到提交后发送");
|
||||
}
|
||||
|
||||
private String read(String relative) throws IOException
|
||||
{
|
||||
Path root = locateRepoRoot();
|
||||
return Files.readString(root.resolve(relative), StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private Path locateRepoRoot()
|
||||
{
|
||||
Path current = Paths.get(System.getProperty("user.dir")).toAbsolutePath();
|
||||
int depth = 0;
|
||||
while (current != null && depth < 8)
|
||||
{
|
||||
if (Files.isDirectory(current.resolve("dev/RuoYi-Vue-master")))
|
||||
{
|
||||
return current;
|
||||
}
|
||||
if (Files.isDirectory(current.resolve("RuoYi-Vue-master")))
|
||||
{
|
||||
return current.getParent();
|
||||
}
|
||||
current = current.getParent();
|
||||
depth++;
|
||||
}
|
||||
throw new IllegalStateException("未找到仓库根目录");
|
||||
}
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
package com.ruoyi.apt.duty;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* R1 值班台脚本契约(对齐 `AptIt5SqlContractTest` 的做法)。
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
class DutySqlContractTest
|
||||
{
|
||||
private static final String[] DUTY_PERMS = { "duty:jcxx:list", "duty:jcxx:query",
|
||||
"duty:call:stop", "duty:call:recall", "duty:call:cancel", "duty:call:forceFinish",
|
||||
"duty:call:markLeave" };
|
||||
|
||||
@Test
|
||||
void permissionScriptRegistersSevenDutyPoints() throws IOException
|
||||
{
|
||||
String sql = read("31_duty_permissions.sql");
|
||||
for (String perm : DUTY_PERMS)
|
||||
{
|
||||
assertTrue(sql.contains(perm), "缺少权限点:" + perm);
|
||||
}
|
||||
assertTrue(sql.contains("ON DUPLICATE KEY UPDATE"), "权限脚本必须可重放");
|
||||
assertTrue(sql.contains("sys_role_menu"), "缺少超管绑定");
|
||||
assertTrue(sql.contains("'F', '0', '0'"), "权限点应为可见的 F 类型");
|
||||
}
|
||||
|
||||
@Test
|
||||
void permissionScriptDoesNotCreatePageMenu() throws IOException
|
||||
{
|
||||
String sql = read("31_duty_permissions.sql");
|
||||
assertFalse(sql.contains("'C', '0', '0'"),
|
||||
"R1 不得插 C 页面菜单,否则与前端静态路由 /duty/jcxx 冲突");
|
||||
}
|
||||
|
||||
@Test
|
||||
void permissionScriptNeverWritesBusinessTables() throws IOException
|
||||
{
|
||||
String sql = read("31_duty_permissions.sql");
|
||||
for (String table : new String[] { "gy_jcxx", "gy_bjxx", "duty_manual_action",
|
||||
"duty_operation_log" })
|
||||
{
|
||||
assertFalse(sql.contains("INSERT INTO " + table) || sql.contains("UPDATE " + table),
|
||||
"权限脚本不得写业务表:" + table);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void scriptNeverGrantsAptPrefixedPointsToDuty() throws IOException
|
||||
{
|
||||
String sql = read("31_duty_permissions.sql");
|
||||
assertFalse(sql.contains("'apt:jcxx:lockMatch'"),
|
||||
"值班台不注册管理端锁定权限点(应用端/02 表 2-9 命名纪律)");
|
||||
assertFalse(sql.contains("'apt:jcxx:changeStatus'"),
|
||||
"值班台命令走 duty:call:*,不复用管理端 changeStatus");
|
||||
}
|
||||
|
||||
private String read(String name) throws IOException
|
||||
{
|
||||
Path dir = locateSqlDir();
|
||||
assertNotNull(dir, "未找到 dev/sql/apt_rev01");
|
||||
return Files.readString(dir.resolve(name), StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private Path locateSqlDir()
|
||||
{
|
||||
Path current = Paths.get(System.getProperty("user.dir")).toAbsolutePath();
|
||||
int depth = 0;
|
||||
while (current != null && depth < 8)
|
||||
{
|
||||
Path direct = current.resolve("dev/sql/apt_rev01");
|
||||
if (Files.isDirectory(direct))
|
||||
{
|
||||
return direct;
|
||||
}
|
||||
Path sibling = current.resolve("sql/apt_rev01");
|
||||
if (Files.isDirectory(sibling))
|
||||
{
|
||||
return sibling;
|
||||
}
|
||||
current = current.getParent();
|
||||
depth++;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
package com.ruoyi.apt.duty;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import java.text.ParseException;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
import com.ruoyi.apt.duty.domain.DutyStateDeriver;
|
||||
import com.ruoyi.apt.duty.domain.DutyUiState;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* 值班台状态派生口径(应用端/11 表 11-2)的逐行断言。
|
||||
*
|
||||
* <p>这张表是前后端唯一口径,任何一行变了都必须先改文档再改这里。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
class DutyStateDeriverTest
|
||||
{
|
||||
private static final String[] RYZT_ALL = { "0", "1", "2", "3", "4", "5" };
|
||||
private static final DutyUiState[] EXPECTED = { DutyUiState.TODO, DutyUiState.CALLING,
|
||||
DutyUiState.ALARM_CALL, DutyUiState.LEAVE, DutyUiState.ALARM_LEAVE, DutyUiState.TALKING };
|
||||
|
||||
@Test
|
||||
void ryztMapsToSixStates()
|
||||
{
|
||||
for (int i = 0; i < RYZT_ALL.length; i++)
|
||||
{
|
||||
assertEquals(EXPECTED[i], DutyStateDeriver.derive(RYZT_ALL[i], "0", "0"),
|
||||
"ryzt=" + RYZT_ALL[i] + " 派生错误");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void terminalFlagsWinOverRyzt()
|
||||
{
|
||||
// 已离寓是终态:即使 ryzt 还是 2,也判 DONE
|
||||
assertEquals(DutyUiState.DONE, DutyStateDeriver.derive("2", "0", "1"));
|
||||
// 已停止优先于 ryzt=1
|
||||
assertEquals(DutyUiState.STOPPED, DutyStateDeriver.derive("1", "1", "0"));
|
||||
// 无 ryzt 但有未完成在寓事实 → 已入寓(等待排任务)
|
||||
assertEquals(DutyUiState.IN, DutyStateDeriver.derive(null, "0", "0"));
|
||||
assertEquals(DutyUiState.IN, DutyStateDeriver.derive("", "0", "0"));
|
||||
assertEquals(DutyUiState.IN, DutyStateDeriver.derive("9", "0", "0"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void alarmTypeOnlyForAlarmStates()
|
||||
{
|
||||
assertEquals("CALL", DutyStateDeriver.alarmType(DutyUiState.ALARM_CALL));
|
||||
assertEquals("LEAVE", DutyStateDeriver.alarmType(DutyUiState.ALARM_LEAVE));
|
||||
assertNull(DutyStateDeriver.alarmType(DutyUiState.CALLING));
|
||||
assertNull(DutyStateDeriver.alarmType(DutyUiState.TODO));
|
||||
}
|
||||
|
||||
@Test
|
||||
void overdueOnlyAppliesToTodo() throws ParseException
|
||||
{
|
||||
Date call = at("2026-11-12 08:30:00");
|
||||
Date now = at("2026-11-12 08:36:00");
|
||||
|
||||
assertTrue(DutyStateDeriver.overdue(DutyUiState.TODO, call, now));
|
||||
assertFalse(DutyStateDeriver.overdue(DutyUiState.CALLING, call, now));
|
||||
// 没有 jcsj(未排任务)不算超时
|
||||
assertFalse(DutyStateDeriver.overdue(DutyUiState.TODO, null, now));
|
||||
// 未到点不算超时
|
||||
assertFalse(DutyStateDeriver.overdue(DutyUiState.TODO, at("2026-11-12 09:00:00"), now));
|
||||
}
|
||||
|
||||
@Test
|
||||
void remainMinutesIsNegativeWhenOverdue() throws ParseException
|
||||
{
|
||||
Date now = at("2026-11-12 08:36:00");
|
||||
assertEquals(-6L, DutyStateDeriver.remainMinutes(at("2026-11-12 08:30:00"), now).longValue());
|
||||
assertEquals(24L, DutyStateDeriver.remainMinutes(at("2026-11-12 09:00:00"), now).longValue());
|
||||
assertNull(DutyStateDeriver.remainMinutes(null, now));
|
||||
}
|
||||
|
||||
@Test
|
||||
void urgentCoversBothAlarmsAndOverdueTodo() throws ParseException
|
||||
{
|
||||
Date now = at("2026-11-12 08:36:00");
|
||||
assertTrue(DutyStateDeriver.urgent(DutyUiState.ALARM_CALL, false));
|
||||
assertTrue(DutyStateDeriver.urgent(DutyUiState.ALARM_LEAVE, false));
|
||||
assertTrue(DutyStateDeriver.urgent(DutyUiState.TODO, true));
|
||||
assertFalse(DutyStateDeriver.urgent(DutyUiState.TODO, false));
|
||||
assertFalse(DutyStateDeriver.urgent(DutyUiState.TALKING, false));
|
||||
assertFalse(DutyStateDeriver.urgent(DutyUiState.TODO,
|
||||
DutyStateDeriver.overdue(DutyUiState.TODO, at("2026-11-12 09:30:00"), now)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void namesAreMaskedButStillSearchableByFullName()
|
||||
{
|
||||
assertEquals("李*", DutyStateDeriver.maskName("李四"));
|
||||
assertEquals("欧**", DutyStateDeriver.maskName("欧阳锋"));
|
||||
assertEquals("张*", DutyStateDeriver.maskName("张"));
|
||||
assertNull(DutyStateDeriver.maskName(null));
|
||||
assertEquals("", DutyStateDeriver.maskName(""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void needCoversSixStatesButNotTerminal()
|
||||
{
|
||||
assertTrue(DutyUiState.TODO.isNeed());
|
||||
assertTrue(DutyUiState.ALARM_CALL.isNeed());
|
||||
assertTrue(DutyUiState.LEAVE.isNeed());
|
||||
assertFalse(DutyUiState.IN.isNeed());
|
||||
assertFalse(DutyUiState.DONE.isNeed());
|
||||
assertFalse(DutyUiState.STOPPED.isNeed());
|
||||
}
|
||||
|
||||
private Date at(String text) throws ParseException
|
||||
{
|
||||
return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").parse(text);
|
||||
}
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
package com.ruoyi.apt.duty.realtime;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
import com.ruoyi.apt.dispatch.domain.AptStateSnapshot;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* 状态机迁移推送桥断言(R4 / `L-11`、`04` 表 4-12)。
|
||||
*
|
||||
* <p>四条边界:报警起停只在真变化时发、读不到新态不猜、推送异常不影响业务、
|
||||
* {@code pushSeq} 只回填本线程本次迁移的序号。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
class DutyTransitionPushTest
|
||||
{
|
||||
private IDutyPushService pushService;
|
||||
|
||||
private DutyTransitionPush bridge;
|
||||
|
||||
@BeforeEach
|
||||
void setUp()
|
||||
{
|
||||
pushService = mock(IDutyPushService.class);
|
||||
bridge = new DutyTransitionPush(pushService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void turnIntoCallAlarmRaisesSoundWithStableKey()
|
||||
{
|
||||
when(pushService.stateChanged(anyString(), any(), any(), anyString(), anyString(), any()))
|
||||
.thenReturn(11L);
|
||||
|
||||
bridge.afterTransition("JC1", snapshot("1", "0", "0", "0"), snapshot("2", "0", "0", "0"), null);
|
||||
|
||||
verify(pushService).stateChanged("JC1", 1, 2, "ALARM_CALL", "叫乘报警", null);
|
||||
verify(pushService).alarmRaised("JC1", "CALL", "JC1:CALL");
|
||||
verify(pushService, never()).alarmCleared(anyString(), anyString(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameStateTwiceDoesNotEmitFakeClear()
|
||||
{
|
||||
// 只改 finish_jc 的 FORCE_FINISH:前后都是 ALARM_CALL,声音必须继续响
|
||||
bridge.afterTransition("JC2", snapshot("2", "0", "0", "0"), snapshot("2", "1", "0", "0"), "ACT-2");
|
||||
|
||||
verify(pushService).stateChanged("JC2", 2, 2, "ALARM_CALL", "叫乘报警", "ACT-2");
|
||||
verify(pushService, never()).alarmRaised(anyString(), anyString(), anyString());
|
||||
verify(pushService, never()).alarmCleared(anyString(), anyString(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolvedCallAlarmStopsSound()
|
||||
{
|
||||
bridge.afterTransition("JC3", snapshot("2", "0", "0", "0"), snapshot("3", "1", "0", "0"), null);
|
||||
|
||||
verify(pushService).stateChanged("JC3", 2, 3, "LEAVE", "待离寓", null);
|
||||
verify(pushService).alarmCleared("JC3", "CALL", "JC3:CALL");
|
||||
verify(pushService, never()).alarmRaised(anyString(), anyString(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void leaveAlarmUsesLeaveTypeAndKey()
|
||||
{
|
||||
bridge.afterTransition("JC4", null, snapshot("4", "0", "0", "0"), null);
|
||||
|
||||
verify(pushService).stateChanged("JC4", null, 4, "ALARM_LEAVE", "离寓报警", null);
|
||||
verify(pushService).alarmRaised("JC4", "LEAVE", "JC4:LEAVE");
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingSnapshotAfterTransitionSkipsPushAndSeq()
|
||||
{
|
||||
bridge.afterTransition("JC5", snapshot("1", "0", "0", "0"), null, null);
|
||||
|
||||
verifyNoInteractions(pushService);
|
||||
assertEquals(-1L, bridge.takeStateSeq("JC5"), "读不到新态就不该有 pushSeq");
|
||||
}
|
||||
|
||||
@Test
|
||||
void pushFailureIsSwallowedAndSeqIsMinusOne()
|
||||
{
|
||||
when(pushService.stateChanged(anyString(), any(), any(), anyString(), anyString(), any()))
|
||||
.thenThrow(new IllegalStateException("推送通道炸了"));
|
||||
|
||||
assertDoesNotThrow(() -> bridge.afterTransition("JC6",
|
||||
snapshot("1", "0", "0", "0"), snapshot("2", "0", "0", "0"), null));
|
||||
assertEquals(-1L, bridge.takeStateSeq("JC6"), "未登记时 pushSeq 为 -1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void seqIsOneShotAndBoundToJcid()
|
||||
{
|
||||
when(pushService.stateChanged(anyString(), any(), any(), anyString(), anyString(), any()))
|
||||
.thenReturn(21L, 22L);
|
||||
|
||||
bridge.afterTransition("JC7", snapshot("1", "0", "0", "0"), snapshot("2", "0", "0", "0"), null);
|
||||
assertEquals(21L, bridge.takeStateSeq("JC7"));
|
||||
assertEquals(-1L, bridge.takeStateSeq("JC7"), "取过一次就必须清掉,避免回填到下一次命令");
|
||||
|
||||
bridge.afterTransition("JC8", snapshot("1", "0", "0", "0"), snapshot("2", "0", "0", "0"), null);
|
||||
assertEquals(-1L, bridge.takeStateSeq("JC7"), "jcid 不匹配不能回填");
|
||||
assertEquals(22L, bridge.takeStateSeq("JC8"));
|
||||
}
|
||||
|
||||
private AptStateSnapshot snapshot(String ryzt, String finishJc, String finishLy, String stoped)
|
||||
{
|
||||
AptStateSnapshot snapshot = new AptStateSnapshot();
|
||||
snapshot.setRyzt(ryzt);
|
||||
snapshot.setFinishJc(finishJc);
|
||||
snapshot.setFinishLy(finishLy);
|
||||
snapshot.setStoped(stoped);
|
||||
return snapshot;
|
||||
}
|
||||
}
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
package com.ruoyi.apt.duty.realtime;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.atLeastOnce;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.ruoyi.apt.framework.realtime.AptRealtimeSessionRegistry;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.web.socket.CloseStatus;
|
||||
import org.springframework.web.socket.TextMessage;
|
||||
import org.springframework.web.socket.WebSocketSession;
|
||||
|
||||
/**
|
||||
* `/ws/duty` 端点行为(R3):CONNECTED 握手回执、PING/PONG、非法连接直接关。
|
||||
*
|
||||
* <p>放在 realtime 包内,才能直接调用 {@code protected handleTextMessage}
|
||||
* (不为测试把生产方法放宽成 public)。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
class DutyWebSocketHandlerTest
|
||||
{
|
||||
private AptRealtimeSessionRegistry registry;
|
||||
|
||||
private IDutyPushService pushService;
|
||||
|
||||
private DutyWebSocketHandler handler;
|
||||
|
||||
private WebSocketSession session;
|
||||
|
||||
private Map<String, Object> attributes;
|
||||
|
||||
@BeforeEach
|
||||
void setUp()
|
||||
{
|
||||
registry = mock(AptRealtimeSessionRegistry.class);
|
||||
pushService = mock(IDutyPushService.class);
|
||||
handler = new DutyWebSocketHandler(registry, pushService);
|
||||
when(pushService.currentSeq()).thenReturn(7L);
|
||||
|
||||
attributes = new HashMap<>();
|
||||
attributes.put("userId", 7L);
|
||||
attributes.put("username", "duty1");
|
||||
attributes.put("gyid", "APT-A");
|
||||
attributes.put("permissions", Set.of("duty:jcxx:list"));
|
||||
|
||||
session = mock(WebSocketSession.class);
|
||||
when(session.getId()).thenReturn("S1");
|
||||
when(session.getAttributes()).thenReturn(attributes);
|
||||
when(session.isOpen()).thenReturn(true);
|
||||
}
|
||||
|
||||
@Test
|
||||
void connectReturnsHeartbeatAndWatermark() throws Exception
|
||||
{
|
||||
handler.afterConnectionEstablished(session);
|
||||
|
||||
verify(registry).register(session, 7L, "duty1", "APT-A", Set.of("duty:jcxx:list"));
|
||||
|
||||
JSONObject message = lastSent();
|
||||
assertEquals("CONNECTED", message.getString("type"));
|
||||
JSONObject data = message.getJSONObject("data");
|
||||
assertEquals(30, data.getIntValue("heartbeatIntervalSec"), "心跳间隔必须是 30 s");
|
||||
assertEquals(7L, data.getLongValue("lastSeq"), "必须下发当前水位,前端据此丢重");
|
||||
assertEquals("S1", data.getString("sessionId"));
|
||||
assertEquals("APT-A", data.getString("gyid"));
|
||||
assertTrue(data.getJSONArray("messages").contains("alarm.raised"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void pingGetsPongWithEchoedTimestamp() throws Exception
|
||||
{
|
||||
handler.afterConnectionEstablished(session);
|
||||
handler.handleTextMessage(session, new TextMessage("{\"type\":\"PING\",\"ts\":123456}"));
|
||||
|
||||
JSONObject message = lastSent();
|
||||
assertEquals("PONG", message.getString("type"));
|
||||
assertEquals(123456L, message.getJSONObject("data").getLongValue("ts"));
|
||||
assertEquals(7L, message.getJSONObject("data").getLongValue("lastSeq"));
|
||||
verify(registry).touch("S1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void plainTextPingIsAccepted() throws Exception
|
||||
{
|
||||
handler.afterConnectionEstablished(session);
|
||||
handler.handleTextMessage(session, new TextMessage("PING"));
|
||||
assertEquals("PONG", lastSent().getString("type"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void connectionWithoutIdentityIsRejected() throws Exception
|
||||
{
|
||||
attributes.remove("userId");
|
||||
|
||||
handler.afterConnectionEstablished(session);
|
||||
|
||||
verify(session).close(CloseStatus.NOT_ACCEPTABLE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void closedConnectionIsRemovedFromRegistry()
|
||||
{
|
||||
handler.afterConnectionClosed(session, CloseStatus.NORMAL);
|
||||
verify(registry).remove("S1");
|
||||
}
|
||||
|
||||
private JSONObject lastSent() throws Exception
|
||||
{
|
||||
ArgumentCaptor<TextMessage> captor = ArgumentCaptor.forClass(TextMessage.class);
|
||||
verify(session, atLeastOnce()).sendMessage(captor.capture());
|
||||
List<TextMessage> all = captor.getAllValues();
|
||||
return JSON.parseObject(all.get(all.size() - 1).getPayload());
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -66,10 +66,11 @@ class AptVoiceEventServiceTest
|
||||
}
|
||||
|
||||
@Test
|
||||
void roomConfbridgeJoinKeepsCallingState()
|
||||
void roomConfbridgeJoinMovesToTalking()
|
||||
{
|
||||
service.handle(command("CONFBRIDGEJOIN"));
|
||||
|
||||
// 入会 -> 5 通话中;不能直接跳到待离寓(3)
|
||||
verify(stateService).joinConf("JC-1");
|
||||
verify(stateService, never()).answered(anyString());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
/** 值班台列表(默认"当日 + 需处理") */
|
||||
export function listDutyTasks(params) {
|
||||
return request({
|
||||
url: '/duty/jcxx',
|
||||
method: 'get',
|
||||
params: params
|
||||
})
|
||||
}
|
||||
|
||||
/** 吸底统计 + 紧急条数 + serverTime(用于时钟偏差提示) */
|
||||
export function getDutyCounts() {
|
||||
return request({
|
||||
url: '/duty/jcxx/counts',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
/** 详情(抽屉):叫乘行 + 会话 + 最近人工动作 */
|
||||
export function getDutyTask(jcid) {
|
||||
return request({
|
||||
url: '/duty/jcxx/' + encodeURIComponent(jcid),
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
/** 时间线(抽屉):按时间倒序的关键节点 */
|
||||
export function getDutyTimeline(jcid) {
|
||||
return request({
|
||||
url: '/duty/jcxx/' + encodeURIComponent(jcid) + '/timeline',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行状态命令。
|
||||
* @param {String} jcid 任务业务键
|
||||
* @param {Object} data { commandType, commandId, reason, expectedState }
|
||||
*/
|
||||
export function executeDutyCommand(jcid, data) {
|
||||
return request({
|
||||
url: '/duty/jcxx/' + encodeURIComponent(jcid) + '/commands',
|
||||
method: 'post',
|
||||
data: data
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
/**
|
||||
* 值班台测试配置(应用端/17)。
|
||||
*
|
||||
* 四组能力:房间信息、房间↔分机绑定、SIP 服务器(AMI)、测试办理入住。
|
||||
* 写接口在后端受 apt.debug.enabled 二次保护,关闭时返回 40940。
|
||||
*/
|
||||
|
||||
/** 面板状态:debug/voice 开关、AMI 配置、房间与人员概况 */
|
||||
export function getTestStatus() {
|
||||
return request({
|
||||
url: '/duty/test/status',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
/** 房间看板:平台基线 + 本端登记 + 当前分机 + 在住人数 */
|
||||
export function listTestRooms() {
|
||||
return request({
|
||||
url: '/duty/test/rooms',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
/** 录入房间(留空分机号则按号段自动取号) */
|
||||
export function createTestRoom(data) {
|
||||
return request({
|
||||
url: '/duty/test/rooms',
|
||||
method: 'post',
|
||||
data: data
|
||||
})
|
||||
}
|
||||
|
||||
/** 分机占用看板 */
|
||||
export function listExtensions() {
|
||||
return request({
|
||||
url: '/duty/test/sip/extensions',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
/** 绑定分机号(分机号创建后不可改) */
|
||||
export function bindSip(data) {
|
||||
return request({
|
||||
url: '/duty/test/sip/bind',
|
||||
method: 'post',
|
||||
data: data
|
||||
})
|
||||
}
|
||||
|
||||
/** 解绑(分机号不回收) */
|
||||
export function unbindSip(roomNo) {
|
||||
return request({
|
||||
url: '/duty/test/sip/bind/' + encodeURIComponent(roomNo),
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
|
||||
/** 当前 AMI 配置(口令只回引用名) */
|
||||
export function getSipServer() {
|
||||
return request({
|
||||
url: '/duty/test/sip/server',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
/** 保存 AMI 配置 */
|
||||
export function saveSipServer(data) {
|
||||
return request({
|
||||
url: '/duty/test/sip/server',
|
||||
method: 'put',
|
||||
data: data
|
||||
})
|
||||
}
|
||||
|
||||
/** 连通性探测(TCP 端口 + 口令是否已注入) */
|
||||
export function probeSipServer() {
|
||||
return request({
|
||||
url: '/duty/test/sip/server/probe',
|
||||
method: 'post'
|
||||
})
|
||||
}
|
||||
|
||||
/** 可办理入住的人员与可住房间 */
|
||||
export function getCheckinCandidates() {
|
||||
return request({
|
||||
url: '/duty/test/checkin/candidates',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
/** 测试办理入住(服务端走 apply + 自动通过) */
|
||||
export function testCheckin(data) {
|
||||
return request({
|
||||
url: '/duty/test/checkin',
|
||||
method: 'post',
|
||||
data: data
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* 值班台告警音(应用端/11 表 11-9 R3)。
|
||||
*
|
||||
* 三个必须做对的点:
|
||||
* 1. **单例**:多条报警同时来只播一路声音,否则三张卡片叠在一起像故障;
|
||||
* 2. **按 key 停**:`alarmEventKey` 对应一条报警,消警只停那一条;
|
||||
* 3. **首次交互解锁**:浏览器自动播放策略会让"进页面就响"失败,
|
||||
* 未解锁时先挂起,等值班员点一下页面再补响(不能静默失败)。
|
||||
*
|
||||
* 声音用 Web Audio 合成(不引入音频文件),TTS 用浏览器 speechSynthesis。
|
||||
*/
|
||||
|
||||
const state = {
|
||||
ctx: null,
|
||||
unlocked: false,
|
||||
rings: new Map(),
|
||||
tts: []
|
||||
}
|
||||
|
||||
function audioContext() {
|
||||
if (state.ctx) {
|
||||
return state.ctx
|
||||
}
|
||||
const Ctx = window.AudioContext || window.webkitAudioContext
|
||||
if (!Ctx) {
|
||||
return null
|
||||
}
|
||||
state.ctx = new Ctx()
|
||||
return state.ctx
|
||||
}
|
||||
|
||||
/** 由第一次用户手势调用;成功后把挂起的报警补响 */
|
||||
export function unlockAudio() {
|
||||
if (state.unlocked) {
|
||||
return true
|
||||
}
|
||||
const ctx = audioContext()
|
||||
if (!ctx) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
if (ctx.state === 'suspended') {
|
||||
ctx.resume()
|
||||
}
|
||||
// 播放一个几乎无声的帧,真正把音频通道解锁
|
||||
const osc = ctx.createOscillator()
|
||||
const gain = ctx.createGain()
|
||||
gain.gain.value = 0.0001
|
||||
osc.connect(gain)
|
||||
gain.connect(ctx.destination)
|
||||
osc.start()
|
||||
osc.stop(ctx.currentTime + 0.02)
|
||||
state.unlocked = true
|
||||
flushPending()
|
||||
return true
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function isUnlocked() {
|
||||
return state.unlocked
|
||||
}
|
||||
|
||||
function beep(freq, duration, volume) {
|
||||
const ctx = audioContext()
|
||||
if (!ctx) {
|
||||
return
|
||||
}
|
||||
const osc = ctx.createOscillator()
|
||||
const gain = ctx.createGain()
|
||||
osc.type = 'triangle'
|
||||
osc.frequency.value = freq
|
||||
gain.gain.value = volume
|
||||
osc.connect(gain)
|
||||
gain.connect(ctx.destination)
|
||||
const now = ctx.currentTime
|
||||
gain.gain.setValueAtTime(volume, now)
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, now + duration)
|
||||
osc.start(now)
|
||||
osc.stop(now + duration)
|
||||
}
|
||||
|
||||
function pattern() {
|
||||
beep(880, 0.18, 0.12)
|
||||
setTimeout(() => beep(660, 0.18, 0.12), 220)
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始一条报警的声音。
|
||||
* @returns {Boolean} 是否真的开始播(false 表示被自动播放策略拦住,已挂起)
|
||||
*/
|
||||
export function ring(key) {
|
||||
if (!key) {
|
||||
return false
|
||||
}
|
||||
if (state.rings.has(key)) {
|
||||
return true
|
||||
}
|
||||
if (!state.unlocked) {
|
||||
state.rings.set(key, { pending: true, timer: null })
|
||||
return false
|
||||
}
|
||||
pattern()
|
||||
const timer = setInterval(pattern, 1600)
|
||||
state.rings.set(key, { pending: false, timer: timer })
|
||||
return true
|
||||
}
|
||||
|
||||
/** 停止某条报警的声音;未知 key 静默忽略 */
|
||||
export function stopRing(key) {
|
||||
const entry = state.rings.get(key)
|
||||
if (!entry) {
|
||||
return
|
||||
}
|
||||
if (entry.timer) {
|
||||
clearInterval(entry.timer)
|
||||
}
|
||||
state.rings.delete(key)
|
||||
}
|
||||
|
||||
export function stopAll() {
|
||||
state.rings.forEach(entry => {
|
||||
if (entry.timer) {
|
||||
clearInterval(entry.timer)
|
||||
}
|
||||
})
|
||||
state.rings.clear()
|
||||
}
|
||||
|
||||
export function activeKeys() {
|
||||
return Array.from(state.rings.keys())
|
||||
}
|
||||
|
||||
function flushPending() {
|
||||
const pending = []
|
||||
state.rings.forEach((entry, key) => {
|
||||
if (entry.pending) {
|
||||
pending.push(key)
|
||||
}
|
||||
})
|
||||
pending.forEach(key => {
|
||||
state.rings.delete(key)
|
||||
ring(key)
|
||||
})
|
||||
}
|
||||
|
||||
/** 语音播报:单队列,避免多条报警同时说话 */
|
||||
export function speak(text) {
|
||||
if (!text || !state.unlocked || !window.speechSynthesis) {
|
||||
return false
|
||||
}
|
||||
state.tts.push(String(text))
|
||||
if (state.tts.length > 5) {
|
||||
state.tts.splice(0, state.tts.length - 5)
|
||||
}
|
||||
drain()
|
||||
return true
|
||||
}
|
||||
|
||||
function drain() {
|
||||
if (!window.speechSynthesis || window.speechSynthesis.speaking || !state.tts.length) {
|
||||
return
|
||||
}
|
||||
const utterance = new SpeechSynthesisUtterance(state.tts.shift())
|
||||
utterance.lang = 'zh-CN'
|
||||
utterance.rate = 0.95
|
||||
utterance.onend = drain
|
||||
utterance.onerror = drain
|
||||
window.speechSynthesis.speak(utterance)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* 值班台跳转白名单(应用端/12 表 12-6)。
|
||||
*
|
||||
* 登录页的 ?redirect= 来自 URL,属于不可信输入:不校验就能被打成
|
||||
* 开放重定向(?redirect=//evil.com)或死循环(?redirect=/duty/login)。
|
||||
* 规则固定为:只允许本站 /duty/ 下的绝对路径。
|
||||
*/
|
||||
|
||||
export const DUTY_HOME = '/duty/jcxx'
|
||||
export const DUTY_LOGIN = '/duty/login'
|
||||
|
||||
/**
|
||||
* @param {String} raw 原始 redirect(可能被 encodeURIComponent 处理过)
|
||||
* @returns {String} 安全的目标路径,非法一律回 /duty/jcxx
|
||||
*/
|
||||
export function safeRedirect(raw) {
|
||||
if (!raw) {
|
||||
return DUTY_HOME
|
||||
}
|
||||
let value = String(raw)
|
||||
try {
|
||||
value = decodeURIComponent(value)
|
||||
} catch (e) {
|
||||
return DUTY_HOME
|
||||
}
|
||||
// 协议相对地址(//evil.com)与带协议的绝对地址(http:、javascript:)一律拒绝
|
||||
if (value.indexOf('//') >= 0) {
|
||||
return DUTY_HOME
|
||||
}
|
||||
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(value)) {
|
||||
return DUTY_HOME
|
||||
}
|
||||
if (value.charAt(0) !== '/') {
|
||||
return DUTY_HOME
|
||||
}
|
||||
// 只允许 /duty/ 段;/duty/login 自指会死循环
|
||||
if (value.indexOf('/duty/') !== 0) {
|
||||
return DUTY_HOME
|
||||
}
|
||||
if (value.indexOf(DUTY_LOGIN) === 0) {
|
||||
return DUTY_HOME
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/** 生成登录页地址(带 redirect 与失效原因),供守卫与 401 处置复用 */
|
||||
export function toLoginLocation(fullPath, reason) {
|
||||
const params = []
|
||||
if (fullPath) {
|
||||
params.push('redirect=' + encodeURIComponent(fullPath))
|
||||
}
|
||||
if (reason) {
|
||||
params.push('reason=' + encodeURIComponent(reason))
|
||||
}
|
||||
return DUTY_LOGIN + (params.length ? '?' + params.join('&') : '')
|
||||
}
|
||||
|
||||
/** 登录页 reason 参数对应的提示文案 */
|
||||
export function reasonText(reason) {
|
||||
const map = {
|
||||
expired: '登录状态已过期,请重新登录',
|
||||
forceLogout: '管理员已将您强制退出,请重新登录',
|
||||
noPermission: '当前账号没有值班台权限,请联系管理员开通 duty:*',
|
||||
blocked: '该账号已停用或已删除,请联系公寓管理员',
|
||||
pwdChanged: '密码已变更,请用新密码重新登录'
|
||||
}
|
||||
return map[reason] || ''
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* 值班台实时通道客户端(应用端/04 表 4-12、11 表 11-9 R3)。
|
||||
*
|
||||
* 四条约束,缺一条都会在生产上出事故:
|
||||
* 1. 令牌只能走 query(浏览器 WebSocket 不能设 Authorization 头);
|
||||
* 2. 心跳 30 s,连续 2 个周期没收到 PONG 就主动重连(半开连接靠这个兜);
|
||||
* 3. 重连指数退避 1/2/4/8/16/30 s + ±20% 抖动,**禁止 location.reload()**;
|
||||
* 4. 按 seq 丢重与丢乱序:只处理 seq 更大的业务消息。
|
||||
*
|
||||
* 推送只是"更快看到",事实仍以 HTTP 快照为准:每次连上都要回调 onOpen 拉一次列表。
|
||||
*/
|
||||
|
||||
import { getToken } from '@/utils/auth'
|
||||
|
||||
const WS_PATH = '/ws/duty'
|
||||
const HEARTBEAT_MS = 30000
|
||||
const PONG_TIMEOUT_MS = 65000
|
||||
const BACKOFF_MS = [1000, 2000, 4000, 8000, 16000, 30000]
|
||||
/** 这些消息不参与 seq 丢重(控制帧) */
|
||||
const CONTROL_TYPES = ['CONNECTED', 'PONG']
|
||||
|
||||
export default class DutySocket {
|
||||
constructor(options) {
|
||||
const opts = options || {}
|
||||
this.onMessage = opts.onMessage || function () {}
|
||||
this.onStatus = opts.onStatus || function () {}
|
||||
this.onOpen = opts.onOpen || function () {}
|
||||
|
||||
this.socket = null
|
||||
this.seq = 0
|
||||
this.attempt = 0
|
||||
this.manualClosed = false
|
||||
this.status = 'idle'
|
||||
this.lastMessageAt = 0
|
||||
this.lastPongAt = 0
|
||||
this.heartbeatTimer = null
|
||||
this.watchdogTimer = null
|
||||
this.reconnectTimer = null
|
||||
}
|
||||
|
||||
connect() {
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
this.setStatus('offline')
|
||||
return
|
||||
}
|
||||
this.manualClosed = false
|
||||
this.clearReconnect()
|
||||
this.setStatus(this.attempt === 0 ? 'connecting' : 'reconnecting')
|
||||
|
||||
const base = process.env.VUE_APP_WS_BASE || (window.location.protocol === 'https:' ? 'wss://' : 'ws://') + window.location.host
|
||||
const url = base.replace(/\/$/, '') + WS_PATH + '?token=' + encodeURIComponent(token)
|
||||
let socket
|
||||
try {
|
||||
socket = new WebSocket(url)
|
||||
} catch (e) {
|
||||
this.scheduleReconnect()
|
||||
return
|
||||
}
|
||||
this.socket = socket
|
||||
|
||||
socket.onopen = () => {
|
||||
this.attempt = 0
|
||||
this.lastPongAt = Date.now()
|
||||
this.setStatus('online')
|
||||
this.startHeartbeat()
|
||||
this.onOpen()
|
||||
}
|
||||
|
||||
socket.onmessage = event => {
|
||||
this.lastMessageAt = Date.now()
|
||||
let message
|
||||
try {
|
||||
message = JSON.parse(event.data)
|
||||
} catch (e) {
|
||||
return
|
||||
}
|
||||
if (!message || !message.type) {
|
||||
return
|
||||
}
|
||||
if (CONTROL_TYPES.indexOf(message.type) < 0) {
|
||||
if (typeof message.seq === 'number') {
|
||||
if (message.seq <= this.seq) {
|
||||
return // 重复或乱序,丢弃
|
||||
}
|
||||
this.seq = message.seq
|
||||
}
|
||||
} else if (message.type === 'CONNECTED' && message.data && typeof message.data.lastSeq === 'number') {
|
||||
this.seq = Math.max(this.seq, message.data.lastSeq)
|
||||
}
|
||||
if (message.type === 'PONG') {
|
||||
this.lastPongAt = Date.now()
|
||||
}
|
||||
this.onMessage(message)
|
||||
}
|
||||
|
||||
socket.onclose = () => {
|
||||
this.stopHeartbeat()
|
||||
if (this.manualClosed) {
|
||||
this.setStatus('offline')
|
||||
return
|
||||
}
|
||||
this.setStatus(this.attempt >= 3 ? 'degraded' : 'reconnecting')
|
||||
this.scheduleReconnect()
|
||||
}
|
||||
|
||||
socket.onerror = () => {
|
||||
// onclose 一定会随后触发,这里不重复重连
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
this.manualClosed = true
|
||||
this.clearReconnect()
|
||||
this.stopHeartbeat()
|
||||
if (this.socket) {
|
||||
try {
|
||||
this.socket.close()
|
||||
} catch (e) {
|
||||
// 忽略关闭异常
|
||||
}
|
||||
this.socket = null
|
||||
}
|
||||
this.setStatus('offline')
|
||||
}
|
||||
|
||||
startHeartbeat() {
|
||||
this.stopHeartbeat()
|
||||
this.heartbeatTimer = setInterval(() => {
|
||||
if (!this.socket || this.socket.readyState !== 1) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
this.socket.send(JSON.stringify({ type: 'PING', ts: Date.now() }))
|
||||
} catch (e) {
|
||||
this.forceReconnect()
|
||||
}
|
||||
}, HEARTBEAT_MS)
|
||||
|
||||
this.watchdogTimer = setInterval(() => {
|
||||
if (Date.now() - this.lastPongAt > PONG_TIMEOUT_MS) {
|
||||
this.forceReconnect()
|
||||
}
|
||||
}, 15000)
|
||||
}
|
||||
|
||||
stopHeartbeat() {
|
||||
if (this.heartbeatTimer) {
|
||||
clearInterval(this.heartbeatTimer)
|
||||
this.heartbeatTimer = null
|
||||
}
|
||||
if (this.watchdogTimer) {
|
||||
clearInterval(this.watchdogTimer)
|
||||
this.watchdogTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
forceReconnect() {
|
||||
this.stopHeartbeat()
|
||||
if (this.socket) {
|
||||
try {
|
||||
this.socket.close()
|
||||
} catch (e) {
|
||||
// 忽略
|
||||
}
|
||||
this.socket = null
|
||||
}
|
||||
// 手动 close 会走 onclose,但已经 stopHeartbeat,直接把重连排上更稳
|
||||
if (!this.manualClosed) {
|
||||
this.setStatus('reconnecting')
|
||||
this.scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
scheduleReconnect() {
|
||||
this.clearReconnect()
|
||||
const base = BACKOFF_MS[Math.min(this.attempt, BACKOFF_MS.length - 1)]
|
||||
const jitter = 0.8 + Math.random() * 0.4
|
||||
const delay = Math.round(base * jitter)
|
||||
this.attempt += 1
|
||||
this.reconnectTimer = setTimeout(() => this.connect(), delay)
|
||||
}
|
||||
|
||||
clearReconnect() {
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer)
|
||||
this.reconnectTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
setStatus(status) {
|
||||
if (this.status === status) {
|
||||
return
|
||||
}
|
||||
this.status = status
|
||||
this.onStatus(status)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,832 @@
|
||||
<template>
|
||||
<div class="duty-config">
|
||||
<header class="hd">
|
||||
<div class="brand">
|
||||
<div class="mark" aria-hidden="true">配</div>
|
||||
<div>
|
||||
<h1>值班台测试配置</h1>
|
||||
<p class="sub">房间信息、房间与 SIP 分机绑定、SIP 服务器。配置完回盯控页「办理入住」即可跑通叫乘链路。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="acts">
|
||||
<button class="btn neutral" :disabled="loading" @click="loadAll">
|
||||
{{ loading ? '读取中…' : '刷新' }}
|
||||
</button>
|
||||
<button class="btn primary" @click="goConsole">回盯控页</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="!status" class="warnbar">正在读取配置状态…</div>
|
||||
<div v-else-if="!status.debugEnabled" class="errbar" role="alert">
|
||||
测试配置未启用:请在运行环境设置 <code>APT_DEBUG_ENABLED=true</code> 后重启后端。
|
||||
当前页面可以查看现状,但任何保存都会返回 40940。
|
||||
</div>
|
||||
<div v-else class="okbar" role="status">
|
||||
已启用 · 数据域 <b>{{ status.gyid }}</b> · SIP 叫乘总开关
|
||||
<b>{{ status.voiceEnabled ? '已开' : '未开' }}</b>
|
||||
<span v-if="!status.voiceEnabled">(未开时到点不会真正拨号,只能验到「叫乘中」)</span>
|
||||
</div>
|
||||
|
||||
<div v-if="notice.text" class="notice" :class="notice.type" role="status">
|
||||
{{ notice.text }}
|
||||
<button class="btn mini neutral" @click="notice.text = ''">关闭</button>
|
||||
</div>
|
||||
|
||||
<nav class="tabs">
|
||||
<button
|
||||
v-for="item in tabs"
|
||||
:key="item.value"
|
||||
class="btn"
|
||||
:class="tab === item.value ? 'primary' : 'neutral'"
|
||||
@click="switchTab(item.value)"
|
||||
>{{ item.label }}</button>
|
||||
</nav>
|
||||
|
||||
<!-- ================= 房间信息 ================= -->
|
||||
<section v-if="tab === 'rooms'" class="card">
|
||||
<h2>录入房间</h2>
|
||||
<p class="tip">
|
||||
房号是唯一键。平台基线里没有的房间会自动补一条测试基线(仅 debug 模式),
|
||||
然后按正式链路登记本端房间并创建 SIP 分机。分机号留空则从号段里取最小空闲号。
|
||||
</p>
|
||||
<div class="form">
|
||||
<label class="fi">
|
||||
<span>房号 <i>*</i></span>
|
||||
<input v-model.trim="roomForm.roomNo" maxlength="32" placeholder="例如 305" />
|
||||
</label>
|
||||
<label class="fi">
|
||||
<span>展示名</span>
|
||||
<input v-model.trim="roomForm.displayName" maxlength="64" placeholder="例如 305 房(话机显示名)" />
|
||||
</label>
|
||||
<label class="fi">
|
||||
<span>楼栋</span>
|
||||
<input v-model.trim="roomForm.building" maxlength="16" placeholder="例如 A 栋" />
|
||||
</label>
|
||||
<label class="fi">
|
||||
<span>楼层</span>
|
||||
<input v-model.trim="roomForm.roomFloor" maxlength="16" placeholder="例如 3" />
|
||||
</label>
|
||||
<label class="fi">
|
||||
<span>房型</span>
|
||||
<input v-model.trim="roomForm.roomType" maxlength="16" placeholder="例如 单人间" />
|
||||
</label>
|
||||
<label class="fi">
|
||||
<span>床位数 <i>*</i></span>
|
||||
<input v-model.number="roomForm.bedCount" type="number" min="1" max="20" />
|
||||
</label>
|
||||
<label class="fi">
|
||||
<span>分机号</span>
|
||||
<input v-model.trim="roomForm.extension" maxlength="8" placeholder="留空自动取号" />
|
||||
</label>
|
||||
<div class="fi submit">
|
||||
<button class="btn primary" :disabled="submitting" @click="submitRoom">
|
||||
{{ submitting ? '保存中…' : '保存房间' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="roomError" class="err" role="alert">{{ roomError }}</div>
|
||||
|
||||
<h2 class="mt">房间现状</h2>
|
||||
<p class="tip">
|
||||
共 {{ rooms.length }} 间,已登记 {{ registeredCount }} 间,已绑定分机 {{ boundCount }} 间。
|
||||
「未登记」的房间在入住下拉里不会出现。
|
||||
</p>
|
||||
<table class="tb">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>房号</th><th>展示名 / 楼栋</th><th>床位</th><th>在住</th><th>房态</th>
|
||||
<th>分机</th><th>本端登记</th><th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in rooms" :key="row.roomNo">
|
||||
<td><b>{{ row.roomNo }}</b></td>
|
||||
<td>{{ row.displayName || row.building || '—' }}<span class="muted"> {{ row.roomFloor ? '· ' + row.roomFloor + ' 层' : '' }}</span></td>
|
||||
<td>{{ row.bedCount == null ? '—' : row.bedCount }}</td>
|
||||
<td>{{ row.inPersonCount == null ? '—' : row.inPersonCount }}</td>
|
||||
<td>
|
||||
<span
|
||||
class="tag"
|
||||
:class="isRoomBlocked(row.roomStatus) ? 'warn' : 'ok'"
|
||||
:title="isRoomBlocked(row.roomStatus) ? '停用/维修:入住下拉里不会出现,也不会被占用' : ''"
|
||||
>{{ roomStatusLabel(row.roomStatus) }}</span>
|
||||
</td>
|
||||
<td>{{ row.extension || '—' }}</td>
|
||||
<td>
|
||||
<span class="tag" :class="row.localId ? 'ok' : 'warn'">{{ row.localId ? '已登记' : '未登记' }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn mini neutral" @click="pickRoom(row)">绑定分机</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!rooms.length">
|
||||
<td colspan="8" class="empty">还没有房间,先在上面录入一间。</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<!-- ================= SIP 绑定 ================= -->
|
||||
<section v-if="tab === 'sip'" class="card">
|
||||
<h2>房间 ↔ 分机绑定</h2>
|
||||
<p class="tip">
|
||||
号段 <b>{{ poolText }}</b>,已用 <b>{{ usedInPool }}</b>,可用 <b>{{ freeInPool }}</b>。
|
||||
分机号创建后不可改:要换号必须先解绑;解绑后的号码<b>不会回收</b>。
|
||||
</p>
|
||||
<div class="form">
|
||||
<label class="fi">
|
||||
<span>房号 <i>*</i></span>
|
||||
<select v-model="bindForm.roomNo">
|
||||
<option value="">请选择已登记房间</option>
|
||||
<option v-for="row in registeredRooms" :key="row.roomNo" :value="row.roomNo">
|
||||
{{ row.roomNo }}{{ row.extension ? '(已有分机 ' + row.extension + ')' : '' }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="fi">
|
||||
<span>分机号 <i>*</i></span>
|
||||
<input v-model.trim="bindForm.extension" maxlength="8" placeholder="例如 1010" />
|
||||
</label>
|
||||
<label class="fi">
|
||||
<span>话机展示名</span>
|
||||
<input v-model.trim="bindForm.displayName" maxlength="64" placeholder="留空沿用房间展示名" />
|
||||
</label>
|
||||
<div class="fi submit">
|
||||
<button class="btn primary" :disabled="submitting" @click="submitBind">
|
||||
{{ submitting ? '绑定中…' : '绑定分机' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="sipError" class="err" role="alert">{{ sipError }}</div>
|
||||
|
||||
<h2 class="mt">分机占用</h2>
|
||||
<table class="tb">
|
||||
<thead>
|
||||
<tr><th>分机</th><th>房间</th><th>展示名</th><th>同步态</th><th>密码态</th><th>操作</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in extensions" :key="row.extension">
|
||||
<td><b>{{ row.extension }}</b></td>
|
||||
<td>{{ row.roomNo || '—' }}<span v-if="!row.roomNo" class="muted"> 未绑定房间</span></td>
|
||||
<td>{{ row.displayName || '—' }}</td>
|
||||
<td>{{ row.syncState || '—' }}</td>
|
||||
<td>{{ row.authState || '—' }}</td>
|
||||
<td>
|
||||
<button
|
||||
v-if="row.roomNo"
|
||||
class="btn mini danger"
|
||||
:disabled="submitting"
|
||||
@click="doUnbind(row.roomNo)"
|
||||
>解绑</button>
|
||||
<span v-else class="muted">—</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!extensions.length">
|
||||
<td colspan="6" class="empty">还没有分机,先在「房间信息」录入房间或在这里绑定。</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<!-- ================= SIP 服务器 ================= -->
|
||||
<section v-if="tab === 'server'" class="card">
|
||||
<h2>SIP 服务器(AMI)</h2>
|
||||
<p class="tip">
|
||||
口令<b>不落库</b>:这里只填「口令所在的环境变量名」,值由运行环境注入
|
||||
(本机在 <code>dev/.env</code> 里写 <code>APT_AMI_SECRET=******</code>,改完重启后端)。
|
||||
保存后 AMI 状态会复位为 DISABLED,重连成功才会变 UP。
|
||||
</p>
|
||||
<div class="form">
|
||||
<label class="fi">
|
||||
<span>AMI 地址 <i>*</i></span>
|
||||
<input v-model.trim="serverForm.amiHost" maxlength="64" placeholder="例如 192.168.1.179" />
|
||||
</label>
|
||||
<label class="fi">
|
||||
<span>AMI 端口 <i>*</i></span>
|
||||
<input v-model.number="serverForm.amiPort" type="number" min="1" max="65535" placeholder="7058" />
|
||||
</label>
|
||||
<label class="fi">
|
||||
<span>用户名 <i>*</i></span>
|
||||
<input v-model.trim="serverForm.amiUser" maxlength="64" placeholder="manager01" />
|
||||
</label>
|
||||
<label class="fi">
|
||||
<span>口令引用 <i>*</i></span>
|
||||
<input v-model.trim="serverForm.amiSecretRef" maxlength="64" placeholder="APT_AMI_SECRET" />
|
||||
<small>只填环境变量名(大写字母/数字/下划线),明文口令会被拒绝。</small>
|
||||
</label>
|
||||
<label class="fi">
|
||||
<span>号段起</span>
|
||||
<input v-model.trim="serverForm.extPoolFrom" maxlength="8" placeholder="1010" />
|
||||
</label>
|
||||
<label class="fi">
|
||||
<span>号段止</span>
|
||||
<input v-model.trim="serverForm.extPoolTo" maxlength="8" placeholder="1050" />
|
||||
</label>
|
||||
<label class="fi">
|
||||
<span>启用 AMI</span>
|
||||
<select v-model="serverForm.enabledText">
|
||||
<option value="true">启用</option>
|
||||
<option value="false">停用</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="fi submit">
|
||||
<button class="btn primary" :disabled="submitting" @click="submitServer">
|
||||
{{ submitting ? '保存中…' : '保存配置' }}
|
||||
</button>
|
||||
<button class="btn neutral" :disabled="probing" @click="doProbe">
|
||||
{{ probing ? '探测中…' : '探测连通性' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="serverError" class="err" role="alert">{{ serverError }}</div>
|
||||
|
||||
<h2 class="mt">当前状态</h2>
|
||||
<dl class="kv">
|
||||
<dt>配置行</dt><dd>{{ server.configured ? '已配置' : '未配置' }}</dd>
|
||||
<dt>AMI 状态</dt><dd>{{ server.state || '—' }}</dd>
|
||||
<dt>口令注入</dt>
|
||||
<dd>
|
||||
<span class="tag" :class="server.secretReady ? 'ok' : 'warn'">
|
||||
{{ server.secretReady ? '已注入' : '未注入' }}
|
||||
</span>
|
||||
<span class="muted"> {{ server.amiSecretRef || '—' }}</span>
|
||||
</dd>
|
||||
<dt>最近连接</dt><dd>{{ server.lastConnectedAt || '—' }}</dd>
|
||||
<dt>最近错误</dt><dd>{{ server.lastError || '—' }}</dd>
|
||||
<dt v-if="probe.reachable !== undefined">探测结果</dt>
|
||||
<dd v-if="probe.reachable !== undefined">
|
||||
<span class="tag" :class="probe.reachable ? 'ok' : 'warn'">
|
||||
{{ probe.reachable ? '端口可达' : '端口不可达' }}
|
||||
</span>
|
||||
<span class="muted"> {{ probe.latencyMs }} ms · {{ probe.probeNote }}</span>
|
||||
<div v-if="probe.probeError" class="muted">错误:{{ probe.probeError }}</div>
|
||||
</dd>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {
|
||||
getTestStatus,
|
||||
listTestRooms,
|
||||
createTestRoom,
|
||||
listExtensions,
|
||||
bindSip,
|
||||
unbindSip,
|
||||
getSipServer,
|
||||
saveSipServer,
|
||||
probeSipServer
|
||||
} from '@/api/duty/test'
|
||||
|
||||
const EMPTY_ROOM = {
|
||||
roomNo: '',
|
||||
displayName: '',
|
||||
building: '',
|
||||
roomFloor: '',
|
||||
roomType: '',
|
||||
bedCount: 2,
|
||||
extension: ''
|
||||
}
|
||||
const EMPTY_BIND = { roomNo: '', extension: '', displayName: '' }
|
||||
const EMPTY_SERVER = {
|
||||
amiHost: '',
|
||||
amiPort: 5038,
|
||||
amiUser: '',
|
||||
amiSecretRef: 'APT_AMI_SECRET',
|
||||
extPoolFrom: '',
|
||||
extPoolTo: '',
|
||||
enabledText: 'true'
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'DutyTestConfig',
|
||||
data() {
|
||||
return {
|
||||
tabs: [
|
||||
{ value: 'rooms', label: '房间信息' },
|
||||
{ value: 'sip', label: '房间与分机绑定' },
|
||||
{ value: 'server', label: 'SIP 服务器' }
|
||||
],
|
||||
tab: 'rooms',
|
||||
loading: false,
|
||||
submitting: false,
|
||||
probing: false,
|
||||
status: null,
|
||||
rooms: [],
|
||||
extensions: [],
|
||||
server: {},
|
||||
probe: {},
|
||||
notice: { type: 'ok', text: '' },
|
||||
roomError: '',
|
||||
sipError: '',
|
||||
serverError: '',
|
||||
roomForm: { ...EMPTY_ROOM },
|
||||
bindForm: { ...EMPTY_BIND },
|
||||
serverForm: { ...EMPTY_SERVER }
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
registeredRooms() {
|
||||
return this.rooms.filter(item => item.localId)
|
||||
},
|
||||
registeredCount() {
|
||||
return this.registeredRooms.length
|
||||
},
|
||||
boundCount() {
|
||||
return this.rooms.filter(item => item.extension).length
|
||||
},
|
||||
poolFrom() {
|
||||
return this.server.extPoolFrom || ''
|
||||
},
|
||||
poolTo() {
|
||||
return this.server.extPoolTo || ''
|
||||
},
|
||||
poolText() {
|
||||
return this.poolFrom && this.poolTo ? this.poolFrom + '-' + this.poolTo : '未配置(不限制)'
|
||||
},
|
||||
usedInPool() {
|
||||
if (!this.poolFrom || !this.poolTo) {
|
||||
return this.extensions.length
|
||||
}
|
||||
const from = Number(this.poolFrom)
|
||||
const to = Number(this.poolTo)
|
||||
return this.extensions.filter(item => {
|
||||
const no = Number(item.extension)
|
||||
return no >= from && no <= to
|
||||
}).length
|
||||
},
|
||||
freeInPool() {
|
||||
if (!this.poolFrom || !this.poolTo) {
|
||||
return '—'
|
||||
}
|
||||
return Math.max(Number(this.poolTo) - Number(this.poolFrom) + 1 - this.usedInPool, 0)
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.loadAll()
|
||||
},
|
||||
methods: {
|
||||
loadAll() {
|
||||
this.loading = true
|
||||
Promise.all([getTestStatus(), listTestRooms(), listExtensions(), getSipServer()])
|
||||
.then(([status, rooms, extensions, server]) => {
|
||||
this.status = status.data
|
||||
this.rooms = rooms.data || []
|
||||
this.extensions = extensions.data || []
|
||||
this.server = server.data || {}
|
||||
if (this.server.configured && !this.serverForm.amiHost) {
|
||||
this.fillServerForm()
|
||||
}
|
||||
})
|
||||
.catch(this.fail)
|
||||
.finally(() => {
|
||||
this.loading = false
|
||||
})
|
||||
},
|
||||
fillServerForm() {
|
||||
this.serverForm = {
|
||||
amiHost: this.server.amiHost || '',
|
||||
amiPort: this.server.amiPort || 5038,
|
||||
amiUser: this.server.amiUser || '',
|
||||
amiSecretRef: this.server.amiSecretRef || 'APT_AMI_SECRET',
|
||||
extPoolFrom: this.server.extPoolFrom || '',
|
||||
extPoolTo: this.server.extPoolTo || '',
|
||||
enabledText: String(this.server.enabled === 1 || this.server.enabled === true)
|
||||
}
|
||||
},
|
||||
switchTab(value) {
|
||||
this.tab = value
|
||||
if (value === 'server' && this.server.configured && !this.serverForm.amiHost) {
|
||||
this.fillServerForm()
|
||||
}
|
||||
},
|
||||
goConsole() {
|
||||
this.$router.push('/duty/jcxx')
|
||||
},
|
||||
/** 房态码:1 空闲 / 2 满 / 3 待清扫 / 4 停用 / 5 部分占用 / 6 维修 */
|
||||
roomStatusLabel(code) {
|
||||
const map = { '1': '空闲', '2': '满', '3': '待清扫', '4': '停用', '5': '部分占用', '6': '维修' }
|
||||
return code == null ? '—' : map[String(code)] || '未知(' + code + ')'
|
||||
},
|
||||
isRoomBlocked(code) {
|
||||
return code != null && ['4', '6'].includes(String(code))
|
||||
},
|
||||
pickRoom(row) {
|
||||
this.bindForm = { ...EMPTY_BIND, roomNo: row.roomNo, extension: row.extension || '' }
|
||||
this.sipError = ''
|
||||
this.tab = 'sip'
|
||||
},
|
||||
// 房间校验:全部在前端先挡一遍,后端仍会再校验
|
||||
validateRoom() {
|
||||
const form = this.roomForm
|
||||
if (!form.roomNo) {
|
||||
return '请填写房号'
|
||||
}
|
||||
if (!/^[0-9A-Za-z-]{1,32}$/.test(form.roomNo)) {
|
||||
return '房号只能包含数字、字母与短横线'
|
||||
}
|
||||
if (form.bedCount == null || form.bedCount < 1 || form.bedCount > 20) {
|
||||
return '床位数必须在 1~20 之间'
|
||||
}
|
||||
if (form.extension && !/^[0-9]{3,8}$/.test(form.extension)) {
|
||||
return '分机号只能是 3~8 位数字'
|
||||
}
|
||||
return ''
|
||||
},
|
||||
submitRoom() {
|
||||
const error = this.validateRoom()
|
||||
this.roomError = error
|
||||
if (error) {
|
||||
return
|
||||
}
|
||||
this.submitting = true
|
||||
createTestRoom({
|
||||
roomNo: this.roomForm.roomNo,
|
||||
displayName: this.roomForm.displayName || null,
|
||||
building: this.roomForm.building || null,
|
||||
roomFloor: this.roomForm.roomFloor || null,
|
||||
roomType: this.roomForm.roomType || null,
|
||||
bedCount: this.roomForm.bedCount,
|
||||
extension: this.roomForm.extension || null
|
||||
})
|
||||
.then(res => {
|
||||
this.ok(res.data.message + (res.data.roomNo ? '(' + res.data.roomNo + ')' : ''))
|
||||
this.roomForm = { ...EMPTY_ROOM }
|
||||
this.loadAll()
|
||||
})
|
||||
.catch(e => {
|
||||
this.roomError = this.messageOf(e)
|
||||
})
|
||||
.finally(() => {
|
||||
this.submitting = false
|
||||
})
|
||||
},
|
||||
validateBind() {
|
||||
if (!this.bindForm.roomNo) {
|
||||
return '请选择房间'
|
||||
}
|
||||
if (!/^[0-9]{3,8}$/.test(this.bindForm.extension)) {
|
||||
return '分机号只能是 3~8 位数字'
|
||||
}
|
||||
return ''
|
||||
},
|
||||
submitBind() {
|
||||
const error = this.validateBind()
|
||||
this.sipError = error
|
||||
if (error) {
|
||||
return
|
||||
}
|
||||
this.submitting = true
|
||||
bindSip({
|
||||
roomNo: this.bindForm.roomNo,
|
||||
extension: this.bindForm.extension,
|
||||
displayName: this.bindForm.displayName || null
|
||||
})
|
||||
.then(res => {
|
||||
this.ok(res.data.message)
|
||||
this.bindForm = { ...EMPTY_BIND }
|
||||
this.loadAll()
|
||||
})
|
||||
.catch(e => {
|
||||
this.sipError = this.messageOf(e)
|
||||
})
|
||||
.finally(() => {
|
||||
this.submitting = false
|
||||
})
|
||||
},
|
||||
doUnbind(roomNo) {
|
||||
const row = this.rooms.find(item => item.roomNo === roomNo)
|
||||
const extension = row && row.extension ? row.extension : ''
|
||||
this.$confirm(
|
||||
'解绑后分机 ' + (extension || '') + ' 不会回收,再次绑定需要用号段内的新号码。继续?',
|
||||
'解绑确认',
|
||||
{ type: 'warning' }
|
||||
)
|
||||
.then(() => {
|
||||
this.submitting = true
|
||||
return unbindSip(roomNo)
|
||||
})
|
||||
.then(res => {
|
||||
if (res) {
|
||||
this.ok(res.data.message)
|
||||
this.loadAll()
|
||||
}
|
||||
})
|
||||
.catch(e => {
|
||||
if (e && e !== 'cancel' && e !== 'close') {
|
||||
this.sipError = this.messageOf(e)
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
this.submitting = false
|
||||
})
|
||||
},
|
||||
validateServer() {
|
||||
const form = this.serverForm
|
||||
if (!form.amiHost) {
|
||||
return '请填写 AMI 地址'
|
||||
}
|
||||
if (form.amiPort == null || form.amiPort < 1 || form.amiPort > 65535) {
|
||||
return '端口范围 1~65535'
|
||||
}
|
||||
if (!form.amiUser) {
|
||||
return '请填写 AMI 用户名'
|
||||
}
|
||||
if (!/^[A-Z][A-Z0-9_]{2,63}$/.test(form.amiSecretRef)) {
|
||||
return '口令引用必须是环境变量名(大写字母/数字/下划线);口令本身不落库'
|
||||
}
|
||||
if ((form.extPoolFrom && !form.extPoolTo) || (!form.extPoolFrom && form.extPoolTo)) {
|
||||
return '号段起止必须同时填写,或同时留空'
|
||||
}
|
||||
if (form.extPoolFrom && form.extPoolTo && Number(form.extPoolFrom) > Number(form.extPoolTo)) {
|
||||
return '号段起始不能大于结束'
|
||||
}
|
||||
return ''
|
||||
},
|
||||
submitServer() {
|
||||
const error = this.validateServer()
|
||||
this.serverError = error
|
||||
if (error) {
|
||||
return
|
||||
}
|
||||
this.submitting = true
|
||||
saveSipServer({
|
||||
amiHost: this.serverForm.amiHost,
|
||||
amiPort: this.serverForm.amiPort,
|
||||
amiUser: this.serverForm.amiUser,
|
||||
amiSecretRef: this.serverForm.amiSecretRef,
|
||||
extPoolFrom: this.serverForm.extPoolFrom || null,
|
||||
extPoolTo: this.serverForm.extPoolTo || null,
|
||||
enabled: this.serverForm.enabledText === 'true'
|
||||
})
|
||||
.then(res => {
|
||||
this.server = res.data
|
||||
this.ok(res.data.message || '已保存')
|
||||
this.loadAll()
|
||||
})
|
||||
.catch(e => {
|
||||
this.serverError = this.messageOf(e)
|
||||
})
|
||||
.finally(() => {
|
||||
this.submitting = false
|
||||
})
|
||||
},
|
||||
doProbe() {
|
||||
this.probing = true
|
||||
probeSipServer()
|
||||
.then(res => {
|
||||
this.probe = res.data || {}
|
||||
this.ok(res.data.message || '探测完成')
|
||||
})
|
||||
.catch(e => {
|
||||
this.serverError = this.messageOf(e)
|
||||
})
|
||||
.finally(() => {
|
||||
this.probing = false
|
||||
})
|
||||
},
|
||||
ok(text) {
|
||||
this.notice = { type: 'ok', text: text }
|
||||
},
|
||||
fail(e) {
|
||||
this.notice = { type: 'err', text: this.messageOf(e) }
|
||||
},
|
||||
messageOf(e) {
|
||||
return (e && (e.message || e.msg)) || '请求失败,请查看后端日志'
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.duty-config {
|
||||
min-height: 100vh;
|
||||
padding: 20px 24px 48px;
|
||||
background: #f5f6f8;
|
||||
color: #1f2430;
|
||||
font-size: 14px;
|
||||
}
|
||||
.hd {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.brand {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.mark {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
background: #1f5eff;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
}
|
||||
.brand h1 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 20px;
|
||||
}
|
||||
.sub {
|
||||
margin: 0;
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
}
|
||||
.acts {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 12px 0 16px;
|
||||
}
|
||||
.card {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
padding: 18px 20px 24px;
|
||||
}
|
||||
.card h2 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 16px;
|
||||
}
|
||||
.card h2.mt {
|
||||
margin-top: 28px;
|
||||
}
|
||||
.tip {
|
||||
margin: 0 0 14px;
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.tip b {
|
||||
color: #1f2430;
|
||||
}
|
||||
.form {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 12px 16px;
|
||||
}
|
||||
.fi {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: #4b5563;
|
||||
}
|
||||
.fi i {
|
||||
color: #dc2626;
|
||||
font-style: normal;
|
||||
}
|
||||
.fi input,
|
||||
.fi select {
|
||||
height: 34px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
color: #1f2430;
|
||||
background: #fff;
|
||||
}
|
||||
.fi small {
|
||||
color: #9ca3af;
|
||||
font-size: 11px;
|
||||
}
|
||||
.fi.submit {
|
||||
flex-direction: row;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
.btn {
|
||||
height: 34px;
|
||||
padding: 0 14px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
.btn.primary {
|
||||
background: #1f5eff;
|
||||
color: #fff;
|
||||
}
|
||||
.btn.neutral {
|
||||
background: #fff;
|
||||
border-color: #d1d5db;
|
||||
color: #374151;
|
||||
}
|
||||
.btn.danger {
|
||||
background: #fff;
|
||||
border-color: #fca5a5;
|
||||
color: #b91c1c;
|
||||
}
|
||||
.btn.mini {
|
||||
height: 26px;
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.tb {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
.tb th,
|
||||
.tb td {
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid #eef0f3;
|
||||
}
|
||||
.tb th {
|
||||
color: #6b7280;
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
}
|
||||
.tb .empty {
|
||||
text-align: center;
|
||||
color: #9ca3af;
|
||||
padding: 18px;
|
||||
}
|
||||
.muted {
|
||||
color: #9ca3af;
|
||||
font-size: 12px;
|
||||
}
|
||||
.tag {
|
||||
display: inline-block;
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.tag.ok {
|
||||
background: #ecfdf3;
|
||||
color: #047857;
|
||||
}
|
||||
.tag.warn {
|
||||
background: #fff7ed;
|
||||
color: #b45309;
|
||||
}
|
||||
.notice,
|
||||
.warnbar,
|
||||
.errbar,
|
||||
.okbar {
|
||||
margin: 10px 0;
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.okbar {
|
||||
background: #f0f7ff;
|
||||
border: 1px solid #bfdbfe;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
.warnbar {
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fde68a;
|
||||
color: #92400e;
|
||||
}
|
||||
.errbar,
|
||||
.notice.err {
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
color: #b91c1c;
|
||||
}
|
||||
.notice.ok {
|
||||
background: #ecfdf3;
|
||||
border: 1px solid #bbf7d0;
|
||||
color: #047857;
|
||||
}
|
||||
.err {
|
||||
margin-top: 10px;
|
||||
color: #b91c1c;
|
||||
font-size: 12px;
|
||||
}
|
||||
.kv {
|
||||
display: grid;
|
||||
grid-template-columns: 120px 1fr;
|
||||
gap: 8px 12px;
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.kv dt {
|
||||
color: #6b7280;
|
||||
}
|
||||
.kv dd {
|
||||
margin: 0;
|
||||
}
|
||||
code {
|
||||
background: #eef0f3;
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,392 @@
|
||||
<template>
|
||||
<div class="duty-login" :class="'mode-' + mode">
|
||||
<div class="card">
|
||||
<div class="brand">
|
||||
<div class="mark" aria-hidden="true">值</div>
|
||||
<div>
|
||||
<h1>值班员登录</h1>
|
||||
<p class="sub">登录后进入叫乘盯控台。红色按钮操作会再确认一次。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="reasonHint" class="reason" role="alert">{{ reasonHint }}</div>
|
||||
|
||||
<el-form ref="loginForm" :model="loginForm" :rules="loginRules" class="form" @submit.native.prevent>
|
||||
<el-form-item prop="username">
|
||||
<label class="lbl">账号</label>
|
||||
<el-input
|
||||
ref="username"
|
||||
v-model="loginForm.username"
|
||||
placeholder="请输入值班员账号"
|
||||
autocomplete="username"
|
||||
@keyup.enter.native="handleLogin"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="password">
|
||||
<label class="lbl">密码</label>
|
||||
<el-input
|
||||
v-model="loginForm.password"
|
||||
type="password"
|
||||
show-password
|
||||
placeholder="请输入密码"
|
||||
autocomplete="current-password"
|
||||
@keyup.enter.native="handleLogin"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="captchaEnabled" prop="code">
|
||||
<label class="lbl">验证码</label>
|
||||
<div class="code-row">
|
||||
<el-input
|
||||
v-model="loginForm.code"
|
||||
placeholder="4 位验证码"
|
||||
autocomplete="off"
|
||||
maxlength="4"
|
||||
@keyup.enter.native="handleLogin"
|
||||
/>
|
||||
<img v-if="codeUrl" class="code-img" :src="codeUrl" alt="验证码,点击刷新" title="点击刷新验证码" @click="getCode" />
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<div class="row">
|
||||
<el-checkbox v-model="rememberAccount">记住账号</el-checkbox>
|
||||
<span class="tip">忘记密码请联系公寓管理员</span>
|
||||
</div>
|
||||
|
||||
<div v-if="errorText" class="err" role="alert">{{ errorText }}</div>
|
||||
|
||||
<button type="button" class="submit" :disabled="loading || frozen" @click="handleLogin">
|
||||
{{ loading ? '正在登录…' : '登录' }}
|
||||
</button>
|
||||
</el-form>
|
||||
|
||||
<p class="foot">本页面仅供值班员使用;账号由公寓管理员创建与停用。</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Cookies from 'js-cookie'
|
||||
import { getCodeImg } from '@/api/login'
|
||||
import { safeRedirect, reasonText } from '@/utils/dutyRedirect'
|
||||
|
||||
const USERNAME_PATTERN = /^[^\d]\S{1,19}$/
|
||||
const PASSWORD_PATTERN = /^\S{5,50}$/
|
||||
|
||||
export default {
|
||||
name: 'DutyLogin',
|
||||
data() {
|
||||
return {
|
||||
mode: 'senior',
|
||||
loginForm: { username: '', password: '', code: '', uuid: '' },
|
||||
rememberAccount: false,
|
||||
captchaEnabled: true,
|
||||
codeUrl: '',
|
||||
loading: false,
|
||||
frozen: false,
|
||||
errorText: '',
|
||||
redirect: undefined,
|
||||
reason: undefined,
|
||||
loginRules: {
|
||||
username: [{ required: true, trigger: 'blur', validator: this.validateUsername }],
|
||||
password: [{ required: true, trigger: 'blur', validator: this.validatePassword }],
|
||||
code: [{ required: true, trigger: 'change', message: '请输入 4 位验证码' }]
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
reasonHint() {
|
||||
return reasonText(this.reason)
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
$route: {
|
||||
handler(route) {
|
||||
this.redirect = route.query && route.query.redirect
|
||||
this.reason = route.query && route.query.reason
|
||||
},
|
||||
immediate: true
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.getCode()
|
||||
const saved = Cookies.get('dutyUsername')
|
||||
if (saved) {
|
||||
this.loginForm.username = saved
|
||||
this.rememberAccount = true
|
||||
}
|
||||
try {
|
||||
const mode = localStorage.getItem('duty.mode')
|
||||
if (mode === 'normal' || mode === 'senior') {
|
||||
this.mode = mode
|
||||
}
|
||||
} catch (e) {
|
||||
this.mode = 'senior'
|
||||
}
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.username) {
|
||||
this.$refs.username.focus()
|
||||
}
|
||||
})
|
||||
},
|
||||
methods: {
|
||||
validateUsername(rule, value, callback) {
|
||||
if (!value) {
|
||||
return callback(new Error('请输入账号'))
|
||||
}
|
||||
if (!USERNAME_PATTERN.test(String(value).trim())) {
|
||||
return callback(new Error('账号为 2–20 位字母、数字、汉字或下划线,且不能以数字开头'))
|
||||
}
|
||||
callback()
|
||||
},
|
||||
validatePassword(rule, value, callback) {
|
||||
if (!value) {
|
||||
return callback(new Error('请输入密码'))
|
||||
}
|
||||
if (!PASSWORD_PATTERN.test(value)) {
|
||||
return callback(new Error('密码为 5–50 个字符,且不含空格'))
|
||||
}
|
||||
callback()
|
||||
},
|
||||
getCode() {
|
||||
getCodeImg().then(res => {
|
||||
this.captchaEnabled = res.captchaEnabled === undefined ? true : res.captchaEnabled
|
||||
if (this.captchaEnabled) {
|
||||
this.codeUrl = 'data:image/gif;base64,' + res.img
|
||||
this.loginForm.uuid = res.uuid
|
||||
this.loginForm.code = ''
|
||||
}
|
||||
}).catch(() => {
|
||||
this.errorText = '验证码加载失败,请检查网络后点击图片重试'
|
||||
})
|
||||
},
|
||||
handleLogin() {
|
||||
if (this.loading) {
|
||||
return
|
||||
}
|
||||
this.$refs.loginForm.validate(valid => {
|
||||
if (!valid) {
|
||||
return
|
||||
}
|
||||
this.loading = true
|
||||
this.errorText = ''
|
||||
this.$store.dispatch('Login', {
|
||||
username: this.loginForm.username.trim(),
|
||||
password: this.loginForm.password,
|
||||
code: this.loginForm.code,
|
||||
uuid: this.loginForm.uuid
|
||||
}).then(() => {
|
||||
return this.$store.dispatch('GetInfo')
|
||||
}).then(() => {
|
||||
const perms = this.$store.getters.permissions || []
|
||||
if (perms.indexOf('*:*:*') < 0 && perms.indexOf('duty:jcxx:list') < 0) {
|
||||
return this.$store.dispatch('LogOut').then(() => {
|
||||
throw new Error('NO_DUTY_PERMISSION')
|
||||
})
|
||||
}
|
||||
return this.$store.dispatch('GenerateRoutes').then(() => {
|
||||
this.persistAccount()
|
||||
this.$router.replace(safeRedirect(this.redirect)).catch(() => {})
|
||||
})
|
||||
}).catch(error => {
|
||||
this.handleFailure(error)
|
||||
}).then(() => {
|
||||
this.loading = false
|
||||
})
|
||||
})
|
||||
},
|
||||
handleFailure(error) {
|
||||
const message = (error && (error.message || error.msg)) || ''
|
||||
if (message === 'NO_DUTY_PERMISSION') {
|
||||
this.errorText = '当前账号没有值班台权限,请联系管理员开通 duty:jcxx:list'
|
||||
this.getCode()
|
||||
return
|
||||
}
|
||||
const mapped = mapFailure(message)
|
||||
this.errorText = mapped.text
|
||||
if (mapped.clearPassword) {
|
||||
this.loginForm.password = ''
|
||||
}
|
||||
if (mapped.refreshCode) {
|
||||
this.getCode()
|
||||
}
|
||||
if (mapped.disabled) {
|
||||
this.frozen = true
|
||||
}
|
||||
},
|
||||
persistAccount() {
|
||||
if (this.rememberAccount) {
|
||||
Cookies.set('dutyUsername', this.loginForm.username.trim(), { expires: 30 })
|
||||
} else {
|
||||
Cookies.remove('dutyUsername')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 后端 msg(i18n/messages.properties 原文)→ 值班台文案与页面动作 */
|
||||
function mapFailure(message) {
|
||||
const msg = String(message || '')
|
||||
if (msg.indexOf('锁定') >= 0) {
|
||||
return { text: '密码连续错误 5 次,账号已锁定 10 分钟,请稍后再试或联系管理员', refreshCode: true, clearPassword: true, disabled: true }
|
||||
}
|
||||
if (msg.indexOf('验证码') >= 0) {
|
||||
return { text: msg.indexOf('失效') >= 0 ? '验证码已过期,已刷新,请重新输入' : '验证码不正确,已刷新,请重新输入', refreshCode: true, clearPassword: false }
|
||||
}
|
||||
if (msg.indexOf('封禁') >= 0 || msg.indexOf('停用') >= 0) {
|
||||
return { text: '该账号已停用,请联系公寓管理员', refreshCode: true, clearPassword: true, disabled: true }
|
||||
}
|
||||
if (msg.indexOf('删除') >= 0) {
|
||||
return { text: '该账号不存在,请联系公寓管理员', refreshCode: true, clearPassword: true, disabled: true }
|
||||
}
|
||||
if (msg.indexOf('黑名单') >= 0) {
|
||||
return { text: '当前设备网络被限制访问,请联系管理员', refreshCode: true, clearPassword: false, disabled: true }
|
||||
}
|
||||
if (msg.indexOf('用户不存在') >= 0 || msg.indexOf('密码') >= 0) {
|
||||
return { text: '账号或密码错误,请重新输入', refreshCode: true, clearPassword: true }
|
||||
}
|
||||
if (!msg) {
|
||||
return { text: '系统暂时不可用,请稍后重试', refreshCode: false, clearPassword: false }
|
||||
}
|
||||
return { text: msg, refreshCode: true, clearPassword: false }
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.duty-login {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: #f3f6f5;
|
||||
color: #17201f;
|
||||
font-size: 18px;
|
||||
}
|
||||
.duty-login.mode-normal {
|
||||
font-size: 14px;
|
||||
}
|
||||
.card {
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
background: #fff;
|
||||
border: 3px solid #c6d0ce;
|
||||
border-radius: 16px;
|
||||
padding: 24px 26px 18px;
|
||||
}
|
||||
.mode-normal .card {
|
||||
border-width: 1px;
|
||||
border-radius: 10px;
|
||||
padding: 18px 20px 14px;
|
||||
}
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.mark {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 12px;
|
||||
background: #026b69;
|
||||
color: #fff;
|
||||
font-weight: 800;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
}
|
||||
.mode-normal .mark {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
margin: 0;
|
||||
}
|
||||
.mode-normal h1 {
|
||||
font-size: 20px;
|
||||
}
|
||||
.sub {
|
||||
margin: 4px 0 0;
|
||||
color: #404b49;
|
||||
}
|
||||
.reason {
|
||||
background: #fff2d6;
|
||||
border: 2px solid #e0b357;
|
||||
color: #6b4700;
|
||||
border-radius: 10px;
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.lbl {
|
||||
display: block;
|
||||
font-weight: 800;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.code-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
.code-img {
|
||||
height: 44px;
|
||||
width: 132px;
|
||||
border: 2px solid #c6d0ce;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin: 4px 0 14px;
|
||||
}
|
||||
.tip {
|
||||
color: #495652;
|
||||
}
|
||||
.err {
|
||||
background: #fbe3e1;
|
||||
border: 2px solid #d98880;
|
||||
color: #8c1d18;
|
||||
border-radius: 10px;
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 14px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.submit {
|
||||
width: 100%;
|
||||
min-height: 56px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: #026b69;
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
.mode-normal .submit {
|
||||
min-height: 40px;
|
||||
font-size: 16px;
|
||||
}
|
||||
.submit:disabled {
|
||||
background: #7fa5a3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.submit:focus-visible {
|
||||
outline: 4px solid #0b5fff;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.foot {
|
||||
margin: 14px 0 0;
|
||||
color: #495652;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,61 @@
|
||||
-- =============================================================================
|
||||
-- R1 值班台权限点(可重放)。执行顺序:30_realtime.sql → 本文件。
|
||||
-- 口径(应用端/02 表 2-9、应用端/12 表 12-1、应用端/11 表 11-7):
|
||||
-- 1) 本端前缀统一 duty:,不复用 apt:;不注册 apt:jcxx:lockMatch 等管理端点。
|
||||
-- 2) R1 只登记「只读 + 状态命令」共 7 个 F 点:值班台列表/详情读取与 5 个状态命令。
|
||||
-- 语音(duty:call:voice/broadcast/hangup)、审计/交接/房态等点随对应批次再登记。
|
||||
-- 3) **不建 C 页面菜单**:R1 的 /duty/login 与 /duty/jcxx 走前端静态路由,
|
||||
-- 若此处再插同名动态路由会出现两条 /duty/jcxx(Layout 版与整屏版)冲突。
|
||||
-- C 菜单在 R2 与 entry-duty 动态路由(sys_menu 双载体)一起落。
|
||||
-- 4) 不写任何业务表(仅 sys_menu / sys_role_menu)。
|
||||
-- =============================================================================
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
INSERT INTO sys_menu
|
||||
(menu_id, menu_name, parent_id, order_num, path, component, query, route_name,
|
||||
is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
|
||||
VALUES
|
||||
(3260, '值班台盯控列表', 3000, 60, '', '', '', '', 1, 0, 'F', '0', '0',
|
||||
'duty:jcxx:list', '#', 'R1', NOW(3),
|
||||
'R1 GET /duty/jcxx 与 /duty/jcxx/counts;同时是登录准入闸门 2 的判据'),
|
||||
(3261, '值班台任务详情', 3000, 61, '', '', '', '', 1, 0, 'F', '0', '0',
|
||||
'duty:jcxx:query', '#', 'R1', NOW(3),
|
||||
'R1 预留(详情抽屉随 T-BE-03 在 R2 启用),用于与列表权限分离'),
|
||||
(3262, '停止叫乘', 3000, 62, '', '', '', '', 1, 0, 'F', '0', '0',
|
||||
'duty:call:stop', '#', 'R1', NOW(3),
|
||||
'commands{STOP};前置 ryzt in (1,2) 且 finish_ly=0,reason 必填'),
|
||||
(3263, '复叫', 3000, 63, '', '', '', '', 1, 0, 'F', '0', '0',
|
||||
'duty:call:recall', '#', 'R1', NOW(3),
|
||||
'commands{RE_CALL};前置 stoped=1 或 ryzt=2'),
|
||||
(3264, '取消叫乘', 3000, 64, '', '', '', '', 1, 0, 'F', '0', '0',
|
||||
'duty:call:cancel', '#', 'R1', NOW(3),
|
||||
'commands{CANCEL};前置 ryzt=0,仅置 stoped=1 不删行'),
|
||||
(3265, '强制结束', 3000, 65, '', '', '', '', 1, 0, 'F', '0', '0',
|
||||
'duty:call:forceFinish', '#', 'R1', NOW(3),
|
||||
'commands{FORCE_FINISH};前置 finish_ly=0'),
|
||||
(3266, '登记离寓', 3000, 66, '', '', '', '', 1, 0, 'F', '0', '0',
|
||||
'duty:call:markLeave', '#', 'R1', NOW(3),
|
||||
'commands{MARK_LEAVE};写 gygl_ly_event(leave_type=MANUAL) + finish_ly=1')
|
||||
ON DUPLICATE KEY UPDATE menu_name = VALUES(menu_name), parent_id = VALUES(parent_id),
|
||||
order_num = VALUES(order_num), menu_type = VALUES(menu_type), visible = VALUES(visible),
|
||||
status = VALUES(status), perms = VALUES(perms), remark = VALUES(remark);
|
||||
|
||||
-- 超管绑定(其余角色由公寓管理员按岗位授权)
|
||||
INSERT INTO sys_role_menu (role_id, menu_id)
|
||||
SELECT 1, m.menu_id
|
||||
FROM sys_menu m
|
||||
WHERE m.menu_id IN (3260, 3261, 3262, 3263, 3264, 3265, 3266)
|
||||
AND NOT EXISTS (SELECT 1 FROM sys_role_menu rm WHERE rm.role_id = 1 AND rm.menu_id = m.menu_id);
|
||||
|
||||
-- 校验
|
||||
SELECT '=== 68. 值班台权限点(应 7)===' AS section;
|
||||
SELECT menu_id, perms, menu_name, status FROM sys_menu
|
||||
WHERE perms LIKE 'duty:%' AND menu_id BETWEEN 3260 AND 3266
|
||||
ORDER BY menu_id;
|
||||
|
||||
SELECT '=== 69. 超管绑定(应 7)===' AS section;
|
||||
SELECT COUNT(*) AS admin_bound FROM sys_role_menu
|
||||
WHERE role_id = 1 AND menu_id BETWEEN 3260 AND 3266;
|
||||
|
||||
SELECT '=== 70. 不得出现 duty C 菜单(应 0)===' AS section;
|
||||
SELECT COUNT(*) AS duty_c_menu FROM sys_menu WHERE perms LIKE 'duty:%' AND menu_type = 'C';
|
||||
@@ -0,0 +1,29 @@
|
||||
-- =============================================================================
|
||||
-- 33 值班台测试配置增量列(R5 / 应用端/17)
|
||||
--
|
||||
-- 目的:SIP 分机可用号码段(如 1010-1050)需要可在值班台配置页录入,
|
||||
-- 原先只存在于运维口头约定,`apt_ast_instance` 没有承载字段。
|
||||
-- 约束:
|
||||
-- 1) 只做幂等补列,不改既有列、不动任何 CHECK 约束;
|
||||
-- 2) 分机号本身仍逐条登记在 `apt_sip_endpoint.extension`(号码不复用),
|
||||
-- 号段只用于「录入时的取值范围校验与空闲号推荐」;
|
||||
-- 3) 号段为空表示不限制(兼容既有部署)。
|
||||
-- =============================================================================
|
||||
SET NAMES utf8mb4;
|
||||
SET SESSION sql_mode = 'STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION';
|
||||
|
||||
USE apt_rev01;
|
||||
|
||||
CALL apt_add_column_if_missing('apt_ast_instance', 'ext_pool_from',
|
||||
'ext_pool_from VARCHAR(8) NULL COMMENT ''可用分机号段起(含),空=不限制''');
|
||||
|
||||
CALL apt_add_column_if_missing('apt_ast_instance', 'ext_pool_to',
|
||||
'ext_pool_to VARCHAR(8) NULL COMMENT ''可用分机号段止(含),空=不限制''');
|
||||
|
||||
-- 校验 1:两列都存在
|
||||
SELECT '=== 33. apt_ast_instance 号段列(应 2)===' AS section;
|
||||
SELECT COUNT(*) AS ext_pool_columns
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'apt_ast_instance'
|
||||
AND COLUMN_NAME IN ('ext_pool_from', 'ext_pool_to');
|
||||
@@ -0,0 +1,95 @@
|
||||
-- =============================================================================
|
||||
-- 34 值班台测试前置数据(R5 / 应用端/17)
|
||||
--
|
||||
-- 两件事:
|
||||
-- ① SIP 服务器(AMI)配置落库:地址 / 端口 / 用户名 / 口令引用 / 号码段。
|
||||
-- **口令不落库**:`ami_secret_ref` 只存环境变量名,实际口令由部署环境注入
|
||||
-- (21_ami_instance.sql 已断言「口令不落明文」,此处不改口径)。
|
||||
-- 本机联调请在 `dev/.env` 或 IDEA 运行配置里设置:
|
||||
-- APT_AMI_SECRET=<AMI 口令>
|
||||
-- ② 为「系统内人员」补齐一条 CONFIRMED 人脸注册记录:
|
||||
-- 入住申请(N-40)在服务层强制校验「人脸注册已获值班员确认」,
|
||||
-- 没有这条记录时任何入住都会被 40922 拒绝,叫乘链路无从验证。
|
||||
-- 这里只是把前置台账补齐,不绕过任何服务层校验。
|
||||
-- =============================================================================
|
||||
SET NAMES utf8mb4;
|
||||
SET SESSION sql_mode = 'STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION';
|
||||
|
||||
USE apt_rev01;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- ① AMI / SIP 服务器配置(一公寓一实例,uk_astinstance_gyid)
|
||||
-- ---------------------------------------------------------------------------
|
||||
UPDATE apt_ast_instance
|
||||
SET ami_host = '192.168.1.179',
|
||||
ami_port = 7058,
|
||||
ami_user = 'manager01',
|
||||
ami_secret_ref = 'APT_AMI_SECRET',
|
||||
ext_pool_from = '1010',
|
||||
ext_pool_to = '1050',
|
||||
enabled = 1,
|
||||
biz_version = biz_version + 1
|
||||
WHERE gyid = 'APT-REV01-001' AND deleted = 0;
|
||||
|
||||
-- 若该 gyid 尚无实例行则补一条(同一公寓只允许一条)
|
||||
INSERT INTO apt_ast_instance
|
||||
(gyid, instance_code, ami_host, ami_port, ami_user, ami_secret_ref,
|
||||
tls_enabled, channel_tech, state, enabled, biz_version, ext_pool_from, ext_pool_to, deleted)
|
||||
SELECT 'APT-REV01-001', 'AST-REV01-001', '192.168.1.179', 7058, 'manager01', 'APT_AMI_SECRET',
|
||||
0, 'PJSIP', 'DISABLED', 1, 1, '1010', '1050', 0
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM apt_ast_instance WHERE gyid = 'APT-REV01-001');
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- ② 人脸注册确认台账(仅为缺记录的人员补一条,幂等)
|
||||
-- ---------------------------------------------------------------------------
|
||||
INSERT INTO apt_person_face_reg
|
||||
(gyid, reg_id, work_no, name, unit_code, face_feature_ref, feature_version,
|
||||
person_category, reg_state, local_only, verdict, confirmed_ryid, confirmed_by, confirmed_at,
|
||||
upload_state, dispatch_state, report_state, face_sync_state, platform_version,
|
||||
occurred_at, created_at)
|
||||
SELECT p.gyid,
|
||||
CONCAT('REG-TEST-', p.ryid),
|
||||
p.work_no,
|
||||
p.xm,
|
||||
NULL,
|
||||
CONCAT('test://feature/', p.ryid),
|
||||
COALESCE(p.feature_version, 1),
|
||||
COALESCE(p.person_category, 'TRANSIENT'),
|
||||
'CONFIRMED',
|
||||
1,
|
||||
'ACCEPTED',
|
||||
p.ryid,
|
||||
'LOCAL-SEED',
|
||||
NOW(3),
|
||||
'PENDING',
|
||||
'PENDING',
|
||||
'PENDING',
|
||||
'SYNCED',
|
||||
1,
|
||||
NOW(3),
|
||||
NOW(3)
|
||||
FROM apt_master_person p
|
||||
WHERE p.gyid = 'APT-REV01-001'
|
||||
AND COALESCE(p.deleted, 0) = 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM apt_person_face_reg r
|
||||
WHERE r.gyid = p.gyid AND r.reg_state = 'CONFIRMED' AND r.confirmed_ryid = p.ryid);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 自检
|
||||
-- ---------------------------------------------------------------------------
|
||||
SELECT '=== 34. AMI 配置(口令只显示引用名)===' AS section;
|
||||
SELECT ami_host, ami_port, ami_user, ami_secret_ref, ext_pool_from, ext_pool_to, enabled, state
|
||||
FROM apt_ast_instance WHERE gyid = 'APT-REV01-001';
|
||||
|
||||
SELECT '=== 34. 可办理入住的人员数(有 CONFIRMED 注册且当前未在住)===' AS section;
|
||||
SELECT COUNT(*) AS candidate_person
|
||||
FROM apt_master_person p
|
||||
WHERE p.gyid = 'APT-REV01-001' AND COALESCE(p.deleted, 0) = 0
|
||||
AND EXISTS (SELECT 1 FROM apt_person_face_reg r
|
||||
WHERE r.gyid = p.gyid
|
||||
AND r.reg_state = 'CONFIRMED' AND r.confirmed_ryid = p.ryid)
|
||||
AND NOT EXISTS (SELECT 1 FROM gy_jcxx j
|
||||
WHERE j.gyid = p.gyid AND j.ryid = p.ryid AND j.deleted = 0
|
||||
AND j.finish_ly = '0' AND j.sjlysj IS NULL);
|
||||
@@ -0,0 +1,68 @@
|
||||
-- =============================================================================
|
||||
-- 35 值班台测试配置与测试入住权限点(R5 / 应用端/17)
|
||||
--
|
||||
-- 五个权限点:
|
||||
-- duty:config:read 查看测试配置面板(房间/绑定/服务器现状)
|
||||
-- duty:config:room 录入测试房间(补平台基线 + 登记本端房间)
|
||||
-- duty:config:sip 房间与 SIP 分机绑定/解绑
|
||||
-- duty:config:sipServer SIP 服务器(AMI)地址/端口/口令引用/号段
|
||||
-- duty:checkin:test 测试办理入住(造入住记录 + 生成叫乘任务)
|
||||
--
|
||||
-- 发放:超级管理员天然拥有 *:*:*;另外发给「公寓值班员」
|
||||
-- (role_key = apartmentduty),测试配置由值班员自己维护。
|
||||
-- 幂等:按 menu_id 与 (role_id, menu_id) 双重判重。
|
||||
-- =============================================================================
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
USE apt_rev01;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 权限点
|
||||
-- ---------------------------------------------------------------------------
|
||||
INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame,
|
||||
is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
|
||||
SELECT 3270, '测试配置查看', 0, 20, '', NULL, 1, 0, 'F', '0', '0', 'duty:config:read', '#', 'apt-rev01', NOW(), '值班台测试配置面板只读'
|
||||
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE menu_id = 3270);
|
||||
|
||||
INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame,
|
||||
is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
|
||||
SELECT 3271, '测试房间录入', 0, 21, '', NULL, 1, 0, 'F', '0', '0', 'duty:config:room', '#', 'apt-rev01', NOW(), '补平台房间基线并登记本端房间(仅测试环境)'
|
||||
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE menu_id = 3271);
|
||||
|
||||
INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame,
|
||||
is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
|
||||
SELECT 3272, '房间SIP绑定', 0, 22, '', NULL, 1, 0, 'F', '0', '0', 'duty:config:sip', '#', 'apt-rev01', NOW(), '房间与 SIP 分机绑定/解绑'
|
||||
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE menu_id = 3272);
|
||||
|
||||
INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame,
|
||||
is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
|
||||
SELECT 3273, 'SIP服务器配置', 0, 23, '', NULL, 1, 0, 'F', '0', '0', 'duty:config:sipServer', '#', 'apt-rev01', NOW(), 'AMI 地址/端口/用户名/口令引用/号段'
|
||||
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE menu_id = 3273);
|
||||
|
||||
INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame,
|
||||
is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
|
||||
SELECT 3274, '测试办理入住', 0, 24, '', NULL, 1, 0, 'F', '0', '0', 'duty:checkin:test', '#', 'apt-rev01', NOW(), '为系统内人员造入住记录并生成叫乘任务(仅测试环境)'
|
||||
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE menu_id = 3274);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 发放给「公寓值班员」
|
||||
-- ---------------------------------------------------------------------------
|
||||
INSERT INTO sys_role_menu (role_id, menu_id)
|
||||
SELECT r.role_id, m.menu_id
|
||||
FROM sys_role r
|
||||
JOIN sys_menu m ON m.menu_id IN (3270, 3271, 3272, 3273, 3274)
|
||||
WHERE r.role_key = 'apartmentduty'
|
||||
AND NOT EXISTS (SELECT 1 FROM sys_role_menu rm
|
||||
WHERE rm.role_id = r.role_id AND rm.menu_id = m.menu_id);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 自检
|
||||
-- ---------------------------------------------------------------------------
|
||||
SELECT '=== 35. 新增权限点(应 5)===' AS section;
|
||||
SELECT menu_id, menu_name, perms FROM sys_menu WHERE menu_id BETWEEN 3270 AND 3274 ORDER BY menu_id;
|
||||
|
||||
SELECT '=== 35. 值班员已获授权(应 5)===' AS section;
|
||||
SELECT COUNT(*) AS granted
|
||||
FROM sys_role_menu rm
|
||||
JOIN sys_role r ON r.role_id = rm.role_id
|
||||
WHERE r.role_key = 'apartmentduty' AND rm.menu_id BETWEEN 3270 AND 3274;
|
||||
@@ -0,0 +1,96 @@
|
||||
-- ============================================================================
|
||||
-- 值班台(应用端)演示用叫乘任务 —— 本地联调数据,不是迁移脚本
|
||||
--
|
||||
-- 用途:在本地库造几条覆盖全部 UI 状态的 gy_jcxx 行,用来打开 /duty/jcxx 看效果。
|
||||
-- 特征:jcid 一律以 JC-DEMO- 开头,可重放(先按前缀删除再插入),
|
||||
-- 时刻全部基于 NOW() 推算,保证落在值班台「今日 [00:00, 明日 00:00)」窗口内。
|
||||
-- 注意:这是唯一写入口之外的手工造数,只允许出现在本地联调库;
|
||||
-- 生产/测试环境的叫乘数据只能由签到与叫乘链路产生。
|
||||
-- 库名与 gyid 对齐 dev/.env 的 APT_DB_NAME / APT_GY_ID。
|
||||
-- ============================================================================
|
||||
|
||||
USE apt_rev01;
|
||||
|
||||
SET @gyid = 'APT-REV01-001';
|
||||
|
||||
DELETE FROM gy_jcxx
|
||||
WHERE gyid = @gyid AND jcid LIKE 'JC-DEMO-%';
|
||||
|
||||
INSERT INTO gy_jcxx
|
||||
(gyid, jcid, cc, xm, fjh, kcsj, jcsj, ryzt, finish_jc, finish_ly, stoped,
|
||||
call_mode, biz_version, occurred_at, received_at, deleted,
|
||||
jcsj_s, hjxysj, jcbjsj_s, fjsj_s, lybjsj, sjlysj,
|
||||
is_jcbj, is_lybj, is_temp_call, leave_remark)
|
||||
VALUES
|
||||
-- 1 待叫乘 + 已过点(应进入「紧急」区,urgent=true)
|
||||
(@gyid, 'JC-DEMO-001', 'G1234', '张伟', '201',
|
||||
NOW(3) + INTERVAL 3 HOUR, NOW(3) - INTERVAL 30 MINUTE, '0', '0', '0', '0',
|
||||
'SIP', 1, NOW(3), NOW(3), 0,
|
||||
NULL, NULL, NULL, NULL, NULL, NULL,
|
||||
'0', '0', '0', NULL),
|
||||
|
||||
-- 2 待叫乘 + 未到点(普通待办,remainMinutes 为正)
|
||||
(@gyid, 'JC-DEMO-002', 'D5678', '李强', '202',
|
||||
NOW(3) + INTERVAL 4 HOUR, NOW(3) + INTERVAL 2 HOUR, '0', '0', '0', '0',
|
||||
'SIP', 1, NOW(3), NOW(3), 0,
|
||||
NULL, NULL, NULL, NULL, NULL, NULL,
|
||||
'0', '0', '0', NULL),
|
||||
|
||||
-- 3 呼叫中(ryzt=1,平板链路)
|
||||
(@gyid, 'JC-DEMO-003', 'K9012', '王芳', '301',
|
||||
NOW(3) + INTERVAL 3 HOUR, NOW(3) - INTERVAL 3 MINUTE, '1', '0', '0', '0',
|
||||
'TABLET', 1, NOW(3), NOW(3), 0,
|
||||
NOW(3) - INTERVAL 3 MINUTE, NULL, NULL, NULL, NULL, NULL,
|
||||
'0', '0', '0', NULL),
|
||||
|
||||
-- 4 叫乘报警(ryzt=2,SIP 30s 无入会;值班台应置红 + 起报警音)
|
||||
(@gyid, 'JC-DEMO-004', 'Z3456', '赵敏', '302',
|
||||
NOW(3) + INTERVAL 3 HOUR, NOW(3) - INTERVAL 8 MINUTE, '2', '0', '0', '0',
|
||||
'SIP', 1, NOW(3), NOW(3), 0,
|
||||
NOW(3) - INTERVAL 8 MINUTE, NULL, NOW(3) - INTERVAL 3 MINUTE, NULL, NULL, NULL,
|
||||
'1', '0', '0', NULL),
|
||||
|
||||
-- 5 待离寓(ryzt=3,叫乘已完成)
|
||||
(@gyid, 'JC-DEMO-005', 'G7890', '陈刚', '303',
|
||||
NOW(3) + INTERVAL 1 HOUR, NOW(3) - INTERVAL 1 HOUR, '3', '1', '0', '0',
|
||||
'SIP', 1, NOW(3), NOW(3), 0,
|
||||
NOW(3) - INTERVAL 1 HOUR, NOW(3) - INTERVAL 50 MINUTE, NULL, NULL, NULL, NULL,
|
||||
'0', '0', '0', NULL),
|
||||
|
||||
-- 6 离寓报警(ryzt=4,超时应离寓)
|
||||
(@gyid, 'JC-DEMO-006', 'D1122', '刘晓', '201',
|
||||
NOW(3) - INTERVAL 1 HOUR, NOW(3) - INTERVAL 2 HOUR, '4', '1', '0', '0',
|
||||
'SIP', 1, NOW(3), NOW(3), 0,
|
||||
NOW(3) - INTERVAL 2 HOUR, NOW(3) - INTERVAL 110 MINUTE, NULL, NULL,
|
||||
NOW(3) - INTERVAL 5 MINUTE, NULL,
|
||||
'0', '1', '0', NULL),
|
||||
|
||||
-- 7 已停止(stoped=1)
|
||||
(@gyid, 'JC-DEMO-007', 'G3344', '孙悦', '202',
|
||||
NOW(3) + INTERVAL 2 HOUR, NOW(3) - INTERVAL 20 MINUTE, '1', '0', '0', '1',
|
||||
'SIP', 1, NOW(3), NOW(3), 0,
|
||||
NOW(3) - INTERVAL 20 MINUTE, NULL, NULL, NULL, NULL, NULL,
|
||||
'0', '0', '0', NULL),
|
||||
|
||||
-- 8 已完成并离寓(finish_ly=1,应落在「已完成」筛选)
|
||||
(@gyid, 'JC-DEMO-008', 'T5566', '周涛', '301',
|
||||
NOW(3) - INTERVAL 2 HOUR, NOW(3) - INTERVAL 3 HOUR, '3', '1', '1', '0',
|
||||
'TABLET', 1, NOW(3), NOW(3), 0,
|
||||
NOW(3) - INTERVAL 3 HOUR, NOW(3) - INTERVAL 170 MINUTE, NULL, NULL, NULL,
|
||||
NOW(3) - INTERVAL 30 MINUTE,
|
||||
'0', '0', '0', '演示数据:已完成并登记离寓'),
|
||||
|
||||
-- 9 已入寓但未排任务(jcsj 为空,任何时间窗都必须可见)
|
||||
-- 注意:ck_jcxx_ryzt 只允许 '0'~'5',所以这里不能写空串(DutyStateDeriver 的
|
||||
-- "ryzt 空 = IN 已入寓" 在库里不可达,见交付说明 L-14)
|
||||
(@gyid, 'JC-DEMO-009', 'G7788', '吴磊', '302',
|
||||
NOW(3) + INTERVAL 5 HOUR, NULL, '0', '0', '0', '0',
|
||||
'SIP', 1, NOW(3), NOW(3), 0,
|
||||
NULL, NULL, NULL, NULL, NULL, NULL,
|
||||
'0', '0', '0', NULL);
|
||||
|
||||
-- 自检:各 UI 状态各 1 条 + 1 条未排任务
|
||||
SELECT jcid, fjh, xm, ryzt, finish_jc, finish_ly, stoped, jcsj, call_mode
|
||||
FROM gy_jcxx
|
||||
WHERE gyid = @gyid AND jcid LIKE 'JC-DEMO-%'
|
||||
ORDER BY jcid;
|
||||
Reference in new issue
Block a user