1 parent
417c076465
commit
35751b4f00
144 files changed
+13905
-342
No files matched your search
+8
-2
@@ -1,7 +1,10 @@
|
||||
# 公寓端本地运行环境变量模板(IT-0 / IT-1)
|
||||
# 用法:复制为 dev/.env(或 dev/scripts/.env)后填写真实值。
|
||||
# 公寓端本地运行环境变量模板(IT-0 / IT-1 / IT-2)
|
||||
# 用法:复制为 dev/.env 后填写真实值。
|
||||
# .env 已被 .gitignore 忽略,禁止提交;本模板不得写入任何真实口令。
|
||||
# 严禁把明文口令写入数据库配置文件、CI 脚本或提交记录。
|
||||
# 填好 APT_DB_PASSWORD 后执行:
|
||||
# powershell -ExecutionPolicy Bypass -File dev/scripts/apply-it2-sql.ps1
|
||||
# 无凭据时可先预览:追加 -DryRun
|
||||
|
||||
# ---- MySQL(apt_rev01)----
|
||||
APT_DB_HOST=127.0.0.1
|
||||
@@ -35,6 +38,9 @@ APT_HTTP_PORT=8080
|
||||
APT_UPLOAD_PATH=./uploadPath
|
||||
APT_PROBE_ENABLED=true
|
||||
APT_PROBE_TIMEOUT_MS=2000
|
||||
# Table 9-16: Mock master downlink is only allowed with Spring profile 'local'.
|
||||
# Keep false in production; a non-local profile with true fails startup.
|
||||
APT_MASTER_CLIENT_MOCK=false
|
||||
|
||||
# ---- Druid console (disabled by default) ----
|
||||
APT_DRUID_CONSOLE=false
|
||||
|
||||
@@ -170,6 +170,13 @@
|
||||
<version>${ruoyi.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 公寓端集中模块-->
|
||||
<dependency>
|
||||
<groupId>com.ruoyi</groupId>
|
||||
<artifactId>ruoyi-apt</artifactId>
|
||||
<version>${ruoyi.version}</version>
|
||||
</dependency>
|
||||
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
@@ -180,6 +187,7 @@
|
||||
<module>ruoyi-quartz</module>
|
||||
<module>ruoyi-generator</module>
|
||||
<module>ruoyi-common</module>
|
||||
<module>ruoyi-apt</module>
|
||||
</modules>
|
||||
<packaging>pom</packaging>
|
||||
|
||||
|
||||
@@ -42,6 +42,12 @@
|
||||
<artifactId>ruoyi-framework</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- 公寓端集中模块:接口 / 业务逻辑 / 配置 / 静态资源挂载点 -->
|
||||
<dependency>
|
||||
<groupId>com.ruoyi</groupId>
|
||||
<artifactId>ruoyi-apt</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- 定时任务-->
|
||||
<dependency>
|
||||
<groupId>com.ruoyi</groupId>
|
||||
|
||||
-52
@@ -1,52 +0,0 @@
|
||||
package com.ruoyi.apt.ops.controller;
|
||||
|
||||
import java.util.List;
|
||||
import com.ruoyi.apt.common.AptContext;
|
||||
import com.ruoyi.apt.common.audit.AptAudit;
|
||||
import com.ruoyi.apt.common.page.AptTableDataInfo;
|
||||
import com.ruoyi.apt.framework.web.AptBaseController;
|
||||
import com.ruoyi.apt.master.domain.AptRoute;
|
||||
import com.ruoyi.apt.master.mapper.AptRouteMapper;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* IT-1 / M0 业务表只读查询骨架:仅用于验证鉴权、gyid 隔离、分页上限与审计链路。
|
||||
* 按表 13-11「不含业务表 CRUD」,本类不提供新增 / 编辑 / 删除 / 状态命令接口。
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping(value = "/apt/route", produces = "application/json;charset=UTF-8")
|
||||
public class AptRouteController extends AptBaseController
|
||||
{
|
||||
private final AptRouteMapper routeMapper;
|
||||
|
||||
@Autowired
|
||||
public AptRouteController(AptRouteMapper routeMapper)
|
||||
{
|
||||
this.routeMapper = routeMapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /apt/route/list
|
||||
* 请求参数:cc、jlid、state、pageNum、pageSize、orderByColumn、isAsc
|
||||
* 返回:{code,msg,rows,total,pageSize,traceId};pageSize 上限 100。
|
||||
*/
|
||||
@PreAuthorize("@ss.hasPermi('apt:route:list')")
|
||||
@AptAudit(module = "ROUTE", action = "LIST")
|
||||
@GetMapping("/list")
|
||||
public AptTableDataInfo list(
|
||||
@RequestParam(required = false) String cc,
|
||||
@RequestParam(required = false) String jlid,
|
||||
@RequestParam(required = false) String state)
|
||||
{
|
||||
startAptPage();
|
||||
List<AptRoute> rows = routeMapper.selectRouteList(AptContext.getGyId(), cc, jlid, state);
|
||||
return getAptDataTable(rows);
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,6 @@ server:
|
||||
logging:
|
||||
level:
|
||||
com.ruoyi: debug
|
||||
com.ruoyi.apt: info
|
||||
org.springframework: warn
|
||||
|
||||
user:
|
||||
@@ -35,6 +34,10 @@ user:
|
||||
lockTime: 10
|
||||
|
||||
spring:
|
||||
# 公寓端自有配置(apt.*、springdoc 分组、com.ruoyi.apt 日志级别)由 ruoyi-apt 模块提供;
|
||||
# optional: 保证该模块不在 classpath 时后台仍可启动。主配置同键优先级更高。
|
||||
config:
|
||||
import: 'optional:classpath:apt-defaults.yml'
|
||||
messages:
|
||||
basename: i18n/messages
|
||||
profiles:
|
||||
@@ -74,6 +77,7 @@ token:
|
||||
expireTime: 30
|
||||
|
||||
mybatis:
|
||||
# 通配覆盖 ruoyi-apt 模块内的 mapper/apt/**/*Mapper.xml
|
||||
typeAliasesPackage: com.ruoyi.**.domain
|
||||
mapperLocations: classpath*:mapper/**/*Mapper.xml
|
||||
configLocation: classpath:mybatis/mybatis-config.xml
|
||||
@@ -90,20 +94,7 @@ springdoc:
|
||||
enabled: true
|
||||
path: /swagger-ui.html
|
||||
tags-sorter: alpha
|
||||
group-configs:
|
||||
- group: 'apt'
|
||||
display-name: '公寓管理端 /apt'
|
||||
paths-to-match: '/apt/**'
|
||||
packages-to-scan: com.ruoyi.apt.**
|
||||
- group: 'duty'
|
||||
display-name: '值班台 /duty'
|
||||
paths-to-match: '/duty/**'
|
||||
packages-to-scan: com.ruoyi.apt.**
|
||||
- group: 'open'
|
||||
display-name: '设备侧 /open/v1'
|
||||
paths-to-match: '/open/v1/**'
|
||||
packages-to-scan: com.ruoyi.apt.**
|
||||
# /platform/** 属平台管理端,公寓端不提供,故不分组
|
||||
# group-configs 由 ruoyi-apt 的 apt-defaults.yml 引入
|
||||
|
||||
referer:
|
||||
enabled: false
|
||||
@@ -113,19 +104,3 @@ xss:
|
||||
enabled: true
|
||||
excludes: /system/notice
|
||||
urlPatterns: /system/*,/monitor/*,/tool/*
|
||||
|
||||
# ---- 公寓端基座自定义配置(IT-0 / IT-1)----
|
||||
apt:
|
||||
# 一进程一 gyid(D5):只从部署环境注入,不接受请求参数
|
||||
gy-id: ${APT_GY_ID:APT-REV01-001}
|
||||
probe:
|
||||
enabled: ${APT_PROBE_ENABLED:true}
|
||||
# 单次探针超时(毫秒),超过即判定 TIMEOUT
|
||||
timeout-ms: ${APT_PROBE_TIMEOUT_MS:2000}
|
||||
redis-key-prefix: ${APT_REDIS_KEY_PREFIX:apt:rev01:}
|
||||
# 探针数据存活时间(秒),避免残留脏数据
|
||||
redis-ttl-seconds: 60
|
||||
# 探针表名(只写探针专用表,不触碰任何业务表)
|
||||
table: apt_ops_probe
|
||||
page:
|
||||
max-size: 100
|
||||
@@ -0,0 +1,64 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<parent>
|
||||
<artifactId>ruoyi</artifactId>
|
||||
<groupId>com.ruoyi</groupId>
|
||||
<version>3.9.2</version>
|
||||
</parent>
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<packaging>jar</packaging>
|
||||
<artifactId>ruoyi-apt</artifactId>
|
||||
|
||||
<description>
|
||||
公寓端集中模块:四前缀接口、业务逻辑、审计与鉴权、配置项、Mapper XML 与前端静态资源挂载点
|
||||
</description>
|
||||
|
||||
<dependencies>
|
||||
|
||||
<!-- 公共模块:AptContext/错误码基础、AjaxResult、PageHelper、Security、Validation、fastjson2、Redis -->
|
||||
<dependency>
|
||||
<groupId>com.ruoyi</groupId>
|
||||
<artifactId>ruoyi-common</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- 框架模块:Spring WebMVC / AspectJ / Druid 与若依安全处理器、BaseController -->
|
||||
<dependency>
|
||||
<groupId>com.ruoyi</groupId>
|
||||
<artifactId>ruoyi-framework</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Mapper 接口 @Param 与 classpath*:mapper/**/*Mapper.xml -->
|
||||
<dependency>
|
||||
<groupId>org.mybatis.spring.boot</groupId>
|
||||
<artifactId>mybatis-spring-boot-starter</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- 审计 REQUIRES_NEW 与房态 state_version 条件更新 -->
|
||||
<dependency>
|
||||
<groupId>org.springframework</groupId>
|
||||
<artifactId>spring-tx</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- 公寓端自测(JUnit5 + Mockito):仅在 ruoyi-apt 内生效,不向下游传递 -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<!-- JUnit5 需 Surefire 3.x,默认 2.12.4 会静默漏跑全部用例 -->
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.5.2</version>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
</project>
|
||||
File renamed without changes.
+26
@@ -29,6 +29,32 @@ public enum AptErrorCode
|
||||
FACE_REG_PENDING(40914, "注册申请未获值班员确认"),
|
||||
TRANSFER_TIME_ILLEGAL(40915, "换乘时刻非法"),
|
||||
TABLET_TASK_EXPIRED(40916, "平板任务已派发或过期"),
|
||||
/** 设备越出可见范围(IT-3 登记,设备侧执行在 IT-7) */
|
||||
FACE_SCOPE_DENIED(40917, "设备越出人脸可见范围"),
|
||||
/** 同 work_no 已有已确认人员(未决重复申请走原 regId 幂等,不报此码) */
|
||||
FACE_PERSON_DUPLICATE(40918, "该工号已存在人脸注册"),
|
||||
FACE_MEDIA_MISSING(40919, "照片或特征值缺失"),
|
||||
FACE_MEDIA_CHECKSUM_MISMATCH(40920, "媒体校验和不符"),
|
||||
/** 算法不在白名单:允许入档,但不得下发该设备(不静默截断) */
|
||||
FACE_ALGO_UNSUPPORTED(40921, "人脸特征算法不受支持"),
|
||||
FACE_MEDIA_TOO_LARGE(40922, "人脸媒体超过大小上限"),
|
||||
/** 媒体未同步完成(区别于 40914 注册未确认) */
|
||||
FACE_NOT_SYNCED(40923, "人脸媒体未同步完成,禁止下发"),
|
||||
FACE_REG_STATE_INVALID(40924, "注册态不允许该操作"),
|
||||
/** IT-4:入住流程态冲突(非当前态 / bizVersion 过期 / 已入住后换房) */
|
||||
CHECKIN_STATE_CONFLICT(40926, "入住状态冲突,请刷新后重试"),
|
||||
/** IT-4:设备侧人脸缓存版本落后(需重新拉取核验) */
|
||||
FACE_CACHE_STALE(40927, "人脸缓存版本过期,请重新核验"),
|
||||
/** IT-4:房间不可住 / 已满 / 不属于本公寓 */
|
||||
ROOM_NOT_AVAILABLE(40928, "房间不可住或已满"),
|
||||
/** IT-4:N-38 核验会话超过 5 分钟 */
|
||||
VERIFY_SESSION_EXPIRED(40929, "核验会话已过期,请重新刷脸"),
|
||||
/** IT-4:核验结论非 MATCHED 或与申请人不一致 */
|
||||
FACE_VERIFY_MISMATCH(40930, "核验不符"),
|
||||
/** IT-4:交路要素不合法(jlid/cc/jch 与主数据不一致或缺失) */
|
||||
ROUTE_INVALID(40931, "交路要素不合法"),
|
||||
/** IT-4:同一人员已有在住记录 */
|
||||
DUPLICATE_STAY(40932, "该人员已在住,请先办理退宿"),
|
||||
FEATURE_DISABLED(40940, "能力未启用"),
|
||||
RECORDING_PURGED(41001, "录音已清理"),
|
||||
PROTOCOL_NOT_CONFIRMED(50101, "现场协议未确认,暂不支持"),
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+17
-1
@@ -1,10 +1,15 @@
|
||||
package com.ruoyi.apt.common.page;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.ruoyi.common.core.page.TableDataInfo;
|
||||
|
||||
/**
|
||||
* 在若依 TableDataInfo 的 {code,msg,rows,total} 之上追加
|
||||
* 归一化 pageSize 与 traceId(追加字段,不破坏既有约定 公寓端/04 §1.2)。
|
||||
* 归一化 pageSize、traceId 与 warnings[](公寓端/04 §1.2、表 4-7 约束 1)。
|
||||
*
|
||||
* <p>warnings 用于承载「楼栋字段未启用」等降级提示,默认空数组,
|
||||
* 不破坏既有列表响应约定。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
@@ -14,6 +19,7 @@ public class AptTableDataInfo extends TableDataInfo
|
||||
|
||||
private Integer pageSize;
|
||||
private String traceId;
|
||||
private List<String> warnings = new ArrayList<>();
|
||||
|
||||
public Integer getPageSize()
|
||||
{
|
||||
@@ -34,4 +40,14 @@ public class AptTableDataInfo extends TableDataInfo
|
||||
{
|
||||
this.traceId = traceId;
|
||||
}
|
||||
|
||||
public List<String> getWarnings()
|
||||
{
|
||||
return warnings;
|
||||
}
|
||||
|
||||
public void setWarnings(List<String> warnings)
|
||||
{
|
||||
this.warnings = warnings == null ? new ArrayList<>() : warnings;
|
||||
}
|
||||
}
|
||||
+15
-3
@@ -10,6 +10,7 @@ import com.ruoyi.apt.common.AptException;
|
||||
import com.ruoyi.apt.dispatch.mapper.AptJcxxMapper;
|
||||
import com.ruoyi.apt.dispatch.tablet.service.IAptTabletDispatchService;
|
||||
import com.ruoyi.apt.framework.config.AptProperties;
|
||||
import com.ruoyi.apt.voice.service.IAptCallFlowService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -31,16 +32,19 @@ public class AptDispatchScanServiceImpl implements IAptDispatchScanService
|
||||
private final IAptDutyStateService stateService;
|
||||
private final AptJcxxMapper jcxxMapper;
|
||||
private final AptProperties properties;
|
||||
private final IAptCallFlowService callFlowService;
|
||||
|
||||
public AptDispatchScanServiceImpl(IAptTabletDispatchService tabletDispatchService,
|
||||
IAptDutyStateService stateService,
|
||||
AptJcxxMapper jcxxMapper,
|
||||
AptProperties properties)
|
||||
AptProperties properties,
|
||||
IAptCallFlowService callFlowService)
|
||||
{
|
||||
this.tabletDispatchService = tabletDispatchService;
|
||||
this.stateService = stateService;
|
||||
this.jcxxMapper = jcxxMapper;
|
||||
this.properties = properties;
|
||||
this.callFlowService = callFlowService;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -80,13 +84,21 @@ public class AptDispatchScanServiceImpl implements IAptDispatchScanService
|
||||
}
|
||||
}
|
||||
|
||||
// SIP 到点发起(IT-7):房间腿 Originate + 值班台腿;AMI 未就绪时自动跳过
|
||||
int dialed = 0;
|
||||
if (properties.getVoice().isEnabled() && properties.getVoice().isSipCallEnabled())
|
||||
{
|
||||
dialed = callFlowService.dialDue(properties.getVoice().getMaxCallsPerScan());
|
||||
}
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("expiredTasks", expired);
|
||||
result.put("generatedTasks", generated);
|
||||
result.put("sipTimeouts", timedOut);
|
||||
result.put("dialedCalls", dialed);
|
||||
result.put("scannedAt", new Date());
|
||||
log.info("叫乘扫描完成:过期任务 {},新生成 {},SIP 超时 {}",
|
||||
expired, generated, timedOut);
|
||||
log.info("叫乘扫描完成:过期任务 {},新生成 {},SIP 超时 {},新发起呼叫 {}",
|
||||
expired, generated, timedOut, dialed);
|
||||
return result;
|
||||
}
|
||||
finally
|
||||
|
||||
+6
-6
@@ -8,7 +8,6 @@ import com.alibaba.fastjson2.JSON;
|
||||
import com.ruoyi.apt.common.AptContext;
|
||||
import com.ruoyi.apt.common.audit.AptAudit;
|
||||
import com.ruoyi.apt.ops.domain.AptOperationLog;
|
||||
import com.ruoyi.apt.ops.mapper.AptAuditMapper;
|
||||
import com.ruoyi.common.utils.SecurityUtils;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.aspectj.lang.ProceedingJoinPoint;
|
||||
@@ -24,7 +23,8 @@ import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
* 审计切面:把带 @AptAudit 的操作落到 duty_operation_log。
|
||||
*
|
||||
* <p>与若依 @Log(落 sys_oper_log)并行,不改上游表;日志写入失败只记 WARN,
|
||||
* 绝不反向影响业务结果。detail 用 JSON 承载 Q-40 冻结前的扩展要素。</p>
|
||||
* 绝不反向影响业务结果。detail 用 JSON 承载 Q-40 冻结前的扩展要素。
|
||||
* IT-2 起经 AptAuditWriter 以 REQUIRES_NEW 独立事务写入,业务回滚不丢留痕。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
@@ -34,11 +34,11 @@ public class AptAuditAspect
|
||||
{
|
||||
private static final Logger log = LoggerFactory.getLogger(AptAuditAspect.class);
|
||||
|
||||
private final AptAuditMapper auditMapper;
|
||||
private final AptAuditWriter auditWriter;
|
||||
|
||||
public AptAuditAspect(AptAuditMapper auditMapper)
|
||||
public AptAuditAspect(AptAuditWriter auditWriter)
|
||||
{
|
||||
this.auditMapper = auditMapper;
|
||||
this.auditWriter = auditWriter;
|
||||
}
|
||||
|
||||
@Around("@annotation(aptAudit)")
|
||||
@@ -109,7 +109,7 @@ public class AptAuditAspect
|
||||
entry.setDetail(JSON.toJSONString(detail));
|
||||
entry.setTraceId(AptContext.getTraceId());
|
||||
entry.setOccurredAt(new Date());
|
||||
auditMapper.insertOperationLog(entry);
|
||||
auditWriter.write(entry);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package com.ruoyi.apt.framework.audit;
|
||||
|
||||
import com.ruoyi.apt.ops.domain.AptOperationLog;
|
||||
import com.ruoyi.apt.ops.mapper.AptAuditMapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* 审计独立事务写入口(IT-2 补齐 IT-1 遗留项)。
|
||||
*
|
||||
* <p>业务写操作与审计同事务时,业务回滚会连带回滚审计留痕;
|
||||
* 这里用 REQUIRES_NEW 让 duty_operation_log 独立提交。
|
||||
* 切面仍对写库异常降级为 WARN,不反向影响业务结果。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
@Service
|
||||
public class AptAuditWriter
|
||||
{
|
||||
private final AptAuditMapper auditMapper;
|
||||
|
||||
public AptAuditWriter(AptAuditMapper auditMapper)
|
||||
{
|
||||
this.auditMapper = auditMapper;
|
||||
}
|
||||
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public void write(AptOperationLog entry)
|
||||
{
|
||||
auditMapper.insertOperationLog(entry);
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package com.ruoyi.apt.framework.config;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Mock 主数据下行开关的生产护栏(公寓端/09 表 9-16、13 IR-10)。
|
||||
*
|
||||
* <p>apt.master-client.mock=true 只在 local profile 允许;
|
||||
* 其它 profile 启动即失败,避免把 source='LOCAL' 的 Mock 数据带到生产。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
@Component
|
||||
public class AptMasterClientMockGuard
|
||||
{
|
||||
private final AptProperties properties;
|
||||
private final Environment environment;
|
||||
|
||||
public AptMasterClientMockGuard(AptProperties properties, Environment environment)
|
||||
{
|
||||
this.properties = properties;
|
||||
this.environment = environment;
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
public void validate()
|
||||
{
|
||||
if (!properties.getMasterClient().isMock())
|
||||
{
|
||||
return;
|
||||
}
|
||||
for (String profile : environment.getActiveProfiles())
|
||||
{
|
||||
if ("local".equalsIgnoreCase(profile))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new IllegalStateException(
|
||||
"apt.master-client.mock=true is only allowed with Spring profile 'local'");
|
||||
}
|
||||
}
|
||||
+560
@@ -0,0 +1,560 @@
|
||||
package com.ruoyi.apt.framework.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 公寓端基座配置(application.yml 的 apt.* 段)。
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "apt")
|
||||
public class AptProperties
|
||||
{
|
||||
/** 一进程一 gyid(D5),只从部署环境注入 */
|
||||
private String gyId;
|
||||
|
||||
private Probe probe = new Probe();
|
||||
private Page page = new Page();
|
||||
private MasterClient masterClient = new MasterClient();
|
||||
private Device device = new Device();
|
||||
private Features features = new Features();
|
||||
private Dispatch dispatch = new Dispatch();
|
||||
private Voice voice = new Voice();
|
||||
|
||||
public String getGyId()
|
||||
{
|
||||
return gyId;
|
||||
}
|
||||
|
||||
public void setGyId(String gyId)
|
||||
{
|
||||
this.gyId = gyId;
|
||||
}
|
||||
|
||||
public Probe getProbe()
|
||||
{
|
||||
return probe;
|
||||
}
|
||||
|
||||
public void setProbe(Probe probe)
|
||||
{
|
||||
this.probe = probe;
|
||||
}
|
||||
|
||||
public Page getPage()
|
||||
{
|
||||
return page;
|
||||
}
|
||||
|
||||
public void setPage(Page page)
|
||||
{
|
||||
this.page = page;
|
||||
}
|
||||
|
||||
public MasterClient getMasterClient()
|
||||
{
|
||||
return masterClient;
|
||||
}
|
||||
|
||||
public void setMasterClient(MasterClient masterClient)
|
||||
{
|
||||
this.masterClient = masterClient;
|
||||
}
|
||||
|
||||
public Device getDevice()
|
||||
{
|
||||
return device;
|
||||
}
|
||||
|
||||
public void setDevice(Device device)
|
||||
{
|
||||
this.device = device;
|
||||
}
|
||||
|
||||
public Features getFeatures()
|
||||
{
|
||||
return features;
|
||||
}
|
||||
|
||||
public void setFeatures(Features features)
|
||||
{
|
||||
this.features = features;
|
||||
}
|
||||
|
||||
public Dispatch getDispatch()
|
||||
{
|
||||
return dispatch;
|
||||
}
|
||||
|
||||
public void setDispatch(Dispatch dispatch)
|
||||
{
|
||||
this.dispatch = dispatch;
|
||||
}
|
||||
|
||||
public Voice getVoice()
|
||||
{
|
||||
return voice;
|
||||
}
|
||||
|
||||
public void setVoice(Voice voice)
|
||||
{
|
||||
this.voice = voice;
|
||||
}
|
||||
|
||||
public static class Probe
|
||||
{
|
||||
private boolean enabled = true;
|
||||
/** 单次探针超时判定阈值(毫秒) */
|
||||
private long timeoutMs = 2000;
|
||||
private String redisKeyPrefix = "apt:rev01:";
|
||||
private int redisTtlSeconds = 60;
|
||||
/** 探针专用表,不触碰任何业务表 */
|
||||
private String table = "apt_ops_probe";
|
||||
|
||||
public boolean isEnabled()
|
||||
{
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(boolean enabled)
|
||||
{
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public long getTimeoutMs()
|
||||
{
|
||||
return timeoutMs;
|
||||
}
|
||||
|
||||
public void setTimeoutMs(long timeoutMs)
|
||||
{
|
||||
this.timeoutMs = timeoutMs;
|
||||
}
|
||||
|
||||
public String getRedisKeyPrefix()
|
||||
{
|
||||
return redisKeyPrefix;
|
||||
}
|
||||
|
||||
public void setRedisKeyPrefix(String redisKeyPrefix)
|
||||
{
|
||||
this.redisKeyPrefix = redisKeyPrefix;
|
||||
}
|
||||
|
||||
public int getRedisTtlSeconds()
|
||||
{
|
||||
return redisTtlSeconds;
|
||||
}
|
||||
|
||||
public void setRedisTtlSeconds(int redisTtlSeconds)
|
||||
{
|
||||
this.redisTtlSeconds = redisTtlSeconds;
|
||||
}
|
||||
|
||||
public String getTable()
|
||||
{
|
||||
return table;
|
||||
}
|
||||
|
||||
public void setTable(String table)
|
||||
{
|
||||
this.table = table;
|
||||
}
|
||||
}
|
||||
|
||||
public static class Page
|
||||
{
|
||||
private int maxSize = 100;
|
||||
|
||||
public int getMaxSize()
|
||||
{
|
||||
return maxSize;
|
||||
}
|
||||
|
||||
public void setMaxSize(int maxSize)
|
||||
{
|
||||
this.maxSize = maxSize;
|
||||
}
|
||||
}
|
||||
|
||||
/** 表 9-16:Mock 主数据下行只允许本地 profile 开启,生产强制 false */
|
||||
public static class MasterClient
|
||||
{
|
||||
private boolean mock = false;
|
||||
|
||||
public boolean isMock()
|
||||
{
|
||||
return mock;
|
||||
}
|
||||
|
||||
public void setMock(boolean mock)
|
||||
{
|
||||
this.mock = mock;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* IT-3 设备侧鉴权(表 10-24 #1):设备编码 + HMAC + 时间窗 + nonce 四要素。
|
||||
* 仅作用于 {@code /open/v1/**};{@code /apt/**} 与 {@code /duty/**} 不走此配置。
|
||||
*/
|
||||
public static class Device
|
||||
{
|
||||
/** 关闭后只校验 X-Device-Code(本地联调用),生产必须为 true */
|
||||
private boolean hmacEnabled = true;
|
||||
/** HMAC-SHA256 共享密钥,只从部署环境注入,不落库、不打日志 */
|
||||
private String hmacSecret;
|
||||
/** 允许的时钟偏移(秒) */
|
||||
private long timeWindowSeconds = 300;
|
||||
/** nonce 去重窗口(秒),须不小于 timeWindowSeconds */
|
||||
private long nonceTtlSeconds = 600;
|
||||
/** 特征值算法白名单;不在名单内允许入档但不下发 */
|
||||
private String[] faceAlgoWhitelist = { "FEAT_V1", "FEAT_V2", "PHOTO_REF" };
|
||||
private int photoMaxBytes = 524288;
|
||||
private int featureMaxBytes = 65536;
|
||||
|
||||
public boolean isHmacEnabled()
|
||||
{
|
||||
return hmacEnabled;
|
||||
}
|
||||
|
||||
public void setHmacEnabled(boolean hmacEnabled)
|
||||
{
|
||||
this.hmacEnabled = hmacEnabled;
|
||||
}
|
||||
|
||||
public String getHmacSecret()
|
||||
{
|
||||
return hmacSecret;
|
||||
}
|
||||
|
||||
public void setHmacSecret(String hmacSecret)
|
||||
{
|
||||
this.hmacSecret = hmacSecret;
|
||||
}
|
||||
|
||||
public long getTimeWindowSeconds()
|
||||
{
|
||||
return timeWindowSeconds;
|
||||
}
|
||||
|
||||
public void setTimeWindowSeconds(long timeWindowSeconds)
|
||||
{
|
||||
this.timeWindowSeconds = timeWindowSeconds;
|
||||
}
|
||||
|
||||
public long getNonceTtlSeconds()
|
||||
{
|
||||
return nonceTtlSeconds;
|
||||
}
|
||||
|
||||
public void setNonceTtlSeconds(long nonceTtlSeconds)
|
||||
{
|
||||
this.nonceTtlSeconds = nonceTtlSeconds;
|
||||
}
|
||||
|
||||
public String[] getFaceAlgoWhitelist()
|
||||
{
|
||||
return faceAlgoWhitelist;
|
||||
}
|
||||
|
||||
public void setFaceAlgoWhitelist(String[] faceAlgoWhitelist)
|
||||
{
|
||||
this.faceAlgoWhitelist = faceAlgoWhitelist;
|
||||
}
|
||||
|
||||
public int getPhotoMaxBytes()
|
||||
{
|
||||
return photoMaxBytes;
|
||||
}
|
||||
|
||||
public void setPhotoMaxBytes(int photoMaxBytes)
|
||||
{
|
||||
this.photoMaxBytes = photoMaxBytes;
|
||||
}
|
||||
|
||||
public int getFeatureMaxBytes()
|
||||
{
|
||||
return featureMaxBytes;
|
||||
}
|
||||
|
||||
public void setFeatureMaxBytes(int featureMaxBytes)
|
||||
{
|
||||
this.featureMaxBytes = featureMaxBytes;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 能力开关(公寓端/05 表 5-18 / CR-C23):TTL 能力未确认前保持默认值,
|
||||
* {@code tabletCall=false} 时平板链路整体回落 SIP,且平板不发 #24。
|
||||
*/
|
||||
public static class Features
|
||||
{
|
||||
/** 平板叫乘总开关;关闭后派发任务不生成、已有 TABLET 记录回落 SIP */
|
||||
private boolean tabletCall = true;
|
||||
/** #24 语音质量摘要只由 SIP 链路发送(硬约束,配置仅作显式声明) */
|
||||
private boolean voiceSummary = true;
|
||||
|
||||
public boolean isTabletCall()
|
||||
{
|
||||
return tabletCall;
|
||||
}
|
||||
|
||||
public void setTabletCall(boolean tabletCall)
|
||||
{
|
||||
this.tabletCall = tabletCall;
|
||||
}
|
||||
|
||||
public boolean isVoiceSummary()
|
||||
{
|
||||
return voiceSummary;
|
||||
}
|
||||
|
||||
public void setVoiceSummary(boolean voiceSummary)
|
||||
{
|
||||
this.voiceSummary = voiceSummary;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 叫乘执行参数(公寓端/05 §9、Q-29 默认口径):
|
||||
* 平板签到窗口 60s / 时钟偏差阈值 1s / SIP 无入会 30s / 复叫 35s。
|
||||
*/
|
||||
public static class Dispatch
|
||||
{
|
||||
private int signTimeoutSec = 60;
|
||||
private long clockSkewThresholdMs = 1000;
|
||||
private int sipNoAnswerSeconds = 30;
|
||||
private int recallSeconds = 35;
|
||||
/** 平板任务过期时间(秒),从 jcsj 起算 */
|
||||
private int tabletTaskTtlSeconds = 900;
|
||||
/** 单次扫描批大小 */
|
||||
private int scanBatchSize = 50;
|
||||
/** TTS 模板编码(平板本地合成,文本不下发图像) */
|
||||
private String ttsTemplate = "CALL_ROOM";
|
||||
private String ttsText = "请到门口集合,准备乘车";
|
||||
|
||||
public int getSignTimeoutSec()
|
||||
{
|
||||
return signTimeoutSec;
|
||||
}
|
||||
|
||||
public void setSignTimeoutSec(int signTimeoutSec)
|
||||
{
|
||||
this.signTimeoutSec = signTimeoutSec;
|
||||
}
|
||||
|
||||
public long getClockSkewThresholdMs()
|
||||
{
|
||||
return clockSkewThresholdMs;
|
||||
}
|
||||
|
||||
public void setClockSkewThresholdMs(long clockSkewThresholdMs)
|
||||
{
|
||||
this.clockSkewThresholdMs = clockSkewThresholdMs;
|
||||
}
|
||||
|
||||
public int getSipNoAnswerSeconds()
|
||||
{
|
||||
return sipNoAnswerSeconds;
|
||||
}
|
||||
|
||||
public void setSipNoAnswerSeconds(int sipNoAnswerSeconds)
|
||||
{
|
||||
this.sipNoAnswerSeconds = sipNoAnswerSeconds;
|
||||
}
|
||||
|
||||
public int getRecallSeconds()
|
||||
{
|
||||
return recallSeconds;
|
||||
}
|
||||
|
||||
public void setRecallSeconds(int recallSeconds)
|
||||
{
|
||||
this.recallSeconds = recallSeconds;
|
||||
}
|
||||
|
||||
public int getTabletTaskTtlSeconds()
|
||||
{
|
||||
return tabletTaskTtlSeconds;
|
||||
}
|
||||
|
||||
public void setTabletTaskTtlSeconds(int tabletTaskTtlSeconds)
|
||||
{
|
||||
this.tabletTaskTtlSeconds = tabletTaskTtlSeconds;
|
||||
}
|
||||
|
||||
public int getScanBatchSize()
|
||||
{
|
||||
return scanBatchSize;
|
||||
}
|
||||
|
||||
public void setScanBatchSize(int scanBatchSize)
|
||||
{
|
||||
this.scanBatchSize = scanBatchSize;
|
||||
}
|
||||
|
||||
public String getTtsTemplate()
|
||||
{
|
||||
return ttsTemplate;
|
||||
}
|
||||
|
||||
public void setTtsTemplate(String ttsTemplate)
|
||||
{
|
||||
this.ttsTemplate = ttsTemplate;
|
||||
}
|
||||
|
||||
public String getTtsText()
|
||||
{
|
||||
return ttsText;
|
||||
}
|
||||
|
||||
public void setTtsText(String ttsText)
|
||||
{
|
||||
this.ttsText = ttsText;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 语音链路开关与上层行为(AMI 连接参数在 {@code apt_ast_instance} 表,随公寓实例走)。
|
||||
*
|
||||
* <p>默认关闭:未接 Asterisk 的环境不得因缺少依赖而启动失败或静默改状态
|
||||
* (公寓端/06 §7「依赖缺失显式降级」)。</p>
|
||||
*/
|
||||
public static class Voice
|
||||
{
|
||||
/** AMI 连接与语音链路总开关 */
|
||||
private boolean enabled = false;
|
||||
/** 到 jcsj 自动发起 SIP 叫乘(关闭时只能由值班台命令触发) */
|
||||
private boolean sipCallEnabled = true;
|
||||
/** 话机通道技术:PJSIP / SIP(与 Asterisk 侧 endpoint 类型一致) */
|
||||
private String channelTech = "PJSIP";
|
||||
/** TTS 播报拨号计划上下文(Asterisk 侧 TTS 应用入口) */
|
||||
private String ttsContext = "apt-tts";
|
||||
/** TTS 播报分机(Local/<ext>@<context>) */
|
||||
private String ttsExtension = "s";
|
||||
/** 提示语音文本(只下发文本,音频由 Asterisk 侧合成) */
|
||||
private String ttsText = "请到门口集合,准备乘车";
|
||||
/** 播放媒体前缀(Asterisk sounds 目录下的文件名前缀) */
|
||||
private String ttsMediaPrefix = "apt/call-prompt";
|
||||
/** 呼叫超时(毫秒),同时下发 Originate 的 Timeout */
|
||||
private int dialTimeoutMs = 30000;
|
||||
/** 事件处理队列容量(单实例单线程保序消费;满了丢弃并告警) */
|
||||
private int eventQueueCapacity = 2000;
|
||||
/** 值班台分机列表(双腿呼叫的第二腿) */
|
||||
private java.util.List<String> dutyExtensions = new java.util.ArrayList<>();
|
||||
/** 单次扫描最多发起的 SIP 呼叫数,避免补跑时瞬间打爆 Asterisk */
|
||||
private int maxCallsPerScan = 10;
|
||||
|
||||
public boolean isEnabled()
|
||||
{
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(boolean enabled)
|
||||
{
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public boolean isSipCallEnabled()
|
||||
{
|
||||
return sipCallEnabled;
|
||||
}
|
||||
|
||||
public void setSipCallEnabled(boolean sipCallEnabled)
|
||||
{
|
||||
this.sipCallEnabled = sipCallEnabled;
|
||||
}
|
||||
|
||||
public String getChannelTech()
|
||||
{
|
||||
return channelTech;
|
||||
}
|
||||
|
||||
public void setChannelTech(String channelTech)
|
||||
{
|
||||
this.channelTech = channelTech;
|
||||
}
|
||||
|
||||
public String getTtsContext()
|
||||
{
|
||||
return ttsContext;
|
||||
}
|
||||
|
||||
public void setTtsContext(String ttsContext)
|
||||
{
|
||||
this.ttsContext = ttsContext;
|
||||
}
|
||||
|
||||
public String getTtsExtension()
|
||||
{
|
||||
return ttsExtension;
|
||||
}
|
||||
|
||||
public void setTtsExtension(String ttsExtension)
|
||||
{
|
||||
this.ttsExtension = ttsExtension;
|
||||
}
|
||||
|
||||
public String getTtsText()
|
||||
{
|
||||
return ttsText;
|
||||
}
|
||||
|
||||
public void setTtsText(String ttsText)
|
||||
{
|
||||
this.ttsText = ttsText;
|
||||
}
|
||||
|
||||
public String getTtsMediaPrefix()
|
||||
{
|
||||
return ttsMediaPrefix;
|
||||
}
|
||||
|
||||
public void setTtsMediaPrefix(String ttsMediaPrefix)
|
||||
{
|
||||
this.ttsMediaPrefix = ttsMediaPrefix;
|
||||
}
|
||||
|
||||
public int getDialTimeoutMs()
|
||||
{
|
||||
return dialTimeoutMs;
|
||||
}
|
||||
|
||||
public void setDialTimeoutMs(int dialTimeoutMs)
|
||||
{
|
||||
this.dialTimeoutMs = dialTimeoutMs;
|
||||
}
|
||||
|
||||
public int getEventQueueCapacity()
|
||||
{
|
||||
return eventQueueCapacity;
|
||||
}
|
||||
|
||||
public void setEventQueueCapacity(int eventQueueCapacity)
|
||||
{
|
||||
this.eventQueueCapacity = eventQueueCapacity;
|
||||
}
|
||||
|
||||
public java.util.List<String> getDutyExtensions()
|
||||
{
|
||||
return dutyExtensions;
|
||||
}
|
||||
|
||||
public void setDutyExtensions(java.util.List<String> dutyExtensions)
|
||||
{
|
||||
this.dutyExtensions = dutyExtensions;
|
||||
}
|
||||
|
||||
public int getMaxCallsPerScan()
|
||||
{
|
||||
return maxCallsPerScan;
|
||||
}
|
||||
|
||||
public void setMaxCallsPerScan(int maxCallsPerScan)
|
||||
{
|
||||
this.maxCallsPerScan = maxCallsPerScan;
|
||||
}
|
||||
}
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
+14
-12
@@ -6,10 +6,12 @@ import com.ruoyi.apt.common.AptException;
|
||||
import com.ruoyi.apt.common.AptPrefixResolver;
|
||||
import com.ruoyi.apt.common.AptPrefixResolver.Prefix;
|
||||
import com.ruoyi.apt.framework.config.AptProperties;
|
||||
import com.ruoyi.apt.framework.device.AptDeviceSigner;
|
||||
import com.ruoyi.common.annotation.Anonymous;
|
||||
import com.ruoyi.common.utils.SecurityUtils;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.method.HandlerMethod;
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
@@ -21,7 +23,7 @@ import org.springframework.web.servlet.HandlerInterceptor;
|
||||
* 请求中一旦出现与上下文不一致的 gyid,立即拒绝,避免跨公寓数据泄露。
|
||||
* 分区策略:
|
||||
* /apt/**、/duty/** -> 需登录态(@Anonymous 例外)
|
||||
* /open/v1/** -> 设备凭据骨架(HMAC / 时间窗 / nonce 在 IT-3 落地)
|
||||
* /open/v1/** -> 设备编码 + HMAC + 时间窗 + nonce(IT-3 已落地)
|
||||
* /platform/** -> 本端不提供,显式 40401
|
||||
* 其余路径不在注册范围内。</p>
|
||||
*
|
||||
@@ -31,10 +33,19 @@ import org.springframework.web.servlet.HandlerInterceptor;
|
||||
public class AptContextInterceptor implements HandlerInterceptor
|
||||
{
|
||||
private final AptProperties properties;
|
||||
private final AptDeviceSigner deviceSigner;
|
||||
|
||||
/** 保留单参构造器:既有拦截器单测不感知设备签名组件 */
|
||||
public AptContextInterceptor(AptProperties properties)
|
||||
{
|
||||
this(properties, new AptDeviceSigner(properties));
|
||||
}
|
||||
|
||||
@Autowired
|
||||
public AptContextInterceptor(AptProperties properties, AptDeviceSigner deviceSigner)
|
||||
{
|
||||
this.properties = properties;
|
||||
this.deviceSigner = deviceSigner;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -51,7 +62,8 @@ public class AptContextInterceptor implements HandlerInterceptor
|
||||
throw new AptException(AptErrorCode.NOT_FOUND, "PREFIX_NOT_SERVED",
|
||||
"平台管理端接口不在公寓端提供");
|
||||
case OPEN:
|
||||
assertDeviceCredential(request);
|
||||
// IT-3:设备编码 + HMAC + 时间窗 + nonce 四要素校验(DV-3)
|
||||
deviceSigner.verify(request);
|
||||
break;
|
||||
case APT:
|
||||
case DUTY:
|
||||
@@ -95,16 +107,6 @@ public class AptContextInterceptor implements HandlerInterceptor
|
||||
}
|
||||
}
|
||||
|
||||
private void assertDeviceCredential(HttpServletRequest request)
|
||||
{
|
||||
String deviceCode = request.getHeader("X-Device-Code");
|
||||
if (deviceCode == null || deviceCode.isBlank())
|
||||
{
|
||||
throw new AptException(AptErrorCode.UNAUTHORIZED, "DEVICE_CREDENTIAL_MISSING",
|
||||
"缺少设备凭据 X-Device-Code");
|
||||
}
|
||||
}
|
||||
|
||||
private void assertNoGyidOverride(HttpServletRequest request, Prefix prefix)
|
||||
{
|
||||
String requested = request.getParameter("gyid");
|
||||
+29
@@ -24,6 +24,10 @@ import org.springframework.data.redis.RedisSystemException;
|
||||
import org.springframework.jdbc.BadSqlGrammarException;
|
||||
import org.springframework.jdbc.CannotGetJdbcConnectionException;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.validation.BindException;
|
||||
import org.springframework.validation.BindingResult;
|
||||
import org.springframework.validation.FieldError;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.context.request.RequestAttributes;
|
||||
@@ -114,6 +118,31 @@ public class AptInfraExceptionHandler
|
||||
return handle(AptErrorCode.FORBIDDEN_PERM, "PERMISSION_DENIED", "无权限点", e, request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 参数校验异常(@Valid / BindException):公寓端 40001 且 msg 指出字段名(表 7-3 M1),
|
||||
* 非公寓端保持若依 GlobalExceptionHandler 的字段错误口径。
|
||||
*/
|
||||
@ExceptionHandler({MethodArgumentNotValidException.class, BindException.class})
|
||||
public AjaxResult handleValidation(Exception e, HttpServletRequest request)
|
||||
{
|
||||
BindingResult binding = e instanceof MethodArgumentNotValidException
|
||||
? ((MethodArgumentNotValidException) e).getBindingResult()
|
||||
: ((BindException) e).getBindingResult();
|
||||
String message = validationMessage(binding);
|
||||
if (!AptPrefixResolver.isAptManaged(request.getRequestURI()))
|
||||
{
|
||||
return AjaxResult.error(message);
|
||||
}
|
||||
return build(AptErrorCode.BAD_REQUEST, "PARAM_INVALID", message, request);
|
||||
}
|
||||
|
||||
private String validationMessage(BindingResult binding)
|
||||
{
|
||||
FieldError field = binding.getFieldError();
|
||||
return field == null ? "参数校验失败"
|
||||
: "参数校验失败: " + field.getField() + " " + field.getDefaultMessage();
|
||||
}
|
||||
|
||||
/**
|
||||
* 若依 ServiceException:非公寓端保留其自定义 code(GlobalExceptionHandler 口径),
|
||||
* 避免 @Order(HIGHEST_PRECEDENCE) 吞掉若依后台业务错误码;公寓端走统一响应体。
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
+67
@@ -0,0 +1,67 @@
|
||||
package com.ruoyi.apt.master.controller;
|
||||
|
||||
import java.util.List;
|
||||
import com.ruoyi.apt.common.audit.AptAudit;
|
||||
import com.ruoyi.apt.framework.web.AptBaseController;
|
||||
import com.ruoyi.apt.master.domain.AptMasterVersion;
|
||||
import com.ruoyi.apt.master.service.IAptMasterVersionService;
|
||||
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.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 主数据快照版本(IT-2 / 任务 5,公寓端/04 表 4-17 #1/#2)。
|
||||
*
|
||||
* <p>权限点说明:表 4-17 权威口径为 apt:ops:health,IT-2 权限点种子另建
|
||||
* apt:master:version(3015)。此处用 hasAnyPermi 兼容二者,偏离在交付说明登记。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping(value = "/apt/master", produces = "application/json;charset=UTF-8")
|
||||
public class AptMasterVersionController extends AptBaseController
|
||||
{
|
||||
private static final String VERSION_PERMISSION =
|
||||
"@ss.hasAnyPermi('apt:ops:health,apt:master:version')";
|
||||
|
||||
private final IAptMasterVersionService versionService;
|
||||
|
||||
public AptMasterVersionController(IAptMasterVersionService versionService)
|
||||
{
|
||||
this.versionService = versionService;
|
||||
}
|
||||
|
||||
@PreAuthorize(VERSION_PERMISSION)
|
||||
@AptAudit(module = "MASTER", action = "VERSION_LIST")
|
||||
@GetMapping("/versions")
|
||||
public AjaxResult versions()
|
||||
{
|
||||
List<AptMasterVersion> versions = versionService.list();
|
||||
AjaxResult result = AjaxResult.success(versions);
|
||||
if (versions.stream().anyMatch(AptMasterVersion::isLocalMock))
|
||||
{
|
||||
result.put("mockNotice", "非平台下发(IT-2 Mock:source=LOCAL / source_version=0)");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@PreAuthorize(VERSION_PERMISSION)
|
||||
@GetMapping("/versions/{domain}")
|
||||
public AjaxResult detail(@PathVariable String domain)
|
||||
{
|
||||
return AjaxResult.success(versionService.detail(domain));
|
||||
}
|
||||
|
||||
/** 平台未就绪前不开放全量重拉,恒返回 40940,不产生假成功的版本记录 */
|
||||
@PreAuthorize("@ss.hasPermi('apt:ops:reconcile')")
|
||||
@PostMapping("/{masterType}/resync")
|
||||
public AjaxResult resync(@PathVariable String masterType)
|
||||
{
|
||||
versionService.resync(masterType);
|
||||
return AjaxResult.success();
|
||||
}
|
||||
}
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
package com.ruoyi.apt.master.controller;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.ruoyi.apt.common.AptContext;
|
||||
import com.ruoyi.apt.common.audit.AptAudit;
|
||||
import com.ruoyi.apt.common.page.AptTableDataInfo;
|
||||
import com.ruoyi.apt.framework.audit.AptAuditWriter;
|
||||
import com.ruoyi.apt.framework.web.AptBaseController;
|
||||
import com.ruoyi.apt.master.domain.AptRoute;
|
||||
import com.ruoyi.apt.master.domain.AptRouteCommand;
|
||||
import com.ruoyi.apt.master.domain.AptRouteRecalc;
|
||||
import com.ruoyi.apt.master.domain.AptRouteStatusCommand;
|
||||
import com.ruoyi.apt.master.service.IAptRouteService;
|
||||
import com.ruoyi.apt.ops.domain.AptOperationLog;
|
||||
import com.ruoyi.common.core.domain.AjaxResult;
|
||||
import com.ruoyi.common.utils.SecurityUtils;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
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.PutMapping;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 交路管理(IT-2 / M1,公寓端/04 表 4-5 接口 1~7)。
|
||||
*
|
||||
* <p>IT-1 的只读骨架已按表 13-21 迁入 com.ruoyi.apt.master.controller;
|
||||
* 本轮补齐 CRUD / 启停 / 复算校验。D7 未签前新增与编辑默认由权限点控制,
|
||||
* 不开放给普通角色;停用原因显式写 duty_operation_log(含 reason)。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping(value = "/apt/route", produces = "application/json;charset=UTF-8")
|
||||
public class AptRouteController extends AptBaseController
|
||||
{
|
||||
private final IAptRouteService routeService;
|
||||
private final AptAuditWriter auditWriter;
|
||||
|
||||
public AptRouteController(IAptRouteService routeService, AptAuditWriter auditWriter)
|
||||
{
|
||||
this.routeService = routeService;
|
||||
this.auditWriter = auditWriter;
|
||||
}
|
||||
|
||||
@PreAuthorize("@ss.hasPermi('apt:route:list')")
|
||||
@AptAudit(module = "MASTER", action = "ROUTE_LIST")
|
||||
@GetMapping("/list")
|
||||
public AptTableDataInfo list(
|
||||
@RequestParam(required = false) String cc,
|
||||
@RequestParam(required = false) String jlid,
|
||||
@RequestParam(required = false) String state)
|
||||
{
|
||||
startAptPage();
|
||||
return getAptDataTable(routeService.list(cc, jlid, state));
|
||||
}
|
||||
|
||||
@PreAuthorize("@ss.hasPermi('apt:route:query')")
|
||||
@AptAudit(module = "MASTER", action = "ROUTE_DETAIL")
|
||||
@GetMapping("/{id}")
|
||||
public AjaxResult detail(@PathVariable Long id)
|
||||
{
|
||||
return AjaxResult.success(routeService.detail(id));
|
||||
}
|
||||
|
||||
@PreAuthorize("@ss.hasPermi('apt:route:list')")
|
||||
@GetMapping("/options")
|
||||
public AjaxResult options(@RequestParam(required = false) String keyword)
|
||||
{
|
||||
return AjaxResult.success(routeService.options(keyword));
|
||||
}
|
||||
|
||||
@PreAuthorize("@ss.hasPermi('apt:route:add')")
|
||||
@AptAudit(module = "MASTER", action = "ROUTE_ADD")
|
||||
@PostMapping
|
||||
public AjaxResult add(@Valid @RequestBody AptRouteCommand command)
|
||||
{
|
||||
AjaxResult result = AjaxResult.success();
|
||||
result.put("id", routeService.add(command));
|
||||
return result;
|
||||
}
|
||||
|
||||
@PreAuthorize("@ss.hasPermi('apt:route:edit')")
|
||||
@AptAudit(module = "MASTER", action = "ROUTE_EDIT")
|
||||
@PutMapping("/{id}")
|
||||
public AjaxResult edit(@PathVariable Long id, @Valid @RequestBody AptRouteCommand command)
|
||||
{
|
||||
routeService.edit(id, command);
|
||||
return AjaxResult.success();
|
||||
}
|
||||
|
||||
@PreAuthorize("@ss.hasPermi('apt:route:changeStatus')")
|
||||
@PutMapping("/{id}/status")
|
||||
public AjaxResult changeStatus(@PathVariable Long id,
|
||||
@Valid @RequestBody AptRouteStatusCommand command)
|
||||
{
|
||||
routeService.changeStatus(id, command);
|
||||
writeReasonAudit(command.getEnabled() ? "ROUTE_ENABLE" : "ROUTE_DISABLE", id, command.getReason());
|
||||
return AjaxResult.success();
|
||||
}
|
||||
|
||||
@PreAuthorize("@ss.hasPermi('apt:route:query')")
|
||||
@PostMapping("/{id}/recalc-check")
|
||||
public AjaxResult recalcCheck(@PathVariable Long id,
|
||||
@RequestParam(required = false)
|
||||
@DateTimeFormat(pattern = "yyyy-MM-dd") Date baseDate)
|
||||
{
|
||||
AptRouteRecalc result = routeService.recalcCheck(id, baseDate);
|
||||
return AjaxResult.success(result);
|
||||
}
|
||||
|
||||
/** 启停原因必须进审计 detail(表 7-3 M1「停用审计」) */
|
||||
private void writeReasonAudit(String action, Long routeId, String reason)
|
||||
{
|
||||
Map<String, Object> detail = new LinkedHashMap<>();
|
||||
detail.put("reason", reason);
|
||||
detail.put("routeId", routeId);
|
||||
detail.put("status", "SUCCESS");
|
||||
detail.put("traceId", AptContext.getTraceId());
|
||||
|
||||
AptOperationLog entry = new AptOperationLog();
|
||||
entry.setLogId(UUID.randomUUID().toString().replace("-", ""));
|
||||
entry.setGyId(AptContext.getGyIdOrNull());
|
||||
entry.setOperatorRef(operatorRef());
|
||||
entry.setModule("MASTER");
|
||||
entry.setAction(action);
|
||||
entry.setDetail(JSON.toJSONString(detail));
|
||||
entry.setTraceId(AptContext.getTraceId());
|
||||
entry.setOccurredAt(new Date());
|
||||
auditWriter.write(entry);
|
||||
}
|
||||
|
||||
private String operatorRef()
|
||||
{
|
||||
try
|
||||
{
|
||||
return SecurityUtils.getLoginUser().getUsername();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return "ANONYMOUS";
|
||||
}
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package com.ruoyi.apt.master.domain;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 主数据快照版本(IT-2 / 任务 5,公寓端/09 表 9-16、04 表 4-17 #1)。
|
||||
*
|
||||
* <p>Mock 灌入的数据恒为 source='LOCAL' / source_version=0 / state='APPLIED',
|
||||
* 响应必须显式标识「非平台下发」,不得冒充平台下行。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
public class AptMasterVersion
|
||||
{
|
||||
private Long id;
|
||||
private String gyid;
|
||||
private String domain;
|
||||
private Long sourceVersion;
|
||||
private String source;
|
||||
private String state;
|
||||
private Date appliedAt;
|
||||
private String remark;
|
||||
/** 该域当前快照记录数,用于表 9-16 的落库断言 */
|
||||
private Long itemCount;
|
||||
private boolean localMock;
|
||||
/** 页面展示文案:非平台下发 / 平台下发 */
|
||||
private String sourceLabel;
|
||||
|
||||
public Long getId() { return id; }
|
||||
public void setId(Long id) { this.id = id; }
|
||||
public String getGyid() { return gyid; }
|
||||
public void setGyid(String gyid) { this.gyid = gyid; }
|
||||
public String getDomain() { return domain; }
|
||||
public void setDomain(String domain) { this.domain = domain; }
|
||||
public Long getSourceVersion() { return sourceVersion; }
|
||||
public void setSourceVersion(Long sourceVersion) { this.sourceVersion = sourceVersion; }
|
||||
public String getSource() { return source; }
|
||||
public void setSource(String source) { this.source = source; }
|
||||
public String getState() { return state; }
|
||||
public void setState(String state) { this.state = state; }
|
||||
public Date getAppliedAt() { return appliedAt; }
|
||||
public void setAppliedAt(Date appliedAt) { this.appliedAt = appliedAt; }
|
||||
public String getRemark() { return remark; }
|
||||
public void setRemark(String remark) { this.remark = remark; }
|
||||
public Long getItemCount() { return itemCount; }
|
||||
public void setItemCount(Long itemCount) { this.itemCount = itemCount; }
|
||||
public boolean isLocalMock() { return localMock; }
|
||||
public void setLocalMock(boolean localMock) { this.localMock = localMock; }
|
||||
public String getSourceLabel() { return sourceLabel; }
|
||||
public void setSourceLabel(String sourceLabel) { this.sourceLabel = sourceLabel; }
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
package com.ruoyi.apt.master.domain;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 交路主数据(apt_master_route)。
|
||||
*
|
||||
* <p>IT-2 字段映射:设计文档表 4-5 的 jch/qd/zd 分别对应 DDL 的
|
||||
* route_name/start_station/end_station;enabled 对应 state='ACTIVE';
|
||||
* sourceVersion 对应 source_version。DTO 与 VO 对外只用设计字段名,
|
||||
* 不改 59 张表的口径(公寓端/15 表 15-15 #11)。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
public class AptRoute
|
||||
{
|
||||
private Long id;
|
||||
private String gyid;
|
||||
private String jlid;
|
||||
private String cc;
|
||||
private String jch;
|
||||
private String qd;
|
||||
private String zd;
|
||||
private Date kcsj;
|
||||
private Date jcsj;
|
||||
private Date lysj;
|
||||
private String cclx;
|
||||
private String bz;
|
||||
private String state;
|
||||
private Integer enabled;
|
||||
private String stopReason;
|
||||
private Long sourceVersion;
|
||||
private Date createTime;
|
||||
|
||||
public Long getId() { return id; }
|
||||
public void setId(Long id) { this.id = id; }
|
||||
public String getGyid() { return gyid; }
|
||||
public void setGyid(String gyid) { this.gyid = gyid; }
|
||||
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 String getQd() { return qd; }
|
||||
public void setQd(String qd) { this.qd = qd; }
|
||||
public String getZd() { return zd; }
|
||||
public void setZd(String zd) { this.zd = zd; }
|
||||
public Date getKcsj() { return kcsj; }
|
||||
public void setKcsj(Date kcsj) { this.kcsj = kcsj; }
|
||||
public Date getJcsj() { return jcsj; }
|
||||
public void setJcsj(Date jcsj) { this.jcsj = jcsj; }
|
||||
public Date getLysj() { return lysj; }
|
||||
public void setLysj(Date lysj) { this.lysj = lysj; }
|
||||
public String getCclx() { return cclx; }
|
||||
public void setCclx(String cclx) { this.cclx = cclx; }
|
||||
public String getBz() { return bz; }
|
||||
public void setBz(String bz) { this.bz = bz; }
|
||||
public String getState() { return state; }
|
||||
public void setState(String state) { this.state = state; }
|
||||
public Integer getEnabled() { return enabled; }
|
||||
public void setEnabled(Integer enabled) { this.enabled = enabled; }
|
||||
public String getStopReason() { return stopReason; }
|
||||
public void setStopReason(String stopReason) { this.stopReason = stopReason; }
|
||||
public Long getSourceVersion() { return sourceVersion; }
|
||||
public void setSourceVersion(Long sourceVersion) { this.sourceVersion = sourceVersion; }
|
||||
public Date getCreateTime() { return createTime; }
|
||||
public void setCreateTime(Date createTime) { this.createTime = createTime; }
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
package com.ruoyi.apt.master.domain;
|
||||
|
||||
import java.util.Date;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
/**
|
||||
* 交路新增 / 编辑入参(公寓端/04 表 4-5)。
|
||||
*
|
||||
* <p>sourceVersion / createTime 是平台快照只读字段,若入参显式携带则
|
||||
* 由 Service 返回 40904(表 4-5 校验规则 5)。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
public class AptRouteCommand
|
||||
{
|
||||
@NotBlank(message = "jlid 不能为空")
|
||||
@Size(max = 64, message = "jlid 长度不能超过 64")
|
||||
private String jlid;
|
||||
|
||||
@NotBlank(message = "cc 不能为空")
|
||||
@Size(max = 32, message = "cc 长度不能超过 32")
|
||||
private String cc;
|
||||
|
||||
@NotNull(message = "kcsj 不能为空")
|
||||
private Date kcsj;
|
||||
|
||||
@NotNull(message = "jcsj 不能为空")
|
||||
private Date jcsj;
|
||||
|
||||
@NotNull(message = "lysj 不能为空")
|
||||
private Date lysj;
|
||||
|
||||
private String jch;
|
||||
private String qd;
|
||||
private String zd;
|
||||
private String cclx;
|
||||
private String bz;
|
||||
|
||||
/** 只读保护:非空即视为试图改平台快照 */
|
||||
private Long sourceVersion;
|
||||
private Date createTime;
|
||||
|
||||
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 Date getKcsj() { return kcsj; }
|
||||
public void setKcsj(Date kcsj) { this.kcsj = kcsj; }
|
||||
public Date getJcsj() { return jcsj; }
|
||||
public void setJcsj(Date jcsj) { this.jcsj = jcsj; }
|
||||
public Date getLysj() { return lysj; }
|
||||
public void setLysj(Date lysj) { this.lysj = lysj; }
|
||||
public String getJch() { return jch; }
|
||||
public void setJch(String jch) { this.jch = jch; }
|
||||
public String getQd() { return qd; }
|
||||
public void setQd(String qd) { this.qd = qd; }
|
||||
public String getZd() { return zd; }
|
||||
public void setZd(String zd) { this.zd = zd; }
|
||||
public String getCclx() { return cclx; }
|
||||
public void setCclx(String cclx) { this.cclx = cclx; }
|
||||
public String getBz() { return bz; }
|
||||
public void setBz(String bz) { this.bz = bz; }
|
||||
public Long getSourceVersion() { return sourceVersion; }
|
||||
public void setSourceVersion(Long sourceVersion) { this.sourceVersion = sourceVersion; }
|
||||
public Date getCreateTime() { return createTime; }
|
||||
public void setCreateTime(Date createTime) { this.createTime = createTime; }
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.ruoyi.apt.master.domain;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 交路本地复算校验结果(不落库,表 4-5 #7)。
|
||||
* computedJcsj 为公寓复算权威值(D8'),diffMs 为与平台基线 jcsj 的偏差。
|
||||
*/
|
||||
public class AptRouteRecalc
|
||||
{
|
||||
private Long routeId;
|
||||
private String jlid;
|
||||
private Date baseDate;
|
||||
private Date computedJcsj;
|
||||
private Date computedFjsj;
|
||||
private Long ruleVersion;
|
||||
private Long diffMs;
|
||||
private final List<String> warnings = new ArrayList<>();
|
||||
|
||||
public Long getRouteId() { return routeId; }
|
||||
public void setRouteId(Long routeId) { this.routeId = routeId; }
|
||||
public String getJlid() { return jlid; }
|
||||
public void setJlid(String jlid) { this.jlid = jlid; }
|
||||
public Date getBaseDate() { return baseDate; }
|
||||
public void setBaseDate(Date baseDate) { this.baseDate = baseDate; }
|
||||
public Date getComputedJcsj() { return computedJcsj; }
|
||||
public void setComputedJcsj(Date computedJcsj) { this.computedJcsj = computedJcsj; }
|
||||
public Date getComputedFjsj() { return computedFjsj; }
|
||||
public void setComputedFjsj(Date computedFjsj) { this.computedFjsj = computedFjsj; }
|
||||
public Long getRuleVersion() { return ruleVersion; }
|
||||
public void setRuleVersion(Long ruleVersion) { this.ruleVersion = ruleVersion; }
|
||||
public Long getDiffMs() { return diffMs; }
|
||||
public void setDiffMs(Long diffMs) { this.diffMs = diffMs; }
|
||||
public List<String> getWarnings() { return warnings; }
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package com.ruoyi.apt.master.domain;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
/** 交路启停入参:reason 必填并落 duty_operation_log(表 4-5 校验规则 4)。 */
|
||||
public class AptRouteStatusCommand
|
||||
{
|
||||
@NotNull(message = "enabled 不能为空")
|
||||
private Boolean enabled;
|
||||
|
||||
@NotNull(message = "reason 不能为空")
|
||||
@Size(min = 1, max = 512, message = "reason 长度必须为 1~512")
|
||||
private String reason;
|
||||
|
||||
public Boolean getEnabled() { return enabled; }
|
||||
public void setEnabled(Boolean enabled) { this.enabled = enabled; }
|
||||
public String getReason() { return reason; }
|
||||
public void setReason(String reason) { this.reason = reason; }
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package com.ruoyi.apt.master.mapper;
|
||||
|
||||
import java.util.List;
|
||||
import com.ruoyi.apt.master.domain.AptMasterVersion;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
/**
|
||||
* 主数据快照版本只读访问(IT-2 / 任务 5)。
|
||||
*
|
||||
* <p>只读:业务模块不得写 apt_master_version(公寓端/03 表 3-1);
|
||||
* 每条 SQL 必带 gyid(AptMapperGyidArchTest 断言)。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
public interface AptMasterVersionMapper
|
||||
{
|
||||
List<AptMasterVersion> selectVersions(@Param("gyid") String gyid);
|
||||
|
||||
AptMasterVersion selectVersionByDomain(@Param("gyid") String gyid,
|
||||
@Param("domain") String domain);
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package com.ruoyi.apt.master.mapper;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.ruoyi.apt.master.domain.AptRoute;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
/**
|
||||
* 交路主数据访问(IT-2 / M1)。
|
||||
* 硬约束:每条 SQL 必须带 gyid = #{gyid}(AptMapperGyidArchTest 断言)。
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
public interface AptRouteMapper
|
||||
{
|
||||
List<AptRoute> selectRouteList(@Param("gyid") String gyid,
|
||||
@Param("cc") String cc,
|
||||
@Param("jlid") String jlid,
|
||||
@Param("state") String state);
|
||||
|
||||
AptRoute selectRouteById(@Param("gyid") String gyid, @Param("id") Long id);
|
||||
|
||||
List<AptRoute> selectRouteOptions(@Param("gyid") String gyid, @Param("keyword") String keyword);
|
||||
|
||||
int countByJlid(@Param("gyid") String gyid, @Param("jlid") String jlid,
|
||||
@Param("excludeId") Long excludeId);
|
||||
|
||||
int insertRoute(AptRoute route);
|
||||
|
||||
int updateRoute(AptRoute route);
|
||||
|
||||
int updateRouteStatus(@Param("gyid") String gyid, @Param("id") Long id,
|
||||
@Param("state") String state, @Param("stopReason") String stopReason);
|
||||
|
||||
/** 当前有效的叫乘提前量规则(CALL_TIME),返回 ruleVersion / ruleBody */
|
||||
Map<String, Object> selectCallTimeRule(@Param("gyid") String gyid);
|
||||
|
||||
/** 指定日期是否为节假日 */
|
||||
int countHoliday(@Param("gyid") String gyid, @Param("holidayDate") String holidayDate);
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
package com.ruoyi.apt.master.service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import com.ruoyi.apt.common.AptContext;
|
||||
import com.ruoyi.apt.common.AptErrorCode;
|
||||
import com.ruoyi.apt.common.AptException;
|
||||
import com.ruoyi.apt.master.domain.AptMasterVersion;
|
||||
import com.ruoyi.apt.master.mapper.AptMasterVersionMapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* 主数据快照版本只读服务(IT-2 / 任务 5,公寓端/09 表 9-16)。
|
||||
*
|
||||
* <p>把 source='LOCAL' 显式标成「非平台下发」,供页面按 R-03 展示;
|
||||
* 域白名单与 apt_master_version 的 6 个 Mock 域一致。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
@Service
|
||||
public class AptMasterVersionServiceImpl implements IAptMasterVersionService
|
||||
{
|
||||
private static final Set<String> DOMAINS = Set.of(
|
||||
"APARTMENT", "ROOM", "PERSON", "ROUTE", "RULE", "HOLIDAY");
|
||||
|
||||
private final AptMasterVersionMapper versionMapper;
|
||||
|
||||
public AptMasterVersionServiceImpl(AptMasterVersionMapper versionMapper)
|
||||
{
|
||||
this.versionMapper = versionMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<AptMasterVersion> list()
|
||||
{
|
||||
List<AptMasterVersion> versions = versionMapper.selectVersions(gyid());
|
||||
if (versions == null)
|
||||
{
|
||||
return List.of();
|
||||
}
|
||||
versions.forEach(this::markSource);
|
||||
return versions;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AptMasterVersion detail(String domain)
|
||||
{
|
||||
if (domain == null || domain.isBlank())
|
||||
{
|
||||
throw missing("domain");
|
||||
}
|
||||
String key = domain.trim().toUpperCase(Locale.ROOT);
|
||||
if (!DOMAINS.contains(key))
|
||||
{
|
||||
throw invalid("domain 仅允许 " + String.join("/", DOMAINS));
|
||||
}
|
||||
AptMasterVersion version = versionMapper.selectVersionByDomain(gyid(), key);
|
||||
if (version == null)
|
||||
{
|
||||
throw new AptException(AptErrorCode.NOT_FOUND, "MASTER_VERSION_NOT_FOUND",
|
||||
"该域尚无主数据版本记录");
|
||||
}
|
||||
markSource(version);
|
||||
return version;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void resync(String masterType)
|
||||
{
|
||||
throw new AptException(AptErrorCode.FEATURE_DISABLED, "MASTER_RESYNC_DISABLED",
|
||||
"平台下行未就绪,本期不开放主数据全量重拉(表 9-16 不允许假成功)");
|
||||
}
|
||||
|
||||
private void markSource(AptMasterVersion version)
|
||||
{
|
||||
boolean local = "LOCAL".equalsIgnoreCase(version.getSource());
|
||||
version.setLocalMock(local);
|
||||
version.setSourceLabel(local ? "非平台下发" : "平台下发");
|
||||
}
|
||||
|
||||
private AptException missing(String field)
|
||||
{
|
||||
return new AptException(AptErrorCode.BAD_REQUEST, "PARAM_INVALID", "缺少必填字段: " + field);
|
||||
}
|
||||
|
||||
private AptException invalid(String message)
|
||||
{
|
||||
return new AptException(AptErrorCode.BAD_REQUEST, "PARAM_INVALID", message);
|
||||
}
|
||||
|
||||
private String gyid()
|
||||
{
|
||||
return AptContext.getGyId();
|
||||
}
|
||||
}
|
||||
+279
@@ -0,0 +1,279 @@
|
||||
package com.ruoyi.apt.master.service;
|
||||
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import com.ruoyi.apt.common.AptContext;
|
||||
import com.ruoyi.apt.common.AptErrorCode;
|
||||
import com.ruoyi.apt.common.AptException;
|
||||
import com.ruoyi.apt.master.domain.AptRoute;
|
||||
import com.ruoyi.apt.master.domain.AptRouteCommand;
|
||||
import com.ruoyi.apt.master.domain.AptRouteRecalc;
|
||||
import com.ruoyi.apt.master.domain.AptRouteStatusCommand;
|
||||
import com.ruoyi.apt.master.mapper.AptRouteMapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* 交路主数据领域服务(IT-2 / M1)。
|
||||
*
|
||||
* <p>校验口径(公寓端/04 表 4-5):必填 cc/kcsj/jcsj/lysj、时序 jcsj<kcsj 与
|
||||
* lysj<=kcsj、(gyid,jlid) 唯一、停用原因必填、快照字段只读。
|
||||
* gyid 恒取自 AptContext,服务签名不接受任何客户端传入的公寓标识。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
@Service
|
||||
public class AptRouteServiceImpl implements IAptRouteService
|
||||
{
|
||||
private static final long MINUTE_MS = 60_000L;
|
||||
private static final int DEFAULT_ADVANCE_MINUTES = 120;
|
||||
private static final int DEFAULT_HOLIDAY_ADVANCE_MINUTES = 180;
|
||||
private static final int DEFAULT_WAKE_MINUTES = 30;
|
||||
private static final long DIFF_WARN_THRESHOLD_MS = 5 * MINUTE_MS;
|
||||
|
||||
private final AptRouteMapper routeMapper;
|
||||
|
||||
public AptRouteServiceImpl(AptRouteMapper routeMapper)
|
||||
{
|
||||
this.routeMapper = routeMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<AptRoute> list(String cc, String jlid, String state)
|
||||
{
|
||||
return routeMapper.selectRouteList(gyid(), cc, jlid, state);
|
||||
}
|
||||
|
||||
@Override
|
||||
public AptRoute detail(Long id)
|
||||
{
|
||||
return mustExist(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<AptRoute> options(String keyword)
|
||||
{
|
||||
return routeMapper.selectRouteOptions(gyid(), keyword);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Long add(AptRouteCommand command)
|
||||
{
|
||||
validate(command, null);
|
||||
AptRoute route = new AptRoute();
|
||||
fill(route, command);
|
||||
route.setGyid(gyid());
|
||||
route.setState("ACTIVE");
|
||||
route.setSourceVersion(0L);
|
||||
routeMapper.insertRoute(route);
|
||||
return route.getId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void edit(Long id, AptRouteCommand command)
|
||||
{
|
||||
mustExist(id);
|
||||
validate(command, id);
|
||||
AptRoute route = new AptRoute();
|
||||
route.setId(id);
|
||||
fill(route, command);
|
||||
route.setGyid(gyid());
|
||||
routeMapper.updateRoute(route);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void changeStatus(Long id, AptRouteStatusCommand command)
|
||||
{
|
||||
mustExist(id);
|
||||
if (command == null || command.getEnabled() == null)
|
||||
{
|
||||
throw missing("enabled");
|
||||
}
|
||||
String reason = command.getReason();
|
||||
if (reason == null || reason.isBlank())
|
||||
{
|
||||
throw missing("reason");
|
||||
}
|
||||
if (reason.length() > 512)
|
||||
{
|
||||
throw invalid("reason 长度不能超过 512");
|
||||
}
|
||||
boolean enable = command.getEnabled();
|
||||
routeMapper.updateRouteStatus(gyid(), id,
|
||||
enable ? "ACTIVE" : "STOPPED", enable ? null : reason.trim());
|
||||
}
|
||||
|
||||
@Override
|
||||
public AptRouteRecalc recalcCheck(Long id, Date baseDate)
|
||||
{
|
||||
AptRoute route = mustExist(id);
|
||||
Date effectiveBase = baseDate == null ? new Date() : baseDate;
|
||||
String day = new SimpleDateFormat("yyyy-MM-dd").format(effectiveBase);
|
||||
|
||||
Map<String, Object> rule = routeMapper.selectCallTimeRule(gyid());
|
||||
boolean holiday = routeMapper.countHoliday(gyid(), day) > 0;
|
||||
String body = rule == null ? null : stringValue(rule.get("ruleBody"));
|
||||
int advance = readIntRule(body,
|
||||
holiday ? "holidayAdvanceMinutes" : "advanceMinutes",
|
||||
holiday ? DEFAULT_HOLIDAY_ADVANCE_MINUTES : DEFAULT_ADVANCE_MINUTES);
|
||||
int wake = readIntRule(body, "wakeMinutes", DEFAULT_WAKE_MINUTES);
|
||||
|
||||
AptRouteRecalc result = new AptRouteRecalc();
|
||||
result.setRouteId(id);
|
||||
result.setJlid(route.getJlid());
|
||||
result.setBaseDate(effectiveBase);
|
||||
result.setRuleVersion(rule == null ? null : longValue(rule.get("ruleVersion")));
|
||||
if (rule == null)
|
||||
{
|
||||
result.getWarnings().add("CALL_TIME 规则缺失,采用默认提前量");
|
||||
}
|
||||
if (holiday)
|
||||
{
|
||||
result.getWarnings().add("已按节假日口径调整提前量:" + advance + " 分钟");
|
||||
}
|
||||
if (route.getKcsj() == null)
|
||||
{
|
||||
result.getWarnings().add("kcsj 缺失,无法复算");
|
||||
return result;
|
||||
}
|
||||
|
||||
long computed = route.getKcsj().getTime() - advance * MINUTE_MS;
|
||||
result.setComputedJcsj(new Date(computed));
|
||||
result.setComputedFjsj(new Date(computed - wake * MINUTE_MS));
|
||||
if (route.getJcsj() == null)
|
||||
{
|
||||
result.getWarnings().add("jcsj 基线缺失,diffMs 为空");
|
||||
}
|
||||
else
|
||||
{
|
||||
long diff = computed - route.getJcsj().getTime();
|
||||
result.setDiffMs(diff);
|
||||
if (Math.abs(diff) > DIFF_WARN_THRESHOLD_MS)
|
||||
{
|
||||
result.getWarnings().add("复算值与 jcsj 基线偏差超过 5 分钟");
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private void validate(AptRouteCommand command, Long excludeId)
|
||||
{
|
||||
if (command == null)
|
||||
{
|
||||
throw missing("请求体");
|
||||
}
|
||||
if (command.getSourceVersion() != null || command.getCreateTime() != null)
|
||||
{
|
||||
throw new AptException(AptErrorCode.SNAPSHOT_READONLY, "SNAPSHOT_READONLY",
|
||||
"sourceVersion / createTime 为平台快照只读字段");
|
||||
}
|
||||
requireText(command.getJlid(), "jlid");
|
||||
requireText(command.getCc(), "cc");
|
||||
if (command.getCc().length() > 32)
|
||||
{
|
||||
throw invalid("cc 长度不能超过 32");
|
||||
}
|
||||
requireDate(command.getKcsj(), "kcsj");
|
||||
requireDate(command.getJcsj(), "jcsj");
|
||||
requireDate(command.getLysj(), "lysj");
|
||||
if (!command.getJcsj().before(command.getKcsj()))
|
||||
{
|
||||
throw invalid("jcsj 必须早于 kcsj");
|
||||
}
|
||||
if (command.getLysj().after(command.getKcsj()))
|
||||
{
|
||||
throw invalid("lysj 不能晚于 kcsj");
|
||||
}
|
||||
if (routeMapper.countByJlid(gyid(), command.getJlid(), excludeId) > 0)
|
||||
{
|
||||
throw new AptException(AptErrorCode.DUPLICATE_KEY, "DUPLICATE_KEY",
|
||||
"jlid 已存在:" + command.getJlid());
|
||||
}
|
||||
}
|
||||
|
||||
private AptRoute mustExist(Long id)
|
||||
{
|
||||
if (id == null)
|
||||
{
|
||||
throw missing("id");
|
||||
}
|
||||
AptRoute route = routeMapper.selectRouteById(gyid(), id);
|
||||
if (route == null)
|
||||
{
|
||||
throw new AptException(AptErrorCode.NOT_FOUND, "ROUTE_NOT_FOUND", "交路不存在");
|
||||
}
|
||||
return route;
|
||||
}
|
||||
|
||||
private void fill(AptRoute route, AptRouteCommand command)
|
||||
{
|
||||
route.setJlid(command.getJlid());
|
||||
route.setCc(command.getCc());
|
||||
route.setKcsj(command.getKcsj());
|
||||
route.setJcsj(command.getJcsj());
|
||||
route.setLysj(command.getLysj());
|
||||
route.setJch(command.getJch());
|
||||
route.setQd(command.getQd());
|
||||
route.setZd(command.getZd());
|
||||
route.setCclx(command.getCclx());
|
||||
route.setBz(command.getBz());
|
||||
}
|
||||
|
||||
private void requireText(String value, String field)
|
||||
{
|
||||
if (value == null || value.isBlank())
|
||||
{
|
||||
throw missing(field);
|
||||
}
|
||||
}
|
||||
|
||||
private void requireDate(Date value, String field)
|
||||
{
|
||||
if (value == null)
|
||||
{
|
||||
throw missing(field);
|
||||
}
|
||||
}
|
||||
|
||||
private AptException missing(String field)
|
||||
{
|
||||
return new AptException(AptErrorCode.BAD_REQUEST, "PARAM_INVALID", "缺少必填字段: " + field);
|
||||
}
|
||||
|
||||
private AptException invalid(String message)
|
||||
{
|
||||
return new AptException(AptErrorCode.BAD_REQUEST, "PARAM_INVALID", message);
|
||||
}
|
||||
|
||||
private int readIntRule(String body, String key, int defaultValue)
|
||||
{
|
||||
if (body == null)
|
||||
{
|
||||
return defaultValue;
|
||||
}
|
||||
Matcher matcher = Pattern.compile("\"" + key + "\"\\s*:\\s*(\\d+)").matcher(body);
|
||||
return matcher.find() ? Integer.parseInt(matcher.group(1)) : defaultValue;
|
||||
}
|
||||
|
||||
private String stringValue(Object value)
|
||||
{
|
||||
return value == null ? null : value.toString();
|
||||
}
|
||||
|
||||
private Long longValue(Object value)
|
||||
{
|
||||
if (value == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return value instanceof Number ? ((Number) value).longValue() : Long.valueOf(value.toString());
|
||||
}
|
||||
|
||||
private String gyid()
|
||||
{
|
||||
return AptContext.getGyId();
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package com.ruoyi.apt.master.service;
|
||||
|
||||
import java.util.List;
|
||||
import com.ruoyi.apt.master.domain.AptMasterVersion;
|
||||
|
||||
/**
|
||||
* 主数据快照版本只读服务(IT-2 / 任务 5)。
|
||||
*
|
||||
* <p>服务签名不接受客户端 gyid;resync 在平台未就绪前恒拒绝,不允许假成功。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
public interface IAptMasterVersionService
|
||||
{
|
||||
List<AptMasterVersion> list();
|
||||
|
||||
AptMasterVersion detail(String domain);
|
||||
|
||||
/** 表 4-17 #2:本期不开放全量重拉,调用即抛 40940 */
|
||||
void resync(String masterType);
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package com.ruoyi.apt.master.service;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import com.ruoyi.apt.master.domain.AptRoute;
|
||||
import com.ruoyi.apt.master.domain.AptRouteCommand;
|
||||
import com.ruoyi.apt.master.domain.AptRouteRecalc;
|
||||
import com.ruoyi.apt.master.domain.AptRouteStatusCommand;
|
||||
|
||||
/**
|
||||
* 交路主数据领域服务(IT-2 / M1,公寓端/04 表 4-5)。
|
||||
* gyid 一律由 AptContext 注入,服务签名不接受该参数。
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
public interface IAptRouteService
|
||||
{
|
||||
List<AptRoute> list(String cc, String jlid, String state);
|
||||
|
||||
AptRoute detail(Long id);
|
||||
|
||||
List<AptRoute> options(String keyword);
|
||||
|
||||
Long add(AptRouteCommand command);
|
||||
|
||||
void edit(Long id, AptRouteCommand command);
|
||||
|
||||
void changeStatus(Long id, AptRouteStatusCommand command);
|
||||
|
||||
/** 本地复算校验,不落库 */
|
||||
AptRouteRecalc recalcCheck(Long id, Date baseDate);
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
+154
@@ -0,0 +1,154 @@
|
||||
package com.ruoyi.apt.stay.controller;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.ruoyi.apt.common.AptContext;
|
||||
import com.ruoyi.apt.common.AptErrorCode;
|
||||
import com.ruoyi.apt.common.AptException;
|
||||
import com.ruoyi.apt.common.audit.AptAudit;
|
||||
import com.ruoyi.apt.common.page.AptTableDataInfo;
|
||||
import com.ruoyi.apt.framework.audit.AptAuditWriter;
|
||||
import com.ruoyi.apt.framework.web.AptBaseController;
|
||||
import com.ruoyi.apt.ops.domain.AptOperationLog;
|
||||
import com.ruoyi.apt.stay.domain.AptRoomCommand;
|
||||
import com.ruoyi.apt.stay.domain.AptRoomStatusCommand;
|
||||
import com.ruoyi.apt.stay.domain.AptRoomStatusResult;
|
||||
import com.ruoyi.apt.stay.service.IAptRoomService;
|
||||
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.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.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 房间管理(IT-2 / M2,公寓端/04 表 4-7 接口 1~6)。
|
||||
*
|
||||
* <p>楼栋字段未启用(G-01)时 building 降级为楼层前缀匹配并在 warnings[] 标注;
|
||||
* 停用/恢复原因显式写 duty_operation_log;新增房间在 G-01/G-04 签字前恒返回 40940。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping(value = "/apt/room", produces = "application/json;charset=UTF-8")
|
||||
public class AptRoomController extends AptBaseController
|
||||
{
|
||||
private final IAptRoomService roomService;
|
||||
private final AptAuditWriter auditWriter;
|
||||
|
||||
public AptRoomController(IAptRoomService roomService, AptAuditWriter auditWriter)
|
||||
{
|
||||
this.roomService = roomService;
|
||||
this.auditWriter = auditWriter;
|
||||
}
|
||||
|
||||
@PreAuthorize("@ss.hasPermi('apt:room:list')")
|
||||
@AptAudit(module = "STAY", action = "ROOM_LIST")
|
||||
@GetMapping("/list")
|
||||
public AptTableDataInfo list(
|
||||
@RequestParam(required = false) String building,
|
||||
@RequestParam(required = false) String roomFloor,
|
||||
@RequestParam(required = false) String roomNo,
|
||||
@RequestParam(required = false) List<String> roomStatus,
|
||||
@RequestParam(required = false) String roomType)
|
||||
{
|
||||
startAptPage();
|
||||
AptTableDataInfo data = getAptDataTable(
|
||||
roomService.list(building, roomFloor, roomNo, roomStatus, roomType));
|
||||
if (building != null && !building.isBlank())
|
||||
{
|
||||
data.getWarnings().add("楼栋字段未启用(G-01),已降级为楼层前缀匹配");
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
@PreAuthorize("@ss.hasPermi('apt:room:list')")
|
||||
@GetMapping("/status-summary")
|
||||
public AjaxResult statusSummary()
|
||||
{
|
||||
return AjaxResult.success(roomService.statusSummary());
|
||||
}
|
||||
|
||||
@PreAuthorize("@ss.hasPermi('apt:room:query')")
|
||||
@AptAudit(module = "STAY", action = "ROOM_DETAIL")
|
||||
@GetMapping("/{id}")
|
||||
public AjaxResult detail(@PathVariable Long id)
|
||||
{
|
||||
return AjaxResult.success(roomService.detail(id));
|
||||
}
|
||||
|
||||
@PreAuthorize("@ss.hasPermi('apt:room:edit')")
|
||||
@AptAudit(module = "STAY", action = "ROOM_EDIT")
|
||||
@PutMapping("/{id}")
|
||||
public AjaxResult edit(@PathVariable Long id, @RequestBody AptRoomCommand command)
|
||||
{
|
||||
roomService.edit(id, command);
|
||||
return AjaxResult.success();
|
||||
}
|
||||
|
||||
@PreAuthorize("@ss.hasPermi('apt:room:changeStatus')")
|
||||
@PutMapping("/{id}/status")
|
||||
public AjaxResult changeStatus(@PathVariable Long id,
|
||||
@Valid @RequestBody AptRoomStatusCommand command)
|
||||
{
|
||||
AptRoomStatusResult result = roomService.changeStatus(id, command);
|
||||
writeStatusAudit(command, result, id);
|
||||
return AjaxResult.success(result);
|
||||
}
|
||||
|
||||
/** 表 4-7 #6:G-01/G-04 未签字前不开放本地新增,权限点 3014 亦保持置灰 */
|
||||
@PreAuthorize("@ss.hasPermi('apt:room:add')")
|
||||
@PostMapping
|
||||
public AjaxResult add()
|
||||
{
|
||||
throw new AptException(AptErrorCode.FEATURE_DISABLED, "ROOM_ADD_DISABLED",
|
||||
"楼栋与房间新增口径(G-01/G-04)未定稿,本期不开放新增房间");
|
||||
}
|
||||
|
||||
/** 停用/恢复原因必须进审计 detail(表 7-3 M2「恢复触发重算」取证) */
|
||||
private void writeStatusAudit(AptRoomStatusCommand command, AptRoomStatusResult result, Long roomId)
|
||||
{
|
||||
Map<String, Object> detail = new LinkedHashMap<>();
|
||||
detail.put("reason", command.getReason());
|
||||
detail.put("commandId", command.getCommandId());
|
||||
detail.put("roomId", roomId);
|
||||
detail.put("roomStatus", result.getRoomStatus());
|
||||
detail.put("stateVersion", result.getStateVersion());
|
||||
detail.put("duplicated", result.isDuplicated());
|
||||
detail.put("status", "SUCCESS");
|
||||
detail.put("traceId", AptContext.getTraceId());
|
||||
|
||||
AptOperationLog entry = new AptOperationLog();
|
||||
entry.setLogId(UUID.randomUUID().toString().replace("-", ""));
|
||||
entry.setGyId(AptContext.getGyIdOrNull());
|
||||
entry.setOperatorRef(operatorRef());
|
||||
entry.setModule("STAY");
|
||||
entry.setAction("ROOM_" + command.getAction().trim().toUpperCase());
|
||||
entry.setDetail(JSON.toJSONString(detail));
|
||||
entry.setTraceId(AptContext.getTraceId());
|
||||
entry.setOccurredAt(new Date());
|
||||
auditWriter.write(entry);
|
||||
}
|
||||
|
||||
private String operatorRef()
|
||||
{
|
||||
try
|
||||
{
|
||||
return SecurityUtils.getLoginUser().getUsername();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return "ANONYMOUS";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.ruoyi.apt.stay.domain;
|
||||
|
||||
/**
|
||||
* 房间档案 + 房态(IT-2 / M2,公寓端/04 表 4-7)。
|
||||
*
|
||||
* <p>gy_rooms 是运行时事实,apt_master_room 是平台下发基线(只读)。
|
||||
* room_status 只能由 apt-stay 房态重算服务写入,管理端只提交停用/恢复命令。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
public class AptRoom
|
||||
{
|
||||
private Long id;
|
||||
private String gyid;
|
||||
private String roomId;
|
||||
private String roomNo;
|
||||
private String roomFloor;
|
||||
private String roomDir;
|
||||
private String roomFace;
|
||||
private String bedNumbers;
|
||||
private String roomStatus;
|
||||
private String roomStatusLabel;
|
||||
private Integer livePerson;
|
||||
private String personName;
|
||||
private String roomType;
|
||||
private String roomDept;
|
||||
private Integer roomOrder;
|
||||
private Integer bedCount;
|
||||
private Long stateVersion;
|
||||
private String disabledReason;
|
||||
private Long sourceVersion;
|
||||
private Integer inPersonCount;
|
||||
|
||||
public Long getId() { return id; }
|
||||
public void setId(Long id) { this.id = id; }
|
||||
public String getGyid() { return gyid; }
|
||||
public void setGyid(String gyid) { this.gyid = gyid; }
|
||||
public String getRoomId() { return roomId; }
|
||||
public void setRoomId(String roomId) { this.roomId = roomId; }
|
||||
public String getRoomNo() { return roomNo; }
|
||||
public void setRoomNo(String roomNo) { this.roomNo = roomNo; }
|
||||
public String getRoomFloor() { return roomFloor; }
|
||||
public void setRoomFloor(String roomFloor) { this.roomFloor = roomFloor; }
|
||||
public String getRoomDir() { return roomDir; }
|
||||
public void setRoomDir(String roomDir) { this.roomDir = roomDir; }
|
||||
public String getRoomFace() { return roomFace; }
|
||||
public void setRoomFace(String roomFace) { this.roomFace = roomFace; }
|
||||
public String getBedNumbers() { return bedNumbers; }
|
||||
public void setBedNumbers(String bedNumbers) { this.bedNumbers = bedNumbers; }
|
||||
public String getRoomStatus() { return roomStatus; }
|
||||
public void setRoomStatus(String roomStatus) { this.roomStatus = roomStatus; }
|
||||
public String getRoomStatusLabel() { return roomStatusLabel; }
|
||||
public void setRoomStatusLabel(String roomStatusLabel) { this.roomStatusLabel = roomStatusLabel; }
|
||||
public Integer getLivePerson() { return livePerson; }
|
||||
public void setLivePerson(Integer livePerson) { this.livePerson = livePerson; }
|
||||
public String getPersonName() { return personName; }
|
||||
public void setPersonName(String personName) { this.personName = personName; }
|
||||
public String getRoomType() { return roomType; }
|
||||
public void setRoomType(String roomType) { this.roomType = roomType; }
|
||||
public String getRoomDept() { return roomDept; }
|
||||
public void setRoomDept(String roomDept) { this.roomDept = roomDept; }
|
||||
public Integer getRoomOrder() { return roomOrder; }
|
||||
public void setRoomOrder(Integer roomOrder) { this.roomOrder = roomOrder; }
|
||||
public Integer getBedCount() { return bedCount; }
|
||||
public void setBedCount(Integer bedCount) { this.bedCount = bedCount; }
|
||||
public Long getStateVersion() { return stateVersion; }
|
||||
public void setStateVersion(Long stateVersion) { this.stateVersion = stateVersion; }
|
||||
public String getDisabledReason() { return disabledReason; }
|
||||
public void setDisabledReason(String disabledReason) { this.disabledReason = disabledReason; }
|
||||
public Long getSourceVersion() { return sourceVersion; }
|
||||
public void setSourceVersion(Long sourceVersion) { this.sourceVersion = sourceVersion; }
|
||||
public Integer getInPersonCount() { return inPersonCount; }
|
||||
public void setInPersonCount(Integer inPersonCount) { this.inPersonCount = inPersonCount; }
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package com.ruoyi.apt.stay.domain;
|
||||
|
||||
/**
|
||||
* 房间本地属性编辑入参(表 4-7 #4)。
|
||||
*
|
||||
* <p>bedNumbers / roomFloor / sourceVersion 属平台快照字段,
|
||||
* 入参携带即返回 40904 SNAPSHOT_READONLY(表 4-7 实现约束 4)。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
public class AptRoomCommand
|
||||
{
|
||||
private String roomType;
|
||||
private String roomDept;
|
||||
private Integer roomOrder;
|
||||
private String roomDir;
|
||||
private String roomFace;
|
||||
|
||||
/** 只读保护字段 */
|
||||
private String bedNumbers;
|
||||
private String roomFloor;
|
||||
private Long sourceVersion;
|
||||
|
||||
public String getRoomType() { return roomType; }
|
||||
public void setRoomType(String roomType) { this.roomType = roomType; }
|
||||
public String getRoomDept() { return roomDept; }
|
||||
public void setRoomDept(String roomDept) { this.roomDept = roomDept; }
|
||||
public Integer getRoomOrder() { return roomOrder; }
|
||||
public void setRoomOrder(Integer roomOrder) { this.roomOrder = roomOrder; }
|
||||
public String getRoomDir() { return roomDir; }
|
||||
public void setRoomDir(String roomDir) { this.roomDir = roomDir; }
|
||||
public String getRoomFace() { return roomFace; }
|
||||
public void setRoomFace(String roomFace) { this.roomFace = roomFace; }
|
||||
public String getBedNumbers() { return bedNumbers; }
|
||||
public void setBedNumbers(String bedNumbers) { this.bedNumbers = bedNumbers; }
|
||||
public String getRoomFloor() { return roomFloor; }
|
||||
public void setRoomFloor(String roomFloor) { this.roomFloor = roomFloor; }
|
||||
public Long getSourceVersion() { return sourceVersion; }
|
||||
public void setSourceVersion(Long sourceVersion) { this.sourceVersion = sourceVersion; }
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package com.ruoyi.apt.stay.domain;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** 房间详情:房间全字段 + 分机绑定 + 在寓人员 + 最近保洁任务(表 4-7 #2)。 */
|
||||
public class AptRoomDetail extends AptRoom
|
||||
{
|
||||
private final List<Map<String, Object>> bindings = new ArrayList<>();
|
||||
private final List<Map<String, Object>> livePersons = new ArrayList<>();
|
||||
private final List<Map<String, Object>> recentCleanTasks = new ArrayList<>();
|
||||
|
||||
public List<Map<String, Object>> getBindings() { return bindings; }
|
||||
public List<Map<String, Object>> getLivePersons() { return livePersons; }
|
||||
public List<Map<String, Object>> getRecentCleanTasks() { return recentCleanTasks; }
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package com.ruoyi.apt.stay.domain;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
/**
|
||||
* 房间停用 / 恢复命令(表 4-7 #5,L2 幂等)。
|
||||
* action 仅允许 DISABLE / RESTORE;reason 必填并落 duty_operation_log。
|
||||
*/
|
||||
public class AptRoomStatusCommand
|
||||
{
|
||||
@NotBlank(message = "action 不能为空")
|
||||
private String action;
|
||||
|
||||
@NotBlank(message = "reason 不能为空")
|
||||
@Size(min = 1, max = 512, message = "reason 长度必须为 1~512")
|
||||
private String reason;
|
||||
|
||||
/** 可选幂等键;重复提交返回 duplicated=true,不重复执行 */
|
||||
private String commandId;
|
||||
|
||||
public String getAction() { return action; }
|
||||
public void setAction(String action) { this.action = action; }
|
||||
public String getReason() { return reason; }
|
||||
public void setReason(String reason) { this.reason = reason; }
|
||||
public String getCommandId() { return commandId; }
|
||||
public void setCommandId(String commandId) { this.commandId = commandId; }
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package com.ruoyi.apt.stay.domain;
|
||||
|
||||
/** 停用 / 恢复执行结果:新房态、递增后的 state_version、是否重复命令。 */
|
||||
public class AptRoomStatusResult
|
||||
{
|
||||
private String roomStatus;
|
||||
private Long stateVersion;
|
||||
private boolean duplicated;
|
||||
|
||||
public String getRoomStatus() { return roomStatus; }
|
||||
public void setRoomStatus(String roomStatus) { this.roomStatus = roomStatus; }
|
||||
public Long getStateVersion() { return stateVersion; }
|
||||
public void setStateVersion(Long stateVersion) { this.stateVersion = stateVersion; }
|
||||
public boolean isDuplicated() { return duplicated; }
|
||||
public void setDuplicated(boolean duplicated) { this.duplicated = duplicated; }
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.ruoyi.apt.stay.domain;
|
||||
|
||||
/** 房态汇总卡片(表 4-7 #3):idle/full/partial/cleaning/disabled/total。 */
|
||||
public class AptRoomStatusSummary
|
||||
{
|
||||
private long idle;
|
||||
private long full;
|
||||
private long partial;
|
||||
private long cleaning;
|
||||
private long disabled;
|
||||
private long total;
|
||||
|
||||
public long getIdle() { return idle; }
|
||||
public void setIdle(long idle) { this.idle = idle; }
|
||||
public long getFull() { return full; }
|
||||
public void setFull(long full) { this.full = full; }
|
||||
public long getPartial() { return partial; }
|
||||
public void setPartial(long partial) { this.partial = partial; }
|
||||
public long getCleaning() { return cleaning; }
|
||||
public void setCleaning(long cleaning) { this.cleaning = cleaning; }
|
||||
public long getDisabled() { return disabled; }
|
||||
public void setDisabled(long disabled) { this.disabled = disabled; }
|
||||
public long getTotal() { return total; }
|
||||
public void setTotal(long total) { this.total = total; }
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
package com.ruoyi.apt.stay.mapper;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.ruoyi.apt.stay.domain.AptRoom;
|
||||
import com.ruoyi.apt.stay.domain.AptRoomCommand;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
/**
|
||||
* 房间档案与房态数据访问(IT-2 / M2)。
|
||||
*
|
||||
* <p>硬约束:每条 SQL 必带 gyid(AptMapperGyidArchTest 断言);
|
||||
* 房态更新必须带 state_version 条件,管理端不得直写 room_status。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
public interface AptRoomMapper
|
||||
{
|
||||
List<AptRoom> selectRoomList(@Param("gyid") String gyid,
|
||||
@Param("building") String building,
|
||||
@Param("roomFloor") String roomFloor,
|
||||
@Param("roomNo") String roomNo,
|
||||
@Param("roomStatuses") List<String> roomStatuses,
|
||||
@Param("roomType") String roomType);
|
||||
|
||||
AptRoom selectRoomById(@Param("gyid") String gyid, @Param("id") Long id);
|
||||
|
||||
List<Map<String, Object>> selectRoomBindings(@Param("gyid") String gyid,
|
||||
@Param("roomId") String roomId);
|
||||
|
||||
List<Map<String, Object>> selectLivePersons(@Param("gyid") String gyid,
|
||||
@Param("roomNo") String roomNo);
|
||||
|
||||
List<Map<String, Object>> selectRecentCleanTasks(@Param("gyid") String gyid,
|
||||
@Param("roomId") String roomId);
|
||||
|
||||
Map<String, Object> selectStatusSummary(@Param("gyid") String gyid);
|
||||
|
||||
/** 恢复时重算房态所需的有效保洁任务状态 */
|
||||
String selectActiveCleanState(@Param("gyid") String gyid, @Param("roomId") String roomId);
|
||||
|
||||
int updateRoomLocal(@Param("gyid") String gyid, @Param("id") Long id,
|
||||
@Param("command") AptRoomCommand command);
|
||||
|
||||
/** 条件更新:state_version 不匹配返回 0,映射 40903 */
|
||||
int updateRoomStatus(@Param("gyid") String gyid, @Param("id") Long id,
|
||||
@Param("expectedVersion") long expectedVersion,
|
||||
@Param("roomStatus") String roomStatus,
|
||||
@Param("disabledReason") String disabledReason);
|
||||
|
||||
Map<String, Object> selectManualAction(@Param("gyid") String gyid,
|
||||
@Param("actionId") String actionId);
|
||||
|
||||
int insertManualAction(@Param("gyid") String gyid, @Param("actionId") String actionId,
|
||||
@Param("actionType") String actionType, @Param("state") String state,
|
||||
@Param("requestedBy") String requestedBy,
|
||||
@Param("occurredAt") Date occurredAt);
|
||||
|
||||
int updateManualActionResult(@Param("gyid") String gyid, @Param("actionId") String actionId,
|
||||
@Param("state") String state, @Param("resultCode") String resultCode);
|
||||
|
||||
int markManualActionDuplicated(@Param("gyid") String gyid, @Param("actionId") String actionId);
|
||||
}
|
||||
+334
@@ -0,0 +1,334 @@
|
||||
package com.ruoyi.apt.stay.service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
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.stay.domain.AptRoom;
|
||||
import com.ruoyi.apt.stay.domain.AptRoomCommand;
|
||||
import com.ruoyi.apt.stay.domain.AptRoomDetail;
|
||||
import com.ruoyi.apt.stay.domain.AptRoomStatusCommand;
|
||||
import com.ruoyi.apt.stay.domain.AptRoomStatusResult;
|
||||
import com.ruoyi.apt.stay.domain.AptRoomStatusSummary;
|
||||
import com.ruoyi.apt.stay.mapper.AptRoomMapper;
|
||||
import com.ruoyi.common.utils.SecurityUtils;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* 房间档案、房态与 state_version(IT-2 / M2)。
|
||||
*
|
||||
* <p>口径(公寓端/04 表 4-7、07 表 7-3):</p>
|
||||
* <ul>
|
||||
* <li>停用 = 房态置 4 并记原因,不改在寓人员、不停叫乘;</li>
|
||||
* <li>恢复 = 触发房态重算(保洁任务 / 在寓人数 / 床位数),清空原因;</li>
|
||||
* <li>两者都走 state_version 条件更新,冲突返回 40903;</li>
|
||||
* <li>commandId 落 duty_manual_action 唯一键,重复返回 duplicated=true。</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
@Service
|
||||
public class AptRoomServiceImpl implements IAptRoomService
|
||||
{
|
||||
private static final String DISABLE = "DISABLE";
|
||||
private static final String RESTORE = "RESTORE";
|
||||
private static final String STATUS_DISABLED = "4";
|
||||
private static final String ACTION_STOP = "STOP";
|
||||
private static final String ACTION_CORRECT = "CORRECT";
|
||||
|
||||
private final AptRoomMapper roomMapper;
|
||||
|
||||
public AptRoomServiceImpl(AptRoomMapper roomMapper)
|
||||
{
|
||||
this.roomMapper = roomMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<AptRoom> list(String building, String roomFloor, String roomNo,
|
||||
List<String> roomStatuses, String roomType)
|
||||
{
|
||||
return roomMapper.selectRoomList(gyid(), building, roomFloor, roomNo,
|
||||
normalize(roomStatuses), roomType);
|
||||
}
|
||||
|
||||
@Override
|
||||
public AptRoomDetail detail(Long id)
|
||||
{
|
||||
AptRoom room = mustExist(id);
|
||||
AptRoomDetail detail = new AptRoomDetail();
|
||||
BeanUtils.copyProperties(room, detail);
|
||||
detail.getBindings().addAll(nonNull(roomMapper.selectRoomBindings(gyid(), room.getRoomId())));
|
||||
detail.getLivePersons().addAll(nonNull(roomMapper.selectLivePersons(gyid(), room.getRoomNo())));
|
||||
detail.getRecentCleanTasks().addAll(nonNull(roomMapper.selectRecentCleanTasks(gyid(), room.getRoomId())));
|
||||
return detail;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AptRoomStatusSummary statusSummary()
|
||||
{
|
||||
AptRoomStatusSummary summary = new AptRoomStatusSummary();
|
||||
Map<String, Object> row = roomMapper.selectStatusSummary(gyid());
|
||||
if (row == null)
|
||||
{
|
||||
return summary;
|
||||
}
|
||||
summary.setIdle(longValue(row.get("idle")));
|
||||
summary.setFull(longValue(row.get("full")));
|
||||
summary.setPartial(longValue(row.get("partial")));
|
||||
summary.setCleaning(longValue(row.get("cleaning")));
|
||||
summary.setDisabled(longValue(row.get("disabled")));
|
||||
summary.setTotal(longValue(row.get("total")));
|
||||
return summary;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void edit(Long id, AptRoomCommand command)
|
||||
{
|
||||
mustExist(id);
|
||||
if (command == null)
|
||||
{
|
||||
throw missing("请求体");
|
||||
}
|
||||
if (command.getBedNumbers() != null || command.getRoomFloor() != null
|
||||
|| command.getSourceVersion() != null)
|
||||
{
|
||||
throw new AptException(AptErrorCode.SNAPSHOT_READONLY, "SNAPSHOT_READONLY",
|
||||
"bedNumbers / roomFloor / sourceVersion 为平台快照只读字段,须走平台下行");
|
||||
}
|
||||
if (command.getRoomType() == null && command.getRoomDept() == null
|
||||
&& command.getRoomOrder() == null && command.getRoomDir() == null
|
||||
&& command.getRoomFace() == null)
|
||||
{
|
||||
throw missing("roomType/roomDept/roomOrder/roomDir/roomFace 至少一项");
|
||||
}
|
||||
if (command.getRoomOrder() != null && command.getRoomOrder() < 0)
|
||||
{
|
||||
throw invalid("roomOrder 不能为负数");
|
||||
}
|
||||
roomMapper.updateRoomLocal(gyid(), id, command);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public AptRoomStatusResult changeStatus(Long id, AptRoomStatusCommand command)
|
||||
{
|
||||
AptRoom room = mustExist(id);
|
||||
String action = validateCommand(command);
|
||||
boolean disable = DISABLE.equals(action);
|
||||
|
||||
// L2 幂等必须先于状态合法性判断:重复停用命令重放时房态已是 4,
|
||||
// 若先判状态会错返 40902,而不是 duplicated=true(表 4-7 #5)。
|
||||
String actionId = actionId(command);
|
||||
Map<String, Object> existing = roomMapper.selectManualAction(gyid(), actionId);
|
||||
if (existing != null && !existing.isEmpty())
|
||||
{
|
||||
roomMapper.markManualActionDuplicated(gyid(), actionId);
|
||||
return duplicatedResult(id);
|
||||
}
|
||||
if (disable && STATUS_DISABLED.equals(room.getRoomStatus()))
|
||||
{
|
||||
throw stateIllegal("房间已停用,不可重复停用");
|
||||
}
|
||||
if (!disable && !STATUS_DISABLED.equals(room.getRoomStatus()))
|
||||
{
|
||||
throw stateIllegal("仅停用房间可恢复");
|
||||
}
|
||||
try
|
||||
{
|
||||
roomMapper.insertManualAction(gyid(), actionId,
|
||||
disable ? ACTION_STOP : ACTION_CORRECT, "PENDING",
|
||||
requestedBy(), new Date());
|
||||
}
|
||||
catch (DuplicateKeyException e)
|
||||
{
|
||||
roomMapper.markManualActionDuplicated(gyid(), actionId);
|
||||
return duplicatedResult(id);
|
||||
}
|
||||
|
||||
long version = room.getStateVersion() == null ? 0L : room.getStateVersion();
|
||||
String targetStatus = disable ? STATUS_DISABLED : recalcStatus(room);
|
||||
String reason = disable ? command.getReason().trim() : null;
|
||||
int updated = roomMapper.updateRoomStatus(gyid(), id, version, targetStatus, reason);
|
||||
if (updated == 0)
|
||||
{
|
||||
roomMapper.updateManualActionResult(gyid(), actionId, "FAILED", "STATE_VERSION_CONFLICT");
|
||||
throw new AptException(AptErrorCode.SNAPSHOT_EXPIRED, "STATE_VERSION_CONFLICT",
|
||||
"房态版本已变化,请重读后重试");
|
||||
}
|
||||
roomMapper.updateManualActionResult(gyid(), actionId, "DONE", targetStatus);
|
||||
|
||||
AptRoomStatusResult result = new AptRoomStatusResult();
|
||||
result.setRoomStatus(targetStatus);
|
||||
result.setStateVersion(version + 1);
|
||||
result.setDuplicated(false);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 房态重算:保洁中 > 待清扫 > 空闲 > 住满 > 未满(表 4-7 状态码 1~6) */
|
||||
private String recalcStatus(AptRoom room)
|
||||
{
|
||||
String cleanState = roomMapper.selectActiveCleanState(gyid(), room.getRoomId());
|
||||
if ("PROCESSING".equals(cleanState))
|
||||
{
|
||||
return "6";
|
||||
}
|
||||
if ("PENDING".equals(cleanState))
|
||||
{
|
||||
return "3";
|
||||
}
|
||||
int inPerson = room.getInPersonCount() == null ? 0 : room.getInPersonCount();
|
||||
int beds = room.getBedCount() == null ? 0 : room.getBedCount();
|
||||
if (inPerson <= 0)
|
||||
{
|
||||
return "1";
|
||||
}
|
||||
if (beds > 0 && inPerson >= beds)
|
||||
{
|
||||
return "2";
|
||||
}
|
||||
return "5";
|
||||
}
|
||||
|
||||
private AptRoomStatusResult duplicatedResult(Long id)
|
||||
{
|
||||
AptRoom current = mustExist(id);
|
||||
AptRoomStatusResult result = new AptRoomStatusResult();
|
||||
result.setRoomStatus(current.getRoomStatus());
|
||||
result.setStateVersion(current.getStateVersion());
|
||||
result.setDuplicated(true);
|
||||
return result;
|
||||
}
|
||||
|
||||
private String validateCommand(AptRoomStatusCommand command)
|
||||
{
|
||||
if (command == null)
|
||||
{
|
||||
throw missing("请求体");
|
||||
}
|
||||
if (command.getAction() == null || command.getAction().isBlank())
|
||||
{
|
||||
throw missing("action");
|
||||
}
|
||||
if (command.getReason() == null || command.getReason().isBlank())
|
||||
{
|
||||
throw missing("reason");
|
||||
}
|
||||
if (command.getReason().length() > 512)
|
||||
{
|
||||
throw invalid("reason 长度不能超过 512");
|
||||
}
|
||||
if (command.getCommandId() != null && command.getCommandId().length() > 64)
|
||||
{
|
||||
throw invalid("commandId 长度不能超过 64");
|
||||
}
|
||||
String action = command.getAction().trim().toUpperCase(Locale.ROOT);
|
||||
if (!DISABLE.equals(action) && !RESTORE.equals(action))
|
||||
{
|
||||
throw invalid("action 仅允许 DISABLE / RESTORE");
|
||||
}
|
||||
return action;
|
||||
}
|
||||
|
||||
private String actionId(AptRoomStatusCommand command)
|
||||
{
|
||||
if (command.getCommandId() == null || command.getCommandId().isBlank())
|
||||
{
|
||||
return "ROOM-" + UUID.randomUUID().toString().replace("-", "");
|
||||
}
|
||||
return command.getCommandId().trim();
|
||||
}
|
||||
|
||||
/** 兼容 roomStatus=4,5 与 roomStatus=4&roomStatus=5 两种传参 */
|
||||
private List<String> normalize(List<String> statuses)
|
||||
{
|
||||
if (statuses == null || statuses.isEmpty())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
List<String> result = new ArrayList<>();
|
||||
for (String item : statuses)
|
||||
{
|
||||
if (item == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
for (String part : item.split(","))
|
||||
{
|
||||
String value = part.trim();
|
||||
if (!value.isEmpty())
|
||||
{
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.isEmpty() ? null : result;
|
||||
}
|
||||
|
||||
private AptRoom mustExist(Long id)
|
||||
{
|
||||
if (id == null)
|
||||
{
|
||||
throw missing("id");
|
||||
}
|
||||
AptRoom room = roomMapper.selectRoomById(gyid(), id);
|
||||
if (room == null)
|
||||
{
|
||||
throw new AptException(AptErrorCode.NOT_FOUND, "ROOM_NOT_FOUND", "房间不存在");
|
||||
}
|
||||
return room;
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> nonNull(List<Map<String, Object>> list)
|
||||
{
|
||||
return list == null ? List.of() : list;
|
||||
}
|
||||
|
||||
private long longValue(Object value)
|
||||
{
|
||||
if (value == null)
|
||||
{
|
||||
return 0L;
|
||||
}
|
||||
return value instanceof Number ? ((Number) value).longValue() : Long.parseLong(value.toString());
|
||||
}
|
||||
|
||||
private String requestedBy()
|
||||
{
|
||||
try
|
||||
{
|
||||
return SecurityUtils.getLoginUser().getUsername();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return "ANONYMOUS";
|
||||
}
|
||||
}
|
||||
|
||||
private AptException missing(String field)
|
||||
{
|
||||
return new AptException(AptErrorCode.BAD_REQUEST, "PARAM_INVALID", "缺少必填字段: " + field);
|
||||
}
|
||||
|
||||
private AptException invalid(String message)
|
||||
{
|
||||
return new AptException(AptErrorCode.BAD_REQUEST, "PARAM_INVALID", message);
|
||||
}
|
||||
|
||||
private AptException stateIllegal(String message)
|
||||
{
|
||||
return new AptException(AptErrorCode.STATE_ILLEGAL, "STATE_ILLEGAL", message);
|
||||
}
|
||||
|
||||
private String gyid()
|
||||
{
|
||||
return AptContext.getGyId();
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.ruoyi.apt.stay.service;
|
||||
|
||||
import java.util.List;
|
||||
import com.ruoyi.apt.stay.domain.AptRoom;
|
||||
import com.ruoyi.apt.stay.domain.AptRoomCommand;
|
||||
import com.ruoyi.apt.stay.domain.AptRoomDetail;
|
||||
import com.ruoyi.apt.stay.domain.AptRoomStatusCommand;
|
||||
import com.ruoyi.apt.stay.domain.AptRoomStatusResult;
|
||||
import com.ruoyi.apt.stay.domain.AptRoomStatusSummary;
|
||||
|
||||
/**
|
||||
* 房间档案与房态领域服务(IT-2 / M2,公寓端/04 表 4-7)。
|
||||
*
|
||||
* <p>服务签名不接受客户端 gyid;隔离值恒由 AptContext 注入(D5 一进程一 gyid)。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
public interface IAptRoomService
|
||||
{
|
||||
List<AptRoom> list(String building, String roomFloor, String roomNo,
|
||||
List<String> roomStatuses, String roomType);
|
||||
|
||||
AptRoomDetail detail(Long id);
|
||||
|
||||
AptRoomStatusSummary statusSummary();
|
||||
|
||||
void edit(Long id, AptRoomCommand command);
|
||||
|
||||
AptRoomStatusResult changeStatus(Long id, AptRoomStatusCommand command);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package com.ruoyi.apt.voice.ami;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* AMI Action(发送给 Asterisk 的指令)。
|
||||
*
|
||||
* <p>只覆盖本期用到的 5 类:Login(明文 / MD5 挑战)、Ping、Originate、Playback、Hangup;
|
||||
* 其余能力按需追加,不引入第三方协议库(见设计文档 19 §7 偏离登记)。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
public class AmiAction
|
||||
{
|
||||
private final Map<String, String> fields = new LinkedHashMap<>();
|
||||
|
||||
public AmiAction(String action)
|
||||
{
|
||||
fields.put("Action", action);
|
||||
}
|
||||
|
||||
public AmiAction set(String key, String value)
|
||||
{
|
||||
if (value != null)
|
||||
{
|
||||
fields.put(key, value);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public AmiAction actionId(String actionId)
|
||||
{
|
||||
return set("ActionID", actionId);
|
||||
}
|
||||
|
||||
public String actionId()
|
||||
{
|
||||
return fields.get("ActionID");
|
||||
}
|
||||
|
||||
public String action()
|
||||
{
|
||||
return fields.get("Action");
|
||||
}
|
||||
|
||||
public Map<String, String> fields()
|
||||
{
|
||||
return fields;
|
||||
}
|
||||
|
||||
public static AmiAction login(String username, String secret)
|
||||
{
|
||||
return new AmiAction("Login")
|
||||
.set("Username", username)
|
||||
.set("Secret", secret)
|
||||
.set("Events", "on");
|
||||
}
|
||||
|
||||
public static AmiAction loginMd5(String username, String challenge, String secret)
|
||||
{
|
||||
return new AmiAction("Login")
|
||||
.set("Username", username)
|
||||
.set("AuthType", "MD5")
|
||||
.set("Key", AmiProtocol.md5Hex(challenge + secret))
|
||||
.set("Events", "on");
|
||||
}
|
||||
|
||||
public static AmiAction ping()
|
||||
{
|
||||
return new AmiAction("Ping");
|
||||
}
|
||||
|
||||
public static AmiAction hangup(String channel, String cause)
|
||||
{
|
||||
return new AmiAction("Hangup")
|
||||
.set("Channel", channel)
|
||||
.set("Cause", cause);
|
||||
}
|
||||
|
||||
/** Playback:把本地媒体播放到指定通道(TTS 由 Asterisk 侧先合成音频) */
|
||||
public static AmiAction playback(String channel, String media)
|
||||
{
|
||||
return new AmiAction("Playback")
|
||||
.set("Channel", channel)
|
||||
.set("Media", media);
|
||||
}
|
||||
|
||||
/**
|
||||
* Originate:向分机发起呼叫。
|
||||
*
|
||||
* @param channel 通道,如 {@code PJSIP/1001}
|
||||
* @param context 拨号上下文,如 {@code from-internal}
|
||||
* @param exten 目标分机/会议号,如 {@code Conf-01} 对应的拨号串
|
||||
* @param timeoutMs 呼叫超时
|
||||
*/
|
||||
public static AmiAction originate(String channel, String context, String exten, int timeoutMs)
|
||||
{
|
||||
return new AmiAction("Originate")
|
||||
.set("Channel", channel)
|
||||
.set("Context", context)
|
||||
.set("Exten", exten)
|
||||
.set("Priority", "1")
|
||||
.set("Timeout", String.valueOf(timeoutMs))
|
||||
.set("Async", "true");
|
||||
}
|
||||
|
||||
/** Originate + Application:用于把本地通道直接投进 Playback(TTS 播报腿) */
|
||||
public static AmiAction originateApp(String channel, String application, String data, int timeoutMs)
|
||||
{
|
||||
return new AmiAction("Originate")
|
||||
.set("Channel", channel)
|
||||
.set("Application", application)
|
||||
.set("Data", data)
|
||||
.set("Timeout", String.valueOf(timeoutMs))
|
||||
.set("Async", "true");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
package com.ruoyi.apt.voice.ami;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.EOFException;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.OutputStream;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.Socket;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* 单个 Asterisk 实例的 AMI 客户端(一公寓一实例,一实例一连接)。
|
||||
*
|
||||
* <p>职责与边界:</p>
|
||||
* <ul>
|
||||
* <li>TCP 建连 + Login(明文或 MD5 挑战,自适应)→ 启动读线程;</li>
|
||||
* <li>Action 发送:按 {@code ActionID} 与 Response 配对,带超时;</li>
|
||||
* <li>Event 分发:读线程解析成 {@link AmiFrame} 后 **直接回调**,实现方须立即转队列;</li>
|
||||
* <li>断连:IO 异常/对端关闭 → 失败所有在途 Action → 回调 {@code onDisconnect};</li>
|
||||
* <li>不负责重连与状态落库(那是 {@link AmiConnectionManager} 的职责)。</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
public class AmiClient implements AutoCloseable
|
||||
{
|
||||
private static final Logger log = LoggerFactory.getLogger(AmiClient.class);
|
||||
|
||||
private final String host;
|
||||
private final int port;
|
||||
private final String username;
|
||||
private final String secret;
|
||||
private final int connectTimeoutMs;
|
||||
private final int actionTimeoutMs;
|
||||
|
||||
private final AtomicLong actionSeq = new AtomicLong();
|
||||
private final Map<String, CompletableFuture<AmiFrame>> pending = new ConcurrentHashMap<>();
|
||||
private final List<AmiEventListener> listeners = new CopyOnWriteArrayList<>();
|
||||
|
||||
private Socket socket;
|
||||
private BufferedReader reader;
|
||||
private OutputStream out;
|
||||
private Thread readerThread;
|
||||
|
||||
private volatile boolean connected;
|
||||
private volatile boolean closing;
|
||||
private volatile String sessionId;
|
||||
private volatile String lastError;
|
||||
|
||||
public AmiClient(String host, int port, String username, String secret,
|
||||
int connectTimeoutMs, int actionTimeoutMs)
|
||||
{
|
||||
this.host = host;
|
||||
this.port = port;
|
||||
this.username = username;
|
||||
this.secret = secret;
|
||||
this.connectTimeoutMs = connectTimeoutMs;
|
||||
this.actionTimeoutMs = actionTimeoutMs;
|
||||
}
|
||||
|
||||
public void addListener(AmiEventListener listener)
|
||||
{
|
||||
if (listener != null)
|
||||
{
|
||||
listeners.add(listener);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 建立连接并登录。
|
||||
*
|
||||
* @throws AmiException 建连失败 / 认证失败 / 对端不是 AMI
|
||||
*/
|
||||
public synchronized void connect()
|
||||
{
|
||||
if (connected)
|
||||
{
|
||||
return;
|
||||
}
|
||||
closing = false;
|
||||
try
|
||||
{
|
||||
socket = new Socket();
|
||||
socket.connect(new InetSocketAddress(host, port), connectTimeoutMs);
|
||||
socket.setSoTimeout(connectTimeoutMs);
|
||||
socket.setKeepAlive(true);
|
||||
socket.setTcpNoDelay(true);
|
||||
reader = new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8));
|
||||
out = socket.getOutputStream();
|
||||
|
||||
String banner = reader.readLine();
|
||||
if (banner == null || !banner.startsWith(AmiProtocol.BANNER_PREFIX))
|
||||
{
|
||||
throw AmiException.ioError(new IOException("非 AMI 服务端,首行:" + banner));
|
||||
}
|
||||
login();
|
||||
socket.setSoTimeout(0);
|
||||
connected = true;
|
||||
lastError = null;
|
||||
startReaderThread();
|
||||
log.info("AMI 连接成功 host={}:{} user={} session={}", host, port, username, sessionId);
|
||||
}
|
||||
catch (AmiException e)
|
||||
{
|
||||
lastError = e.getReason();
|
||||
closeQuietly();
|
||||
throw e;
|
||||
}
|
||||
catch (IOException e)
|
||||
{
|
||||
lastError = "AMI_IO_ERROR";
|
||||
closeQuietly();
|
||||
throw AmiException.ioError(e);
|
||||
}
|
||||
}
|
||||
|
||||
private void login() throws IOException
|
||||
{
|
||||
write(AmiAction.login(username, secret));
|
||||
AmiFrame response = readFrame();
|
||||
if (response == null)
|
||||
{
|
||||
throw AmiException.authFailed("登录无响应(连接被关闭)");
|
||||
}
|
||||
// Asterisk 在 md5 认证配置下先回 Challenge,再要求 AuthType=MD5 二次登录
|
||||
if (response.isSuccess() && response.has("challenge"))
|
||||
{
|
||||
write(AmiAction.loginMd5(username, response.get("challenge"), secret));
|
||||
response = readFrame();
|
||||
if (response == null)
|
||||
{
|
||||
throw AmiException.authFailed("MD5 登录无响应");
|
||||
}
|
||||
}
|
||||
if (!response.isSuccess())
|
||||
{
|
||||
throw AmiException.authFailed(response.get("message", "用户名或口令不被接受"));
|
||||
}
|
||||
}
|
||||
|
||||
private AmiFrame readFrame() throws IOException
|
||||
{
|
||||
List<String> lines = new ArrayList<>();
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null)
|
||||
{
|
||||
if (line.startsWith(AmiProtocol.BANNER_PREFIX))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (line.isEmpty())
|
||||
{
|
||||
if (!lines.isEmpty())
|
||||
{
|
||||
return AmiProtocol.parse(lines);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
lines.add(line);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void startReaderThread()
|
||||
{
|
||||
readerThread = new Thread(this::readLoop, "apt-ami-reader-" + port);
|
||||
readerThread.setDaemon(true);
|
||||
readerThread.start();
|
||||
}
|
||||
|
||||
private void readLoop()
|
||||
{
|
||||
List<String> lines = new ArrayList<>();
|
||||
try
|
||||
{
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null)
|
||||
{
|
||||
if (line.startsWith(AmiProtocol.BANNER_PREFIX))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (line.isEmpty())
|
||||
{
|
||||
if (!lines.isEmpty())
|
||||
{
|
||||
dispatch(AmiProtocol.parse(lines));
|
||||
lines = new ArrayList<>();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
lines.add(line);
|
||||
}
|
||||
handleDisconnect(new EOFException("AMI 连接被对端关闭"));
|
||||
}
|
||||
catch (IOException e)
|
||||
{
|
||||
handleDisconnect(e);
|
||||
}
|
||||
}
|
||||
|
||||
private void dispatch(AmiFrame frame)
|
||||
{
|
||||
// 先判 Event:OriginateResponse 等事件帧同时携带 Response 头,不能当成 Action 响应吞掉
|
||||
if (frame.isEvent())
|
||||
{
|
||||
for (AmiEventListener listener : listeners)
|
||||
{
|
||||
try
|
||||
{
|
||||
listener.onEvent(frame);
|
||||
}
|
||||
catch (RuntimeException e)
|
||||
{
|
||||
// 单个监听器异常不得影响读线程
|
||||
log.warn("AMI 事件监听器异常 event={}", frame.eventName(), e);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (frame.isResponse())
|
||||
{
|
||||
String actionId = frame.actionId();
|
||||
CompletableFuture<AmiFrame> future = actionId == null ? null : pending.remove(actionId);
|
||||
if (future != null)
|
||||
{
|
||||
future.complete(frame);
|
||||
return;
|
||||
}
|
||||
log.debug("收到无匹配 ActionID 的响应:{}", frame);
|
||||
}
|
||||
}
|
||||
|
||||
private void handleDisconnect(Throwable cause)
|
||||
{
|
||||
if (closing || !connected)
|
||||
{
|
||||
return;
|
||||
}
|
||||
connected = false;
|
||||
lastError = cause instanceof EOFException ? "AMI_PEER_CLOSED" : "AMI_IO_ERROR";
|
||||
failPending(cause);
|
||||
closeQuietly();
|
||||
log.warn("AMI 连接断开:{}", cause.getMessage());
|
||||
for (AmiEventListener listener : listeners)
|
||||
{
|
||||
try
|
||||
{
|
||||
listener.onDisconnect(cause);
|
||||
}
|
||||
catch (RuntimeException e)
|
||||
{
|
||||
log.warn("AMI 断连监听器异常", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void failPending(Throwable cause)
|
||||
{
|
||||
for (String actionId : pending.keySet())
|
||||
{
|
||||
CompletableFuture<AmiFrame> future = pending.remove(actionId);
|
||||
if (future != null)
|
||||
{
|
||||
future.completeExceptionally(
|
||||
new AmiException("AMI_IO_ERROR", "连接断开,Action 未完成:" + actionId, cause));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public AmiFrame sendAction(AmiAction action)
|
||||
{
|
||||
if (!connected)
|
||||
{
|
||||
throw AmiException.notConnected();
|
||||
}
|
||||
String actionId = action.actionId();
|
||||
if (actionId == null)
|
||||
{
|
||||
actionId = "APT-" + actionSeq.incrementAndGet();
|
||||
action.actionId(actionId);
|
||||
}
|
||||
CompletableFuture<AmiFrame> future = new CompletableFuture<>();
|
||||
pending.put(actionId, future);
|
||||
try
|
||||
{
|
||||
write(action);
|
||||
return future.get(actionTimeoutMs, TimeUnit.MILLISECONDS);
|
||||
}
|
||||
catch (TimeoutException e)
|
||||
{
|
||||
pending.remove(actionId);
|
||||
throw AmiException.actionTimeout(actionId);
|
||||
}
|
||||
catch (InterruptedException e)
|
||||
{
|
||||
Thread.currentThread().interrupt();
|
||||
pending.remove(actionId);
|
||||
throw new AmiException("AMI_INTERRUPTED", "AMI Action 被中断:" + actionId, e);
|
||||
}
|
||||
catch (ExecutionException e)
|
||||
{
|
||||
pending.remove(actionId);
|
||||
Throwable cause = e.getCause() == null ? e : e.getCause();
|
||||
if (cause instanceof AmiException)
|
||||
{
|
||||
throw (AmiException) cause;
|
||||
}
|
||||
throw new AmiException("AMI_IO_ERROR", "AMI Action 失败:" + cause.getMessage(), cause);
|
||||
}
|
||||
catch (IOException e)
|
||||
{
|
||||
pending.remove(actionId);
|
||||
throw AmiException.ioError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/** 心跳:Ping → 必须收到 Success;失败由调用方判定断连 */
|
||||
public boolean ping()
|
||||
{
|
||||
AmiFrame response = sendAction(AmiAction.ping());
|
||||
return response.isSuccess();
|
||||
}
|
||||
|
||||
private synchronized void write(AmiAction action) throws IOException
|
||||
{
|
||||
out.write(AmiProtocol.encode(action).getBytes(StandardCharsets.UTF_8));
|
||||
out.flush();
|
||||
}
|
||||
|
||||
public boolean isConnected()
|
||||
{
|
||||
return connected;
|
||||
}
|
||||
|
||||
public String getSessionId()
|
||||
{
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
public void setSessionId(String sessionId)
|
||||
{
|
||||
this.sessionId = sessionId;
|
||||
}
|
||||
|
||||
public String getLastError()
|
||||
{
|
||||
return lastError;
|
||||
}
|
||||
|
||||
public String describe()
|
||||
{
|
||||
return host + ":" + port;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close()
|
||||
{
|
||||
close(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 主动关闭:不触发 {@code onDisconnect}(由调用方决定如何记账)。
|
||||
*
|
||||
* @param reason 关闭原因(落 apt_ast_ami_session.close_reason)
|
||||
*/
|
||||
public synchronized void close(String reason)
|
||||
{
|
||||
closing = true;
|
||||
connected = false;
|
||||
lastError = reason;
|
||||
failPending(new IOException("AMI 客户端主动关闭"));
|
||||
closeQuietly();
|
||||
if (readerThread != null)
|
||||
{
|
||||
readerThread.interrupt();
|
||||
readerThread = null;
|
||||
}
|
||||
log.info("AMI 连接已关闭 session={} reason={}", sessionId, reason);
|
||||
}
|
||||
|
||||
private void closeQuietly()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (socket != null && !socket.isClosed())
|
||||
{
|
||||
socket.close();
|
||||
}
|
||||
}
|
||||
catch (IOException ignored)
|
||||
{
|
||||
// 关闭失败无需处理
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.ruoyi.apt.voice.ami;
|
||||
|
||||
import com.ruoyi.apt.voice.domain.AptAmiInstance;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* AMI 客户端工厂(便于在单测里注入假客户端,避免真连 Asterisk)。
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
public interface AmiClientFactory
|
||||
{
|
||||
AmiClient create(AptAmiInstance instance, String secret);
|
||||
|
||||
@Component
|
||||
class DefaultAmiClientFactory implements AmiClientFactory
|
||||
{
|
||||
@Override
|
||||
public AmiClient create(AptAmiInstance instance, String secret)
|
||||
{
|
||||
return new AmiClient(instance.getAmiHost(), instance.getAmiPort(), instance.getAmiUser(),
|
||||
secret, instance.getConnectTimeoutMs(), instance.getActionTimeoutMs());
|
||||
}
|
||||
}
|
||||
}
|
||||
+363
@@ -0,0 +1,363 @@
|
||||
package com.ruoyi.apt.voice.ami;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import com.ruoyi.apt.framework.config.AptProperties;
|
||||
import com.ruoyi.apt.voice.domain.AptAmiInstance;
|
||||
import com.ruoyi.apt.voice.service.AptAmiEventDispatcher;
|
||||
import com.ruoyi.apt.voice.service.AptAmiInstanceService;
|
||||
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 连接生命周期管理(一公寓一 Asterisk 实例 → 一连接)。
|
||||
*
|
||||
* <p>生命周期:</p>
|
||||
* <pre>
|
||||
* 启动 → CONNECTING →(Login 成功)→ UP → 心跳 Ping(每 ping-interval)
|
||||
* ↘(失败) → DOWN → 指数退避重连(1s→2s→4s…封顶 60s,抖动 ±20%)
|
||||
* UP 心跳失败 → DEGRADED → 关闭旧连接 → 重连
|
||||
* 应用关闭 / 显式释放 → CLOSED(不触发重连)
|
||||
* </pre>
|
||||
*
|
||||
* <p>为什么不用 Quartz:连接心跳与业务调度无关,且若依 {@code ScheduleConfig}
|
||||
* 当前被注释(表 13-15 依赖项),用自建单线程调度器可保证「有连接就有心跳」,
|
||||
* 不受任务表启停影响。连接与状态落库都在这里,事件处理在
|
||||
* {@link AptAmiEventDispatcher} 的单线程队列中串行执行。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
@Component
|
||||
public class AmiConnectionManager implements ApplicationListener<ApplicationReadyEvent>
|
||||
{
|
||||
private static final Logger log = LoggerFactory.getLogger(AmiConnectionManager.class);
|
||||
|
||||
private final AptProperties properties;
|
||||
private final AptAmiInstanceService instanceService;
|
||||
private final AmiClientFactory clientFactory;
|
||||
private final AptAmiEventDispatcher dispatcher;
|
||||
|
||||
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(runnable -> {
|
||||
Thread thread = new Thread(runnable, "apt-ami-conn");
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
});
|
||||
private final AtomicBoolean connecting = new AtomicBoolean(false);
|
||||
private final AtomicInteger attempts = new AtomicInteger(0);
|
||||
|
||||
private volatile AmiClient client;
|
||||
private volatile AptAmiInstance instance;
|
||||
private volatile String amiSessionId;
|
||||
private volatile boolean released;
|
||||
private volatile ScheduledFuture<?> healthFuture;
|
||||
|
||||
public AmiConnectionManager(AptProperties properties, AptAmiInstanceService instanceService,
|
||||
AmiClientFactory clientFactory, AptAmiEventDispatcher dispatcher)
|
||||
{
|
||||
this.properties = properties;
|
||||
this.instanceService = instanceService;
|
||||
this.clientFactory = clientFactory;
|
||||
this.dispatcher = dispatcher;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onApplicationEvent(ApplicationReadyEvent event)
|
||||
{
|
||||
start();
|
||||
}
|
||||
|
||||
/** 启动连接(不阻塞启动流程;3s 后异步建连) */
|
||||
public void start()
|
||||
{
|
||||
if (!properties.getVoice().isEnabled())
|
||||
{
|
||||
log.info("AMI 语音链路未启用(apt.voice.enabled=false),跳过连接");
|
||||
return;
|
||||
}
|
||||
released = false;
|
||||
scheduler.schedule(this::attemptConnect, 3, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
private void attemptConnect()
|
||||
{
|
||||
if (released || !properties.getVoice().isEnabled())
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!connecting.compareAndSet(false, true))
|
||||
{
|
||||
return;
|
||||
}
|
||||
String gyid = instanceService.currentGyid();
|
||||
try
|
||||
{
|
||||
AptAmiInstance config = instanceService.load(gyid);
|
||||
if (config == null)
|
||||
{
|
||||
log.warn("公寓 {} 未配置 Asterisk 实例(apt_ast_instance),AMI 不建连", gyid);
|
||||
return;
|
||||
}
|
||||
instance = config;
|
||||
instanceService.markConnecting(gyid);
|
||||
|
||||
String secret = instanceService.resolveSecret(config);
|
||||
String sessionId = "AS-" + UUID.randomUUID().toString().replace("-", "");
|
||||
AmiClient newClient = clientFactory.create(config, secret);
|
||||
newClient.setSessionId(sessionId);
|
||||
newClient.addListener(dispatcher.forGyid(gyid));
|
||||
newClient.connect();
|
||||
|
||||
client = newClient;
|
||||
amiSessionId = sessionId;
|
||||
instanceService.markUp(gyid);
|
||||
instanceService.openSession(gyid, sessionId, "UP");
|
||||
attempts.set(0);
|
||||
scheduleHealth(gyid, config);
|
||||
log.info("AMI 已就绪 gyid={} instance={} {} 第 {} 次尝试",
|
||||
gyid, config.getInstanceCode(), newClient.describe(), attempts.get());
|
||||
}
|
||||
catch (AmiException e)
|
||||
{
|
||||
handleConnectFailure(gyid, e.getReason());
|
||||
}
|
||||
catch (RuntimeException e)
|
||||
{
|
||||
handleConnectFailure(gyid, "AMI_CONNECT_ERROR");
|
||||
log.warn("AMI 建连异常", e);
|
||||
}
|
||||
finally
|
||||
{
|
||||
connecting.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
private void handleConnectFailure(String gyid, String reason)
|
||||
{
|
||||
String sessionId = amiSessionId;
|
||||
instanceService.markDown(gyid, "DOWN", reason);
|
||||
if (sessionId != null)
|
||||
{
|
||||
instanceService.closeSession(gyid, sessionId, "FAILED", "CONNECT_FAILED", reason);
|
||||
amiSessionId = null;
|
||||
}
|
||||
client = null;
|
||||
scheduleReconnect(gyid);
|
||||
}
|
||||
|
||||
private void scheduleReconnect(String gyid)
|
||||
{
|
||||
if (released)
|
||||
{
|
||||
return;
|
||||
}
|
||||
AptAmiInstance config = instance;
|
||||
int attempt = attempts.getAndIncrement();
|
||||
long base = config == null ? 1000L : config.getReconnectInitialMs();
|
||||
long cap = config == null ? 60000L : config.getReconnectMaxMs();
|
||||
long delay = Math.min(base << Math.min(attempt, 10), cap);
|
||||
// 抖动 ±20%,避免多实例/重启风暴同时打到 Asterisk
|
||||
long jittered = (long) (delay * (0.8 + ThreadLocalRandom.current().nextDouble() * 0.4));
|
||||
log.info("AMI {}ms 后重连(第 {} 次)", jittered, attempt + 1);
|
||||
scheduler.schedule(this::attemptConnect, jittered, TimeUnit.MILLISECONDS);
|
||||
}
|
||||
|
||||
private void scheduleHealth(String gyid, AptAmiInstance config)
|
||||
{
|
||||
cancelHealth();
|
||||
int period = Math.max(5, config.getPingIntervalSec());
|
||||
healthFuture = scheduler.scheduleWithFixedDelay(() -> healthCheck(gyid, config),
|
||||
period, period, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
private void healthCheck(String gyid, AptAmiInstance config)
|
||||
{
|
||||
if (released)
|
||||
{
|
||||
return;
|
||||
}
|
||||
AmiClient current = client;
|
||||
if (current == null || !current.isConnected())
|
||||
{
|
||||
failHealth(gyid, config, "AMI_NOT_CONNECTED");
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
if (current.ping())
|
||||
{
|
||||
instanceService.touchPing(gyid, amiSessionId);
|
||||
return;
|
||||
}
|
||||
failHealth(gyid, config, "AMI_PING_FAILED");
|
||||
}
|
||||
catch (RuntimeException e)
|
||||
{
|
||||
failHealth(gyid, config, e instanceof AmiException
|
||||
? ((AmiException) e).getReason() : "AMI_PING_FAILED");
|
||||
}
|
||||
}
|
||||
|
||||
private void failHealth(String gyid, AptAmiInstance config, String reason)
|
||||
{
|
||||
if (released)
|
||||
{
|
||||
return;
|
||||
}
|
||||
log.warn("AMI 心跳失败,标记 DEGRADED 并重连:{}", reason);
|
||||
cancelHealth();
|
||||
instanceService.markDown(gyid, "DEGRADED", reason);
|
||||
String sessionId = amiSessionId;
|
||||
if (sessionId != null)
|
||||
{
|
||||
instanceService.closeSession(gyid, sessionId, "DEGRADED", "PING_TIMEOUT", reason);
|
||||
amiSessionId = null;
|
||||
}
|
||||
AmiClient current = client;
|
||||
client = null;
|
||||
if (current != null)
|
||||
{
|
||||
current.close("PING_TIMEOUT");
|
||||
}
|
||||
instance = config;
|
||||
scheduleReconnect(gyid);
|
||||
}
|
||||
|
||||
private void cancelHealth()
|
||||
{
|
||||
ScheduledFuture<?> future = healthFuture;
|
||||
if (future != null)
|
||||
{
|
||||
future.cancel(false);
|
||||
healthFuture = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 取当前可用连接;未就绪抛 {@code AMI_NOT_CONNECTED} */
|
||||
public AmiClient current()
|
||||
{
|
||||
AmiClient current = client;
|
||||
if (current == null || !current.isConnected())
|
||||
{
|
||||
throw AmiException.notConnected();
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
public boolean isUp()
|
||||
{
|
||||
AmiClient current = client;
|
||||
return current != null && current.isConnected();
|
||||
}
|
||||
|
||||
public String currentSessionId()
|
||||
{
|
||||
return amiSessionId;
|
||||
}
|
||||
|
||||
public AptAmiInstance currentInstance()
|
||||
{
|
||||
return instance;
|
||||
}
|
||||
|
||||
/** 运维/自检口径(IT-7 的 /apt/ops/selfcheck 第 1 项直接消费本结构) */
|
||||
public Map<String, Object> status()
|
||||
{
|
||||
AmiClient current = client;
|
||||
Map<String, Object> status = new LinkedHashMap<>();
|
||||
status.put("enabled", properties.getVoice().isEnabled());
|
||||
status.put("state", isUp() ? "UP" : (instance == null ? "DOWN" : instance.getState()));
|
||||
status.put("instanceCode", instance == null ? null : instance.getInstanceCode());
|
||||
status.put("endpoint", instance == null ? null : instance.getAmiHost() + ":" + instance.getAmiPort());
|
||||
status.put("connected", isUp());
|
||||
status.put("amiSessionId", amiSessionId);
|
||||
status.put("lastError", current == null ? null : current.getLastError());
|
||||
status.put("reconnectAttempts", attempts.get());
|
||||
status.put("droppedEvents", dispatcher.droppedCount());
|
||||
return status;
|
||||
}
|
||||
|
||||
/** 手工触发重连(运维动作;不影响 released 语义) */
|
||||
public void reconnectNow()
|
||||
{
|
||||
cancelHealth();
|
||||
attempts.set(0);
|
||||
AmiClient current = client;
|
||||
client = null;
|
||||
if (current != null)
|
||||
{
|
||||
current.close("MANUAL_RECONNECT");
|
||||
}
|
||||
scheduler.execute(this::attemptConnect);
|
||||
}
|
||||
|
||||
/**
|
||||
* 读线程报告对端断开:由事件处理器调用(异步线程),立即进入重连流程,
|
||||
* 不必等下一次心跳才发现。
|
||||
*/
|
||||
public void notifyTransportLost(String reason)
|
||||
{
|
||||
if (released)
|
||||
{
|
||||
return;
|
||||
}
|
||||
cancelHealth();
|
||||
AmiClient current = client;
|
||||
client = null;
|
||||
if (current != null)
|
||||
{
|
||||
current.close("PEER_CLOSED");
|
||||
}
|
||||
String gyid = instanceService.currentGyid();
|
||||
instanceService.markDown(gyid, "DEGRADED", reason);
|
||||
String sessionId = amiSessionId;
|
||||
amiSessionId = null;
|
||||
if (sessionId != null)
|
||||
{
|
||||
instanceService.closeSession(gyid, sessionId, "DEGRADED", "IO_ERROR", reason);
|
||||
}
|
||||
scheduleReconnect(gyid);
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放连接:先关连接再落状态,保证「库里是 DOWN 时确实没有连接」。
|
||||
*/
|
||||
public void release(String reason)
|
||||
{
|
||||
released = true;
|
||||
cancelHealth();
|
||||
AmiClient current = client;
|
||||
client = null;
|
||||
if (current != null)
|
||||
{
|
||||
current.close(reason);
|
||||
}
|
||||
String gyid = instanceService.currentGyid();
|
||||
String sessionId = amiSessionId;
|
||||
amiSessionId = null;
|
||||
if (sessionId != null)
|
||||
{
|
||||
instanceService.closeSession(gyid, sessionId, "CLOSED", reason, null);
|
||||
}
|
||||
instanceService.markDown(gyid, "DOWN", null);
|
||||
scheduler.shutdownNow();
|
||||
log.info("AMI 连接已释放:{}", reason);
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
public void onDestroy()
|
||||
{
|
||||
release("SHUTDOWN");
|
||||
}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package com.ruoyi.apt.voice.ami;
|
||||
|
||||
/**
|
||||
* AMI 事件回调。
|
||||
*
|
||||
* <p><b>重要</b>:回调运行在 AMI 读线程上,实现方必须立刻转交到自己的单线程队列
|
||||
* (见 {@code AptAmiEventDispatcher}),不得在此做数据库或阻塞操作,
|
||||
* 否则会拖死整个 AMI 连接(含心跳响应)。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
public interface AmiEventListener
|
||||
{
|
||||
/** 收到一个 Event 帧(不含 Action 响应) */
|
||||
default void onEvent(AmiFrame frame)
|
||||
{
|
||||
}
|
||||
|
||||
/** 连接被动断开(IO 异常 / 对端关闭);主动 close 不触发 */
|
||||
default void onDisconnect(Throwable cause)
|
||||
{
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
package com.ruoyi.apt.voice.ami;
|
||||
|
||||
/**
|
||||
* AMI 协议/连接异常。
|
||||
*
|
||||
* <p>{@code reason} 是脱敏的机器可读原因(不含口令),会落
|
||||
* {@code apt_ast_instance.last_error} 与 {@code apt_ast_ami_session.last_error}。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
public class AmiException extends RuntimeException
|
||||
{
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private final String reason;
|
||||
|
||||
public AmiException(String reason, String message)
|
||||
{
|
||||
super(message);
|
||||
this.reason = reason;
|
||||
}
|
||||
|
||||
public AmiException(String reason, String message, Throwable cause)
|
||||
{
|
||||
super(message, cause);
|
||||
this.reason = reason;
|
||||
}
|
||||
|
||||
public String getReason()
|
||||
{
|
||||
return reason;
|
||||
}
|
||||
|
||||
public static AmiException configMissing(String ref)
|
||||
{
|
||||
return new AmiException("AMI_CONFIG_MISSING", "缺少 AMI 配置或口令引用:" + ref);
|
||||
}
|
||||
|
||||
public static AmiException notConnected()
|
||||
{
|
||||
return new AmiException("AMI_NOT_CONNECTED", "AMI 连接未就绪");
|
||||
}
|
||||
|
||||
public static AmiException authFailed(String message)
|
||||
{
|
||||
return new AmiException("AMI_AUTH_FAILED", "AMI 登录失败:" + message);
|
||||
}
|
||||
|
||||
public static AmiException actionTimeout(String actionId)
|
||||
{
|
||||
return new AmiException("AMI_ACTION_TIMEOUT", "AMI Action 等待响应超时:" + actionId);
|
||||
}
|
||||
|
||||
public static AmiException ioError(Throwable cause)
|
||||
{
|
||||
return new AmiException("AMI_IO_ERROR", "AMI 连接异常:" + cause.getMessage(), cause);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.ruoyi.apt.voice.ami;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* AMI 一帧(Action 响应或 Event)。
|
||||
*
|
||||
* <p>AMI 是纯文本行协议:每条报文由若干 {@code Key: Value} 行组成,以空行结束。
|
||||
* 本类用大小写不敏感的键存储(内部统一小写),保留原始顺序便于排查。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
public class AmiFrame
|
||||
{
|
||||
public static final String KEY_EVENT = "event";
|
||||
public static final String KEY_RESPONSE = "response";
|
||||
public static final String KEY_ACTION_ID = "actionid";
|
||||
public static final String KEY_CHANNEL = "channel";
|
||||
public static final String KEY_UNIQUE_ID = "uniqueid";
|
||||
public static final String KEY_CAUSE = "cause";
|
||||
public static final String KEY_MESSAGE = "message";
|
||||
|
||||
private final Map<String, String> attributes = new LinkedHashMap<>();
|
||||
|
||||
public void put(String key, String value)
|
||||
{
|
||||
if (key == null || key.isBlank())
|
||||
{
|
||||
return;
|
||||
}
|
||||
attributes.put(key.trim().toLowerCase(Locale.ROOT), value == null ? "" : value.trim());
|
||||
}
|
||||
|
||||
public String get(String key)
|
||||
{
|
||||
return key == null ? null : attributes.get(key.toLowerCase(Locale.ROOT));
|
||||
}
|
||||
|
||||
public String get(String key, String defaultValue)
|
||||
{
|
||||
String value = get(key);
|
||||
return value == null || value.isEmpty() ? defaultValue : value;
|
||||
}
|
||||
|
||||
public boolean has(String key)
|
||||
{
|
||||
return get(key) != null;
|
||||
}
|
||||
|
||||
/** Event 名称(如 ConfbridgeJoin);非事件帧返回 null */
|
||||
public String eventName()
|
||||
{
|
||||
return get(KEY_EVENT);
|
||||
}
|
||||
|
||||
/** Response 结果(Success / Error);非响应帧返回 null */
|
||||
public String response()
|
||||
{
|
||||
return get(KEY_RESPONSE);
|
||||
}
|
||||
|
||||
public boolean isEvent()
|
||||
{
|
||||
return has(KEY_EVENT);
|
||||
}
|
||||
|
||||
public boolean isResponse()
|
||||
{
|
||||
return has(KEY_RESPONSE);
|
||||
}
|
||||
|
||||
public boolean isSuccess()
|
||||
{
|
||||
return "Success".equalsIgnoreCase(response());
|
||||
}
|
||||
|
||||
public String actionId()
|
||||
{
|
||||
return get(KEY_ACTION_ID);
|
||||
}
|
||||
|
||||
public Map<String, String> asMap()
|
||||
{
|
||||
return Collections.unmodifiableMap(attributes);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
return (isEvent() ? "Event:" + eventName() : "Response:" + response())
|
||||
+ " actionId=" + actionId();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.ruoyi.apt.voice.ami;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* AMI 文本协议编解码(纯静态,便于单测)。
|
||||
*
|
||||
* <p>编码:{@code Key: Value\r\n} × n + {@code \r\n};
|
||||
* 解码:连续非空行组成一帧,空行结束。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
public final class AmiProtocol
|
||||
{
|
||||
public static final String LINE_SEPARATOR = "\r\n";
|
||||
public static final String FRAME_SEPARATOR = "\r\n\r\n";
|
||||
public static final String BANNER_PREFIX = "Asterisk Call Manager";
|
||||
|
||||
private AmiProtocol()
|
||||
{
|
||||
}
|
||||
|
||||
public static String encode(AmiAction action)
|
||||
{
|
||||
StringBuilder builder = new StringBuilder();
|
||||
for (var entry : action.fields().entrySet())
|
||||
{
|
||||
builder.append(entry.getKey()).append(": ").append(entry.getValue()).append(LINE_SEPARATOR);
|
||||
}
|
||||
builder.append(LINE_SEPARATOR);
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
public static AmiFrame parse(List<String> lines)
|
||||
{
|
||||
AmiFrame frame = new AmiFrame();
|
||||
if (lines == null)
|
||||
{
|
||||
return frame;
|
||||
}
|
||||
for (String line : lines)
|
||||
{
|
||||
int index = line.indexOf(':');
|
||||
if (index <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
frame.put(line.substring(0, index), line.substring(index + 1));
|
||||
}
|
||||
return frame;
|
||||
}
|
||||
|
||||
public static String md5Hex(String text)
|
||||
{
|
||||
try
|
||||
{
|
||||
MessageDigest digest = MessageDigest.getInstance("MD5");
|
||||
byte[] hash = digest.digest(text.getBytes(StandardCharsets.UTF_8));
|
||||
StringBuilder builder = new StringBuilder(hash.length * 2);
|
||||
for (byte value : hash)
|
||||
{
|
||||
builder.append(Character.forDigit((value >> 4) & 0xF, 16));
|
||||
builder.append(Character.forDigit(value & 0xF, 16));
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
catch (NoSuchAlgorithmException e)
|
||||
{
|
||||
throw new IllegalStateException("JVM 不支持 MD5,AMI 挑战认证不可用", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+290
@@ -0,0 +1,290 @@
|
||||
package com.ruoyi.apt.voice.domain;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 公寓 ↔ Asterisk 实例映射与 AMI 连接配置(表 {@code apt_ast_instance},1:1)。
|
||||
*
|
||||
* <p>口令不在本对象里:只保存环境变量引用 {@code amiSecretRef},
|
||||
* 运行时由 {@code AptAmiInstanceService.resolveSecret()} 解析。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
public class AptAmiInstance
|
||||
{
|
||||
private Long id;
|
||||
private String gyid;
|
||||
private String instanceCode;
|
||||
private String displayName;
|
||||
private String amiHost;
|
||||
private int amiPort = 5038;
|
||||
private String amiUser;
|
||||
private String amiSecretRef;
|
||||
private boolean tlsEnabled;
|
||||
private String conference = "Conf-01";
|
||||
private String dialContext = "from-internal";
|
||||
private String dialPrefix;
|
||||
private String channelTech = "PJSIP";
|
||||
private String recordingBaseUrl;
|
||||
private int connectTimeoutMs = 3000;
|
||||
private int actionTimeoutMs = 5000;
|
||||
private int pingIntervalSec = 30;
|
||||
private int pingTimeoutSec = 5;
|
||||
private int reconnectInitialMs = 1000;
|
||||
private int reconnectMaxMs = 60000;
|
||||
private String state = "DOWN";
|
||||
private Date lastConnectedAt;
|
||||
private Date lastPingAt;
|
||||
private String lastError;
|
||||
private boolean enabled = true;
|
||||
|
||||
public Long getId()
|
||||
{
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id)
|
||||
{
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getGyid()
|
||||
{
|
||||
return gyid;
|
||||
}
|
||||
|
||||
public void setGyid(String gyid)
|
||||
{
|
||||
this.gyid = gyid;
|
||||
}
|
||||
|
||||
public String getInstanceCode()
|
||||
{
|
||||
return instanceCode;
|
||||
}
|
||||
|
||||
public void setInstanceCode(String instanceCode)
|
||||
{
|
||||
this.instanceCode = instanceCode;
|
||||
}
|
||||
|
||||
public String getDisplayName()
|
||||
{
|
||||
return displayName;
|
||||
}
|
||||
|
||||
public void setDisplayName(String displayName)
|
||||
{
|
||||
this.displayName = displayName;
|
||||
}
|
||||
|
||||
public String getAmiHost()
|
||||
{
|
||||
return amiHost;
|
||||
}
|
||||
|
||||
public void setAmiHost(String amiHost)
|
||||
{
|
||||
this.amiHost = amiHost;
|
||||
}
|
||||
|
||||
public int getAmiPort()
|
||||
{
|
||||
return amiPort;
|
||||
}
|
||||
|
||||
public void setAmiPort(int 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 boolean isTlsEnabled()
|
||||
{
|
||||
return tlsEnabled;
|
||||
}
|
||||
|
||||
public void setTlsEnabled(boolean tlsEnabled)
|
||||
{
|
||||
this.tlsEnabled = tlsEnabled;
|
||||
}
|
||||
|
||||
public String getConference()
|
||||
{
|
||||
return conference;
|
||||
}
|
||||
|
||||
public void setConference(String conference)
|
||||
{
|
||||
this.conference = conference;
|
||||
}
|
||||
|
||||
public String getDialContext()
|
||||
{
|
||||
return dialContext;
|
||||
}
|
||||
|
||||
public void setDialContext(String dialContext)
|
||||
{
|
||||
this.dialContext = dialContext;
|
||||
}
|
||||
|
||||
public String getDialPrefix()
|
||||
{
|
||||
return dialPrefix;
|
||||
}
|
||||
|
||||
public void setDialPrefix(String dialPrefix)
|
||||
{
|
||||
this.dialPrefix = dialPrefix;
|
||||
}
|
||||
|
||||
public String getChannelTech()
|
||||
{
|
||||
return channelTech;
|
||||
}
|
||||
|
||||
public void setChannelTech(String channelTech)
|
||||
{
|
||||
this.channelTech = channelTech;
|
||||
}
|
||||
|
||||
public String getRecordingBaseUrl()
|
||||
{
|
||||
return recordingBaseUrl;
|
||||
}
|
||||
|
||||
public void setRecordingBaseUrl(String recordingBaseUrl)
|
||||
{
|
||||
this.recordingBaseUrl = recordingBaseUrl;
|
||||
}
|
||||
|
||||
public int getConnectTimeoutMs()
|
||||
{
|
||||
return connectTimeoutMs;
|
||||
}
|
||||
|
||||
public void setConnectTimeoutMs(int connectTimeoutMs)
|
||||
{
|
||||
this.connectTimeoutMs = connectTimeoutMs;
|
||||
}
|
||||
|
||||
public int getActionTimeoutMs()
|
||||
{
|
||||
return actionTimeoutMs;
|
||||
}
|
||||
|
||||
public void setActionTimeoutMs(int actionTimeoutMs)
|
||||
{
|
||||
this.actionTimeoutMs = actionTimeoutMs;
|
||||
}
|
||||
|
||||
public int getPingIntervalSec()
|
||||
{
|
||||
return pingIntervalSec;
|
||||
}
|
||||
|
||||
public void setPingIntervalSec(int pingIntervalSec)
|
||||
{
|
||||
this.pingIntervalSec = pingIntervalSec;
|
||||
}
|
||||
|
||||
public int getPingTimeoutSec()
|
||||
{
|
||||
return pingTimeoutSec;
|
||||
}
|
||||
|
||||
public void setPingTimeoutSec(int pingTimeoutSec)
|
||||
{
|
||||
this.pingTimeoutSec = pingTimeoutSec;
|
||||
}
|
||||
|
||||
public int getReconnectInitialMs()
|
||||
{
|
||||
return reconnectInitialMs;
|
||||
}
|
||||
|
||||
public void setReconnectInitialMs(int reconnectInitialMs)
|
||||
{
|
||||
this.reconnectInitialMs = reconnectInitialMs;
|
||||
}
|
||||
|
||||
public int getReconnectMaxMs()
|
||||
{
|
||||
return reconnectMaxMs;
|
||||
}
|
||||
|
||||
public void setReconnectMaxMs(int reconnectMaxMs)
|
||||
{
|
||||
this.reconnectMaxMs = reconnectMaxMs;
|
||||
}
|
||||
|
||||
public String getState()
|
||||
{
|
||||
return state;
|
||||
}
|
||||
|
||||
public void setState(String state)
|
||||
{
|
||||
this.state = state;
|
||||
}
|
||||
|
||||
public Date getLastConnectedAt()
|
||||
{
|
||||
return lastConnectedAt;
|
||||
}
|
||||
|
||||
public void setLastConnectedAt(Date lastConnectedAt)
|
||||
{
|
||||
this.lastConnectedAt = lastConnectedAt;
|
||||
}
|
||||
|
||||
public Date getLastPingAt()
|
||||
{
|
||||
return lastPingAt;
|
||||
}
|
||||
|
||||
public void setLastPingAt(Date lastPingAt)
|
||||
{
|
||||
this.lastPingAt = lastPingAt;
|
||||
}
|
||||
|
||||
public String getLastError()
|
||||
{
|
||||
return lastError;
|
||||
}
|
||||
|
||||
public void setLastError(String lastError)
|
||||
{
|
||||
this.lastError = lastError;
|
||||
}
|
||||
|
||||
public boolean isEnabled()
|
||||
{
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(boolean enabled)
|
||||
{
|
||||
this.enabled = enabled;
|
||||
}
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
package com.ruoyi.apt.voice.mapper;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.ruoyi.apt.voice.domain.AptAmiInstance;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
/**
|
||||
* 语音链路数据访问(AMI 实例 / 连接会话 / 通话腿 / TTS)。
|
||||
*
|
||||
* <p>硬约束:每条 SQL 必带 gyid;本 Mapper 不写 {@code gy_jcxx} 的任何字段
|
||||
* (叫乘状态仍只由 {@code apt.duty.state} 写)。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
public interface AptVoiceMapper
|
||||
{
|
||||
// ---------------------------------------------------------------- 实例
|
||||
AptAmiInstance selectInstance(@Param("gyid") String gyid);
|
||||
|
||||
int updateInstanceState(@Param("gyid") String gyid, @Param("state") String state,
|
||||
@Param("lastError") String lastError);
|
||||
|
||||
int updateInstanceConnected(@Param("gyid") String gyid);
|
||||
|
||||
int updateInstancePing(@Param("gyid") String gyid);
|
||||
|
||||
// ------------------------------------------------------------ AMI 会话
|
||||
int insertAmiSession(@Param("gyid") String gyid, @Param("amiSessionId") String amiSessionId,
|
||||
@Param("state") String state);
|
||||
|
||||
int closeAmiSession(@Param("gyid") String gyid, @Param("amiSessionId") String amiSessionId,
|
||||
@Param("state") String state, @Param("closeReason") String closeReason,
|
||||
@Param("lastError") String lastError);
|
||||
|
||||
int updateAmiSessionPing(@Param("gyid") String gyid, @Param("amiSessionId") String amiSessionId);
|
||||
|
||||
// ------------------------------------------------------------ 通话会话
|
||||
int insertCallSession(@Param("gyid") String gyid, @Param("sessionId") String sessionId,
|
||||
@Param("jcid") String jcid, @Param("endpointId") String endpointId,
|
||||
@Param("confNo") String confNo, @Param("legRole") String legRole,
|
||||
@Param("channel") String channel,
|
||||
@Param("originateActionId") String originateActionId,
|
||||
@Param("amiSessionId") String amiSessionId,
|
||||
@Param("startedAt") Date startedAt);
|
||||
|
||||
int updateCallSessionState(@Param("gyid") String gyid, @Param("sessionId") String sessionId,
|
||||
@Param("callState") String callState,
|
||||
@Param("answered") boolean answered,
|
||||
@Param("ended") boolean ended,
|
||||
@Param("hangupCause") String hangupCause);
|
||||
|
||||
int bindCallSessionChannel(@Param("gyid") String gyid, @Param("sessionId") String sessionId,
|
||||
@Param("channel") String channel);
|
||||
|
||||
int markSessionsUnknown(@Param("gyid") String gyid, @Param("amiSessionId") String amiSessionId,
|
||||
@Param("cause") String cause);
|
||||
|
||||
Map<String, Object> selectCallSession(@Param("gyid") String gyid,
|
||||
@Param("sessionId") String sessionId);
|
||||
|
||||
Map<String, Object> selectSessionByActionId(@Param("gyid") String gyid,
|
||||
@Param("actionId") String actionId);
|
||||
|
||||
Map<String, Object> selectSessionByChannel(@Param("gyid") String gyid,
|
||||
@Param("channel") String channel);
|
||||
|
||||
// ------------------------------------------------------------ 叫乘候选
|
||||
/** 到点待发起 SIP 叫乘:ryzt=0、未停止、未完成未离寓、jcsj<=now */
|
||||
List<Map<String, Object>> selectDueSipCalls(@Param("gyid") String gyid,
|
||||
@Param("now") Date now,
|
||||
@Param("limit") int limit);
|
||||
|
||||
Map<String, Object> selectJcxxForCall(@Param("gyid") String gyid, @Param("jcid") String jcid);
|
||||
|
||||
/** 房间主分机(分机台账 + 房间绑定,ACTIVE 且在生效期内) */
|
||||
Map<String, Object> selectRoomExtension(@Param("gyid") String gyid,
|
||||
@Param("roomNo") String roomNo);
|
||||
|
||||
// ----------------------------------------------------------------- TTS
|
||||
Long selectNextTtsSeq(@Param("gyid") String gyid, @Param("sessionId") String sessionId);
|
||||
|
||||
int insertTts(@Param("gyid") String gyid, @Param("utteranceId") String utteranceId,
|
||||
@Param("sessionId") String sessionId, @Param("seq") long seq,
|
||||
@Param("textRef") String textRef);
|
||||
|
||||
int updateTts(@Param("gyid") String gyid, @Param("utteranceId") String utteranceId,
|
||||
@Param("state") String state, @Param("audioRef") String audioRef,
|
||||
@Param("errorMsg") String errorMsg);
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
package com.ruoyi.apt.voice.service;
|
||||
|
||||
import java.util.concurrent.ArrayBlockingQueue;
|
||||
import java.util.concurrent.ThreadPoolExecutor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import com.ruoyi.apt.common.AptContext;
|
||||
import com.ruoyi.apt.framework.config.AptProperties;
|
||||
import com.ruoyi.apt.voice.ami.AmiEventListener;
|
||||
import com.ruoyi.apt.voice.ami.AmiFrame;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* AMI 事件串行分发器。
|
||||
*
|
||||
* <p>读线程回调必须"只入队不干活":本类用**单线程 + 有界队列**保证
|
||||
* 同一实例的事件严格按到达顺序处理(ConfbridgeJoin 之后才是 Hangup),
|
||||
* 同时在任务里绑定 {@link AptContext}(后台线程没有 HTTP 上下文)。</p>
|
||||
*
|
||||
* <p>队列满时丢弃并告警(不阻塞读线程),丢弃意味着该事件后由
|
||||
* 「5 分钟巡检 + 状态机幂等」兜底,不会造成状态回退。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
@Component
|
||||
public class AptAmiEventDispatcher
|
||||
{
|
||||
private static final Logger log = LoggerFactory.getLogger(AptAmiEventDispatcher.class);
|
||||
|
||||
private final AptAmiEventHandler handler;
|
||||
private final ThreadPoolExecutor executor;
|
||||
private final AtomicLong dropped = new AtomicLong();
|
||||
|
||||
public AptAmiEventDispatcher(AptAmiEventHandler handler, AptProperties properties)
|
||||
{
|
||||
this.handler = handler;
|
||||
int capacity = Math.max(64, properties.getVoice().getEventQueueCapacity());
|
||||
this.executor = new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS,
|
||||
new ArrayBlockingQueue<>(capacity),
|
||||
runnable -> {
|
||||
Thread thread = new Thread(runnable, "apt-ami-event");
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
},
|
||||
new ThreadPoolExecutor.DiscardPolicy());
|
||||
}
|
||||
|
||||
/** 为指定公寓绑定事件监听器(一公寓一实例,gyid 固定) */
|
||||
public AmiEventListener forGyid(String gyid)
|
||||
{
|
||||
return new AmiEventListener()
|
||||
{
|
||||
@Override
|
||||
public void onEvent(AmiFrame frame)
|
||||
{
|
||||
submit(gyid, () -> handler.onEvent(frame, gyid), "event=" + frame.eventName());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisconnect(Throwable cause)
|
||||
{
|
||||
submit(gyid, () -> handler.onDisconnect(cause, gyid), "disconnect");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private void submit(String gyid, Runnable task, String what)
|
||||
{
|
||||
if (executor.getQueue().remainingCapacity() == 0)
|
||||
{
|
||||
long total = dropped.incrementAndGet();
|
||||
log.warn("AMI 事件队列已满,丢弃 {}(累计丢弃 {})", what, total);
|
||||
return;
|
||||
}
|
||||
executor.execute(() -> runBound(gyid, task));
|
||||
}
|
||||
|
||||
private void runBound(String gyid, Runnable task)
|
||||
{
|
||||
AptContext.setGyId(gyid);
|
||||
AptContext.initTraceId();
|
||||
try
|
||||
{
|
||||
task.run();
|
||||
}
|
||||
catch (RuntimeException e)
|
||||
{
|
||||
log.warn("AMI 事件处理失败 gyid={}", gyid, e);
|
||||
}
|
||||
finally
|
||||
{
|
||||
AptContext.clear();
|
||||
}
|
||||
}
|
||||
|
||||
public long droppedCount()
|
||||
{
|
||||
return dropped.get();
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
public void shutdown()
|
||||
{
|
||||
executor.shutdownNow();
|
||||
}
|
||||
}
|
||||
+273
@@ -0,0 +1,273 @@
|
||||
package com.ruoyi.apt.voice.service;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.Map;
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.ruoyi.apt.framework.config.AptProperties;
|
||||
import com.ruoyi.apt.voice.ami.AmiConnectionManager;
|
||||
import com.ruoyi.apt.voice.ami.AmiFrame;
|
||||
import com.ruoyi.apt.voice.domain.AptSipEventCommand;
|
||||
import com.ruoyi.apt.voice.mapper.AptVoiceMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* AMI 事件 → 会话状态 → 状态机命令(公寓端/05 表 5-13)。
|
||||
*
|
||||
* <p>关键判定:</p>
|
||||
* <ul>
|
||||
* <li><b>房间腿</b>的 {@code ConfbridgeJoin} 才算应答(提交 {@code ANSWERED}),
|
||||
* 值班台腿入会只记会话状态;</li>
|
||||
* <li>{@code OriginateResponse.Failure} 只有房间腿失败才提交 {@code CALL_FAILED};</li>
|
||||
* <li>{@code Hangup} 只归档会话,不回写叫乘终态;</li>
|
||||
* <li>AMI 断连 → 在途会话标 {@code UNKNOWN} + 立即触发重连,不推定叫乘结论。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>本类运行在 {@code apt-ami-event} 单线程队列中,可安全做数据库操作。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
@Service
|
||||
public class AptAmiEventHandler
|
||||
{
|
||||
private static final Logger log = LoggerFactory.getLogger(AptAmiEventHandler.class);
|
||||
|
||||
private static final String LEG_ROOM = "ROOM";
|
||||
private static final String LEG_TTS = "TTS";
|
||||
|
||||
private final AptVoiceMapper voiceMapper;
|
||||
private final IAptVoiceEventService voiceEventService;
|
||||
private final AptAmiInstanceService instanceService;
|
||||
private final AptProperties properties;
|
||||
/** 延迟取用,避免 manager → dispatcher → handler → callFlow → manager 的构造环 */
|
||||
private final ObjectProvider<IAptCallFlowService> callFlowProvider;
|
||||
private final ObjectProvider<AmiConnectionManager> connectionManagerProvider;
|
||||
|
||||
public AptAmiEventHandler(AptVoiceMapper voiceMapper, IAptVoiceEventService voiceEventService,
|
||||
AptAmiInstanceService instanceService, AptProperties properties,
|
||||
ObjectProvider<IAptCallFlowService> callFlowProvider,
|
||||
ObjectProvider<AmiConnectionManager> connectionManagerProvider)
|
||||
{
|
||||
this.voiceMapper = voiceMapper;
|
||||
this.voiceEventService = voiceEventService;
|
||||
this.instanceService = instanceService;
|
||||
this.properties = properties;
|
||||
this.callFlowProvider = callFlowProvider;
|
||||
this.connectionManagerProvider = connectionManagerProvider;
|
||||
}
|
||||
|
||||
public void onEvent(AmiFrame frame, String gyid)
|
||||
{
|
||||
String event = frame.eventName();
|
||||
if (event == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
switch (event)
|
||||
{
|
||||
case "OriginateResponse":
|
||||
onOriginateResponse(frame, gyid);
|
||||
break;
|
||||
case "ConfbridgeJoin":
|
||||
onConfbridgeJoin(frame, gyid);
|
||||
break;
|
||||
case "ConfbridgeLeave":
|
||||
onConfbridgeLeave(frame, gyid);
|
||||
break;
|
||||
case "Hangup":
|
||||
onHangup(frame, gyid);
|
||||
break;
|
||||
default:
|
||||
// Newchannel/PeerStatus/VarSet 等本期不消费,避免事件表被噪声灌满
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void onOriginateResponse(AmiFrame frame, String gyid)
|
||||
{
|
||||
String actionId = frame.actionId();
|
||||
if (actionId == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Map<String, Object> session = voiceMapper.selectSessionByActionId(gyid, actionId);
|
||||
if (session == null)
|
||||
{
|
||||
log.debug("OriginateResponse 未匹配到会话 actionId={}", actionId);
|
||||
return;
|
||||
}
|
||||
String sessionId = str(session.get("sessionId"));
|
||||
String legRole = str(session.get("legRole"));
|
||||
boolean success = "Success".equalsIgnoreCase(frame.get("response"));
|
||||
if (success)
|
||||
{
|
||||
// 绑定 Asterisk 真实通道(PJSIP/1001-0000000a),后续 ConfbridgeJoin/Hangup 用通道反查
|
||||
voiceMapper.bindCallSessionChannel(gyid, sessionId, frame.get("channel"));
|
||||
record(gyid, session, LEG_ROOM.equals(legRole) ? "ORIGINATE_SUCCESS" : "DUTY_ORIGINATE_SUCCESS",
|
||||
frame);
|
||||
return;
|
||||
}
|
||||
String cause = frame.get("cause", frame.get("message", "ORIGINATE_FAILED"));
|
||||
voiceMapper.updateCallSessionState(gyid, sessionId, "FAILED", false, true, cause);
|
||||
if (LEG_ROOM.equals(legRole))
|
||||
{
|
||||
// 房间腿失败 = 无人接听/不可达 → CALL_FAILED(ryzt=2)
|
||||
record(gyid, session, "ORIGINATE_FAILURE", frame);
|
||||
}
|
||||
else
|
||||
{
|
||||
log.warn("非房间腿呼叫失败 leg={} session={} cause={}", legRole, sessionId, cause);
|
||||
record(gyid, session, "DUTY_ORIGINATE_FAILURE", frame);
|
||||
}
|
||||
}
|
||||
|
||||
private void onConfbridgeJoin(AmiFrame frame, String gyid)
|
||||
{
|
||||
Map<String, Object> session = byChannel(gyid, frame);
|
||||
if (session == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
String sessionId = str(session.get("sessionId"));
|
||||
String legRole = str(session.get("legRole"));
|
||||
voiceMapper.updateCallSessionState(gyid, sessionId, "JOINED_CONF", true, false, null);
|
||||
|
||||
if (LEG_ROOM.equals(legRole))
|
||||
{
|
||||
// 只有房间侧入会才判「应答」,并播放 TTS 提示音
|
||||
record(gyid, session, "CONFBRIDGEJOIN", frame);
|
||||
playTtsQuietly(sessionId);
|
||||
}
|
||||
else
|
||||
{
|
||||
record(gyid, session, "DUTY_CONFBRIDGEJOIN", frame);
|
||||
}
|
||||
}
|
||||
|
||||
private void onConfbridgeLeave(AmiFrame frame, String gyid)
|
||||
{
|
||||
Map<String, Object> session = byChannel(gyid, frame);
|
||||
if (session == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
String sessionId = str(session.get("sessionId"));
|
||||
String legRole = str(session.get("legRole"));
|
||||
if (LEG_TTS.equals(legRole))
|
||||
{
|
||||
markTtsPlayed(gyid, session);
|
||||
voiceMapper.updateCallSessionState(gyid, sessionId, "LEFT", false, true, "TTS_DONE");
|
||||
return;
|
||||
}
|
||||
voiceMapper.updateCallSessionState(gyid, sessionId, "LEFT", false, false, null);
|
||||
record(gyid, session, "CONFBRIDGELEAVE", frame);
|
||||
}
|
||||
|
||||
private void onHangup(AmiFrame frame, String gyid)
|
||||
{
|
||||
Map<String, Object> session = byChannel(gyid, frame);
|
||||
if (session == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
String sessionId = str(session.get("sessionId"));
|
||||
String legRole = str(session.get("legRole"));
|
||||
String cause = frame.get("cause", "NORMAL_CLEARING");
|
||||
if (LEG_TTS.equals(legRole))
|
||||
{
|
||||
markTtsPlayed(gyid, session);
|
||||
}
|
||||
voiceMapper.updateCallSessionState(gyid, sessionId, "HANGUP", false, true, cause);
|
||||
record(gyid, session, LEG_ROOM.equals(legRole) ? "HANGUP" : "DUTY_HANGUP", frame);
|
||||
}
|
||||
|
||||
/** AMI 断连:在途会话标 UNKNOWN,并通知连接管理器立刻重连 */
|
||||
public void onDisconnect(Throwable cause, String gyid)
|
||||
{
|
||||
String reason = cause == null ? "AMI_PEER_CLOSED" : cause.getMessage();
|
||||
AmiConnectionManager manager = connectionManagerProvider.getIfAvailable();
|
||||
String amiSessionId = manager == null ? null : manager.currentSessionId();
|
||||
if (amiSessionId != null)
|
||||
{
|
||||
instanceService.markSessionsUnknown(gyid, amiSessionId, "AMI_DISCONNECT:" + reason);
|
||||
}
|
||||
if (manager != null)
|
||||
{
|
||||
manager.notifyTransportLost(reason);
|
||||
}
|
||||
log.warn("AMI 断连已处理 gyid={} reason={}", gyid, reason);
|
||||
}
|
||||
|
||||
private void playTtsQuietly(String sessionId)
|
||||
{
|
||||
try
|
||||
{
|
||||
IAptCallFlowService callFlow = callFlowProvider.getIfAvailable();
|
||||
if (callFlow == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
callFlow.playTts(sessionId, properties.getVoice().getTtsText());
|
||||
}
|
||||
catch (RuntimeException e)
|
||||
{
|
||||
// TTS 失败只告警,不改叫乘结论
|
||||
log.warn("TTS 触发失败 session={}", sessionId, e);
|
||||
}
|
||||
}
|
||||
|
||||
private void markTtsPlayed(String gyid, Map<String, Object> session)
|
||||
{
|
||||
String actionId = str(session.get("originateActionId"));
|
||||
if (actionId.startsWith("TTS-"))
|
||||
{
|
||||
voiceMapper.updateTts(gyid, actionId.substring(4), "PLAYED", null, null);
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> byChannel(String gyid, AmiFrame frame)
|
||||
{
|
||||
String channel = frame.get("channel");
|
||||
if (channel == null || channel.isBlank())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
Map<String, Object> session = voiceMapper.selectSessionByChannel(gyid, channel);
|
||||
if (session == null)
|
||||
{
|
||||
log.debug("事件 {} 未匹配到会话 channel={}", frame.eventName(), channel);
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
private void record(String gyid, Map<String, Object> session, String eventType, AmiFrame frame)
|
||||
{
|
||||
String jcid = str(session.get("jcid"));
|
||||
if (jcid.isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
AptSipEventCommand command = new AptSipEventCommand();
|
||||
command.setJcid(jcid);
|
||||
command.setSessionId(str(session.get("sessionId")));
|
||||
command.setEventType(eventType);
|
||||
command.setOccurredAt(new Date());
|
||||
command.setPayload(JSON.toJSONString(frame.asMap()));
|
||||
try
|
||||
{
|
||||
voiceEventService.handle(command);
|
||||
}
|
||||
catch (RuntimeException e)
|
||||
{
|
||||
// 事件已落库,状态命令失败不应阻塞后续事件处理
|
||||
log.warn("AMI 事件落库/命令失败 event={} jcid={}", eventType, jcid, e);
|
||||
}
|
||||
}
|
||||
|
||||
private String str(Object value)
|
||||
{
|
||||
return value == null ? "" : String.valueOf(value);
|
||||
}
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
package com.ruoyi.apt.voice.service;
|
||||
|
||||
import com.ruoyi.apt.common.AptContext;
|
||||
import com.ruoyi.apt.framework.config.AptProperties;
|
||||
import com.ruoyi.apt.voice.ami.AmiException;
|
||||
import com.ruoyi.apt.voice.domain.AptAmiInstance;
|
||||
import com.ruoyi.apt.voice.mapper.AptVoiceMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* 公寓-Asterisk 实例配置与连接状态落库。
|
||||
*
|
||||
* <p>口令解析规则:表里只存环境变量名(如 {@code APT_AMI_SECRET}),
|
||||
* 运行时从进程环境读取;读不到即 {@code AMI_CONFIG_MISSING},
|
||||
* 不回落默认口令、不写入日志(红线:AMI 口令不进仓库、不落库)。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
@Service
|
||||
public class AptAmiInstanceService
|
||||
{
|
||||
private static final Logger log = LoggerFactory.getLogger(AptAmiInstanceService.class);
|
||||
|
||||
private final AptVoiceMapper voiceMapper;
|
||||
private final AptProperties properties;
|
||||
|
||||
public AptAmiInstanceService(AptVoiceMapper voiceMapper, AptProperties properties)
|
||||
{
|
||||
this.voiceMapper = voiceMapper;
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
/** 部署态 gyid:请求线程优先取上下文,后台线程回落 apt.gy-id(D5 一进程一公寓) */
|
||||
public String currentGyid()
|
||||
{
|
||||
String contextGyid = AptContext.getGyIdOrNull();
|
||||
return contextGyid != null ? contextGyid : properties.getGyId();
|
||||
}
|
||||
|
||||
public AptAmiInstance load(String gyid)
|
||||
{
|
||||
return voiceMapper.selectInstance(gyid);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 AMI 口令。只接受环境变量注入,缺失即抛错,绝不使用默认值。
|
||||
*/
|
||||
public String resolveSecret(AptAmiInstance instance)
|
||||
{
|
||||
String ref = instance.getAmiSecretRef();
|
||||
if (ref == null || ref.isBlank())
|
||||
{
|
||||
throw AmiException.configMissing("ami_secret_ref 为空");
|
||||
}
|
||||
String secret = System.getenv(ref);
|
||||
if (secret == null || secret.isBlank())
|
||||
{
|
||||
throw AmiException.configMissing(ref);
|
||||
}
|
||||
return secret;
|
||||
}
|
||||
|
||||
public void markConnecting(String gyid)
|
||||
{
|
||||
voiceMapper.updateInstanceState(gyid, "CONNECTING", null);
|
||||
}
|
||||
|
||||
public void markUp(String gyid)
|
||||
{
|
||||
voiceMapper.updateInstanceConnected(gyid);
|
||||
}
|
||||
|
||||
public void markDown(String gyid, String state, String error)
|
||||
{
|
||||
voiceMapper.updateInstanceState(gyid, state, truncate(error));
|
||||
log.warn("AMI 实例状态 -> {},原因:{}", state, truncate(error));
|
||||
}
|
||||
|
||||
public void touchPing(String gyid, String amiSessionId)
|
||||
{
|
||||
voiceMapper.updateInstancePing(gyid);
|
||||
if (amiSessionId != null)
|
||||
{
|
||||
voiceMapper.updateAmiSessionPing(gyid, amiSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
public void openSession(String gyid, String amiSessionId, String state)
|
||||
{
|
||||
voiceMapper.insertAmiSession(gyid, amiSessionId, state);
|
||||
}
|
||||
|
||||
public void closeSession(String gyid, String amiSessionId, String state,
|
||||
String closeReason, String error)
|
||||
{
|
||||
voiceMapper.closeAmiSession(gyid, amiSessionId, state, closeReason, truncate(error));
|
||||
}
|
||||
|
||||
public void markSessionsUnknown(String gyid, String amiSessionId, String cause)
|
||||
{
|
||||
int rows = voiceMapper.markSessionsUnknown(gyid, amiSessionId, truncate(cause));
|
||||
if (rows > 0)
|
||||
{
|
||||
log.warn("AMI 断连:{} 条在途会话标记 UNKNOWN(session={})", rows, amiSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
private String truncate(String value)
|
||||
{
|
||||
if (value == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return value.length() <= 256 ? value : value.substring(0, 256);
|
||||
}
|
||||
}
|
||||
+330
@@ -0,0 +1,330 @@
|
||||
package com.ruoyi.apt.voice.service;
|
||||
|
||||
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.AptStateSnapshot;
|
||||
import com.ruoyi.apt.dispatch.service.IAptDutyStateService;
|
||||
import com.ruoyi.apt.framework.config.AptProperties;
|
||||
import com.ruoyi.apt.voice.ami.AmiAction;
|
||||
import com.ruoyi.apt.voice.ami.AmiClient;
|
||||
import com.ruoyi.apt.voice.ami.AmiConnectionManager;
|
||||
import com.ruoyi.apt.voice.ami.AmiException;
|
||||
import com.ruoyi.apt.voice.domain.AptAmiInstance;
|
||||
import com.ruoyi.apt.voice.mapper.AptVoiceMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* SIP 叫乘执行实现(公寓端/05 表 5-12 步骤 2~7)。
|
||||
*
|
||||
* <p>调用顺序(缺一不可):</p>
|
||||
* <pre>
|
||||
* 校验 ryzt=0 → 解析房间分机 → CALL_START(0→1) → 建腿(apt_call_session)
|
||||
* → AMI Originate(房间 + 值班台)→ 等 OriginateResponse 绑定真实通道
|
||||
* → ConfbridgeJoin(房间腿) → ANSWERED(1→3) → TTS 播报
|
||||
* </pre>
|
||||
*
|
||||
* <p>失败语义:房间腿 Originate 失败 → {@code CALL_FAILED}(ryzt=2);
|
||||
* 值班台腿失败只告警,不影响房间侧结论;TTS 失败只告警,不改叫乘结论。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
@Service
|
||||
public class AptCallFlowServiceImpl implements IAptCallFlowService
|
||||
{
|
||||
private static final Logger log = LoggerFactory.getLogger(AptCallFlowServiceImpl.class);
|
||||
|
||||
private static final String LEG_ROOM = "ROOM";
|
||||
private static final String LEG_DUTY = "DUTY";
|
||||
private static final String LEG_TTS = "TTS";
|
||||
|
||||
private final AptVoiceMapper voiceMapper;
|
||||
private final IAptDutyStateService stateService;
|
||||
private final IAptVoiceEventService voiceEventService;
|
||||
private final AmiConnectionManager connectionManager;
|
||||
private final AptProperties properties;
|
||||
|
||||
public AptCallFlowServiceImpl(AptVoiceMapper voiceMapper, IAptDutyStateService stateService,
|
||||
IAptVoiceEventService voiceEventService,
|
||||
AmiConnectionManager connectionManager, AptProperties properties)
|
||||
{
|
||||
this.voiceMapper = voiceMapper;
|
||||
this.stateService = stateService;
|
||||
this.voiceEventService = voiceEventService;
|
||||
this.connectionManager = connectionManager;
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> dial(String jcid)
|
||||
{
|
||||
String gyid = gyid();
|
||||
Map<String, Object> jcxx = voiceMapper.selectJcxxForCall(gyid, jcid);
|
||||
if (jcxx == null)
|
||||
{
|
||||
throw new AptException(AptErrorCode.NOT_FOUND, "JCXX_NOT_FOUND", "叫乘记录不存在:" + jcid);
|
||||
}
|
||||
if (!"SIP".equals(str(jcxx.get("callMode"))))
|
||||
{
|
||||
throw new AptException(AptErrorCode.BAD_REQUEST, "CALL_MODE_NOT_SIP",
|
||||
"该叫乘为平板链路,不可走 SIP 呼叫");
|
||||
}
|
||||
if ("1".equals(str(jcxx.get("finishJc"))) || "1".equals(str(jcxx.get("finishLy"))))
|
||||
{
|
||||
throw new AptException(AptErrorCode.STATE_ILLEGAL, "CALL_ALREADY_FINISHED",
|
||||
"该叫乘已完成或已离寓,不可再呼叫");
|
||||
}
|
||||
AptStateSnapshot snapshot = stateService.snapshot(jcid);
|
||||
if (snapshot == null)
|
||||
{
|
||||
throw new AptException(AptErrorCode.NOT_FOUND, "JCXX_NOT_FOUND", "叫乘记录不存在:" + jcid);
|
||||
}
|
||||
if (!"0".equals(snapshot.getRyzt()) || "1".equals(snapshot.getStoped()))
|
||||
{
|
||||
throw new AptException(AptErrorCode.STATE_ILLEGAL, "CALL_ALREADY_STARTED",
|
||||
"仅 ryzt=0 且未停止的叫乘可发起呼叫,当前 ryzt=" + snapshot.getRyzt());
|
||||
}
|
||||
if (!connectionManager.isUp())
|
||||
{
|
||||
throw new AptException(AptErrorCode.DEPENDENCY_UNAVAILABLE, "AMI_NOT_CONNECTED",
|
||||
"AMI 连接未就绪,无法发起语音叫乘");
|
||||
}
|
||||
AmiClient ami = connectionManager.current();
|
||||
AptAmiInstance instance = connectionManager.currentInstance();
|
||||
if (instance == null)
|
||||
{
|
||||
throw new AptException(AptErrorCode.DEPENDENCY_UNAVAILABLE, "AMI_INSTANCE_MISSING",
|
||||
"公寓未配置 Asterisk 实例");
|
||||
}
|
||||
String roomNo = str(jcxx.get("fjh"));
|
||||
Map<String, Object> roomEndpoint = voiceMapper.selectRoomExtension(gyid, roomNo);
|
||||
if (roomEndpoint == null)
|
||||
{
|
||||
throw new AptException(AptErrorCode.NOT_FOUND, "ENDPOINT_NOT_FOUND",
|
||||
"房间 " + roomNo + " 未绑定可用分机(apt_sip_binding / apt_sip_endpoint)");
|
||||
}
|
||||
|
||||
// 先落状态机再发起呼叫:即使 Originate 立即失败,也会经 CALL_FAILED 收敛到 ryzt=2
|
||||
stateService.callStart(jcid);
|
||||
|
||||
String confNo = instance.getConference();
|
||||
List<Map<String, Object>> legs = new ArrayList<>();
|
||||
String roomSessionId = newSessionId();
|
||||
boolean roomStarted = originateLeg(ami, instance, gyid, roomSessionId, jcid, confNo,
|
||||
LEG_ROOM, str(roomEndpoint.get("endpointId")), str(roomEndpoint.get("extension")));
|
||||
legs.add(legSummary(roomSessionId, LEG_ROOM, str(roomEndpoint.get("extension")), roomStarted));
|
||||
if (!roomStarted)
|
||||
{
|
||||
stateService.callFailed(jcid);
|
||||
throw new AptException(AptErrorCode.DEPENDENCY_UNAVAILABLE, "AMI_ORIGINATE_FAILED",
|
||||
"房间分机呼叫发起失败:" + roomNo);
|
||||
}
|
||||
|
||||
int index = 0;
|
||||
for (String dutyExtension : properties.getVoice().getDutyExtensions())
|
||||
{
|
||||
if (dutyExtension == null || dutyExtension.isBlank())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
index++;
|
||||
String dutySessionId = newSessionId() + "-D" + index;
|
||||
boolean dutyStarted = originateLeg(ami, instance, gyid, dutySessionId, jcid, confNo,
|
||||
LEG_DUTY, null, dutyExtension.trim());
|
||||
if (!dutyStarted)
|
||||
{
|
||||
log.warn("值班台分机 {} 呼叫失败,不影响房间侧结论(jcid={})", dutyExtension, jcid);
|
||||
}
|
||||
legs.add(legSummary(dutySessionId, LEG_DUTY, dutyExtension.trim(), dutyStarted));
|
||||
}
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("jcid", jcid);
|
||||
result.put("confNo", confNo);
|
||||
result.put("instanceCode", instance.getInstanceCode());
|
||||
result.put("legs", legs);
|
||||
result.put("state", "CALLING");
|
||||
result.put("traceId", AptContext.getTraceId());
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 发起一条呼叫腿:先落 apt_call_session(INIT),再发 AMI Originate */
|
||||
private boolean originateLeg(AmiClient ami, AptAmiInstance instance, String gyid, String sessionId,
|
||||
String jcid, String confNo, String legRole, String endpointId,
|
||||
String extension)
|
||||
{
|
||||
String channel = instance.getChannelTech() + "/" + extension;
|
||||
try
|
||||
{
|
||||
voiceMapper.insertCallSession(gyid, sessionId, jcid, endpointId, confNo, legRole,
|
||||
null, sessionId, connectionManager.currentSessionId(), new Date());
|
||||
AmiAction originate = AmiAction.originate(channel, instance.getDialContext(),
|
||||
confNo, properties.getVoice().getDialTimeoutMs()).actionId(sessionId);
|
||||
var response = ami.sendAction(originate);
|
||||
if (!response.isSuccess())
|
||||
{
|
||||
String message = response.get("message", "Originate 被拒绝");
|
||||
voiceMapper.updateCallSessionState(gyid, sessionId, "FAILED", false, true, message);
|
||||
log.warn("Originate 失败 leg={} channel={} message={}", legRole, channel, message);
|
||||
return false;
|
||||
}
|
||||
voiceMapper.updateCallSessionState(gyid, sessionId, "CALLING", false, false, null);
|
||||
return true;
|
||||
}
|
||||
catch (AmiException e)
|
||||
{
|
||||
voiceMapper.updateCallSessionState(gyid, sessionId, "FAILED", false, true, e.getReason());
|
||||
log.warn("Originate 异常 leg={} channel={} reason={}", legRole, channel, e.getReason());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int dialDue(int limit)
|
||||
{
|
||||
if (!properties.getVoice().isEnabled() || !properties.getVoice().isSipCallEnabled())
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
if (!connectionManager.isUp())
|
||||
{
|
||||
log.debug("AMI 未就绪,跳过本轮 SIP 叫乘发起");
|
||||
return 0;
|
||||
}
|
||||
String gyid = gyid();
|
||||
List<Map<String, Object>> due = voiceMapper.selectDueSipCalls(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
|
||||
{
|
||||
dial(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)
|
||||
{
|
||||
String gyid = gyid();
|
||||
Map<String, Object> session = voiceMapper.selectCallSession(gyid, sessionId);
|
||||
if (session == null)
|
||||
{
|
||||
throw new AptException(AptErrorCode.NOT_FOUND, "CALL_SESSION_NOT_FOUND",
|
||||
"通话会话不存在:" + sessionId);
|
||||
}
|
||||
String content = text == null || text.isBlank() ? properties.getVoice().getTtsText() : text;
|
||||
Long nextSeq = voiceMapper.selectNextTtsSeq(gyid, sessionId);
|
||||
long seq = nextSeq == null ? 1L : nextSeq;
|
||||
String utteranceId = "TTS-" + UUID.randomUUID().toString().replace("-", "");
|
||||
String mediaRef = properties.getVoice().getTtsMediaPrefix();
|
||||
|
||||
voiceMapper.insertTts(gyid, utteranceId, sessionId, seq, content);
|
||||
String legSessionId = sessionId + "-T" + seq;
|
||||
String localChannel = "Local/" + properties.getVoice().getTtsExtension()
|
||||
+ "@" + properties.getVoice().getTtsContext();
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("utteranceId", utteranceId);
|
||||
result.put("sessionId", sessionId);
|
||||
result.put("seq", seq);
|
||||
result.put("textRef", content);
|
||||
try
|
||||
{
|
||||
voiceMapper.insertCallSession(gyid, legSessionId, str(session.get("jcid")),
|
||||
null, str(session.get("confNo")), LEG_TTS, localChannel,
|
||||
"TTS-" + utteranceId, connectionManager.currentSessionId(), new Date());
|
||||
AmiAction action = AmiAction.originateApp(localChannel, "Playback", mediaRef,
|
||||
properties.getVoice().getDialTimeoutMs()).actionId("TTS-" + utteranceId);
|
||||
var response = connectionManager.current().sendAction(action);
|
||||
if (response.isSuccess())
|
||||
{
|
||||
voiceMapper.updateTts(gyid, utteranceId, "SENT", mediaRef, null);
|
||||
voiceMapper.updateCallSessionState(gyid, legSessionId, "CALLING", false, false, null);
|
||||
result.put("state", "SENT");
|
||||
}
|
||||
else
|
||||
{
|
||||
String message = response.get("message", "Playback Originate 被拒绝");
|
||||
voiceMapper.updateTts(gyid, utteranceId, "FAILED", mediaRef, message);
|
||||
result.put("state", "FAILED");
|
||||
result.put("error", message);
|
||||
}
|
||||
}
|
||||
catch (RuntimeException e)
|
||||
{
|
||||
// TTS 失败不阻断叫乘结论(05 表 5-12 第 7 行)
|
||||
String message = e instanceof AmiException ? ((AmiException) e).getReason() : e.getMessage();
|
||||
voiceMapper.updateTts(gyid, utteranceId, "FAILED", mediaRef, message);
|
||||
log.warn("TTS 播报失败 utterance={} session={}:{}", utteranceId, sessionId, message);
|
||||
result.put("state", "FAILED");
|
||||
result.put("error", message);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void hangup(String sessionId, String cause)
|
||||
{
|
||||
String gyid = gyid();
|
||||
Map<String, Object> session = voiceMapper.selectCallSession(gyid, sessionId);
|
||||
if (session == null)
|
||||
{
|
||||
throw new AptException(AptErrorCode.NOT_FOUND, "CALL_SESSION_NOT_FOUND",
|
||||
"通话会话不存在:" + sessionId);
|
||||
}
|
||||
String channel = str(session.get("channel"));
|
||||
if (channel.isEmpty())
|
||||
{
|
||||
throw new AptException(AptErrorCode.STATE_ILLEGAL, "CHANNEL_UNKNOWN",
|
||||
"会话尚未绑定 Asterisk 通道,无法挂断:" + sessionId);
|
||||
}
|
||||
connectionManager.current().sendAction(AmiAction.hangup(channel, cause));
|
||||
voiceMapper.updateCallSessionState(gyid, sessionId, "HANGUP", false, true, cause);
|
||||
}
|
||||
|
||||
private Map<String, Object> legSummary(String sessionId, String legRole, String extension,
|
||||
boolean started)
|
||||
{
|
||||
Map<String, Object> leg = new LinkedHashMap<>();
|
||||
leg.put("sessionId", sessionId);
|
||||
leg.put("legRole", legRole);
|
||||
leg.put("extension", extension);
|
||||
leg.put("started", started);
|
||||
return leg;
|
||||
}
|
||||
|
||||
private String newSessionId()
|
||||
{
|
||||
return "CS-" + UUID.randomUUID().toString().replace("-", "");
|
||||
}
|
||||
|
||||
private String str(Object value)
|
||||
{
|
||||
return value == null ? "" : String.valueOf(value);
|
||||
}
|
||||
|
||||
private String gyid()
|
||||
{
|
||||
return AptContext.getGyId();
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package com.ruoyi.apt.voice.service;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* SIP 叫乘执行链路(Originate → 状态跟踪 → TTS)。
|
||||
*
|
||||
* <p>只提交状态机命令,不直写 {@code gy_jcxx};一条 jcid 双路呼叫(房间 + 值班台),
|
||||
* 房间腿的 {@code ConfbridgeJoin} 才判「应答」。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
public interface IAptCallFlowService
|
||||
{
|
||||
/** 对单条叫乘发起 SIP 双路呼叫(要求 ryzt=0 且 AMI 已就绪) */
|
||||
Map<String, Object> dial(String jcid);
|
||||
|
||||
/** 扫描到点待叫乘并逐条发起呼叫,返回成功发起数 */
|
||||
int dialDue(int limit);
|
||||
|
||||
/** 在会话内播放 TTS 提示语音;失败只告警,不改叫乘结论 */
|
||||
Map<String, Object> playTts(String sessionId, String text);
|
||||
|
||||
/** 挂断某条呼叫腿 */
|
||||
void hangup(String sessionId, String cause);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
# 公寓端模块专属配置(ruoyi-apt)
|
||||
# 由 ruoyi-admin/src/main/resources/application.yml 的
|
||||
# spring.config.import: 'optional:classpath:apt-defaults.yml'
|
||||
# 引入;主配置中同键优先级更高,本文件只放公寓端自有配置项。
|
||||
# optional: 前缀保证 ruoyi-apt 不在 classpath 时后台仍能启动。
|
||||
|
||||
# 公寓端日志级别
|
||||
logging:
|
||||
level:
|
||||
com.ruoyi.apt: info
|
||||
|
||||
# springdoc 分组:四个受管前缀中公寓端实际提供的三个
|
||||
springdoc:
|
||||
group-configs:
|
||||
- group: 'apt'
|
||||
display-name: '公寓管理端 /apt'
|
||||
paths-to-match: '/apt/**'
|
||||
packages-to-scan: com.ruoyi.apt
|
||||
- group: 'duty'
|
||||
display-name: '值班台 /duty'
|
||||
paths-to-match: '/duty/**'
|
||||
packages-to-scan: com.ruoyi.apt
|
||||
- group: 'open'
|
||||
display-name: '设备侧 /open/v1'
|
||||
paths-to-match: '/open/v1/**'
|
||||
packages-to-scan: com.ruoyi.apt
|
||||
# /platform/** 属平台管理端,公寓端不提供,故不分组
|
||||
|
||||
# 公寓端基座配置,绑定 AptProperties(com.ruoyi.apt.framework.config)
|
||||
apt:
|
||||
# 一进程一 gyid(D5):只从部署环境注入,不接受请求参数
|
||||
gy-id: ${APT_GY_ID:APT-REV01-001}
|
||||
probe:
|
||||
enabled: ${APT_PROBE_ENABLED:true}
|
||||
# 单次探针超时(毫秒),超过即判定 TIMEOUT
|
||||
timeout-ms: ${APT_PROBE_TIMEOUT_MS:2000}
|
||||
redis-key-prefix: ${APT_REDIS_KEY_PREFIX:apt:rev01:}
|
||||
# 探针数据存活时间(秒),避免残留脏数据
|
||||
redis-ttl-seconds: 60
|
||||
# 探针表名(只写探针专用表,不触碰任何业务表)
|
||||
table: apt_ops_probe
|
||||
page:
|
||||
max-size: 100
|
||||
master-client:
|
||||
# 表 9-16:Mock 主数据下行仅 local profile 可开启;生产强制 false(启动校验)
|
||||
mock: ${APT_MASTER_CLIENT_MOCK:false}
|
||||
# IT-3 设备侧鉴权(表 10-24 #1):仅作用于 /open/v1/**,不影 /apt/** 与 /duty/**
|
||||
device:
|
||||
# 生产必须 true;本地联调可设 APT_DEVICE_HMAC_ENABLED=false 退化为只校验设备编码
|
||||
hmac-enabled: ${APT_DEVICE_HMAC_ENABLED:true}
|
||||
# HMAC-SHA256 共享密钥,只从部署环境注入,不落库、不打日志
|
||||
hmac-secret: ${APT_DEVICE_HMAC_SECRET:}
|
||||
# 允许的时钟偏移(秒)
|
||||
time-window-seconds: 300
|
||||
# nonce 去重窗口(秒),须不小于时间窗
|
||||
nonce-ttl-seconds: 600
|
||||
# 特征值算法白名单:不在名单内允许入档但不下发设备(40921)
|
||||
face-algo-whitelist:
|
||||
- FEAT_V1
|
||||
- FEAT_V2
|
||||
- PHOTO_REF
|
||||
# 媒体大小上限(字节),超限 40922
|
||||
photo-max-bytes: 524288
|
||||
feature-max-bytes: 65536
|
||||
# IT-5 能力开关(05 表 5-18 / CR-C23)
|
||||
features:
|
||||
# 平板叫乘总开关;false 时任务不生成,TABLET 记录回落 SIP
|
||||
tablet-call: ${APT_FEATURE_TABLET_CALL:true}
|
||||
# #24 语音质量摘要只由 SIP 链路发送(平板链路硬性不发)
|
||||
voice-summary: ${APT_FEATURE_VOICE_SUMMARY:true}
|
||||
# IT-5 叫乘执行参数(05 §9、Q-29 默认口径)
|
||||
dispatch:
|
||||
# 平板刷脸签到窗口(秒)
|
||||
sign-timeout-sec: ${APT_SIGN_TIMEOUT_SEC:60}
|
||||
# 平板时钟偏差阈值(毫秒),超过需按 serverTime 重拉
|
||||
clock-skew-threshold-ms: ${APT_CLOCK_SKEW_MS:1000}
|
||||
# SIP 无入会转叫乘报警(秒)
|
||||
sip-no-answer-seconds: ${APT_SIP_NO_ANSWER_SEC:30}
|
||||
# 复叫间隔(秒),仅供提示音与值班台提示,不触发自动状态迁移
|
||||
recall-seconds: ${APT_RECALL_SEC:35}
|
||||
# 平板任务过期时间(秒,自 jcsj 起算)
|
||||
tablet-task-ttl-seconds: ${APT_TABLET_TASK_TTL_SEC:900}
|
||||
# 单次扫描批大小
|
||||
scan-batch-size: ${APT_SCAN_BATCH:50}
|
||||
tts-template: ${APT_TTS_TEMPLATE:CALL_ROOM}
|
||||
tts-text: ${APT_TTS_TEXT:请到门口集合,准备乘车}
|
||||
File renamed without changes.
+37
@@ -0,0 +1,37 @@
|
||||
<?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.master.mapper.AptMasterVersionMapper">
|
||||
|
||||
<!--
|
||||
IT-2 / 任务 5 主数据快照版本(公寓端/04 表 4-17 #1、09 表 9-16)。
|
||||
只读;每条 SQL 必带 gyid;按业务字段 domain 排序,禁止按代理主键排序。
|
||||
itemCount 用固定表名子查询统计,避免动态拼接表名。
|
||||
-->
|
||||
<sql id="versionColumns">
|
||||
v.id, v.gyid, v.domain,
|
||||
v.source_version AS sourceVersion, v.source, v.state,
|
||||
v.applied_at AS appliedAt, v.remark,
|
||||
CASE v.domain
|
||||
WHEN 'APARTMENT' THEN (SELECT COUNT(*) FROM apt_master_apartment t WHERE t.gyid = v.gyid AND t.deleted = 0)
|
||||
WHEN 'ROOM' THEN (SELECT COUNT(*) FROM apt_master_room t WHERE t.gyid = v.gyid AND t.deleted = 0)
|
||||
WHEN 'PERSON' THEN (SELECT COUNT(*) FROM apt_master_person t WHERE t.gyid = v.gyid AND t.deleted = 0)
|
||||
WHEN 'ROUTE' THEN (SELECT COUNT(*) FROM apt_master_route t WHERE t.gyid = v.gyid AND t.deleted = 0)
|
||||
WHEN 'RULE' THEN (SELECT COUNT(*) FROM apt_master_rule t WHERE t.gyid = v.gyid AND t.deleted = 0)
|
||||
WHEN 'HOLIDAY' THEN (SELECT COUNT(*) FROM apt_holiday t WHERE t.gyid = v.gyid AND t.deleted = 0)
|
||||
ELSE 0 END AS itemCount
|
||||
</sql>
|
||||
|
||||
<select id="selectVersions" resultType="com.ruoyi.apt.master.domain.AptMasterVersion">
|
||||
SELECT <include refid="versionColumns"/>
|
||||
FROM apt_master_version v
|
||||
WHERE v.gyid = #{gyid} AND v.deleted = 0
|
||||
ORDER BY v.domain ASC
|
||||
</select>
|
||||
|
||||
<select id="selectVersionByDomain" resultType="com.ruoyi.apt.master.domain.AptMasterVersion">
|
||||
SELECT <include refid="versionColumns"/>
|
||||
FROM apt_master_version v
|
||||
WHERE v.gyid = #{gyid} AND v.domain = #{domain} AND v.deleted = 0
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
@@ -0,0 +1,173 @@
|
||||
<?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.stay.mapper.AptRoomMapper">
|
||||
|
||||
<!--
|
||||
IT-2 / M2 房间档案与房态(公寓端/04 表 4-7)。
|
||||
硬约束:每条 SQL 必带 gyid;列表按业务字段默认排序,禁止按代理主键排序;
|
||||
gy_rooms 为运行时事实,apt_master_room 为平台只读基线,二者靠 (gyid, room_id) 左连。
|
||||
room_status 只由本文件的条件更新写入(管理端提交命令,领域服务重算)。
|
||||
-->
|
||||
<sql id="roomColumns">
|
||||
r.id, r.gyid,
|
||||
r.room_id AS roomId, r.room_no AS roomNo, r.room_floor AS roomFloor,
|
||||
COALESCE(r.room_dir, m.orientation) AS roomDir,
|
||||
r.room_face AS roomFace,
|
||||
r.bed_numbers AS bedNumbers,
|
||||
r.room_status AS roomStatus,
|
||||
CASE r.room_status
|
||||
WHEN '1' THEN '空闲'
|
||||
WHEN '2' THEN '住满'
|
||||
WHEN '3' THEN '待清扫'
|
||||
WHEN '4' THEN '不能入住'
|
||||
WHEN '5' THEN '未满'
|
||||
WHEN '6' THEN '保洁中'
|
||||
ELSE '未知' END AS roomStatusLabel,
|
||||
r.in_person_count AS livePerson,
|
||||
r.in_person_count AS inPersonCount,
|
||||
(SELECT GROUP_CONCAT(j.xm ORDER BY j.jcsj SEPARATOR ',')
|
||||
FROM gy_jcxx j
|
||||
WHERE j.gyid = r.gyid AND j.fjh = r.room_no
|
||||
AND j.finish_ly = '0' AND j.deleted = 0) AS personName,
|
||||
COALESCE(r.room_type, m.room_type) AS roomType,
|
||||
r.room_dept AS roomDept,
|
||||
r.room_order AS roomOrder,
|
||||
COALESCE(m.bed_count, 0) AS bedCount,
|
||||
r.state_version AS stateVersion,
|
||||
r.disabled_reason AS disabledReason,
|
||||
m.source_version AS sourceVersion
|
||||
</sql>
|
||||
|
||||
<sql id="roomFrom">
|
||||
FROM gy_rooms r
|
||||
LEFT JOIN apt_master_room m
|
||||
ON m.gyid = r.gyid AND m.room_id = r.room_id AND m.deleted = 0
|
||||
</sql>
|
||||
|
||||
<select id="selectRoomList" resultType="com.ruoyi.apt.stay.domain.AptRoom">
|
||||
SELECT <include refid="roomColumns"/>
|
||||
<include refid="roomFrom"/>
|
||||
WHERE r.gyid = #{gyid} AND r.deleted = 0
|
||||
<if test="building != null and building != ''">
|
||||
AND r.room_floor LIKE CONCAT(#{building}, '%')
|
||||
</if>
|
||||
<if test="roomFloor != null and roomFloor != ''">
|
||||
AND r.room_floor = #{roomFloor}
|
||||
</if>
|
||||
<if test="roomNo != null and roomNo != ''">
|
||||
AND r.room_no LIKE CONCAT('%', #{roomNo}, '%')
|
||||
</if>
|
||||
<if test="roomStatuses != null and roomStatuses.size() > 0">
|
||||
AND r.room_status IN
|
||||
<foreach collection="roomStatuses" item="status" open="(" separator="," close=")">
|
||||
#{status}
|
||||
</foreach>
|
||||
</if>
|
||||
<if test="roomType != null and roomType != ''">
|
||||
AND COALESCE(r.room_type, m.room_type) = #{roomType}
|
||||
</if>
|
||||
ORDER BY r.room_floor ASC, r.room_order ASC, r.room_no ASC
|
||||
</select>
|
||||
|
||||
<select id="selectRoomById" resultType="com.ruoyi.apt.stay.domain.AptRoom">
|
||||
SELECT <include refid="roomColumns"/>
|
||||
<include refid="roomFrom"/>
|
||||
WHERE r.gyid = #{gyid} AND r.id = #{id} AND r.deleted = 0
|
||||
</select>
|
||||
|
||||
<select id="selectRoomBindings" resultType="java.util.LinkedHashMap">
|
||||
SELECT binding_id AS bindingId, endpoint_id AS endpointId,
|
||||
effective_from AS effectiveFrom, state
|
||||
FROM apt_sip_binding
|
||||
WHERE gyid = #{gyid} AND room_id = #{roomId} AND deleted = 0 AND state = 'ACTIVE'
|
||||
ORDER BY effective_from DESC
|
||||
</select>
|
||||
|
||||
<select id="selectLivePersons" resultType="java.util.LinkedHashMap">
|
||||
SELECT jcid, ryid, xm, cc, jcsj, ryzt
|
||||
FROM gy_jcxx
|
||||
WHERE gyid = #{gyid} AND fjh = #{roomNo} AND finish_ly = '0' AND deleted = 0
|
||||
ORDER BY jcsj ASC
|
||||
</select>
|
||||
|
||||
<select id="selectRecentCleanTasks" resultType="java.util.LinkedHashMap">
|
||||
SELECT task_id AS taskId, state, assignee_ref AS assigneeRef,
|
||||
scheduled_at AS scheduledAt, finished_at AS finishedAt
|
||||
FROM gy_clean_task
|
||||
WHERE gyid = #{gyid} AND room_id = #{roomId} AND deleted = 0
|
||||
ORDER BY scheduled_at DESC
|
||||
LIMIT 5
|
||||
</select>
|
||||
|
||||
<select id="selectStatusSummary" resultType="java.util.LinkedHashMap">
|
||||
SELECT COUNT(*) AS total,
|
||||
SUM(CASE WHEN room_status = '1' THEN 1 ELSE 0 END) AS idle,
|
||||
SUM(CASE WHEN room_status = '2' THEN 1 ELSE 0 END) AS full,
|
||||
SUM(CASE WHEN room_status = '5' THEN 1 ELSE 0 END) AS partial,
|
||||
SUM(CASE WHEN room_status IN ('3', '6') THEN 1 ELSE 0 END) AS cleaning,
|
||||
SUM(CASE WHEN room_status = '4' THEN 1 ELSE 0 END) AS disabled
|
||||
FROM gy_rooms
|
||||
WHERE gyid = #{gyid} AND deleted = 0
|
||||
</select>
|
||||
|
||||
<select id="selectActiveCleanState" resultType="java.lang.String">
|
||||
SELECT state
|
||||
FROM gy_clean_task
|
||||
WHERE gyid = #{gyid} AND room_id = #{roomId} AND deleted = 0
|
||||
AND state IN ('PENDING', 'PROCESSING')
|
||||
ORDER BY scheduled_at DESC
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<update id="updateRoomLocal">
|
||||
UPDATE gy_rooms
|
||||
<set>
|
||||
<if test="command.roomType != null">room_type = #{command.roomType},</if>
|
||||
<if test="command.roomDept != null">room_dept = #{command.roomDept},</if>
|
||||
<if test="command.roomOrder != null">room_order = #{command.roomOrder},</if>
|
||||
<if test="command.roomDir != null">room_dir = #{command.roomDir},</if>
|
||||
<if test="command.roomFace != null">room_face = #{command.roomFace},</if>
|
||||
update_time = NOW(3)
|
||||
</set>
|
||||
WHERE gyid = #{gyid} AND id = #{id} AND deleted = 0
|
||||
</update>
|
||||
|
||||
<update id="updateRoomStatus">
|
||||
UPDATE gy_rooms
|
||||
SET room_status = #{roomStatus},
|
||||
disabled_reason = #{disabledReason},
|
||||
state_version = state_version + 1,
|
||||
update_time = NOW(3)
|
||||
WHERE gyid = #{gyid} AND id = #{id}
|
||||
AND state_version = #{expectedVersion} AND deleted = 0
|
||||
</update>
|
||||
|
||||
<select id="selectManualAction" resultType="java.util.LinkedHashMap">
|
||||
SELECT action_id AS actionId, action_type AS actionType, state,
|
||||
duplicated, result_code AS resultCode
|
||||
FROM duty_manual_action
|
||||
WHERE gyid = #{gyid} AND action_id = #{actionId}
|
||||
</select>
|
||||
|
||||
<insert id="insertManualAction">
|
||||
INSERT INTO duty_manual_action
|
||||
(gyid, action_id, jcid, action_type, state, duplicated,
|
||||
result_code, requested_by, occurred_at)
|
||||
VALUES
|
||||
(#{gyid}, #{actionId}, NULL, #{actionType}, #{state}, 0,
|
||||
NULL, #{requestedBy}, #{occurredAt})
|
||||
</insert>
|
||||
|
||||
<update id="updateManualActionResult">
|
||||
UPDATE duty_manual_action
|
||||
SET state = #{state}, result_code = #{resultCode}
|
||||
WHERE gyid = #{gyid} AND action_id = #{actionId}
|
||||
</update>
|
||||
|
||||
<update id="markManualActionDuplicated">
|
||||
UPDATE duty_manual_action
|
||||
SET duplicated = 1
|
||||
WHERE gyid = #{gyid} AND action_id = #{actionId}
|
||||
</update>
|
||||
|
||||
</mapper>
|
||||
@@ -0,0 +1,101 @@
|
||||
<?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.master.mapper.AptRouteMapper">
|
||||
|
||||
<!--
|
||||
IT-2 / M1 交路主数据。
|
||||
硬约束:每条 SQL 必带 gyid = #{gyid}(AptMapperGyidArchTest 断言),
|
||||
默认排序固定为业务字段,禁止按代理主键排序(公寓端/04 §1.2)。
|
||||
字段映射:jch/qd/zd <- route_name/start_station/end_station;
|
||||
enabled <- state;sourceVersion <- source_version。
|
||||
-->
|
||||
<sql id="routeColumns">
|
||||
id, gyid, jlid, cc,
|
||||
route_name AS jch, start_station AS qd, end_station AS zd,
|
||||
kcsj, jcsj, lysj, cclx, bz, state,
|
||||
CASE WHEN state = 'ACTIVE' THEN 1 ELSE 0 END AS enabled,
|
||||
stop_reason AS stopReason, source_version AS sourceVersion, create_time
|
||||
</sql>
|
||||
|
||||
<select id="selectRouteList" resultType="com.ruoyi.apt.master.domain.AptRoute">
|
||||
SELECT <include refid="routeColumns"/>
|
||||
FROM apt_master_route
|
||||
WHERE gyid = #{gyid} AND deleted = 0
|
||||
<if test="cc != null and cc != ''">
|
||||
AND cc = #{cc}
|
||||
</if>
|
||||
<if test="jlid != null and jlid != ''">
|
||||
AND jlid = #{jlid}
|
||||
</if>
|
||||
<if test="state != null and state != ''">
|
||||
AND state = #{state}
|
||||
</if>
|
||||
ORDER BY state DESC, kcsj ASC, cc ASC
|
||||
</select>
|
||||
|
||||
<select id="selectRouteById" resultType="com.ruoyi.apt.master.domain.AptRoute">
|
||||
SELECT <include refid="routeColumns"/>
|
||||
FROM apt_master_route
|
||||
WHERE gyid = #{gyid} AND id = #{id} AND deleted = 0
|
||||
</select>
|
||||
|
||||
<select id="selectRouteOptions" resultType="com.ruoyi.apt.master.domain.AptRoute">
|
||||
SELECT jlid, cc, jcsj
|
||||
FROM apt_master_route
|
||||
WHERE gyid = #{gyid} AND deleted = 0 AND state = 'ACTIVE'
|
||||
<if test="keyword != null and keyword != ''">
|
||||
AND (cc LIKE CONCAT('%', #{keyword}, '%') OR jlid LIKE CONCAT('%', #{keyword}, '%'))
|
||||
</if>
|
||||
ORDER BY jcsj ASC, cc ASC
|
||||
</select>
|
||||
|
||||
<select id="countByJlid" resultType="int">
|
||||
SELECT COUNT(*)
|
||||
FROM apt_master_route
|
||||
WHERE gyid = #{gyid} AND jlid = #{jlid} AND deleted = 0
|
||||
<if test="excludeId != null">
|
||||
AND id <> #{excludeId}
|
||||
</if>
|
||||
</select>
|
||||
|
||||
<insert id="insertRoute" parameterType="com.ruoyi.apt.master.domain.AptRoute"
|
||||
useGeneratedKeys="true" keyProperty="id">
|
||||
INSERT INTO apt_master_route
|
||||
(gyid, jlid, cc, kcsj, jcsj, lysj, route_name, start_station, end_station,
|
||||
cclx, bz, state, stop_reason, source_version, deleted)
|
||||
VALUES
|
||||
(#{gyid}, #{jlid}, #{cc}, #{kcsj}, #{jcsj}, #{lysj}, #{jch}, #{qd}, #{zd},
|
||||
#{cclx}, #{bz}, #{state}, #{stopReason}, #{sourceVersion}, 0)
|
||||
</insert>
|
||||
|
||||
<update id="updateRoute" parameterType="com.ruoyi.apt.master.domain.AptRoute">
|
||||
UPDATE apt_master_route
|
||||
<set>
|
||||
jlid = #{jlid}, cc = #{cc}, kcsj = #{kcsj}, jcsj = #{jcsj}, lysj = #{lysj},
|
||||
route_name = #{jch}, start_station = #{qd}, end_station = #{zd},
|
||||
cclx = #{cclx}, bz = #{bz}
|
||||
</set>
|
||||
WHERE gyid = #{gyid} AND id = #{id} AND deleted = 0
|
||||
</update>
|
||||
|
||||
<update id="updateRouteStatus">
|
||||
UPDATE apt_master_route
|
||||
SET state = #{state}, stop_reason = #{stopReason}
|
||||
WHERE gyid = #{gyid} AND id = #{id} AND deleted = 0
|
||||
</update>
|
||||
|
||||
<select id="selectCallTimeRule" resultType="java.util.LinkedHashMap">
|
||||
SELECT rule_version AS ruleVersion, CAST(rule_body AS CHAR) AS ruleBody
|
||||
FROM apt_master_rule
|
||||
WHERE gyid = #{gyid} AND rule_type = 'CALL_TIME' AND state = 'ACTIVE' AND deleted = 0
|
||||
ORDER BY rule_version DESC
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<select id="countHoliday" resultType="int">
|
||||
SELECT COUNT(*)
|
||||
FROM apt_holiday
|
||||
WHERE gyid = #{gyid} AND holiday_date = #{holidayDate} AND deleted = 0
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
@@ -0,0 +1,208 @@
|
||||
<?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.voice.mapper.AptVoiceMapper">
|
||||
|
||||
<!--
|
||||
AMI 连接与语音链路(一公寓一 Asterisk 实例)。
|
||||
硬约束:
|
||||
1) 每条 SQL 必带 gyid;
|
||||
2) 本文件不写 gy_jcxx(叫乘状态唯一写入口仍是 AptDutyStateMapper.xml);
|
||||
3) 口令只存环境变量引用,本文件任何语句都不落明文。
|
||||
-->
|
||||
|
||||
<sql id="instanceColumns">
|
||||
id, gyid, instance_code AS instanceCode, display_name AS displayName,
|
||||
ami_host AS amiHost, ami_port AS amiPort, ami_user AS amiUser,
|
||||
ami_secret_ref AS amiSecretRef, tls_enabled AS tlsEnabled,
|
||||
conference, dial_context AS dialContext, dial_prefix AS dialPrefix,
|
||||
channel_tech AS channelTech, recording_base_url AS recordingBaseUrl,
|
||||
connect_timeout_ms AS connectTimeoutMs, action_timeout_ms AS actionTimeoutMs,
|
||||
ping_interval_sec AS pingIntervalSec, ping_timeout_sec AS pingTimeoutSec,
|
||||
reconnect_initial_ms AS reconnectInitialMs, reconnect_max_ms AS reconnectMaxMs,
|
||||
state, last_connected_at AS lastConnectedAt, last_ping_at AS lastPingAt,
|
||||
last_error AS lastError, enabled
|
||||
</sql>
|
||||
|
||||
<select id="selectInstance" resultType="com.ruoyi.apt.voice.domain.AptAmiInstance">
|
||||
SELECT <include refid="instanceColumns"/>
|
||||
FROM apt_ast_instance
|
||||
WHERE gyid = #{gyid} AND deleted = 0 AND enabled = 1
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<update id="updateInstanceState">
|
||||
UPDATE apt_ast_instance
|
||||
SET state = #{state},
|
||||
last_error = #{lastError},
|
||||
biz_version = biz_version + 1
|
||||
WHERE gyid = #{gyid} AND deleted = 0
|
||||
</update>
|
||||
|
||||
<update id="updateInstanceConnected">
|
||||
UPDATE apt_ast_instance
|
||||
SET state = 'UP',
|
||||
last_connected_at = NOW(3),
|
||||
last_ping_at = NOW(3),
|
||||
last_error = NULL,
|
||||
biz_version = biz_version + 1
|
||||
WHERE gyid = #{gyid} AND deleted = 0
|
||||
</update>
|
||||
|
||||
<update id="updateInstancePing">
|
||||
UPDATE apt_ast_instance
|
||||
SET last_ping_at = NOW(3),
|
||||
last_error = NULL
|
||||
WHERE gyid = #{gyid} AND deleted = 0
|
||||
</update>
|
||||
|
||||
<insert id="insertAmiSession">
|
||||
INSERT INTO apt_ast_ami_session
|
||||
(gyid, ami_session_id, connected_at, reconnect_count, state)
|
||||
VALUES
|
||||
(#{gyid}, #{amiSessionId}, NOW(3),
|
||||
(SELECT t.cnt FROM (SELECT COUNT(*) AS cnt FROM apt_ast_ami_session WHERE gyid = #{gyid}) t),
|
||||
#{state})
|
||||
</insert>
|
||||
|
||||
<update id="closeAmiSession">
|
||||
UPDATE apt_ast_ami_session s
|
||||
SET s.disconnected_at = NOW(3),
|
||||
s.state = #{state},
|
||||
s.close_reason = #{closeReason},
|
||||
s.last_error = #{lastError}
|
||||
WHERE s.gyid = #{gyid} AND s.ami_session_id = #{amiSessionId}
|
||||
</update>
|
||||
|
||||
<update id="updateAmiSessionPing">
|
||||
UPDATE apt_ast_ami_session
|
||||
SET last_ping_at = NOW(3)
|
||||
WHERE gyid = #{gyid} AND ami_session_id = #{amiSessionId}
|
||||
</update>
|
||||
|
||||
<insert id="insertCallSession">
|
||||
INSERT INTO apt_call_session
|
||||
(gyid, session_id, jcid, endpoint_id, conf_no, call_mode, call_state,
|
||||
leg_role, channel, originate_action_id, ami_session_id, started_at)
|
||||
VALUES
|
||||
(#{gyid}, #{sessionId}, #{jcid}, #{endpointId}, #{confNo}, 'SIP', 'INIT',
|
||||
#{legRole}, #{channel}, #{originateActionId}, #{amiSessionId}, #{startedAt})
|
||||
</insert>
|
||||
|
||||
<update id="updateCallSessionState">
|
||||
UPDATE apt_call_session
|
||||
<set>
|
||||
call_state = #{callState},
|
||||
<if test="answered">answered_at = COALESCE(answered_at, NOW(3)),</if>
|
||||
<if test="ended">ended_at = COALESCE(ended_at, NOW(3)),</if>
|
||||
<if test="hangupCause != null">hangup_cause = #{hangupCause},</if>
|
||||
biz_version = biz_version + 1
|
||||
</set>
|
||||
WHERE gyid = #{gyid} AND session_id = #{sessionId}
|
||||
</update>
|
||||
|
||||
<update id="bindCallSessionChannel">
|
||||
UPDATE apt_call_session
|
||||
SET channel = #{channel},
|
||||
call_state = 'RINGING',
|
||||
biz_version = biz_version + 1
|
||||
WHERE gyid = #{gyid} AND session_id = #{sessionId}
|
||||
</update>
|
||||
|
||||
<update id="markSessionsUnknown">
|
||||
UPDATE apt_call_session
|
||||
SET call_state = 'UNKNOWN',
|
||||
ended_at = COALESCE(ended_at, NOW(3)),
|
||||
hangup_cause = #{cause},
|
||||
biz_version = biz_version + 1
|
||||
WHERE gyid = #{gyid}
|
||||
AND ami_session_id = #{amiSessionId}
|
||||
AND call_state IN ('INIT','CALLING','RINGING','JOINED_CONF')
|
||||
</update>
|
||||
|
||||
<select id="selectCallSession" resultType="java.util.LinkedHashMap">
|
||||
SELECT session_id AS sessionId, jcid, endpoint_id AS endpointId, conf_no AS confNo,
|
||||
call_mode AS callMode, call_state AS callState, leg_role AS legRole,
|
||||
channel, originate_action_id AS originateActionId,
|
||||
ami_session_id AS amiSessionId, started_at AS startedAt,
|
||||
answered_at AS answeredAt, ended_at AS endedAt, hangup_cause AS hangupCause
|
||||
FROM apt_call_session
|
||||
WHERE gyid = #{gyid} AND session_id = #{sessionId} AND deleted = 0
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<select id="selectSessionByActionId" resultType="java.util.LinkedHashMap">
|
||||
SELECT session_id AS sessionId, jcid, leg_role AS legRole, channel,
|
||||
call_state AS callState, conf_no AS confNo
|
||||
FROM apt_call_session
|
||||
WHERE gyid = #{gyid} AND originate_action_id = #{actionId} AND deleted = 0
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<select id="selectSessionByChannel" resultType="java.util.LinkedHashMap">
|
||||
SELECT session_id AS sessionId, jcid, leg_role AS legRole, channel,
|
||||
call_state AS callState, conf_no AS confNo
|
||||
FROM apt_call_session
|
||||
WHERE gyid = #{gyid} AND channel = #{channel} AND deleted = 0
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<select id="selectDueSipCalls" resultType="java.util.LinkedHashMap">
|
||||
SELECT j.jcid, j.jcsj, j.fjh, j.ryid, j.xm AS rymc
|
||||
FROM gy_jcxx j
|
||||
WHERE j.gyid = #{gyid} AND j.deleted = 0
|
||||
AND j.call_mode = 'SIP'
|
||||
AND j.ryzt = '0' AND j.stoped = '0'
|
||||
AND j.finish_jc = '0' AND j.finish_ly = '0'
|
||||
AND j.jcsj IS NOT NULL AND j.jcsj <= #{now}
|
||||
ORDER BY j.jcsj 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,
|
||||
call_mode AS callMode, jcsj
|
||||
FROM gy_jcxx
|
||||
WHERE gyid = #{gyid} AND jcid = #{jcid} AND deleted = 0
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<select id="selectRoomExtension" resultType="java.util.LinkedHashMap">
|
||||
SELECT e.endpoint_id AS endpointId, e.extension
|
||||
FROM apt_sip_binding b
|
||||
JOIN apt_sip_endpoint e
|
||||
ON e.gyid = b.gyid AND e.endpoint_id = b.endpoint_id AND e.deleted = 0
|
||||
WHERE b.gyid = #{gyid}
|
||||
AND b.room_id = #{roomNo}
|
||||
AND b.state = 'ACTIVE'
|
||||
AND b.deleted = 0
|
||||
AND (b.effective_to IS NULL OR b.effective_to > NOW(3))
|
||||
AND e.enabled = 1
|
||||
ORDER BY b.effective_from DESC
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<select id="selectNextTtsSeq" resultType="java.lang.Long">
|
||||
SELECT COALESCE(MAX(utterance_seq), 0) + 1
|
||||
FROM apt_tts_utterance
|
||||
WHERE gyid = #{gyid} AND session_id = #{sessionId}
|
||||
</select>
|
||||
|
||||
<insert id="insertTts">
|
||||
INSERT INTO apt_tts_utterance
|
||||
(gyid, utterance_id, session_id, utterance_seq, text_ref, state)
|
||||
VALUES
|
||||
(#{gyid}, #{utteranceId}, #{sessionId}, #{seq}, #{textRef}, 'PENDING')
|
||||
</insert>
|
||||
|
||||
<update id="updateTts">
|
||||
UPDATE apt_tts_utterance
|
||||
SET state = #{state},
|
||||
audio_ref = COALESCE(#{audioRef}, audio_ref),
|
||||
error_msg = #{errorMsg},
|
||||
played_at = CASE WHEN #{state} = 'PLAYED' THEN NOW(3) ELSE played_at END
|
||||
WHERE gyid = #{gyid} AND utterance_id = #{utteranceId}
|
||||
</update>
|
||||
|
||||
</mapper>
|
||||
Whitespace-only changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+6
-7
@@ -14,8 +14,8 @@ import static org.mockito.Mockito.when;
|
||||
import com.ruoyi.apt.common.AptContext;
|
||||
import com.ruoyi.apt.common.audit.AptAudit;
|
||||
import com.ruoyi.apt.framework.audit.AptAuditAspect;
|
||||
import com.ruoyi.apt.framework.audit.AptAuditWriter;
|
||||
import com.ruoyi.apt.ops.domain.AptOperationLog;
|
||||
import com.ruoyi.apt.ops.mapper.AptAuditMapper;
|
||||
import org.aspectj.lang.ProceedingJoinPoint;
|
||||
import org.aspectj.lang.Signature;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
@@ -29,7 +29,7 @@ import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
/** 审计切面落 duty_operation_log:字段完整、失败留痕、写库异常不影响业务(IT-1 / DV-1)。 */
|
||||
class AptAuditAspectTest
|
||||
{
|
||||
private AptAuditMapper mapper;
|
||||
private AptAuditWriter writer;
|
||||
private AptAuditAspect aspect;
|
||||
private ProceedingJoinPoint joinPoint;
|
||||
private AptAudit audit;
|
||||
@@ -37,8 +37,8 @@ class AptAuditAspectTest
|
||||
@BeforeEach
|
||||
void setUp() throws Throwable
|
||||
{
|
||||
mapper = mock(AptAuditMapper.class);
|
||||
aspect = new AptAuditAspect(mapper);
|
||||
writer = mock(AptAuditWriter.class);
|
||||
aspect = new AptAuditAspect(writer);
|
||||
joinPoint = mock(ProceedingJoinPoint.class);
|
||||
Signature signature = mock(Signature.class);
|
||||
when(signature.toShortString()).thenReturn("AptRouteController.list(..)");
|
||||
@@ -92,8 +92,7 @@ class AptAuditAspectTest
|
||||
void auditWriteFailureDoesNotAffectBusinessResult() throws Throwable
|
||||
{
|
||||
when(joinPoint.proceed()).thenReturn("ok");
|
||||
doThrow(new RuntimeException("db down")).when(mapper)
|
||||
.insertOperationLog(any(AptOperationLog.class));
|
||||
doThrow(new RuntimeException("db down")).when(writer).write(any(AptOperationLog.class));
|
||||
assertEquals("ok", aspect.around(joinPoint, audit));
|
||||
}
|
||||
|
||||
@@ -118,7 +117,7 @@ class AptAuditAspectTest
|
||||
private AptOperationLog capturedRow()
|
||||
{
|
||||
ArgumentCaptor<AptOperationLog> captor = ArgumentCaptor.forClass(AptOperationLog.class);
|
||||
verify(mapper).insertOperationLog(captor.capture());
|
||||
verify(writer).write(captor.capture());
|
||||
return captor.getValue();
|
||||
}
|
||||
}
|
||||
+4
@@ -28,6 +28,10 @@ class AptAuthInterceptorTest
|
||||
{
|
||||
AptProperties properties = new AptProperties();
|
||||
properties.setGyId("APT-A");
|
||||
AptProperties.Device device = new AptProperties.Device();
|
||||
// 本类只验证四前缀与跨公寓治理;签名四要素由 AptDeviceSignerTest 覆盖
|
||||
device.setHmacEnabled(false);
|
||||
properties.setDevice(device);
|
||||
interceptor = new AptContextInterceptor(properties);
|
||||
request = mock(HttpServletRequest.class);
|
||||
when(request.getHeader("X-Gy-Id")).thenReturn(null);
|
||||
File renamed without changes.
File renamed without changes.
+94
@@ -0,0 +1,94 @@
|
||||
package com.ruoyi.apt.framework;
|
||||
|
||||
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 java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* IT-2 / 任务 5 静态校验:不需要实库即可断言 Mock 灌数的版本标记纪律
|
||||
* (公寓端/09 表 9-16、13 表 13-12 验收标准)。
|
||||
*/
|
||||
class AptIt2SeedMarkersTest
|
||||
{
|
||||
private static final Pattern LOCAL_MARKER = Pattern.compile(
|
||||
"\\(@apt_gyid,\\s*'([A-Z_]+)',\\s*0,\\s*'LOCAL',\\s*'APPLIED'");
|
||||
|
||||
@Test
|
||||
void seedMarksAllSixDomainsAsLocalApplied() throws IOException
|
||||
{
|
||||
String seed = read("11_it2_seed.sql");
|
||||
Matcher matcher = LOCAL_MARKER.matcher(seed);
|
||||
Set<String> domains = new HashSet<>();
|
||||
while (matcher.find())
|
||||
{
|
||||
domains.add(matcher.group(1));
|
||||
}
|
||||
assertEquals(Set.of("APARTMENT", "ROOM", "PERSON", "ROUTE", "RULE", "HOLIDAY"), domains,
|
||||
"6 个主数据域必须全部标 LOCAL / 0 / APPLIED");
|
||||
}
|
||||
|
||||
@Test
|
||||
void seedInsertNeverPretendsToBePlatformDownlink() throws IOException
|
||||
{
|
||||
String seed = read("11_it2_seed.sql");
|
||||
int start = seed.indexOf("INSERT INTO apt_master_version");
|
||||
int end = seed.indexOf("ON DUPLICATE KEY UPDATE", start);
|
||||
assertTrue(start >= 0 && end > start, "版本灌数段应存在");
|
||||
assertFalse(seed.substring(start, end).contains("'PLATFORM'"),
|
||||
"Mock 版本不得冒充平台下发");
|
||||
}
|
||||
|
||||
@Test
|
||||
void seedContainsMarkerValidationSelects() throws IOException
|
||||
{
|
||||
String seed = read("11_it2_seed.sql");
|
||||
assertTrue(seed.contains("source = 'LOCAL' AND source_version = 0"),
|
||||
"须校验 source / source_version 组合");
|
||||
assertTrue(seed.contains("fake_platform_rows"), "须校验不存在伪平台行");
|
||||
assertTrue(seed.contains("sourceLabel"), "须给出页面「非平台下发」文案");
|
||||
}
|
||||
|
||||
@Test
|
||||
void permissionsRegisterVersionPointAndCompatibilityNote() throws IOException
|
||||
{
|
||||
String permissions = read("12_it2_permissions.sql");
|
||||
assertTrue(permissions.contains("'apt:master:version'"), "3015 权限点须登记");
|
||||
assertTrue(permissions.contains("apt:ops:health"), "须登记表 4-17 权威权限点口径");
|
||||
assertTrue(permissions.contains("version_permission_bound"), "须校验 3015 已关联超管");
|
||||
}
|
||||
|
||||
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 < 6)
|
||||
{
|
||||
Path candidate = current.resolve("sql/apt_rev01");
|
||||
if (Files.isDirectory(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
current = current.getParent();
|
||||
depth++;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+26
-4
@@ -28,7 +28,7 @@ class AptMapperGyidArchTest
|
||||
void everyAptMapperStatementCarriesGyid() throws IOException
|
||||
{
|
||||
Path dir = locateMapperDir();
|
||||
assertNotNull(dir, "未找到 ruoyi-system/src/main/resources/mapper/apt");
|
||||
assertNotNull(dir, "未找到 ruoyi-apt/src/main/resources/mapper/apt");
|
||||
List<Path> files;
|
||||
try (java.util.stream.Stream<Path> stream = Files.list(dir))
|
||||
{
|
||||
@@ -65,16 +65,38 @@ class AptMapperGyidArchTest
|
||||
assertTrue(xml.contains("deleted = 0"), "列表须过滤逻辑删除");
|
||||
}
|
||||
|
||||
@Test
|
||||
void roomMapperUsesBusinessOrderingAndOptimisticLock() throws IOException
|
||||
{
|
||||
Path dir = locateMapperDir();
|
||||
assertNotNull(dir);
|
||||
String xml = Files.readString(dir.resolve("AptRoomMapper.xml"), StandardCharsets.UTF_8);
|
||||
assertTrue(xml.contains("ORDER BY r.room_floor ASC, r.room_order ASC, r.room_no ASC"),
|
||||
"房间列表须按楼层/排序号/房号的业务字段默认排序(表 4-7)");
|
||||
assertFalse(xml.toLowerCase().contains("order by id"),
|
||||
"禁止按代理主键排序");
|
||||
assertTrue(xml.contains("state_version = #{expectedVersion}"),
|
||||
"房态更新必须带 state_version 条件(表 7-3 M2)");
|
||||
assertTrue(xml.contains("deleted = 0"), "列表须过滤逻辑删除");
|
||||
}
|
||||
|
||||
private Path locateMapperDir()
|
||||
{
|
||||
Path current = Paths.get(System.getProperty("user.dir")).toAbsolutePath();
|
||||
int depth = 0;
|
||||
while (current != null && depth < 6)
|
||||
{
|
||||
Path candidate = current.resolve("ruoyi-system/src/main/resources/mapper/apt");
|
||||
if (Files.isDirectory(candidate))
|
||||
// 从 ruoyi-apt 模块根执行单测
|
||||
Path inModule = current.resolve("src/main/resources/mapper/apt");
|
||||
if (Files.isDirectory(inModule))
|
||||
{
|
||||
return candidate;
|
||||
return inModule;
|
||||
}
|
||||
// 从 Maven 反应堆根执行单测
|
||||
Path inReactor = current.resolve("ruoyi-apt/src/main/resources/mapper/apt");
|
||||
if (Files.isDirectory(inReactor))
|
||||
{
|
||||
return inReactor;
|
||||
}
|
||||
current = current.getParent();
|
||||
depth++;
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package com.ruoyi.apt.framework;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import com.ruoyi.apt.framework.config.AptMasterClientMockGuard;
|
||||
import com.ruoyi.apt.framework.config.AptProperties;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.core.env.StandardEnvironment;
|
||||
|
||||
/** 表 9-16:Mock 主数据下行只允许 local profile 开启。 */
|
||||
class AptMasterClientMockGuardTest
|
||||
{
|
||||
@Test
|
||||
void mockDisabledStartsOnAnyProfile()
|
||||
{
|
||||
AptProperties properties = new AptProperties();
|
||||
assertDoesNotThrow(() ->
|
||||
new AptMasterClientMockGuard(properties, new StandardEnvironment()).validate());
|
||||
}
|
||||
|
||||
@Test
|
||||
void mockEnabledWithoutLocalProfileFailsFast()
|
||||
{
|
||||
AptProperties properties = new AptProperties();
|
||||
properties.getMasterClient().setMock(true);
|
||||
|
||||
IllegalStateException e = assertThrows(IllegalStateException.class, () ->
|
||||
new AptMasterClientMockGuard(properties, new StandardEnvironment()).validate());
|
||||
|
||||
assertTrue(e.getMessage().contains("local"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void mockEnabledWithLocalProfileStarts()
|
||||
{
|
||||
AptProperties properties = new AptProperties();
|
||||
properties.getMasterClient().setMock(true);
|
||||
StandardEnvironment environment = new StandardEnvironment();
|
||||
environment.setActiveProfiles("local");
|
||||
|
||||
assertDoesNotThrow(() ->
|
||||
new AptMasterClientMockGuard(properties, environment).validate());
|
||||
}
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
package com.ruoyi.apt.master;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import java.util.List;
|
||||
import com.ruoyi.apt.common.AptContext;
|
||||
import com.ruoyi.apt.common.AptException;
|
||||
import com.ruoyi.apt.master.domain.AptMasterVersion;
|
||||
import com.ruoyi.apt.master.mapper.AptMasterVersionMapper;
|
||||
import com.ruoyi.apt.master.service.AptMasterVersionServiceImpl;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/** IT-2 / 任务 5:Mock 版本标记、域白名单与重拉禁用(表 9-16、表 4-17)。 */
|
||||
class AptMasterVersionServiceTest
|
||||
{
|
||||
private AptMasterVersionMapper mapper;
|
||||
private AptMasterVersionServiceImpl service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp()
|
||||
{
|
||||
mapper = mock(AptMasterVersionMapper.class);
|
||||
service = new AptMasterVersionServiceImpl(mapper);
|
||||
AptContext.clear();
|
||||
AptContext.setGyId("APT-A");
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown()
|
||||
{
|
||||
AptContext.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void listMarksLocalSourceAsNonPlatform()
|
||||
{
|
||||
AptMasterVersion version = version("ROUTE", "LOCAL", 0L, "APPLIED");
|
||||
version.setItemCount(3L);
|
||||
when(mapper.selectVersions("APT-A")).thenReturn(List.of(version));
|
||||
|
||||
List<AptMasterVersion> list = service.list();
|
||||
|
||||
assertTrue(list.get(0).isLocalMock());
|
||||
assertEquals("非平台下发", list.get(0).getSourceLabel());
|
||||
assertEquals(0L, list.get(0).getSourceVersion().longValue());
|
||||
assertEquals(3L, list.get(0).getItemCount().longValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
void platformSourceIsNotMarkedAsMock()
|
||||
{
|
||||
when(mapper.selectVersions("APT-A"))
|
||||
.thenReturn(List.of(version("ROUTE", "PLATFORM", 12L, "APPLIED")));
|
||||
|
||||
AptMasterVersion version = service.list().get(0);
|
||||
|
||||
assertFalse(version.isLocalMock());
|
||||
assertEquals("平台下发", version.getSourceLabel());
|
||||
assertEquals(12L, version.getSourceVersion().longValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
void listUsesServerSideGyid()
|
||||
{
|
||||
when(mapper.selectVersions("APT-A")).thenReturn(List.of());
|
||||
service.list();
|
||||
verify(mapper).selectVersions("APT-A");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownDomainReturns40001()
|
||||
{
|
||||
AptException e = assertThrows(AptException.class, () -> service.detail("UNKNOWN"));
|
||||
assertEquals(40001, e.getErrorCode().getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingDomainRecordReturns40401()
|
||||
{
|
||||
when(mapper.selectVersionByDomain("APT-A", "ROUTE")).thenReturn(null);
|
||||
AptException e = assertThrows(AptException.class, () -> service.detail("route"));
|
||||
assertEquals(40401, e.getErrorCode().getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void detailNormalizesDomainAndMarksSource()
|
||||
{
|
||||
when(mapper.selectVersionByDomain("APT-A", "HOLIDAY"))
|
||||
.thenReturn(version("HOLIDAY", "LOCAL", 0L, "APPLIED"));
|
||||
|
||||
AptMasterVersion version = service.detail(" holiday ");
|
||||
|
||||
assertEquals("非平台下发", version.getSourceLabel());
|
||||
verify(mapper).selectVersionByDomain("APT-A", "HOLIDAY");
|
||||
}
|
||||
|
||||
@Test
|
||||
void resyncAlwaysDisabledWith40940()
|
||||
{
|
||||
AptException e = assertThrows(AptException.class, () -> service.resync("ROUTE"));
|
||||
assertEquals(40940, e.getErrorCode().getCode());
|
||||
}
|
||||
|
||||
private AptMasterVersion version(String domain, String source, long versionNo, String state)
|
||||
{
|
||||
AptMasterVersion version = new AptMasterVersion();
|
||||
version.setGyid("APT-A");
|
||||
version.setDomain(domain);
|
||||
version.setSource(source);
|
||||
version.setSourceVersion(versionNo);
|
||||
version.setState(state);
|
||||
return version;
|
||||
}
|
||||
}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
package com.ruoyi.apt.master;
|
||||
|
||||
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.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import java.util.Date;
|
||||
import com.ruoyi.apt.common.AptContext;
|
||||
import com.ruoyi.apt.common.AptException;
|
||||
import com.ruoyi.apt.master.domain.AptRoute;
|
||||
import com.ruoyi.apt.master.domain.AptRouteCommand;
|
||||
import com.ruoyi.apt.master.domain.AptRouteRecalc;
|
||||
import com.ruoyi.apt.master.domain.AptRouteStatusCommand;
|
||||
import com.ruoyi.apt.master.mapper.AptRouteMapper;
|
||||
import com.ruoyi.apt.master.service.AptRouteServiceImpl;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/** IT-2 / M1 交路校验与复算回归(表 7-3 M1、表 13-12 验收标准)。 */
|
||||
class AptRouteServiceTest
|
||||
{
|
||||
private static final long HOUR = 3_600_000L;
|
||||
private AptRouteMapper mapper;
|
||||
private AptRouteServiceImpl service;
|
||||
private long now;
|
||||
|
||||
@BeforeEach
|
||||
void setUp()
|
||||
{
|
||||
mapper = mock(AptRouteMapper.class);
|
||||
service = new AptRouteServiceImpl(mapper);
|
||||
AptContext.clear();
|
||||
AptContext.setGyId("APT-A");
|
||||
now = System.currentTimeMillis();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown()
|
||||
{
|
||||
AptContext.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingCcReturns40001AndNamesField()
|
||||
{
|
||||
AptRouteCommand command = validCommand();
|
||||
command.setCc(null);
|
||||
AptException e = assertThrows(AptException.class, () -> service.add(command));
|
||||
assertEquals(40001, e.getErrorCode().getCode());
|
||||
assertTrue(e.getMessage().contains("cc"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingKcsjReturns40001AndNamesField()
|
||||
{
|
||||
AptRouteCommand command = validCommand();
|
||||
command.setKcsj(null);
|
||||
AptException e = assertThrows(AptException.class, () -> service.add(command));
|
||||
assertEquals(40001, e.getErrorCode().getCode());
|
||||
assertTrue(e.getMessage().contains("kcsj"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void illegalTimeOrderReturns40001()
|
||||
{
|
||||
AptRouteCommand command = validCommand();
|
||||
command.setJcsj(new Date(command.getKcsj().getTime() + HOUR));
|
||||
AptException e = assertThrows(AptException.class, () -> service.add(command));
|
||||
assertEquals(40001, e.getErrorCode().getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void duplicateJlidReturns40901()
|
||||
{
|
||||
when(mapper.countByJlid(eq("APT-A"), eq("JL-X"), any())).thenReturn(1);
|
||||
AptException e = assertThrows(AptException.class, () -> service.add(validCommand()));
|
||||
assertEquals(40901, e.getErrorCode().getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void snapshotFieldWriteReturns40904()
|
||||
{
|
||||
AptRouteCommand command = validCommand();
|
||||
command.setSourceVersion(1L);
|
||||
AptException e = assertThrows(AptException.class, () -> service.add(command));
|
||||
assertEquals(40904, e.getErrorCode().getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingReasonReturns40001AndNamesField()
|
||||
{
|
||||
stubRoute(new AptRoute());
|
||||
AptRouteStatusCommand command = new AptRouteStatusCommand();
|
||||
command.setEnabled(false);
|
||||
AptException e = assertThrows(AptException.class, () -> service.changeStatus(1L, command));
|
||||
assertEquals(40001, e.getErrorCode().getCode());
|
||||
assertTrue(e.getMessage().contains("reason"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void disableWritesStoppedStateAndReason()
|
||||
{
|
||||
stubRoute(new AptRoute());
|
||||
AptRouteStatusCommand command = new AptRouteStatusCommand();
|
||||
command.setEnabled(false);
|
||||
command.setReason("检修");
|
||||
service.changeStatus(1L, command);
|
||||
verify(mapper).updateRouteStatus("APT-A", 1L, "STOPPED", "检修");
|
||||
}
|
||||
|
||||
@Test
|
||||
void recalcUsesDefaultAdvanceWhenRuleMissing()
|
||||
{
|
||||
AptRoute route = new AptRoute();
|
||||
route.setJlid("JL-X");
|
||||
route.setKcsj(new Date(now + 8 * HOUR));
|
||||
route.setJcsj(new Date(now + 6 * HOUR));
|
||||
stubRoute(route);
|
||||
when(mapper.selectCallTimeRule("APT-A")).thenReturn(null);
|
||||
when(mapper.countHoliday(eq("APT-A"), any())).thenReturn(0);
|
||||
|
||||
AptRouteRecalc result = service.recalcCheck(1L, null);
|
||||
|
||||
assertEquals(route.getKcsj().getTime() - 120 * 60_000L, result.getComputedJcsj().getTime());
|
||||
assertEquals(route.getKcsj().getTime() - 150 * 60_000L, result.getComputedFjsj().getTime());
|
||||
assertNotNull(result.getDiffMs());
|
||||
assertTrue(result.getWarnings().stream().anyMatch(w -> w.contains("规则缺失")));
|
||||
}
|
||||
|
||||
private void stubRoute(AptRoute route)
|
||||
{
|
||||
when(mapper.selectRouteById(eq("APT-A"), eq(1L))).thenReturn(route);
|
||||
}
|
||||
|
||||
private AptRouteCommand validCommand()
|
||||
{
|
||||
Date kcsj = new Date(now + 8 * HOUR);
|
||||
AptRouteCommand command = new AptRouteCommand();
|
||||
command.setJlid("JL-X");
|
||||
command.setCc("G101");
|
||||
command.setKcsj(kcsj);
|
||||
command.setJcsj(new Date(kcsj.getTime() - 2 * HOUR));
|
||||
command.setLysj(new Date(kcsj.getTime() - 3 * HOUR));
|
||||
return command;
|
||||
}
|
||||
}
|
||||
File renamed without changes.
+280
@@ -0,0 +1,280 @@
|
||||
package com.ruoyi.apt.stay;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
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.when;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.ruoyi.apt.common.AptContext;
|
||||
import com.ruoyi.apt.common.AptException;
|
||||
import com.ruoyi.apt.stay.domain.AptRoom;
|
||||
import com.ruoyi.apt.stay.domain.AptRoomCommand;
|
||||
import com.ruoyi.apt.stay.domain.AptRoomDetail;
|
||||
import com.ruoyi.apt.stay.domain.AptRoomStatusCommand;
|
||||
import com.ruoyi.apt.stay.domain.AptRoomStatusResult;
|
||||
import com.ruoyi.apt.stay.domain.AptRoomStatusSummary;
|
||||
import com.ruoyi.apt.stay.mapper.AptRoomMapper;
|
||||
import com.ruoyi.apt.stay.service.AptRoomServiceImpl;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
/** IT-2 / M2 房间房态、state_version、快照只读与幂等回归(表 7-3 / 表 13-12)。 */
|
||||
class AptRoomServiceTest
|
||||
{
|
||||
private AptRoomMapper mapper;
|
||||
private AptRoomServiceImpl service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp()
|
||||
{
|
||||
mapper = mock(AptRoomMapper.class);
|
||||
service = new AptRoomServiceImpl(mapper);
|
||||
AptContext.clear();
|
||||
AptContext.setGyId("APT-A");
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown()
|
||||
{
|
||||
AptContext.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void listAlwaysUsesServerSideGyid()
|
||||
{
|
||||
service.list(null, "2", "20", List.of("4"), "DOUBLE");
|
||||
verify(mapper).selectRoomList("APT-A", null, "2", "20", List.of("4"), "DOUBLE");
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void commaSeparatedRoomStatusIsSplit()
|
||||
{
|
||||
service.list(null, null, null, List.of("4,5"), null);
|
||||
|
||||
ArgumentCaptor<List<String>> captor = ArgumentCaptor.forClass(List.class);
|
||||
verify(mapper).selectRoomList(eq("APT-A"), any(), any(), any(), captor.capture(), any());
|
||||
assertEquals(List.of("4", "5"), captor.getValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
void snapshotFieldWriteReturns40904()
|
||||
{
|
||||
stubRoom(room("1", 1L));
|
||||
AptRoomCommand command = new AptRoomCommand();
|
||||
command.setRoomType("DOUBLE");
|
||||
command.setRoomFloor("3");
|
||||
|
||||
AptException e = assertThrows(AptException.class, () -> service.edit(1L, command));
|
||||
assertEquals(40904, e.getErrorCode().getCode());
|
||||
verify(mapper, never()).updateRoomLocal(anyString(), anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyLocalEditReturns40001()
|
||||
{
|
||||
stubRoom(room("1", 1L));
|
||||
AptException e = assertThrows(AptException.class,
|
||||
() -> service.edit(1L, new AptRoomCommand()));
|
||||
assertEquals(40001, e.getErrorCode().getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingReasonReturns40001AndNamesField()
|
||||
{
|
||||
stubRoom(room("1", 1L));
|
||||
AptRoomStatusCommand command = command("DISABLE", null, "CMD-1");
|
||||
|
||||
AptException e = assertThrows(AptException.class, () -> service.changeStatus(1L, command));
|
||||
assertEquals(40001, e.getErrorCode().getCode());
|
||||
assertTrue(e.getMessage().contains("reason"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidActionReturns40001()
|
||||
{
|
||||
stubRoom(room("1", 1L));
|
||||
AptException e = assertThrows(AptException.class,
|
||||
() -> service.changeStatus(1L, command("PAUSE", "检修", "CMD-1")));
|
||||
assertEquals(40001, e.getErrorCode().getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void disableSetsStatus4AndBumpsStateVersion()
|
||||
{
|
||||
stubRoom(room("1", 7L));
|
||||
when(mapper.insertManualAction(eq("APT-A"), eq("CMD-1"), eq("STOP"),
|
||||
eq("PENDING"), any(), any())).thenReturn(1);
|
||||
when(mapper.updateRoomStatus("APT-A", 1L, 7L, "4", "检修")).thenReturn(1);
|
||||
|
||||
AptRoomStatusResult result = service.changeStatus(1L, command("DISABLE", "检修", "CMD-1"));
|
||||
|
||||
assertEquals("4", result.getRoomStatus());
|
||||
assertEquals(8L, result.getStateVersion());
|
||||
assertFalse(result.isDuplicated());
|
||||
verify(mapper).updateRoomStatus("APT-A", 1L, 7L, "4", "检修");
|
||||
verify(mapper).updateManualActionResult("APT-A", "CMD-1", "DONE", "4");
|
||||
}
|
||||
|
||||
@Test
|
||||
void restoreRecalculatesToPartialAndClearsReason()
|
||||
{
|
||||
AptRoom room = room("4", 1L);
|
||||
room.setInPersonCount(1);
|
||||
stubRoom(room);
|
||||
when(mapper.selectActiveCleanState("APT-A", "ROOM-201")).thenReturn(null);
|
||||
when(mapper.updateRoomStatus("APT-A", 1L, 1L, "5", null)).thenReturn(1);
|
||||
|
||||
AptRoomStatusResult result = service.changeStatus(1L, command("RESTORE", "检修完成", "CMD-2"));
|
||||
|
||||
assertEquals("5", result.getRoomStatus());
|
||||
assertEquals(2L, result.getStateVersion());
|
||||
verify(mapper).updateRoomStatus("APT-A", 1L, 1L, "5", null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void restoreWithProcessingCleanTaskReturnsCleaningStatus()
|
||||
{
|
||||
stubRoom(room("4", 3L));
|
||||
when(mapper.selectActiveCleanState("APT-A", "ROOM-201")).thenReturn("PROCESSING");
|
||||
when(mapper.updateRoomStatus("APT-A", 1L, 3L, "6", null)).thenReturn(1);
|
||||
|
||||
AptRoomStatusResult result = service.changeStatus(1L, command("RESTORE", "检修完成", null));
|
||||
|
||||
assertEquals("6", result.getRoomStatus());
|
||||
assertEquals(4L, result.getStateVersion());
|
||||
}
|
||||
|
||||
@Test
|
||||
void duplicateCommandDoesNotUpdateStatus()
|
||||
{
|
||||
stubRoom(room("4", 1L));
|
||||
when(mapper.selectManualAction("APT-A", "CMD-1"))
|
||||
.thenReturn(row("actionId", "CMD-1"));
|
||||
|
||||
AptRoomStatusResult result = service.changeStatus(1L, command("RESTORE", "检修完成", "CMD-1"));
|
||||
|
||||
assertTrue(result.isDuplicated());
|
||||
assertEquals("4", result.getRoomStatus());
|
||||
verify(mapper).markManualActionDuplicated("APT-A", "CMD-1");
|
||||
verify(mapper, never()).updateRoomStatus(anyString(), anyLong(), anyLong(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void duplicateDisableOnAlreadyDisabledRoomReturnsDuplicatedInsteadOfStateConflict()
|
||||
{
|
||||
stubRoom(room("4", 2L));
|
||||
when(mapper.selectManualAction("APT-A", "CMD-1")).thenReturn(row("actionId", "CMD-1"));
|
||||
|
||||
AptRoomStatusResult result = service.changeStatus(1L, command("DISABLE", "检修", "CMD-1"));
|
||||
|
||||
assertTrue(result.isDuplicated());
|
||||
assertEquals("4", result.getRoomStatus());
|
||||
verify(mapper).markManualActionDuplicated("APT-A", "CMD-1");
|
||||
verify(mapper, never()).updateRoomStatus(anyString(), anyLong(), anyLong(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void stateVersionConflictReturns40903()
|
||||
{
|
||||
stubRoom(room("1", 2L));
|
||||
when(mapper.updateRoomStatus("APT-A", 1L, 2L, "4", "检修")).thenReturn(0);
|
||||
|
||||
AptException e = assertThrows(AptException.class,
|
||||
() -> service.changeStatus(1L, command("DISABLE", "检修", "CMD-3")));
|
||||
assertEquals(40903, e.getErrorCode().getCode());
|
||||
verify(mapper).updateManualActionResult("APT-A", "CMD-3", "FAILED", "STATE_VERSION_CONFLICT");
|
||||
}
|
||||
|
||||
@Test
|
||||
void disableAlreadyDisabledRoomReturns40902()
|
||||
{
|
||||
stubRoom(room("4", 1L));
|
||||
AptException e = assertThrows(AptException.class,
|
||||
() -> service.changeStatus(1L, command("DISABLE", "检修", "CMD-4")));
|
||||
assertEquals(40902, e.getErrorCode().getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void detailIncludesBindingsLivePersonsAndCleanTasks()
|
||||
{
|
||||
stubRoom(room("2", 1L));
|
||||
when(mapper.selectRoomBindings("APT-A", "ROOM-201")).thenReturn(List.of(row("bindingId", "B-1")));
|
||||
when(mapper.selectLivePersons("APT-A", "201")).thenReturn(List.of(row("xm", "张三")));
|
||||
when(mapper.selectRecentCleanTasks("APT-A", "ROOM-201")).thenReturn(List.of(row("taskId", "T-1")));
|
||||
|
||||
AptRoomDetail detail = service.detail(1L);
|
||||
|
||||
assertEquals("201", detail.getRoomNo());
|
||||
assertEquals(1, detail.getBindings().size());
|
||||
assertEquals(1, detail.getLivePersons().size());
|
||||
assertEquals(1, detail.getRecentCleanTasks().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void statusSummaryMapsAggregatedCounts()
|
||||
{
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("idle", 1L);
|
||||
row.put("full", 2L);
|
||||
row.put("partial", 3L);
|
||||
row.put("cleaning", 4L);
|
||||
row.put("disabled", 5L);
|
||||
row.put("total", 15L);
|
||||
when(mapper.selectStatusSummary("APT-A")).thenReturn(row);
|
||||
|
||||
AptRoomStatusSummary summary = service.statusSummary();
|
||||
|
||||
assertEquals(1L, summary.getIdle());
|
||||
assertEquals(5L, summary.getDisabled());
|
||||
assertEquals(15L, summary.getTotal());
|
||||
}
|
||||
|
||||
private void stubRoom(AptRoom room)
|
||||
{
|
||||
when(mapper.selectRoomById("APT-A", 1L)).thenReturn(room);
|
||||
}
|
||||
|
||||
private AptRoom room(String status, long version)
|
||||
{
|
||||
AptRoom room = new AptRoom();
|
||||
room.setId(1L);
|
||||
room.setGyid("APT-A");
|
||||
room.setRoomId("ROOM-201");
|
||||
room.setRoomNo("201");
|
||||
room.setRoomFloor("2");
|
||||
room.setRoomStatus(status);
|
||||
room.setStateVersion(version);
|
||||
room.setInPersonCount(0);
|
||||
room.setBedCount(2);
|
||||
return room;
|
||||
}
|
||||
|
||||
private AptRoomStatusCommand command(String action, String reason, String commandId)
|
||||
{
|
||||
AptRoomStatusCommand command = new AptRoomStatusCommand();
|
||||
command.setAction(action);
|
||||
command.setReason(reason);
|
||||
command.setCommandId(commandId);
|
||||
return command;
|
||||
}
|
||||
|
||||
private Map<String, Object> row(String key, String value)
|
||||
{
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put(key, value);
|
||||
return row;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package com.ruoyi.apt.voice;
|
||||
|
||||
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.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import com.ruoyi.apt.voice.ami.AmiAction;
|
||||
import com.ruoyi.apt.voice.ami.AmiClient;
|
||||
import com.ruoyi.apt.voice.ami.AmiException;
|
||||
import com.ruoyi.apt.voice.ami.AmiFrame;
|
||||
import com.ruoyi.apt.voice.ami.AmiEventListener;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* AMI 客户端与真实 TCP 假服务端联调(登录、心跳、Originate、事件、断连)。
|
||||
*/
|
||||
class AmiClientTest
|
||||
{
|
||||
@Test
|
||||
void connectLoginAndPing() throws Exception
|
||||
{
|
||||
try (FakeAmiServer server = new FakeAmiServer(false))
|
||||
{
|
||||
AmiClient client = new AmiClient("127.0.0.1", server.port(), "apt", "secret", 2000, 2000);
|
||||
client.connect();
|
||||
try
|
||||
{
|
||||
assertTrue(client.isConnected());
|
||||
assertTrue(client.ping(), "Ping 必须返回 Success");
|
||||
assertTrue(server.receivedActions().contains("Action: Login"));
|
||||
assertTrue(server.receivedActions().contains("Action: Ping"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
client.close("TEST_DONE");
|
||||
}
|
||||
assertFalse(client.isConnected());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void originateReturnsResponseAndDeliversEvent() throws Exception
|
||||
{
|
||||
try (FakeAmiServer server = new FakeAmiServer(false))
|
||||
{
|
||||
AmiClient client = new AmiClient("127.0.0.1", server.port(), "apt", "secret", 2000, 2000);
|
||||
CountDownLatch eventLatch = new CountDownLatch(1);
|
||||
CopyOnWriteArrayList<AmiFrame> events = new CopyOnWriteArrayList<>();
|
||||
client.addListener(new AmiEventListener()
|
||||
{
|
||||
@Override
|
||||
public void onEvent(AmiFrame frame)
|
||||
{
|
||||
events.add(frame);
|
||||
eventLatch.countDown();
|
||||
}
|
||||
});
|
||||
client.connect();
|
||||
try
|
||||
{
|
||||
AmiFrame response = client.sendAction(AmiAction
|
||||
.originate("PJSIP/1001", "from-internal", "Conf-01", 30000)
|
||||
.actionId("CS-1"));
|
||||
assertTrue(response.isSuccess());
|
||||
assertEquals("CS-1", response.actionId());
|
||||
|
||||
assertTrue(eventLatch.await(3, TimeUnit.SECONDS), "应收到 OriginateResponse 事件");
|
||||
AmiFrame event = events.get(0);
|
||||
assertEquals("OriginateResponse", event.eventName());
|
||||
assertEquals("PJSIP/1001-0000000a", event.get("channel"));
|
||||
|
||||
assertTrue(server.actions().stream()
|
||||
.anyMatch(action -> "Originate".equals(action.get("Action"))
|
||||
&& "Conf-01".equals(action.get("Exten"))));
|
||||
}
|
||||
finally
|
||||
{
|
||||
client.close("TEST_DONE");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void authenticationFailureIsReported() throws Exception
|
||||
{
|
||||
try (FakeAmiServer server = new FakeAmiServer(true))
|
||||
{
|
||||
AmiClient client = new AmiClient("127.0.0.1", server.port(), "apt", "bad", 2000, 2000);
|
||||
AmiException error = assertThrows(AmiException.class, client::connect);
|
||||
assertEquals("AMI_AUTH_FAILED", error.getReason());
|
||||
assertFalse(client.isConnected());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void peerCloseTriggersDisconnectCallback() throws Exception
|
||||
{
|
||||
try (FakeAmiServer server = new FakeAmiServer(false))
|
||||
{
|
||||
AmiClient client = new AmiClient("127.0.0.1", server.port(), "apt", "secret", 2000, 2000);
|
||||
CountDownLatch disconnectLatch = new CountDownLatch(1);
|
||||
client.addListener(new AmiEventListener()
|
||||
{
|
||||
@Override
|
||||
public void onDisconnect(Throwable cause)
|
||||
{
|
||||
disconnectLatch.countDown();
|
||||
}
|
||||
});
|
||||
client.connect();
|
||||
server.dropConnection();
|
||||
assertTrue(disconnectLatch.await(3, TimeUnit.SECONDS), "对端关闭应触发 onDisconnect");
|
||||
assertFalse(client.isConnected());
|
||||
assertNotNull(client.getLastError());
|
||||
client.close("TEST_DONE");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendActionOnClosedClientFailsFast() throws Exception
|
||||
{
|
||||
try (FakeAmiServer server = new FakeAmiServer(false))
|
||||
{
|
||||
AmiClient client = new AmiClient("127.0.0.1", server.port(), "apt", "secret", 2000, 2000);
|
||||
AmiException error = assertThrows(AmiException.class, () -> client.sendAction(AmiAction.ping()));
|
||||
assertEquals("AMI_NOT_CONNECTED", error.getReason());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.ruoyi.apt.voice;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import java.util.List;
|
||||
import com.ruoyi.apt.voice.ami.AmiAction;
|
||||
import com.ruoyi.apt.voice.ami.AmiFrame;
|
||||
import com.ruoyi.apt.voice.ami.AmiProtocol;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* AMI 文本协议编解码(不依赖网络)。
|
||||
*/
|
||||
class AmiProtocolTest
|
||||
{
|
||||
@Test
|
||||
void encodeEndsWithBlankLineAndKeepsOrder()
|
||||
{
|
||||
String encoded = AmiProtocol.encode(AmiAction.ping().actionId("A-1"));
|
||||
|
||||
assertEquals("Action: Ping\r\nActionID: A-1\r\n\r\n", encoded);
|
||||
}
|
||||
|
||||
@Test
|
||||
void parseIsCaseInsensitiveOnKeys()
|
||||
{
|
||||
AmiFrame frame = AmiProtocol.parse(List.of(
|
||||
"Event: ConfbridgeJoin",
|
||||
"Channel: PJSIP/1001-0000000a",
|
||||
"ActionID: CS-1"));
|
||||
|
||||
assertTrue(frame.isEvent());
|
||||
assertEquals("ConfbridgeJoin", frame.eventName());
|
||||
assertEquals("ConfbridgeJoin", frame.get("EVENT"));
|
||||
assertEquals("PJSIP/1001-0000000a", frame.get("channel"));
|
||||
assertEquals("CS-1", frame.actionId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void responseFramesAreNotTreatedAsEvents()
|
||||
{
|
||||
AmiFrame frame = AmiProtocol.parse(List.of("Response: Success", "ActionID: A-2", "Message: Pong"));
|
||||
|
||||
assertTrue(frame.isResponse());
|
||||
assertTrue(frame.isSuccess());
|
||||
assertFalse(frame.isEvent());
|
||||
assertEquals("Pong", frame.get("message"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void md5ChallengeMatchesAsteriskRule()
|
||||
{
|
||||
// Asterisk MD5 登录:MD5(challenge + secret)
|
||||
assertEquals(AmiProtocol.md5Hex("1234secret"), AmiProtocol.md5Hex("1234" + "secret"));
|
||||
assertEquals(32, AmiProtocol.md5Hex("x").length());
|
||||
}
|
||||
|
||||
@Test
|
||||
void originateCarriesRequiredFields()
|
||||
{
|
||||
AmiAction action = AmiAction.originate("PJSIP/1001", "from-internal", "Conf-01", 30000)
|
||||
.actionId("CS-1");
|
||||
String encoded = AmiProtocol.encode(action);
|
||||
|
||||
assertTrue(encoded.contains("Action: Originate"));
|
||||
assertTrue(encoded.contains("Channel: PJSIP/1001"));
|
||||
assertTrue(encoded.contains("Context: from-internal"));
|
||||
assertTrue(encoded.contains("Exten: Conf-01"));
|
||||
assertTrue(encoded.contains("Async: true"));
|
||||
assertTrue(encoded.contains("ActionID: CS-1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void ttsOriginateUsesApplicationPlayback()
|
||||
{
|
||||
String encoded = AmiProtocol.encode(
|
||||
AmiAction.originateApp("Local/s@apt-tts", "Playback", "apt/call-prompt", 30000));
|
||||
|
||||
assertTrue(encoded.contains("Application: Playback"));
|
||||
assertTrue(encoded.contains("Data: apt/call-prompt"));
|
||||
assertTrue(encoded.contains("Channel: Local/s@apt-tts"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package com.ruoyi.apt.voice;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.BufferedWriter;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.OutputStreamWriter;
|
||||
import java.net.ServerSocket;
|
||||
import java.net.Socket;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 极简 AMI 假服务端:验证客户端建连/登录/心跳/Originate/事件分发,不依赖真 Asterisk。
|
||||
*/
|
||||
class FakeAmiServer implements AutoCloseable
|
||||
{
|
||||
private final ServerSocket serverSocket;
|
||||
private final boolean loginFails;
|
||||
private final List<String> receivedActions = Collections.synchronizedList(new ArrayList<>());
|
||||
private final List<Map<String, String>> actions = Collections.synchronizedList(new ArrayList<>());
|
||||
private volatile Socket socket;
|
||||
private final Thread thread;
|
||||
|
||||
FakeAmiServer(boolean loginFails) throws IOException
|
||||
{
|
||||
this.loginFails = loginFails;
|
||||
this.serverSocket = new ServerSocket(0);
|
||||
this.thread = new Thread(this::serve, "fake-ami");
|
||||
this.thread.setDaemon(true);
|
||||
this.thread.start();
|
||||
}
|
||||
|
||||
int port()
|
||||
{
|
||||
return serverSocket.getLocalPort();
|
||||
}
|
||||
|
||||
List<Map<String, String>> actions()
|
||||
{
|
||||
return actions;
|
||||
}
|
||||
|
||||
List<String> receivedActions()
|
||||
{
|
||||
return receivedActions;
|
||||
}
|
||||
|
||||
private void serve()
|
||||
{
|
||||
try
|
||||
{
|
||||
Socket accepted = serverSocket.accept();
|
||||
this.socket = accepted;
|
||||
BufferedWriter out = new BufferedWriter(
|
||||
new OutputStreamWriter(accepted.getOutputStream(), StandardCharsets.UTF_8));
|
||||
BufferedReader in = new BufferedReader(
|
||||
new InputStreamReader(accepted.getInputStream(), StandardCharsets.UTF_8));
|
||||
write(out, "Asterisk Call Manager/5.0.4");
|
||||
List<String> lines = new ArrayList<>();
|
||||
String line;
|
||||
while ((line = in.readLine()) != null)
|
||||
{
|
||||
if (line.isEmpty())
|
||||
{
|
||||
if (!lines.isEmpty())
|
||||
{
|
||||
handle(out, lines);
|
||||
lines = new ArrayList<>();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
lines.add(line);
|
||||
receivedActions.add(line);
|
||||
}
|
||||
}
|
||||
catch (IOException ignored)
|
||||
{
|
||||
// 测试结束时的正常关闭
|
||||
}
|
||||
}
|
||||
|
||||
private void handle(BufferedWriter out, List<String> lines) throws IOException
|
||||
{
|
||||
Map<String, String> action = new LinkedHashMap<>();
|
||||
for (String line : lines)
|
||||
{
|
||||
int index = line.indexOf(':');
|
||||
if (index > 0)
|
||||
{
|
||||
action.put(line.substring(0, index).trim(), line.substring(index + 1).trim());
|
||||
}
|
||||
}
|
||||
actions.add(action);
|
||||
String actionName = action.getOrDefault("Action", "");
|
||||
String actionId = action.getOrDefault("ActionID", "NA");
|
||||
switch (actionName)
|
||||
{
|
||||
case "Login":
|
||||
if (loginFails)
|
||||
{
|
||||
write(out, "Response: Error", "ActionID: " + actionId,
|
||||
"Message: Authentication failed");
|
||||
}
|
||||
else
|
||||
{
|
||||
write(out, "Response: Success", "ActionID: " + actionId,
|
||||
"Message: Authentication accepted");
|
||||
}
|
||||
break;
|
||||
case "Ping":
|
||||
write(out, "Response: Success", "ActionID: " + actionId, "Message: Pong");
|
||||
break;
|
||||
case "Originate":
|
||||
write(out, "Response: Success", "ActionID: " + actionId,
|
||||
"Message: Originate successfully queued");
|
||||
write(out, "Event: OriginateResponse", "ActionID: " + actionId,
|
||||
"Response: Success", "Channel: PJSIP/1001-0000000a",
|
||||
"Uniqueid: 1700000000.1");
|
||||
break;
|
||||
case "Hangup":
|
||||
write(out, "Response: Success", "ActionID: " + actionId);
|
||||
break;
|
||||
default:
|
||||
write(out, "Response: Error", "ActionID: " + actionId, "Message: Invalid/unknown command");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void write(BufferedWriter out, String... lines) throws IOException
|
||||
{
|
||||
for (String line : lines)
|
||||
{
|
||||
out.write(line);
|
||||
out.write("\r\n");
|
||||
}
|
||||
out.write("\r\n");
|
||||
out.flush();
|
||||
}
|
||||
|
||||
/** 主动断开,触发客户端 onDisconnect */
|
||||
void dropConnection()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (socket != null && !socket.isClosed())
|
||||
{
|
||||
socket.close();
|
||||
}
|
||||
}
|
||||
catch (IOException ignored)
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close()
|
||||
{
|
||||
dropConnection();
|
||||
try
|
||||
{
|
||||
serverSocket.close();
|
||||
}
|
||||
catch (IOException ignored)
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
thread.interrupt();
|
||||
}
|
||||
}
|
||||
-126
@@ -1,126 +0,0 @@
|
||||
package com.ruoyi.apt.framework.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 公寓端基座配置(application.yml 的 apt.* 段)。
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "apt")
|
||||
public class AptProperties
|
||||
{
|
||||
/** 一进程一 gyid(D5),只从部署环境注入 */
|
||||
private String gyId;
|
||||
|
||||
private Probe probe = new Probe();
|
||||
private Page page = new Page();
|
||||
|
||||
public String getGyId()
|
||||
{
|
||||
return gyId;
|
||||
}
|
||||
|
||||
public void setGyId(String gyId)
|
||||
{
|
||||
this.gyId = gyId;
|
||||
}
|
||||
|
||||
public Probe getProbe()
|
||||
{
|
||||
return probe;
|
||||
}
|
||||
|
||||
public void setProbe(Probe probe)
|
||||
{
|
||||
this.probe = probe;
|
||||
}
|
||||
|
||||
public Page getPage()
|
||||
{
|
||||
return page;
|
||||
}
|
||||
|
||||
public void setPage(Page page)
|
||||
{
|
||||
this.page = page;
|
||||
}
|
||||
|
||||
public static class Probe
|
||||
{
|
||||
private boolean enabled = true;
|
||||
/** 单次探针超时判定阈值(毫秒) */
|
||||
private long timeoutMs = 2000;
|
||||
private String redisKeyPrefix = "apt:rev01:";
|
||||
private int redisTtlSeconds = 60;
|
||||
/** 探针专用表,不触碰任何业务表 */
|
||||
private String table = "apt_ops_probe";
|
||||
|
||||
public boolean isEnabled()
|
||||
{
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(boolean enabled)
|
||||
{
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public long getTimeoutMs()
|
||||
{
|
||||
return timeoutMs;
|
||||
}
|
||||
|
||||
public void setTimeoutMs(long timeoutMs)
|
||||
{
|
||||
this.timeoutMs = timeoutMs;
|
||||
}
|
||||
|
||||
public String getRedisKeyPrefix()
|
||||
{
|
||||
return redisKeyPrefix;
|
||||
}
|
||||
|
||||
public void setRedisKeyPrefix(String redisKeyPrefix)
|
||||
{
|
||||
this.redisKeyPrefix = redisKeyPrefix;
|
||||
}
|
||||
|
||||
public int getRedisTtlSeconds()
|
||||
{
|
||||
return redisTtlSeconds;
|
||||
}
|
||||
|
||||
public void setRedisTtlSeconds(int redisTtlSeconds)
|
||||
{
|
||||
this.redisTtlSeconds = redisTtlSeconds;
|
||||
}
|
||||
|
||||
public String getTable()
|
||||
{
|
||||
return table;
|
||||
}
|
||||
|
||||
public void setTable(String table)
|
||||
{
|
||||
this.table = table;
|
||||
}
|
||||
}
|
||||
|
||||
public static class Page
|
||||
{
|
||||
private int maxSize = 100;
|
||||
|
||||
public int getMaxSize()
|
||||
{
|
||||
return maxSize;
|
||||
}
|
||||
|
||||
public void setMaxSize(int maxSize)
|
||||
{
|
||||
this.maxSize = maxSize;
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
-6
@@ -1,6 +1,7 @@
|
||||
package com.ruoyi.framework.config;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.HttpMethod;
|
||||
@@ -10,12 +11,12 @@ import org.springframework.security.config.annotation.method.configuration.Enabl
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.web.AuthenticationEntryPoint;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.access.AccessDeniedHandler;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
import org.springframework.security.web.authentication.logout.LogoutFilter;
|
||||
import org.springframework.web.filter.CorsFilter;
|
||||
import com.ruoyi.apt.framework.security.AptAccessDeniedHandler;
|
||||
import com.ruoyi.apt.framework.security.AptAuthenticationEntryPoint;
|
||||
import com.ruoyi.framework.config.properties.PermitAllUrlProperties;
|
||||
import com.ruoyi.framework.security.filter.JwtAuthenticationTokenFilter;
|
||||
import com.ruoyi.framework.security.handle.LogoutSuccessHandlerImpl;
|
||||
@@ -27,19 +28,25 @@ import com.ruoyi.framework.security.handle.LogoutSuccessHandlerImpl;
|
||||
* 使 /apt/**、/duty/**、/open/v1/**、/platform/** 返回真实 HTTP 状态与
|
||||
* 约定错误码 + traceId;非公寓端路径仍委托若依原 AuthenticationEntryPointImpl。</p>
|
||||
*
|
||||
* <p>ruoyi-apt 模块归置后:本类只依赖 Spring Security 标准接口,按 bean 名
|
||||
* 注入 ruoyi-apt 提供的实现,避免 framework 与 ruoyi-apt 形成编译期循环依赖。
|
||||
* 公寓端处理器对非公寓端路径仍委托若依原行为。</p>
|
||||
*
|
||||
* @author ruoyi
|
||||
*/
|
||||
@EnableMethodSecurity(prePostEnabled = true, securedEnabled = true)
|
||||
@Configuration
|
||||
public class SecurityConfig
|
||||
{
|
||||
/** 公寓端认证失败处理类(内部对非公寓端路径委托若依原实现) */
|
||||
/** 公寓端认证失败处理类:按 bean 名装配 ruoyi-apt 的实现(aptAuthenticationEntryPoint) */
|
||||
@Autowired
|
||||
private AptAuthenticationEntryPoint aptAuthenticationEntryPoint;
|
||||
@Qualifier("aptAuthenticationEntryPoint")
|
||||
private AuthenticationEntryPoint aptAuthenticationEntryPoint;
|
||||
|
||||
/** 公寓端权限不足处理类 */
|
||||
/** 公寓端权限不足处理类:按 bean 名装配 ruoyi-apt 的实现(aptAccessDeniedHandler) */
|
||||
@Autowired
|
||||
private AptAccessDeniedHandler aptAccessDeniedHandler;
|
||||
@Qualifier("aptAccessDeniedHandler")
|
||||
private AccessDeniedHandler aptAccessDeniedHandler;
|
||||
|
||||
/**
|
||||
* 退出处理类
|
||||
|
||||
-42
@@ -1,42 +0,0 @@
|
||||
package com.ruoyi.apt.master.domain;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
/** apt_master_route 只读列表视图(IT-1 业务表只读查询骨架,不做 CRUD)。 */
|
||||
public class AptRoute
|
||||
{
|
||||
private Long id;
|
||||
private String gyid;
|
||||
private String jlid;
|
||||
private String cc;
|
||||
private String routeName;
|
||||
private String startStation;
|
||||
private String endStation;
|
||||
private Date kcsj;
|
||||
private String state;
|
||||
private Long sourceVersion;
|
||||
private Date createTime;
|
||||
|
||||
public Long getId() { return id; }
|
||||
public void setId(Long id) { this.id = id; }
|
||||
public String getGyid() { return gyid; }
|
||||
public void setGyid(String gyid) { this.gyid = gyid; }
|
||||
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 getRouteName() { return routeName; }
|
||||
public void setRouteName(String routeName) { this.routeName = routeName; }
|
||||
public String getStartStation() { return startStation; }
|
||||
public void setStartStation(String startStation) { this.startStation = startStation; }
|
||||
public String getEndStation() { return endStation; }
|
||||
public void setEndStation(String endStation) { this.endStation = endStation; }
|
||||
public Date getKcsj() { return kcsj; }
|
||||
public void setKcsj(Date kcsj) { this.kcsj = kcsj; }
|
||||
public String getState() { return state; }
|
||||
public void setState(String state) { this.state = state; }
|
||||
public Long getSourceVersion() { return sourceVersion; }
|
||||
public void setSourceVersion(Long sourceVersion) { this.sourceVersion = sourceVersion; }
|
||||
public Date getCreateTime() { return createTime; }
|
||||
public void setCreateTime(Date createTime) { this.createTime = createTime; }
|
||||
}
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
package com.ruoyi.apt.master.mapper;
|
||||
|
||||
import java.util.List;
|
||||
import com.ruoyi.apt.master.domain.AptRoute;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
/** 交路只读查询骨架(IT-1:不含任何 INSERT/UPDATE/DELETE)。 */
|
||||
public interface AptRouteMapper
|
||||
{
|
||||
List<AptRoute> selectRouteList(@Param("gyid") String gyid,
|
||||
@Param("cc") String cc,
|
||||
@Param("jlid") String jlid,
|
||||
@Param("state") String state);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
<?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.master.mapper.AptRouteMapper">
|
||||
|
||||
<!--
|
||||
IT-1 业务表只读查询骨架。
|
||||
硬约束:WHERE 必须带 gyid = #{gyid},由 AptContext 注入,禁止客户端指定;
|
||||
默认排序按业务字段(state desc, kcsj asc, cc asc),不按 id 排序(公寓端/04 §1.2)。
|
||||
-->
|
||||
<select id="selectRouteList" resultType="com.ruoyi.apt.master.domain.AptRoute">
|
||||
SELECT id, gyid, jlid, cc, route_name, start_station, end_station,
|
||||
kcsj, state, source_version, create_time
|
||||
FROM apt_master_route
|
||||
WHERE gyid = #{gyid} AND deleted = 0
|
||||
<if test="cc != null and cc != ''">
|
||||
AND cc = #{cc}
|
||||
</if>
|
||||
<if test="jlid != null and jlid != ''">
|
||||
AND jlid = #{jlid}
|
||||
</if>
|
||||
<if test="state != null and state != ''">
|
||||
AND state = #{state}
|
||||
</if>
|
||||
ORDER BY state DESC, kcsj ASC, cc ASC
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
@@ -0,0 +1,59 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 入住作业核心:值班台 N-42~N-46 + 管理端 N-47(公寓端/11 表 11-34)
|
||||
// 口径:
|
||||
// 1) 列表/详情/时间线用 duty:checkin:list;审核 duty:checkin:review;换房 duty:checkin:adjust。
|
||||
// 2) 审核与换房必须带 expectedBizVersion(详情里拿到的 bizVersion),版本不一致返回 40926。
|
||||
// 3) 设备侧 N-39~N-41 走 /open/v1/** 四要素鉴权,前端不持有该通道,故不在此封装。
|
||||
// 4) 不封装「编辑申请单」「删除申请单」:入住事实只能经审核/入住/退宿流转。
|
||||
|
||||
// N-42 入住待审列表(state 缺省 SUBMITTED)
|
||||
export function listCheckin(query) {
|
||||
return request({
|
||||
url: '/duty/checkin/applications',
|
||||
method: 'get',
|
||||
params: query
|
||||
})
|
||||
}
|
||||
|
||||
// N-43 审核详情:首次查看会把 SUBMITTED 条件迁移为 REVIEWING(接单)
|
||||
export function getCheckin(applyId) {
|
||||
return request({
|
||||
url: '/duty/checkin/applications/' + applyId,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// N-44 审核:APPROVE / APPROVE_WITH_ROOM_CHANGE / REJECT
|
||||
export function reviewCheckin(applyId, data) {
|
||||
return request({
|
||||
url: '/duty/checkin/applications/' + applyId + '/review',
|
||||
method: 'post',
|
||||
data: data
|
||||
})
|
||||
}
|
||||
|
||||
// N-45 审核期换房(仅 APPROVED / ROOM_ADJUSTED;已入住返回 40926)
|
||||
export function adjustCheckinRoom(applyId, data) {
|
||||
return request({
|
||||
url: '/duty/checkin/applications/' + applyId + '/room-adjust',
|
||||
method: 'post',
|
||||
data: data
|
||||
})
|
||||
}
|
||||
|
||||
// N-46 审核时间线
|
||||
export function getCheckinTimeline(applyId) {
|
||||
return request({
|
||||
url: '/duty/checkin/applications/' + applyId + '/timeline',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// N-47 按 jcid 查入住与叫乘任务(task 为空表示任务未就绪)
|
||||
export function getCheckinCallTask(jcid) {
|
||||
return request({
|
||||
url: '/apt/checkin/' + jcid + '/call-task',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 公寓端人脸注册审核:对应 AptDutyFaceRegController(IT-3 / DV-3,表 10-26)
|
||||
// 口径:
|
||||
// 1) 权限点统一为 duty:person:reg;确认 / 驳回均须二次确认(前端 $modal.confirm 承担)。
|
||||
// 2) 注册生效点 = 值班员确认(CR-C25),平台回执(verdict)只作同步结果展示,不决定本地生效。
|
||||
// 3) 本文件不封装「编辑 / 删除注册申请」:人脸相关表只读,注册态只能经确认 / 驳回两态流转。
|
||||
// 4) N-34 设备申请(POST /open/v1/face/reg-requests)走设备凭据 + HMAC,前端不持有该通道。
|
||||
|
||||
// N-35 注册台账;regState 缺省 APPLIED(待审),可传 CONFIRMED / REJECTED 查看历史
|
||||
export function listFaceReg(query) {
|
||||
return request({
|
||||
url: '/duty/person/face-reg/pending',
|
||||
method: 'get',
|
||||
params: query
|
||||
})
|
||||
}
|
||||
|
||||
// 注册申请详情(要素、媒体引用与校验和、双路状态)
|
||||
export function getFaceReg(regId) {
|
||||
return request({
|
||||
url: '/duty/person/face-reg/' + regId,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// N-36 确认注册:可修正 unitCode / personCategory,重复确认按 regId 幂等返回原 ryid
|
||||
export function confirmFaceReg(regId, data) {
|
||||
return request({
|
||||
url: '/duty/person/face-reg/' + regId + '/confirm',
|
||||
method: 'post',
|
||||
data: data
|
||||
})
|
||||
}
|
||||
|
||||
// N-37 驳回:终态,驳回原因必填(@NotBlank rejectReason,<=256)
|
||||
export function rejectFaceReg(regId, data) {
|
||||
return request({
|
||||
url: '/duty/person/face-reg/' + regId + '/reject',
|
||||
method: 'post',
|
||||
data: data
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
/**
|
||||
* 叫乘信息管理 API(公寓端/04 表 4-9 接口 1~6,IT-5 / M3)。
|
||||
*
|
||||
* 刻意没有 update 方法:`PUT /apt/jcxx/{id}` 不存在,
|
||||
* 状态字段只能经 submitJcxxCommand 提交命令,非状态字段走 correctJcxx 白名单。
|
||||
*/
|
||||
|
||||
// 表 4-9 #1:叫乘列表 / 台账
|
||||
export function listJcxx(query) {
|
||||
return request({
|
||||
url: '/apt/jcxx/list',
|
||||
method: 'get',
|
||||
params: query
|
||||
})
|
||||
}
|
||||
|
||||
// 表 4-9 #6:状态分布(看板)
|
||||
export function getJcxxStatistics(date) {
|
||||
return request({
|
||||
url: '/apt/jcxx/statistics',
|
||||
method: 'get',
|
||||
params: { date }
|
||||
})
|
||||
}
|
||||
|
||||
// 表 4-9 #2:详情(含最近会话、录音索引、人工命令)
|
||||
export function getJcxx(id) {
|
||||
return request({
|
||||
url: '/apt/jcxx/' + id,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// 表 4-9 #3:时间线(状态迁移 + AMI + 人工命令 + 审计 + 平板结果,按 at 升序)
|
||||
export function getJcxxTimeline(id) {
|
||||
return request({
|
||||
url: '/apt/jcxx/' + id + '/timeline',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// 表 4-9 #4:非状态字段修正(白名单)
|
||||
export function correctJcxx(id, data) {
|
||||
return request({
|
||||
url: '/apt/jcxx/' + id + '/correct',
|
||||
method: 'post',
|
||||
data: data
|
||||
})
|
||||
}
|
||||
|
||||
// 表 4-9 #5:状态机命令(STOP / RE_CALL / FORCE_FINISH / MARK_LEAVE / CANCEL)
|
||||
export function submitJcxxCommand(id, data) {
|
||||
return request({
|
||||
url: '/apt/jcxx/' + id + '/commands',
|
||||
method: 'post',
|
||||
data: data
|
||||
})
|
||||
}
|
||||
Loaded 100 of 144 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user