1111
IT-0 verify / verify (push) Waiting to run

This commit is contained in:
zfjia committed 2026-11-11 18:30:31 +08:00
1 parent 417c076465
commit 35751b4f00
144 files changed
+13905 -342

No files matched your search

+8 -2
View File
@@ -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
+8
View File
@@ -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>
+6
View File
@@ -42,6 +42,12 @@
<artifactId>ruoyi-framework</artifactId>
</dependency>
<!-- 公寓端集中模块:接口 / 业务逻辑 / 配置 / 静态资源挂载点 -->
<dependency>
<groupId>com.ruoyi</groupId>
<artifactId>ruoyi-apt</artifactId>
</dependency>
<!-- 定时任务-->
<dependency>
<groupId>com.ruoyi</groupId>
@@ -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
+64
View File
@@ -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>
@@ -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, "现场协议未确认,暂不支持"),
@@ -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;
}
}
@@ -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
@@ -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)
{
@@ -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);
}
}
@@ -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'");
}
}
@@ -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;
}
}
}
@@ -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");
@@ -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) 吞掉若依后台业务错误码;公寓端走统一响应体。
@@ -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();
}
}
@@ -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";
}
}
}
@@ -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; }
}
@@ -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; }
}
@@ -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; }
}
@@ -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; }
}
@@ -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; }
}
@@ -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);
}
@@ -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);
}
@@ -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();
}
}
@@ -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&lt;kcsj 与
* lysj&lt;=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();
}
}
@@ -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);
}
@@ -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);
}
@@ -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; }
}
@@ -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; }
}
@@ -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; }
}
@@ -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; }
}
@@ -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; }
}
@@ -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; }
}
@@ -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);
}
@@ -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();
}
}
@@ -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)
{
// 关闭失败无需处理
}
}
}
@@ -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());
}
}
}
@@ -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");
}
}
@@ -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)
{
}
}
@@ -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);
}
}
}
@@ -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;
}
}
@@ -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);
}
@@ -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();
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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();
}
}
@@ -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:请到门口集合,准备乘车}
@@ -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 &lt;&gt; #{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 &lt;= #{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 &gt; 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.
@@ -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();
}
}
@@ -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);
@@ -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;
}
}
@@ -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++;
@@ -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());
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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();
}
}
@@ -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;
}
}
}
@@ -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;
/**
* 退出处理类
@@ -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; }
}
@@ -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'
})
}
+43
View File
@@ -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
})
}
+60
View File
@@ -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