diff --git a/dev/.env.example b/dev/.env.example index 0895c94..6a45e55 100644 --- a/dev/.env.example +++ b/dev/.env.example @@ -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 diff --git a/dev/RuoYi-Vue-master/pom.xml b/dev/RuoYi-Vue-master/pom.xml index 1a1c13a..b618f79 100644 --- a/dev/RuoYi-Vue-master/pom.xml +++ b/dev/RuoYi-Vue-master/pom.xml @@ -170,6 +170,13 @@ ${ruoyi.version} + + + com.ruoyi + ruoyi-apt + ${ruoyi.version} + + @@ -180,6 +187,7 @@ ruoyi-quartz ruoyi-generator ruoyi-common + ruoyi-apt pom diff --git a/dev/RuoYi-Vue-master/ruoyi-admin/pom.xml b/dev/RuoYi-Vue-master/ruoyi-admin/pom.xml index a4e5736..a0b3573 100644 --- a/dev/RuoYi-Vue-master/ruoyi-admin/pom.xml +++ b/dev/RuoYi-Vue-master/ruoyi-admin/pom.xml @@ -42,6 +42,12 @@ ruoyi-framework + + + com.ruoyi + ruoyi-apt + + com.ruoyi diff --git a/dev/RuoYi-Vue-master/ruoyi-admin/src/main/java/com/ruoyi/apt/ops/controller/AptRouteController.java b/dev/RuoYi-Vue-master/ruoyi-admin/src/main/java/com/ruoyi/apt/ops/controller/AptRouteController.java deleted file mode 100644 index 88f8ef6..0000000 --- a/dev/RuoYi-Vue-master/ruoyi-admin/src/main/java/com/ruoyi/apt/ops/controller/AptRouteController.java +++ /dev/null @@ -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 rows = routeMapper.selectRouteList(AptContext.getGyId(), cc, jlid, state); - return getAptDataTable(rows); - } -} diff --git a/dev/RuoYi-Vue-master/ruoyi-admin/src/main/resources/application.yml b/dev/RuoYi-Vue-master/ruoyi-admin/src/main/resources/application.yml index 691cb8f..f8e4347 100644 --- a/dev/RuoYi-Vue-master/ruoyi-admin/src/main/resources/application.yml +++ b/dev/RuoYi-Vue-master/ruoyi-admin/src/main/resources/application.yml @@ -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 diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/pom.xml b/dev/RuoYi-Vue-master/ruoyi-apt/pom.xml new file mode 100644 index 0000000..1aeb152 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/pom.xml @@ -0,0 +1,64 @@ + + + + ruoyi + com.ruoyi + 3.9.2 + + 4.0.0 + jar + ruoyi-apt + + + 公寓端集中模块:四前缀接口、业务逻辑、审计与鉴权、配置项、Mapper XML 与前端静态资源挂载点 + + + + + + + com.ruoyi + ruoyi-common + + + + + com.ruoyi + ruoyi-framework + + + + + org.mybatis.spring.boot + mybatis-spring-boot-starter + + + + + org.springframework + spring-tx + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.2 + + + + + diff --git a/dev/RuoYi-Vue-master/ruoyi-common/src/main/java/com/ruoyi/apt/common/AptContext.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/common/AptContext.java similarity index 100% rename from dev/RuoYi-Vue-master/ruoyi-common/src/main/java/com/ruoyi/apt/common/AptContext.java rename to dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/common/AptContext.java diff --git a/dev/RuoYi-Vue-master/ruoyi-common/src/main/java/com/ruoyi/apt/common/AptErrorCode.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/common/AptErrorCode.java similarity index 57% rename from dev/RuoYi-Vue-master/ruoyi-common/src/main/java/com/ruoyi/apt/common/AptErrorCode.java rename to dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/common/AptErrorCode.java index b983220..ad39e6d 100644 --- a/dev/RuoYi-Vue-master/ruoyi-common/src/main/java/com/ruoyi/apt/common/AptErrorCode.java +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/common/AptErrorCode.java @@ -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, "现场协议未确认,暂不支持"), diff --git a/dev/RuoYi-Vue-master/ruoyi-common/src/main/java/com/ruoyi/apt/common/AptException.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/common/AptException.java similarity index 100% rename from dev/RuoYi-Vue-master/ruoyi-common/src/main/java/com/ruoyi/apt/common/AptException.java rename to dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/common/AptException.java diff --git a/dev/RuoYi-Vue-master/ruoyi-common/src/main/java/com/ruoyi/apt/common/AptPrefixResolver.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/common/AptPrefixResolver.java similarity index 100% rename from dev/RuoYi-Vue-master/ruoyi-common/src/main/java/com/ruoyi/apt/common/AptPrefixResolver.java rename to dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/common/AptPrefixResolver.java diff --git a/dev/RuoYi-Vue-master/ruoyi-common/src/main/java/com/ruoyi/apt/common/audit/AptAudit.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/common/audit/AptAudit.java similarity index 100% rename from dev/RuoYi-Vue-master/ruoyi-common/src/main/java/com/ruoyi/apt/common/audit/AptAudit.java rename to dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/common/audit/AptAudit.java diff --git a/dev/RuoYi-Vue-master/ruoyi-common/src/main/java/com/ruoyi/apt/common/page/AptPageSupport.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/common/page/AptPageSupport.java similarity index 100% rename from dev/RuoYi-Vue-master/ruoyi-common/src/main/java/com/ruoyi/apt/common/page/AptPageSupport.java rename to dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/common/page/AptPageSupport.java diff --git a/dev/RuoYi-Vue-master/ruoyi-common/src/main/java/com/ruoyi/apt/common/page/AptTableDataInfo.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/common/page/AptTableDataInfo.java similarity index 54% rename from dev/RuoYi-Vue-master/ruoyi-common/src/main/java/com/ruoyi/apt/common/page/AptTableDataInfo.java rename to dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/common/page/AptTableDataInfo.java index bd289a2..45293f3 100644 --- a/dev/RuoYi-Vue-master/ruoyi-common/src/main/java/com/ruoyi/apt/common/page/AptTableDataInfo.java +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/common/page/AptTableDataInfo.java @@ -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)。 + * + *

warnings 用于承载「楼栋字段未启用」等降级提示,默认空数组, + * 不破坏既有列表响应约定。

* * @author apt-rev01 */ @@ -14,6 +19,7 @@ public class AptTableDataInfo extends TableDataInfo private Integer pageSize; private String traceId; + private List warnings = new ArrayList<>(); public Integer getPageSize() { @@ -34,4 +40,14 @@ public class AptTableDataInfo extends TableDataInfo { this.traceId = traceId; } + + public List getWarnings() + { + return warnings; + } + + public void setWarnings(List warnings) + { + this.warnings = warnings == null ? new ArrayList<>() : warnings; + } } diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/AptDispatchScanServiceImpl.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/AptDispatchScanServiceImpl.java index 5b2da49..e136dc4 100644 --- a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/AptDispatchScanServiceImpl.java +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/AptDispatchScanServiceImpl.java @@ -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 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 diff --git a/dev/RuoYi-Vue-master/ruoyi-framework/src/main/java/com/ruoyi/apt/framework/audit/AptAuditAspect.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/audit/AptAuditAspect.java similarity index 93% rename from dev/RuoYi-Vue-master/ruoyi-framework/src/main/java/com/ruoyi/apt/framework/audit/AptAuditAspect.java rename to dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/audit/AptAuditAspect.java index 94bb82f..03d2302 100644 --- a/dev/RuoYi-Vue-master/ruoyi-framework/src/main/java/com/ruoyi/apt/framework/audit/AptAuditAspect.java +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/audit/AptAuditAspect.java @@ -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。 * *

与若依 @Log(落 sys_oper_log)并行,不改上游表;日志写入失败只记 WARN, - * 绝不反向影响业务结果。detail 用 JSON 承载 Q-40 冻结前的扩展要素。

+ * 绝不反向影响业务结果。detail 用 JSON 承载 Q-40 冻结前的扩展要素。 + * IT-2 起经 AptAuditWriter 以 REQUIRES_NEW 独立事务写入,业务回滚不丢留痕。

* * @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) { diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/audit/AptAuditWriter.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/audit/AptAuditWriter.java new file mode 100644 index 0000000..8738829 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/audit/AptAuditWriter.java @@ -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 遗留项)。 + * + *

业务写操作与审计同事务时,业务回滚会连带回滚审计留痕; + * 这里用 REQUIRES_NEW 让 duty_operation_log 独立提交。 + * 切面仍对写库异常降级为 WARN,不反向影响业务结果。

+ * + * @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); + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/config/AptMasterClientMockGuard.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/config/AptMasterClientMockGuard.java new file mode 100644 index 0000000..ccadb9f --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/config/AptMasterClientMockGuard.java @@ -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)。 + * + *

apt.master-client.mock=true 只在 local profile 允许; + * 其它 profile 启动即失败,避免把 source='LOCAL' 的 Mock 数据带到生产。

+ * + * @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'"); + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/config/AptProperties.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/config/AptProperties.java new file mode 100644 index 0000000..2683ddd --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/config/AptProperties.java @@ -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} 表,随公寓实例走)。 + * + *

默认关闭:未接 Asterisk 的环境不得因缺少依赖而启动失败或静默改状态 + * (公寓端/06 §7「依赖缺失显式降级」)。

+ */ + 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/@) */ + 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 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 getDutyExtensions() + { + return dutyExtensions; + } + + public void setDutyExtensions(java.util.List dutyExtensions) + { + this.dutyExtensions = dutyExtensions; + } + + public int getMaxCallsPerScan() + { + return maxCallsPerScan; + } + + public void setMaxCallsPerScan(int maxCallsPerScan) + { + this.maxCallsPerScan = maxCallsPerScan; + } + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-framework/src/main/java/com/ruoyi/apt/framework/security/AptAccessDeniedHandler.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/security/AptAccessDeniedHandler.java similarity index 100% rename from dev/RuoYi-Vue-master/ruoyi-framework/src/main/java/com/ruoyi/apt/framework/security/AptAccessDeniedHandler.java rename to dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/security/AptAccessDeniedHandler.java diff --git a/dev/RuoYi-Vue-master/ruoyi-framework/src/main/java/com/ruoyi/apt/framework/security/AptAuthenticationEntryPoint.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/security/AptAuthenticationEntryPoint.java similarity index 100% rename from dev/RuoYi-Vue-master/ruoyi-framework/src/main/java/com/ruoyi/apt/framework/security/AptAuthenticationEntryPoint.java rename to dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/security/AptAuthenticationEntryPoint.java diff --git a/dev/RuoYi-Vue-master/ruoyi-framework/src/main/java/com/ruoyi/apt/framework/web/AptBaseController.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/web/AptBaseController.java similarity index 100% rename from dev/RuoYi-Vue-master/ruoyi-framework/src/main/java/com/ruoyi/apt/framework/web/AptBaseController.java rename to dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/web/AptBaseController.java diff --git a/dev/RuoYi-Vue-master/ruoyi-framework/src/main/java/com/ruoyi/apt/framework/web/AptContextInterceptor.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/web/AptContextInterceptor.java similarity index 85% rename from dev/RuoYi-Vue-master/ruoyi-framework/src/main/java/com/ruoyi/apt/framework/web/AptContextInterceptor.java rename to dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/web/AptContextInterceptor.java index 7b129e0..b9deb31 100644 --- a/dev/RuoYi-Vue-master/ruoyi-framework/src/main/java/com/ruoyi/apt/framework/web/AptContextInterceptor.java +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/web/AptContextInterceptor.java @@ -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 * 其余路径不在注册范围内。

* @@ -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"); diff --git a/dev/RuoYi-Vue-master/ruoyi-framework/src/main/java/com/ruoyi/apt/framework/web/AptInfraExceptionHandler.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/web/AptInfraExceptionHandler.java similarity index 86% rename from dev/RuoYi-Vue-master/ruoyi-framework/src/main/java/com/ruoyi/apt/framework/web/AptInfraExceptionHandler.java rename to dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/web/AptInfraExceptionHandler.java index 467785f..46d234c 100644 --- a/dev/RuoYi-Vue-master/ruoyi-framework/src/main/java/com/ruoyi/apt/framework/web/AptInfraExceptionHandler.java +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/web/AptInfraExceptionHandler.java @@ -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) 吞掉若依后台业务错误码;公寓端走统一响应体。 diff --git a/dev/RuoYi-Vue-master/ruoyi-framework/src/main/java/com/ruoyi/apt/framework/web/AptWebMvcConfig.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/web/AptWebMvcConfig.java similarity index 100% rename from dev/RuoYi-Vue-master/ruoyi-framework/src/main/java/com/ruoyi/apt/framework/web/AptWebMvcConfig.java rename to dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/web/AptWebMvcConfig.java diff --git a/dev/RuoYi-Vue-master/ruoyi-framework/src/main/java/com/ruoyi/apt/infra/connectivity/AptConnectivityService.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/infra/connectivity/AptConnectivityService.java similarity index 100% rename from dev/RuoYi-Vue-master/ruoyi-framework/src/main/java/com/ruoyi/apt/infra/connectivity/AptConnectivityService.java rename to dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/infra/connectivity/AptConnectivityService.java diff --git a/dev/RuoYi-Vue-master/ruoyi-framework/src/main/java/com/ruoyi/apt/infra/connectivity/ProbeResult.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/infra/connectivity/ProbeResult.java similarity index 100% rename from dev/RuoYi-Vue-master/ruoyi-framework/src/main/java/com/ruoyi/apt/infra/connectivity/ProbeResult.java rename to dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/infra/connectivity/ProbeResult.java diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/master/controller/AptMasterVersionController.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/master/controller/AptMasterVersionController.java new file mode 100644 index 0000000..575dbf0 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/master/controller/AptMasterVersionController.java @@ -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)。 + * + *

权限点说明:表 4-17 权威口径为 apt:ops:health,IT-2 权限点种子另建 + * apt:master:version(3015)。此处用 hasAnyPermi 兼容二者,偏离在交付说明登记。

+ * + * @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 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(); + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/master/controller/AptRouteController.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/master/controller/AptRouteController.java new file mode 100644 index 0000000..8ad91f6 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/master/controller/AptRouteController.java @@ -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)。 + * + *

IT-1 的只读骨架已按表 13-21 迁入 com.ruoyi.apt.master.controller; + * 本轮补齐 CRUD / 启停 / 复算校验。D7 未签前新增与编辑默认由权限点控制, + * 不开放给普通角色;停用原因显式写 duty_operation_log(含 reason)。

+ * + * @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 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"; + } + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/master/domain/AptMasterVersion.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/master/domain/AptMasterVersion.java new file mode 100644 index 0000000..3279f2c --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/master/domain/AptMasterVersion.java @@ -0,0 +1,51 @@ +package com.ruoyi.apt.master.domain; + +import java.util.Date; + +/** + * 主数据快照版本(IT-2 / 任务 5,公寓端/09 表 9-16、04 表 4-17 #1)。 + * + *

Mock 灌入的数据恒为 source='LOCAL' / source_version=0 / state='APPLIED', + * 响应必须显式标识「非平台下发」,不得冒充平台下行。

+ * + * @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; } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/master/domain/AptRoute.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/master/domain/AptRoute.java new file mode 100644 index 0000000..5425084 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/master/domain/AptRoute.java @@ -0,0 +1,69 @@ +package com.ruoyi.apt.master.domain; + +import java.util.Date; + +/** + * 交路主数据(apt_master_route)。 + * + *

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)。

+ * + * @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; } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/master/domain/AptRouteCommand.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/master/domain/AptRouteCommand.java new file mode 100644 index 0000000..f244f17 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/master/domain/AptRouteCommand.java @@ -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)。 + * + *

sourceVersion / createTime 是平台快照只读字段,若入参显式携带则 + * 由 Service 返回 40904(表 4-5 校验规则 5)。

+ * + * @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; } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/master/domain/AptRouteRecalc.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/master/domain/AptRouteRecalc.java new file mode 100644 index 0000000..5d23687 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/master/domain/AptRouteRecalc.java @@ -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 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 getWarnings() { return warnings; } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/master/domain/AptRouteStatusCommand.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/master/domain/AptRouteStatusCommand.java new file mode 100644 index 0000000..d57766a --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/master/domain/AptRouteStatusCommand.java @@ -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; } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/master/mapper/AptMasterVersionMapper.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/master/mapper/AptMasterVersionMapper.java new file mode 100644 index 0000000..39d9506 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/master/mapper/AptMasterVersionMapper.java @@ -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)。 + * + *

只读:业务模块不得写 apt_master_version(公寓端/03 表 3-1); + * 每条 SQL 必带 gyid(AptMapperGyidArchTest 断言)。

+ * + * @author apt-rev01 + */ +public interface AptMasterVersionMapper +{ + List selectVersions(@Param("gyid") String gyid); + + AptMasterVersion selectVersionByDomain(@Param("gyid") String gyid, + @Param("domain") String domain); +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/master/mapper/AptRouteMapper.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/master/mapper/AptRouteMapper.java new file mode 100644 index 0000000..f0f980a --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/master/mapper/AptRouteMapper.java @@ -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 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 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 selectCallTimeRule(@Param("gyid") String gyid); + + /** 指定日期是否为节假日 */ + int countHoliday(@Param("gyid") String gyid, @Param("holidayDate") String holidayDate); +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/master/service/AptMasterVersionServiceImpl.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/master/service/AptMasterVersionServiceImpl.java new file mode 100644 index 0000000..3f0c53d --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/master/service/AptMasterVersionServiceImpl.java @@ -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)。 + * + *

把 source='LOCAL' 显式标成「非平台下发」,供页面按 R-03 展示; + * 域白名单与 apt_master_version 的 6 个 Mock 域一致。

+ * + * @author apt-rev01 + */ +@Service +public class AptMasterVersionServiceImpl implements IAptMasterVersionService +{ + private static final Set DOMAINS = Set.of( + "APARTMENT", "ROOM", "PERSON", "ROUTE", "RULE", "HOLIDAY"); + + private final AptMasterVersionMapper versionMapper; + + public AptMasterVersionServiceImpl(AptMasterVersionMapper versionMapper) + { + this.versionMapper = versionMapper; + } + + @Override + public List list() + { + List 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(); + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/master/service/AptRouteServiceImpl.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/master/service/AptRouteServiceImpl.java new file mode 100644 index 0000000..55d5787 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/master/service/AptRouteServiceImpl.java @@ -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)。 + * + *

校验口径(公寓端/04 表 4-5):必填 cc/kcsj/jcsj/lysj、时序 jcsj<kcsj 与 + * lysj<=kcsj、(gyid,jlid) 唯一、停用原因必填、快照字段只读。 + * gyid 恒取自 AptContext,服务签名不接受任何客户端传入的公寓标识。

+ * + * @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 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 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 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(); + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/master/service/IAptMasterVersionService.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/master/service/IAptMasterVersionService.java new file mode 100644 index 0000000..8ab94f2 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/master/service/IAptMasterVersionService.java @@ -0,0 +1,21 @@ +package com.ruoyi.apt.master.service; + +import java.util.List; +import com.ruoyi.apt.master.domain.AptMasterVersion; + +/** + * 主数据快照版本只读服务(IT-2 / 任务 5)。 + * + *

服务签名不接受客户端 gyid;resync 在平台未就绪前恒拒绝,不允许假成功。

+ * + * @author apt-rev01 + */ +public interface IAptMasterVersionService +{ + List list(); + + AptMasterVersion detail(String domain); + + /** 表 4-17 #2:本期不开放全量重拉,调用即抛 40940 */ + void resync(String masterType); +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/master/service/IAptRouteService.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/master/service/IAptRouteService.java new file mode 100644 index 0000000..dd6aacf --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/master/service/IAptRouteService.java @@ -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 list(String cc, String jlid, String state); + + AptRoute detail(Long id); + + List 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); +} diff --git a/dev/RuoYi-Vue-master/ruoyi-admin/src/main/java/com/ruoyi/apt/ops/controller/AptConnectivityController.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/ops/controller/AptConnectivityController.java similarity index 100% rename from dev/RuoYi-Vue-master/ruoyi-admin/src/main/java/com/ruoyi/apt/ops/controller/AptConnectivityController.java rename to dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/ops/controller/AptConnectivityController.java diff --git a/dev/RuoYi-Vue-master/ruoyi-system/src/main/java/com/ruoyi/apt/ops/domain/AptOperationLog.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/ops/domain/AptOperationLog.java similarity index 100% rename from dev/RuoYi-Vue-master/ruoyi-system/src/main/java/com/ruoyi/apt/ops/domain/AptOperationLog.java rename to dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/ops/domain/AptOperationLog.java diff --git a/dev/RuoYi-Vue-master/ruoyi-system/src/main/java/com/ruoyi/apt/ops/mapper/AptAuditMapper.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/ops/mapper/AptAuditMapper.java similarity index 100% rename from dev/RuoYi-Vue-master/ruoyi-system/src/main/java/com/ruoyi/apt/ops/mapper/AptAuditMapper.java rename to dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/ops/mapper/AptAuditMapper.java diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/controller/AptRoomController.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/controller/AptRoomController.java new file mode 100644 index 0000000..f25963d --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/controller/AptRoomController.java @@ -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)。 + * + *

楼栋字段未启用(G-01)时 building 降级为楼层前缀匹配并在 warnings[] 标注; + * 停用/恢复原因显式写 duty_operation_log;新增房间在 G-01/G-04 签字前恒返回 40940。

+ * + * @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 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 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"; + } + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/domain/AptRoom.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/domain/AptRoom.java new file mode 100644 index 0000000..9d36dc4 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/domain/AptRoom.java @@ -0,0 +1,74 @@ +package com.ruoyi.apt.stay.domain; + +/** + * 房间档案 + 房态(IT-2 / M2,公寓端/04 表 4-7)。 + * + *

gy_rooms 是运行时事实,apt_master_room 是平台下发基线(只读)。 + * room_status 只能由 apt-stay 房态重算服务写入,管理端只提交停用/恢复命令。

+ * + * @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; } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/domain/AptRoomCommand.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/domain/AptRoomCommand.java new file mode 100644 index 0000000..16eda2b --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/domain/AptRoomCommand.java @@ -0,0 +1,40 @@ +package com.ruoyi.apt.stay.domain; + +/** + * 房间本地属性编辑入参(表 4-7 #4)。 + * + *

bedNumbers / roomFloor / sourceVersion 属平台快照字段, + * 入参携带即返回 40904 SNAPSHOT_READONLY(表 4-7 实现约束 4)。

+ * + * @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; } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/domain/AptRoomDetail.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/domain/AptRoomDetail.java new file mode 100644 index 0000000..a2b840a --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/domain/AptRoomDetail.java @@ -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> bindings = new ArrayList<>(); + private final List> livePersons = new ArrayList<>(); + private final List> recentCleanTasks = new ArrayList<>(); + + public List> getBindings() { return bindings; } + public List> getLivePersons() { return livePersons; } + public List> getRecentCleanTasks() { return recentCleanTasks; } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/domain/AptRoomStatusCommand.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/domain/AptRoomStatusCommand.java new file mode 100644 index 0000000..a5a023d --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/domain/AptRoomStatusCommand.java @@ -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; } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/domain/AptRoomStatusResult.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/domain/AptRoomStatusResult.java new file mode 100644 index 0000000..d6c55ab --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/domain/AptRoomStatusResult.java @@ -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; } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/domain/AptRoomStatusSummary.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/domain/AptRoomStatusSummary.java new file mode 100644 index 0000000..e3f7aca --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/domain/AptRoomStatusSummary.java @@ -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; } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/mapper/AptRoomMapper.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/mapper/AptRoomMapper.java new file mode 100644 index 0000000..5acff98 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/mapper/AptRoomMapper.java @@ -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)。 + * + *

硬约束:每条 SQL 必带 gyid(AptMapperGyidArchTest 断言); + * 房态更新必须带 state_version 条件,管理端不得直写 room_status。

+ * + * @author apt-rev01 + */ +public interface AptRoomMapper +{ + List selectRoomList(@Param("gyid") String gyid, + @Param("building") String building, + @Param("roomFloor") String roomFloor, + @Param("roomNo") String roomNo, + @Param("roomStatuses") List roomStatuses, + @Param("roomType") String roomType); + + AptRoom selectRoomById(@Param("gyid") String gyid, @Param("id") Long id); + + List> selectRoomBindings(@Param("gyid") String gyid, + @Param("roomId") String roomId); + + List> selectLivePersons(@Param("gyid") String gyid, + @Param("roomNo") String roomNo); + + List> selectRecentCleanTasks(@Param("gyid") String gyid, + @Param("roomId") String roomId); + + Map 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 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); +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/service/AptRoomServiceImpl.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/service/AptRoomServiceImpl.java new file mode 100644 index 0000000..e377d01 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/service/AptRoomServiceImpl.java @@ -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)。 + * + *

口径(公寓端/04 表 4-7、07 表 7-3):

+ *
    + *
  • 停用 = 房态置 4 并记原因,不改在寓人员、不停叫乘;
  • + *
  • 恢复 = 触发房态重算(保洁任务 / 在寓人数 / 床位数),清空原因;
  • + *
  • 两者都走 state_version 条件更新,冲突返回 40903;
  • + *
  • commandId 落 duty_manual_action 唯一键,重复返回 duplicated=true。
  • + *
+ * + * @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 list(String building, String roomFloor, String roomNo, + List 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 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 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 normalize(List statuses) + { + if (statuses == null || statuses.isEmpty()) + { + return null; + } + List 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> nonNull(List> 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(); + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/service/IAptRoomService.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/service/IAptRoomService.java new file mode 100644 index 0000000..cd76e84 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/service/IAptRoomService.java @@ -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)。 + * + *

服务签名不接受客户端 gyid;隔离值恒由 AptContext 注入(D5 一进程一 gyid)。

+ * + * @author apt-rev01 + */ +public interface IAptRoomService +{ + List list(String building, String roomFloor, String roomNo, + List roomStatuses, String roomType); + + AptRoomDetail detail(Long id); + + AptRoomStatusSummary statusSummary(); + + void edit(Long id, AptRoomCommand command); + + AptRoomStatusResult changeStatus(Long id, AptRoomStatusCommand command); +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/ami/AmiAction.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/ami/AmiAction.java new file mode 100644 index 0000000..f4c08e4 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/ami/AmiAction.java @@ -0,0 +1,118 @@ +package com.ruoyi.apt.voice.ami; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * AMI Action(发送给 Asterisk 的指令)。 + * + *

只覆盖本期用到的 5 类:Login(明文 / MD5 挑战)、Ping、Originate、Playback、Hangup; + * 其余能力按需追加,不引入第三方协议库(见设计文档 19 §7 偏离登记)。

+ * + * @author apt-rev01 + */ +public class AmiAction +{ + private final Map 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 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"); + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/ami/AmiClient.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/ami/AmiClient.java new file mode 100644 index 0000000..ee99957 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/ami/AmiClient.java @@ -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 客户端(一公寓一实例,一实例一连接)。 + * + *

职责与边界:

+ *
    + *
  • TCP 建连 + Login(明文或 MD5 挑战,自适应)→ 启动读线程;
  • + *
  • Action 发送:按 {@code ActionID} 与 Response 配对,带超时;
  • + *
  • Event 分发:读线程解析成 {@link AmiFrame} 后 **直接回调**,实现方须立即转队列;
  • + *
  • 断连:IO 异常/对端关闭 → 失败所有在途 Action → 回调 {@code onDisconnect};
  • + *
  • 不负责重连与状态落库(那是 {@link AmiConnectionManager} 的职责)。
  • + *
+ * + * @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> pending = new ConcurrentHashMap<>(); + private final List 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 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 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 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 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 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) + { + // 关闭失败无需处理 + } + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/ami/AmiClientFactory.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/ami/AmiClientFactory.java new file mode 100644 index 0000000..59bc567 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/ami/AmiClientFactory.java @@ -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()); + } + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/ami/AmiConnectionManager.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/ami/AmiConnectionManager.java new file mode 100644 index 0000000..98640d4 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/ami/AmiConnectionManager.java @@ -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 实例 → 一连接)。 + * + *

生命周期:

+ *
+ * 启动 → CONNECTING →(Login 成功)→ UP → 心跳 Ping(每 ping-interval)
+ *                   ↘(失败)     → DOWN → 指数退避重连(1s→2s→4s…封顶 60s,抖动 ±20%)
+ * UP 心跳失败 → DEGRADED → 关闭旧连接 → 重连
+ * 应用关闭 / 显式释放 → CLOSED(不触发重连)
+ * 
+ * + *

为什么不用 Quartz:连接心跳与业务调度无关,且若依 {@code ScheduleConfig} + * 当前被注释(表 13-15 依赖项),用自建单线程调度器可保证「有连接就有心跳」, + * 不受任务表启停影响。连接与状态落库都在这里,事件处理在 + * {@link AptAmiEventDispatcher} 的单线程队列中串行执行。

+ * + * @author apt-rev01 + */ +@Component +public class AmiConnectionManager implements ApplicationListener +{ + 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 status() + { + AmiClient current = client; + Map 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"); + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/ami/AmiEventListener.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/ami/AmiEventListener.java new file mode 100644 index 0000000..9544101 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/ami/AmiEventListener.java @@ -0,0 +1,23 @@ +package com.ruoyi.apt.voice.ami; + +/** + * AMI 事件回调。 + * + *

重要:回调运行在 AMI 读线程上,实现方必须立刻转交到自己的单线程队列 + * (见 {@code AptAmiEventDispatcher}),不得在此做数据库或阻塞操作, + * 否则会拖死整个 AMI 连接(含心跳响应)。

+ * + * @author apt-rev01 + */ +public interface AmiEventListener +{ + /** 收到一个 Event 帧(不含 Action 响应) */ + default void onEvent(AmiFrame frame) + { + } + + /** 连接被动断开(IO 异常 / 对端关闭);主动 close 不触发 */ + default void onDisconnect(Throwable cause) + { + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/ami/AmiException.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/ami/AmiException.java new file mode 100644 index 0000000..0eac0b1 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/ami/AmiException.java @@ -0,0 +1,58 @@ +package com.ruoyi.apt.voice.ami; + +/** + * AMI 协议/连接异常。 + * + *

{@code reason} 是脱敏的机器可读原因(不含口令),会落 + * {@code apt_ast_instance.last_error} 与 {@code apt_ast_ami_session.last_error}。

+ * + * @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); + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/ami/AmiFrame.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/ami/AmiFrame.java new file mode 100644 index 0000000..40c6224 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/ami/AmiFrame.java @@ -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)。 + * + *

AMI 是纯文本行协议:每条报文由若干 {@code Key: Value} 行组成,以空行结束。 + * 本类用大小写不敏感的键存储(内部统一小写),保留原始顺序便于排查。

+ * + * @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 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 asMap() + { + return Collections.unmodifiableMap(attributes); + } + + @Override + public String toString() + { + return (isEvent() ? "Event:" + eventName() : "Response:" + response()) + + " actionId=" + actionId(); + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/ami/AmiProtocol.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/ami/AmiProtocol.java new file mode 100644 index 0000000..aed49d4 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/ami/AmiProtocol.java @@ -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 文本协议编解码(纯静态,便于单测)。 + * + *

编码:{@code Key: Value\r\n} × n + {@code \r\n}; + * 解码:连续非空行组成一帧,空行结束。

+ * + * @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 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); + } + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/domain/AptAmiInstance.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/domain/AptAmiInstance.java new file mode 100644 index 0000000..a57d86a --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/domain/AptAmiInstance.java @@ -0,0 +1,290 @@ +package com.ruoyi.apt.voice.domain; + +import java.util.Date; + +/** + * 公寓 ↔ Asterisk 实例映射与 AMI 连接配置(表 {@code apt_ast_instance},1:1)。 + * + *

口令不在本对象里:只保存环境变量引用 {@code amiSecretRef}, + * 运行时由 {@code AptAmiInstanceService.resolveSecret()} 解析。

+ * + * @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; + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/mapper/AptVoiceMapper.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/mapper/AptVoiceMapper.java new file mode 100644 index 0000000..32c6e2c --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/mapper/AptVoiceMapper.java @@ -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)。 + * + *

硬约束:每条 SQL 必带 gyid;本 Mapper 不写 {@code gy_jcxx} 的任何字段 + * (叫乘状态仍只由 {@code apt.duty.state} 写)。

+ * + * @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 selectCallSession(@Param("gyid") String gyid, + @Param("sessionId") String sessionId); + + Map selectSessionByActionId(@Param("gyid") String gyid, + @Param("actionId") String actionId); + + Map selectSessionByChannel(@Param("gyid") String gyid, + @Param("channel") String channel); + + // ------------------------------------------------------------ 叫乘候选 + /** 到点待发起 SIP 叫乘:ryzt=0、未停止、未完成未离寓、jcsj<=now */ + List> selectDueSipCalls(@Param("gyid") String gyid, + @Param("now") Date now, + @Param("limit") int limit); + + Map selectJcxxForCall(@Param("gyid") String gyid, @Param("jcid") String jcid); + + /** 房间主分机(分机台账 + 房间绑定,ACTIVE 且在生效期内) */ + Map 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); +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/service/AptAmiEventDispatcher.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/service/AptAmiEventDispatcher.java new file mode 100644 index 0000000..b68501c --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/service/AptAmiEventDispatcher.java @@ -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 事件串行分发器。 + * + *

读线程回调必须"只入队不干活":本类用**单线程 + 有界队列**保证 + * 同一实例的事件严格按到达顺序处理(ConfbridgeJoin 之后才是 Hangup), + * 同时在任务里绑定 {@link AptContext}(后台线程没有 HTTP 上下文)。

+ * + *

队列满时丢弃并告警(不阻塞读线程),丢弃意味着该事件后由 + * 「5 分钟巡检 + 状态机幂等」兜底,不会造成状态回退。

+ * + * @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(); + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/service/AptAmiEventHandler.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/service/AptAmiEventHandler.java new file mode 100644 index 0000000..1c3292b --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/service/AptAmiEventHandler.java @@ -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)。 + * + *

关键判定:

+ *
    + *
  • 房间腿的 {@code ConfbridgeJoin} 才算应答(提交 {@code ANSWERED}), + * 值班台腿入会只记会话状态;
  • + *
  • {@code OriginateResponse.Failure} 只有房间腿失败才提交 {@code CALL_FAILED};
  • + *
  • {@code Hangup} 只归档会话,不回写叫乘终态;
  • + *
  • AMI 断连 → 在途会话标 {@code UNKNOWN} + 立即触发重连,不推定叫乘结论。
  • + *
+ * + *

本类运行在 {@code apt-ami-event} 单线程队列中,可安全做数据库操作。

+ * + * @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 callFlowProvider; + private final ObjectProvider connectionManagerProvider; + + public AptAmiEventHandler(AptVoiceMapper voiceMapper, IAptVoiceEventService voiceEventService, + AptAmiInstanceService instanceService, AptProperties properties, + ObjectProvider callFlowProvider, + ObjectProvider 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 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 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 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 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 session) + { + String actionId = str(session.get("originateActionId")); + if (actionId.startsWith("TTS-")) + { + voiceMapper.updateTts(gyid, actionId.substring(4), "PLAYED", null, null); + } + } + + private Map byChannel(String gyid, AmiFrame frame) + { + String channel = frame.get("channel"); + if (channel == null || channel.isBlank()) + { + return null; + } + Map session = voiceMapper.selectSessionByChannel(gyid, channel); + if (session == null) + { + log.debug("事件 {} 未匹配到会话 channel={}", frame.eventName(), channel); + } + return session; + } + + private void record(String gyid, Map 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); + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/service/AptAmiInstanceService.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/service/AptAmiInstanceService.java new file mode 100644 index 0000000..e800820 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/service/AptAmiInstanceService.java @@ -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 实例配置与连接状态落库。 + * + *

口令解析规则:表里只存环境变量名(如 {@code APT_AMI_SECRET}), + * 运行时从进程环境读取;读不到即 {@code AMI_CONFIG_MISSING}, + * 不回落默认口令、不写入日志(红线:AMI 口令不进仓库、不落库)。

+ * + * @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); + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/service/AptCallFlowServiceImpl.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/service/AptCallFlowServiceImpl.java new file mode 100644 index 0000000..7610120 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/service/AptCallFlowServiceImpl.java @@ -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)。 + * + *

调用顺序(缺一不可):

+ *
+ * 校验 ryzt=0 → 解析房间分机 → CALL_START(0→1) → 建腿(apt_call_session)
+ *   → AMI Originate(房间 + 值班台)→ 等 OriginateResponse 绑定真实通道
+ *   → ConfbridgeJoin(房间腿) → ANSWERED(1→3) → TTS 播报
+ * 
+ * + *

失败语义:房间腿 Originate 失败 → {@code CALL_FAILED}(ryzt=2); + * 值班台腿失败只告警,不影响房间侧结论;TTS 失败只告警,不改叫乘结论。

+ * + * @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 dial(String jcid) + { + String gyid = gyid(); + Map 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 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> 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 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> due = voiceMapper.selectDueSipCalls(gyid, new Date(), limit); + int started = 0; + for (Map row : due == null ? List.>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 playTts(String sessionId, String text) + { + String gyid = gyid(); + Map 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 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 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 legSummary(String sessionId, String legRole, String extension, + boolean started) + { + Map 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(); + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/service/IAptCallFlowService.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/service/IAptCallFlowService.java new file mode 100644 index 0000000..0d01b66 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/service/IAptCallFlowService.java @@ -0,0 +1,26 @@ +package com.ruoyi.apt.voice.service; + +import java.util.Map; + +/** + * SIP 叫乘执行链路(Originate → 状态跟踪 → TTS)。 + * + *

只提交状态机命令,不直写 {@code gy_jcxx};一条 jcid 双路呼叫(房间 + 值班台), + * 房间腿的 {@code ConfbridgeJoin} 才判「应答」。

+ * + * @author apt-rev01 + */ +public interface IAptCallFlowService +{ + /** 对单条叫乘发起 SIP 双路呼叫(要求 ryzt=0 且 AMI 已就绪) */ + Map dial(String jcid); + + /** 扫描到点待叫乘并逐条发起呼叫,返回成功发起数 */ + int dialDue(int limit); + + /** 在会话内播放 TTS 提示语音;失败只告警,不改叫乘结论 */ + Map playTts(String sessionId, String text); + + /** 挂断某条呼叫腿 */ + void hangup(String sessionId, String cause); +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/apt-defaults.yml b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/apt-defaults.yml new file mode 100644 index 0000000..8ad26e9 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/apt-defaults.yml @@ -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:请到门口集合,准备乘车} diff --git a/dev/RuoYi-Vue-master/ruoyi-system/src/main/resources/mapper/apt/AptAuditMapper.xml b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptAuditMapper.xml similarity index 100% rename from dev/RuoYi-Vue-master/ruoyi-system/src/main/resources/mapper/apt/AptAuditMapper.xml rename to dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptAuditMapper.xml diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptMasterVersionMapper.xml b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptMasterVersionMapper.xml new file mode 100644 index 0000000..7e4bef5 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptMasterVersionMapper.xml @@ -0,0 +1,37 @@ + + + + + + + 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 + + + + + + + diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptRoomMapper.xml b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptRoomMapper.xml new file mode 100644 index 0000000..6803ecd --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptRoomMapper.xml @@ -0,0 +1,173 @@ + + + + + + + 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 + + + + 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 + + + + + + + + + + + + + + + + + + UPDATE gy_rooms + + room_type = #{command.roomType}, + room_dept = #{command.roomDept}, + room_order = #{command.roomOrder}, + room_dir = #{command.roomDir}, + room_face = #{command.roomFace}, + update_time = NOW(3) + + WHERE gyid = #{gyid} AND id = #{id} AND deleted = 0 + + + + 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 + + + + + + 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}) + + + + UPDATE duty_manual_action + SET state = #{state}, result_code = #{resultCode} + WHERE gyid = #{gyid} AND action_id = #{actionId} + + + + UPDATE duty_manual_action + SET duplicated = 1 + WHERE gyid = #{gyid} AND action_id = #{actionId} + + + diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptRouteMapper.xml b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptRouteMapper.xml new file mode 100644 index 0000000..0468078 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptRouteMapper.xml @@ -0,0 +1,101 @@ + + + + + + + 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 + + + + + + + + + + + + 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) + + + + UPDATE apt_master_route + + jlid = #{jlid}, cc = #{cc}, kcsj = #{kcsj}, jcsj = #{jcsj}, lysj = #{lysj}, + route_name = #{jch}, start_station = #{qd}, end_station = #{zd}, + cclx = #{cclx}, bz = #{bz} + + WHERE gyid = #{gyid} AND id = #{id} AND deleted = 0 + + + + UPDATE apt_master_route + SET state = #{state}, stop_reason = #{stopReason} + WHERE gyid = #{gyid} AND id = #{id} AND deleted = 0 + + + + + + + diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptVoiceMapper.xml b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptVoiceMapper.xml new file mode 100644 index 0000000..121f110 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptVoiceMapper.xml @@ -0,0 +1,208 @@ + + + + + + + + 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 + + + + + + UPDATE apt_ast_instance + SET state = #{state}, + last_error = #{lastError}, + biz_version = biz_version + 1 + WHERE gyid = #{gyid} AND deleted = 0 + + + + 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 apt_ast_instance + SET last_ping_at = NOW(3), + last_error = NULL + WHERE gyid = #{gyid} AND deleted = 0 + + + + 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}) + + + + 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 apt_ast_ami_session + SET last_ping_at = NOW(3) + WHERE gyid = #{gyid} AND ami_session_id = #{amiSessionId} + + + + 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}) + + + + UPDATE apt_call_session + + call_state = #{callState}, + answered_at = COALESCE(answered_at, NOW(3)), + ended_at = COALESCE(ended_at, NOW(3)), + hangup_cause = #{hangupCause}, + biz_version = biz_version + 1 + + WHERE gyid = #{gyid} AND session_id = #{sessionId} + + + + UPDATE apt_call_session + SET channel = #{channel}, + call_state = 'RINGING', + biz_version = biz_version + 1 + WHERE gyid = #{gyid} AND session_id = #{sessionId} + + + + 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') + + + + + + + + + + + + + + + + + + INSERT INTO apt_tts_utterance + (gyid, utterance_id, session_id, utterance_seq, text_ref, state) + VALUES + (#{gyid}, #{utteranceId}, #{sessionId}, #{seq}, #{textRef}, 'PENDING') + + + + 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} + + + diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/static/apt/.gitkeep b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/static/apt/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/dev/RuoYi-Vue-master/ruoyi-admin/src/test/java/com/ruoyi/apt/common/AptContextTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/common/AptContextTest.java similarity index 100% rename from dev/RuoYi-Vue-master/ruoyi-admin/src/test/java/com/ruoyi/apt/common/AptContextTest.java rename to dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/common/AptContextTest.java diff --git a/dev/RuoYi-Vue-master/ruoyi-admin/src/test/java/com/ruoyi/apt/common/AptPageSupportTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/common/AptPageSupportTest.java similarity index 100% rename from dev/RuoYi-Vue-master/ruoyi-admin/src/test/java/com/ruoyi/apt/common/AptPageSupportTest.java rename to dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/common/AptPageSupportTest.java diff --git a/dev/RuoYi-Vue-master/ruoyi-admin/src/test/java/com/ruoyi/apt/common/AptPrefixResolverTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/common/AptPrefixResolverTest.java similarity index 100% rename from dev/RuoYi-Vue-master/ruoyi-admin/src/test/java/com/ruoyi/apt/common/AptPrefixResolverTest.java rename to dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/common/AptPrefixResolverTest.java diff --git a/dev/RuoYi-Vue-master/ruoyi-admin/src/test/java/com/ruoyi/apt/framework/AptAccessDeniedHandlerTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/framework/AptAccessDeniedHandlerTest.java similarity index 100% rename from dev/RuoYi-Vue-master/ruoyi-admin/src/test/java/com/ruoyi/apt/framework/AptAccessDeniedHandlerTest.java rename to dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/framework/AptAccessDeniedHandlerTest.java diff --git a/dev/RuoYi-Vue-master/ruoyi-admin/src/test/java/com/ruoyi/apt/framework/AptAuditAspectTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/framework/AptAuditAspectTest.java similarity index 92% rename from dev/RuoYi-Vue-master/ruoyi-admin/src/test/java/com/ruoyi/apt/framework/AptAuditAspectTest.java rename to dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/framework/AptAuditAspectTest.java index 5a088ab..055fae7 100644 --- a/dev/RuoYi-Vue-master/ruoyi-admin/src/test/java/com/ruoyi/apt/framework/AptAuditAspectTest.java +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/framework/AptAuditAspectTest.java @@ -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 captor = ArgumentCaptor.forClass(AptOperationLog.class); - verify(mapper).insertOperationLog(captor.capture()); + verify(writer).write(captor.capture()); return captor.getValue(); } } diff --git a/dev/RuoYi-Vue-master/ruoyi-admin/src/test/java/com/ruoyi/apt/framework/AptAuthInterceptorTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/framework/AptAuthInterceptorTest.java similarity index 95% rename from dev/RuoYi-Vue-master/ruoyi-admin/src/test/java/com/ruoyi/apt/framework/AptAuthInterceptorTest.java rename to dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/framework/AptAuthInterceptorTest.java index e6ebd37..a59c578 100644 --- a/dev/RuoYi-Vue-master/ruoyi-admin/src/test/java/com/ruoyi/apt/framework/AptAuthInterceptorTest.java +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/framework/AptAuthInterceptorTest.java @@ -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); diff --git a/dev/RuoYi-Vue-master/ruoyi-admin/src/test/java/com/ruoyi/apt/framework/AptErrorResponseTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/framework/AptErrorResponseTest.java similarity index 100% rename from dev/RuoYi-Vue-master/ruoyi-admin/src/test/java/com/ruoyi/apt/framework/AptErrorResponseTest.java rename to dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/framework/AptErrorResponseTest.java diff --git a/dev/RuoYi-Vue-master/ruoyi-admin/src/test/java/com/ruoyi/apt/framework/AptInfraExceptionHandlerTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/framework/AptInfraExceptionHandlerTest.java similarity index 100% rename from dev/RuoYi-Vue-master/ruoyi-admin/src/test/java/com/ruoyi/apt/framework/AptInfraExceptionHandlerTest.java rename to dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/framework/AptInfraExceptionHandlerTest.java diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/framework/AptIt2SeedMarkersTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/framework/AptIt2SeedMarkersTest.java new file mode 100644 index 0000000..ab0eacf --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/framework/AptIt2SeedMarkersTest.java @@ -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 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; + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-admin/src/test/java/com/ruoyi/apt/framework/AptMapperGyidArchTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/framework/AptMapperGyidArchTest.java similarity index 68% rename from dev/RuoYi-Vue-master/ruoyi-admin/src/test/java/com/ruoyi/apt/framework/AptMapperGyidArchTest.java rename to dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/framework/AptMapperGyidArchTest.java index 3f138bd..804ebe3 100644 --- a/dev/RuoYi-Vue-master/ruoyi-admin/src/test/java/com/ruoyi/apt/framework/AptMapperGyidArchTest.java +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/framework/AptMapperGyidArchTest.java @@ -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 files; try (java.util.stream.Stream 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++; diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/framework/AptMasterClientMockGuardTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/framework/AptMasterClientMockGuardTest.java new file mode 100644 index 0000000..91b4eaa --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/framework/AptMasterClientMockGuardTest.java @@ -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()); + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/master/AptMasterVersionServiceTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/master/AptMasterVersionServiceTest.java new file mode 100644 index 0000000..06ba8a5 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/master/AptMasterVersionServiceTest.java @@ -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 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; + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/master/AptRouteServiceTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/master/AptRouteServiceTest.java new file mode 100644 index 0000000..2af4de3 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/master/AptRouteServiceTest.java @@ -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; + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-admin/src/test/java/com/ruoyi/apt/ops/AptConnectivityControllerTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/ops/AptConnectivityControllerTest.java similarity index 100% rename from dev/RuoYi-Vue-master/ruoyi-admin/src/test/java/com/ruoyi/apt/ops/AptConnectivityControllerTest.java rename to dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/ops/AptConnectivityControllerTest.java diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/stay/AptRoomServiceTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/stay/AptRoomServiceTest.java new file mode 100644 index 0000000..bb57c77 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/stay/AptRoomServiceTest.java @@ -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> 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 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 row(String key, String value) + { + Map row = new LinkedHashMap<>(); + row.put(key, value); + return row; + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/voice/AmiClientTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/voice/AmiClientTest.java new file mode 100644 index 0000000..6b4f972 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/voice/AmiClientTest.java @@ -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 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()); + } + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/voice/AmiProtocolTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/voice/AmiProtocolTest.java new file mode 100644 index 0000000..05270a5 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/voice/AmiProtocolTest.java @@ -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")); + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/voice/FakeAmiServer.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/voice/FakeAmiServer.java new file mode 100644 index 0000000..9ee5c28 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/voice/FakeAmiServer.java @@ -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 receivedActions = Collections.synchronizedList(new ArrayList<>()); + private final List> 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> actions() + { + return actions; + } + + List 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 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 lines) throws IOException + { + Map 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(); + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-framework/src/main/java/com/ruoyi/apt/framework/config/AptProperties.java b/dev/RuoYi-Vue-master/ruoyi-framework/src/main/java/com/ruoyi/apt/framework/config/AptProperties.java deleted file mode 100644 index cd033c4..0000000 --- a/dev/RuoYi-Vue-master/ruoyi-framework/src/main/java/com/ruoyi/apt/framework/config/AptProperties.java +++ /dev/null @@ -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; - } - } -} diff --git a/dev/RuoYi-Vue-master/ruoyi-framework/src/main/java/com/ruoyi/framework/config/SecurityConfig.java b/dev/RuoYi-Vue-master/ruoyi-framework/src/main/java/com/ruoyi/framework/config/SecurityConfig.java index 508ceee..704e4eb 100644 --- a/dev/RuoYi-Vue-master/ruoyi-framework/src/main/java/com/ruoyi/framework/config/SecurityConfig.java +++ b/dev/RuoYi-Vue-master/ruoyi-framework/src/main/java/com/ruoyi/framework/config/SecurityConfig.java @@ -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。

* + *

ruoyi-apt 模块归置后:本类只依赖 Spring Security 标准接口,按 bean 名 + * 注入 ruoyi-apt 提供的实现,避免 framework 与 ruoyi-apt 形成编译期循环依赖。 + * 公寓端处理器对非公寓端路径仍委托若依原行为。

+ * * @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; /** * 退出处理类 diff --git a/dev/RuoYi-Vue-master/ruoyi-system/src/main/java/com/ruoyi/apt/master/domain/AptRoute.java b/dev/RuoYi-Vue-master/ruoyi-system/src/main/java/com/ruoyi/apt/master/domain/AptRoute.java deleted file mode 100644 index 82a13ac..0000000 --- a/dev/RuoYi-Vue-master/ruoyi-system/src/main/java/com/ruoyi/apt/master/domain/AptRoute.java +++ /dev/null @@ -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; } -} diff --git a/dev/RuoYi-Vue-master/ruoyi-system/src/main/java/com/ruoyi/apt/master/mapper/AptRouteMapper.java b/dev/RuoYi-Vue-master/ruoyi-system/src/main/java/com/ruoyi/apt/master/mapper/AptRouteMapper.java deleted file mode 100644 index 78937b8..0000000 --- a/dev/RuoYi-Vue-master/ruoyi-system/src/main/java/com/ruoyi/apt/master/mapper/AptRouteMapper.java +++ /dev/null @@ -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 selectRouteList(@Param("gyid") String gyid, - @Param("cc") String cc, - @Param("jlid") String jlid, - @Param("state") String state); -} diff --git a/dev/RuoYi-Vue-master/ruoyi-system/src/main/resources/mapper/apt/AptRouteMapper.xml b/dev/RuoYi-Vue-master/ruoyi-system/src/main/resources/mapper/apt/AptRouteMapper.xml deleted file mode 100644 index 85b908e..0000000 --- a/dev/RuoYi-Vue-master/ruoyi-system/src/main/resources/mapper/apt/AptRouteMapper.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - diff --git a/dev/RuoYi-Vue2-master/src/api/apt/checkin.js b/dev/RuoYi-Vue2-master/src/api/apt/checkin.js new file mode 100644 index 0000000..b06f74b --- /dev/null +++ b/dev/RuoYi-Vue2-master/src/api/apt/checkin.js @@ -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' + }) +} diff --git a/dev/RuoYi-Vue2-master/src/api/apt/face.js b/dev/RuoYi-Vue2-master/src/api/apt/face.js new file mode 100644 index 0000000..fa8b98e --- /dev/null +++ b/dev/RuoYi-Vue2-master/src/api/apt/face.js @@ -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 + }) +} diff --git a/dev/RuoYi-Vue2-master/src/api/apt/jcxx.js b/dev/RuoYi-Vue2-master/src/api/apt/jcxx.js new file mode 100644 index 0000000..bb61ab3 --- /dev/null +++ b/dev/RuoYi-Vue2-master/src/api/apt/jcxx.js @@ -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 + }) +} diff --git a/dev/RuoYi-Vue2-master/src/api/apt/master.js b/dev/RuoYi-Vue2-master/src/api/apt/master.js new file mode 100644 index 0000000..38c02d1 --- /dev/null +++ b/dev/RuoYi-Vue2-master/src/api/apt/master.js @@ -0,0 +1,28 @@ +import request from '@/utils/request' + +// 公寓端主数据版本只读台账:对应 AptMasterVersionController(/apt/master),IT-2 表 16-12 +// 权限口径:@ss.hasAnyPermi('apt:ops:health,apt:master:version') + +// 查询 6 域版本列表(顶层可能含 mockNotice) +export function listMasterVersion() { + return request({ + url: '/apt/master/versions', + method: 'get' + }) +} + +// 查询单域版本(domain 白名单 APARTMENT/ROOM/PERSON/ROUTE/RULE/HOLIDAY) +export function getMasterVersion(domain) { + return request({ + url: '/apt/master/versions/' + domain, + method: 'get' + }) +} + +// 主数据重同步(后端恒 40940 MASTER_RESYNC_DISABLED;保留契约,页面不提供可点击入口) +export function resyncMaster(masterType) { + return request({ + url: '/apt/master/' + masterType + '/resync', + method: 'post' + }) +} diff --git a/dev/RuoYi-Vue2-master/src/api/apt/ops.js b/dev/RuoYi-Vue2-master/src/api/apt/ops.js new file mode 100644 index 0000000..145a432 --- /dev/null +++ b/dev/RuoYi-Vue2-master/src/api/apt/ops.js @@ -0,0 +1,30 @@ +import request from '@/utils/request' + +// 公寓端运维连通性自检:对应 AptConnectivityController(/apt/ops/connectivity) +// 接口为 @Anonymous,但后端要求 apt.probe.enabled=true 且仅回环地址, +// 经非回环反向代理访问会返回 40301/NON_LOOPBACK_FORBIDDEN,页面需如实提示。 + +// 只读连通性检查 +export function checkConnectivity() { + return request({ + url: '/apt/ops/connectivity', + method: 'get' + }) +} + +// 读写闭环探针(target=DB|REDIS|ALL,strict=true 时失败抛业务异常) +export function probeConnectivity(data) { + return request({ + url: '/apt/ops/connectivity/probe', + method: 'post', + data: data + }) +} + +// 错误链路探针:恒触发 500 / UNEXPECTED_ERROR,仅用于验收脚本,不提供页面入口 +export function errorProbe() { + return request({ + url: '/apt/ops/connectivity/error-probe', + method: 'get' + }) +} diff --git a/dev/RuoYi-Vue2-master/src/api/apt/room.js b/dev/RuoYi-Vue2-master/src/api/apt/room.js new file mode 100644 index 0000000..517279b --- /dev/null +++ b/dev/RuoYi-Vue2-master/src/api/apt/room.js @@ -0,0 +1,48 @@ +import request from '@/utils/request' + +// 公寓端房间管理:对应 AptRoomController(/apt/room),IT-2 / M2 表 16-9 +// 说明:POST /apt/room 恒返回 40940 ROOM_ADD_DISABLED,后端无删除接口, +// 因此本文件不封装 addRoom / delRoom,页面以禁用按钮 + 提示如实呈现。 + +// 查询房间列表(分页,building / roomFloor / roomNo / roomStatus[] / roomType) +export function listRoom(query) { + return request({ + url: '/apt/room/list', + method: 'get', + params: query + }) +} + +// 房态汇总(idle / full / partial / cleaning / disabled / total) +export function getRoomStatusSummary() { + return request({ + url: '/apt/room/status-summary', + method: 'get' + }) +} + +// 查询房间详细(含 bindings / livePersons / recentCleanTasks) +export function getRoom(id) { + return request({ + url: '/apt/room/' + id, + method: 'get' + }) +} + +// 修改房间本地属性(仅 roomType / roomDept / roomOrder / roomDir / roomFace) +export function updateRoom(id, data) { + return request({ + url: '/apt/room/' + id, + method: 'put', + data: data + }) +} + +// 房间停用 / 恢复(action=DISABLE|RESTORE,reason 必填,commandId 为 L2 幂等键) +export function changeRoomStatus(id, data) { + return request({ + url: '/apt/room/' + id + '/status', + method: 'put', + data: data + }) +} diff --git a/dev/RuoYi-Vue2-master/src/api/apt/route.js b/dev/RuoYi-Vue2-master/src/api/apt/route.js new file mode 100644 index 0000000..76b6be4 --- /dev/null +++ b/dev/RuoYi-Vue2-master/src/api/apt/route.js @@ -0,0 +1,65 @@ +import request from '@/utils/request' + +// 公寓端交路管理:对应 AptRouteController(/apt/route),IT-2 / M1 表 16-6 + +// 查询交路列表(分页,pageSize 上限 100) +export function listRoute(query) { + return request({ + url: '/apt/route/list', + method: 'get', + params: query + }) +} + +// 查询交路详细 +export function getRoute(id) { + return request({ + url: '/apt/route/' + id, + method: 'get' + }) +} + +// 交路下拉选项(仅 ACTIVE,SQL 只取 jlid / cc / jcsj) +export function listRouteOptions(keyword) { + return request({ + url: '/apt/route/options', + method: 'get', + params: { keyword: keyword } + }) +} + +// 新增交路(jlid / cc / kcsj / jcsj / lysj 必填;勿带 sourceVersion / createTime) +export function addRoute(data) { + return request({ + url: '/apt/route', + method: 'post', + data: data + }) +} + +// 修改交路 +export function updateRoute(id, data) { + return request({ + url: '/apt/route/' + id, + method: 'put', + data: data + }) +} + +// 交路启用 / 停用(reason 必填并落 duty_operation_log) +export function changeRouteStatus(id, data) { + return request({ + url: '/apt/route/' + id + '/status', + method: 'put', + data: data + }) +} + +// 叫乘时刻复算校验(baseDate 格式 yyyy-MM-dd,可空) +export function recalcRoute(id, baseDate) { + return request({ + url: '/apt/route/' + id + '/recalc-check', + method: 'post', + params: baseDate ? { baseDate: baseDate } : {} + }) +} diff --git a/dev/RuoYi-Vue2-master/src/components/AptDisabledAction/index.vue b/dev/RuoYi-Vue2-master/src/components/AptDisabledAction/index.vue new file mode 100644 index 0000000..0bbb0db --- /dev/null +++ b/dev/RuoYi-Vue2-master/src/components/AptDisabledAction/index.vue @@ -0,0 +1,32 @@ + + + + + diff --git a/dev/RuoYi-Vue2-master/src/router/index.js b/dev/RuoYi-Vue2-master/src/router/index.js index 2dd6e79..a21a120 100644 --- a/dev/RuoYi-Vue2-master/src/router/index.js +++ b/dev/RuoYi-Vue2-master/src/router/index.js @@ -167,6 +167,34 @@ export const dynamicRoutes = [ meta: { title: '修改生成配置', activeMenu: '/tool/gen' } } ] + }, + { + path: '/apt/route-detail', + component: Layout, + hidden: true, + permissions: ['apt:route:query'], + children: [ + { + path: 'index/:id(\\d+)', + component: () => import('@/views/apt/route/detail'), + name: 'AptRouteDetail', + meta: { title: '交路详情', activeMenu: '/apt/route' } + } + ] + }, + { + path: '/apt/room-detail', + component: Layout, + hidden: true, + permissions: ['apt:room:query'], + children: [ + { + path: 'index/:id(\\d+)', + component: () => import('@/views/apt/room/detail'), + name: 'AptRoomDetail', + meta: { title: '房间详情', activeMenu: '/apt/room' } + } + ] } ] diff --git a/dev/RuoYi-Vue2-master/src/utils/apt.js b/dev/RuoYi-Vue2-master/src/utils/apt.js new file mode 100644 index 0000000..07e01d4 --- /dev/null +++ b/dev/RuoYi-Vue2-master/src/utils/apt.js @@ -0,0 +1,340 @@ +import { saveAs } from 'file-saver' + +/** + * 公寓端公共常量与工具。 + * + * 口径来源: + * - 房态 1~6:dev/sql/apt_rev01/02_domain_02_exec_device.sql(gy_rooms.room_status CHECK) + * - 交路状态、主数据版本:dev/sql/apt_rev01/01_domain_01_master.sql + * - 错误码 / reason:ruoyi-apt/.../common/AptErrorCode.java、framework/web/AptInfraExceptionHandler.java + * - 接口契约:docs/公寓端/04-接口设计.md、16-第三轮开发(IT-2)交付说明.md + * + * 本文件不引入第二套 UI 库:options 结构兼容若依全局组件 dict-tag, + * 颜色只用 Element UI 语义色(success / info / warning / danger / primary), + * 跟随 ThemePicker 的 --current-color 主题变量。 + */ + +/** 把 [value, label, listClass] 三元组转成若依字典 / dict-tag 通用结构 */ +function dictOptions(pairs) { + return pairs.map(pair => ({ + value: String(pair[0]), + label: pair[1], + listClass: pair[2] || 'default', + elTagType: pair[2] || 'default', + raw: { listClass: pair[2] || 'default', cssClass: '' } + })) +} + +/** 房态(room_status) */ +export const aptRoomStatusOptions = dictOptions([ + ['1', '空闲', 'success'], + ['2', '住满', 'primary'], + ['3', '待清扫', 'warning'], + ['4', '不能入住(停用)', 'danger'], + ['5', '未满', 'info'], + ['6', '保洁中', 'warning'] +]) + +/** 交路状态(state) */ +export const aptRouteStateOptions = dictOptions([ + ['ACTIVE', '启用', 'success'], + ['STOPPED', '停用', 'danger'] +]) + +/** 主数据版本来源 */ +export const aptVersionSourceOptions = dictOptions([ + ['PLATFORM', '平台下发', 'success'], + ['LOCAL', '非平台下发', 'warning'] +]) + +/** 主数据版本状态 */ +export const aptVersionStateOptions = dictOptions([ + ['PENDING', '待生效', 'info'], + ['APPLIED', '已生效', 'success'], + ['FAILED', '失败', 'danger'] +]) + +/** 主数据版本域白名单(AptMasterVersionServiceImpl) */ +export const aptVersionDomainOptions = dictOptions([ + ['APARTMENT', '公寓档案', 'primary'], + ['ROOM', '房间档案', 'primary'], + ['PERSON', '人员档案', 'primary'], + ['ROUTE', '交路', 'primary'], + ['RULE', '叫乘规则', 'primary'], + ['HOLIDAY', '节假日', 'primary'] +]) + +/** 本地房型 */ +export const aptRoomTypeOptions = dictOptions([ + ['SINGLE', '单人房', 'info'], + ['DOUBLE', '双人房', 'info'], + ['TRIPLE', '三人房', 'info'] +]) + +/** 朝向(orientation) */ +export const aptOrientationOptions = dictOptions([ + ['SOUTH', '南', 'info'], + ['NORTH', '北', 'info'], + ['EAST', '东', 'info'], + ['WEST', '西', 'info'] +]) + +/** 车次类型(cclx) */ +export const aptCarTypeOptions = dictOptions([ + ['HIGH_SPEED', '高铁', 'primary'], + ['NORMAL_SPEED', '普速', 'info'] +]) + +/** 连通性探针目标 */ +export const aptProbeTargetOptions = dictOptions([ + ['ALL', '全部(DB + Redis)', 'primary'], + ['DB', '数据库', 'info'], + ['REDIS', '缓存', 'info'] +]) + +/** 整体健康度 */ +export const aptOverallOptions = dictOptions([ + ['OK', '正常', 'success'], + ['DEGRADED', '降级', 'warning'], + ['FAILED', '异常', 'danger'] +]) + +/** 人脸注册态(apt_person_face_reg.reg_state,DATABASE CHECK ck_facereg_state) */ +export const aptFaceRegStateOptions = dictOptions([ + ['APPLIED', '待审核', 'warning'], + ['CONFIRMED', '已确认', 'success'], + ['REJECTED', '已驳回', 'danger'], + ['ARCHIVED', '已归档', 'info'] +]) + +/** 人员类别(person_category,CHECK ck_facereg_category / ck_mp_category) */ +export const aptFacePersonCategoryOptions = dictOptions([ + ['TRANSIENT', '临时人员', 'info'], + ['LONG_TERM', '长期人员', 'primary'] +]) + +/** 平台同步回执(verdict,#2 personRegVerdicts[];不决定本地生效) */ +export const aptFaceVerdictOptions = dictOptions([ + ['PENDING', '待回执', 'info'], + ['ACCEPTED', '平台已接收', 'success'], + ['REJECTED', '平台已拒绝', 'danger'] +]) + +/** 4a 上行状态链(upload_state,#9 PERSON_REG 独立重试) */ +export const aptFaceUploadStateOptions = dictOptions([ + ['PENDING', '待上报', 'info'], + ['SENT', '已上报', 'success'], + ['ACKED', '平台已确认', 'success'], + ['FAILED', '上报失败', 'danger'], + ['DEAD', '死信', 'danger'] +]) + +/** 4b 下发状态链(dispatch_state,FACE_ISSUE 独立重试) */ +export const aptFaceDispatchStateOptions = dictOptions([ + ['PENDING', '待下发', 'info'], + ['ISSUED', '已下发', 'success'], + ['ACKED', '设备已确认', 'success'], + ['FAILED', '下发失败', 'danger'], + ['DEAD', '死信', 'danger'] +]) + +/** 入住流程态(apt_local_task.checkin_state,表 11-4,8 值) */ +export const aptCheckinStateOptions = dictOptions([ + ['SUBMITTED', '待审核', 'warning'], + ['REVIEWING', '审核中', 'primary'], + ['APPROVED', '已通过', 'success'], + ['ROOM_ADJUSTED', '已换房', 'warning'], + ['REJECTED', '已驳回', 'danger'], + ['CHECKED_IN', '已入住', 'success'], + ['CANCELLED', '已取消', 'info'], + ['CLOSED', '已关闭', 'info'] +]) + +/** 同步态(apt_local_task.sync_state,表 11-9,5 值;与流程态独立) */ +export const aptCheckinSyncStateOptions = dictOptions([ + ['PENDING', '待上报', 'info'], + ['REPORTED', '已上报', 'primary'], + ['MATCHED', '已匹配待复算', 'warning'], + ['UNMATCHED', '未匹配', 'warning'], + ['TASK_READY', '任务就绪', 'success'] +]) + +/** 运安匹配态(match_state,表 11-9) */ +export const aptMatchStateOptions = dictOptions([ + ['PENDING', '待匹配', 'info'], + ['MATCHED', '已匹配', 'success'], + ['UNMATCHED', '未匹配', 'warning'], + ['LOCKED', '本地锁定', 'warning'], + ['REJECTED', '平台拒绝', 'danger'] +]) + +/** 叫乘状态(gy_jcxx.ryzt,03 表 3-10 / 05 表 5-20) */ +export const aptRyztOptions = dictOptions([ + ['0', '待叫乘', 'info'], + ['1', '叫乘中', 'primary'], + ['2', '叫乘报警', 'warning'], + ['3', '已应答待离寓', 'success'], + ['4', '离寓报警', 'danger'], + ['5', '通话中', 'primary'] +]) + +/** 叫乘方式(gy_jcxx.call_mode);一条 jcid 只有一种事实来源 */ +export const aptCallModeOptions = dictOptions([ + ['SIP', 'SIP 语音叫乘', 'primary'], + ['TABLET', '平板叫乘', 'success'] +]) + +/** 状态机命令(04 表 4-9 第 5 个接口,commandId 幂等) */ +export const aptDutyCommandOptions = dictOptions([ + ['STOP', '停止叫乘', 'warning'], + ['RE_CALL', '复叫', 'primary'], + ['FORCE_FINISH', '强制结束', 'danger'], + ['MARK_LEAVE', '登记离寓', 'success'], + ['CANCEL', '取消叫乘', 'info'] +]) + +/** 时间线来源(04 表 4-9 第 3 个接口:状态迁移 / AMI / 人工命令 / 审计 / 平板结果) */ +export const aptTimelineSourceOptions = dictOptions([ + ['STATE', '状态迁移', 'success'], + ['AMI', 'AMI 事件', 'primary'], + ['MANUAL', '人工命令', 'warning'], + ['AUDIT', '审计', 'info'], + ['TABLET', '平板结果', 'success'] +]) + +/** 错误码 → 面向值班员的中文提示 */ +export const aptErrorText = { + 40001: '请求参数不合法,请检查表单填写', + 40101: '登录状态已失效,请重新登录', + 40301: '当前账号没有该操作权限', + 40302: '公寓隔离标识不匹配,禁止跨公寓访问', + 40303: '设备公寓标识不匹配', + 40401: '请求的资源不存在,或该前缀尚未开放', + 40901: '编号已存在,请勿重复新增', + 40902: '当前状态不允许该操作,请刷新后重试', + 40903: '数据已被其他操作更新(快照版本过期),请刷新后重试', + 40904: '平台快照字段只读,不允许修改', + 40905: '主数据同步尚未完成,请稍后再试', + 40906: '扩展字段不可变更', + 40907: '命令重复,请勿重复提交', + 40908: '配置繁忙,请稍后重试', + 40913: '离线额度已超限', + 40914: '人脸注册待处理', + 40915: '换乘时间不合法', + 40916: '平板任务已过期', + 40917: '设备越出人脸可见范围', + 40918: '该工号已存在已确认的人脸注册,请勿重复注册', + 40919: '照片与特征值至少提供一项', + 40920: '人脸媒体校验和不符,整条申请已拒收', + 40921: '特征算法不在白名单,允许入档但不会下发设备', + 40922: '人脸媒体超过大小上限', + 40923: '人脸媒体未同步完成,禁止下发', + 40924: '当前注册态不允许该操作,请刷新后重试', + 40926: '入住状态冲突,请刷新后重试', + 40927: '人脸缓存版本过期,请重新核验', + 40928: '房间不可住或已满,请重新选择', + 40929: '核验会话已过期,请重新刷脸', + 40930: '核验不符,禁止办理入住', + 40931: '交路要素不合法,请检查房号 / 交路 / 机次', + 40932: '该人员已在住,请先办理退宿', + 40940: '该能力本期未开放(FEATURE_DISABLED)', + 41001: '录音已按留存策略清除', + 50101: '协议尚未确认', + 50102: '暂不支持该操作', + 50301: '依赖服务不可用', + 50302: '上报重试繁忙', + 50304: '数据异常', + 500: '系统内部错误', + 'default': '操作失败,请稍后重试或联系管理员' +} + +/** reason → 补充定位信息 */ +export const aptReasonText = { + PARAM_INVALID: '参数校验未通过', + PERMISSION_DENIED: '权限不足', + UNAUTHORIZED: '未认证', + GY_ID_MISSING: '缺少公寓隔离标识', + GY_ID_CROSS_SCOPE: '存在跨公寓访问', + PREFIX_NOT_SERVED: '接口前缀未开放', + SNAPSHOT_READONLY: '命中平台快照只读字段', + STATE_ILLEGAL: '状态流转不合法', + STATE_VERSION_CONFLICT: '乐观锁版本冲突', + DUPLICATE_KEY: '唯一键冲突', + ROOM_NOT_FOUND: '房间不存在', + ROUTE_NOT_FOUND: '交路不存在', + MASTER_VERSION_NOT_FOUND: '版本域不存在', + ROOM_ADD_DISABLED: '房间新增接口未开放', + MASTER_RESYNC_DISABLED: '主数据重同步接口未开放', + PROBE_DISABLED: '探针开关未启用', + NON_LOOPBACK_FORBIDDEN: '仅允许回环地址访问', + INVALID_TARGET: '探针目标不合法', + DB_CONNECT_FAILED: '数据库连接失败', + REDIS_CONNECT_FAILED: '缓存连接失败', + UNEXPECTED_ERROR: '未预期的服务端异常' +} + +/** 组合错误提示:优先错误码文案,追加 reason 定位 */ +export function formatAptError(code, reason) { + const base = aptErrorText[code] || aptErrorText['default'] + const detail = aptReasonText[reason] + return detail ? `${base}(${detail})` : base +} + +/** 从若依 request 拦截器透传的错误对象中提取公寓端错误提示 */ +export function pickAptError(error) { + const payload = error && error.response && error.response.data + if (!payload || typeof payload !== 'object') { + return { code: undefined, reason: undefined, text: aptErrorText['default'] } + } + const data = payload.data || {} + return { + code: payload.code, + reason: data.reason, + traceId: data.traceId, + text: formatAptError(payload.code, data.reason) + } +} + +/** 取字典项显示文本 */ +export function aptLabel(options, value) { + const hit = (options || []).find(item => String(item.value) === String(value)) + return hit ? hit.label : (value === null || value === undefined || value === '' ? '-' : String(value)) +} + +/** 列表响应 warnings[] 的展示文案 */ +export function aptWarnings(warnings) { + return Array.isArray(warnings) ? warnings : [] +} + +/** 生成 L2 幂等键(房间停复、交路命令) */ +export function newAptCommandId(prefix) { + return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}` +} + +/** + * 前端 CSV 导出。 + * 公寓端 IT-0~IT-2 未提供 /export 后端接口,因此导出当前页数据, + * 不伪造后端调用;UTF-8 BOM 保证 Excel 打开中文不乱码。 + * @param {String} filename 文件名(含 .csv) + * @param {Array} columns [{ label, prop, value(row) }] + * @param {Array} rows 数据行 + */ +export function exportAptCsv(filename, columns, rows) { + const escape = val => { + if (val === null || val === undefined) return '' + let text = String(val) + if (text.charAt(0) === '=' || text.charAt(0) === '+' || text.charAt(0) === '-' || text.charAt(0) === '@') { + text = "'" + text + } + return /[",\r\n]/.test(text) ? '"' + text.replace(/"/g, '""') + '"' : text + } + const lines = [columns.map(col => escape(col.label)).join(',')] + ;(rows || []).forEach(row => { + lines.push(columns.map(col => { + const raw = typeof col.value === 'function' ? col.value(row) : row[col.prop] + return escape(raw) + }).join(',')) + }) + const blob = new Blob(['\ufeff' + lines.join('\r\n')], { type: 'text/csv;charset=utf-8;' }) + saveAs(blob, filename) +} diff --git a/dev/RuoYi-Vue2-master/src/utils/request.js b/dev/RuoYi-Vue2-master/src/utils/request.js index 8de353b..b3062e2 100644 --- a/dev/RuoYi-Vue2-master/src/utils/request.js +++ b/dev/RuoYi-Vue2-master/src/utils/request.js @@ -111,7 +111,19 @@ service.interceptors.response.use(res => { error => { console.log('err' + error) let { message } = error - if (message == "Network Error") { + // 业务异常(如公寓端 40902 / 40940)以 code/100 映射为 HTTP 4xx,axios 会走此分支。 + // 优先采用后端 msg 与 data.reason / traceId,避免只提示「系统接口409异常」而丢失定位信息。 + const payload = error.response && error.response.data + if (payload && typeof payload === 'object' && (payload.code || payload.msg)) { + const detail = (payload.data && typeof payload.data === 'object') ? payload.data : {} + message = payload.msg || errorCode[payload.code] || errorCode['default'] + if (detail.reason) { + message += '(' + detail.reason + ')' + } + if (detail.traceId) { + message += ',追踪号 ' + detail.traceId + } + } else if (message == "Network Error") { message = "后端接口连接异常" } else if (message.includes("timeout")) { message = "系统接口请求超时" diff --git a/dev/RuoYi-Vue2-master/src/views/apt/checkin/index.vue b/dev/RuoYi-Vue2-master/src/views/apt/checkin/index.vue new file mode 100644 index 0000000..c674403 --- /dev/null +++ b/dev/RuoYi-Vue2-master/src/views/apt/checkin/index.vue @@ -0,0 +1,778 @@ + + + + + diff --git a/dev/RuoYi-Vue2-master/src/views/apt/face/reg/index.vue b/dev/RuoYi-Vue2-master/src/views/apt/face/reg/index.vue new file mode 100644 index 0000000..716780c --- /dev/null +++ b/dev/RuoYi-Vue2-master/src/views/apt/face/reg/index.vue @@ -0,0 +1,601 @@ + + + + + diff --git a/dev/RuoYi-Vue2-master/src/views/apt/jcxx/index.vue b/dev/RuoYi-Vue2-master/src/views/apt/jcxx/index.vue new file mode 100644 index 0000000..1deed67 --- /dev/null +++ b/dev/RuoYi-Vue2-master/src/views/apt/jcxx/index.vue @@ -0,0 +1,550 @@ + + + + + diff --git a/dev/RuoYi-Vue2-master/src/views/apt/master/version/index.vue b/dev/RuoYi-Vue2-master/src/views/apt/master/version/index.vue new file mode 100644 index 0000000..4f6a13f --- /dev/null +++ b/dev/RuoYi-Vue2-master/src/views/apt/master/version/index.vue @@ -0,0 +1,304 @@ + + + diff --git a/dev/RuoYi-Vue2-master/src/views/apt/ops/connectivity/index.vue b/dev/RuoYi-Vue2-master/src/views/apt/ops/connectivity/index.vue new file mode 100644 index 0000000..f8c3b6e --- /dev/null +++ b/dev/RuoYi-Vue2-master/src/views/apt/ops/connectivity/index.vue @@ -0,0 +1,269 @@ + + + + + diff --git a/dev/RuoYi-Vue2-master/src/views/apt/room/detail.vue b/dev/RuoYi-Vue2-master/src/views/apt/room/detail.vue new file mode 100644 index 0000000..c41a4a2 --- /dev/null +++ b/dev/RuoYi-Vue2-master/src/views/apt/room/detail.vue @@ -0,0 +1,169 @@ + + + + + diff --git a/dev/RuoYi-Vue2-master/src/views/apt/room/index.vue b/dev/RuoYi-Vue2-master/src/views/apt/room/index.vue new file mode 100644 index 0000000..f477723 --- /dev/null +++ b/dev/RuoYi-Vue2-master/src/views/apt/room/index.vue @@ -0,0 +1,562 @@ + + + + + diff --git a/dev/RuoYi-Vue2-master/src/views/apt/route/detail.vue b/dev/RuoYi-Vue2-master/src/views/apt/route/detail.vue new file mode 100644 index 0000000..dcd836e --- /dev/null +++ b/dev/RuoYi-Vue2-master/src/views/apt/route/detail.vue @@ -0,0 +1,110 @@ + + + diff --git a/dev/RuoYi-Vue2-master/src/views/apt/route/index.vue b/dev/RuoYi-Vue2-master/src/views/apt/route/index.vue new file mode 100644 index 0000000..bc7df53 --- /dev/null +++ b/dev/RuoYi-Vue2-master/src/views/apt/route/index.vue @@ -0,0 +1,626 @@ + + + diff --git a/dev/scripts/apply-it2-sql.ps1 b/dev/scripts/apply-it2-sql.ps1 new file mode 100644 index 0000000..4ba4f1d --- /dev/null +++ b/dev/scripts/apply-it2-sql.ps1 @@ -0,0 +1,109 @@ +<# + Apply IT-2 SQL scripts to apt_rev01 (idempotent, replayable). + Credentials come from dev/.env first, then APT_DB_* environment variables. + Passwords are never printed. + SQL files are streamed to mysql stdin as raw UTF-8 bytes: PowerShell 5.1's + text pipeline can replace Chinese literals with '?'. + Usage: + powershell -ExecutionPolicy Bypass -File dev/scripts/apply-it2-sql.ps1 + powershell -ExecutionPolicy Bypass -File dev/scripts/apply-it2-sql.ps1 -DryRun + powershell -ExecutionPolicy Bypass -File dev/scripts/apply-it2-sql.ps1 -EnvFile C:\secrets\apt.env +#> +param( + [string]$EnvFile = '', + [string]$MysqlPath = 'C:\Program Files\MySQL\MySQL Server 9.5\bin\mysql.exe', + [switch]$DryRun +) + +$ErrorActionPreference = 'Stop' +$devDir = Split-Path $PSScriptRoot -Parent +$sqlDir = Join-Path $devDir 'sql/apt_rev01' +$scripts = @('10_it2_columns.sql', '11_it2_seed.sql', '12_it2_permissions.sql', '13_it2_frontend_menus.sql') + +foreach ($name in $scripts) { + $file = Join-Path $sqlDir $name + if (-not [System.IO.File]::Exists($file)) { throw "Missing SQL file: $file" } +} +if ($EnvFile -eq '') { $EnvFile = Join-Path $devDir '.env' } + +$values = @{} +if ([System.IO.File]::Exists($EnvFile)) { + foreach ($line in [System.IO.File]::ReadAllLines($EnvFile)) { + $trimmed = $line.Trim() + if ($trimmed -eq '' -or $trimmed.StartsWith('#')) { continue } + if ($trimmed -match '^([A-Za-z0-9_]+)\s*=\s*(.*)$') { $values[$Matches[1]] = $Matches[2] } + } + Write-Host "Credentials source: $EnvFile" +} else { + Write-Host "No credentials file at $EnvFile; using APT_DB_* environment variables." + foreach ($key in @('APT_DB_HOST','APT_DB_PORT','APT_DB_NAME','APT_DB_USER','APT_DB_PASSWORD')) { + $values[$key] = [Environment]::GetEnvironmentVariable($key) + } +} + +$dbHost = $values['APT_DB_HOST']; if ([string]::IsNullOrEmpty($dbHost)) { $dbHost = '127.0.0.1' } +$dbPort = $values['APT_DB_PORT']; if ([string]::IsNullOrEmpty($dbPort)) { $dbPort = '3306' } +$dbName = $values['APT_DB_NAME']; if ([string]::IsNullOrEmpty($dbName)) { $dbName = 'apt_rev01' } +$dbUser = $values['APT_DB_USER']; if ([string]::IsNullOrEmpty($dbUser)) { $dbUser = 'iotlocks' } + +if ($DryRun) { + Write-Host '--- DRY RUN: no database connection, no credentials required ---' + Write-Host ("Target database: {0}:{1}/{2} (user {3})" -f $dbHost, $dbPort, $dbName, $dbUser) + foreach ($name in $scripts) { + $file = Join-Path $sqlDir $name + $lineCount = ([System.IO.File]::ReadAllLines($file, [System.Text.Encoding]::UTF8)).Count + Write-Host (" would apply {0} ({1} lines)" -f $name, $lineCount) + } + Write-Host 'Validation SELECTs in 11/12/13 print Mock marker counts, permission and frontend menu checks.' + return +} + +if ([string]::IsNullOrEmpty($values['APT_DB_PASSWORD'])) { + $hint = @' +Missing APT_DB_PASSWORD. Provide one of: + 1) copy dev/.env.example to dev/.env, fill APT_DB_PASSWORD, then re-run this script + 2) set APT_DB_PASSWORD (plus optional APT_DB_HOST/PORT/NAME/USER) in the environment +Use -DryRun to preview SQL files without connecting to MySQL. +'@ + throw $hint +} +if (-not [System.IO.File]::Exists($MysqlPath)) { throw "Missing mysql client: $MysqlPath" } + +[Environment]::SetEnvironmentVariable('MYSQL_PWD', $values['APT_DB_PASSWORD'], 'Process') +try { + foreach ($name in $scripts) { + $file = Join-Path $sqlDir $name + Write-Host "--- applying $name ---" + + $arguments = "--host=$dbHost --port=$dbPort --user=$dbUser --database=$dbName" + + ' --default-character-set=utf8mb4 --batch' + $psi = New-Object System.Diagnostics.ProcessStartInfo + $psi.FileName = $MysqlPath + $psi.Arguments = $arguments + $psi.UseShellExecute = $false + $psi.RedirectStandardInput = $true + $psi.RedirectStandardOutput = $true + $psi.RedirectStandardError = $true + $psi.StandardOutputEncoding = [System.Text.Encoding]::UTF8 + $psi.StandardErrorEncoding = [System.Text.Encoding]::UTF8 + + $process = [System.Diagnostics.Process]::Start($psi) + $bytes = [System.IO.File]::ReadAllBytes($file) + $offset = 0 + if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) { + $offset = 3 + } + $process.StandardInput.BaseStream.Write($bytes, $offset, $bytes.Length - $offset) + $process.StandardInput.BaseStream.Flush() + $process.StandardInput.Close() + $stdout = $process.StandardOutput.ReadToEnd() + $stderr = $process.StandardError.ReadToEnd() + $process.WaitForExit() + if ($stdout) { Write-Host $stdout } + if ($stderr) { Write-Host $stderr } + if ($process.ExitCode -ne 0) { throw "SQL failed: $name (exit $($process.ExitCode))" } + } + Write-Host 'IT-2 SQL scripts applied successfully.' +} finally { + [Environment]::SetEnvironmentVariable('MYSQL_PWD', $null, 'Process') +} diff --git a/dev/sql/apt_rev01/10_it2_columns.sql b/dev/sql/apt_rev01/10_it2_columns.sql new file mode 100644 index 0000000..f7f4a70 --- /dev/null +++ b/dev/sql/apt_rev01/10_it2_columns.sql @@ -0,0 +1,55 @@ +-- ============================================================================= +-- IT-2 增量补列(幂等):把 公寓端/04 表 4-5 / 4-7 的既定业务字段补齐到 IT-0 [推断] 表。 +-- 前提:先执行 00_common.sql 建立 apt_add_column_if_missing。 +-- 纪律:Q-20 / Q-40 / Q-43 / Q-48 未决项仍不新增列、不启用(表 13-10 用 JSON 兜底); +-- 本次只补设计文档已经明确列出的字段,不改 59 张表的口径与主键/唯一键/索引。 +-- ============================================================================= + +-- 交路(表 4-5):jch/qd/zd 复用 route_name/start_station/end_station,enabled 复用 state, +-- sourceVersion 复用 source_version;其余 4 个字段为设计既定但 IT-0 未建的列。 +CALL apt_add_column_if_missing('apt_master_route', 'jcsj', + 'jcsj DATETIME(3) NULL COMMENT ''叫乘时刻基线;权威值恒由公寓复算(D8)'''); +CALL apt_add_column_if_missing('apt_master_route', 'lysj', + 'lysj DATETIME(3) NULL COMMENT ''离寓时刻'''); +CALL apt_add_column_if_missing('apt_master_route', 'cclx', + 'cclx VARCHAR(32) NULL COMMENT ''车次类型'''); +CALL apt_add_column_if_missing('apt_master_route', 'bz', + 'bz VARCHAR(512) NULL COMMENT ''备注'''); + +-- 房间运行时事实(表 4-7 #4 本地可编辑属性);快照字段仍在 apt_master_room,不在此表。 +CALL apt_add_column_if_missing('gy_rooms', 'room_type', + 'room_type VARCHAR(32) NULL COMMENT ''本地房型'''); +CALL apt_add_column_if_missing('gy_rooms', 'room_dept', + 'room_dept VARCHAR(64) NULL COMMENT ''所属部门'''); +CALL apt_add_column_if_missing('gy_rooms', 'room_order', + 'room_order INT NOT NULL DEFAULT 0 COMMENT ''展示排序'''); +CALL apt_add_column_if_missing('gy_rooms', 'room_dir', + 'room_dir VARCHAR(32) NULL COMMENT ''朝向'''); +CALL apt_add_column_if_missing('gy_rooms', 'room_face', + 'room_face VARCHAR(32) NULL COMMENT ''方位'''); +CALL apt_add_column_if_missing('gy_rooms', 'bed_numbers', + 'bed_numbers VARCHAR(64) NULL COMMENT ''床位号集合(本地展示)'''); + +-- 修复历史执行中因 Windows PowerShell 5.1 ASCII 管道损坏的列注释。 +-- 只重置元数据,列类型/默认值/主键/索引口径不变;可重复执行。 +ALTER TABLE apt_master_route + MODIFY COLUMN jcsj DATETIME(3) NULL COMMENT '叫乘时刻基线;权威值恒由公寓复算(D8)', + MODIFY COLUMN lysj DATETIME(3) NULL COMMENT '离寓时刻', + MODIFY COLUMN cclx VARCHAR(32) NULL COMMENT '车次类型', + MODIFY COLUMN bz VARCHAR(512) NULL COMMENT '备注'; + +ALTER TABLE gy_rooms + MODIFY COLUMN room_type VARCHAR(32) NULL COMMENT '本地房型', + MODIFY COLUMN room_dept VARCHAR(64) NULL COMMENT '所属部门', + MODIFY COLUMN room_order INT NOT NULL DEFAULT 0 COMMENT '展示排序', + MODIFY COLUMN room_dir VARCHAR(32) NULL COMMENT '朝向', + MODIFY COLUMN room_face VARCHAR(32) NULL COMMENT '方位', + MODIFY COLUMN bed_numbers VARCHAR(64) NULL COMMENT '床位号集合(本地展示)'; + +SELECT '=== IT-2 补列结果(含注释,中文不应为问号)===' AS section; +SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE, COLUMN_COMMENT +FROM information_schema.COLUMNS +WHERE TABLE_SCHEMA = DATABASE() + AND ((TABLE_NAME = 'apt_master_route' AND COLUMN_NAME IN ('jcsj','lysj','cclx','bz')) + OR (TABLE_NAME = 'gy_rooms' AND COLUMN_NAME IN ('room_type','room_dept','room_order','room_dir','room_face','bed_numbers'))) +ORDER BY TABLE_NAME, COLUMN_NAME; diff --git a/dev/sql/apt_rev01/11_it2_seed.sql b/dev/sql/apt_rev01/11_it2_seed.sql new file mode 100644 index 0000000..dd08271 --- /dev/null +++ b/dev/sql/apt_rev01/11_it2_seed.sql @@ -0,0 +1,109 @@ +-- ============================================================================= +-- IT-2 / DV-2 Mock 灌数(可重放):3 交路 / 5 房间 / 5 人员 / 2 天计划。 +-- 依据:公寓端/09 表 9-16(Mock source='LOCAL'、source_version=0)、表 13-12、表 7-7 步骤 2。 +-- 所有数据标 LOCAL / version=0,页面须显示「非平台下发」;不得冒充真实平台下行。 +-- ============================================================================= +SET @apt_gyid = 'APT-REV01-001'; + +-- 1. 主数据版本快照:6 个域均 APPLIED / LOCAL / source_version=0 +INSERT INTO apt_master_version (gyid, domain, source_version, source, state, applied_at, remark, deleted) +VALUES + (@apt_gyid, 'APARTMENT', 0, 'LOCAL', 'APPLIED', NOW(3), 'IT-2 Mock', 0), + (@apt_gyid, 'ROOM', 0, 'LOCAL', 'APPLIED', NOW(3), 'IT-2 Mock', 0), + (@apt_gyid, 'PERSON', 0, 'LOCAL', 'APPLIED', NOW(3), 'IT-2 Mock', 0), + (@apt_gyid, 'ROUTE', 0, 'LOCAL', 'APPLIED', NOW(3), 'IT-2 Mock', 0), + (@apt_gyid, 'RULE', 0, 'LOCAL', 'APPLIED', NOW(3), 'IT-2 Mock', 0), + (@apt_gyid, 'HOLIDAY', 0, 'LOCAL', 'APPLIED', NOW(3), 'IT-2 Mock', 0) +ON DUPLICATE KEY UPDATE source = VALUES(source), state = VALUES(state), remark = VALUES(remark); + +-- 2. 3 条交路(jlid 唯一;kcsj 为开车时刻,jcsj 为基线,权威值由复算服务给出) +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 + (@apt_gyid, 'JL-001', 'G101', DATE_ADD(CURDATE(), INTERVAL 8 HOUR), + DATE_ADD(CURDATE(), INTERVAL 6 HOUR), DATE_ADD(CURDATE(), INTERVAL 5 HOUR), + '北京南-济南西', '北京南', '济南西', 'HIGH_SPEED', 'IT-2 Mock', 'ACTIVE', NULL, 0, 0), + (@apt_gyid, 'JL-002', 'G203', DATE_ADD(CURDATE(), INTERVAL 12 HOUR), + DATE_ADD(CURDATE(), INTERVAL 10 HOUR), DATE_ADD(CURDATE(), INTERVAL 9 HOUR), + '济南西-上海虹桥', '济南西', '上海虹桥', 'HIGH_SPEED', 'IT-2 Mock', 'ACTIVE', NULL, 0, 0), + (@apt_gyid, 'JL-003', 'K51', DATE_ADD(CURDATE(), INTERVAL 18 HOUR), + DATE_ADD(CURDATE(), INTERVAL 16 HOUR), DATE_ADD(CURDATE(), INTERVAL 15 HOUR), + '北京-青岛北', '北京', '青岛北', 'NORMAL_SPEED', 'IT-2 Mock', 'STOPPED', 'Mock 停用样例', 0, 0) +ON DUPLICATE KEY UPDATE cc = VALUES(cc), bz = VALUES(bz), + route_name = VALUES(route_name), start_station = VALUES(start_station), + end_station = VALUES(end_station), stop_reason = VALUES(stop_reason); + +-- 3. 5 个房间:平台基线 + 运行时房态各一条 +INSERT INTO apt_master_room + (gyid, room_id, room_no, room_floor, bed_count, orientation, room_type, state, source_version, deleted) +VALUES + (@apt_gyid, 'ROOM-201', '201', '2', 2, 'SOUTH', 'DOUBLE', 'ACTIVE', 0, 0), + (@apt_gyid, 'ROOM-202', '202', '2', 2, 'NORTH', 'DOUBLE', 'ACTIVE', 0, 0), + (@apt_gyid, 'ROOM-301', '301', '3', 3, 'SOUTH', 'TRIPLE', 'ACTIVE', 0, 0), + (@apt_gyid, 'ROOM-302', '302', '3', 2, 'EAST', 'DOUBLE', 'ACTIVE', 0, 0), + (@apt_gyid, 'ROOM-303', '303', '3', 1, 'WEST', 'SINGLE', 'ACTIVE', 0, 0) +ON DUPLICATE KEY UPDATE room_no = VALUES(room_no), room_floor = VALUES(room_floor); + +INSERT INTO gy_rooms + (gyid, room_id, room_no, room_floor, room_status, in_person_count, state_version, + room_type, room_order, room_dir, bed_numbers, deleted) +VALUES + (@apt_gyid, 'ROOM-201', '201', '2', '1', 0, 1, 'DOUBLE', 1, 'SOUTH', '201-1,201-2', 0), + (@apt_gyid, 'ROOM-202', '202', '2', '5', 1, 1, 'DOUBLE', 2, 'NORTH', '202-1,202-2', 0), + (@apt_gyid, 'ROOM-301', '301', '3', '2', 3, 1, 'TRIPLE', 3, 'SOUTH', '301-1,301-2,301-3', 0), + (@apt_gyid, 'ROOM-302', '302', '3', '3', 0, 1, 'DOUBLE', 4, 'EAST', '302-1,302-2', 0), + (@apt_gyid, 'ROOM-303', '303', '3', '4', 0, 1, 'SINGLE', 5, 'WEST', '303-1', 0) +ON DUPLICATE KEY UPDATE room_no = VALUES(room_no), room_floor = VALUES(room_floor); + +-- 4. 5 名人员(xm 为平台快照字段,本地改须返回 40904) +INSERT INTO apt_master_person + (gyid, ryid, xm, work_no, dept_code, person_category, state, source_version, deleted) +VALUES + (@apt_gyid, 'RY-001', '张三', 'W001', 'D01', 'TRANSIENT', 'ACTIVE', 0, 0), + (@apt_gyid, 'RY-002', '李四', 'W002', 'D01', 'TRANSIENT', 'ACTIVE', 0, 0), + (@apt_gyid, 'RY-003', '王五', 'W003', 'D02', 'LONG_TERM', 'ACTIVE', 0, 0), + (@apt_gyid, 'RY-004', '赵六', 'W004', 'D02', 'TRANSIENT', 'ACTIVE', 0, 0), + (@apt_gyid, 'RY-005', '钱七', 'W005', 'D03', 'TRANSIENT', 'ACTIVE', 0, 0) +ON DUPLICATE KEY UPDATE xm = VALUES(xm), dept_code = VALUES(dept_code); + +-- 5. 2 天计划(jcsj 仅基线,computed_jcsj 为公寓复算权威值) +INSERT INTO gy_syn_jcxx + (gyid, jcid, jlid, cc, ryid, xm, fjh, kcsj, jcsj, computed_jcsj, + plan_version, sync_state, source_version, deleted) +VALUES + (@apt_gyid, 'PLAN-D1-001', 'JL-001', 'G101', 'RY-001', '张三', '201', + DATE_ADD(CURDATE(), INTERVAL 8 HOUR), DATE_ADD(CURDATE(), INTERVAL 6 HOUR), + DATE_ADD(CURDATE(), INTERVAL 6 HOUR), 1, 'SYNCED', 0, 0), + (@apt_gyid, 'PLAN-D2-001', 'JL-002', 'G203', 'RY-002', '李四', '202', + DATE_ADD(DATE_ADD(CURDATE(), INTERVAL 1 DAY), INTERVAL 12 HOUR), + DATE_ADD(DATE_ADD(CURDATE(), INTERVAL 1 DAY), INTERVAL 10 HOUR), + DATE_ADD(DATE_ADD(CURDATE(), INTERVAL 1 DAY), INTERVAL 10 HOUR), 1, 'SYNCED', 0, 0) +ON DUPLICATE KEY UPDATE xm = VALUES(xm), fjh = VALUES(fjh); + +SELECT '=== IT-2 Mock 数据量(交路/房间/人员/计划/版本)===' AS section; +SELECT 'route' AS domain, COUNT(*) AS cnt FROM apt_master_route WHERE gyid = @apt_gyid AND deleted = 0 +UNION ALL SELECT 'room', COUNT(*) FROM gy_rooms WHERE gyid = @apt_gyid AND deleted = 0 +UNION ALL SELECT 'person', COUNT(*) FROM apt_master_person WHERE gyid = @apt_gyid AND deleted = 0 +UNION ALL SELECT 'plan', COUNT(*) FROM gy_syn_jcxx WHERE gyid = @apt_gyid AND deleted = 0 +UNION ALL SELECT 'version', COUNT(*) FROM apt_master_version + WHERE gyid = @apt_gyid AND state = 'APPLIED' AND source = 'LOCAL'; + +-- 6. 版本标记纪律校验(公寓端/09 表 9-16、08 R-03) +SELECT '=== 版本标记:LOCAL / source_version=0 / APPLIED(应为 6)===' AS section; +SELECT COUNT(*) AS local_applied_domains +FROM apt_master_version +WHERE gyid = @apt_gyid AND source = 'LOCAL' AND source_version = 0 + AND state = 'APPLIED' AND deleted = 0; + +SELECT '=== 不得冒充平台下发(应为 0)===' AS section; +SELECT COUNT(*) AS fake_platform_rows +FROM apt_master_version +WHERE gyid = @apt_gyid AND source = 'PLATFORM' AND source_version = 0 AND deleted = 0; + +SELECT '=== 各域版本明细(页面须显示「非平台下发」)===' AS section; +SELECT domain, source, source_version AS sourceVersion, state, + CASE WHEN source = 'LOCAL' THEN '非平台下发' ELSE '平台下发' END AS sourceLabel +FROM apt_master_version +WHERE gyid = @apt_gyid AND deleted = 0 +ORDER BY domain; diff --git a/dev/sql/apt_rev01/12_it2_permissions.sql b/dev/sql/apt_rev01/12_it2_permissions.sql new file mode 100644 index 0000000..940dc57 --- /dev/null +++ b/dev/sql/apt_rev01/12_it2_permissions.sql @@ -0,0 +1,58 @@ +-- ============================================================================= +-- IT-2 权限点种子(可重放):M1 交路 CRUD + M2 房间路径权限点。 +-- 说明:3003 在 IT-1 为置灰预置,IT-2 起启用;3004~3006 仍保持置灰不关联角色。 +-- IT-1 校验段 grey_permission_bound(3003~3006)在 IT-2 后为 1 属预期变化。 +-- ============================================================================= + +-- 1. 启用 IT-1 预置的房间列表权限点 +UPDATE sys_menu SET visible = '0', status = '0', remark = 'IT-2 / M2 启用' +WHERE menu_id = 3003 AND perms = 'apt:room:list'; + +-- 2. 新增权限点 3007~3015 +INSERT INTO sys_menu + (menu_id, menu_name, parent_id, order_num, path, component, query, route_name, + is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +VALUES + (3007, '交路详情', 3000, 7, '', NULL, NULL, '', 1, 0, 'F', '0', '0', + 'apt:route:query', '#', 'IT-2', NOW(), 'IT-2 / M1 启用'), + (3008, '交路新增', 3000, 8, '', NULL, NULL, '', 1, 0, 'F', '0', '0', + 'apt:route:add', '#', 'IT-2', NOW(), 'IT-2 / M1 启用;受 D7 约束,本期默认关闭'), + (3009, '交路编辑', 3000, 9, '', NULL, NULL, '', 1, 0, 'F', '0', '0', + 'apt:route:edit', '#', 'IT-2', NOW(), 'IT-2 / M1 启用;受 D7 约束'), + (3010, '交路启停', 3000, 10, '', NULL, NULL, '', 1, 0, 'F', '0', '0', + 'apt:route:changeStatus', '#', 'IT-2', NOW(), 'IT-2 / M1 启用'), + (3011, '房间详情', 3000, 11, '', NULL, NULL, '', 1, 0, 'F', '0', '0', + 'apt:room:query', '#', 'IT-2', NOW(), 'IT-2 / M2 登记'), + (3012, '房间编辑', 3000, 12, '', NULL, NULL, '', 1, 0, 'F', '0', '0', + 'apt:room:edit', '#', 'IT-2', NOW(), 'IT-2 / M2 登记'), + (3013, '房间停复', 3000, 13, '', NULL, NULL, '', 1, 0, 'F', '0', '0', + 'apt:room:changeStatus', '#', 'IT-2', NOW(), 'IT-2 / M2 登记'), + (3014, '房间新增', 3000, 14, '', NULL, NULL, '', 1, 0, 'F', '1', '1', + 'apt:room:add', '#', 'IT-2', NOW(), 'G-01/G-04 补齐前保持置灰'), + (3015, '主数据版本', 3000, 15, '', NULL, NULL, '', 1, 0, 'F', '0', '0', + 'apt:master:version', '#', 'IT-2', NOW(), + 'IT-2 / Mock 版本查询;表 4-17 权威权限点为 apt:ops:health,本点为兼容登记') +ON DUPLICATE KEY UPDATE menu_name = VALUES(menu_name), perms = VALUES(perms), + remark = VALUES(remark); + +-- 3. 关联超管角色(显式留痕;超管本身已有 *:*:*) +-- 3014 为置灰项不关联;3015 为本轮主数据版本点,需显式留痕 +INSERT INTO sys_role_menu (role_id, menu_id) +SELECT 1, m.menu_id FROM sys_menu m +WHERE (m.menu_id BETWEEN 3007 AND 3013 OR m.menu_id = 3015) + AND NOT EXISTS (SELECT 1 FROM sys_role_menu rm WHERE rm.role_id = 1 AND rm.menu_id = m.menu_id); + +-- 4. 校验 +SELECT '=== IT-2 权限点 ===' AS section; +SELECT menu_id, perms, visible, status, remark FROM sys_menu WHERE menu_id BETWEEN 3000 AND 3015 ORDER BY menu_id; +SELECT '=== 置灰项是否仍未关联角色(3004~3006 / 3014 应为 0)===' AS section; +SELECT COUNT(*) AS grey_bound FROM sys_role_menu rm JOIN sys_menu m ON m.menu_id = rm.menu_id +WHERE m.menu_id IN (3004, 3005, 3006, 3014); +SELECT '=== 主数据版本权限点已关联超管(应为 1)===' AS section; +SELECT COUNT(*) AS version_permission_bound +FROM sys_role_menu rm JOIN sys_menu m ON m.menu_id = rm.menu_id +WHERE rm.role_id = 1 AND m.menu_id = 3015 AND m.perms = 'apt:master:version'; +SELECT '=== M0 验收账号仍无公寓端权限点(应为 0)===' AS section; +SELECT COUNT(*) AS apt_permission_count +FROM sys_role_menu rm JOIN sys_menu m ON m.menu_id = rm.menu_id +WHERE rm.role_id = 300 AND (m.perms LIKE 'apt:%' OR m.perms LIKE 'duty:%' OR m.perms = '*:*:*'); diff --git a/dev/sql/apt_rev01/13_it2_frontend_menus.sql b/dev/sql/apt_rev01/13_it2_frontend_menus.sql new file mode 100644 index 0000000..6f17f46 --- /dev/null +++ b/dev/sql/apt_rev01/13_it2_frontend_menus.sql @@ -0,0 +1,75 @@ +-- ============================================================================= +-- IT-2 前端页面菜单种子(可重放):为公寓端 4 个已落地 Vue 页面登记 C 类型菜单。 +-- 依据:公寓端/02 §2.5、16 第 9 章、04 表 4-6 / 4-8 / 4-17、13 表 13-12。 +-- 前提:09_it1_permissions.sql 已建 3000 父目录;12_it2_permissions.sql 已建 F 权限点。 +-- +-- 口径: +-- 1) 只新增 menu_type='C' 的页面菜单(3101~3104),不改 3000 目录、不改任何 F 权限点, +-- 不动 59 张业务表;重复执行不产生重复数据。 +-- 2) 路由由后端 getRouters 下发,component 走前端 loadView('@/views/' + component), +-- 与 system/user 等既有页面同构;不在 constantRoutes 里硬编码业务页面。 +-- 3) 详情页 /apt/route-detail/index/:id、/apt/room-detail/index/:id 属前端 +-- dynamicRoutes(hidden + permissions),不建 C 菜单、不出现在侧边栏。 +-- 4) 仅登记「后端接口已就绪」的 4 个页面。叫乘、人员、设备、语音、入住、 +-- 消费/充值/菜品等仅设计未实现,按 13 表 13-29「预埋不上线」不建菜单、不建页面; +-- IT-9~IT-11 账务域更是明文「不开接口、不建包、权限点置灰」。 +-- 5) C 菜单 perms 留空:菜单可见性由 sys_role_menu 控制,按钮级权限由 F 权限点控制。 +-- ============================================================================= + +-- 1. 页面菜单(C):父目录 3000(path='apt'),完整路由为 /apt/ +INSERT INTO sys_menu + (menu_id, menu_name, parent_id, order_num, path, component, query, route_name, + is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +VALUES + (3101, '交路管理', 3000, 1, 'route', 'apt/route/index', '', '', 1, 0, 'C', '0', '0', + '', 'guide', 'IT-2', NOW(), + 'IT-2 / M1 交路列表页;对应 /apt/route/** 7 接口;按钮权限 3001/3007~3010'), + (3102, '房间管理', 3000, 2, 'room', 'apt/room/index', '', '', 1, 0, 'C', '0', '0', + '', 'build', 'IT-2', NOW(), + 'IT-2 / M2 房间列表页;对应 /apt/room/** 5 接口;按钮权限 3003/3011~3014'), + (3103, '主数据版本', 3000, 3, 'version', 'apt/master/version/index', '', '', 1, 0, 'C', '0', '0', + '', 'clipboard', 'IT-2', NOW(), + 'IT-2 版本只读台账;对应 /apt/master/versions;权限口径 apt:ops:health / apt:master:version'), + (3104, '连通性自检', 3000, 4, 'connectivity', 'apt/ops/connectivity/index', '', '', 1, 0, 'C', '0', '0', + '', 'monitor', 'IT-2', NOW(), + 'IT-0~IT-1 自检页;对应 /apt/ops/connectivity/**;接口 @Anonymous + 开关 + 回环双限') +ON DUPLICATE KEY UPDATE menu_name = VALUES(menu_name), parent_id = VALUES(parent_id), + order_num = VALUES(order_num), path = VALUES(path), component = VALUES(component), + menu_type = VALUES(menu_type), visible = VALUES(visible), status = VALUES(status), + icon = VALUES(icon), remark = VALUES(remark); + +-- 2. 关联超管角色(超管本身已有 *:*:*,此处为 getRouters 下发与审计留痕) +INSERT INTO sys_role_menu (role_id, menu_id) +SELECT 1, m.menu_id FROM sys_menu m +WHERE m.menu_id BETWEEN 3101 AND 3104 + AND NOT EXISTS (SELECT 1 FROM sys_role_menu rm WHERE rm.role_id = 1 AND rm.menu_id = m.menu_id); + +-- 3. 校验 +SELECT '=== IT-2 前端页面菜单(应为 4 条 C)===' AS section; +SELECT menu_id, menu_name, parent_id, path, component, menu_type, visible, status, icon +FROM sys_menu WHERE menu_id BETWEEN 3101 AND 3104 ORDER BY menu_id; + +SELECT '=== 组件路径(须与 RuoYi-Vue2-master/src/views/apt/** 一致)===' AS section; +SELECT menu_id, CONCAT('src/views/', component, '.vue') AS vue_file +FROM sys_menu WHERE menu_id BETWEEN 3101 AND 3104 ORDER BY menu_id; + +SELECT '=== C 菜单是否全部关联超管(应为 4)===' AS section; +SELECT COUNT(*) AS admin_frontend_menu_bound +FROM sys_role_menu rm JOIN sys_menu m ON m.menu_id = rm.menu_id +WHERE rm.role_id = 1 AND m.menu_id BETWEEN 3101 AND 3104 AND m.menu_type = 'C'; + +SELECT '=== 公寓端菜单树构成(应 M=1 / C=4 / F=15)===' AS section; +SELECT menu_type, COUNT(*) AS cnt +FROM sys_menu +WHERE menu_id = 3000 OR parent_id = 3000 +GROUP BY menu_type ORDER BY menu_type; + +SELECT '=== 置灰权限点是否仍未关联角色(3004~3006 / 3014 应为 0)===' AS section; +SELECT COUNT(*) AS grey_bound +FROM sys_role_menu rm JOIN sys_menu m ON m.menu_id = rm.menu_id +WHERE m.menu_id IN (3004, 3005, 3006, 3014); + +SELECT '=== M0 验收账号仍无公寓端权限点(应为 0)===' AS section; +SELECT COUNT(*) AS apt_permission_count +FROM sys_role_menu rm JOIN sys_menu m ON m.menu_id = rm.menu_id +WHERE rm.role_id = 300 AND (m.perms LIKE 'apt:%' OR m.perms LIKE 'duty:%' OR m.perms = '*:*:*'); diff --git a/dev/sql/apt_rev01/14_it3_columns.sql b/dev/sql/apt_rev01/14_it3_columns.sql new file mode 100644 index 0000000..845c255 --- /dev/null +++ b/dev/sql/apt_rev01/14_it3_columns.sql @@ -0,0 +1,111 @@ +-- ============================================================================= +-- IT-3 补列(可重放):人脸注册「回执态」字段 + §12 设备范围字段。 +-- 依据:公寓端/10 §2(表 10-2 / 10-3)、03-数据设计、CR-02 表 3-49 / 3-66、13 表 13-13。 +-- 口径: +-- 1) 59 张业务表不变:只补列,不建表、不删列、不改既有 CHECK 与唯一键。 +-- 2) DV-3「注册态与回执态字段」= reg_state / upload_state / dispatch_state / verdict +-- + platform_version;实库已有前四项与 verdict_version,仅缺 platform_version。 +-- 3) 平台回执为「拉升回」(CR-C07 零回调),platform_version 仅承载 #2 personRegVerdicts[] +-- 的 platformVersion,不参与注册生效判定(生效点恒为值班员 CONFIRM)。 +-- 4) 表 10-2 的 gy_sb §12 补列(device_class / face_scope_value / face_cache_enabled) +-- 本期只建列并登记 Mock 设备;按范围选设备与缓存开关的执行逻辑属 IT-7(N-26/N-27)。 +-- ============================================================================= + +-- 1. apt_person_face_reg.platform_version —— #2 回执版本 +SET @ddl_reg = (SELECT IF(COUNT(*) = 0, + 'ALTER TABLE apt_person_face_reg ADD COLUMN platform_version BIGINT NOT NULL DEFAULT 0 COMMENT ''平台回执版本(#2 personRegVerdicts[].platformVersion)'' AFTER verdict_version', + 'DO 0') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'apt_person_face_reg' + AND COLUMN_NAME = 'platform_version'); +PREPARE apt_stmt_reg FROM @ddl_reg; +EXECUTE apt_stmt_reg; +DEALLOCATE PREPARE apt_stmt_reg; + +-- 2. apt_master_person.face_platform_version —— 主档人脸回执版本 +SET @ddl_person = (SELECT IF(COUNT(*) = 0, + 'ALTER TABLE apt_master_person ADD COLUMN face_platform_version BIGINT NOT NULL DEFAULT 0 COMMENT ''人脸主档平台回执版本'' AFTER feature_version', + 'DO 0') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'apt_master_person' + AND COLUMN_NAME = 'face_platform_version'); +PREPARE apt_stmt_person FROM @ddl_person; +EXECUTE apt_stmt_person; +DEALLOCATE PREPARE apt_stmt_person; + +-- 3. apt_person_face_reg.unit_code —— N-34 必填要素(N-36 允许值班员修正) +SET @ddl_unit = (SELECT IF(COUNT(*) = 0, + 'ALTER TABLE apt_person_face_reg ADD COLUMN unit_code VARCHAR(64) NULL COMMENT ''所属单位编码(N-34 必填要素,N-36 可修正)'' AFTER name', + 'DO 0') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'apt_person_face_reg' + AND COLUMN_NAME = 'unit_code'); +PREPARE apt_stmt_unit FROM @ddl_unit; +EXECUTE apt_stmt_unit; +DEALLOCATE PREPARE apt_stmt_unit; + +-- 4. gy_sb.device_class —— 表 10-2 设备类别(CHECKIN / CONSUME / CALL / TABLET / OTHER) +-- 表 10-15 / 10-16 的目标集计算依赖本列;IT-3 只建列 + 种子登记, +-- 按范围选设备的逻辑属 IT-7(N-26 / N-27),本期不启用。 +SET @ddl_sb_class = (SELECT IF(COUNT(*) = 0, + 'ALTER TABLE gy_sb ADD COLUMN device_class VARCHAR(20) NOT NULL DEFAULT ''OTHER'' COMMENT ''设备类别 CHECKIN/CONSUME/CALL/TABLET/OTHER;IT-7 启用'' AFTER device_type', + 'DO 0') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'gy_sb' + AND COLUMN_NAME = 'device_class'); +PREPARE apt_stmt_sb_class FROM @ddl_sb_class; +EXECUTE apt_stmt_sb_class; +DEALLOCATE PREPARE apt_stmt_sb_class; + +-- 5. gy_sb.face_scope_value —— 表 10-2 可见范围取值(房间号 / 类别白名单 / 区域编码) +-- 与既有 face_scope_ref(IT-2 §12 预置引用列)并存:本期只建列,取值由 IT-7 N-27 写入。 +SET @ddl_sb_value = (SELECT IF(COUNT(*) = 0, + 'ALTER TABLE gy_sb ADD COLUMN face_scope_value VARCHAR(256) NULL COMMENT ''可见范围取值:房间号 / 类别白名单 / 区域编码;IT-7 启用'' AFTER face_scope_ref', + 'DO 0') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'gy_sb' + AND COLUMN_NAME = 'face_scope_value'); +PREPARE apt_stmt_sb_value FROM @ddl_sb_value; +EXECUTE apt_stmt_sb_value; +DEALLOCATE PREPARE apt_stmt_sb_value; + +-- 6. gy_sb.face_cache_enabled —— 表 10-2 是否允许缓存人脸(平板等临时拉取设备为 0) +SET @ddl_sb_cache = (SELECT IF(COUNT(*) = 0, + 'ALTER TABLE gy_sb ADD COLUMN face_cache_enabled TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否允许缓存人脸;TABLET 等临时拉取设备须为 0'' AFTER face_scope_value', + 'DO 0') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'gy_sb' + AND COLUMN_NAME = 'face_cache_enabled'); +PREPARE apt_stmt_sb_cache FROM @ddl_sb_cache; +EXECUTE apt_stmt_sb_cache; +DEALLOCATE PREPARE apt_stmt_sb_cache; + +-- 7. 校验 +SELECT '=== IT-3 回执态列(应 3 行)===' AS section; +SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_DEFAULT, COLUMN_COMMENT +FROM information_schema.COLUMNS +WHERE TABLE_SCHEMA = DATABASE() + AND ( (TABLE_NAME = 'apt_person_face_reg' AND COLUMN_NAME IN ('platform_version','unit_code')) + OR (TABLE_NAME = 'apt_master_person' AND COLUMN_NAME = 'face_platform_version')) +ORDER BY TABLE_NAME, COLUMN_NAME; + +SELECT '=== §12 设备范围补列(应 3 行 device_class/face_scope_value/face_cache_enabled)===' AS section; +SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_DEFAULT, COLUMN_COMMENT +FROM information_schema.COLUMNS +WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'gy_sb' + AND COLUMN_NAME IN ('device_class','face_scope_value','face_cache_enabled') +ORDER BY ORDINAL_POSITION; + +SELECT '=== 注册双状态链 CHECK 未被改动(应 5 行 state/verdict/upload/dispatch/report)===' AS section; +SELECT CONSTRAINT_NAME, CHECK_CLAUSE +FROM information_schema.CHECK_CONSTRAINTS +WHERE CONSTRAINT_SCHEMA = DATABASE() + AND CONSTRAINT_NAME IN ('ck_facereg_state','ck_facereg_verdict','ck_facereg_upload','ck_facereg_dispatch','ck_facereg_report') +ORDER BY CONSTRAINT_NAME; diff --git a/dev/sql/apt_rev01/15_it3_seed.sql b/dev/sql/apt_rev01/15_it3_seed.sql new file mode 100644 index 0000000..c369413 --- /dev/null +++ b/dev/sql/apt_rev01/15_it3_seed.sql @@ -0,0 +1,56 @@ +-- ============================================================================= +-- IT-3 / DV-3 Mock 设备灌数(可重放):1 签到终端 + 1 平板 + 1 停用签到终端。 +-- 依据:公寓端/10 表 10-2(gy_sb §12 补列)、表 10-15 / 10-16(设备类别→可见范围矩阵)、 +-- 表 10-26 N-34(申请设备须在公寓设备档案登记)、13 表 13-13。 +-- 口径: +-- 1) 只登记设备档案,不伪造心跳与缓存台账(gy_sb_xt 由设备真实心跳写入,IT-7 才读)。 +-- 2) 签到终端 CHECKIN / ALL_ACTIVE / 允许缓存;平板 TABLET / NONE / 不缓存(表 10-16)。 +-- 3) 停用设备 DEV-CHECKIN-99 用于验证 40303:countDeviceByCode 只认 state <> 'DISABLED', +-- 停用设备不得作为 N-34 申请设备。 +-- 4) device_code 入库大写,与 AptDeviceSigner 的规范化口径一致;重复执行不产生重复设备。 +-- 执行前可用以下方式覆盖公寓标识(需与环境变量 APT_GY_ID 保持一致): +-- mysql ... -e "SET @apt_gyid='YOUR_GYID'; SOURCE 15_it3_seed.sql" +-- ============================================================================= +SET @apt_gyid := COALESCE(NULLIF(@apt_gyid, ''), 'APT-REV01-001'); + +-- 1. 设备档案(§12 补列随 14_it3_columns.sql 一并落地) +INSERT INTO gy_sb + (gyid, device_code, device_type, device_class, room_id, state, + face_scope_type, face_scope_ref, face_scope_value, face_cache_enabled, + biz_version, deleted) +VALUES + (@apt_gyid, 'DEV-CHECKIN-01', 'FACE', 'CHECKIN', NULL, 'ACTIVE', + 'ALL_ACTIVE', 'scope://face/all-active', 'ALL', 1, 1, 0), + (@apt_gyid, 'DEV-TABLET-01', 'TABLET', 'TABLET', 'ROOM-201', 'ACTIVE', + 'NONE', NULL, NULL, 0, 1, 0), + (@apt_gyid, 'DEV-CHECKIN-99', 'FACE', 'CHECKIN', NULL, 'DISABLED', + 'ALL_ACTIVE', 'scope://face/all-active', 'ALL', 1, 1, 0) +ON DUPLICATE KEY UPDATE device_type = VALUES(device_type), device_class = VALUES(device_class), + room_id = VALUES(room_id), state = VALUES(state), + face_scope_type = VALUES(face_scope_type), face_scope_ref = VALUES(face_scope_ref), + face_scope_value = VALUES(face_scope_value), + face_cache_enabled = VALUES(face_cache_enabled), deleted = VALUES(deleted); + +-- 2. 校验 +SELECT '=== IT-3 Mock 设备(应 3 行:2 CHECKIN + 1 TABLET)===' AS section; +SELECT device_code, device_type, device_class, state, + face_scope_type, face_scope_value, face_cache_enabled +FROM gy_sb +WHERE gyid = @apt_gyid + AND device_code IN ('DEV-CHECKIN-01', 'DEV-TABLET-01', 'DEV-CHECKIN-99') +ORDER BY device_code; + +SELECT '=== N-34 可申请设备(state <> DISABLED,应 2)===' AS section; +SELECT COUNT(*) AS apply_device_count +FROM gy_sb +WHERE gyid = @apt_gyid + AND device_code IN ('DEV-CHECKIN-01', 'DEV-TABLET-01', 'DEV-CHECKIN-99') + AND state <> 'DISABLED'; + +SELECT '=== 表 10-16 矩阵一致性(CHECKIN 须 ALL_ACTIVE 且可缓存;TABLET 不得缓存,应 0 行违规)===' AS section; +SELECT device_code, device_class, face_scope_type, face_cache_enabled +FROM gy_sb +WHERE gyid = @apt_gyid AND deleted = 0 + AND ( (device_class = 'CHECKIN' AND (face_scope_type <> 'ALL_ACTIVE' OR face_cache_enabled <> 1)) + OR (device_class = 'TABLET' AND face_cache_enabled <> 0)) +ORDER BY device_code; diff --git a/dev/sql/apt_rev01/16_it3_permissions.sql b/dev/sql/apt_rev01/16_it3_permissions.sql new file mode 100644 index 0000000..7e8814e --- /dev/null +++ b/dev/sql/apt_rev01/16_it3_permissions.sql @@ -0,0 +1,89 @@ +-- ============================================================================= +-- IT-3 权限点与页面菜单种子(可重放)。执行顺序:14 补列 → 15 设备种子 → 本文件。 +-- 依据:公寓端/10 表 10-24(权限与安全)、表 10-26(N-25~N-37 接口)、 +-- 04 §0 接口增量表、13 表 13-13 验收①、CR-02 §12(CR-C25 / 验收 15)。 +-- 口径: +-- 1) 只新增 IT-3 需要的权限点与 1 个页面菜单,不改 3000 目录、不改 IT-1/IT-2 既有 F 点。 +-- 2) N-34 / N-38(`/open/v1/**`)为设备侧匿名分区,无权限点字符串, +-- 靠「设备编码 + HMAC + 时间窗 + nonce」四要素鉴权,不在此登记。 +-- 3) 签到终端只有「发起申请」权(N-34);确认 / 驳回必须 `duty:person:reg` + 二次确认(N-36/N-37)。 +-- 4) 设备下发 / 可见范围 / 类别标注 / 三级对账属 IT-7(N-26/N-27/N-29), +-- 表 10-24 #1 要求登记 `apt:face:category`,本期只登记权限点并置灰, +-- 与 IT-2 的 3014 `apt:room:add` 置灰策略一致,避免「页面有按钮、后端恒 40940」的假交互。 +-- ============================================================================= + +-- 1. F 权限点(parent 3000) +INSERT INTO sys_menu + (menu_id, menu_name, parent_id, order_num, path, component, query, route_name, + is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +VALUES + (3211, '人脸人员查看', 3000, 11, '', '', '', '', 1, 0, 'F', '0', '0', + 'apt:face:list', '#', 'IT-3', NOW(), + 'IT-3 N-25 人脸人员 / N-35 注册台账查看;只读,不接受客户端 gyid'), + (3212, '人脸注册审核', 3000, 12, '', '', '', '', 1, 0, 'F', '0', '0', + 'duty:person:reg', '#', 'IT-3', NOW(), + 'IT-3 N-35/N-36/N-37 值班台;确认=注册生效点,须二次确认;签到终端只有申请权'), + (3213, '人脸下发', 3000, 13, '', '', '', '', 1, 0, 'F', '1', '1', + 'apt:face:dispatch', '#', 'IT-3', NOW(), + 'N-26 设备下发指令;IT-7 才开放,本期置灰不授予角色'), + (3214, '人脸可见范围', 3000, 14, '', '', '', '', 1, 0, 'F', '1', '1', + 'apt:face:scope', '#', 'IT-3', NOW(), + 'N-27 设备人脸可见范围;IT-7 才开放,本期置灰不授予角色'), + (3215, '人脸对账', 3000, 15, '', '', '', '', 1, 0, 'F', '1', '1', + 'apt:face:reconcile', '#', 'IT-3', NOW(), + 'N-29 三级版本 / hash 对账;IT-7 才开放,本期置灰不授予角色'), + (3216, '人脸类别标注', 3000, 16, '', '', '', '', 1, 0, 'F', '1', '1', + 'apt:face:category', '#', 'IT-3', NOW(), + '表 10-24 #7 人员类别标注仅公寓管理员可改;类别变更联动 FACE_ERASE 属 IT-7,本期置灰不授予角色') +ON DUPLICATE KEY UPDATE menu_name = VALUES(menu_name), parent_id = VALUES(parent_id), + order_num = VALUES(order_num), menu_type = VALUES(menu_type), visible = VALUES(visible), + status = VALUES(status), perms = VALUES(perms), remark = VALUES(remark); + +-- 2. C 页面菜单(父目录 3000,完整路由 /apt/face) +INSERT INTO sys_menu + (menu_id, menu_name, parent_id, order_num, path, component, query, route_name, + is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +VALUES + (3201, '人脸注册审核', 3000, 5, 'face', 'apt/face/reg/index', '', '', 1, 0, 'C', '0', '0', + '', 'peoples', 'IT-3', NOW(), + 'IT-3 人脸注册申请台账:regState 切换待审/已确认/已驳回;N-36 确认、N-37 驳回;双路同步状态只读') +ON DUPLICATE KEY UPDATE menu_name = VALUES(menu_name), parent_id = VALUES(parent_id), + order_num = VALUES(order_num), path = VALUES(path), component = VALUES(component), + menu_type = VALUES(menu_type), visible = VALUES(visible), status = VALUES(status), + icon = VALUES(icon), remark = VALUES(remark); + +-- 3. 超管关联(仅可用项;置灰项 3213~3216 不关联) +INSERT INTO sys_role_menu (role_id, menu_id) +SELECT 1, m.menu_id FROM sys_menu m +WHERE m.menu_id IN (3201, 3211, 3212) + AND NOT EXISTS (SELECT 1 FROM sys_role_menu rm WHERE rm.role_id = 1 AND rm.menu_id = m.menu_id); + +-- 4. 校验 +SELECT '=== IT-3 权限点与页面菜单(应 7 行:6 F + 1 C)===' AS section; +SELECT menu_id, menu_name, menu_type, visible, status, perms, component +FROM sys_menu WHERE menu_id BETWEEN 3201 AND 3216 ORDER BY menu_id; + +SELECT '=== C 菜单组件路径(须与 src/views/apt/face/reg/index.vue 一致)===' AS section; +SELECT menu_id, CONCAT('src/views/', component, '.vue') AS vue_file +FROM sys_menu WHERE menu_id = 3201; + +SELECT '=== 超管关联可用项(应 3)===' AS section; +SELECT COUNT(*) AS admin_bound +FROM sys_role_menu rm JOIN sys_menu m ON m.menu_id = rm.menu_id +WHERE rm.role_id = 1 AND m.menu_id IN (3201, 3211, 3212); + +SELECT '=== 置灰项是否仍未关联角色(3213~3216 应为 0)===' AS section; +SELECT COUNT(*) AS grey_bound +FROM sys_role_menu rm JOIN sys_menu m ON m.menu_id = rm.menu_id +WHERE m.menu_id IN (3213, 3214, 3215, 3216); + +SELECT '=== 公寓端菜单树构成(应 M=1 / C=5 / F=21)===' AS section; +SELECT menu_type, COUNT(*) AS cnt +FROM sys_menu +WHERE menu_id = 3000 OR parent_id = 3000 +GROUP BY menu_type ORDER BY menu_type; + +SELECT '=== M0 验收账号仍无公寓端权限点(应为 0)===' AS section; +SELECT COUNT(*) AS apt_permission_count +FROM sys_role_menu rm JOIN sys_menu m ON m.menu_id = rm.menu_id +WHERE rm.role_id = 300 AND (m.perms LIKE 'apt:%' OR m.perms LIKE 'duty:%' OR m.perms = '*:*:*'); diff --git a/dev/sql/apt_rev01/17_it4_columns.sql b/dev/sql/apt_rev01/17_it4_columns.sql new file mode 100644 index 0000000..baa40aa --- /dev/null +++ b/dev/sql/apt_rev01/17_it4_columns.sql @@ -0,0 +1,178 @@ +-- ============================================================================= +-- IT-4 入住作业核心补列与状态字典(可重放):CI-02 ~ CI-05。 +-- 依据:公寓端/11 §3(表 11-7 / 11-8 / 11-9)、CR-02 §13 表 3-57 验收 18~20、13 表 13-14。 +-- 口径: +-- 1) 59 张业务表不变:只补列、不放宽既有事实列语义、不新增 gy_jcxx 状态字段。 +-- 2) `checkin_state` / `sync_state` 两条流程态字典按表 11-4 / 11-9 扩展: +-- checkin_state: SUBMITTED/REVIEWING/APPROVED/ROOM_ADJUSTED/REJECTED/CHECKED_IN/CANCELLED/CLOSED +-- sync_state : PENDING/REPORTED/MATCHED/UNMATCHED/TASK_READY +-- 这是**流程态**字典替换(表 11-4),不是新增状态字段;gy_jcxx 的 ryzt/finish_ly 仍只经 apt.duty.state 写入。 +-- 3) `gygl_ly_event.event_type` 按 CR-02 表 3-14 增补 ROOM_SWAP(审核期换房的证据事件)。 +-- 4) 复用既有列:`audited_by` / `audited_at` 即表 11-7 的 reviewed_by / reviewed_at(不重复建列)。 +-- 5) 依赖 00_common.sql 的 apt_add_column_if_missing(与 07_patch_columns.sql 同一辅助)。 +-- ============================================================================= + +-- 1. apt_local_task 申请阶段补列(表 11-7) +CALL apt_add_column_if_missing('apt_local_task', 'device_code', + 'device_code VARCHAR(64) NULL COMMENT ''签到终端来源(表 11-7)'''); +CALL apt_add_column_if_missing('apt_local_task', 'device_event_id', + 'device_event_id VARCHAR(64) NULL COMMENT ''设备侧幂等键(表 11-7)'''); +CALL apt_add_column_if_missing('apt_local_task', 'work_no', + 'work_no VARCHAR(64) NULL COMMENT ''工号快照(平台匹配输入)'''); +CALL apt_add_column_if_missing('apt_local_task', 'name_snapshot', + 'name_snapshot VARCHAR(128) NULL COMMENT ''姓名快照(不替代 apt_master_person)'''); +CALL apt_add_column_if_missing('apt_local_task', 'reg_id', + 'reg_id VARCHAR(64) NULL COMMENT ''关联 apt_person_face_reg'''); +CALL apt_add_column_if_missing('apt_local_task', 'reg_state_at_apply', + 'reg_state_at_apply VARCHAR(20) NULL COMMENT ''申请时注册态,必须是 CONFIRMED'''); +CALL apt_add_column_if_missing('apt_local_task', 'jch', + 'jch VARCHAR(32) NULL COMMENT ''机次快照'''); +CALL apt_add_column_if_missing('apt_local_task', 'kcsj_snapshot', + 'kcsj_snapshot DATETIME(3) NULL COMMENT ''开车时刻快照(不写主数据)'''); +CALL apt_add_column_if_missing('apt_local_task', 'verified_ryid', + 'verified_ryid VARCHAR(64) NULL COMMENT ''N-38 核验通过的人员'''); +CALL apt_add_column_if_missing('apt_local_task', 'verified_at', + 'verified_at DATETIME(3) NULL COMMENT ''N-38 核验时刻(≤5 min 才能提交申请)'''); +CALL apt_add_column_if_missing('apt_local_task', 'feature_version', + 'feature_version BIGINT NULL COMMENT ''核验所用特征版本'''); +CALL apt_add_column_if_missing('apt_local_task', 'source', + 'source VARCHAR(20) NOT NULL DEFAULT ''DEVICE'' COMMENT ''DEVICE / DUTY / SELF(CR-02 表 3-14)'''); +CALL apt_add_column_if_missing('apt_local_task', 'submitted_at', + 'submitted_at DATETIME(3) NULL COMMENT ''申请提交时刻'''); +CALL apt_add_column_if_missing('apt_local_task', 'occurred_at', + 'occurred_at DATETIME(3) NULL COMMENT ''业务发生时刻(设备事件时间,不判日切)'''); +CALL apt_add_column_if_missing('apt_local_task', 'review_note', + 'review_note VARCHAR(256) NULL COMMENT ''审核说明 / 驳回原因'''); +CALL apt_add_column_if_missing('apt_local_task', 'adjusted_by', + 'adjusted_by VARCHAR(64) NULL COMMENT ''房间调整操作人'''); +CALL apt_add_column_if_missing('apt_local_task', 'adjusted_at', + 'adjusted_at DATETIME(3) NULL COMMENT ''房间调整时刻'''); +CALL apt_add_column_if_missing('apt_local_task', 'adjust_reason', + 'adjust_reason VARCHAR(256) NULL COMMENT ''房间调整理由'''); +CALL apt_add_column_if_missing('apt_local_task', 'reported_at', + 'reported_at DATETIME(3) NULL COMMENT ''#9 CHECKIN 入队时刻'''); +CALL apt_add_column_if_missing('apt_local_task', 'matched_at', + 'matched_at DATETIME(3) NULL COMMENT ''#7 匹配结论落库时刻'''); +CALL apt_add_column_if_missing('apt_local_task', 'task_ready_at', + 'task_ready_at DATETIME(3) NULL COMMENT ''任务就绪(jcsj 复算完成)时刻'''); +CALL apt_add_column_if_missing('apt_local_task', 'matched_plan_key', + 'matched_plan_key VARCHAR(128) NULL COMMENT ''平台匹配键(仅登记,不替代 jcid)'''); +CALL apt_add_column_if_missing('apt_local_task', 'base_kcsj', + 'base_kcsj DATETIME(3) NULL COMMENT ''平台补齐的开車时刻(要素非叫乘时刻)'''); +CALL apt_add_column_if_missing('apt_local_task', 'base_jcfs', + 'base_jcfs VARCHAR(32) NULL COMMENT ''平台补齐的叫乘方式'''); +CALL apt_add_column_if_missing('apt_local_task', 'origin_jcsj', + 'origin_jcsj DATETIME(3) NULL COMMENT ''本地指定叫乘时刻(锁定时保留)'''); +CALL apt_add_column_if_missing('apt_local_task', 'derived_rule_version', + 'derived_rule_version BIGINT NULL COMMENT ''jcsj 复算所用规则版本,升级不回溯'''); +CALL apt_add_column_if_missing('apt_local_task', 'jllx', + 'jllx VARCHAR(8) NULL COMMENT ''1=本地锁定交路要素(D8 例外②)'''); + +-- 2. 幂等键:设备事件唯一、申请单唯一(表 11-7) +SET @uk_device_event = (SELECT IF(COUNT(*) = 0, + 'ALTER TABLE apt_local_task ADD UNIQUE KEY uk_lt_gyid_device_event (gyid, device_code, device_event_id)', + 'DO 0') + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'apt_local_task' + AND INDEX_NAME = 'uk_lt_gyid_device_event'); +PREPARE apt_stmt_uk_de FROM @uk_device_event; +EXECUTE apt_stmt_uk_de; +DEALLOCATE PREPARE apt_stmt_uk_de; + +SET @uk_apply = (SELECT IF(COUNT(*) = 0, + 'ALTER TABLE apt_local_task ADD UNIQUE KEY uk_lt_gyid_apply (gyid, apply_id)', + 'DO 0') + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'apt_local_task' + AND INDEX_NAME = 'uk_lt_gyid_apply'); +PREPARE apt_stmt_uk_apply FROM @uk_apply; +EXECUTE apt_stmt_uk_apply; +DEALLOCATE PREPARE apt_stmt_uk_apply; + +-- 3. 流程态字典(表 11-4 / 11-9):先删旧 CHECK,再按新字典重建,可重放 +SET @drop_checkin = (SELECT IF(COUNT(*) > 0, + 'ALTER TABLE apt_local_task DROP CHECK ck_lt_checkin', + 'DO 0') + FROM information_schema.TABLE_CONSTRAINTS + WHERE CONSTRAINT_SCHEMA = DATABASE() AND TABLE_NAME = 'apt_local_task' + AND CONSTRAINT_NAME = 'ck_lt_checkin' AND CONSTRAINT_TYPE = 'CHECK'); +PREPARE apt_stmt_drop_ci FROM @drop_checkin; +EXECUTE apt_stmt_drop_ci; +DEALLOCATE PREPARE apt_stmt_drop_ci; + +SET @add_checkin = (SELECT IF(COUNT(*) = 0, + 'ALTER TABLE apt_local_task ADD CONSTRAINT ck_lt_checkin CHECK (checkin_state IN (''SUBMITTED'',''REVIEWING'',''APPROVED'',''ROOM_ADJUSTED'',''REJECTED'',''CHECKED_IN'',''CANCELLED'',''CLOSED''))', + 'DO 0') + FROM information_schema.TABLE_CONSTRAINTS + WHERE CONSTRAINT_SCHEMA = DATABASE() AND TABLE_NAME = 'apt_local_task' + AND CONSTRAINT_NAME = 'ck_lt_checkin' AND CONSTRAINT_TYPE = 'CHECK'); +PREPARE apt_stmt_add_ci FROM @add_checkin; +EXECUTE apt_stmt_add_ci; +DEALLOCATE PREPARE apt_stmt_add_ci; + +SET @drop_sync = (SELECT IF(COUNT(*) > 0, + 'ALTER TABLE apt_local_task DROP CHECK ck_lt_sync', + 'DO 0') + FROM information_schema.TABLE_CONSTRAINTS + WHERE CONSTRAINT_SCHEMA = DATABASE() AND TABLE_NAME = 'apt_local_task' + AND CONSTRAINT_NAME = 'ck_lt_sync' AND CONSTRAINT_TYPE = 'CHECK'); +PREPARE apt_stmt_drop_sync FROM @drop_sync; +EXECUTE apt_stmt_drop_sync; +DEALLOCATE PREPARE apt_stmt_drop_sync; + +SET @add_sync = (SELECT IF(COUNT(*) = 0, + 'ALTER TABLE apt_local_task ADD CONSTRAINT ck_lt_sync CHECK (sync_state IN (''PENDING'',''REPORTED'',''MATCHED'',''UNMATCHED'',''TASK_READY''))', + 'DO 0') + FROM information_schema.TABLE_CONSTRAINTS + WHERE CONSTRAINT_SCHEMA = DATABASE() AND TABLE_NAME = 'apt_local_task' + AND CONSTRAINT_NAME = 'ck_lt_sync' AND CONSTRAINT_TYPE = 'CHECK'); +PREPARE apt_stmt_add_sync FROM @add_sync; +EXECUTE apt_stmt_add_sync; +DEALLOCATE PREPARE apt_stmt_add_sync; + +-- 4. gygl_ly_event.event_type 增补 ROOM_SWAP(CR-02 表 3-14;审核期换房证据) +SET @drop_lye = (SELECT IF(COUNT(*) > 0, + 'ALTER TABLE gygl_ly_event DROP CHECK ck_lye_type', + 'DO 0') + FROM information_schema.TABLE_CONSTRAINTS + WHERE CONSTRAINT_SCHEMA = DATABASE() AND TABLE_NAME = 'gygl_ly_event' + AND CONSTRAINT_NAME = 'ck_lye_type' AND CONSTRAINT_TYPE = 'CHECK'); +PREPARE apt_stmt_drop_lye FROM @drop_lye; +EXECUTE apt_stmt_drop_lye; +DEALLOCATE PREPARE apt_stmt_drop_lye; + +SET @add_lye = (SELECT IF(COUNT(*) = 0, + 'ALTER TABLE gygl_ly_event ADD CONSTRAINT ck_lye_type CHECK (event_type IN (''CHECKIN'',''CHECKOUT'',''ROOM_SWAP''))', + 'DO 0') + FROM information_schema.TABLE_CONSTRAINTS + WHERE CONSTRAINT_SCHEMA = DATABASE() AND TABLE_NAME = 'gygl_ly_event' + AND CONSTRAINT_NAME = 'ck_lye_type' AND CONSTRAINT_TYPE = 'CHECK'); +PREPARE apt_stmt_add_lye FROM @add_lye; +EXECUTE apt_stmt_add_lye; +DEALLOCATE PREPARE apt_stmt_add_lye; + +-- 5. 校验 +SELECT '=== IT-4 apt_local_task 补列(应 27 行)===' AS section; +SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_DEFAULT +FROM information_schema.COLUMNS +WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'apt_local_task' + AND COLUMN_NAME IN ('device_code','device_event_id','work_no','name_snapshot','reg_id', + 'reg_state_at_apply','jch','kcsj_snapshot','verified_ryid','verified_at','feature_version', + 'source','submitted_at','occurred_at','review_note','adjusted_by','adjusted_at','adjust_reason', + 'reported_at','matched_at','task_ready_at','matched_plan_key','base_kcsj','base_jcfs', + 'origin_jcsj','derived_rule_version','jllx') +ORDER BY COLUMN_NAME; + +SELECT '=== 流程态字典 CHECK(checkin_state 应 8 值 / sync_state 应 5 值)===' AS section; +SELECT CONSTRAINT_NAME, CHECK_CLAUSE +FROM information_schema.CHECK_CONSTRAINTS +WHERE CONSTRAINT_SCHEMA = DATABASE() + AND CONSTRAINT_NAME IN ('ck_lt_checkin','ck_lt_sync','ck_lye_type') +ORDER BY CONSTRAINT_NAME; + +SELECT '=== 幂等唯一键(应 2 行)===' AS section; +SELECT INDEX_NAME, GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX) AS cols +FROM information_schema.STATISTICS +WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'apt_local_task' + AND INDEX_NAME IN ('uk_lt_gyid_device_event','uk_lt_gyid_apply') +GROUP BY INDEX_NAME; diff --git a/dev/sql/apt_rev01/18_it4_permissions.sql b/dev/sql/apt_rev01/18_it4_permissions.sql new file mode 100644 index 0000000..92bccb8 --- /dev/null +++ b/dev/sql/apt_rev01/18_it4_permissions.sql @@ -0,0 +1,80 @@ +-- ============================================================================= +-- IT-4 权限点与页面菜单种子(可重放)。执行顺序:17_it4_columns.sql → 本文件。 +-- 依据:公寓端/11 表 11-33(权限点)、表 11-34(接口清单 N-38~N-47)、 +-- 13 表 13-14、CR-02 表 3-57 验收 18~20。 +-- 口径: +-- 1) `duty:checkin:list` 在 IT-1 已登记并置灰(menu_id 3005),本期**启用**,不新增重复点。 +-- 2) 新增 3 个 F 点:审核(review)/ 换房(adjust)/ 管理端查询(apt:checkin:query)。 +-- 3) 新增 1 个 C 页面菜单 3202「入住审核」,完整路由 /apt/checkin。 +-- 4) 设备侧 `/open/v1/checkin/**`(N-39~N-41)走设备四要素鉴权,无权限点字符串。 +-- 5) 跨公寓、超范围一律 40302/40303;本脚本不引入 role_id 300 的任何 apt 权限。 +-- ============================================================================= + +-- 1. 启用 IT-1 预置的入住列表权限点(3005) +UPDATE sys_menu +SET visible = '0', status = '0', + remark = 'IT-4 启用:N-42 入住待审列表 duty:checkin:list(IT-1 预置并置灰)' +WHERE menu_id = 3005 AND perms = 'duty:checkin:list'; + +-- 2. 新增 F 权限点(parent 3000) +INSERT INTO sys_menu + (menu_id, menu_name, parent_id, order_num, path, component, query, route_name, + is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +VALUES + (3217, '入住审核', 3000, 17, '', '', '', '', 1, 0, 'F', '0', '0', + 'duty:checkin:review', '#', 'IT-4', NOW(), + 'IT-4 N-44 审核:APPROVE / APPROVE_WITH_ROOM_CHANGE / REJECT,须二次确认且带 expectedBizVersion'), + (3218, '入住换房', 3000, 18, '', '', '', '', 1, 0, 'F', '0', '0', + 'duty:checkin:adjust', '#', 'IT-4', NOW(), + 'IT-4 N-45 换房:仅审核期(APPROVED / ROOM_ADJUSTED),CHECKED_IN 后走退宿换房(40926)'), + (3219, '入住查询', 3000, 19, '', '', '', '', 1, 0, 'F', '0', '0', + 'apt:checkin:query', '#', 'IT-4', NOW(), + 'IT-4 N-47 按 jcid 查入住与叫乘任务(管理端只读,不接受客户端 gyid)') +ON DUPLICATE KEY UPDATE menu_name = VALUES(menu_name), parent_id = VALUES(parent_id), + order_num = VALUES(order_num), menu_type = VALUES(menu_type), visible = VALUES(visible), + status = VALUES(status), perms = VALUES(perms), remark = VALUES(remark); + +-- 3. C 页面菜单(父目录 3000) +INSERT INTO sys_menu + (menu_id, menu_name, parent_id, order_num, path, component, query, route_name, + is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +VALUES + (3202, '入住审核', 3000, 6, 'checkin', 'apt/checkin/index', '', '', 1, 0, 'C', '0', '0', + '', 'druid', 'IT-4', NOW(), + 'IT-4 值班台入住待审台账:审核/换房/时间线只读;换房仅审核期,入住后走退宿换房') +ON DUPLICATE KEY UPDATE menu_name = VALUES(menu_name), parent_id = VALUES(parent_id), + order_num = VALUES(order_num), path = VALUES(path), component = VALUES(component), + menu_type = VALUES(menu_type), visible = VALUES(visible), status = VALUES(status), + icon = VALUES(icon), remark = VALUES(remark); + +-- 4. 超管关联(本期全部可用) +INSERT INTO sys_role_menu (role_id, menu_id) +SELECT 1, m.menu_id FROM sys_menu m +WHERE m.menu_id IN (3202, 3005, 3217, 3218, 3219) + AND NOT EXISTS (SELECT 1 FROM sys_role_menu rm WHERE rm.role_id = 1 AND rm.menu_id = m.menu_id); + +-- 5. 校验 +SELECT '=== IT-4 权限点与页面菜单(应 5 行:3005 + 3217~3219 + 3202)===' AS section; +SELECT menu_id, menu_name, menu_type, visible, status, perms, component +FROM sys_menu WHERE menu_id IN (3005, 3217, 3218, 3219, 3202) ORDER BY menu_id; + +SELECT '=== C 菜单组件路径(须与 src/views/apt/checkin/index.vue 一致)===' AS section; +SELECT menu_id, CONCAT('src/views/', component, '.vue') AS vue_file +FROM sys_menu WHERE menu_id = 3202; + +SELECT '=== 超管关联(应 5)===' AS section; +SELECT COUNT(*) AS admin_bound +FROM sys_role_menu rm JOIN sys_menu m ON m.menu_id = rm.menu_id +WHERE rm.role_id = 1 AND m.menu_id IN (3202, 3005, 3217, 3218, 3219); + +SELECT '=== 公寓端菜单树构成(应 M=1 / C=6 / F=24)===' AS section; +SELECT menu_type, COUNT(*) AS cnt +FROM sys_menu +WHERE menu_id = 3000 OR parent_id = 3000 +GROUP BY menu_type ORDER BY menu_type; + +SELECT '=== 无遗留置灰的入住权限点(应为 0)===' AS section; +SELECT COUNT(*) AS still_grey +FROM sys_menu +WHERE perms IN ('duty:checkin:list','duty:checkin:review','duty:checkin:adjust','apt:checkin:query') + AND (visible <> '0' OR status <> '0'); diff --git a/dev/sql/apt_rev01/19_it5_columns.sql b/dev/sql/apt_rev01/19_it5_columns.sql new file mode 100644 index 0000000..d4257d1 --- /dev/null +++ b/dev/sql/apt_rev01/19_it5_columns.sql @@ -0,0 +1,114 @@ +-- ============================================================================= +-- IT-5 叫乘执行与状态机(M3)增量 DDL(可重放) +-- 依据:公寓端/03 表 3-10(状态迁移矩阵)、公寓端/04 表 4-9(叫乘接口)、 +-- 公寓端/05 表 5-7(干预动作)/ 表 5-20(终态矩阵)、13 表 13-15 +-- 口径: +-- 1) 不新增业务表(59 张口径不变);只补 gy_jcxx 的时间戳/标志位存量列 +-- 与 duty_manual_action 的 reason 列。 +-- 2) ryzt / finish_jc / finish_ly / stoped 仍只有 apt.duty.state 一个写入口, +-- 本脚本不写任何业务数据。 +-- 3) 幂等:补列走 apt_add_column_if_missing,索引先查 information_schema。 +-- ============================================================================= +SET NAMES utf8mb4; + +-- ----------------------------------------------------------------------------- +-- 1. gy_jcxx 存量列补齐(表 3-10 的「同表写入」列) +-- 注意:apt_add_column_if_missing(p_table, p_column, p_ddl) 的 p_ddl 必须 +-- **自带列名**(过程只拼 "ALTER TABLE `t` ADD COLUMN "), +-- 与 07_patch_columns.sql 的写法一致。 +-- ----------------------------------------------------------------------------- +CALL apt_add_column_if_missing('gy_jcxx', 'jcsj_s', + "jcsj_s DATETIME(3) NULL COMMENT '实际开始叫乘时刻(CALL_START 条件更新写入)'"); +CALL apt_add_column_if_missing('gy_jcxx', 'jch', + "jch VARCHAR(32) NULL COMMENT '机次;表 4-9 correct 白名单字段(存量表缺列,IT-5 补齐)'"); +CALL apt_add_column_if_missing('gy_jcxx', 'hjxysj', + "hjxysj DATETIME(3) NULL COMMENT '应答时刻:SIP ConfbridgeJoin / 平板 VERIFIED'"); +CALL apt_add_column_if_missing('gy_jcxx', 'jcbjsj_s', + "jcbjsj_s DATETIME(3) NULL COMMENT '叫乘报警时刻(无应答 / NO_ANSWER)'"); +CALL apt_add_column_if_missing('gy_jcxx', 'fjsj_s', + "fjsj_s DATETIME(3) NULL COMMENT '复叫时刻(RE_CALL 或自动复叫)'"); +CALL apt_add_column_if_missing('gy_jcxx', 'lybjsj', + "lybjsj DATETIME(3) NULL COMMENT '离寓报警时刻'"); +CALL apt_add_column_if_missing('gy_jcxx', 'is_jcbj', + "is_jcbj VARCHAR(2) NOT NULL DEFAULT '0' COMMENT '叫乘报警标志 0/1'"); +CALL apt_add_column_if_missing('gy_jcxx', 'is_lybj', + "is_lybj VARCHAR(2) NOT NULL DEFAULT '0' COMMENT '离寓报警标志 0/1'"); +CALL apt_add_column_if_missing('gy_jcxx', 'is_temp_call', + "is_temp_call VARCHAR(2) NOT NULL DEFAULT '0' COMMENT '临时叫乘标志(人工临时叫乘仅置 1)'"); +CALL apt_add_column_if_missing('gy_jcxx', 'leave_type', + "leave_type VARCHAR(20) NULL COMMENT '离寓类型:MANUAL / DEVICE / FACE 等'"); +CALL apt_add_column_if_missing('gy_jcxx', 'leave_event_id', + "leave_event_id VARCHAR(64) NULL COMMENT '离寓事件 ID(gygl_ly_event.event_id)'"); +CALL apt_add_column_if_missing('gy_jcxx', 'leave_remark', + "leave_remark VARCHAR(256) NULL COMMENT '离寓/修正备注(correct 白名单字段)'"); + +-- ----------------------------------------------------------------------------- +-- 2. duty_manual_action:reason 列 + 动作枚举注释扩展 +-- 表 4-9 第 5 个接口的 5 个命令全部先落本表,再由 apt.duty.state 执行 +-- ----------------------------------------------------------------------------- +CALL apt_add_column_if_missing('duty_manual_action', 'reason', + "reason VARCHAR(256) NULL COMMENT '命令原因(审计与追责用)'"); + +ALTER TABLE duty_manual_action + MODIFY COLUMN action_type VARCHAR(32) NOT NULL + COMMENT 'STOP / RE_CALL / FORCE_FINISH / MARK_LEAVE / CANCEL / CORRECT'; + +ALTER TABLE duty_manual_action + MODIFY COLUMN state VARCHAR(20) NOT NULL DEFAULT 'PENDING' + COMMENT 'PENDING / DONE / FAILED / DUPLICATED'; + +-- ----------------------------------------------------------------------------- +-- 3. gy_jcxx 派发扫描索引(平板补任务 + SIP 超时扫描) +-- ----------------------------------------------------------------------------- +SET @apt_idx_mode = (SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'gy_jcxx' + AND INDEX_NAME = 'idx_jcxx_gyid_mode_state'); +SET @apt_sql_mode = IF(@apt_idx_mode = 0, + 'ALTER TABLE gy_jcxx ADD KEY idx_jcxx_gyid_mode_state (gyid, call_mode, ryzt, finish_ly)', + 'SELECT 1'); +PREPARE apt_stmt_mode FROM @apt_sql_mode; +EXECUTE apt_stmt_mode; +DEALLOCATE PREPARE apt_stmt_mode; + +-- 平板结果按 (gyid, occurred_at) 归日统计的覆盖索引 +SET @apt_idx_result = (SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'apt_tablet_call_result' + AND INDEX_NAME = 'idx_tabletresult_gyid_time'); +SET @apt_sql_result = IF(@apt_idx_result = 0, + 'ALTER TABLE apt_tablet_call_result ADD KEY idx_tabletresult_gyid_time (gyid, occurred_at)', + 'SELECT 1'); +PREPARE apt_stmt_result FROM @apt_sql_result; +EXECUTE apt_stmt_result; +DEALLOCATE PREPARE apt_stmt_result; + +-- ----------------------------------------------------------------------------- +-- 4. 校验 +-- ----------------------------------------------------------------------------- +SELECT '=== IT-5 gy_jcxx 增量列(应 12)===' AS section; +SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_DEFAULT +FROM information_schema.COLUMNS +WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'gy_jcxx' + AND COLUMN_NAME IN ('jcsj_s','hjxysj','jcbjsj_s','fjsj_s','lybjsj','is_jcbj', + 'is_lybj','is_temp_call','leave_type','leave_event_id','leave_remark','jch') +ORDER BY COLUMN_NAME; + +SELECT '=== IT-5 duty_manual_action 动作枚举(应含 5 命令 + CORRECT / reason 列)===' AS section; +SELECT COLUMN_NAME, COLUMN_TYPE, COLUMN_COMMENT +FROM information_schema.COLUMNS +WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'duty_manual_action' + AND COLUMN_NAME IN ('action_type','state','reason'); + +SELECT '=== IT-5 索引(应 2:gy_jcxx 模式扫描 + 平板结果归日)===' AS section; +SELECT TABLE_NAME, INDEX_NAME, GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX) AS cols +FROM information_schema.STATISTICS +WHERE TABLE_SCHEMA = DATABASE() + AND INDEX_NAME IN ('idx_jcxx_gyid_mode_state','idx_tabletresult_gyid_time') +GROUP BY TABLE_NAME, INDEX_NAME; + +SELECT '=== IT-5 状态字段初值兜底(应 0:不得出现 NULL/stoped 非法值)===' AS section; +SELECT COUNT(*) AS illegal_state_rows +FROM gy_jcxx +WHERE deleted = 0 + AND (stoped IS NULL OR stoped NOT IN ('0','1') + OR finish_jc IS NULL OR finish_jc NOT IN ('0','1') + OR finish_ly IS NULL OR finish_ly NOT IN ('0','1')); diff --git a/dev/sql/apt_rev01/20_it5_permissions.sql b/dev/sql/apt_rev01/20_it5_permissions.sql new file mode 100644 index 0000000..652bc41 --- /dev/null +++ b/dev/sql/apt_rev01/20_it5_permissions.sql @@ -0,0 +1,97 @@ +-- ============================================================================= +-- IT-5 权限点与页面菜单种子(可重放)。执行顺序:19_it5_columns.sql → 本文件。 +-- 依据:公寓端/04 表 4-9(叫乘 6 接口)、表 4-10(叫乘域映射)、13 表 13-15 +-- 口径: +-- 1) `apt:jcxx:list` 在 IT-1 已登记并置灰(menu_id 3004),本期**启用**, +-- 不新增重复点。 +-- 2) 新增 3 个 F 点:详情/时间线(query)、非状态修正(correct)、 +-- 状态机命令(changeStatus)。 +-- 3) 新增 1 个 C 页面菜单 3203「叫乘台账」,路由 /apt/jcxx。 +-- 4) 设备侧 `/open/v1/tablet/**`(N-03/N-04/N-05)走设备四要素鉴权, +-- 无权限点字符串,本脚本不为其建点。 +-- 5) 本脚本不引入 role_id 300 的任何 apt 权限。 +-- ============================================================================= +SET NAMES utf8mb4; + +-- 1. 启用 IT-1 预置的叫乘列表权限点(3004) +UPDATE sys_menu +SET visible = '0', status = '0', + remark = 'IT-5 启用:/apt/jcxx/list 与 /apt/jcxx/statistics(IT-1 预置并置灰)' +WHERE menu_id = 3004 AND perms = 'apt:jcxx:list'; + +-- 2. 新增 F 权限点(parent 3000) +INSERT INTO sys_menu + (menu_id, menu_name, parent_id, order_num, path, component, query, route_name, + is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +VALUES + (3220, '叫乘详情', 3000, 20, '', '', '', '', 1, 0, 'F', '0', '0', + 'apt:jcxx:query', '#', 'IT-5', NOW(), + 'IT-5 详情与时间线:状态迁移 + AMI 事件 + 人工命令按 at 升序,只读'), + (3221, '叫乘修正', 3000, 21, '', '', '', '', 1, 0, 'F', '0', '0', + 'apt:jcxx:correct', '#', 'IT-5', NOW(), + 'IT-5 非状态字段白名单修正(fjh/jlid/cc/jch/leaveRemark/is_temp_call);ryzt 等一律 40001'), + (3222, '叫乘命令', 3000, 22, '', '', '', '', 1, 0, 'F', '0', '0', + 'apt:jcxx:changeStatus', '#', 'IT-5', NOW(), + 'IT-5 STOP/RE_CALL/FORCE_FINISH/MARK_LEAVE/CANCEL,commandId 幂等,非法状态 40902') +ON DUPLICATE KEY UPDATE menu_name = VALUES(menu_name), parent_id = VALUES(parent_id), + order_num = VALUES(order_num), menu_type = VALUES(menu_type), visible = VALUES(visible), + status = VALUES(status), perms = VALUES(perms), remark = VALUES(remark); + +-- 3. C 页面菜单(父目录 3000) +INSERT INTO sys_menu + (menu_id, menu_name, parent_id, order_num, path, component, query, route_name, + is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +VALUES + (3203, '叫乘台账', 3000, 7, 'jcxx', 'apt/jcxx/index', '', '', 1, 0, 'C', '0', '0', + '', 'phone', 'IT-5', NOW(), + 'IT-5 / M3 叫乘台账:只读 + 提交命令;无「编辑状态」入口(PUT /apt/jcxx/{id} 不存在)') +ON DUPLICATE KEY UPDATE menu_name = VALUES(menu_name), parent_id = VALUES(parent_id), + order_num = VALUES(order_num), path = VALUES(path), component = VALUES(component), + menu_type = VALUES(menu_type), visible = VALUES(visible), status = VALUES(status), + icon = VALUES(icon), remark = VALUES(remark); + +-- 4. 超管关联(本期全部可用) +INSERT INTO sys_role_menu (role_id, menu_id) +SELECT 1, m.menu_id FROM sys_menu m +WHERE m.menu_id IN (3203, 3004, 3220, 3221, 3222) + AND NOT EXISTS (SELECT 1 FROM sys_role_menu rm WHERE rm.role_id = 1 AND rm.menu_id = m.menu_id); + +-- 5. 叫乘扫描任务(默认停用,试点时手工启用) +-- 说明:invoke_target 必须是 com.ruoyi.quartz.task 包内的 bean, +-- 故 Job 类放在 ruoyi-apt 模块的 com.ruoyi.quartz.task.AptDispatchTask, +-- 委托 AptDispatchScanService;不扩若依白名单。 +INSERT INTO sys_job + (job_id, job_name, job_group, invoke_target, cron_expression, misfire_policy, + concurrent, status, create_by, create_time, remark) +VALUES + (103, '叫乘扫描(平板派发/过期/SIP 超时)', 'SYSTEM', 'aptDispatchTask.scan()', '0 * * * * ?', + '1', '1', '1', 'IT-5', NOW(), + 'IT-5 每分钟:回收过期平板任务、补生成任务、SIP 30s 无入会转叫乘报警;默认停用') +ON DUPLICATE KEY UPDATE job_name = VALUES(job_name), invoke_target = VALUES(invoke_target), + cron_expression = VALUES(cron_expression), remark = VALUES(remark); + +-- 6. 校验 +SELECT '=== IT-5 权限点与页面菜单(应 5 行:3004 + 3220~3222 + 3203)===' AS section; +SELECT menu_id, menu_name, menu_type, visible, status, perms, component +FROM sys_menu WHERE menu_id IN (3004, 3220, 3221, 3222, 3203) ORDER BY menu_id; + +SELECT '=== C 菜单组件路径(须与 src/views/apt/jcxx/index.vue 一致)===' AS section; +SELECT menu_id, CONCAT('src/views/', component, '.vue') AS vue_file +FROM sys_menu WHERE menu_id = 3203; + +SELECT '=== 超管关联(应 5)===' AS section; +SELECT COUNT(*) AS admin_bound +FROM sys_role_menu rm JOIN sys_menu m ON m.menu_id = rm.menu_id +WHERE rm.role_id = 1 AND m.menu_id IN (3203, 3004, 3220, 3221, 3222); + +SELECT '=== 公寓端菜单树构成(M=1 / C=7 / F=27)===' AS section; +SELECT menu_type, COUNT(*) AS cnt +FROM sys_menu +WHERE menu_id = 3000 OR parent_id = 3000 +GROUP BY menu_type ORDER BY menu_type; + +SELECT '=== 无遗留置灰的叫乘权限点(应为 0)===' AS section; +SELECT COUNT(*) AS still_grey +FROM sys_menu +WHERE perms IN ('apt:jcxx:list','apt:jcxx:query','apt:jcxx:correct','apt:jcxx:changeStatus') + AND (visible <> '0' OR status <> '0'); diff --git a/dev/sql/apt_rev01/21_ami_instance.sql b/dev/sql/apt_rev01/21_ami_instance.sql new file mode 100644 index 0000000..11ae029 --- /dev/null +++ b/dev/sql/apt_rev01/21_ami_instance.sql @@ -0,0 +1,156 @@ +-- ============================================================================= +-- AMI 连接与语音链路增量(一公寓一 Asterisk 实例) +-- 依据:公寓端/19 设计文档;公寓端/05 表 5-12(SIP 叫乘链路)、表 5-13(AMI 事件映射); +-- docs/10 表 8-3(AMI 不出本机)、表 8-4(端口 7058)、D5(一进程一 gyid) +-- 口径: +-- 1) 新增 1 张映射表 apt_ast_instance:gyid 唯一 -> 一公寓一实例(UK 兜底)。 +-- 这是对「59 张表」基线的显式偏离,已在公寓端/19 §7 登记(新增第 60 张)。 +-- 2) AMI 口令**不落库**:只存环境变量引用 ami_secret_ref(红钱:口令不进仓库)。 +-- 3) 复用既有 apt_ast_ami_session / apt_call_session / apt_call_event / +-- apt_tts_utterance / apt_recording,仅补列,不新建语音表。 +-- 4) 幂等:建表 IF NOT EXISTS,补列走 apt_add_column_if_missing。 +-- ============================================================================= +SET NAMES utf8mb4; + +-- ----------------------------------------------------------------------------- +-- 1. 公寓 ↔ Asterisk 实例映射(1:1) +-- ----------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS apt_ast_instance ( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '代理主键', + gyid VARCHAR(32) NOT NULL COMMENT '公寓隔离标识(D5:一进程一 gyid)', + instance_code VARCHAR(64) NOT NULL COMMENT '实例编码,如 AST-REV01-001', + display_name VARCHAR(64) NULL COMMENT '展示名,如 1 号楼机房 Asterisk', + ami_host VARCHAR(128) NOT NULL COMMENT 'AMI 地址;生产固定 127.0.0.1(AMI 不出本机)', + ami_port INT NOT NULL DEFAULT 5038 COMMENT 'AMI 端口;现网配置值 7058', + ami_user VARCHAR(64) NOT NULL COMMENT 'AMI 账号', + ami_secret_ref VARCHAR(128) NOT NULL COMMENT '口令引用(环境变量名),禁止存明文', + tls_enabled TINYINT NOT NULL DEFAULT 0 COMMENT '0 明文 / 1 TLS(TLS 端口另配)', + conference VARCHAR(64) NOT NULL DEFAULT 'Conf-01' COMMENT '会议桥标识(D11 未定前不改)', + dial_context VARCHAR(64) NOT NULL DEFAULT 'from-internal' COMMENT '拨号计划上下文', + dial_prefix VARCHAR(16) NULL COMMENT '外呼前缀(如 9),本地分机为空', + channel_tech VARCHAR(16) NOT NULL DEFAULT 'PJSIP' COMMENT '话机通道技术:PJSIP / SIP', + recording_base_url VARCHAR(256) NULL COMMENT '录音 HTTP 基址(只对内网)', + connect_timeout_ms INT NOT NULL DEFAULT 3000 COMMENT 'TCP 建连超时', + action_timeout_ms INT NOT NULL DEFAULT 5000 COMMENT 'AMI Action 等待 Response 超时', + ping_interval_sec INT NOT NULL DEFAULT 30 COMMENT '心跳间隔(Action: Ping)', + ping_timeout_sec INT NOT NULL DEFAULT 5 COMMENT '单次心跳超时', + reconnect_initial_ms INT NOT NULL DEFAULT 1000 COMMENT '重连初始退避', + reconnect_max_ms INT NOT NULL DEFAULT 60000 COMMENT '重连最大退避(封顶)', + state VARCHAR(20) NOT NULL DEFAULT 'DOWN' COMMENT 'DOWN/CONNECTING/UP/DEGRADED/DISABLED', + last_connected_at DATETIME(3) NULL COMMENT '最近登录成功时刻', + last_ping_at DATETIME(3) NULL COMMENT '最近心跳成功时刻', + last_error VARCHAR(256) NULL COMMENT '最近一次失败原因(脱敏,不含口令)', + enabled TINYINT NOT NULL DEFAULT 1 COMMENT '0 停用(不建连、不叫乘)', + biz_version BIGINT NOT NULL DEFAULT 1 COMMENT '数据库条件更新乐观锁', + deleted TINYINT NOT NULL DEFAULT 0, + create_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + update_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_astinstance_gyid (gyid), + KEY idx_astinstance_gyid_state (gyid, state, enabled), + CONSTRAINT ck_astinstance_state CHECK (state IN ('DOWN','CONNECTING','UP','DEGRADED','DISABLED')), + CONSTRAINT ck_astinstance_enabled CHECK (enabled IN (0,1)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + COMMENT='公寓-Asterisk 实例映射与 AMI 连接配置(一公寓一实例)'; + +-- 默认实例:只写占位地址与口令引用,口令由部署环境 APT_AMI_SECRET 注入 +INSERT INTO apt_ast_instance + (gyid, instance_code, display_name, ami_host, ami_port, ami_user, ami_secret_ref, + conference, dial_context, state, enabled, create_time, update_time) +SELECT 'APT-REV01-001', 'AST-REV01-001', '本机 Asterisk(AMI 仅本机)', + '127.0.0.1', 5038, 'apt', 'APT_AMI_SECRET', + 'Conf-01', 'from-internal', 'DOWN', 1, NOW(3), NOW(3) +WHERE NOT EXISTS ( + SELECT 1 FROM apt_ast_instance WHERE gyid = 'APT-REV01-001' AND deleted = 0 +); + +-- ----------------------------------------------------------------------------- +-- 2. AMI 会话补列:心跳与断连原因(表已有 connected_at/disconnected_at/reconnect_count/state) +-- ----------------------------------------------------------------------------- +CALL apt_add_column_if_missing('apt_ast_ami_session', 'last_ping_at', + "last_ping_at DATETIME(3) NULL COMMENT '最近心跳成功时刻'"); +CALL apt_add_column_if_missing('apt_ast_ami_session', 'last_error', + "last_error VARCHAR(256) NULL COMMENT '本次连接断开的失败原因(脱敏)'"); +CALL apt_add_column_if_missing('apt_ast_ami_session', 'close_reason', + "close_reason VARCHAR(64) NULL COMMENT 'CLIENT_CLOSE / IO_ERROR / PING_TIMEOUT / AUTH_FAILED / SHUTDOWN'"); + +-- ----------------------------------------------------------------------------- +-- 3. 通话会话补列:双腿(房间/值班台)与通道映射 +-- ----------------------------------------------------------------------------- +CALL apt_add_column_if_missing('apt_call_session', 'leg_role', + "leg_role VARCHAR(16) NOT NULL DEFAULT 'ROOM' COMMENT '腿角色:ROOM 房间 / DUTY 值班台 / TTS 播报'"); +CALL apt_add_column_if_missing('apt_call_session', 'channel', + "channel VARCHAR(128) NULL COMMENT 'AMI Channel(PJSIP/1001-00000001),事件反查用'"); +CALL apt_add_column_if_missing('apt_call_session', 'originate_action_id', + "originate_action_id VARCHAR(64) NULL COMMENT 'Originate ActionID,OriginateResponse 关联用'"); +CALL apt_add_column_if_missing('apt_call_session', 'ami_session_id', + "ami_session_id VARCHAR(64) NULL COMMENT '所属 AMI 连接会话(apt_ast_ami_session.ami_session_id)'"); + +-- ----------------------------------------------------------------------------- +-- 4. TTS 播报补列:与会话/序号绑定,避免并发时互相覆盖(docs/10 表 10-13 TTS 槽位风险) +-- ----------------------------------------------------------------------------- +CALL apt_add_column_if_missing('apt_tts_utterance', 'session_id', + "session_id VARCHAR(64) NULL COMMENT '关联会话;一次入会一条播报链'"); +CALL apt_add_column_if_missing('apt_tts_utterance', 'utterance_seq', + "utterance_seq BIGINT NOT NULL DEFAULT 1 COMMENT '同会话单调递增,禁止共用槽位'"); +CALL apt_add_column_if_missing('apt_tts_utterance', 'played_at', + "played_at DATETIME(3) NULL COMMENT '播放完成时刻'"); +CALL apt_add_column_if_missing('apt_tts_utterance', 'error_msg', + "error_msg VARCHAR(256) NULL COMMENT '失败原因(不改叫乘结论,只告警)'"); + +-- 会话按通道反查(事件热点路径) +SET @apt_idx_channel = (SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'apt_call_session' + AND INDEX_NAME = 'idx_callsession_gyid_channel'); +SET @apt_sql_channel = IF(@apt_idx_channel = 0, + 'ALTER TABLE apt_call_session ADD KEY idx_callsession_gyid_channel (gyid, channel)', + 'SELECT 1'); +PREPARE apt_stmt_channel FROM @apt_sql_channel; +EXECUTE apt_stmt_channel; +DEALLOCATE PREPARE apt_stmt_channel; + +SET @apt_idx_action = (SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'apt_call_session' + AND INDEX_NAME = 'idx_callsession_gyid_action'); +SET @apt_sql_action = IF(@apt_idx_action = 0, + 'ALTER TABLE apt_call_session ADD KEY idx_callsession_gyid_action (gyid, originate_action_id)', + 'SELECT 1'); +PREPARE apt_stmt_action FROM @apt_sql_action; +EXECUTE apt_stmt_action; +DEALLOCATE PREPARE apt_stmt_action; + +-- ----------------------------------------------------------------------------- +-- 5. 校验 +-- ----------------------------------------------------------------------------- +SELECT '=== 27. 公寓-实例映射(应 1 行,一公寓一实例)===' AS section; +SELECT gyid, instance_code, ami_host, ami_port, ami_user, ami_secret_ref, + conference, dial_context, state, enabled +FROM apt_ast_instance WHERE deleted = 0 ORDER BY gyid; + +SELECT '=== 28. 口令不落明文(应 0:secret_ref 不得是疑似明文口令)===' AS section; +SELECT COUNT(*) AS plaintext_secret_rows +FROM apt_ast_instance +WHERE deleted = 0 + AND (ami_secret_ref LIKE '% %' OR ami_secret_ref LIKE '%.%' OR CHAR_LENGTH(ami_secret_ref) > 64); + +SELECT '=== 29. AMI 会话补列(应 3)===' AS section; +SELECT COUNT(*) AS ami_session_columns +FROM information_schema.COLUMNS +WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'apt_ast_ami_session' + AND COLUMN_NAME IN ('last_ping_at','last_error','close_reason'); + +SELECT '=== 30. 会话/TTS 补列(应 8:call_session 4 + tts 4)===' AS section; +SELECT COUNT(*) AS call_tts_columns +FROM information_schema.COLUMNS +WHERE TABLE_SCHEMA = DATABASE() + AND ((TABLE_NAME = 'apt_call_session' + AND COLUMN_NAME IN ('leg_role','channel','originate_action_id','ami_session_id')) + OR (TABLE_NAME = 'apt_tts_utterance' + AND COLUMN_NAME IN ('session_id','utterance_seq','played_at','error_msg'))); + +SELECT '=== 31. 会话索引(应 2)===' AS section; +SELECT INDEX_NAME, GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX) AS cols +FROM information_schema.STATISTICS +WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'apt_call_session' + AND INDEX_NAME IN ('idx_callsession_gyid_channel','idx_callsession_gyid_action') +GROUP BY INDEX_NAME; diff --git a/dev/sql/apt_rev01/99_verify.sql b/dev/sql/apt_rev01/99_verify.sql index 9ca3127..960a6ab 100644 --- a/dev/sql/apt_rev01/99_verify.sql +++ b/dev/sql/apt_rev01/99_verify.sql @@ -3,6 +3,7 @@ -- 预期:business_table_count = 59;missing_pk = 0;missing_gyid = 0 -- 口径:apt_* 36 + gy_*/gygl_* 18 + duty_* 5 = 59(CR-02 表 3-25、公寓端/03 表 3-8) -- apt_ops_probe 为连通性探针表,显式排除,不计入 59 张。 +-- 追加:第 7~13 段为 IT-3(DV-3)增量校验,见文件末段说明。 -- ============================================================================= SELECT '=== 1. 业务表总数(应为 59)===' AS section; SELECT COUNT(*) AS business_table_count @@ -62,3 +63,151 @@ SELECT '=== 6. 探针表与基线种子 ===' AS section; SELECT (SELECT COUNT(*) FROM apt_ops_probe) AS probe_rows, (SELECT COUNT(*) FROM apt_master_version WHERE source = 'LOCAL') AS local_baseline_rows, (SELECT COUNT(*) FROM apt_ops_selfcheck) AS selfcheck_item_rows; + +-- ============================================================================= +-- IT-3 追加校验(DV-3 / 13 表 13-13 验收①) +-- 预期:IT-3 增量列 6 条;人脸权限点 7 行(6 F + 1 C);IT-7 置灰项授权 0; +-- N-34 可申请设备 2 台;注册链三处「泄漏」计数均为 0。 +-- ============================================================================= +SELECT '=== 7. IT-3 增量列(应 6:回执态 3 + §12 设备范围 3)===' AS section; +SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_DEFAULT +FROM information_schema.COLUMNS +WHERE TABLE_SCHEMA = DATABASE() + AND ( (TABLE_NAME = 'apt_person_face_reg' AND COLUMN_NAME IN ('platform_version','unit_code')) + OR (TABLE_NAME = 'apt_master_person' AND COLUMN_NAME = 'face_platform_version') + OR (TABLE_NAME = 'gy_sb' AND COLUMN_NAME IN ('device_class','face_scope_value','face_cache_enabled'))) +ORDER BY TABLE_NAME, COLUMN_NAME; + +SELECT '=== 8. 人脸权限点与页面菜单(应 7 行:6 F + 1 C)===' AS section; +SELECT menu_id, menu_name, menu_type, visible, status, perms +FROM sys_menu WHERE menu_id BETWEEN 3201 AND 3216 ORDER BY menu_id; + +SELECT '=== 9. IT-7 预留点置灰且未授权(3213~3216 应为 0)===' AS section; +SELECT COUNT(*) AS it3_grey_bound +FROM sys_role_menu rm JOIN sys_menu m ON m.menu_id = rm.menu_id +WHERE m.menu_id IN (3213, 3214, 3215, 3216); + +SELECT '=== 10. N-34 可申请设备(state <> DISABLED,应 2)===' AS section; +SELECT COUNT(*) AS it3_apply_device_count +FROM gy_sb +WHERE device_code IN ('DEV-CHECKIN-01', 'DEV-TABLET-01', 'DEV-CHECKIN-99') + AND state <> 'DISABLED'; + +SELECT '=== 11. 注册链一致性:APPLIED 不得带 ryid、CONFIRMED 必带 ryid(均应 0)===' AS section; +SELECT + (SELECT COUNT(*) FROM apt_person_face_reg + WHERE reg_state = 'APPLIED' AND (confirmed_ryid IS NOT NULL OR local_only <> 1)) AS it3_applied_leak, + (SELECT COUNT(*) FROM apt_person_face_reg + WHERE reg_state = 'CONFIRMED' AND confirmed_ryid IS NULL) AS it3_confirmed_without_ryid, + (SELECT COUNT(*) FROM apt_person_face_reg + WHERE reg_state = 'REJECTED' AND confirmed_ryid IS NOT NULL) AS it3_rejected_with_ryid; + +SELECT '=== 12. 双路状态链独立:CONFIRMED 行不得缺 upload_state / dispatch_state(应 0)===' AS section; +SELECT reg_id, upload_state, dispatch_state, verdict, platform_version +FROM apt_person_face_reg +WHERE reg_state = 'CONFIRMED' AND (upload_state IS NULL OR dispatch_state IS NULL); + +SELECT '=== 13. 确认行必须有对应人员主档(应 0 孤儿)===' AS section; +SELECT COUNT(*) AS it3_confirmed_orphan +FROM apt_person_face_reg r +WHERE r.reg_state = 'CONFIRMED' + AND NOT EXISTS ( + SELECT 1 FROM apt_master_person p + WHERE p.gyid = r.gyid AND p.ryid = r.confirmed_ryid AND p.deleted = 0 + ); + +-- ============================================================================= +-- IT-4 追加校验(入住作业核心 / 表 11-4、表 11-9、CR-02 表 3-57 验收 18~20) +-- 预期:增量列 26 条;流程态字典 8/5 值;入住权限点 5 行;三处链路泄漏均为 0。 +-- ============================================================================= +SELECT '=== 14. IT-4 apt_local_task 增量列(应 27)===' AS section; +SELECT COUNT(*) AS it4_local_task_columns +FROM information_schema.COLUMNS +WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'apt_local_task' + AND COLUMN_NAME IN ('device_code','device_event_id','work_no','name_snapshot','reg_id', + 'reg_state_at_apply','jch','kcsj_snapshot','verified_ryid','verified_at','feature_version', + 'source','submitted_at','occurred_at','review_note','adjusted_by','adjusted_at','adjust_reason', + 'reported_at','matched_at','task_ready_at','matched_plan_key','base_kcsj','base_jcfs', + 'origin_jcsj','derived_rule_version','jllx'); + +SELECT '=== 15. 流程态字典(checkin_state 8 值 / sync_state 5 值 / 事件含 ROOM_SWAP)===' AS section; +SELECT CONSTRAINT_NAME, CHECK_CLAUSE +FROM information_schema.CHECK_CONSTRAINTS +WHERE CONSTRAINT_SCHEMA = DATABASE() + AND CONSTRAINT_NAME IN ('ck_lt_checkin','ck_lt_sync','ck_lye_type') +ORDER BY CONSTRAINT_NAME; + +SELECT '=== 16. 入住权限点与页面菜单(应 5 行)===' AS section; +SELECT menu_id, menu_name, menu_type, visible, status, perms +FROM sys_menu WHERE menu_id IN (3005, 3202, 3217, 3218, 3219) ORDER BY menu_id; + +SELECT '=== 17. 任务就绪必须已复算 jcsj(应 0)===' AS section; +SELECT COUNT(*) AS it4_task_ready_without_jcsj +FROM apt_local_task +WHERE sync_state = 'TASK_READY' AND jcsj IS NULL AND deleted = 0; + +SELECT '=== 18. CHECKED_IN 必须有 gy_jcxx 行(应 0 孤儿)===' AS section; +SELECT COUNT(*) AS it4_checked_in_orphan +FROM apt_local_task t +WHERE t.checkin_state = 'CHECKED_IN' AND t.deleted = 0 + AND NOT EXISTS ( + SELECT 1 FROM gy_jcxx j WHERE j.gyid = t.gyid AND j.jcid = t.jcid AND j.deleted = 0 + ); + +SELECT '=== 19. 未入住不得提前生成叫乘行(应 0 泄漏)===' AS section; +SELECT COUNT(*) AS it4_checkin_leak +FROM apt_local_task t +WHERE t.checkin_state IN ('SUBMITTED','REVIEWING','APPROVED','ROOM_ADJUSTED','REJECTED') + AND t.deleted = 0 + AND EXISTS (SELECT 1 FROM gy_jcxx j WHERE j.gyid = t.gyid AND j.jcid = t.jcid AND j.deleted = 0); + +-- ============================================================================= +-- IT-5 追加校验(叫乘执行与状态机 / 表 3-10、表 4-9、表 5-20、表 7-3 M3) +-- 预期:增量列 11 条;叫乘权限点 5 行;平板字典 3 个 CHECK; +-- 状态一致性四类泄漏全为 0;平板链路无 #24 语音摘要。 +-- ============================================================================= +SELECT '=== 20. IT-5 gy_jcxx 增量列(应 12)===' AS section; +SELECT COUNT(*) AS it5_jcxx_columns +FROM information_schema.COLUMNS +WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'gy_jcxx' + AND COLUMN_NAME IN ('jcsj_s','hjxysj','jcbjsj_s','fjsj_s','lybjsj','is_jcbj', + 'is_lybj','is_temp_call','leave_type','leave_event_id','leave_remark','jch'); + +SELECT '=== 21. 叫乘权限点与页面菜单(应 5 行)===' AS section; +SELECT menu_id, menu_name, menu_type, visible, status, perms +FROM sys_menu WHERE menu_id IN (3004, 3220, 3221, 3222, 3203) ORDER BY menu_id; + +SELECT '=== 22. 平板字典 CHECK(应 3:face / result_type / applied_state)===' AS section; +SELECT CONSTRAINT_NAME, CHECK_CLAUSE +FROM information_schema.CHECK_CONSTRAINTS +WHERE CONSTRAINT_SCHEMA = DATABASE() + AND CONSTRAINT_NAME IN ('ck_tabletresult_face','ck_tabletresult_type','ck_tabletresult_applied') +ORDER BY CONSTRAINT_NAME; + +SELECT '=== 23. 终态一致性:ryzt=3 必须 finish_jc=1(应 0)===' AS section; +SELECT COUNT(*) AS it5_answered_without_finish +FROM gy_jcxx +WHERE deleted = 0 AND ryzt = '3' AND finish_jc <> '1'; + +SELECT '=== 24. 已结束叫乘不得回退到未完成态(应 0)===' AS section; +SELECT COUNT(*) AS it5_finished_regressed +FROM gy_jcxx +WHERE deleted = 0 AND finish_jc = '1' AND ryzt IN ('0','1'); + +SELECT '=== 25. 平板任务与结果一致:DONE 任务必须有结果行(应 0)===' AS section; +SELECT COUNT(*) AS it5_task_done_without_result +FROM apt_tablet_task t +WHERE t.pull_state = 'DONE' + AND NOT EXISTS ( + SELECT 1 FROM apt_tablet_call_result r + WHERE r.gyid = t.gyid AND r.task_id = t.task_id + ); + +SELECT '=== 26. 平板链路不得上报 #24 语音摘要(应 0)===' AS section; +SELECT COUNT(*) AS it5_tablet_voice_summary +FROM apt_report_outbox o +WHERE o.biz_type = 'VOICE_SUMMARY' + AND EXISTS ( + SELECT 1 FROM gy_jcxx j + WHERE j.gyid = o.gyid AND j.jcid = o.biz_id AND j.call_mode = 'TABLET' + ); diff --git a/docs/README.md b/docs/README.md index 0643979..c270e19 100644 --- a/docs/README.md +++ b/docs/README.md @@ -100,6 +100,40 @@ | 权威关系 | 阶段划分与验收仍以 `公寓端/07` 为准,业务规则 / 状态 / 接口仍以 CR-02 §11~§15 为准;`15` 只记实现、验证、口径偏离与影响范围 | | **变更点与影响范围** | 新增 1 篇交付说明与 1 个 M0 验收脚本;同步 `公寓端/README.md`、`公寓端/14` 表 14-23、`docs/README.md` 与 CR-02 表 0-2 **v1.8**;不改动 CR-02 §12~§15 权威表述、不新增既有编号,跨端 20 编号、`v2-draft`、平台零回调、59 张表不变;**契约冻结签字与 `D5` 签字仍为 P0 待办** | +### 第三轮开发(IT-2)交付(2026-11-11) + +| 项 | 内容 | +| --- | --- | +| 文档 | [公寓端/16-第三轮开发(IT-2)交付说明.md](./公寓端/16-第三轮开发(IT-2)交付说明.md)(v1.0,**实现说明,非设计变更**) | +| 范围 | M1 交路 CRUD / 启停原因审计 / 复算校验、M2 房间档案 / 房态 / `state_version` / `commandId` 幂等、主数据下行 Mock 灌数与版本标记(`/apt/master/versions`)、审计独立事务与参数校验异常分派;不含人员关联 Tab 与叫乘;前端页面已补齐,见 `公寓端/16` 第 10 章与下文「公寓端前端页面」段 | +| 验证 | `mvn package` 7 模块 `BUILD SUCCESS`;79 个单测全绿;`dev/sql/apt_rev01/10~12` 实库执行与校验通过;`dev/scripts/verify-it2-m1m2.ps1` 真实进程端到端 **24/24 通过**(第 9 章模块归置后新增 M0-5 配置导入校验) | +| 权威关系 | 阶段划分与验收仍以 `公寓端/07` 为准,业务规则 / 状态 / 接口仍以 CR-02 §11~§15 为准;`16` 只记实现、验证、口径偏离与影响范围 | +| **变更点与影响范围** | 新增 1 篇交付说明与 2 个脚本(SQL 重放、端到端验收);同步 `公寓端/README.md`、`公寓端/14` 表 14-23、`公寓端/15` 表 15-17、`docs/README.md` 与 CR-02 表 0-2 **v1.9**;不改动 CR-02 §12~§15 权威表述、不新增既有编号,跨端 20 编号、`v2-draft`、平台零回调、59 张表不变;**契约冻结 / `D5` / `D8'` / `D3` / `D7` 签字仍为 P0 待办** | + +### 公寓端集中模块 `ruoyi-apt`(2026-11-11) + +| 项 | 内容 | +| --- | --- | +| 模块 | `dev/RuoYi-Vue-master/ruoyi-apt`(Maven jar,包 `com.ruoyi.apt.**`);64 个公寓端文件从 4 个旧模块统一迁入 | +| 职责 | 四前缀接口、业务逻辑、上下文与错误码、拦截器与鉴权、审计、`apt.*` 配置、Mapper XML、79 个测试、前端静态资源挂载点 | +| 结构定位 | 上层业务模块:依赖 `ruoyi-common` / `ruoyi-framework`(传递 `ruoyi-system`),被唯一启动模块 `ruoyi-admin` 依赖;`ruoyi-framework` 仅按 Spring Security 接口 + bean 名运行时装配 401/403 处理器,编译期零反向依赖 | +| 前端 / 运维边界 | Vue 源码已落在 `dev/RuoYi-Vue2-master/src/views/apt/**`(§2.5 前端落地口径:单入口 + `sys_menu` 动态路由),构建产物可挂载到模块内 `static/apt/`;DDL 与验收脚本保持 `dev/sql/apt_rev01`、`dev/scripts` 原位,不进 Maven jar | +| 验证 | `mvn -pl ruoyi-admin -am clean package` 7 模块 `BUILD SUCCESS`;79 个单测全绿;`verify-it2-m1m2.ps1` 端到端 **24/24 通过**(含 `apt-defaults.yml` 导入与 springdoc 分组校验) | +| 权威落点 | [公寓端/02](./公寓端/02-技术基线与工程结构.md) §2.5(表 2-14 / 2-15、图 2-3)、[公寓端/16](./公寓端/16-第三轮开发(IT-2)交付说明.md) 第 9 章(归置清单、图 16-2、验证) | +| **非设计变更** | 只调整实现层模块归属与构建配置;不改动 CR-02 §12~§15 权威表述、不新增既有编号,跨端 20 编号、`v2-draft`、平台零回调、59 张表不变 | + +### 公寓端前端页面补齐(2026-11-11) + +| 项 | 内容 | +| --- | --- | +| 范围 | 只补齐 `IT-0`~`IT-2` 已有后端 Controller 的 4 个功能域:交路、房间、主数据版本、连通性自检;`IT-3`~`IT-11` 仅设计页面不建(判据见 `公寓端/16` 表 16-25) | +| 页面 | `dev/RuoYi-Vue2-master/src/views/apt/`:4 个菜单列表页 + 2 个隐藏详情页;API 在 `src/api/apt/**`,公共常量 / 错误提示 / 导出在 `src/utils/apt.js` | +| 规范 | 与 `system/post`、`system/user` 同构:`.app-container` + 查询 `el-form` + `el-row.mb8` 按钮区 + `el-table` + `pagination` + `el-dialog`;复用 `Pagination` / `RightToolbar` / `DictTag`、Element 语义色与主题变量、`v-hasPermi`、`$modal`、`rules` 校验、`v-loading`、响应式栅格与小屏全屏弹窗 | +| 路由与菜单 | `dev/sql/apt_rev01/13_it2_frontend_menus.sql` 新增 4 条 `C` 菜单(3101~3104),由 `getRouters` 下发、已实库执行;详情页走 `dynamicRoutes` 并按权限过滤 | +| 未开放能力 | 房间新增 / 删除 / 导入 / 主数据重同步以禁用态 + tooltip 如实呈现;导出为前端 CSV(后端无 `/export`,不伪造接口) | +| 验证 | `npm run build:prod` `exitCode=0`;菜单树 `M=1 / C=4 / F=15`、超管关联 4、置灰项 0、M0 账号 0;lints 0 | +| 权威落点 | [公寓端/16](./公寓端/16-第三轮开发(IT-2)交付说明.md) 第 10 章(表 16-25~16-30、图 16-3)、[公寓端/02](./公寓端/02-技术基线与工程结构.md) §2.5 前端落地口径、CR-02 表 0-2 **v2.1** | + ## 建议阅读路径 - **第一次接触项目**:01 → 02 → 03 → 04;再看前端 01 → 02 → 04。 diff --git a/docs/公寓端/02-技术基线与工程结构.md b/docs/公寓端/02-技术基线与工程结构.md index 9ab420e..cc40780 100644 --- a/docs/公寓端/02-技术基线与工程结构.md +++ b/docs/公寓端/02-技术基线与工程结构.md @@ -136,6 +136,85 @@ flowchart TD **代价**:`sys_menu` 里登记的 apt 菜单 `component` 不参与路由渲染,只作为权限点载体,需在菜单备注中写明。 该细化列入 `08` §4 待确认(差-7)。 +### 2.5 实现层公寓端集中模块 `ruoyi-apt`(2026-11-11 归置) + +> §2.1 的 12 模块是**设计期的逻辑划分**;实际工程复用 RuoYi-Vue 上游后,`IT-0`~`IT-2` 的公寓端代码曾分散在 +> `ruoyi-common` / `ruoyi-framework` / `ruoyi-system` / `ruoyi-admin` 四个 Maven 模块中。为集中管理, +> 现新增单一 Maven 模块 **`ruoyi-apt`**,把全部公寓端实现收拢到 `com.ruoyi.apt.**` 包下。 +> 本节为实现层说明,**不改变** §2.1 的设计口径与 `docs/10` 表 4-3。 + +表 2-14 `ruoyi-apt` 职责范围 + +| 类别 | 归置内容 | 落点 | 边界 | +| --- | --- | --- | --- | +| 接口 | `/apt/route/**`、`/apt/room/**`、`/apt/master/**`、`/apt/ops/**` 全部 Controller | `src/main/java/com/ruoyi/apt/{master,stay,ops}/controller` | 不含 `/system/**`、`/duty/**`、`/open/v1/**`(后者仅预留) | +| 业务逻辑 | 交路 CRUD / 启停 / 复算,房间档案 / 房态重算 / `state_version`,主数据版本只读 | `.../apt/{master,stay}/service` | 不改 59 张表口径;`room_status` 只由领域服务写 | +| 上下文与错误码 | `AptContext`、`AptErrorCode`、`AptException`、`AptPageSupport`、`AptTableDataInfo` | `.../apt/common` | `gyid` 只由服务端注入 | +| 框架适配 | 四前缀拦截器、`WebMvcConfig`、401/403 处理器、异常分派、审计切面、`AptProperties`、Mock 启动护栏 | `.../apt/framework`、`.../apt/infra` | 只承接公寓端;非公寓端路径委托 RuoYi 原行为 | +| 配置项 | `apt.*`(`gy-id` / `probe` / `page` / `master-client`)、springdoc 三个分组、`com.ruoyi.apt` 日志级别 | `src/main/resources/apt-defaults.yml` | 由 `ruoyi-admin/application.yml` 用 `spring.config.import: optional:classpath:apt-defaults.yml` 引入 | +| 持久层 | 4 个 Mapper 接口 + 4 个 `mapper/apt/*.xml` | `.../apt/*/mapper`、`src/main/resources/mapper/apt` | 每条 SQL 带 `gyid`;`classpath*:mapper/**/*Mapper.xml` 通配加载 | +| 前端资源 | 静态资源挂载点 `src/main/resources/static/apt/` | 模块内 | **Vue 源码不进 Maven jar**:前端源码已落在 `dev/RuoYi-Vue2-master/src/views/apt/**`(API 在 `src/api/apt/**`,路由在 `src/router/index.js`),由 `sys_menu` 的 4 条 `C` 菜单经 `getRouters` 动态下发;构建产物可输出到该挂载目录 | +| 测试 | 79 个 JUnit5/Mockito 用例(含架构约束与 SQL 静态校验) | `src/test/java/com/ruoyi/apt` | 测试随模块走,不再落在 `ruoyi-admin` | +| 不纳入 | DDL / 种子 / 权限点 SQL、PowerShell 验收脚本、`.env` 凭据 | 仍在 `dev/sql/apt_rev01`、`dev/scripts`、`dev/.env` | 工程级运维资产,由模块文档登记与驱动,但不参与 Maven 打包 | + +表 2-15 `ruoyi-apt` 与既有模块的依赖 / 被依赖关系 + +| 关系 | 对象 | 内容与依据 | +| --- | --- | --- | +| **依赖** | `ruoyi-common` | `AjaxResult` / `TableDataInfo` / `SecurityUtils`、Spring Security、Validation、PageHelper、fastjson2、Redis、MyBatis 传递 | +| **依赖** | `ruoyi-framework` | Spring WebMVC、AspectJ、Druid、`SecurityConfig`、`AuthenticationEntryPointImpl`、`BaseController`、`@Anonymous` | +| **依赖(传递)** | `ruoyi-system` | 经 `ruoyi-framework` 传递;模块内不直接引用 `com.ruoyi.system.**` 业务类 | +| **依赖** | 第三方 | `mybatis-spring-boot-starter`、`spring-tx`、`spring-boot-starter-test`(test) | +| **被依赖** | `ruoyi-admin` | 启动模块显式声明 `ruoyi-apt`,使其进入 classpath 与 `ruoyi-admin.jar` fat jar | +| **被引用(运行时)** | `ruoyi-framework` | `SecurityConfig` 按 Spring Security 标准接口 + bean 名 `aptAuthenticationEntryPoint` / `aptAccessDeniedHandler` 装配,**编译期不 import 任何 `com.ruoyi.apt.*` 类**,避免循环依赖 | +| **无依赖** | `ruoyi-quartz`、`ruoyi-generator` | 两者与公寓端完全解耦 | + +```mermaid +flowchart TD + ADMIN["ruoyi-admin
唯一启动模块 / fat jar"] --> APT["ruoyi-apt
公寓端集中模块"] + APT --> FRAME["ruoyi-framework
安全链 / AOP / MVC"] + APT --> COMMON["ruoyi-common
基础工具 / Security / Validation"] + FRAME --> SYS["ruoyi-system"] + SYS --> COMMON + ADMIN --> QUARTZ["ruoyi-quartz"] + ADMIN --> GEN["ruoyi-generator"] + QUARTZ --> COMMON + GEN --> COMMON + FRAME -.->|"运行时按 bean 名装配 401/403 处理器
编译期零依赖"| APT + APT --> RES["apt-defaults.yml
mapper/apt/*.xml
static/apt/**"] + FE["RuoYi-Vue2-master
src/views/apt(前端源码)"] -.->|"构建产物挂载"| RES + FE -.->|"getRouters 下发路由
/apt/** 请求"| APT + OPS["dev/sql/apt_rev01
dev/scripts"] -.->|"运维驱动,不进 Maven jar"| APT +``` + +图 2-3 `ruoyi-apt` 在整体工程中的定位与模块关系(箭头为编译期依赖,虚线为运行时/工程级关联) + +构建识别与引用调整(保证模块可被正确加载): + +1. 根 `pom.xml`:`` 增加 `ruoyi-apt`;`` 按 `${ruoyi.version}` 登记其版本。 +2. `ruoyi-admin/pom.xml`:显式依赖 `ruoyi-apt`;Maven 反应堆顺序为 `common → system → framework → apt → admin`(单向无环)。 +3. 包扫描:启动类 `@SpringBootApplication` 位于 `com.ruoyi`,自动覆盖 `com.ruoyi.apt.**`;`ApplicationConfig` 的 + `@MapperScan("com.ruoyi.**.mapper")` 与 `mybatis.mapperLocations: classpath*:mapper/**/*Mapper.xml` 均为通配, + 无需改包名;`typeAliasesPackage: com.ruoyi.**.domain` 同理。 +4. 配置:`apt-defaults.yml` 由主配置 `spring.config.import` 引入;`optional:` 前缀保证模块缺失时后台仍可启动。 + springdoc `packages-to-scan` 用包名 `com.ruoyi.apt`(不使用 Ant 通配),否则分组 `paths` 为空。 +5. 迁移后必须 `mvn clean package`:`classpath*:mapper/**/*Mapper.xml` 会同时命中旧模块 `target/classes` 残留, + 导致 MyBatis 重复注册 MappedStatement;仅 `package` 不清 target 会启动失败。 + +**前端落地口径(2026-11-11 补齐,权威记录见 `16` 第 10 章)**:§2.1 与 `docs/10` 表 2-3(差-7)描述的是 +「单工程双入口 + `entries/apt` 静态路由表」的**设计期**形态;而本仓库实际上游 `RuoYi-Vue2-master` 是标准 +**单入口**工程(无 `src/entries/`、`vue.config.js` 无 `pages` 配置,`src/main.js` 为唯一挂载点)。本期前端页面 +因此落在 `src/views/apt/**`,与 `system/user` 等既有页面同构: + +- 菜单:`dev/sql/apt_rev01/13_it2_frontend_menus.sql` 新增 4 条 `menu_type='C'` 记录(3101~3104,`parent_id=3000`), + 由后端 `getRouters` 下发,`permission.js` 的 `loadView('@/views/' + component)` 懒加载;业务页不写进 `constantRoutes`。 +- 详情:`src/router/index.js` 的 `dynamicRoutes` 增加 2 条 `hidden` 路由,按 `permissions` 过滤,`activeMenu` 回指列表页。 +- 权限:按钮级 `v-hasPermi` 复用 `F` 权限点 3001~3015;菜单可见性由 `sys_role_menu` 控制。 +- 范围:只补齐已有后端 Controller 的交路 / 房间 / 版本 / 连通性自检 4 个功能域;`IT-3`~`IT-11` 的设计页面不建。 + +该选择是实现层对设计期口径的偏离(`16` 偏离项 16-19),**不改** §2.1 与 `docs/10` 的权威表述; +若后续真要落地双入口,需先完成前端工程改造并另行评审。 + ## 3. 分层与编码规范 ### 3.1 请求链路 diff --git a/docs/公寓端/03-数据设计.md b/docs/公寓端/03-数据设计.md index a421b7e..4c28d80 100644 --- a/docs/公寓端/03-数据设计.md +++ b/docs/公寓端/03-数据设计.md @@ -195,17 +195,24 @@ | 1 叫乘中 | 30s 无入会(本地超时) | 2 叫乘报警 | `jcbjsj_s`、`is_jcbj='1'`、写 `gy_bjxx` | `docs/10` §7.9 超时口径 | | 2 叫乘报警 | 复叫入会 | 3 已应答待离寓 | `fjsj_s`、`hjxysj`、`finish_jc='1'` | 表 7-5 复叫 | | 2 叫乘报警 | 人工"停止叫乘" | 2(保持)+ `stoped='1'` | `stoped` | `docs/10` 表 2-3 `duty:call:stop` | +| 2 叫乘报警(或 `stoped='1'` 的任意态) | 人工"复叫"命令(`RE_CALL`) | 1 叫乘中 | 清除 `stoped`、`ryzt=1`、`jcsj_s`、新增一行 `apt_call_session` | 表 4-3 `apt.duty.state`、`04` 命令矩阵 | +| 0 待叫乘 | 人工"取消叫乘"命令(`CANCEL`) | 0(保持)+ `stoped='1'` | `stoped` | 同上 | +| 0/1 | 平板 `TABLET_RESULT`:`ANSWERED` + `VERIFIED`(平板上报) | 3 已应答待离寓 | `hjxysj`、`finish_jc='1'` | `05` 表 5-20、CR-02 表 3-16 | +| 1 | 平板 `TABLET_RESULT`:`NO_ANSWER` / `FAILED`(含签到窗口超时,由平板上报) | 2 叫乘报警 | `jcbjsj_s`、`is_jcbj='1'`、写 `gy_bjxx(TIMEOUT)` | `05` 表 5-20 | +| 0/1/2 | 平板 `TABLET_RESULT`:`ANSWERED` + `UNVERIFIED` / `SKIPPED`(平板上报) | 保持当前 `ryzt`(复叫 + 告警) | 不改状态字段,只记 `duty_operation_log` / 告警 | `05` 表 5-20、Q-22 | +| 任意 | 平板 `TABLET_RESULT` 迟到 / 重复 | 保持(`IGNORED`) | 只记 `apt_tablet_call_result.applied_state='IGNORED'` | `05` 表 5-20 | | 3 已应答 | 会议桥通话开始 | 5 通话中 | — | 表 5-3 `apt_call_session.state` | | 5 通话中 | 挂断 | 3 | — | 同上 | | 3 已应答 | 超时未离寓 | 4 离寓报警 | `lybjsj`、`is_lybj='1'` | 表 4-3 `apt.duty.engine` | | 任意 | 离寓事件(设备/人脸/人工) | 保持 `ryzt`,置离寓 | `finish_ly='1'`、`sjlysj`、`leave_type`、`leave_event_id` | `docs/05` 离寓语义 | | 任意 | 人工"强制结束" | 保持,置结束 | `finish_jc='1'`、`finish_ly='1'` | `duty_manual_action.action_type=FORCE_FINISH` | -三条保护语义(沿用存量,`docs/10` §5.4 处理关系第 3 条): +三条保护语义与一条平板链路约束(沿用存量,`docs/10` §5.4 处理关系第 3 条): 1. **已结束叫乘不回写**:`finish_jc='1'` 后到达的迟到事件只记 `apt_call_event`,不改 `ryzt`。 2. **已离寓不回写**:`finish_ly='1'` 后不因叫乘事件回到"待叫乘"。 3. **停止叫乘不可逆至报警**:`stoped='1'` 后只允许人工"重新叫乘"命令清除,不允许超时判定自动改。 +4. **平板模式无内部自动迁移**:`call_mode='TABLET'` 的 `ryzt` 迁移只由平板上报结果(N-04 `TABLET_RESULT`)触发,服务端不做定时自动迁移;签到窗口超时同样由平板上报 `NO_ANSWER`。 ### 3.3 分机资源状态机 diff --git a/docs/公寓端/05-关键流程.md b/docs/公寓端/05-关键流程.md index ba72f8b..acfefe4 100644 --- a/docs/公寓端/05-关键流程.md +++ b/docs/公寓端/05-关键流程.md @@ -573,9 +573,9 @@ sequenceDiagram | SIP 接通 | `ConfbridgeJoin` | `ANSWERED` | `ryzt=3`、`finish_jc='1'`、`hjxysj` | #9 `DUTY_RESULT` + #24 | `state.changed(1→3)` + `session.changed` | | SIP 无应答 | 30s 无入会 / `OriginateResponse.Failure` | `CALL_FAILED` | `ryzt=2`、`jcbjsj_s`、`is_jcbj='1'` | #9 + #12 | `alarm.raised(NO_ANSWER)` + 复叫 | | 平板刷脸签到 | `ANSWERED` + `VERIFIED` | `ANSWERED` | `ryzt=3`、`finish_jc='1'` | #9 `TABLET_RESULT` | `tablet.result` + `state.changed` | -| 平板未刷脸 | `ANSWERED` + `UNVERIFIED` / `SKIPPED` | 保持待确认 + 复叫 | `ryzt` 不变(超窗口转 `2`) | #9 + #12 | `tablet.result` + `alarm.raised` | +| 平板未刷脸 | `ANSWERED` + `UNVERIFIED` / `SKIPPED` | 保持待确认 + 复叫 | `ryzt` 不变(签到窗口超时由平板上报 `NO_ANSWER`,服务端不自动迁移) | #9 + #12 | `tablet.result` + `alarm.raised` | | 平板无应答 | `NO_ANSWER` / `FAILED` | `CALL_FAILED` | `ryzt=2` | #9 | `alarm.raised` + 复叫 | -| 任务取消 | `CANCELLED` | `STOP`(值班台) | `stoped='1'`,`ryzt` 不变 | #9 | `state.changed` | +| 任务取消 | `CANCELLED` | `STOP`(`ryzt in (1,2)` 时)/ `CANCEL`(`ryzt=0` 时) | `stoped='1'`,`ryzt` 不变 | #9 | `state.changed` | | 迟到 / 重复结果 | 任意 | `IGNORED` | 不回写 | 不重复上报 | 只记留痕(`applied_state=IGNORED`) | ### 9.6 与既有章节的关系与待决策 diff --git a/docs/公寓端/11-人员入住作业流程.md b/docs/公寓端/11-人员入住作业流程.md index f30f55e..f2d8cfb 100644 --- a/docs/公寓端/11-人员入住作业流程.md +++ b/docs/公寓端/11-人员入住作业流程.md @@ -206,7 +206,7 @@ flowchart LR | `matchState` | `PENDING` / `MATCHED` / `UNMATCHED` / `LOCKED` / `REJECTED` | 沿用 `docs/设计变更/01` 表 1-17 正式五值;`LOCAL_ONLY` 属 `offlineScope`,**不是**匹配状态 | | `origin` | `CHECKIN_ROUTE` / `CHECKIN_PENDING` / `LOCAL_ASSIGN` / `LOCAL_TEMP_EXTRA` / `LOCAL_ADJUST` | 交路可确定时取 `CHECKIN_ROUTE`,否则 `CHECKIN_PENDING` | | `reviewVerdict` | `APPROVE` / `APPROVE_WITH_ROOM_CHANGE` / `REJECT` | N-44 审核命令 | -| `faceVerifyState` | `VERIFIED` / `UNVERIFIED` / `NOT_FOUND` | 与 CR-02 表 3-16 同字典 | +| `faceVerifyState` | `VERIFIED` / `UNVERIFIED` / `SKIPPED` | 与 CR-02 表 3-16 同字典;只用于平板叫乘时人员刷脸进行**叫乘结果确认**,**不是**入住核验结论(核验未命中用 `personFound=false` 表达) | | `eventType` | `CHECKIN` / `CHECKOUT` / `ROOM_SWAP` | CR-02 表 3-14 | | `source` | `DEVICE` / `DUTY` / `SELF` | CR-02 表 3-14 | @@ -223,7 +223,7 @@ flowchart LR | 触发 | 人员在签到终端发起"扫脸办理入住";终端按设备凭据 + HMAC + ±5 min 时间窗 + `nonce` 调用 N-38 | | 数据范围 | 设备凭据解析出的 `gyid` / `deviceCode`、`deviceEventId`、现场比对结论(`faceVerifyState` / `verifiedRyid` / `featureVersion`)、`occurredAt`;**不上传抓拍帧、人脸原图、比对帧** | | 前置 | 设备已注册且人脸缓存为 `SYNCED`(`docs/公寓端/10` §6);同一 `(deviceCode, deviceEventId)` 未处理过 | -| 不触发 | 仅查询、心跳、缓存摘要上报;`faceVerifyState=NOT_FOUND` 且人员明确拒绝注册 | +| 不触发 | 仅查询、心跳、缓存摘要上报;比对未命中(`personFound=false`)且人员明确拒绝注册 | 表 11-11 身份核验步骤 @@ -264,7 +264,7 @@ flowchart LR | 场景 | 处理 | 不做什么 | | --- | --- | --- | -| 比对未命中 `NOT_FOUND` | 返回 `REGISTER_REQUIRED` 并引导 N-34 注册 | 不自动建人员、不建申请单 | +| 比对未命中(`personFound=false`) | 返回 `REGISTER_REQUIRED` 并引导 N-34 注册 | 不自动建人员、不建申请单 | | 注册申请未确认 `APPLIED` | `40914`(CR-C25),返回 `REG_PENDING` + `regId` | 不参与入住、不参与运安匹配 | | 设备人脸缓存过期 | `40927` 要求 `FACE_FULL_SYNC` 后重试 | 不用过期缓存放行 | | 抓拍上传 / 凭据越 `gyid` | `40919` / `40301` 拒收并记审计 | 不落库、不生成待办 | diff --git a/docs/公寓端/13-公寓端小步迭代开发计划.md b/docs/公寓端/13-公寓端小步迭代开发计划.md index 07f49a8..12e4459 100644 --- a/docs/公寓端/13-公寓端小步迭代开发计划.md +++ b/docs/公寓端/13-公寓端小步迭代开发计划.md @@ -185,6 +185,11 @@ flowchart LR | 交付物 `DV-2` | Controller/Service/Mapper/DTO/VO/页面/路由/权限点;Mock 灌数脚本(3 交路 / 5 房间 / 5 人员 / 2 天计划);表 7-6 的 M1/M2 用例记录 | | 验收标准 | 缺 `cc`/`kcsj`/`reason` → 40001 且指出字段;同 `jlid` 重复 → 40901;停用审计 `duty_operation_log` 含原因;房态筛选只返回 `room_status=4`;停用后恢复触发重算且 `state_version` 递增;快照字段改 `xm` → 40904;Mock 数据标记 `source=LOCAL` / `source_version=0` | +> **实现层注记(2026-11-11)**:本表「对齐代码模块」的 `src/entries/apt/views/{route,room}` 与 `ruoyi-system` 内业务包均为**设计期**口径。 +> 实际交付:后端统一归置到 `ruoyi-apt` 模块(`16` 第 9 章);前端因实际上游 `RuoYi-Vue2-master` 为标准单入口, +> 页面落在 `src/views/apt/**`、由 `sys_menu` 4 条 `C` 菜单动态下发路由(`16` 第 10 章、`02` §2.5)。 +> 本表设计口径不变,偏离登记见 `16` 偏离项 16-19。 + 表 13-13 `IT-3` 人脸注册与签到核验最小闭环(2 周) | 项 | 内容 | diff --git a/docs/公寓端/14-第一轮开发(IT-0)交付说明.md b/docs/公寓端/14-第一轮开发(IT-0)交付说明.md index e4058d3..347346d 100644 --- a/docs/公寓端/14-第一轮开发(IT-0)交付说明.md +++ b/docs/公寓端/14-第一轮开发(IT-0)交付说明.md @@ -48,6 +48,9 @@ | `sql/apt_rev01` | `dev/sql/apt_rev01/*.sql` | 59 张业务表结构、幂等补列、可重放种子、验收校验 | DDL 与回填分离;不建外键;不含真实凭据 | MySQL 9.5 | | `ci` | `.github/workflows/it0-verify.yml` | 编译 / 单测 / 明文凭据扫描卡口 | 不执行 DDL、不连生产库 | GitHub Actions | +> **归置说明(2026-11-11)**:本表为 IT-0 当时的代码落点记录。当前全部公寓端 Java / Mapper XML / 测试已统一迁入 +> `dev/RuoYi-Vue-master/ruoyi-apt`(`com.ruoyi.apt.**`),职责、定位与依赖见 `公寓端/02` §2.5 与 `公寓端/16` 第 9 章。 + 图 14-1 模块依赖(单向,无环) ```mermaid @@ -386,7 +389,8 @@ graph TD | 迭代 | 本轮提供的前置 | 下一轮要做 | | --- | --- | --- | | `IT-1` M0 运行时基座 | 错误码 / gyid 上下文 / 四前缀中 `/apt/**` 拦截器骨架 / 测试与 CI | **已完成**:四前缀拦截器与 `/open/v1/**` 放行、401/403 处理器、统一错误码与真实 HTTP 状态、审计切面、分页上限、权限点种子、只读列表骨架、41 个单测与端到端 M0 5/5;见 `公寓端/15` | -| `IT-2`~`IT-3` | 59 张表结构、幂等补列脚本 | 主数据 / 房间 / 人脸最小闭环业务代码(落 `ruoyi-system`) | +| `IT-2` M1 + M2 | 59 张表结构、幂等补列脚本 | **已完成**:交路 / 房间 / 主数据版本只读、Mock 灌数与版本标记、79 个单测与端到端 24/24;见 `公寓端/16` | +| `IT-3` | 59 张表结构、幂等补列脚本 | 人脸注册最小闭环业务代码(落 `ruoyi-system`) | | `IT-4`~`IT-5` | `apt_local_task` / `gy_jcxx` 结构、单一状态写入口约束 | 入住 CI-01~CI-05、叫乘状态机 M3 | | `IT-9`~`IT-11` 延后域 | 账务 6 张表**已建结构但不开放读写**、错误码与权限点可预埋 | 阶段三退出 + `D1/D2/P-07/Q-41~Q-45` 决策后再开 `/apt/ledger/**` | diff --git a/docs/公寓端/15-第二轮开发(IT-1)交付说明.md b/docs/公寓端/15-第二轮开发(IT-1)交付说明.md index 2d03fb8..d4e9097 100644 --- a/docs/公寓端/15-第二轮开发(IT-1)交付说明.md +++ b/docs/公寓端/15-第二轮开发(IT-1)交付说明.md @@ -59,6 +59,10 @@ | 配置 | `SecurityConfig`、`application.yml`、`application-druid.yml` | 过滤器链、springdoc 分组、数据源与 Redis | 凭据一律外置,不落明文 | | SQL | `09_it1_permissions.sql` | 只写 `sys_*` 框架表的权限点种子 | 不触碰 59 张业务表 | +> **归置说明(2026-11-11)**:本表为 IT-1 当时的代码落点记录。当前公寓端代码、Mapper XML、测试与 `apt.*` 配置 +> 已统一迁入 `dev/RuoYi-Vue-master/ruoyi-apt`;`SecurityConfig` 改为按 Spring Security 接口 + bean 名装配, +> 编译期不再依赖公寓端类。见 `公寓端/02` §2.5 与 `公寓端/16` 第 9 章。 + 图 15-1 依赖方向 ```mermaid @@ -260,3 +264,5 @@ flowchart LR | 需补 | `BindException` / `MethodArgumentNotValidException` 的非公寓端类型分派;审计写入独立事务;只读骨架 Controller 迁入 `com.ruoyi.apt.master.*` | | 回归 | 表 7-3 M1/M2、表 7-6 八类用例 M0~M2 部分;本文件表 15-14 的 M0 五项须持续全绿 | | 延后 | `IT-9`~`IT-11` 消费 / 充值 / 菜品,本期不开 `/apt/ledger/**`、不建 `com.ruoyi.apt.ledger` 包 | + +> **进度同步(2026-11-11)**:`IT-2` 已完成(含本表「需补」三项),79 个单测与端到端 24/24 通过;详见 `公寓端/16` 表 16-2 / 16-15 / 16-16。 diff --git a/docs/公寓端/16-第三轮开发(IT-2)交付说明.md b/docs/公寓端/16-第三轮开发(IT-2)交付说明.md new file mode 100644 index 0000000..208159b --- /dev/null +++ b/docs/公寓端/16-第三轮开发(IT-2)交付说明.md @@ -0,0 +1,545 @@ +# 16 第三轮开发(IT-2)交付说明 + +> 文档定位:IT-2「交路、房间与主数据快照可读可管」(M1 + M2)的实现说明与验收记录,对应 `13` 表 13-12 / DV-2。 +> 版本:v1.1 | 2026-11-11(v1.1 追加第 10 章「前端页面补齐」,同步表 16-1 / 16-17 / 16-19 / 16-21) +> 环境:MySQL `apt_rev01`(`dev/.env`)、Redis `database=1`、JDK 24 / Maven 3.8.x / Spring Boot 4.1.0、Windows PowerShell 5.1 +> 上位依据:`07` 表 7-2/7-3/7-6/7-7、`04` 表 4-5/4-7/4-17、`03` §4、`09` 表 9-16、`13` 表 13-12/13-21/13-22、`15` 表 15-15/15-17 +> 权威关系:本文只记录实现与验证结果,**不改变** `02`~`09` 的设计口径;与 `13`/`15` 不一致时以 `07` 验收标准和 `04` 接口契约为准,偏离项在第 7 章登记。 + +--- + +## 1. 交付概览与验收对照 + +### 1.1 交付物清单(DV-2) + +| 编号 | 交付物 | 落点 | 状态 | +|---|---|---|---| +| DV-2-1 | M1 交路 CRUD、启停、复算校验 | `ruoyi-apt/.../apt/master`(领域 + Controller) | 完成 | +| DV-2-2 | M2 房间档案、房态、停用/恢复、`state_version` | `ruoyi-apt/.../apt/stay` | 完成 | +| DV-2-3 | 主数据快照版本只读与 Mock 标记 | `ruoyi-apt/.../apt/master` / `/apt/master/versions` | 完成 | +| DV-2-4 | IT-1 遗留项闭环(审计独立事务、参数校验异常分派) | `ruoyi-apt/.../apt/framework` | 完成 | +| DV-2-5 | 幂等 DDL 补列 + 中文注释修复 | `dev/sql/apt_rev01/10_it2_columns.sql` | 完成 | +| DV-2-6 | Mock 灌数与版本标记校验 | `dev/sql/apt_rev01/11_it2_seed.sql` | 完成 | +| DV-2-7 | IT-2 权限点种子与校验 | `dev/sql/apt_rev01/12_it2_permissions.sql` | 完成 | +| DV-2-8 | SQL 重放执行脚本 | `dev/scripts/apply-it2-sql.ps1` | 完成 | +| DV-2-9 | 真实进程端到端验收脚本 | `dev/scripts/verify-it2-m1m2.ps1` | 完成 | +| DV-2-10 | 回归测试 | `ruoyi-apt/src/test`(79 用例) | 完成 | +| DV-2-11 | 本交付说明与索引同步 | 本文 + `README` / `14` / `15` / CR-02 表 0-2 | 完成 | +| DV-2-12 | `ruoyi-apt` 集中模块归置 | `dev/RuoYi-Vue-master/ruoyi-apt`(本文第 9 章) | 完成 | +| DV-2-13 | 公寓端前端页面、API 层、菜单与路由 | `dev/RuoYi-Vue2-master/src/{api,views,router}`、`dev/sql/apt_rev01/13_it2_frontend_menus.sql`(本文第 10 章) | 完成 | + +表 16-1 IT-2 交付物清单(DV-2) + +### 1.2 表 13-12 验收标准逐条对照 + +| # | `13` 表 13-12 验收标准 | 本轮结果 | 证据 | +|---|---|---|---| +| 1 | 缺 `cc` / `kcsj` / `reason` → `40001` 且指出具体字段 | 达成 | `AptRouteServiceTest`、`AptRoomServiceTest`、端到端 M1-4/M2-6 | +| 2 | 同 `jlid` 再次新增 → `40901` | 达成 | 端到端 M1-3(HTTP 409 / 40901) | +| 3 | 停用写 `duty_operation_log` 且 `detail` 含原因 | 达成 | `AptRouteController.writeReasonAudit`、`AptRoomController.writeStatusAudit`、端到端 M1-6 | +| 4 | 选「不能入住」只返回 `room_status=4` | 达成 | 端到端 M2-2(rows=1,全部 `roomStatus=4`) | +| 5 | 恢复触发重算,`state_version` 递增 | 达成 | 端到端 M2-7 / M2-10(`v=3→4→5`) | +| 6 | 改平台下发字段(如 `xm`)→ `40904` | **部分达成,登记偏离** | 房间侧 `roomFloor` / `bedNumbers` / `sourceVersion` → `40904`(M2-5);人员 `xm` 编辑属 M4,不在 IT-2 范围 | +| 7 | Mock 数据标 `source='LOCAL'` / `source_version=0` | 达成 | `11` 校验段 `local_applied_domains=6`、端到端 M2-12 | + +表 16-2 表 13-12 验收标准逐条对照 + +### 1.3 前置条件与 Go / No-Go + +| 前置 | `13` / `15` 要求 | 实际状态 | 开工处理 | +|---|---|---|---| +| IT-1 M0 六项验收 | 全部通过 | 通过(`15` 表 15-13,端到端 5/5) | 无 | +| D8' / D3 / D7 口径签字 | 编码前完成 | **未签字(P0)** | 用户显式推进,按「带条件开工」登记 | +| 契约冻结(表 9-6) | 编码前完成 | **未冻结(P0)** | 同上;`#1~#4` 以本地 Mock 替代 | +| 技术基线 | JDK 1.8 / Boot 2.5.15(IR-01) | JDK 24 / Boot 4.1.0 | 沿用 IT-1 已核准的偏离 | +| 数据库 / Redis / 59 张表 | 可用 | 可用,`dev/.env` 已提供凭据 | 无 | +| `APT_TOKEN_SECRET` | Base64 且解码 ≥64 字节 | `.env` 仅 39 字节 | 端到端脚本进程内生成临时密钥,不写回 `.env` | + +表 16-3 前置条件与 Go / No-Go 状态 + +> **结论**:IT-2 的实现与验证已闭环,但 P0 签字类前置仍未满足;本轮仍属「带条件开工」交付,不得据此认定设计口径已冻结。 + +--- + +## 2. 模块与分层 + +| 层 | 包 / 目录(第 9 章归置后) | 职责 | 关键约束 | +|---|---|---|---| +| 集中模块 | `ruoyi-apt/src/main/java/com/ruoyi/apt/**` | 公共基础 / 框架适配 / 主数据 / 房态 / 接口全部内聚 | 单一 Maven 模块,包路径 `com.ruoyi.apt.**` 不变 | +| 公共基础 | `ruoyi-apt/.../apt/common` | `AptContext`、`AptErrorCode`、`AptException`、`AptTableDataInfo` | `gyid` 只由服务端注入;列表追加 `warnings[]` | +| 框架适配 | `ruoyi-apt/.../apt/framework` | 四前缀拦截器、鉴权、异常分派、审计切面、Mock 开关护栏 | 审计 `REQUIRES_NEW`;非 `local` 禁用 Mock | +| 主数据 | `ruoyi-apt/.../apt/master` | 交路领域、版本只读 | 每条 SQL 带 `gyid`;平台快照字段只读 | +| 房态 | `ruoyi-apt/.../apt/stay` | 房间档案、房态重算、幂等命令 | `room_status` 仅由领域服务条件更新 | +| 接口 | `ruoyi-apt/.../apt/{master,stay,ops}/controller` | `/apt/route/**`、`/apt/room/**`、`/apt/master/**` | 权限点 + 原因审计 + `AptBaseController` 分页上限 | +| 配置与资源 | `ruoyi-apt/src/main/resources`(`apt-defaults.yml`、`mapper/apt/*.xml`、`static/apt/`) | 公寓端配置、Mapper XML、前端静态资源挂载点 | `spring.config.import` 引入;`classpath*:mapper/**/*Mapper.xml` 通配 | +| 工程级资产 | `dev/sql/apt_rev01/10~12`、`dev/scripts/*` | DDL 补列、Mock 灌数、权限点、实库重放、端到端 | 不进 Maven jar;UTF-8 字节流;独立端口 8099 | + +表 16-4 IT-2 模块与分层(第 9 章归置后的实际落点) + +```mermaid +flowchart LR + Admin["ruoyi-admin
启动 / fat jar"] --> Apt["ruoyi-apt
四前缀接口 + 业务 + 配置"] + Framework["ruoyi-framework
安全链 / AOP / MVC"] -. "按 bean 名装配 401/403" .-> Apt + Common["ruoyi-common
基础工具 / Security / Validation"] --> Framework + Apt --> Framework + Apt --> DB[(MySQL apt_rev01)] + SQL["dev/sql/apt_rev01 10~12"] --> DB + E2E["verify-it2-m1m2.ps1"] --> Admin +``` + +图 16-1 IT-2 依赖与数据流向(第 9 章归置后) + +--- + +## 3. M1 交路管理 + +### 3.1 字段映射(不改 59 张表口径) + +| 表 4-5 字段 | DDL 列 | 说明 | +|---|---|---| +| `jlid` | `jlid` | 业务唯一键 | +| `jch` | `route_name` | 交路名 | +| `qd` / `zd` | `start_station` / `end_station` | 起止站 | +| `cc` / `kcsj` | `cc` / `kcsj` | 车次 / 开车时刻 | +| `jcsj` / `lysj` | 本轮补列 | 叫乘 / 离寓时刻基线 | +| `enabled` | `state` | `ACTIVE` / `STOPPED` | +| `sourceVersion` | `source_version` | 平台下发版本 | +| `cclx` / `bz` | 本轮补列 | 车次类型 / 备注 | + +表 16-5 M1 字段映射 + +### 3.2 接口清单 + +| # | 方法 / 路径 | 权限点 | 关键行为 | +|---|---|---|---| +| 1 | `GET /apt/route/list` | `apt:route:list` | 分页上限、`jch/qd/zd/enabled` 映射 | +| 2 | `GET /apt/route/options` | `apt:route:query` | 下拉选项 | +| 3 | `GET /apt/route/{id}` | `apt:route:query` | 详情 | +| 4 | `POST /apt/route` | `apt:route:add` | 新增;`jlid` 重复 `40901` | +| 5 | `PUT /apt/route/{id}` | `apt:route:edit` | 编辑;快照字段 `40904` | +| 6 | `PUT /apt/route/{id}/status` | `apt:route:changeStatus` | 启停,原因写入审计 | +| 7 | `POST /apt/route/{id}/recalc-check` | `apt:route:query` | 只算不落库 | + +表 16-6 M1 接口清单(表 4-5 #1~#7) + +### 3.3 校验、错误码与复算 + +| 场景 | 错误码 | HTTP | +|---|---|---| +| 缺 `cc` / `kcsj` / 时序错误 | `40001 PARAM_INVALID` | 400 | +| `jlid` 重复 | `40901 DUPLICATE_KEY` | 409 | +| 改平台下发字段(`sourceVersion` / `createTime`) | `40904 SNAPSHOT_READONLY` | 409 | +| 启停缺 `reason` | `40001 PARAM_INVALID` | 400 | + +表 16-7 M1 校验与错误码 + +| 规则 | 取值 | +|---|---| +| 叫乘提前量 | `CALL_TIME` 规则;无规则时默认 120 分钟,节假日 180 分钟 | +| `computedJcsj` | `kcsj - 提前量` | +| `computedFjsj` | `computedJcsj - wakeMinutes` | +| 返回 | `diffMs`、`warnings[]`,**不落库** | + +表 16-8 M1 复算规则(表 7-3 M1、D8') + +--- + +## 4. M2 房间档案与房态 + +### 4.1 接口清单 + +| # | 方法 / 路径 | 权限点 | 关键行为 | +|---|---|---|---| +| 1 | `GET /apt/room/list` | `apt:room:list` | `building` 降级楼层前缀 + `warnings[]`;`roomStatus` 多选 | +| 2 | `GET /apt/room/{id}` | `apt:room:query` | 分机绑定 / 在寓人员 / 最近保洁任务 | +| 3 | `GET /apt/room/status-summary` | `apt:room:list` | 房态汇总卡片 | +| 4 | `PUT /apt/room/{id}` | `apt:room:edit` | 仅本地属性;快照字段 `40904` | +| 5 | `PUT /apt/room/{id}/status` | `apt:room:changeStatus` | 停用 / 恢复,`commandId` 幂等 | +| 6 | `POST /apt/room` | `apt:room:add` | G-01/G-04 未定稿,恒 `40940` | + +表 16-9 M2 接口清单(表 4-7 #1~#6) + +### 4.2 房态与 state_version + +| 动作 | `room_status` | `disabled_reason` | `state_version` | 在寓人员 | +|---|---|---|---|---| +| `DISABLE` | 置 `4` | 写入 `reason` | `+1` | 不改动、不停叫乘 | +| `RESTORE` | 重算 | 清空 | 再 `+1` | 不改动 | +| 重复 `commandId` | 不变 | 不变 | 不变 | 不变 | + +表 16-10 停用 / 恢复语义(表 7-3 M2) + +重算优先级:有效保洁任务 `PROCESSING` → `6`;`PENDING` → `3`;在寓人数 `0` → `1`;`in_person_count >= bed_count` → `2`;否则 `5`。汇总卡片 `cleaning` 合并状态码 `3` 与 `6`。 + +| 场景 | 错误码 | HTTP | +|---|---|---| +| 缺 `reason` / 非法 `action` / 空编辑体 | `40001` | 400 | +| 改 `bedNumbers` / `roomFloor` / `sourceVersion` | `40904` | 409 | +| 重复停用 / 恢复非停用房 | `40902` | 409 | +| `state_version` 条件更新命中 0 行 | `40903` | 409 | +| 新增房间 / 主数据全量重拉 | `40940` | 409 | + +表 16-11 M2 幂等与错误码 + +> **本轮修复**:幂等键判断原先排在状态合法性之后,重复停用会错返 `40902`。已把 `commandId` 查询前置,并补 `duplicateDisableOnAlreadyDisabledRoomReturnsDuplicatedInsteadOfStateConflict` 回归用例;端到端 M2-8 复验通过。 + +--- + +## 5. 主数据下行 Mock 灌数与版本标记 + +### 5.1 版本标记与只读接口 + +| 项 | 实现 | +|---|---| +| 6 个域 | `APARTMENT` / `ROOM` / `PERSON` / `ROUTE` / `RULE` / `HOLIDAY` | +| 固定标记 | `source='LOCAL'`、`source_version=0`、`state='APPLIED'`、`deleted=0` | +| 只读接口 | `GET /apt/master/versions`、`GET /apt/master/versions/{domain}`(表 4-17 #1) | +| 页面标识 | `localMock=true`、`sourceLabel="非平台下发"`、列表 `mockNotice` | +| 域记录数 | `itemCount` 用固定表名子查询统计,禁止动态拼接表名 | +| 全量重拉 | `POST /apt/master/{masterType}/resync` 恒 `40940`(表 4-17 #2,平台未就绪不做假成功) | +| 权限点 | `hasAnyPermi('apt:ops:health,apt:master:version')`;3015 已显式关联超管 | +| 生产护栏 | `apt.master-client.mock`(默认 `false`);非 `local` profile 开启即启动失败 | + +表 16-12 版本标记与只读接口(表 9-16、表 4-17) + +### 5.2 SQL 脚本与实库校验 + +| 脚本 | 实库结果 | +|---|---| +| `10_it2_columns.sql` | 补列 10 个(`apt_master_route` 4 / `gy_rooms` 6),列注释中文正确 | +| `11_it2_seed.sql` | 交路 3 / 房间 5 / 人员 5 / 计划 2 / 版本 6 | +| `11` 校验段 | `local_applied_domains=6`、`fake_platform_rows=0`、6 域明细均「非平台下发」 | +| `12_it2_permissions.sql` | `grey_bound=0`、`version_permission_bound=1`、`apt_permission_count=0` | +| 十六进制复核 | `xm` / `route_name` / `menu_name` / `COLUMN_COMMENT` 均为正确 UTF-8 字节 | + +表 16-13 IT-2 SQL 实库执行与校验结果 + +### 5.3 中文编码缺陷与修复 + +首次实库执行时,Windows PowerShell 5.1 以 ASCII 文本管道把 SQL 中文替换成 `?`,库内 `xm` 实测为 `3F3F`,列注释与新增菜单名同样损坏。修复链条: + +| 环节 | 修复 | +|---|---| +| `apply-it2-sql.ps1` | 改为 `.NET Process` + 原始 UTF-8 字节流写入 mysql `stdin`,彻底绕过 PowerShell 文本编码;保留 `-DryRun`、环境变量凭据回退、`MYSQL_PWD` 清理 | +| `10_it2_columns.sql` | 增加可重复执行的 `MODIFY COLUMN` 注释修复段,校验 SELECT 输出 `COLUMN_COMMENT` | +| `11_it2_seed.sql` | 交路 `ON DUPLICATE KEY UPDATE` 补齐 `route_name` / `start_station` / `end_station` / `stop_reason`,使重放可修复中文 | +| 复验 | 重放后中文输出正常,HEX 复核全部为正确 UTF-8,计数不变 | + +表 16-14 PowerShell 中文管道缺陷与修复 + +--- + +## 6. 验证结果 + +### 6.1 实际执行的验证 + +| 项 | 命令 / 脚本 | 结果 | +|---|---|---| +| 编译打包 | `mvn -B -ntp -pl ruoyi-admin -am package` | 7 模块 `BUILD SUCCESS`,`ruoyi-admin.jar` 重新生成 | +| 全量单测 | 同上 | `Tests run: 79, Failures: 0, Errors: 0` | +| 架构约束 | `AptMapperGyidArchTest`(3 例) | 4 个 Mapper XML 全部带 `gyid`、业务字段排序、乐观锁断言通过 | +| SQL 静态校验 | `AptIt2SeedMarkersTest`(4 例) | 6 域标记、禁冒充平台、权限点登记通过 | +| 实库执行 | `apply-it2-sql.ps1` | 3 脚本成功,校验计数符合预期(表 16-13) | +| 端到端 | `verify-it2-m1m2.ps1` | **24/24 通过**(表 16-16;M0-5 校验模块配置导入与 springdoc 分组) | +| DryRun | `apply-it2-sql.ps1 -DryRun` | 通过 | +| 静态诊断 | `read_lints` | 改动文件 0 条 | + +表 16-15 IT-2 验证执行清单 + +> 环境说明:验证码保持开启、`sys_config` 未改;验证码答案只读 Redis `database=1`;`.env` 的 `APT_TOKEN_SECRET` 不足 64 字节,脚本进程内生成临时 HS512 密钥;8080 已有实例,端到端独立使用 8099 端口并在结束时停止自建进程;所有路径均走 `/apt/**` 四个受管前缀。 + +### 6.2 端到端 24 项实测 + +| 用例 | 期望 | 实测 | +|---|---|---| +| M0-1 无 token | 401 / 40101 | 401 / 40101 | +| M0-2 无权限 | 403 / 40301 | 403 / 40301 | +| M0-3 跨 `gyid` | 403 / 40302 | 403 / 40302 | +| M0-4 分页上限 | `pageSize=100` | `pageSize=100` | +| M0-5 模块配置 + springdoc 分组 | `apt-defaults.yml` 导入后 `/apt/*` 路径可见 | HTTP 200 / `aptPaths=17` | +| M1-1 列表与字段映射 | ≥3 行、`jch` 非空 | 3 行、`jch=北京南-济南西` | +| M1-2 详情 | `JL-001` | `JL-001` | +| M1-3 重复 `jlid` | 409 / 40901 | 409 / 40901 | +| M1-4 缺 `reason` | 400 / 40001 | 400 / 40001 | +| M1-5 复算校验 | `computedJcsj` 非空 | 非空(只算不落库) | +| M1-6 启停与原因审计 | 200 / 200 | 200 / 200 | +| M2-1 房间列表 | ≥5 行 | 5 行 | +| M2-2 房态筛选 | 只返回 `4` | 1 行,全部 `4` | +| M2-3 房态汇总 | `total≥5` | 5 | +| M2-4 详情 | `roomNo=201` + 绑定列表 | 通过 | +| M2-5 快照只读 | 409 / 40904 | 409 / 40904 | +| M2-6 缺 `reason` | 400 / 40001 | 400 / 40001 | +| M2-7 停用 | `status=4` / 版本 +1 | `4`,`v=3→4` | +| M2-8 幂等 | `duplicated=true` / 版本不变 | `True`,`v=4` | +| M2-9 停用在寓可查 | `status=4` + `livePersons` | 通过 | +| M2-10 恢复重算 | 非 `4` / 版本再 +1 | `1`,`v=5` | +| M2-11 新增禁用 | 409 / 40940 | 409 / 40940 | +| M2-12 版本标记 | 6 域 `LOCAL/0/mock` | 6 域全通过 + `mockNotice` | +| M2-13 重拉禁用 | 409 / 40940 | 409 / 40940 | + +表 16-16 `verify-it2-m1m2.ps1` 24 项实测结果(表 7-7 步骤 4/5) + +--- + +## 7. 口径偏离与待评审项 + +| # | 偏离 / 未决 | 依据 | 本轮处理 | 状态 | +|---|---|---|---|---| +| 16-1 | 契约冻结、`D5`、`D8'`/`D3`/`D7` 签字未完成 | `13` 表 13-12、IR-01 | 按用户显式推进「带条件开工」 | **P0 待签字** | +| 16-2 | 技术基线 JDK 24 / Boot 4.1.0 ≠ 设计 JDK 1.8 / 2.5.15 | IR-01 | 沿用 IT-1 核准偏离 | 已登记 | +| 16-3 | 表 4-17 #1 权威权限点 `apt:ops:health`,IT-2 种子为 `apt:master:version`(3015) | `04` 表 4-17、`07` 表 7-4 | `hasAnyPermi` 兼容,3015 关联超管并在 remark 登记 | 待评审 | +| 16-4 | 表 4-17 #2 `resync` 本期恒 `40940`;表 4-7 #6 `add` 恒 `40940` | 表 9-16、G-01/G-04 | 明确拒绝,不做假成功 | 待评审 | +| 16-5 | 表 13-12 验收第 6 项以人员 `xm` 为例,M4 不在 IT-2 范围 | 表 13-12 范围 | 房间侧实现 `40904`;`xm` 留待 M4 | 部分达成 | +| 16-6 | `jch/qd/zd` ← `route_name/start_station/end_station`;`enabled` ← `state` | 表 4-5 | 映射层解决,未改 59 张表 | 已闭环 | +| 16-7 | `gy_rooms` 补 6 列、`apt_master_route` 补 4 列 | 表 4-5 / 4-7 | `10` 幂等补列,不改主键/唯一键/索引 | 已闭环 | +| 16-8 | 房间停用无 `enabled` 开关,直接置 `room_status=4` | 表 7-3 M2 | 领域服务条件更新 | 已闭环 | +| 16-9 | `duty_manual_action.action_type` 复用 `STOP`/`CORRECT` | 表 3-1 列注释 | 未新造枚举值 | 待评审 | +| 16-10 | 乐观锁冲突复用 `40903`(快照版本过期) | 表 4-4 | 条件更新命中 0 行即返回 | 待评审 | +| 16-11 | 汇总 `cleaning` 合并状态码 `3` 与 `6` | 表 4-7 | 前端卡片口径 | 待评审 | +| 16-12 | `building` 降级楼层前缀 + `warnings[]`;`AptTableDataInfo` 追加字段 | 表 4-7 约束 1、G-01 | 列表响应追加空数组,不破坏既有字段 | 待评审 | +| 16-13 | 审计 `REQUIRES_NEW`、参数校验异常分派属 IT-1 遗留项 | `15` 表 15-15 | 本轮补齐并纳入回归 | 已闭环 | +| 16-14 | 重复停用优先返回 `duplicated=true`(幂等先于状态检查) | 表 4-7 #5 L2 | 端到端 M2-8 暴露后修复 | 已闭环 | +| 16-15 | `.env` 的 `APT_TOKEN_SECRET` 仅 39 字节 | `15` 表 15-16 | 脚本生成临时密钥,未写回 `.env` | 待用户修正 | +| 16-16 | 表 9-16 的 20 接口契约样例 JSON 未落测试资源目录 | 表 9-16 | 本轮以 SQL 静态校验替代 | 待补 | +| 16-17 | 前端页面待补 | 表 13-12 范围 | 已补齐 6 个 Vue 页面 + 4 个 API 文件 + 4 条 `C` 菜单(本文第 10 章);`mockNotice` / `localMock` 在版本台账页标「非平台下发」 | 已闭环 | +| 16-18 | 端到端使用 8099 独立端口(8080 已有实例) | 环境事实 | 自建进程、结束即停 | 已登记 | +| 16-19 | 前端落点采用单入口 `src/views/apt/**` + `sys_menu` 动态路由,而非设计期 `src/entries/apt` 双入口静态路由 | `docs/10` 表 2-3 差-7、`02` §2.1 | 实际上游 `RuoYi-Vue2-master` 为标准单入口(无 `entries/`、无 `pages` 配置),全部页面由 `getRouters` 下发;本期跟随实际上游,不改写设计期口径,待真建双入口时另行评审 | 已登记 | + +表 16-17 IT-2 口径偏离与待评审项登记 + +--- + +## 8. 变更点、影响范围与后续衔接 + +### 8.1 变更点 + +| 类别 | 变更 | 影响 | +|---|---|---| +| 新增 Java | `apt/master` 交路 7 接口 + 版本只读;`apt/stay` 房间 6 接口;`AptMasterClientMockGuard` | 仅 `/apt/**` 受管前缀,RuoYi 原 `/system/**` 不受影响 | +| 修改 Java | `AptAuditWriter`/`AptAuditAspect`、`AptInfraExceptionHandler`、`AptProperties`、`AptTableDataInfo` | 公寓端列表多 `warnings[]`;审计业务回滚不丢 | +| 新增 SQL | `10` / `11` / `12` 三个幂等脚本 | 实库 `apt_rev01` 已执行;`sys_menu` 3015 关联超管 | +| 新增脚本 | `apply-it2-sql.ps1`、`verify-it2-m1m2.ps1` | 可重放;端到端自启自停 | +| 配置 | `application.yml` 增 `apt.master-client.mock`;`.env.example` 同步 | 默认关闭,生产启动护栏 | +| 文档 | 本文 + `README` / `14` / `15` / CR-02 表 0-2 索引 | 不改变设计口径 | + +表 16-18 IT-2 变更点与影响范围 + +### 8.2 后续衔接 + +| 阶段 | 待办 | 前置 | +|---|---|---| +| IT-3 / M3 | 叫乘闭环、STOP 幂等、`duty_manual_action` 与叫乘联动 | D8'/D7 签字 | +| IT-5 / M4 | 人员主数据编辑、`xm` 快照只读 `40904` | 契约冻结 | +| 前端 | `IT-3`~`IT-8` 的叫乘 / 人员 / 设备 / 语音 / 入住等页面(`04` 表 4-10 / 4-12 / 4-14 / 4-16 已给文件名) | 对应后端 Controller 落地后方可建页;`IT-9`~`IT-11` 账务域按表 13-29 仍不开放 | +| 契约 | 表 9-16 的 20 接口请求样例 JSON → 落库断言 | 契约冻结 | +| 环境 | 修正 `.env` 的 `APT_TOKEN_SECRET` 为合法 Base64 HS512 密钥 | 无 | +| 治理 | 契约冻结、`D5`、`D8'`/`D3`/`D7` 签字并回填本报告 | **P0** | +| 验证 | 扩展端到端覆盖表 7-7 步骤 6/7(保洁任务、叫乘 Mock) | IT-3 | + +表 16-19 IT-2 后续衔接 + +--- + +## 9. 模块归置:`ruoyi-apt`(2026-11-11) + +### 9.1 动因与归置清单 + +IT-0~IT-2 期间公寓端代码按「基座 → 框架 → 领域 → 接口」分别落在 4 个 RuoYi 上游模块中,跨模块引用与文档口径逐渐发散。 +本轮新建单一 Maven 模块 `ruoyi-apt`,把公寓端实现统一收拢到 `com.ruoyi.apt.**`,包路径与接口路径均不变。 + +| 原落点 | 内容 | 新落点 | 数量 | +|---|---|---|---| +| `ruoyi-common/src/main/java/com/ruoyi/apt` | 上下文、错误码、注解、分页 | `ruoyi-apt/src/main/java/com/ruoyi/apt/common` | 7 | +| `ruoyi-framework/src/main/java/com/ruoyi/apt` | 拦截器、鉴权、异常、审计、配置、探针 | `ruoyi-apt/.../apt/framework`、`.../apt/infra` | 12 | +| `ruoyi-system/src/main/java/com/ruoyi/apt` | 交路 / 房间 / 版本领域与服务、Mapper 接口 | `ruoyi-apt/.../apt/{master,stay,ops}` | 22 | +| `ruoyi-admin/src/main/java/com/ruoyi/apt` | 4 个 Controller | `ruoyi-apt/.../apt/{master,stay,ops}/controller` | 4 | +| `ruoyi-system/src/main/resources/mapper/apt` | 4 个 Mapper XML | `ruoyi-apt/src/main/resources/mapper/apt` | 4 | +| `ruoyi-admin/src/test/java/com/ruoyi/apt` | 15 个测试(79 用例) | `ruoyi-apt/src/test/java/com/ruoyi/apt` | 15 | +| `ruoyi-admin/src/main/resources/application.yml` | `apt.*`、springdoc 分组、日志级别 | `ruoyi-apt/src/main/resources/apt-defaults.yml` | 1 | + +表 16-20 归置清单(共移动 64 个 Java/XML 文件并抽出 1 份配置) + +### 9.2 职责范围与边界 + +| 纳入 | 不纳入 | +|---|---| +| 四前缀(`/apt/**` 等)全部 Controller、领域服务、Mapper 接口与 XML | RuoYi 原 `/system/**`、`/monitor/**`、`/tool/**` 后台能力 | +| `AptContext` / 统一错误码 / 拦截器 / 401·403 处理器 / 审计切面 / `AptProperties` / Mock 启动护栏 | `SecurityConfig`(仍属 `ruoyi-framework`,按接口装配公寓端处理器) | +| `apt-defaults.yml`:`apt.*`、springdoc 分组、`com.ruoyi.apt` 日志级别 | 数据源、Redis、token 等基础设施配置(仍在 `ruoyi-admin`) | +| 前端静态资源挂载点 `src/main/resources/static/apt/` | Vue 前端源码:已落在独立工程 `dev/RuoYi-Vue2-master/src/views/apt/**`(API 在 `src/api/apt/**`,路由在 `src/router/index.js`);本期按实际上游单入口 + `sys_menu` 动态路由实现,见第 10 章;构建产物可输出到挂载点 | +| 模块内 79 个 JUnit5/Mockito 用例 | DDL / 种子 / 权限点 SQL 与 PowerShell 验收脚本:保持 `dev/sql/apt_rev01`、`dev/scripts` 原位,不参与 Maven 打包 | + +表 16-21 `ruoyi-apt` 职责范围与边界 + +### 9.3 结构定位、依赖与被依赖 + +| 方向 | 模块 | 说明 | +|---|---|---| +| **依赖** | `ruoyi-common` | 基础工具、`AjaxResult`、Security、Validation、PageHelper、fastjson2、Redis | +| **依赖** | `ruoyi-framework` | Spring WebMVC、AspectJ、Druid、`AuthenticationEntryPointImpl`、`BaseController` | +| **依赖(传递)** | `ruoyi-system` | 经 framework 传递,模块内不引用其业务类 | +| **依赖** | 第三方 | `mybatis-spring-boot-starter`、`spring-tx`、`spring-boot-starter-test`(test) | +| **被依赖** | `ruoyi-admin` | 启动模块显式声明,使 `ruoyi-apt.jar` 进入 classpath 与 fat jar | +| **被运行时引用** | `ruoyi-framework` | `SecurityConfig` 只依赖 `AuthenticationEntryPoint` / `AccessDeniedHandler` 接口,按 bean 名 `aptAuthenticationEntryPoint` / `aptAccessDeniedHandler` 装配;**编译期不 import `com.ruoyi.apt.*`**,消除循环依赖 | +| **无依赖** | `ruoyi-quartz`、`ruoyi-generator` | 与公寓端解耦 | + +表 16-22 依赖与被依赖关系 + +```mermaid +flowchart TD + ADMIN["ruoyi-admin
启动 / fat jar"] --> APT["ruoyi-apt
公寓端集中模块"] + APT --> FRAME["ruoyi-framework"] + APT --> COMMON["ruoyi-common"] + FRAME --> SYS["ruoyi-system"] + SYS --> COMMON + ADMIN --> QUARTZ["ruoyi-quartz"] + ADMIN --> GEN["ruoyi-generator"] + QUARTZ --> COMMON + GEN --> COMMON + FRAME -.->|"运行时按 bean 名装配
401/403 处理器"| APT +``` + +图 16-2 Maven 反应堆中的 `ruoyi-apt` 定位(实线为编译期依赖,虚线为运行时装配) + +### 9.4 构建识别与验证 + +| 调整点 | 内容 | +|---|---| +| 根 `pom.xml` | `` 增加 `ruoyi-apt`;`dependencyManagement` 登记 `${ruoyi.version}` | +| `ruoyi-admin/pom.xml` | 新增 `ruoyi-apt` 依赖 | +| `ruoyi-apt/pom.xml` | 声明 common / framework / mybatis / spring-tx / test 依赖 + Surefire 3.5.2 | +| 包扫描 | `@SpringBootApplication`(`com.ruoyi`)、`@MapperScan("com.ruoyi.**.mapper")`、`typeAliasesPackage: com.ruoyi.**.domain` 均为通配,无需改包名 | +| Mapper 资源 | `classpath*:mapper/**/*Mapper.xml` 通配加载模块内 4 个 XML | +| 配置导入 | `spring.config.import: 'optional:classpath:apt-defaults.yml'`;springdoc `packages-to-scan` 由 `com.ruoyi.apt.**` 修正为 `com.ruoyi.apt` | +| 架构测试 | `AptMapperGyidArchTest` 定位改为 `ruoyi-apt/src/main/resources/mapper/apt`,兼容模块根与反应堆根 | + +表 16-23 构建与识别调整 + +| 验证 | 结果 | +|---|---| +| `mvn -pl ruoyi-admin -am clean package` | 7 模块 `BUILD SUCCESS`;反应堆顺序 `common → system → framework → apt → admin`,单向无环 | +| 全量单测 | `Tests run: 79, Failures: 0, Errors: 0`(全部在 `ruoyi-apt` 模块执行) | +| 端到端 | `verify-it2-m1m2.ps1` **24/24 通过**(新增 M0-5 校验 `apt-defaults.yml` 导入与 springdoc 分组,实测 `aptPaths=17`) | +| 陈旧产物 | `clean` 后 4 个旧模块的 `target/classes/com/ruoyi/apt`、`mapper/apt` 残留为 0 | +| 静态诊断 | 改动文件 lints 0 | + +表 16-24 模块归置验证结果 + +> **构建提示**:迁移后必须 `mvn clean package`。首次只执行 `package` 时,旧模块 `target/classes` 残留的 +> `mapper/apt/AptAuditMapper.xml` 与 `ruoyi-apt.jar` 内同名资源同时被 `classpath*:` 命中, +> MyBatis 抛 `Mapped Statements collection already contains value` 导致启动失败;`clean` 后消除。 +> +> **配置修正**:springdoc `packages-to-scan` 原值 `com.ruoyi.apt.**` 是既有缺陷(分组 `paths` 恒为空), +> 归置时一并改为包名 `com.ruoyi.apt`,端到端 M0-5 复验通过。 + +> 提交纪律:本轮未执行任何 `git commit` / `git push`;`dev/.env` 含真实口令,保持 `.gitignore` 忽略,不得进入提交记录。 + +--- + +## 10. 前端页面补齐(2026-11-11) + +### 10.1 范围界定 + +「公寓端全部尚未实现的前端页面」按「是否有真实后端接口可对接」分为三类,本轮只补齐第一类;判据是 `16` 表 16-6 / 16-9 / 16-12 与 `04` 表 4-10~4-17 的 Controller 落地情况,而非设计文档中的页面命名。 + +| 类别 | 功能域 | 后端现状 | 本轮处理 | +|---|---|---|---| +| A 接口已就绪 | 交路、房间、主数据版本、连通性自检 | `IT-0`~`IT-2` 已交付 4 个 Controller | **建 4 个菜单页 + 2 个详情页** | +| B 仅设计、Controller 未落地 | 叫乘 `jcxx`、人员 `person`、设备 `device`、语音 `voice`、上报 `report`、人脸、入住、值班台、报表 | `04` 已给 `.vue` 文件名与接口契约,`13` 排在 `IT-3`~`IT-8` | **不建页面**:无接口可对接,建页即等于伪造交互 | +| C 明文延后(P2) | 消费 / 线下充值 / 菜品 `ledger` | `13` 表 13-29「不开 `/apt/ledger/**`、不建 `com.ruoyi.apt.ledger` 包、权限点置灰」 | **不建页面、不建菜单** | + +表 16-25 前端页面补齐范围 + +> `docs/公寓端/ui/duty-console-v2.html` 是零依赖静态高保真值班台原型(假数据 + `localStorage`,不发任何 `/apt/**` 请求), +> 与 Vue 工程无构建关系,本轮不改造、不迁移。 + +### 10.2 页面清单与接口对应 + +| 页面(`dev/RuoYi-Vue2-master/src/views/apt/`) | API 文件 | 对接接口 | 操作区 | 主要权限点 | +|---|---|---|---|---| +| `route/index.vue` 交路列表 | `api/apt/route.js` | `GET /apt/route/list`、`/{id}`、`/options`、`POST /apt/route`、`PUT /{id}`、`PUT /{id}/status`、`POST /{id}/recalc-check` | 查询、新增、修改、启停、详情、复算校验、导出、列显隐、分页 | `apt:route:list/query/add/edit/changeStatus` | +| `route/detail.vue` 交路详情 | 同上 | `GET /apt/route/{id}` | 只读详情、刷新、返回 | `apt:route:query` | +| `room/index.vue` 房间列表 | `api/apt/room.js` | `GET /apt/room/list`、`/{id}`、`/status-summary`、`PUT /{id}`、`PUT /{id}/status` | 查询、房态汇总卡片、修改、停用/恢复(`commandId` 幂等)、详情、导出、列显隐、分页 | `apt:room:list/query/edit/changeStatus` | +| `room/detail.vue` 房间详情 | 同上 | `GET /apt/room/{id}` | 主档只读 + `bindings` / `livePersons` / `recentCleanTasks` 三 Tab | `apt:room:query` | +| `master/version/index.vue` 版本台账 | `api/apt/master.js` | `GET /apt/master/versions`、`/versions/{domain}` | 前端筛选、只读详情、导出、`mockNotice` / `localMock` 标「非平台下发」 | `apt:master:version` / `apt:ops:health` | +| `ops/connectivity/index.vue` 连通性自检 | `api/apt/ops.js` | `GET /apt/ops/connectivity`、`POST .../probe` | 目标 / 严格模式表单、只读检查、读写探针、结果表格、明细、导出 | `apt:ops:connectivity` / `apt:ops:health` | + +表 16-26 页面、API 与接口对应(公共常量与工具在 `src/utils/apt.js`,禁用态按钮组件在 `src/components/AptDisabledAction/index.vue`) + +### 10.3 与 RuoYi 页面规范的一致性 + +| 维度 | 采用方式 | +|---|---| +| 页面结构 | `.app-container` → `el-form` 查询区 → `el-row.mb8` 按钮区 → `el-table v-loading` → `pagination` → `el-dialog` 表单,与 `system/post/index.vue`、`system/user/index.vue` 同构 | +| 布局 | `el-row :gutter` + `el-col :xs="24" :sm="12"`;弹窗 `width` + `:fullscreen="isMobile"`(`store.state.app.device`) | +| 组件 | 全局 `Pagination` / `RightToolbar`(`storageKey` 记忆列显隐)/ `DictTag`;局部 `AptDisabledAction`;不引入第二套 UI 库 | +| 主题配色 | 不写死色值:只用 Element 语义色(`primary` / `success` / `danger` / `warning` / `info`)与 `el-tag type`,跟随 `ThemePicker` 注入的 `--current-color` | +| 表格与表单 | `dict-tag :options` 复用 RuoYi 字典结构(`value` / `label` / `raw.listClass`);`parseTime` / `resetForm` 全局方法;`label-width` 统一;`show-overflow-tooltip` 防溢出 | +| 按钮与弹窗 | `v-hasPermi` 数组;`$modal.confirm` 二次确认、`msgSuccess` / `msgWarning` / `msgError`;`el-dialog append-to-body` + `dialog-footer` 的「确 定 / 取 消」 | +| 加载状态 | 列表 `v-loading`、按钮 `:loading`、汇总独立 `v-loading`;所有 `catch` 复位,避免遮罩悬挂 | +| 表单校验 | `rules` + `$refs.form.validate`;交路 `jcsj < kcsj`、`lysj <= kcsj` 前端预校验(对齐 `AptRouteServiceImpl.validate`);房间 `roomOrder >= 0` 与「本地属性至少一项」;`reason` 512 字上限与必填 | +| 错误提示 | `src/utils/request.js` 错误分支增强:公寓端 `code/100` 映射 HTTP 4xx 时优先展示后端 `msg` + `data.reason` + `traceId`;`utils/apt.js` 提供 27 个错误码与 reason 的中文映射;页面用 `el-alert` 内联展示 | +| 权限控制 | 菜单级 `sys_role_menu` → `getRouters`;按钮级 `v-hasPermi`;详情页 `dynamicRoutes.permissions` 过滤(前端隐藏不代替后端 `@PreAuthorize`) | +| 响应式 | `xs/sm/md` 栅格、小屏弹窗全屏、房态汇总六卡换行、`collapse-tags`、RightToolbar 列显隐;沿用 `AppMain` 991px、`ResizeHandler` 992px、`ruoyi.scss` 768px 既有断点 | + +表 16-27 页面规范对照 + +### 10.4 路由与菜单配置统一 + +菜单沿用 RuoYi 标准机制:后端 `getRouters` 下发 `C` 菜单 → `store/modules/permission.js` 的 `loadView('@/views/' + component)` 懒加载,与 `system/user` 完全同构;业务页面**不写进** `constantRoutes`,避免绕过菜单鉴权。 + +| `menu_id` | 菜单名 | `path` | `component` | 完整路由 | 种子 | +|---|---|---|---|---|---| +| 3101 | 交路管理 | `route` | `apt/route/index` | `/apt/route` | `13_it2_frontend_menus.sql` | +| 3102 | 房间管理 | `room` | `apt/room/index` | `/apt/room` | 同上 | +| 3103 | 主数据版本 | `version` | `apt/master/version/index` | `/apt/version` | 同上 | +| 3104 | 连通性自检 | `connectivity` | `apt/ops/connectivity/index` | `/apt/connectivity` | 同上 | + +表 16-28 `C` 菜单与组件路径(父目录 3000 `path='apt'`,`component` 为空时 RuoYi 回落 `Layout`) + +两个详情页登记在 `src/router/index.js` 的 `dynamicRoutes`:`/apt/route-detail/index/:id`(`AptRouteDetail`,`permissions: ['apt:route:query']`,`activeMenu: '/apt/route'`)与 `/apt/room-detail/index/:id`(`AptRoomDetail`,`apt:room:query`),均 `hidden: true`,不建 `C` 菜单。 + +```mermaid +flowchart LR + MENU["sys_menu
3000 + 3101~3104
13_it2_frontend_menus.sql"] -->|"getRouters"| PERM["permission.js
filterAsyncRouter / loadView"] + PERM --> VIEWS["src/views/apt/**
4 个菜单页"] + VIEWS -->|"$router.push"| HIDDEN["dynamicRoutes
2 个隐藏详情页"] + API["src/api/apt/**"] -->|"utils/request.js"| BACKEND["ruoyi-apt
/apt/** Controller"] + VIEWS --> API + F["F 权限点 3001~3015"] -.->|"v-hasPermi"| VIEWS +``` + +图 16-3 菜单 → 路由 → 页面 → 接口关系(实线为加载/调用,虚线为按钮级权限) + +> **口径偏离 16-19**:`docs/10` 表 2-3(差-7)与 `02` §2.1 约定 `entry-apt` 使用前端静态路由表、`sys_menu` 仅作权限点载体。 +> 但本仓库实际上游 `RuoYi-Vue2-master` 是**标准单入口**(无 `src/entries/`、无 `vue.config.js` 的 `pages` 配置), +> 所有 `views` 均由 `getRouters` 下发。本期选择跟随实际上游,不改 `docs/10` 的设计期表述; +> 若后续真要落地双入口,需先做工程改造并另行评审,届时再同步本节。 + +### 10.5 未开放能力的诚实呈现 + +| 能力 | 页面呈现 | 后端事实 | +|---|---|---| +| 房间新增 | 禁用按钮 + tooltip | `POST /apt/room` 恒 `40940 ROOM_ADD_DISABLED` | +| 交路 / 房间删除 | 禁用按钮 + tooltip | 后端无删除接口,业务上以「停用」代替 | +| 交路 / 房间导入 | 禁用按钮 + tooltip | 后端无 `/importData`,主数据须走平台下行 | +| 主数据重新同步 | 禁用按钮 + tooltip | `POST /apt/master/{masterType}/resync` 恒 `40940 MASTER_RESYNC_DISABLED` | +| 导出 | **可用**,前端 CSV(UTF-8 BOM) | 后端未提供 `/export`,导出当前查询/筛选结果,不伪造后端调用 | + +表 16-29 未开放能力与禁用态清单 + +### 10.6 验证 + +| 验证项 | 结果 | +|---|---| +| 生产构建 | `NODE_OPTIONS=--openssl-legacy-provider npm run build:prod`(Node 22.18.0)→ `DONE Build complete`,`exitCode=0`;仅既有 asset / entrypoint size 警告 | +| 构建产物 | `dist/static/js` 命中 `/apt/route/list`、`/apt/room/status-summary`、`/apt/master/versions`、`/apt/ops/connectivity` | +| SQL 重放 | `apply-it2-sql.ps1 -DryRun` 列出 `10`/`11`/`12`/**`13`** 四个文件;实库重放 `exitCode=0` | +| 菜单树 | `M=1 / C=4 / F=15`;超管关联 `C` 菜单 `4`;置灰项 `3004~3006 / 3014` 关联数 `0`;M0 验收账号公寓权限 `0` | +| 静态诊断 | `read_lints` 对 `RuoYi-Vue2-master/src` 全域 `totalCount: 0` | + +表 16-30 前端补齐验证结果 + +> 本轮**未**新增后端接口、**未**改动 59 张业务表、**未**启用 P2 账务域;`sys_menu` 只新增 4 条 `C` 记录与超管关联, +> 既有 `F` 权限点、置灰策略、M0 验收账号全部保持不变。 + diff --git a/docs/公寓端/17-第四轮开发(IT-4)交付说明.md b/docs/公寓端/17-第四轮开发(IT-4)交付说明.md new file mode 100644 index 0000000..400e1b9 --- /dev/null +++ b/docs/公寓端/17-第四轮开发(IT-4)交付说明.md @@ -0,0 +1,128 @@ +# 17 第四轮开发(IT-4)交付说明:入住作业核心 + +> 版本:IT-4 / DV-3 后续 | 范围:`公寓端/11-人员入住作业流程` 的 CI-02 ~ CI-05 | 依据:`13-公寓端小步迭代开发计划` 表 13-14、CR-02 表 3-57 验收 18~20。 + +## 1. 范围与约束 + +**本期交付**:入住选项(N-39)、入住申请(N-40)、申请查询(N-41)、待审列表(N-42)、审核详情(N-43)、审核与办理入住(N-44)、审核期换房(N-45)、审核时间线(N-46)、入住与叫乘任务查询(N-47),以及 `#9 CHECKIN` 入队、`#7 matchVerdict` 落库与 `jcsj` 复算、`#8 TASK_ACK` 入队。 + +**明确不做**:账务冻结(40940)、退宿与退宿换房(IT-5)、设备下发与可见范围(IT-7)、`#7` 的 HTTP 拉取器(IT-6)。入住链路只到「任务就绪」,不产生叫乘执行动作。 + +**硬约束**: +1. `gyid` 只来自部署环境注入(`AptContext`),任何接口不接受请求参数携带 `gyid`。 +2. 申请与审核都不写 `gy_jcxx.ryzt` / `finish_ly`;这两个字段只能经 `apt.duty.state` 语义写入。 +3. 平台回执不决定本地生效:`verdict` 只影响同步态展示,生效点恒为值班员审核。 +4. 59 张业务表不变,本期只做 `apt_local_task` 补列与字典扩展。 + +## 2. 交付清单 + +| 层 | 文件 | 说明 | +| --- | --- | --- | +| SQL | `dev/sql/apt_rev01/17_it4_columns.sql` | `apt_local_task` 补 27 列、2 个幂等唯一键、`checkin_state` 8 值 / `sync_state` 5 值 / `event_type` 增补 `ROOM_SWAP` | +| SQL | `dev/sql/apt_rev01/18_it4_permissions.sql` | 启用 3005、新增 3202 页面与 3217~3219 权限点、超管关联 | +| SQL | `dev/sql/apt_rev01/99_verify.sql` 第 14~19 段 | 增量列、字典、权限点、任务就绪、孤儿与泄漏校验 | +| SQL 脚本 | `dev/scripts/apply-it4-sql.ps1` | 凭据取 `.env`,UTF-8 流式投喂,可重放 | +| 后端 | `ruoyi-apt/.../stay/checkin/**` | domain / mapper / service / controller 共 20 个类 + 1 个 XML | +| 后端测试 | `stay/checkin/AptCallTimeCalculatorTest`、`AptCheckinServiceTest`、`AptCheckinMatchServiceTest` | 28 个用例 | +| 后端测试 | `framework/AptIt4SqlContractTest` | 5 个 SQL 契约用例 | +| 前端 | `src/api/apt/checkin.js` | N-42~N-47 六个接口封装 | +| 前端 | `src/views/apt/checkin/index.vue` | 台账 + 详情 + 审核 + 换房 + 时间线 + 叫乘任务 | +| 前端 | `src/utils/apt.js` | 流程态 / 同步态 / 匹配态字典 + 40926~40932 文案 | + +## 3. 接口说明 + +统一约定:`produces=application/json;charset=UTF-8`;列表分页参数 `pageNum/pageSize`,返回 `rows/total/warnings/traceId`;错误体为 `{code, reason, message, traceId}`。 + +### 3.1 设备侧(`/open/v1/**`,匿名 + 设备四要素) + +| 编号 | 方法与路径 | 请求 | 响应 | 关键错误 | +| --- | --- | --- | --- | --- | +| N-39 | `GET /open/v1/checkin/options?version=` | 版本号可空 | `version/serverTime/notModified/ttlSeconds/source/rooms[]/routes[]` | 40303 | +| N-40 | `POST /open/v1/checkin/applications` | `deviceEventId, ryid, fjh, regId?, jlid?, cc?, jch?, source?, occurredAt?, verifiedAt?, featureVersion?` | `applyId/jcid/checkinState/syncState/origin/bizVersion/duplicated/warnings[]` | 40914 注册未确认、40927 特征版本落后、40929 核验超 5 分钟、40928 房间不可住、40932 已在住、40931 交路非法、40401 人员不存在 | +| N-41 | `GET /open/v1/checkin/applications/{applyId}` | — | 申请单投影(含 `bizVersion`) | 40401 | + +### 3.2 值班台(`/duty/checkin/applications`) + +| 编号 | 方法与路径 | 权限点 | 请求 | 响应 | 关键错误 | +| --- | --- | --- | --- | --- | --- | +| N-42 | `GET /duty/checkin/applications?state=&workNo=&name=` | `duty:checkin:list` | `state` 缺省 `SUBMITTED` | 分页列表 | — | +| N-43 | `GET /duty/checkin/applications/{applyId}` | `duty:checkin:list` | — | 申请单 + `roomCandidates[]` + `warnings[]`;首次查看把 `SUBMITTED` 条件迁移为 `REVIEWING` | 40401 | +| N-44 | `POST /duty/checkin/applications/{applyId}/review` | `duty:checkin:review` | `verdict(APPROVE/APPROVE_WITH_ROOM_CHANGE/REJECT), expectedBizVersion, targetFjh?, reviewNote?, commandId?` | `checkinState/syncState/fjhFinal/checkedIn/uploadState/reportOutboxId/warnings[]` | 40926 版本或状态过期、40928 房间不可住、40001 驳回未填说明 | +| N-45 | `POST /duty/checkin/applications/{applyId}/room-adjust` | `duty:checkin:adjust` | `targetFjh, expectedBizVersion, reason?, commandId?` | `fjhFinal/roomAdjustSeq/originRoomStatus/targetRoomStatus/sipBound/warnings[]` | 40926(含已入住 `ALREADY_CHECKED_IN`、同房 `ROOM_UNCHANGED`)、40928 | +| N-46 | `GET /duty/checkin/applications/{applyId}/timeline` | `duty:checkin:list` | — | `checkinState/syncState/matchState/events[]{state,fromState,at,actor,commandId,note}` | 40401 | + +### 3.3 管理端(`/apt/checkin`) + +| 编号 | 方法与路径 | 权限点 | 响应 | 说明 | +| --- | --- | --- | --- | --- | +| N-47 | `GET /apt/checkin/{jcid}/call-task` | `apt:checkin:query` | `applyId/jcid/checkinState/syncState/matchState/fjhFinal/jcsj/jcfs/taskReady/task{...}` | `task=null` 表示未就绪;查无此入住才返回 40401 | + +## 4. 关键实现要点 + +### 4.1 状态机与事务边界 + +``` +SUBMITTED -(N-43 接单)-> REVIEWING -(N-44 APPROVE)-> APPROVED -(同事务)-> CHECKED_IN +REVIEWING/APPROVED -(N-44 换房通过 / N-45)-> ROOM_ADJUSTED -(N-44 APPROVE)-> CHECKED_IN +REVIEWING/APPROVED/ROOM_ADJUSTED -(N-44 REJECT)-> REJECTED(终态) +``` + +`AptCheckinServiceImpl#checkIn` 一个事务内完成:条件占床(`state_version` 乐观锁 + `in_person_count < bed_count`)→ 房态重算 → `gy_jcxx` 建行(`call_mode` 按是否存在有效 SIP 绑定取 `SIP`/`TABLET`)→ `gygl_ly_event` 记 `CHECKIN` → 流程态迁 `CHECKED_IN` → `#9` 入队 → `sync_state=REPORTED` → 操作日志。任一步返回 0 行即抛异常整体回滚,不会出现「已占床但无叫乘记录」。 + +### 4.2 三条状态链相互独立 + +- `checkin_state`(流程态):只由 N-43/N-44/N-45 推进。 +- `sync_state`(同步态):`PENDING → REPORTED → MATCHED/UNMATCHED → TASK_READY`,只由出站与 `#7` 结论推进。 +- `match_state`(匹配态):只由 `#7` 结论写入,平台拒绝不影响本地已完成的入住事实。 + +### 4.3 叫乘时刻复算(D8 / R-48) + +`AptCallTimeCalculator` 为纯函数:`jcsj = kcsj - advance`(节假日取 `holidayAdvanceMinutes`)、`fjsj = jcsj + 10min`、`jcbjsj = jcsj + 15min`、`ylysj = jcsj + lysjgz`(缺省 30)。不变式 `jcsj ≤ fjsj ≤ jcbjsj < kcsj` 不成立即 40931,不静默钳制;规则版本写入 `derived_rule_version`,规则升级不回溯。 + +`IAptCheckinMatchService#applyVerdict` 是 `#7` 结论的唯一落库入口:`MATCHED/LOCKED` 复算并置 `TASK_READY`、写 `gy_jcxx.jcsj`、入队 `#8 TASK_ACK`;`UNMATCHED/REJECTED` 保留本地要素、不生成任务、不清空既有任务;同结论重复提交幂等返回。 + +### 4.4 换房语义 + +审核期换房只变更意向房号(`fjh_final`)、`room_adjust_seq+1` 与留痕,**不动 `in_person_count`**——申请阶段床位并未占用,若此时 -1/+1 会释放他人床位。床位在 `checkIn` 时按 `fjh_final` 占用。已 `CHECKED_IN` 后换房返回 40926,走 IT-5 退宿换房。 + +### 4.5 幂等与并发 + +- 申请:`uk_lt_gyid_device_event (gyid, device_code, device_event_id)` + 捕获 `DuplicateKeyException` 回查,重复提交返回原 `applyId`。 +- 审核/换房:`biz_version` 条件更新,0 行即 40926;`room_adjust_seq` 单调递增供 `#9` 的 `bizId` 分段。 +- 出站:`idempotency_key = md5(gyId|bizType|bizId|bizVersion)`,命中 `uk_outbox_gyid_idem` 不产生第二条。 + +### 4.6 安全 + +`/open/v1/**` 仅设备四要素可入;`/duty/**` 走权限点 + `@AptAudit`;`/apt/**` 管理端只读。所有 SQL 显式带 `gyid`(架构测试 `AptMapperGyidArchTest` 强制)。 + +## 5. 部署与使用注意 + +1. **执行顺序**:`00_common.sql`(辅助存储过程已存在则跳过)→ `14/15/16`(IT-3)→ `17_it4_columns.sql` → `18_it4_permissions.sql` → `99_verify.sql`。可用 `powershell -ExecutionPolicy Bypass -File dev/scripts/apply-it4-sql.ps1`(失败即中断,可重复执行)。 +2. **CHECK 约束重建**:`17` 会先 `DROP CHECK` 再按新字典重建。若库中已有 `checkin_state` 为 `AUDITED` / `CANCELLED` 之外旧值的数据,重建会失败——上线前需先 `SELECT checkin_state, COUNT(*) FROM apt_local_task GROUP BY 1` 确认。 +3. **函数点激活**:执行后需重新登录(或清前端路由缓存)才能看到「入住审核」菜单;`duty:checkin:list` 由置灰变为可用。 +4. **配置**:无新增配置项。`#9`/`#8` 仅入 outbox,投递依赖既有上报器;未启用上报器时任务停在 `REPORTED`,属预期。 +5. **验收演示路径**:设备侧 `POST /open/v1/checkin/verify` 取 `ryid` → `POST /open/v1/checkin/applications` → 值班台打开详情(自动接单)→ 审核通过 → `SELECT jcid FROM apt_local_task` 取 `jcid` → 调 `IAptCheckinMatchService#applyVerdict(MATCHED)` → `GET /apt/checkin/{jcid}/call-task` 应返回 `taskReady=true` 与复算后的 `jcsj`。 +6. **回滚**:本模块只新增列与行,不回写主数据。回滚应用版本即可;`apt_local_task` 新增列可保留(均为可空或带默认值),`CHECK` 如需回退按 `17` 的反向语句执行。 + +## 6. 验收标准 + +| 验收点 | 判定方式 | +| --- | --- | +| 增量列与字典 | `99_verify.sql` 第 14/15 段:27 列、`checkin_state` 8 值、`sync_state` 5 值、`ROOM_SWAP` | +| 权限点与菜单 | 第 16 段 5 行(3005/3202/3217~3219),`still_grey=0`,菜单树 M1/C6/F24 | +| 任务就绪必须有 jcsj | 第 17 段 `it4_task_ready_without_jcsj = 0` | +| 已入住必须有叫乘行 | 第 18 段 `it4_checked_in_orphan = 0` | +| 未入住不得提前建行 | 第 19 段 `it4_checkin_leak = 0` | +| 单测 | `AptCheckinServiceTest` 15 + `AptCheckinMatchServiceTest` 7 + `AptCallTimeCalculatorTest` 6 + `AptIt4SqlContractTest` 5,全绿 | +| 前端可用 | `npm run build:prod` 通过;页面四类操作(详情/审核/换房/时间线)与 N-47 查询均可完成 | + +## 7. 偏离与遗留登记 + +| 编号 | 事项 | 现状 | 理由 / 后续 | +| --- | --- | --- | --- | +| D-1 | N-42 分页参数 | 用 `pageNum/pageSize` 而非文档的 `cursor/limit` | 与 IT-1/IT-3 全站分页一致,避免两套分页组件;如需游标由 IT-6 统一改造 | +| D-2 | N-45 允许 `REVIEWING` | 表 11-18 仅列 `APPROVED/ROOM_ADJUSTED` | 审核期直接换房是值班台主路径,等价于 N-44「换房后通过」的前半段,已限制不得跨越 `CHECKED_IN` | +| D-3 | 审核期换房不动房态计数 | 表 11-18 列有 `gy_rooms` 变更 | 申请期床位未占用,-1/+1 会释放他人床位;床位在 `checkIn` 时占用 | +| D-4 | `duty_manual_action` 未写 | 时间线取 `duty_operation_log`(`module=CHECKIN`) | 人工命令台账由 IT-5 命令层统一写入,避免两处语义 | +| D-5 | `#7` 无 HTTP 拉取 | 只提供 `IAptCheckinMatchService` | 仓库无平台客户端;IT-6 接入时直接调用,逻辑与幂等已覆盖 | +| D-6 | `jllx` 仅由 `LOCKED` 置 1 | 无「解锁」接口 | 解锁属 IT-6 `UNLOCK_MATCH` 命令 | diff --git a/docs/公寓端/18-第五轮开发(IT-5)交付说明.md b/docs/公寓端/18-第五轮开发(IT-5)交付说明.md new file mode 100644 index 0000000..4eb5274 --- /dev/null +++ b/docs/公寓端/18-第五轮开发(IT-5)交付说明.md @@ -0,0 +1,217 @@ +# 18 第五轮开发(IT-5)交付说明:叫乘执行与状态机 + +> 迭代来源:`13-公寓端小步迭代开发计划.md` 表 13-5 `IT-5`、表 13-15 验收标准 +> 里程碑:`M3`(叫乘信息管理),见 `07-实施计划与验收.md` 表 7-2 / 表 7-3 +> 完成日期:2026-11-11 + +## 1. 范围与约束 + +### 1.1 本期交付 + +| 项 | 内容 | +| --- | --- | +| 状态机唯一写入口 | `apt.duty.state`:`gy_jcxx` 的 `ryzt` / `finish_jc` / `finish_ly` / `stoped` 只有一处可写 | +| 受控命令 | `STOP` / `RE_CALL` / `FORCE_FINISH` / `MARK_LEAVE` / `CANCEL`,全部先落 `duty_manual_action`(`commandId` 幂等) | +| 叫乘台账 | `/apt/jcxx` 列表 / 详情 / 时间线 / 白名单修正 / 命令 / 状态分布(04 表 4-9 六个接口) | +| 平板链路 | N-03 拉任务、N-05 ACK、N-04 结果上报;`TABLET_RESULT` 幂等 + 迟到记 `IGNORED` | +| 最小 SIP 适配 | AMI 事件 → 状态机命令映射;平板不可用回落 SIP(CR-C23);30s 无入会转报警 | +| 扫描任务 | 平板任务过期回收、补生成任务、SIP 超时;Quartz `aptDispatchTask.scan()` | +| 前端 | `/apt/jcxx` 叫乘台账页(列表 + 详情抽屉 + 时间线 + 命令/修正弹窗) | + +### 1.2 约束(红线) + +1. **不新增契约**:不新增跨端接口编号、不新增 `bizType`、不新增 WS 消息名、不新增业务表(仍 59 张)。 +2. **管理端无「编辑状态」入口**:`PUT /apt/jcxx/{id}` 不存在;`correct` 请求体带 `ryzt` / `finishJc` / `finishLy` / `stoped` 一律 `40001 STATE_FIELD_READONLY`。 +3. **一条 `jcid` 只有一种事实来源**:由 `gy_jcxx.call_mode` 标记;平板链路**不发送 #24** 语音质量摘要。 +4. **平板链路只收识别结论**:`resultId` / `faceVerifyState` / `resultType` / `personCount`,不接收抓拍帧、人脸原图、比对帧。 +5. **实现依据取自已修订文档**:`03` 表 3-10 新增的平板/`RE_CALL`/`CANCEL` 迁移行、`05` 表 5-20 的 `STOP`(1,2)/`CANCEL`(0) 与「超窗口由平板上报」口径。 + +## 2. 交付清单 + +### 2.1 后端(`dev/RuoYi-Vue-master/ruoyi-apt`) + +| 文件 | 说明 | +| --- | --- | +| `dispatcher` 域:`dispatch/domain/AptDutyCommand.java` | 命令请求(`commandId` / `command` / `reason` / `leaveType`) | +| `dispatch/domain/AptCorrectCommand.java` | 修正请求;状态字段单独声明以便识别并拒收 | +| `dispatch/domain/AptDutyCommandResult.java` | 命令结果(`accepted` / `duplicated` / `stateAfter` / `actionId`) | +| `dispatch/domain/AptJcxxQuery.java` | 列表过滤条件 | +| `dispatch/domain/AptStateSnapshot.java` | 状态快照(`isTerminal()` 判终态) | +| `dispatch/mapper/AptDutyStateMapper.java` + `mapper/apt/AptDutyStateMapper.xml` | **唯一写入口**:9 条条件更新 + 会话/离寓事件插入 | +| `dispatch/mapper/AptJcxxMapper.java` + `mapper/apt/AptJcxxMapper.xml` | 读侧 + 白名单修正 + 命令流水 + AMI 事件 | +| `dispatch/service/AptDutyStateServiceImpl.java` | 状态机:未命中 → `40902` | +| `dispatch/service/AptDutyCommandServiceImpl.java` | 命令层:幂等 → 前置校验 → 提交状态机 → 结果回写 + 审计 | +| `dispatch/service/AptJcxxServiceImpl.java` | 列表 / 详情 / 时间线 / 修正 / 统计 | +| `dispatch/service/AptDispatchScanServiceImpl.java` | 扫描任务;调度线程自建 `AptContext` | +| `dispatch/controller/AptJcxxController.java` | `/apt/jcxx/**` 六个接口 + `@PreAuthorize` 权限点 | +| `dispatch/tablet/domain/*` | `AptTabletTask`、`AptTabletResultCommand` | +| `dispatch/tablet/mapper/AptTabletMapper.java` + `mapper/apt/AptTabletMapper.xml` | 任务/结果/outbox;只写 `dispatch_seq` 不写状态 | +| `dispatch/tablet/service/AptTabletDispatchServiceImpl.java` | 生成 / 拉取 / ACK / 过期 | +| `dispatch/tablet/service/AptTabletResultServiceImpl.java` | 结果幂等 + 终态映射 + #9/#12 入 outbox | +| `dispatch/tablet/controller/AptTabletOpenController.java` | `/open/v1/tablet/**`(设备四要素鉴权) | +| `voice/domain/AptSipEventCommand.java`、`voice/service/AptVoiceEventServiceImpl.java` | 最小 SIP 适配 + 回落判定 | +| `com/ruoyi/quartz/task/AptDispatchTask.java` | Quartz 入口(**放在 `com.ruoyi.quartz.task` 包**,见 §4.7) | +| `framework/config/AptProperties.java` | 新增 `features`(`tablet-call`、`voice-summary`)与 `dispatch`(5 个参数 + TTS) | +| `resources/apt-defaults.yml` | 新增配置项与环境变量映射 | + +### 2.2 SQL(`dev/sql/apt_rev01`) + +| 文件 | 说明 | +| --- | --- | +| `19_it5_columns.sql` | `gy_jcxx` 补 12 列(11 个时间戳/标志位 + `jch`)、`duty_manual_action.reason`、动作枚举注释、2 个索引;全程幂等 | +| `20_it5_permissions.sql` | 启用 `3004`、新增 `3203` 页面与 `3220/3221/3222` 权限点、超管关联、`sys_job` 103 叫乘扫描(默认停用) | +| `99_verify.sql` | 追加第 20~26 段:增量列、权限点、平板字典 CHECK、终态一致性、已结束不回退、DONE 任务必有结果、平板无 #24 | + +### 2.3 前端(`dev/RuoYi-Vue2-master`) + +| 文件 | 说明 | +| --- | --- | +| `src/api/apt/jcxx.js` | 6 个接口封装;**无 update 方法** | +| `src/views/apt/jcxx/index.vue` | 台账列表、状态分布、详情抽屉 + 时间线、命令弹窗、修正弹窗 | +| `src/utils/apt.js` | 新增 `aptRyztOptions` / `aptCallModeOptions` / `aptDutyCommandOptions` / `aptTimelineSourceOptions` | + +### 2.4 脚本与测试 + +| 文件 | 说明 | +| --- | --- | +| `dev/scripts/apply-it5-sql.ps1` | 复用 `.env` / `APT_DB_*`,按字节流喂 `mysql`,支持 `-DryRun` | +| `ruoyi-apt/src/test/java/com/ruoyi/apt/dispatch/AptDutyStateServiceTest.java` | 6 例 | +| `.../dispatch/AptDutyCommandServiceTest.java` | 7 例 | +| `.../dispatch/AptJcxxServiceTest.java` | 9 例 | +| `.../dispatch/AptTabletResultServiceTest.java` | 9 例 | +| `.../framework/AptIt5SqlContractTest.java` | 5 例 | +| `.../framework/AptDutyStateSingleWriterArchTest.java` | 3 例(架构约束) | + +## 3. 接口说明 + +### 3.1 设备侧(`/open/v1/tablet/**`,设备四要素鉴权) + +| 接口 | 行为 | +| --- | --- | +| `GET /open/v1/tablet/tasks?cursor&limit` | 按 `dispatch_seq` 升序返回 `WAITING`/`PULLED` 任务;含 `serverTime`、`clockSkewThresholdMs`、`signTimeoutSec`、`faceVerifyRequired`、TTS 模板 | +| `POST /open/v1/tablet/tasks/{taskId}/ack` | 条件更新 `WAITING → PULLED`;未命中(重复/过期/已绑其他平板)返回 `40916` | +| `POST /open/v1/tablet/results` | `resultId` 幂等;`(gyid, result_id)` 唯一键冲突时返回首次结果 | + +要点:**拉取 ≠ 执行**,服务端不下发「立即呼叫」指令;平板按本地定时器在 `jcsj` 播放提示音。 + +### 3.2 管理端(`/apt/jcxx`,表 4-9) + +| 接口 | 权限点 | 说明 | +| --- | --- | --- | +| `GET /apt/jcxx/list` | `apt:jcxx:list` | `ryid/rymc/fjh/cc/jlid/ryzt[]/finishJc/finishLy/stoped/isTempCall/jcsjFrom/jcsjTo` | +| `GET /apt/jcxx/statistics` | `apt:jcxx:list` | 6 状态 + 已结束 + 总数;`date` 缺省为全量 | +| `GET /apt/jcxx/{id}` | `apt:jcxx:query` | 主表 + 最近会话 + 录音索引 + 人工命令 | +| `GET /apt/jcxx/{id}/timeline` | `apt:jcxx:query` | 5 类来源合并,按 `atMillis` 升序 | +| `POST /apt/jcxx/{id}/correct` | `apt:jcxx:correct` | 白名单;改状态 `40001`;已离寓禁止改房间/交路 `40902` | +| `POST /apt/jcxx/{id}/commands` | `apt:jcxx:changeStatus` | 5 命令;`commandId` 幂等;非法状态 `40902` | + +### 3.3 内部服务(不暴露为接口) + +- `IAptDutyStateService`:`callStart / answered / callFailed / stop / recall / forceFinish / markLeave / cancel`。 +- `IAptVoiceEventService.handle()`:AMI 事件 → 命令;`resolveCallMode()`:平板不可用回落 `SIP`。 +- `IAptDispatchScanService.scan()`:由 Quartz 调用。 + +## 4. 关键实现要点 + +### 4.1 状态机唯一写入口(DV-5) + +- 全仓 `UPDATE gy_jcxx SET ryzt/finish_jc/finish_ly/stoped` **只出现在** `AptDutyStateMapper.xml`,由 `AptDutyStateSingleWriterArchTest` 静态守卫。 +- 每条 UPDATE 都带 `gyid + jcid + 期望态`,未命中返回 0 行 → 服务层 `40902 STATE_TRANSITION_ILLEGAL`;并发下不会出现「后写覆盖先写」。 +- 全部迁移递增 `biz_version`,为后续跨端版本对照与 outbox 幂等键提供依据。 + +### 4.2 双模式与事实来源唯一 + +- `call_mode='TABLET'` 的记录,`ryzt` 迁移只由 N-04 上报触发;**不做服务端定时自动迁移**(含签到窗口超时,由平板上报 `NO_ANSWER`)。 +- `call_mode='SIP'` 的 30s 无入会由扫描任务提交 `CALL_FAILED` 命令,仍是「提交命令」而非直写。 +- `resolveCallMode()` 在 `features.tablet-call=false` 或无 `ACTIVE` 平板时返回 `SIP`(CR-C23 回落)。 + +### 4.3 平板终态矩阵(表 5-20 逐条实现) + +| 上报 | 状态机动作 | `applied_state` | 上行 | +| --- | --- | --- | --- | +| `ANSWERED` + `VERIFIED` | `ANSWERED` → `ryzt=3`、`finish_jc=1` | `APPLIED` | #9 | +| `ANSWERED` + `UNVERIFIED`/`SKIPPED` | **不提交状态命令**(保持待确认)+ 复叫提示 | `APPLIED` | #9 + #12(`alarmSource=CALL`) | +| `NO_ANSWER` / `FAILED` | `CALL_FAILED` → `ryzt=2` | `APPLIED` | #9 | +| `CANCELLED` | `ryzt in (1,2)` → `STOP`;`ryzt=0` → `CANCEL` | `APPLIED` | #9 | +| 迟到 / 重复 / 状态冲突 | 不回写、不重复上报 | `IGNORED` | — | + +### 4.4 命令幂等与审计 + +- `commandId` → `duty_manual_action(action_id)` 唯一键;重复提交只置 `duplicated=1 / state='DUPLICATED'`,返回 `duplicated=true`,**不产生第二次迁移**。 +- 三层记录:`duty_manual_action`(命令意图与结果,可重试)、`duty_operation_log`(审计事实,`detail.jcid` 可回查)、`gy_jcxx` 状态列(事实)。 +- `STOP` 在 `finish_ly='1'` 上提交 → `40902`;`RE_CALL` 额外新增一行 `apt_call_session`(表 3-10 复叫语义)。 + +### 4.5 时间线装配 + +五路来源合并后按 `atMillis` 升序:状态时间戳(`jcsj`/`jcsj_s`/`jcbjsj_s`/`fjsj_s`/`hjxysj`/`lybjsj`/`sjlysj`)、`apt_call_event`(AMI)、`duty_manual_action`(人工命令)、`duty_operation_log`(审计,`JSON_EXTRACT(detail,'$.jcid')`)、`apt_tablet_call_result`(平板结果)。 + +### 4.6 平板派发 + +- `dispatch_seq` 单调:`MAX(dispatch_seq)+1` 逐条分配,同时回写 `gy_jcxx.dispatch_seq`;任务 `taskId = TT-{jcid}-{seq}`。 +- 过期只置 `EXPIRED`,不重派;`DONE` 任务必须存在结果行(`99_verify.sql` 第 25 段校验)。 +- 结果上行 outbox:`idempotencyKey = sha256(gyid|bizType|bizId|bizVersion)`,`bizType` 仅用 `TABLET_RESULT` / `DEVICE_ALARM`。 + +### 4.7 扫描任务与 Quartz 白名单 + +若依 `ScheduleUtils.whiteList` 只允许 `com.ruoyi.quartz.task` 前缀的 `invokeTarget`。为避免扩白名单,Job bean 仍放在该包(物理位于 `ruoyi-apt` 模块),委托 `IAptDispatchScanService`,因此 `ruoyi-quartz` 无需依赖 `ruoyi-apt`。 +扫描线程无 HTTP 上下文,`AptDispatchScanServiceImpl` 进入时用 `apt.gy-id` 建立 `AptContext`,`finally` 清理。 + +## 5. 部署与使用注意 + +1. **SQL 顺序**:`19_it5_columns.sql` → `20_it5_permissions.sql` →(可选)`99_verify.sql`;可用 + `powershell -ExecutionPolicy Bypass -File dev/scripts/apply-it5-sql.ps1`(先 `-DryRun` 自查)。 +2. **菜单/权限**:`3004 apt:jcxx:list` 由 IT-1 预置,本期改为启用;新增 `3203` 页面菜单,组件路径 `apt/jcxx/index` 必须与 `src/views/apt/jcxx/index.vue` 一致。 +3. **配置项**(`apt-defaults.yml`):`apt.features.tablet-call`、`apt.dispatch.sign-timeout-sec`(60)、`clock-skew-threshold-ms`(1000)、`sip-no-answer-seconds`(30)、`recall-seconds`(35)、`tablet-task-ttl-seconds`(900)。 +4. **定时任务**:`sys_job` 103 `aptDispatchTask.scan()`,cron `0 * * * * ?`,**默认停用**;需确认 `ScheduleConfig` 已启用后再手工启动。 +5. **设备侧**:`/open/v1/tablet/**` 复用 IT-3 的 HMAC + nonce + 时间窗校验,平板须携带 `X-Device-Code`;`apt_tablet_device.register_status` 必须是 `ACTIVE`。 + +### 5.1 执行记录(2026-11-11,`apt_rev01`) + +按上述顺序实际执行完毕,凭据取自 `dev/.env`(`APT_DB_*`),未打印口令: + +| 步骤 | 命令 | 结果 | +| --- | --- | --- | +| 0 | `apply-it5-sql.ps1 -DryRun` | 预检通过:目标 `127.0.0.1:3306/apt_rev01`,2 个脚本(111 / 97 行) | +| 1 | `19_it5_columns.sql` | 成功:`gy_jcxx` 增量列 **12/12**、`duty_manual_action` 三列(`action_type` / `state` / `reason`)、索引 2 个(`idx_jcxx_gyid_mode_state`、`idx_tabletresult_gyid_time`)、状态字段非法值 0 行 | +| 2 | `20_it5_permissions.sql` | 成功:菜单 **5 行**(`3004` 已启用 + `3203/3220/3221/3222`)、`3203` 组件路径 `apt/jcxx/index`、超管关联 5、菜单树 M=1 / C=7 / F=27、置灰残留 0 | +| 3 | `99_verify.sql` | 第 20~26 段全部通过:增量列 12、权限点 5、平板 CHECK 3、`ryzt=3` 缺完成 0、已结束回退 0、DONE 无结果 0、平板 #24 泄漏 0 | + +执行中发现并修复的问题(已回写脚本):`apt_add_column_if_missing` 的过程体只拼 `ALTER TABLE \`t\` ADD COLUMN `,`p_ddl` 必须自带列名;首版漏列名导致 `ERROR 1064 near '(3) ...'`,补齐列名后重放通过(脚本本身幂等,可直接重跑)。 + +## 6. 验收标准 + +### 6.1 与 07 表 7-3(M3)对应 + +| M3 验收项 | 落地 | 证据 | +| --- | --- | --- | +| 只读字段不可改,`PUT /apt/jcxx/{id}` 不存在 | `AptJcxxController` 无 PUT;`correct` 拒绝状态字段 | `AptJcxxServiceTest#correctRejectsStateFieldsWith40001` | +| `STOP` 同 `commandId` 幂等 | `duty_manual_action` 唯一键 + `duplicated` 分支 | `AptDutyCommandServiceTest#sameCommandIdIsIdempotentAndDoesNotTransitionAgain` | +| 对 `finish_ly='1'` 记录 `STOP` → `40902` | `checkAllowed` + 条件更新双保险 | `AptDutyCommandServiceTest#stopOnLeftRecordReturns40902` | +| 时间线按时间升序 | 五路合并 + `atMillis` 排序 | `AptJcxxServiceTest#timelineMergesFourSourcesInAscendingOrder` | + +### 6.2 与 13 表 13-15(IT-5)对应 + +| 验收点 | 证据 | +| --- | --- | +| `TABLET_RESULT` 重复不产生二次终态 | `AptTabletResultServiceTest#duplicateResultIdReturnsFirstResultWithoutTransition` | +| `UNVERIFIED` 保持待确认 + 告警(不自动完成) | `#unverifiedKeepsStateAndRaisesAlarmWithoutVoiceSummary` | +| `NO_ANSWER` → `ryzt=2` | `#noAnswerSubmitsCallFailed` | +| 迟到结果 `IGNORED` | `#lateResultOnFinishedCallIsIgnored`、`#stateConflictDuringTransitionIsRecordedAsIgnored` | +| 平板链路不发 #24 | 同上断言 + `99_verify.sql` 第 26 段 | +| 状态字段单写入口 | `AptDutyStateSingleWriterArchTest`(3 例) | + +### 6.3 回归 + +`mvn -o -pl ruoyi-apt -am test`:**189 例全部通过**(新增 39 例,覆盖 `dispatch` 4 类 + SQL 契约 + 架构约束);前端 `eslint` 无新增告警。 + +## 7. 偏离与遗留登记 + +| 编号 | 类型 | 内容 | 处理 | +| --- | --- | --- | --- | +| IT5-L1 | 文档补充 | `gy_jcxx` 缺 `jch` 列,但表 4-9 correct 白名单含「机次」 | `19_it5_columns.sql` 补列,`03` §3.2 表 3-8 待同步补记 | +| IT5-L2 | 实现取舍 | 拉取响应 `clockSkewMs` 返回 `null`,只给 `serverTime` + 阈值 | 服务端没有设备时钟基准,避免伪造校准依据;平板按 `serverTime` 自行比对 | +| IT5-L3 | 接口边界 | 未新增 AMI 接入端点与 Asterisk 连接,`IAptVoiceEventService` 仅供服务内部调用 | 归 IT-7(语音与运维);本期只保证映射与回落逻辑 | +| IT5-L4 | 调度依赖 | `sys_job` 登记但默认停用,`ScheduleConfig` 是否启用未在本期验证 | 联调步骤 9 前确认,避免「任务存在但不跑」 | +| IT5-L5 | 数据归日 | 平板结果 #9 的 `occurredAt` 取设备上报值,平台侧 `serverReceivedAt` 归日 | 与 IT-3 outbox 一致,沿用既有字段 | +| IT5-L6 | 未联调 | 平板真机、HMAC 双向联调、断网续拉(cursor 重放)未做实机验证 | 待设备到位后补 `M5` 前联调,验收脚本已就绪 | +| IT5-L7 | 遗留字段 | `11` 表 11-13 的 `regState` 仍含 `NOT_FOUND`,与 `10` 表 10-11 不同字典 | 待裁决后统一(本迭代未动) | diff --git a/docs/公寓端/README.md b/docs/公寓端/README.md index cb01a03..b83d294 100644 --- a/docs/公寓端/README.md +++ b/docs/公寓端/README.md @@ -20,7 +20,7 @@ | `README.md` | 索引、编号约定、全局对应关系、一页结论、与 `docs/平台端/` 的编号对应 | 输出形式 | | `00-公寓端主要功能设计.md` | **需求基线**:14 项功能编号化 `A-01`~`A-14`、主责端 / 协同端 / 本期判定 | CR-02 输入 | | `01-功能边界.md` | 定位与职责、三端五维分界、**16 个功能域清单**(§0 为 v2 增量)、本期不做、待决策对边界的影响 | 文档一 + CR-02 | -| `02-技术基线与工程结构.md` | 版本锁定、12 模块职责与依赖、前端双入口边界、分层与编码规范、路由与权限点 | 文档二 1~4 | +| `02-技术基线与工程结构.md` | 版本锁定、12 模块职责与依赖、前端双入口边界、分层与编码规范、路由与权限点;**§2.5 实现层 `ruoyi-apt` 集中模块**:职责范围、结构定位、模块关系图(图 2-3)、构建识别与前端落地口径(单入口动态路由、偏离 16-19) | 文档二 1~4 | | `03-数据设计.md` | 表归属矩阵(**59 张口径**)、语音域字段与注册状态流水、命名分区、状态单写入口、`gyid` 隔离、逻辑删除与审计、缺口与建议 | 文档二 5 + CR-02 §6、§10 | | `04-接口设计.md` | 通用约定与错误码、分域接口清单、设备侧 `/open/v1/tablet/**`、四个核心域映射;§0 表 C-1~C-8 为 CR-02 增量接口与错误码(N-15~N-58、40913~40940) | 文档二 6 + CR-02 §3、§10~§14 | | `05-关键流程.md` | 叫乘执行(SIP / 平板双模式)、设备接入、语音与录音、主数据下行、上报 outbox 与续报、值班人工干预;**§9 两类叫乘实现方式的端到端流程**(触发条件 / 交互时序 / 状态上报 / 结果反馈)、**§10 入住作业流程索引**(F-K)、**§11 消费 / 充值 / 菜品管理索引**(F-L / F-M / F-N) | 文档二 7 + CR-02 §7、§11、§13、§14 | @@ -34,6 +34,7 @@ | `13-公寓端小步迭代开发计划.md` | **实施排期设计稿(非设计变更)**:`dev/` 实测上游、`IT-0`~`IT-8` 迭代目标 / 范围 / 依赖 / 验收、里程碑 `MS-0`~`MS-5`、交付物 `DV-0`~`DV-8`、设计文档 × 代码模块对齐矩阵、6 项 P0 排期前提与 `IR-01`~`IR-14` 风险台账、跨迭代联调与回归基线、`IT-9`~`IT-11` 消费 / 充值 / 菜品衔接;表 13-1~13-32、图 13-1 | 开发排期与联调(**阶段 / 验收口径仍以 `07` 为准**) | | `14-第一轮开发(IT-0)交付说明.md` | **第一轮实现交付说明**:模块职责边界与依赖图、59 张表全量字段 / 主键 / 唯一键 / 索引设计、DDL 脚本与迁移顺序、自检接口的请求参数 / 返回结构 / 错误码约定、连接失败 / 超时 / 数据异常处理矩阵、20 项实测验证结果、口径偏离与后续衔接;表 14-1~14-23、图 14-1 | `IT-0` 落地与验收(代码在 `dev/RuoYi-Vue-master`、`dev/sql/apt_rev01`) | | `15-第二轮开发(IT-1)交付说明.md` | **第二轮实现交付说明**:运行时基座 M0 的四前缀治理与鉴权、统一错误码与表 4-4 HTTP 状态映射、审计切面落 `duty_operation_log`、分页上限 100、springdoc 分组、权限点种子与只读列表骨架、41 个单测与端到端 M0 5/5 验证结果、14 项口径偏离登记与后续衔接;表 15-1~15-17、图 15-1 | `IT-1` 落地与验收(代码在 `dev/RuoYi-Vue-master`、`dev/sql/apt_rev01`、`dev/scripts`) | +| `16-第三轮开发(IT-2)交付说明.md` | **第三轮实现交付说明**:M1 交路 CRUD / 启停 / 复算、M2 房间档案 / 房态 / `state_version` / `commandId` 幂等、主数据下行 Mock 灌数与 `source='LOCAL'` 版本标记、IT-1 遗留项闭环;79 个单测与端到端 24/24 验证结果、SQL 实库校验与中文编码缺陷修复、18 项口径偏离登记;**第 9 章 `ruoyi-apt` 模块归置**(清单 / 职责边界 / 依赖与关系图 / 构建识别 / 验证)、**第 10 章前端页面补齐**(范围界定 / 页面与接口对应 / RuoYi 规范一致性 / 路由菜单统一 / 未开放能力 / 验证);表 16-1~16-30、图 16-1 / 16-2 / 16-3 | `IT-2` 落地与验收(代码在 `dev/RuoYi-Vue-master/ruoyi-apt`、`dev/RuoYi-Vue2-master/src/{api,views,router}`、`dev/sql/apt_rev01`、`dev/scripts`) | | `../设计变更/03-三端功能设计修订(CR-02).md` | **权威增量**:三端覆盖范围、交互协议、交互数据、模块与数据表、关键流程变更、施工单 | CR-02 | ## 1. 编号与引用约定 @@ -108,6 +109,7 @@ flowchart LR M --> N["13 小步迭代开发计划(排期 / 联调)"] N --> O["14 第一轮开发 IT-0 交付说明(实现 / 验收)"] O --> P["15 第二轮开发 IT-1 交付说明(运行时基座 M0)"] + P --> Q["16 第三轮开发 IT-2 交付说明(交路 / 房间 / 版本标记)"] ``` 图 0-1 阅读路径 diff --git a/docs/设计变更/03-三端功能设计修订(CR-02).md b/docs/设计变更/03-三端功能设计修订(CR-02).md index 181b759..3d75639 100644 --- a/docs/设计变更/03-三端功能设计修订(CR-02).md +++ b/docs/设计变更/03-三端功能设计修订(CR-02).md @@ -29,6 +29,10 @@ | v1.6 | 2026-11-10 | **补遗 §15**:把公寓端已完成的四项调整(**平板叫乘、人脸签到、消费、菜品管理**)在应用端 / 平台端同步落地,统一业务规则、交互流程、权限控制、异常处理,并给出**三端状态映射与数据同步机制**唯一权威表(表 3-66);同时新增平台端「以公寓为单位的分类统计 / 月·季·年报表 / 公寓排名」能力(`docs/平台端/10`,`PS-01`~`PS-05`)。新增裁决 CR-C36~CR-C42、流程 F-O / F-P / F-Q、决策点 Q-46~Q-50、验收 24~26、施工单 R-114~R-125;**变更点与影响范围**见 §15 表 3-63。跨端仍 20 个编号、契约 `v2-draft`、平台零回调、公寓本地库 **59 张**、不新增 `bizType`;统计能力全部落平台管理端 `/platform/v1/admin/stat/**`,新增 2 张 `pl_apt_stat_*` 属**平台库**(另计),统计结果不回写任何业务状态 | | v1.7 | 2026-11-10 | **非设计变更登记(索引同步)**:新增 `docs/公寓端/13-公寓端小步迭代开发计划.md`(v1.0,实施排期设计稿),以 `dev/` 的 RuoYi-Vue / Vue2 / MySQL 实测上游为物理起点,把 §11~§15 的设计成果切成 `IT-0`~`IT-8` 九个小步迭代(**P1 入住 + 叫乘核心闭环优先**)、里程碑 `MS-0`~`MS-5` 与交付物 `DV-0`~`DV-8`;**P2 消费 / 充值 / 菜品整体延后**为 `IT-9`~`IT-11`("设计已覆盖、实现延后",非删需求)。登记 6 项 P0 排期前提与 3 项口径偏离(表 13-4 / 表 13-6),须评审签字后方可执行。**变更点与影响范围**:新建 `公寓端/13` 1 篇,同步 `公寓端/00`、`公寓端/README.md`、`公寓端/07`、`公寓端/08`、`docs/README.md` 与本文表 0-2 / 0-3 索引;**不改动** §12~§15 任何权威表述,**不新增**裁决 `CR-C*` / 流程 `F-*` / 决策点 `Q-*` / 验收 / 施工单 `R-*` / `M7` / 阶段六 / `D-4` 等既有编号;跨端仍 20 个编号、契约 `v2-draft`、平台零回调、公寓本地库 **59 张表**、平台库累计新增 5 张(含 2 张 `pl_apt_stat_*`)、`apt.duty.state` 单写入口、统计结果不回写全部不变;阶段与验收仍以 `docs/10` §9、`公寓端/07` 表 7-1~7-8 为权威,`13` 篇内 `IT-*` / `MS-*` / `DV-*` / `IR-*` 编号不纳入本文体系 | | v1.8 | 2026-11-10 | **非设计变更登记(索引同步)**:新增 `docs/公寓端/15-第二轮开发(IT-1)交付说明.md`(v1.0),登记 `IT-1 运行时基座 M0` 的实现与验收——四前缀治理与 `/apt/**` 鉴权、`gyid` 隔离、统一错误码与表 4-4 HTTP 状态映射、401/403 处理器、审计切面落 `duty_operation_log`、分页上限 100、springdoc 分组、权限点种子与只读列表骨架;41 个单测全绿,真实进程端到端 M0 **5/5 通过**(`dev/scripts/verify-it1-m0.ps1`)。**变更点与影响范围**:新建 `公寓端/15` 1 篇与 `dev/scripts/verify-it1-m0.ps1` 1 个脚本,同步 `公寓端/README.md`、`公寓端/14` 表 14-23、`docs/README.md` 与本文表 0-2 索引;**不改动** §12~§15 任何权威表述,**不新增**裁决 `CR-C*` / 流程 `F-*` / 决策点 `Q-*` / 验收 / 施工单 `R-*` 等既有编号;跨端仍 20 个编号、契约 `v2-draft`、平台零回调、公寓本地库 **59 张表**、`apt.duty.state` 单写入口、统计结果不回写全部不变;`IT-*` / `DV-*` 仍为 `13` 篇内编号,不纳入本文体系;契约冻结签字与 `D5` 签字仍为 P0 待办 | +| v1.9 | 2026-11-11 | **非设计变更登记(索引同步)**:新增 `docs/公寓端/16-第三轮开发(IT-2)交付说明.md`(v1.0),登记 `IT-2` M1 交路 / M2 房间 / 主数据下行 Mock 的实现与验收——交路 CRUD、启停原因审计、复算校验;房间档案、房态、`state_version` 条件更新与 `commandId` 幂等;`apt_master_version` 6 域 `source='LOCAL'` / `source_version=0` / `state='APPLIED'` 与只读接口 `/apt/master/versions`;IT-1 遗留项(审计 `REQUIRES_NEW`、参数校验异常分派)闭环。79 个单测全绿,`dev/sql/apt_rev01/10~12` 实库执行与校验通过,`dev/scripts/verify-it2-m1m2.ps1` 真实进程端到端 **23/23 通过**(本行为 v1.9 当时的历史口径;v2.0 新增 M0-5 后扩展为 24/24)。**变更点与影响范围**:新建 `公寓端/16` 1 篇与 `dev/scripts/apply-it2-sql.ps1`、`dev/scripts/verify-it2-m1m2.ps1` 2 个脚本,同步 `公寓端/README.md`、`公寓端/14` 表 14-23、`公寓端/15` 表 15-17、`docs/README.md` 与本文表 0-2 索引;**不改动** §12~§15 任何权威表述,**不新增**裁决 `CR-C*` / 流程 `F-*` / 决策点 `Q-*` / 验收 / 施工单 `R-*` 等既有编号;跨端仍 20 个编号、契约 `v2-draft`、平台零回调、公寓本地库 **59 张表**、`apt.duty.state` 单写入口、统计结果不回写全部不变;`IT-*` / `DV-*` 仍为 `13` 篇内编号,不纳入本文体系;契约冻结 / `D5` / `D8'` / `D3` / `D7` 签字仍为 P0 待办 | +| v2.0 | 2026-11-11 | **非设计变更登记(实现层模块归置)**:新增 `dev/RuoYi-Vue-master/ruoyi-apt` Maven 模块,把 `IT-0`~`IT-2` 分散在 `ruoyi-common` / `ruoyi-framework` / `ruoyi-system` / `ruoyi-admin` 的 64 个公寓端文件(45 主类 + 15 测试 + 4 Mapper XML)统一收拢到 `com.ruoyi.apt.**`,并抽出 `apt-defaults.yml` 承载 `apt.*` / springdoc 分组 / 日志级别。根 `pom.xml` 的 `` 与 ``、`ruoyi-admin/pom.xml` 依赖、`SecurityConfig` 按 Spring Security 接口 + bean 名装配(编译期零反向依赖)、`application.yml` 的 `spring.config.import` 同步调整;`@SpringBootApplication` / `@MapperScan("com.ruoyi.**.mapper")` / `classpath*:mapper/**/*Mapper.xml` 均为通配,无需改扫描包名。**验证**:`mvn -pl ruoyi-admin -am clean package` 7 模块 `BUILD SUCCESS`、79 个单测全绿、端到端 **24/24 通过**(含配置导入与 springdoc 分组校验)。**变更点与影响范围**:`公寓端/02` 新增 §2.5(职责 / 表 2-14 / 2-15 / 图 2-3)、`公寓端/16` 新增第 9 章并同步表 16-4 / 图 16-1,同步 `公寓端/README.md`、`公寓端/14` 表 14-2 注记、`公寓端/15` 表 15-4 注记、`docs/README.md` 与本文表 0-2 索引;**不改动** §12~§15 任何权威表述,**不新增**裁决 `CR-C*` / 流程 `F-*` / 决策点 `Q-*` / 验收 / 施工单 `R-*` 等既有编号;跨端仍 20 个编号、契约 `v2-draft`、平台零回调、公寓本地库 **59 张表**、`apt.duty.state` 单写入口、统计结果不回写全部不变;`IT-*` / `DV-*` 仍为 `13` 篇内编号;契约冻结 / `D5` / `D8'` / `D3` / `D7` 签字仍为 P0 待办 | + +| v2.1 | 2026-11-11 | **非设计变更登记(前端页面补齐)**:在 `dev/RuoYi-Vue2-master` 补齐公寓端 4 个菜单页 + 2 个隐藏详情页(交路列表 / 详情、房间列表 / 详情、主数据版本台账、连通性自检),新建 `src/api/apt/{route,room,master,ops}.js`、`src/utils/apt.js`(状态 / 错误码映射与前端 CSV 导出)、`src/components/AptDisabledAction/index.vue`,增强 `src/utils/request.js` 的 4xx 错误提示;`src/router/index.js` 的 `dynamicRoutes` 增 2 条按权限过滤的详情路由;新增可重放 `dev/sql/apt_rev01/13_it2_frontend_menus.sql`(4 条 `C` 菜单 3101~3104)并纳入 `apply-it2-sql.ps1`。**范围**:只补齐 `IT-0`~`IT-2` 已有后端 Controller 的 4 个功能域;`IT-3`~`IT-8` 设计页面与 `IT-9`~`IT-11` P2 账务域按 `13` 表 13-29 不建页面、不建菜单。**验证**:`npm run build:prod` `exitCode=0`;`13` 实库重放后菜单树 `M=1 / C=4 / F=15`、超管关联 4、置灰项关联 0、M0 账号 0;lints 0。**变更点与影响范围**:`公寓端/02` §2.5 表 2-14 / 图 2-3 与新增「前端落地口径」段、`公寓端/16` 新增第 10 章(表 16-25~16-30、图 16-3)并同步表 16-1 / 16-17 / 16-19 / 16-21、两处 `README.md` 索引;**不改动** §12~§15 权威表述,**不新增**裁决 `CR-C*` / 流程 `F-*` / 决策点 `Q-*` / 验收 / 施工单 `R-*` 等既有编号;设计期 `docs/10` 表 2-3 的「双入口 + 静态路由」口径不改写,实现层偏离登记在 `公寓端/16` 偏离项 16-19;跨端仍 20 个编号、契约 `v2-draft`、平台零回调、公寓本地库 **59 张表**、`apt.duty.state` 单写入口、统计结果不回写全部不变;契约冻结 / `D5` / `D8'` / `D3` / `D7` 签字仍为 P0 待办 | 表 0-3 本文件图表编号规则