From aae952ec13cff27d3ff3d0a0fd034c0d62419ae8 Mon Sep 17 00:00:00 2001 From: jzf Date: Sun, 1 Nov 2026 19:45:36 +0800 Subject: [PATCH] =?UTF-8?q?fix(lock):=20=E5=A4=96=E9=83=A8=E5=85=A5?= =?UTF-8?q?=E4=BD=8F=E4=BA=BA=E5=91=98=E9=80=80=E6=88=BF=E6=B8=85=E7=90=86?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E7=89=A9=E7=90=86=E5=88=A0=E9=99=A4=E5=B9=B6?= =?UTF-8?q?=E5=BC=BA=E5=88=B6=E6=AF=8F=E6=AC=A1=E5=85=A5=E4=BD=8F=E4=BC=A0?= =?UTF-8?q?=E7=85=A7=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 退房/到期清理只针对外部接口自动建档人员(biz_system 非空、create_by=openapi:*、未绑平台账号、 无其它在住单与未清除授权),依次清理本地照片文件、人脸、各版本特征与人员档案; 人脸/特征/人员统一物理删除,lock_person_ref 保留以便设备回执回调与下次入住改指 - 对外入住每次必须传 photoUrl 或 photoBase64,缺参返回 40001,不再沿用历史照片 - lock_face 唯一键不含 del_flag:insertFace 命中历史行时走 reviveFace 复用, 修复重新建档撞 uk_lock_face_person_id 导致的 50002(Duplicate entry) - 同步设计稿 §2.4/§4.5/§12.1、Apifox 用例 C05/C22、项目看板 §4.35 --- .../com/ruoyi/iot/lock/LockConstants.java | 211 ++++ .../ruoyi/iot/lock/mapper/LockAuthMapper.java | 70 ++ .../ruoyi/iot/lock/mapper/LockFaceMapper.java | 40 + .../iot/lock/mapper/LockPersonRefMapper.java | 31 + .../ruoyi/iot/lock/mapper/LockStayMapper.java | 74 ++ .../iot/lock/service/ILockFaceService.java | 66 ++ .../service/impl/LockFaceServiceImpl.java | 460 ++++++++ .../service/impl/LockStayServiceImpl.java | 831 +++++++++++++ .../service/impl/OpenStayServiceImpl.java | 770 +++++++++++++ .../person/service/ILockPersonService.java | 48 + .../service/impl/LockPersonServiceImpl.java | 225 ++++ .../resources/mapper/lock/LockAuthMapper.xml | 252 ++++ .../resources/mapper/lock/LockFaceMapper.xml | 158 +++ .../mapper/lock/LockPersonRefMapper.xml | 49 + .../resources/mapper/lock/LockStayMapper.xml | 237 ++++ .../mapper/person/LockPersonMapper.xml | 149 +++ doc/对外开放房间门禁服务接口设计.md | 819 +++++++++++++ doc/对外开放接口Apifox测试用例.md | 646 +++++++++++ doc/项目管理看板.md | 1024 +++++++++++++++++ 19 files changed, 6160 insertions(+) create mode 100644 base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/LockConstants.java create mode 100644 base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mapper/LockAuthMapper.java create mode 100644 base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mapper/LockFaceMapper.java create mode 100644 base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mapper/LockPersonRefMapper.java create mode 100644 base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mapper/LockStayMapper.java create mode 100644 base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/ILockFaceService.java create mode 100644 base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/impl/LockFaceServiceImpl.java create mode 100644 base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/impl/LockStayServiceImpl.java create mode 100644 base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/openapi/service/impl/OpenStayServiceImpl.java create mode 100644 base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/person/service/ILockPersonService.java create mode 100644 base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/person/service/impl/LockPersonServiceImpl.java create mode 100644 base/RuoYi-Vue-master/ruoyi-iot/src/main/resources/mapper/lock/LockAuthMapper.xml create mode 100644 base/RuoYi-Vue-master/ruoyi-iot/src/main/resources/mapper/lock/LockFaceMapper.xml create mode 100644 base/RuoYi-Vue-master/ruoyi-iot/src/main/resources/mapper/lock/LockPersonRefMapper.xml create mode 100644 base/RuoYi-Vue-master/ruoyi-iot/src/main/resources/mapper/lock/LockStayMapper.xml create mode 100644 base/RuoYi-Vue-master/ruoyi-iot/src/main/resources/mapper/person/LockPersonMapper.xml create mode 100644 doc/对外开放房间门禁服务接口设计.md create mode 100644 doc/对外开放接口Apifox测试用例.md create mode 100644 doc/项目管理看板.md diff --git a/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/LockConstants.java b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/LockConstants.java new file mode 100644 index 0000000..202e20b --- /dev/null +++ b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/LockConstants.java @@ -0,0 +1,211 @@ +package com.ruoyi.iot.lock; + +/** + * 门禁业务常量 + * + * @author ruoyi + */ +public class LockConstants +{ + /** 目标类型:网关 */ + public static final String TARGET_GATEWAY = "1"; + + /** 目标类型:门锁 */ + public static final String TARGET_DEVICE = "2"; + + /** 目标类型:分组 */ + public static final String TARGET_GROUP = "3"; + + /** 命令类型:绑定门锁 */ + public static final String CMD_BIND = "0001"; + + /** 命令类型:解绑门锁 */ + public static final String CMD_UNBIND = "0002"; + + /** 命令类型:同步时间 */ + public static final String CMD_SYNC_TIME = "0003"; + + /** 命令类型:远程开锁/关锁 */ + public static final String CMD_SWITCH = "0004"; + + /** 命令类型:修改网络参数 */ + public static final String CMD_NET_CFG = "0005"; + + /** 命令类型:修改LORA参数 */ + public static final String CMD_LORA_CFG = "0006"; + + /** 命令类型:修改MQTT参数 */ + public static final String CMD_MQTT_CFG = "0007"; + + /** 命令类型:保存配置 */ + public static final String CMD_SAVE = "0008"; + + /** 命令类型:重启网关 */ + public static final String CMD_REBOOT = "0009"; + + /** 命令类型:清除门锁 */ + public static final String CMD_CLEAR_DEVICE = "0010"; + + /** 命令类型:授权下发 */ + public static final String CMD_AUTH_PUSH = "0011"; + + /** 命令类型:清除授权 */ + public static final String CMD_AUTH_CLEAR = "0012"; + + /** 命令状态:待发送 */ + public static final String STATUS_PENDING = "0"; + + /** 命令状态:已发送 */ + public static final String STATUS_SENT = "1"; + + /** 命令状态:成功 */ + public static final String STATUS_SUCCESS = "2"; + + /** 命令状态:失败 */ + public static final String STATUS_FAIL = "3"; + + /** 命令状态:超时 */ + public static final String STATUS_TIMEOUT = "4"; + + /** 命令状态:已取消 */ + public static final String STATUS_CANCELED = "5"; + + /** 授权类型:人脸 */ + public static final String AUTH_FACE = "01"; + + /** 授权类型:密码 */ + public static final String AUTH_PASSWORD = "02"; + + /** 授权类型:指纹 */ + public static final String AUTH_FINGERPRINT = "03"; + + /** 授权类型:IC卡 */ + public static final String AUTH_IC_CARD = "04"; + + /** 同步状态:待下发 */ + public static final String SYNC_PENDING = "0"; + + /** 同步状态:已下发 */ + public static final String SYNC_SENT = "1"; + + /** 同步状态:下发成功 */ + public static final String SYNC_SUCCESS = "2"; + + /** 同步状态:下发失败 */ + public static final String SYNC_FAIL = "3"; + + /** 同步状态:已清除 */ + public static final String SYNC_CLEARED = "4"; + + /** + * 同步状态:待清除(清除指令失败,等待补偿重发删除)。 + *

+ * 不能与 {@link #SYNC_FAIL} 混用:3 是「人脸下发失败」,补偿会重新 add; + * 6 是「删除失败」,补偿必须继续 delete,否则退房/换房后会把人脸重新加回去。 + * 取 6 而不是 5:v2 基线规划 5 = 已过期,避免迁移冲突。 + */ + public static final String SYNC_CLEAR_FAIL = "6"; + + /** locklist 下发状态:待下发 */ + public static final String BIND_PENDING = "0"; + + /** locklist 下发状态:已下发 */ + public static final String BIND_SENT = "1"; + + /** locklist 下发状态:下发失败 */ + public static final String BIND_FAIL = "2"; + + /** 在线:在线 */ + public static final String ONLINE = "0"; + + /** 在线:离线 */ + public static final String OFFLINE = "1"; + + /** 在线:未知 */ + public static final String ONLINE_UNKNOWN = "2"; + + /** 门锁状态:正常(status 字段,非在线状态) */ + public static final String DEVICE_STATUS_NORMAL = "0"; + + /** + * 开放接口自动建档的 create_by 前缀。 + *

+ * 退房清理只针对以此为前缀的人员,人工建档人员无论是否办理过外部单都不动。 + */ + public static final String CREATE_BY_OPENAPI = "openapi"; + + /** 人员状态:正常 */ + public static final String PERSON_STATUS_NORMAL = "0"; + + /** 人员状态:停用 */ + public static final String PERSON_STATUS_DISABLED = "1"; + + /** 授权状态:正常 */ + public static final String AUTH_STATUS_NORMAL = "0"; + + /** 授权状态:停用 */ + public static final String AUTH_STATUS_DISABLED = "1"; + + /** 特征提取状态:未提取 */ + public static final String EXTRACT_PENDING = "0"; + + /** 特征提取状态:成功 */ + public static final String EXTRACT_SUCCESS = "1"; + + /** 特征提取状态:失败 */ + public static final String EXTRACT_FAIL = "2"; + + /** 配置类型:MQTT服务器 */ + public static final String CONFIG_MQTT = "01"; + + /** 配置类型:人脸特征服务 */ + public static final String CONFIG_FACE = "02"; + + /** 配置类型:平台参数 */ + public static final String CONFIG_PLATFORM = "03"; + + /** 报文方向:上行 */ + public static final String DIRECTION_UP = "1"; + + /** 报文方向:下行 */ + public static final String DIRECTION_DOWN = "2"; + + /** 报文处理状态:成功 */ + public static final String HANDLE_SUCCESS = "1"; + + /** 报文处理状态:失败 */ + public static final String HANDLE_FAIL = "2"; + + /** 报文处理状态:已忽略 */ + public static final String HANDLE_IGNORE = "3"; + + /** 设备响应结果码:执行成功(终态) */ + public static final String RESP_CODE_SUCCESS = "0"; + + /** + * 设备响应结果码:网关已接收并转发(中间态) + *

+ * 门锁类下发会收到两次响应:网关先回执(本码),门锁执行完再回最终结果码。 + * 中间态只留痕,不能据此判定命令完成,否则会覆盖门锁的真实结果。 + */ + public static final String RESP_CODE_GATEWAY_RECEIVED = "10"; + + /** 回执标记:未收到 */ + public static final String ACK_NO = "0"; + + /** 回执标记:已收到 */ + public static final String ACK_YES = "1"; + + /** 门锁回执结果:失败 */ + public static final String DEVICE_RESULT_FAIL = "0"; + + /** 门锁回执结果:成功 */ + public static final String DEVICE_RESULT_SUCCESS = "1"; + + /** 开门结果:成功 */ + public static final String OPEN_RESULT_SUCCESS = "1"; + + private LockConstants() + { + } +} diff --git a/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mapper/LockAuthMapper.java b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mapper/LockAuthMapper.java new file mode 100644 index 0000000..7c93e8c --- /dev/null +++ b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mapper/LockAuthMapper.java @@ -0,0 +1,70 @@ +package com.ruoyi.iot.lock.mapper; + +import java.util.List; +import org.apache.ibatis.annotations.Param; +import com.ruoyi.iot.lock.domain.LockAuth; + +/** + * 授权数据 数据层 + * + * @author ruoyi + */ +public interface LockAuthMapper +{ + List selectAuthList(LockAuth auth); + + LockAuth selectAuthById(Long authId); + + List selectAuthsByDeviceId(Long deviceId); + + List selectAuthsByPersonNo(String personNo); + + /** 人员未撤销的授权数,删除人员前的引用校验 */ + int countAuthsByPersonNo(String personNo); + + /** + * 人员除指定授权外未清除的授权数。 + *

+ * 已清除(4)的授权不再有设备侧权限,不计入;下发失败(3)与清除失败(6)都保留, + * 前者可能仍能补偿重发,后者说明人脸还在设备上。 + */ + int countActiveAuthsByPersonNo(@Param("personNo") String personNo, @Param("excludeAuthId") Long excludeAuthId); + + /** 按分组查询待下发或下发失败的授权,用于补偿下发 */ + List selectPendingAuths(@Param("groupId") Long groupId); + + /** 待清除授权(清除指令失败),补偿必须重发删除而不是人脸下发 */ + List selectPendingClearAuths(); + + /** 人员人脸授权涉及的门锁算法版本集合(去重),批量提取用 */ + List selectFaceAlgoVersionsByPersonNo(String personNo); + + /** 指定版本下待下发/下发失败的人脸授权,特征就绪后自动补发 */ + List selectFaceAuthsForRepush(@Param("personNo") String personNo, + @Param("algoVersion") String algoVersion); + + int insertAuth(LockAuth auth); + + /** + * 按唯一键 (person_no, device_id, auth_type) 查授权。 + *

+ * 唯一键不含 del_flag,逻辑删除的行仍占用键值,所以这里不过滤 del_flag。 + */ + LockAuth selectAuthByUniqueKey(@Param("personNo") String personNo, @Param("deviceId") Long deviceId, + @Param("authType") String authType); + + /** 复用已存在的授权行(含已逻辑删除),写入新的凭据并重置为待下发 */ + int reviveAuth(LockAuth auth); + + int updateAuth(LockAuth auth); + + int updateSyncStatus(@Param("authId") Long authId, @Param("syncStatus") String syncStatus, + @Param("syncMsg") String syncMsg); + + /** 门锁算法版本变更:把该锁上未撤销的人脸授权置为下发失败,等待按新版本重提后补发 */ + int markFaceAuthsForReextract(@Param("deviceId") Long deviceId, @Param("syncMsg") String syncMsg); + + int deleteAuthByIds(Long[] authIds); + + int deleteAuthByDeviceId(Long deviceId); +} diff --git a/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mapper/LockFaceMapper.java b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mapper/LockFaceMapper.java new file mode 100644 index 0000000..c529b66 --- /dev/null +++ b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mapper/LockFaceMapper.java @@ -0,0 +1,40 @@ +package com.ruoyi.iot.lock.mapper; + +import java.util.List; +import org.apache.ibatis.annotations.Param; +import com.ruoyi.iot.lock.domain.LockFace; + +/** + * 人脸数据 数据层 + * + * @author ruoyi + */ +public interface LockFaceMapper +{ + /** 列表查询不返回 feature_data,避免大字段传输 */ + List selectFaceList(LockFace face); + + LockFace selectFaceById(Long faceId); + + LockFace selectFaceByPersonNo(String personNo); + + /** 人员已登记的人脸数,删除人员前的引用校验 */ + int countFaceByPersonId(Long personId); + + /** 引用同一照片文件的在用人脸数,调用方复用同一 photoUrl 时不能直接删文件 */ + int countFaceByPhotoUrl(String photoUrl); + + /** 重新建档时查历史人脸行(含已逻辑删除):唯一键不含 del_flag,直接插会撞键 */ + LockFace selectReusableFace(@Param("personId") Long personId, @Param("personNo") String personNo); + + /** 复用历史行:重置为在用并清空上一次的提取结果 */ + int reviveFace(LockFace face); + + int insertFace(LockFace face); + + int updateFace(LockFace face); + + int updateExtractResult(LockFace face); + + int deleteFaceByIds(Long[] faceIds); +} diff --git a/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mapper/LockPersonRefMapper.java b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mapper/LockPersonRefMapper.java new file mode 100644 index 0000000..5b753d0 --- /dev/null +++ b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mapper/LockPersonRefMapper.java @@ -0,0 +1,31 @@ +package com.ruoyi.iot.lock.mapper; + +import org.apache.ibatis.annotations.Param; +import com.ruoyi.iot.lock.domain.LockPersonRef; + +/** + * 外部人员标识映射 数据层 + * + * @author ruoyi + */ +public interface LockPersonRefMapper +{ + /** 按 (应用, 外部标识) 取映射,不存在返回 null */ + LockPersonRef selectByAppAndExternalId(@Param("appCode") String appCode, + @Param("externalId") String externalId); + + /** 按 (应用, 平台人员编号) 反查外部标识(回调报文的 externalPersonId) */ + LockPersonRef selectByAppAndPersonNo(@Param("appCode") String appCode, @Param("personNo") String personNo); + + /** 新增映射(撞唯一键时由业务层捕获后回查) */ + int insertRef(LockPersonRef ref); + + /** + * 把映射改指到新人员(外部人员退房清理后重新入住)。 + *

+ * 带旧人员编号做条件更新:并发下只有先改成功的请求生效,另一方回查映射后 + * 改用现有人员,与首次建档撞键的处理保持一致。 + */ + int rebindPerson(@Param("refId") Long refId, @Param("oldPersonNo") String oldPersonNo, + @Param("personId") Long personId, @Param("personNo") String personNo); +} diff --git a/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mapper/LockStayMapper.java b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mapper/LockStayMapper.java new file mode 100644 index 0000000..9719e28 --- /dev/null +++ b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mapper/LockStayMapper.java @@ -0,0 +1,74 @@ +package com.ruoyi.iot.lock.mapper; + +import java.util.Date; +import java.util.List; +import org.apache.ibatis.annotations.Param; +import com.ruoyi.iot.lock.domain.LockStay; + +/** + * 入住单 数据层 + * + * @author ruoyi + */ +public interface LockStayMapper +{ + List selectStayList(LockStay stay); + + LockStay selectStayById(Long stayId); + + /** 某人在住订单(一人多房,返回列表) */ + List selectActiveStaysByPersonNo(String personNo); + + /** 某人未结束的入住单数(退房清理人员前排除当前单的占用校验) */ + int countActiveStaysByPersonNo(@Param("personNo") String personNo, @Param("excludeStayId") Long excludeStayId); + + /** 房间入住历史 */ + List selectStaysByRoomId(Long roomId); + + /** 房间当前在住单(一房可多人),按入住先后倒序 */ + List selectActiveStaysByRoomId(Long roomId); + + /** 按业务系统单号取入住单(对外接口幂等与定位,跨应用查不到) */ + LockStay selectStayByBiz(@Param("bizSystem") String bizSystem, @Param("bizNo") String bizNo); + + /** 某人在某房间、某业务系统下的在住单(对外退房/续住按人员+房间定位) */ + LockStay selectActiveStayByPersonAndRoom(@Param("personNo") String personNo, @Param("roomId") Long roomId, + @Param("bizSystem") String bizSystem); + + /** 某人在某房间、某业务系统下最近一条入住单(不限状态,用于已退房时幂等返回) */ + LockStay selectLatestStayByPersonAndRoom(@Param("personNo") String personNo, @Param("roomId") Long roomId, + @Param("bizSystem") String bizSystem); + + /** 授权对应的入住单(回调事件按 auth_id 反查业务单号与房号;一对一,取最新一条) */ + LockStay selectStayByAuthId(Long authId); + + /** 门锁上的在住单数(整锁清空人脸前的安全校验) */ + int countActiveStaysByDeviceId(Long deviceId); + + /** 全平台在住单数(修改系统时间前的风险提示) */ + int countActiveStays(); + + /** 某业务系统的入住单数(删除外部应用前的占用校验) */ + int countStaysByBizSystem(String bizSystem); + + /** 已到预计退房时间但仍为在住的入住单(到期任务扫描) */ + List selectExpiredStays(@Param("endTime") Date endTime, @Param("limit") int limit); + + /** 到期:在住 → 已退房,退房时间取计划结束时间 */ + int updateExpire(Long stayId); + + int insertStay(LockStay stay); + + /** 退房:置已退房并记录退房时间 */ + int updateCheckout(@Param("stayId") Long stayId, @Param("checkoutTime") Date checkoutTime); + + /** 换房:旧单置已换房并记录时间 */ + int updateChanged(@Param("stayId") Long stayId, @Param("checkoutTime") Date checkoutTime); + + /** 续住:更新预计退房时间 */ + int updateEndTime(@Param("stayId") Long stayId, @Param("endTime") Date endTime); + + /** 授权回执回写入住单的人脸授权状态 */ + int updateAuthStatusByAuthId(@Param("authId") Long authId, @Param("authStatus") String authStatus, + @Param("authMsg") String authMsg); +} \ No newline at end of file diff --git a/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/ILockFaceService.java b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/ILockFaceService.java new file mode 100644 index 0000000..551a2c0 --- /dev/null +++ b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/ILockFaceService.java @@ -0,0 +1,66 @@ +package com.ruoyi.iot.lock.service; + +import java.util.List; +import com.ruoyi.iot.lock.domain.LockFace; +import com.ruoyi.iot.lock.domain.LockFaceFeature; + +/** + * 人脸特征服务 + * + * @author ruoyi + */ +public interface ILockFaceService +{ + List selectFaceList(LockFace face); + + LockFace selectFaceById(Long faceId); + + LockFace selectFaceByPersonNo(String personNo); + + /** + * 删除平台本地保存的人脸照片文件。 + *

+ * 只处理 {@code /profile/...} 相对地址(上传与 base64 落盘的照片),且限定在 + * profile 目录内;外部 http(s) 地址由业务系统自己保管,平台不删除。 + * + * @param photoUrl 人脸记录中的照片地址 + * @return 删除的文件数(不存在或非本地地址返回 0) + */ + int deleteLocalPhotoFile(String photoUrl); + + /** + * 取人员在指定算法版本下已提取成功的特征 + * + * @param personNo 人员编号 + * @param algoVersion 算法版本(门锁 face_algo_version) + * @return 无可用特征时返回 null + */ + LockFaceFeature selectFeature(String personNo, String algoVersion); + + /** 人脸已提取的算法版本列表(不含特征值) */ + List selectFeaturesByFaceId(Long faceId); + + int insertFace(LockFace face); + + int updateFace(LockFace face); + + int deleteFaceByIds(Long[] faceIds); + + /** + * 触发特征提取 + * + * @param faceId 人脸ID + * @param deviceId 目标门锁ID(用于取该锁维护的 algorithm_version),为空时按该人员的授权自动推断 + * @return 提取后的特征(base64),失败返回 null + */ + String extract(Long faceId, Long deviceId); + + /** + * 批量重提取 + * + * @param faceIds 人脸ID数组 + * @param deviceId 目标门锁ID,为空时按各自授权自动推断 + * @return 成功条数 + */ + int batchExtract(Long[] faceIds, Long deviceId); +} diff --git a/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/impl/LockFaceServiceImpl.java b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/impl/LockFaceServiceImpl.java new file mode 100644 index 0000000..4646ea2 --- /dev/null +++ b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/impl/LockFaceServiceImpl.java @@ -0,0 +1,460 @@ +package com.ruoyi.iot.lock.service.impl; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.Duration; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import com.ruoyi.common.config.RuoYiConfig; +import com.ruoyi.common.constant.Constants; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.common.utils.sign.Md5Utils; +import com.ruoyi.iot.lock.LockConstants; +import com.ruoyi.iot.lock.domain.LockAuth; +import com.ruoyi.iot.lock.domain.LockDevice; +import com.ruoyi.iot.lock.domain.LockFace; +import com.ruoyi.iot.lock.domain.LockFaceFeature; +import com.ruoyi.iot.lock.mapper.LockAuthMapper; +import com.ruoyi.iot.lock.mapper.LockDeviceMapper; +import com.ruoyi.iot.lock.mapper.LockFaceFeatureMapper; +import com.ruoyi.iot.lock.mapper.LockFaceMapper; +import com.ruoyi.iot.lock.remote.FaceExtractClient; +import com.ruoyi.iot.lock.remote.FaceExtractResult; +import com.ruoyi.iot.lock.service.ILockAuthService; +import com.ruoyi.iot.lock.service.ILockFaceService; +import com.ruoyi.iot.person.domain.LockPerson; +import com.ruoyi.iot.person.mapper.LockPersonMapper; + +/** + * 人脸特征服务实现 + * + * @author ruoyi + */ +@Service +public class LockFaceServiceImpl implements ILockFaceService +{ + private static final Logger log = LoggerFactory.getLogger(LockFaceServiceImpl.class); + + @Autowired + private LockFaceMapper faceMapper; + + @Autowired + private LockFaceFeatureMapper featureMapper; + + @Autowired + private LockDeviceMapper deviceMapper; + + @Autowired + private LockAuthMapper authMapper; + + @Autowired + private LockPersonMapper personMapper; + + @Autowired + private FaceExtractClient faceExtractClient; + + @Autowired + private ILockAuthService authService; + + @Override + public List selectFaceList(LockFace face) + { + return faceMapper.selectFaceList(face); + } + + @Override + public LockFace selectFaceById(Long faceId) + { + return faceMapper.selectFaceById(faceId); + } + + @Override + public LockFace selectFaceByPersonNo(String personNo) + { + return faceMapper.selectFaceByPersonNo(personNo); + } + + @Override + public int deleteLocalPhotoFile(String photoUrl) + { + String url = StringUtils.trim(photoUrl); + if (StringUtils.isEmpty(url) || !url.startsWith(Constants.RESOURCE_PREFIX)) + { + return 0; + } + String relative = StringUtils.substringAfter(url, Constants.RESOURCE_PREFIX); + try + { + // 调用方可能把同一张照片给多人用,还有其它人脸引用时只删记录不删文件 + if (faceMapper.countFaceByPhotoUrl(url) > 1) + { + log.debug("照片被多个人脸引用,保留文件, photoUrl={}", url); + return 0; + } + Path profile = Paths.get(RuoYiConfig.getProfile()).toAbsolutePath().normalize(); + Path target = profile.resolve(relative.startsWith("/") ? relative.substring(1) : relative).normalize(); + // photo_url 来自外部传入,必须确认落在 profile 目录内再删,避免路径穿越 + if (!target.startsWith(profile) || !Files.isRegularFile(target)) + { + return 0; + } + Files.delete(target); + return 1; + } + catch (Exception e) + { + log.warn("删除人脸照片文件失败, photoUrl={}, msg={}", url, e.getMessage()); + return 0; + } + } + + @Override + public LockFaceFeature selectFeature(String personNo, String algoVersion) + { + if (StringUtils.isEmpty(personNo) || StringUtils.isEmpty(algoVersion)) + { + return null; + } + return featureMapper.selectUsableFeature(personNo, algoVersion.trim()); + } + + @Override + public List selectFeaturesByFaceId(Long faceId) + { + return featureMapper.selectFeaturesByFaceId(faceId); + } + + @Override + public int insertFace(LockFace face) + { + // 人脸必须挂在人员主数据上:人员不存在时拒绝建档,避免再出现「人脸表自带人员信息」 + LockPerson person = resolvePerson(face.getPersonNo()); + face.setPersonId(person.getPersonId()); + face.setPersonNo(person.getPersonNo()); + face.setExtractStatus(LockConstants.EXTRACT_PENDING); + // 唯一键不含 del_flag:退房清理、人工删人脸留下的历史行会挡住重新建档,命中就复用该行 + LockFace history = faceMapper.selectReusableFace(person.getPersonId(), person.getPersonNo()); + if (history != null) + { + face.setFaceId(history.getFaceId()); + face.setUpdateBy(face.getCreateBy()); + if (StringUtils.isEmpty(face.getStatus())) + { + face.setStatus("0"); + } + faceMapper.reviveFace(face); + return 1; + } + return faceMapper.insertFace(face); + } + + private LockPerson resolvePerson(String personNo) + { + if (StringUtils.isEmpty(StringUtils.trim(personNo))) + { + throw new IllegalArgumentException("人员编号不能为空"); + } + LockPerson person = personMapper.selectPersonByNo(StringUtils.trim(personNo)); + if (person == null) + { + throw new IllegalArgumentException("人员不存在,请先建立人员档案,人员编号=" + personNo); + } + return person; + } + + @Override + public int updateFace(LockFace face) + { + return faceMapper.updateFace(face); + } + + @Override + @Transactional + public int deleteFaceByIds(Long[] faceIds) + { + // 人脸与各版本特征都是生物识别数据,直接物理删除,不留历史行 + featureMapper.deleteFeaturesByFaceIds(faceIds); + return faceMapper.deleteFaceByIds(faceIds); + } + + @Override + public String extract(Long faceId, Long deviceId) + { + LockFace face = faceMapper.selectFaceById(faceId); + if (face == null) + { + throw new IllegalArgumentException("人脸数据不存在"); + } + // algorithm_version 由门锁维护,优先取指定门锁,其次按该人员的授权推断 + String algoVersion = resolveAlgoVersion(face.getPersonNo(), deviceId); + if (StringUtils.isEmpty(algoVersion)) + { + // 特征按版本分别存储,没有版本就没有可下发的目标,直接失败而不是落一份无版本特征 + log.warn("未获取到算法版本(algorithm_version), faceId={}, deviceId={}", faceId, deviceId); + markExtractFail(face, null, "未获取到门锁算法版本(lock_device.face_algo_version),无法按版本提取特征"); + return null; + } + return extractForVersion(face, algoVersion); + } + + /** + * 按指定算法版本提取并落库 + *

+ * 同一张人脸的不同版本各占 lock_face_feature 一行,互不覆盖; + * lock_face 上的特征列同步为最近一次成功结果,保持旧页面与开放接口兼容。 + */ + private String extractForVersion(LockFace face, String algoVersion) + { + if (StringUtils.isEmpty(face.getPhotoUrl())) + { + markExtractFail(face, algoVersion, "未上传人脸图片"); + return null; + } + // photoUrl 存的是图片地址,特征提取服务按文件接收,这里读取图片内容 + byte[] imageBytes = loadImageBytes(face.getPhotoUrl()); + if (imageBytes == null || imageBytes.length == 0) + { + markExtractFail(face, algoVersion, "人脸图片读取失败,请确认图片地址可访问"); + log.warn("人脸图片读取失败, faceId={}, photoUrl={}", face.getFaceId(), face.getPhotoUrl()); + return null; + } + FaceExtractResult result = faceExtractClient.extract(imageBytes, "face_" + face.getFaceId() + ".jpg", + algoVersion); + if (!result.isSuccess()) + { + markExtractFail(face, algoVersion, StringUtils.substring(result.getErrorMessage(), 0, 500)); + log.warn("人脸特征提取失败, faceId={}, algoVersion={}, reason={}", face.getFaceId(), algoVersion, + result.getErrorMessage()); + return null; + } + String feature = result.getFeature(); + String featureMd5 = Md5Utils.hash(feature); + + LockFaceFeature row = new LockFaceFeature(); + row.setFaceId(face.getFaceId()); + row.setPersonId(face.getPersonId()); + row.setPersonNo(face.getPersonNo()); + row.setAlgoVersion(algoVersion); + row.setFeatureData(feature); + row.setFeatureMd5(featureMd5); + row.setFeatureSize(feature.length()); + row.setExtractStatus(LockConstants.EXTRACT_SUCCESS); + row.setExtractTime(new Date()); + featureMapper.upsertFeature(row); + + LockFace update = new LockFace(); + update.setFaceId(face.getFaceId()); + update.setExtractStatus(LockConstants.EXTRACT_SUCCESS); + update.setExtractMsg(null); + update.setFeatureData(feature); + update.setFeatureMd5(featureMd5); + update.setFeatureSize(feature.length()); + update.setFeatureAlgoVersion(algoVersion); + faceMapper.updateExtractResult(update); + + // 新版本特征就绪后,自动补发此前因版本不匹配或等待重提而失败的授权 + repushPendingFaceAuths(face.getPersonNo(), algoVersion); + return feature; + } + + /** + * 提取失败落库 + *

+ * 该版本已有可用特征时不降级:重复提取失败不应让原本可下发的特征失效, + * 只把本次失败原因写到 lock_face 镜像供页面提示。 + */ + private void markExtractFail(LockFace face, String algoVersion, String message) + { + if (StringUtils.isNotEmpty(algoVersion)) + { + LockFaceFeature existing = featureMapper.selectFeatureByFaceAndVersion(face.getFaceId(), algoVersion); + boolean hasUsable = existing != null + && LockConstants.EXTRACT_SUCCESS.equals(existing.getExtractStatus()) + && StringUtils.isNotEmpty(existing.getFeatureData()); + if (!hasUsable) + { + LockFaceFeature row = new LockFaceFeature(); + row.setFaceId(face.getFaceId()); + row.setPersonId(face.getPersonId()); + row.setPersonNo(face.getPersonNo()); + row.setAlgoVersion(algoVersion); + row.setExtractStatus(LockConstants.EXTRACT_FAIL); + row.setExtractTime(new Date()); + row.setExtractMsg(message); + featureMapper.upsertFeature(row); + } + } + LockFace update = new LockFace(); + update.setFaceId(face.getFaceId()); + update.setExtractStatus(LockConstants.EXTRACT_FAIL); + update.setExtractMsg(message); + faceMapper.updateExtractResult(update); + } + + /** + * 特征就绪后补发该版本下待下发/失败的授权 + */ + private void repushPendingFaceAuths(String personNo, String algoVersion) + { + List auths = authMapper.selectFaceAuthsForRepush(personNo, algoVersion); + for (LockAuth auth : auths) + { + try + { + authService.push(auth.getAuthId()); + } + catch (Exception e) + { + log.warn("特征就绪后重发授权失败, authId={}, msg={}", auth.getAuthId(), e.getMessage()); + } + } + } + + /** + * 解析特征提取所需的 algorithm_version + *

+ * 该参数在每把智能门锁上维护(lock_device.face_algo_version): + * 优先使用指定的目标门锁,未指定时回退到该人员第一条授权对应的门锁。 + * + * @param personNo 人员编号 + * @param deviceId 目标门锁ID,可为空 + * @return 算法版本,取不到返回 null + */ + private String resolveAlgoVersion(String personNo, Long deviceId) + { + LockDevice device = null; + if (deviceId != null) + { + device = deviceMapper.selectDeviceById(deviceId); + } + else if (StringUtils.isNotEmpty(personNo)) + { + List auths = authMapper.selectAuthsByPersonNo(personNo); + if (auths != null && !auths.isEmpty() && auths.get(0).getDeviceId() != null) + { + device = deviceMapper.selectDeviceById(auths.get(0).getDeviceId()); + } + } + if (device == null || StringUtils.isEmpty(device.getFaceAlgoVersion())) + { + return null; + } + return device.getFaceAlgoVersion().trim(); + } + + /** + * 读取人脸图片内容 + *

+ * photoUrl 可能是 http(s) 外链,也可能是若依上传接口返回的 /profile/... 相对地址 + * (实际文件位于 RuoYiConfig.getProfile() 对应的目录)。 + */ + private byte[] loadImageBytes(String photoUrl) + { + try + { + if (photoUrl.startsWith("http")) + { + HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build(); + HttpRequest request = HttpRequest.newBuilder(URI.create(photoUrl)) + .timeout(Duration.ofSeconds(30)).GET().build(); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofByteArray()); + if (response.statusCode() != 200) + { + return null; + } + return response.body(); + } + String profile = RuoYiConfig.getProfile(); + String relative = StringUtils.substringAfter(photoUrl, Constants.RESOURCE_PREFIX); + Path path = Paths.get(profile + relative); + if (!Files.exists(path)) + { + return null; + } + return Files.readAllBytes(path); + } + catch (Exception e) + { + log.warn("人脸图片读取失败, photoUrl={}", photoUrl, e); + return null; + } + } + + @Override + public int batchExtract(Long[] faceIds, Long deviceId) + { + int success = 0; + for (Long faceId : faceIds) + { + try + { + LockFace face = faceMapper.selectFaceById(faceId); + if (face == null) + { + continue; + } + // 未指定门锁时按该人员已授权门锁的版本集合逐个提取,一次补齐多个版本 + List versions = resolveRequiredVersions(face.getPersonNo(), deviceId); + if (versions.isEmpty()) + { + // 没有目标版本时仍走一次 extract,让失败原因落到 lock_face + if (extract(faceId, deviceId) != null) + { + success++; + } + continue; + } + boolean allOk = true; + for (String version : versions) + { + if (extractForVersion(face, version) == null) + { + allOk = false; + } + } + if (allOk) + { + success++; + } + } + catch (Exception e) + { + log.warn("批量特征提取异常, faceId={}, msg={}", faceId, e.getMessage()); + } + } + return success; + } + + /** + * 解析批量提取需要覆盖的算法版本 + * + * @param deviceId 指定门锁时只提该锁版本;为空时取该人员人脸授权门锁的版本集合 + */ + private List resolveRequiredVersions(String personNo, Long deviceId) + { + if (deviceId != null) + { + LockDevice device = deviceMapper.selectDeviceById(deviceId); + String version = device == null ? null : StringUtils.trim(device.getFaceAlgoVersion()); + return StringUtils.isEmpty(version) ? Collections.emptyList() : Collections.singletonList(version); + } + List versions = authMapper.selectFaceAlgoVersionsByPersonNo(personNo); + if (versions != null && !versions.isEmpty()) + { + return versions; + } + String fallback = resolveAlgoVersion(personNo, null); + return StringUtils.isEmpty(fallback) ? Collections.emptyList() : Collections.singletonList(fallback); + } +} diff --git a/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/impl/LockStayServiceImpl.java b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/impl/LockStayServiceImpl.java new file mode 100644 index 0000000..8a5cbba --- /dev/null +++ b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/impl/LockStayServiceImpl.java @@ -0,0 +1,831 @@ +package com.ruoyi.iot.lock.service.impl; + +import java.text.SimpleDateFormat; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.common.utils.uuid.IdUtils; +import com.ruoyi.iot.lock.LockConstants; +import com.ruoyi.iot.lock.domain.LockAccessLog; +import com.ruoyi.iot.lock.domain.LockAuth; +import com.ruoyi.iot.lock.domain.LockDevice; +import com.ruoyi.iot.lock.domain.LockFace; +import com.ruoyi.iot.lock.domain.LockFaceFeature; +import com.ruoyi.iot.lock.domain.LockPersonRef; +import com.ruoyi.iot.lock.domain.LockRoom; +import com.ruoyi.iot.lock.domain.LockStay; +import com.ruoyi.iot.lock.domain.LockStayCheckinBody; +import com.ruoyi.iot.lock.domain.LockStayExternalDetail; +import com.ruoyi.iot.lock.mapper.LockAuthMapper; +import com.ruoyi.iot.lock.mapper.LockAccessLogMapper; +import com.ruoyi.iot.lock.mapper.LockDeviceMapper; +import com.ruoyi.iot.lock.mapper.LockFaceFeatureMapper; +import com.ruoyi.iot.lock.mapper.LockPersonRefMapper; +import com.ruoyi.iot.lock.mapper.LockRoomMapper; +import com.ruoyi.iot.lock.mapper.LockStayMapper; +import com.ruoyi.iot.lock.service.ILockAuthService; +import com.ruoyi.iot.lock.service.ILockFaceService; +import com.ruoyi.iot.lock.service.ILockStayService; +import com.ruoyi.iot.openapi.CallbackConstants; +import com.ruoyi.iot.openapi.config.OpenAppRegistry; +import com.ruoyi.iot.openapi.config.OpenApiProperties; +import com.ruoyi.iot.openapi.service.ILockCallbackService; +import com.ruoyi.iot.person.domain.LockPerson; +import com.ruoyi.iot.person.service.ILockPersonService; + +/** + * 入住单服务实现 + *

+ * 入住 / 退房都翻译成既有授权操作: + *

+ * 人员建档与特征提取在入住事务之外执行,提取失败只回滚入住,不回滚已建的人员/人脸。 + * + * @author ruoyi + */ +@Service +public class LockStayServiceImpl implements ILockStayService +{ + private static final Logger log = LoggerFactory.getLogger(LockStayServiceImpl.class); + + /** 房态:空闲 */ + private static final String ROOM_STATUS_FREE = "0"; + + /** 房态:在住 */ + private static final String ROOM_STATUS_OCCUPIED = "1"; + + /** 房态:维修 */ + private static final String ROOM_STATUS_REPAIR = "2"; + + /** 房态:停用 */ + private static final String ROOM_STATUS_DISABLED = "3"; + + /** 入住状态:在住 */ + private static final String STAY_STATUS_ACTIVE = "1"; + + /** 人员类型:住户 / 员工(内部) */ + private static final String PERSON_TYPE_RESIDENT = "0"; + + private static final String PERSON_TYPE_STAFF = "1"; + + /** 访客(外部) */ + private static final String PERSON_TYPE_VISITOR = "2"; + + /** 单据来源:平台办理 */ + private static final String STAY_SOURCE_PLATFORM = "1"; + + /** 删除标志:已删除 */ + private static final String DEL_FLAG_DELETED = "2"; + + @Autowired + private LockStayMapper stayMapper; + + @Autowired + private LockRoomMapper roomMapper; + + @Autowired + private LockDeviceMapper deviceMapper; + + @Autowired + private LockAuthMapper authMapper; + + @Autowired + private LockFaceFeatureMapper faceFeatureMapper; + + @Autowired + private ILockAuthService authService; + + @Autowired + private ILockFaceService faceService; + + @Autowired + private ILockPersonService personService; + + @Autowired + private ILockCallbackService callbackService; + + @Autowired + private LockPersonRefMapper personRefMapper; + + @Autowired + private LockAccessLogMapper accessLogMapper; + + @Autowired + private OpenAppRegistry openAppRegistry; + + @Autowired + private TransactionTemplate transactionTemplate; + + @Override + public List selectStayList(LockStay stay) + { + List list = stayMapper.selectStayList(stay); + for (LockStay row : list) + { + fillBizSystemName(row); + } + return list; + } + + /** 列表回填外部系统名称;应用已从开放管理删除时保持为空,前端回退展示编码 */ + private void fillBizSystemName(LockStay stay) + { + if (stay == null || StringUtils.isEmpty(stay.getBizSystem())) + { + return; + } + OpenApiProperties.App app = openAppRegistry.getApp(stay.getBizSystem()); + if (app != null && StringUtils.isNotEmpty(app.getAppName())) + { + stay.setBizSystemName(app.getAppName()); + } + } + + @Override + public LockStay selectStayById(Long stayId) + { + return stayMapper.selectStayById(stayId); + } + + @Override + public List selectActiveStaysByPersonNo(String personNo) + { + return stayMapper.selectActiveStaysByPersonNo(StringUtils.trim(personNo)); + } + + @Override + public List selectStaysByRoomId(Long roomId) + { + return stayMapper.selectStaysByRoomId(roomId); + } + + @Override + public LockStayExternalDetail selectExternalDetail(Long stayId) + { + LockStay stay = stayMapper.selectStayById(stayId); + if (stay == null) + { + throw new IllegalArgumentException("入住单不存在"); + } + LockStayExternalDetail detail = new LockStayExternalDetail(); + detail.setStayId(stay.getStayId()); + detail.setStayNo(stay.getStayNo()); + detail.setSource(stay.getSource()); + detail.setBizSystem(stay.getBizSystem()); + detail.setBizNo(stay.getBizNo()); + detail.setPersonNo(stay.getPersonNo()); + detail.setPersonName(stay.getPersonName()); + detail.setRoomCode(stay.getRoomCode()); + detail.setRoomNo(stay.getRoomNo()); + detail.setDeviceSn(stay.getDeviceSn()); + detail.setBeginTime(stay.getBeginTime()); + detail.setEndTime(stay.getEndTime()); + detail.setCheckinTime(stay.getCheckinTime()); + detail.setCheckoutTime(stay.getCheckoutTime()); + detail.setStayStatus(stay.getStayStatus()); + detail.setAuthStatus(stay.getAuthStatus()); + detail.setAuthMsg(stay.getAuthMsg()); + // 应用可能已从开放管理中删除,取不到名称时前端只展示编码 + OpenApiProperties.App app = openAppRegistry.getApp(stay.getBizSystem()); + if (app != null) + { + detail.setBizSystemName(app.getAppName()); + } + LockPersonRef ref = personRefMapper.selectByAppAndPersonNo(stay.getBizSystem(), stay.getPersonNo()); + if (ref != null) + { + detail.setExternalPersonId(ref.getExternalId()); + } + return detail; + } + + @Override + public List selectStayAccessLogs(Long stayId, String openType, String openResult) + { + if (stayId == null) + { + return Collections.emptyList(); + } + return accessLogMapper.selectAccessLogListByStay(stayId, StringUtils.trim(openType), + StringUtils.trim(openResult)); + } + + @Override + public LockStay checkin(LockStayCheckinBody body) + { + validateCheckin(body); + // 预校验房间与门锁,避免为无效房间建档/提取照片 + LockRoom room = roomMapper.selectRoomById(body.getRoomId()); + if (room == null) + { + throw new IllegalArgumentException("房间不存在"); + } + if (room.getDeviceId() == null) + { + throw new IllegalArgumentException("房间未分配门锁,请先在房间管理页分配门锁"); + } + LockDevice device = deviceMapper.selectDeviceById(room.getDeviceId()); + if (device == null) + { + throw new IllegalArgumentException("房间绑定的门锁不存在"); + } + // 1) 人员与特征(独立事务,提取是外部调用,不能放进入住事务) + LockPerson person = preparePerson(body, device); + // 2) 入住主流程(房间行锁 + 入住单 + 授权 + 房态) + return transactionTemplate.execute(status -> createStay(body, person)); + } + + @Override + public int checkout(Long stayId) + { + // 人员清理放在退房事务提交之后:清除指令已经下发,清理失败不能把退房一起回滚 + LockStay stay = transactionTemplate.execute(status -> doCheckout(stayId)); + if (stay == null) + { + return 0; + } + cleanupExternalPerson(stay); + return 1; + } + + private LockStay doCheckout(Long stayId) + { + LockStay stay = stayMapper.selectStayById(stayId); + if (stay == null) + { + throw new IllegalArgumentException("入住单不存在"); + } + if (!STAY_STATUS_ACTIVE.equals(stay.getStayStatus())) + { + throw new IllegalArgumentException("只有在住状态的入住单可以退房"); + } + // 锁房间行,避免与其它入住/退房并发 + roomMapper.selectRoomByIdForUpdate(stay.getRoomId()); + Date now = new Date(); + if (stayMapper.updateCheckout(stayId, now) == 0) + { + throw new IllegalArgumentException("入住单状态已变更,请刷新后重试"); + } + refreshRoomOccupancy(stay.getRoomId()); + // 删除本房门锁上该人员的人脸;失败不影响退房本身,授权状态由回执/人工跟进 + if (stay.getAuthId() != null) + { + try + { + authService.clear(stay.getAuthId()); + } + catch (Exception e) + { + log.warn("退房删除人脸失败, stayId={}, authId={}, msg={}", stayId, stay.getAuthId(), e.getMessage()); + } + LockAuth cleared = authMapper.selectAuthById(stay.getAuthId()); + if (cleared != null) + { + registerCallback(stay, cleared, CallbackConstants.EVENT_CHECKOUT); + } + } + return stay; + } + + @Override + public LockStay changeRoom(Long stayId, Long roomId, Date endTime) + { + LockStay oldStay = stayMapper.selectStayById(stayId); + if (oldStay == null) + { + throw new IllegalArgumentException("入住单不存在"); + } + if (!STAY_STATUS_ACTIVE.equals(oldStay.getStayStatus())) + { + throw new IllegalArgumentException("只有在住状态的入住单可以换房"); + } + if (roomId == null) + { + throw new IllegalArgumentException("请选择目标房间"); + } + if (roomId.equals(oldStay.getRoomId())) + { + throw new IllegalArgumentException("目标房间与当前房间相同"); + } + LockPerson person = requireExistingPerson(oldStay.getPersonNo()); + LockRoom target = roomMapper.selectRoomById(roomId); + if (target == null) + { + throw new IllegalArgumentException("房间不存在"); + } + // 不限制入住人数:空闲/在住都可继续办理,只有维修/停用不可 + if (ROOM_STATUS_REPAIR.equals(target.getRoomStatus()) + || ROOM_STATUS_DISABLED.equals(target.getRoomStatus())) + { + throw new IllegalArgumentException("目标房间处于维修/停用状态,不可入住"); + } + if (target.getDeviceId() == null) + { + throw new IllegalArgumentException("目标房间未分配门锁"); + } + LockDevice device = deviceMapper.selectDeviceById(target.getDeviceId()); + if (device == null) + { + throw new IllegalArgumentException("目标房间绑定的门锁不存在"); + } + // 换房后旧房门锁人脸会删除,因此新房必须确认能下发人脸,否则人两处都进不去 + ensureFaceReady(person, device); + Date newEnd = endTime == null ? oldStay.getEndTime() : endTime; + if (newEnd == null || !newEnd.after(new Date())) + { + throw new IllegalArgumentException("预计退房时间必须晚于当前时间"); + } + return transactionTemplate.execute(status -> doChangeRoom(oldStay, person, target, device, newEnd)); + } + + private LockStay doChangeRoom(LockStay oldStay, LockPerson person, LockRoom target, LockDevice device, + Date newEnd) + { + LockRoom locked = roomMapper.selectRoomByIdForUpdate(target.getRoomId()); + if (locked == null) + { + throw new IllegalArgumentException("房间不存在"); + } + // 不限制入住人数:空闲/在住都可作为目标房间,只有维修/停用不可 + if (ROOM_STATUS_REPAIR.equals(locked.getRoomStatus()) + || ROOM_STATUS_DISABLED.equals(locked.getRoomStatus())) + { + throw new IllegalArgumentException("目标房间处于维修/停用状态,不可入住"); + } + Date now = new Date(); + String stayNo = nextStayNo(); + LockAuth auth = new LockAuth(); + auth.setPersonNo(person.getPersonNo()); + auth.setPersonName(person.getPersonName()); + auth.setDeviceId(device.getDeviceId()); + auth.setAuthType(LockConstants.AUTH_FACE); + auth.setBeginTime(now); + auth.setEndTime(newEnd); + auth.setRemark("换房 " + stayNo); + authService.insertAuth(auth); + + LockStay newStay = new LockStay(); + newStay.setStayNo(stayNo); + newStay.setPersonId(person.getPersonId()); + newStay.setPersonNo(person.getPersonNo()); + newStay.setPersonName(person.getPersonName()); + newStay.setGuestType(oldStay.getGuestType()); + newStay.setRoomId(locked.getRoomId()); + newStay.setRoomCode(locked.getRoomCode()); + newStay.setDeviceId(device.getDeviceId()); + newStay.setDeviceSn(device.getDeviceSn()); + newStay.setAuthId(auth.getAuthId()); + newStay.setBeginTime(now); + newStay.setEndTime(newEnd); + newStay.setCheckinTime(now); + newStay.setChangeFromId(oldStay.getStayId()); + newStay.setStayStatus(STAY_STATUS_ACTIVE); + newStay.setAuthStatus(LockConstants.SYNC_PENDING); + newStay.setRemark(oldStay.getRemark()); + // 新单仍归属原业务系统,但不继承 biz_no:(biz_system, biz_no) 是唯一键, + // 沿用会与原单撞键;对方仍可按「人员 + 房间」定位换房后的在住单 + newStay.setBizSystem(oldStay.getBizSystem()); + newStay.setSource(StringUtils.isEmpty(oldStay.getSource()) ? STAY_SOURCE_PLATFORM : oldStay.getSource()); + stayMapper.insertStay(newStay); + + LockAuth savedAuth = authMapper.selectAuthById(auth.getAuthId()); + if (savedAuth != null) + { + newStay.setAuthStatus(savedAuth.getSyncStatus()); + newStay.setAuthMsg(savedAuth.getSyncMsg()); + stayMapper.updateAuthStatusByAuthId(auth.getAuthId(), savedAuth.getSyncStatus(), savedAuth.getSyncMsg()); + } + refreshRoomOccupancy(locked.getRoomId()); + + // 结束旧单并释放旧房;旧单条件更新失败说明并发已处理,整体回滚 + if (stayMapper.updateChanged(oldStay.getStayId(), now) == 0) + { + throw new IllegalArgumentException("原入住单状态已变更,请刷新后重试"); + } + refreshRoomOccupancy(oldStay.getRoomId()); + // 删除旧房门锁上该人员的人脸;失败不影响新单,旧人脸保留到其有效期结束 + if (oldStay.getAuthId() != null) + { + try + { + authService.clear(oldStay.getAuthId()); + } + catch (Exception e) + { + log.warn("换房删除旧房人脸失败, stayId={}, authId={}, msg={}", oldStay.getStayId(), + oldStay.getAuthId(), e.getMessage()); + } + } + log.info("换房完成, oldStayId={}, newStayNo={}, personNo={}, fromRoom={}, toRoom={}", oldStay.getStayId(), + stayNo, person.getPersonNo(), oldStay.getRoomCode(), locked.getRoomCode()); + return newStay; + } + + @Override + @Transactional + public int extend(Long stayId, Date endTime) + { + LockStay stay = stayMapper.selectStayById(stayId); + if (stay == null) + { + throw new IllegalArgumentException("入住单不存在"); + } + if (!STAY_STATUS_ACTIVE.equals(stay.getStayStatus())) + { + throw new IllegalArgumentException("只有在住状态的入住单可以续住"); + } + if (endTime == null) + { + throw new IllegalArgumentException("请选择新的预计退房时间"); + } + if (!endTime.after(new Date())) + { + throw new IllegalArgumentException("预计退房时间必须晚于当前时间"); + } + if (stay.getEndTime() != null && !endTime.after(stay.getEndTime())) + { + throw new IllegalArgumentException("新的预计退房时间必须晚于当前预计退房时间"); + } + int rows = stayMapper.updateEndTime(stayId, endTime); + if (rows == 0) + { + throw new IllegalArgumentException("入住单状态已变更,请刷新后重试"); + } + if (stay.getAuthId() != null) + { + LockAuth auth = authMapper.selectAuthById(stay.getAuthId()); + if (auth != null) + { + // 重新下发 add 帧,更新设备侧授权有效期 + auth.setEndTime(endTime); + authService.updateAuth(auth); + LockAuth latest = authMapper.selectAuthById(stay.getAuthId()); + if (latest != null) + { + registerCallback(stay, latest, CallbackConstants.EVENT_EXTEND); + } + } + } + log.info("续住完成, stayNo={}, personNo={}, newEndTime={}", stay.getStayNo(), stay.getPersonNo(), endTime); + return rows; + } + + @Override + public int expireStays(int limit) + { + int batch = limit <= 0 ? 200 : limit; + List expired = stayMapper.selectExpiredStays(new Date(), batch); + int count = 0; + for (LockStay stay : expired) + { + try + { + Boolean done = transactionTemplate.execute(status -> expireOne(stay)); + if (Boolean.TRUE.equals(done)) + { + count++; + cleanupExternalPerson(stay); + } + } + catch (Exception e) + { + // 单条失败不影响其它入住单 + log.warn("入住到期处理失败, stayId={}, msg={}", stay.getStayId(), e.getMessage()); + } + } + if (count > 0) + { + log.info("入住到期处理完成, count={}, batch={}", count, expired.size()); + } + return count; + } + + /** 单条到期处理:条件更新防止与退房/换房并发冲突 */ + private boolean expireOne(LockStay stay) + { + if (stayMapper.updateExpire(stay.getStayId()) == 0) + { + return false; + } + refreshRoomOccupancy(stay.getRoomId()); + if (stay.getAuthId() != null) + { + // 设备侧授权已按有效期自动失效;本地置「已清除」以停止补偿,避免到期后又把人脸推回设备 + authMapper.updateSyncStatus(stay.getAuthId(), LockConstants.SYNC_CLEARED, "入住到期,平台置为已清除"); + } + log.info("入住到期自动退房, stayNo={}, personNo={}, roomCode={}", stay.getStayNo(), stay.getPersonNo(), + stay.getRoomCode()); + return true; + } + + /** + * 退房(含到期自动退房)清理外部接口自动建档的人员:本地照片文件、人脸、各版本特征、人员档案 + * 一并删除,外部标识映射保留,下次入住时重新建档并改指。 + *

+ * 只处理开放接口建档(create_by=openapi:*)、未绑定平台账号,且名下已无其它未结束入住单 + * 与未清除授权的人员;任一守卫不满足都保留档案,避免误删仍要下发或仍在设备上生效的授权。 + * 映射不删的原因:退房回调可能在设备回执后才生成报文,仍需按旧 person_no 解析 externalPersonId。 + *

+ * 在退房事务提交后独立执行,失败只记日志,不影响已完成的退房。 + */ + private void cleanupExternalPerson(LockStay stay) + { + if (stay == null || StringUtils.isEmpty(stay.getBizSystem()) || stay.getPersonId() == null) + { + return; + } + try + { + transactionTemplate.executeWithoutResult(status -> doCleanupExternalPerson(stay)); + } + catch (Exception e) + { + log.warn("退房清理外部人员失败, stayNo={}, personNo={}, msg={}", stay.getStayNo(), stay.getPersonNo(), + e.getMessage()); + } + } + + private void doCleanupExternalPerson(LockStay stay) + { + LockPerson person = personService.selectPersonById(stay.getPersonId()); + if (person == null || DEL_FLAG_DELETED.equals(person.getDelFlag()) + || !StringUtils.startsWith(StringUtils.trim(person.getCreateBy()), LockConstants.CREATE_BY_OPENAPI) + || person.getSysUserId() != null) + { + return; + } + if (stayMapper.countActiveStaysByPersonNo(person.getPersonNo(), stay.getStayId()) > 0) + { + log.debug("人员仍有其它入住单,保留档案, personNo={}", person.getPersonNo()); + return; + } + // 本单授权刚发起清除、回执可能未到,不计入;其余未清除授权说明人员仍在别的门锁上有效 + if (authMapper.countActiveAuthsByPersonNo(person.getPersonNo(), stay.getAuthId()) > 0) + { + log.info("人员仍有未清除授权,保留档案, personNo={}, stayNo={}", person.getPersonNo(), stay.getStayNo()); + return; + } + LockFace face = faceService.selectFaceByPersonNo(person.getPersonNo()); + if (face != null) + { + // 先删照片文件再删记录:记录删掉后就再也定位不到这张照片;特征是派生数据随人脸一起删 + faceService.deleteLocalPhotoFile(face.getPhotoUrl()); + faceService.deleteFaceByIds(new Long[] { face.getFaceId() }); + } + personService.deletePersonRecord(person.getPersonId()); + log.info("退房清理外部人员完成, stayNo={}, personNo={}, faceId={}", stay.getStayNo(), person.getPersonNo(), + face == null ? null : face.getFaceId()); + } + + /** 换房前确认目标门锁能取到该人员对应算法版本的人脸特征(门锁未配版本时由下发逻辑回退) */ + private void ensureFaceReady(LockPerson person, LockDevice device) + { + String version = StringUtils.trim(device.getFaceAlgoVersion()); + if (StringUtils.isEmpty(version)) + { + return; + } + LockFaceFeature feature = faceFeatureMapper.selectUsableFeature(person.getPersonNo(), version); + if (feature == null || StringUtils.isEmpty(feature.getFeatureData())) + { + throw new IllegalArgumentException("该人员缺少门锁算法版本(" + version + + ")的人脸特征,请先在人脸数据页按该版本提取后再换房"); + } + } + + /** + * 解析人员:newPerson 非空时建档 + 建人脸 + 按目标门锁版本提取特征 + */ + private LockPerson preparePerson(LockStayCheckinBody body, LockDevice device) + { + if (body.getNewPerson() == null) + { + return requireExistingPerson(body.getPersonNo()); + } + LockStayCheckinBody.NewPerson np = body.getNewPerson(); + if (StringUtils.isEmpty(StringUtils.trim(np.getPhotoUrl()))) + { + throw new IllegalArgumentException("外部人员必须上传人脸照片"); + } + if (StringUtils.isEmpty(StringUtils.trim(np.getPersonName()))) + { + throw new IllegalArgumentException("外部人员姓名不能为空"); + } + LockPerson created = new LockPerson(); + created.setPersonNo(StringUtils.trim(np.getPersonNo())); + created.setPersonName(StringUtils.trim(np.getPersonName())); + created.setPersonType(StringUtils.isEmpty(np.getPersonType()) ? PERSON_TYPE_VISITOR + : StringUtils.trim(np.getPersonType())); + created.setIdCardNo(StringUtils.trim(np.getIdCardNo())); + created.setPhone(StringUtils.trim(np.getPhone())); + created.setStatus(LockConstants.PERSON_STATUS_NORMAL); + // personNo 留空时由人员服务自动生成 + personService.insertPerson(created); + + LockFace face = new LockFace(); + face.setPersonNo(created.getPersonNo()); + face.setPersonName(created.getPersonName()); + face.setPhotoUrl(StringUtils.trim(np.getPhotoUrl())); + // 人脸记录状态:0 正常 + face.setStatus("0"); + faceService.insertFace(face); + + String feature = faceService.extract(face.getFaceId(), device.getDeviceId()); + if (StringUtils.isEmpty(feature)) + { + // 人员与人脸记录保留,便于重传照片后再次办理入住 + throw new IllegalArgumentException("人脸特征提取失败,请检查照片质量或门锁算法版本配置后重试"); + } + // 回填 body 的 personNo,后续建档走同一人员 + body.setPersonNo(created.getPersonNo()); + return created; + } + + private LockPerson requireExistingPerson(String personNo) + { + String trimmed = StringUtils.trim(personNo); + if (StringUtils.isEmpty(trimmed)) + { + throw new IllegalArgumentException("请选择人员或填写新建人员信息"); + } + LockPerson person = personService.selectPersonByNo(trimmed); + if (person == null) + { + throw new IllegalArgumentException("人员主数据不存在:" + trimmed); + } + if (!LockConstants.PERSON_STATUS_NORMAL.equals(person.getStatus())) + { + throw new IllegalArgumentException("人员已停用,无法办理入住:" + trimmed); + } + Date now = new Date(); + if (person.getValidFrom() != null && person.getValidFrom().after(now)) + { + throw new IllegalArgumentException("人员尚未到生效时间,无法办理入住:" + trimmed); + } + if (person.getValidTo() != null && person.getValidTo().before(now)) + { + throw new IllegalArgumentException("人员已过有效期,无法办理入住:" + trimmed); + } + return person; + } + + /** + * 入住主流程(事务内):复核房态 → 建授权 → 建入住单 → 更新房态 + */ + private LockStay createStay(LockStayCheckinBody body, LockPerson person) + { + LockRoom room = roomMapper.selectRoomByIdForUpdate(body.getRoomId()); + if (room == null) + { + throw new IllegalArgumentException("房间不存在"); + } + // 不限制房间入住人数:空闲/在住都可继续办理,只有维修/停用不可入住 + if (ROOM_STATUS_REPAIR.equals(room.getRoomStatus()) || ROOM_STATUS_DISABLED.equals(room.getRoomStatus())) + { + throw new IllegalArgumentException("房间处于维修/停用状态,不可办理入住"); + } + if (room.getDeviceId() == null) + { + throw new IllegalArgumentException("房间未分配门锁,请先分配门锁"); + } + LockDevice device = deviceMapper.selectDeviceById(room.getDeviceId()); + if (device == null) + { + throw new IllegalArgumentException("房间绑定的门锁不存在"); + } + + String stayNo = nextStayNo(); + LockAuth auth = new LockAuth(); + auth.setPersonNo(person.getPersonNo()); + auth.setPersonName(person.getPersonName()); + auth.setDeviceId(device.getDeviceId()); + auth.setAuthType(LockConstants.AUTH_FACE); + // 补录历史入住时不能下发一个已失效的授权,生效起取 max(now, begin) + Date begin = body.getBeginTime().before(new Date()) ? new Date() : body.getBeginTime(); + auth.setBeginTime(begin); + auth.setEndTime(body.getEndTime()); + auth.setRemark("入住 " + stayNo); + authService.insertAuth(auth); + + LockStay stay = new LockStay(); + stay.setStayNo(stayNo); + stay.setPersonId(person.getPersonId()); + stay.setPersonNo(person.getPersonNo()); + stay.setPersonName(person.getPersonName()); + stay.setGuestType(resolveGuestType(body.getGuestType(), person)); + stay.setRoomId(room.getRoomId()); + stay.setRoomCode(room.getRoomCode()); + stay.setDeviceId(device.getDeviceId()); + stay.setDeviceSn(device.getDeviceSn()); + stay.setAuthId(auth.getAuthId()); + stay.setBeginTime(body.getBeginTime()); + stay.setEndTime(body.getEndTime()); + stay.setCheckinTime(new Date()); + stay.setStayStatus(STAY_STATUS_ACTIVE); + stay.setAuthStatus(LockConstants.SYNC_PENDING); + stay.setRemark(body.getRemark()); + String bizNo = StringUtils.trim(body.getBizNo()); + String bizSystem = StringUtils.trim(body.getBizSystem()); + // uk_lock_stay_biz(biz_system, biz_no) 只允许多行 NULL,空串会互相撞键, + // 平台手工单必须落 NULL,否则第二次办理入住即 Duplicate entry '' + stay.setBizNo(StringUtils.isEmpty(bizNo) ? null : bizNo); + stay.setBizSystem(StringUtils.isEmpty(bizSystem) ? null : bizSystem); + // insertStay 显式列出 source 列,为空会写入 NULL 而不是列默认值 '1',这里补默认 + stay.setSource(StringUtils.isEmpty(StringUtils.trim(body.getSource())) ? STAY_SOURCE_PLATFORM + : StringUtils.trim(body.getSource())); + stayMapper.insertStay(stay); + + // 授权可能因门锁无路由/缺特征在创建时即失败,回读真实状态写入入住单 + LockAuth savedAuth = authMapper.selectAuthById(auth.getAuthId()); + if (savedAuth != null) + { + stay.setAuthStatus(savedAuth.getSyncStatus()); + stay.setAuthMsg(savedAuth.getSyncMsg()); + stayMapper.updateAuthStatusByAuthId(auth.getAuthId(), savedAuth.getSyncStatus(), savedAuth.getSyncMsg()); + } + + refreshRoomOccupancy(room.getRoomId()); + registerCallback(stay, savedAuth == null ? auth : savedAuth, CallbackConstants.EVENT_CHECKIN); + log.info("入住登记完成, stayNo={}, personNo={}, roomCode={}, deviceSn={}, authId={}", stayNo, + person.getPersonNo(), room.getRoomCode(), device.getDeviceSn(), auth.getAuthId()); + return stay; + } + + /** + * 登记对外回调事件。回调是旁路能力:登记失败只记日志,不能让入住/退房/续住回滚, + * 任务异常可通过回调任务列表人工排查与重发。 + */ + private void registerCallback(LockStay stay, LockAuth auth, String eventType) + { + try + { + callbackService.registerStayEvent(stay.getStayId(), auth, eventType); + } + catch (Exception e) + { + log.warn("回调事件登记失败, stayNo={}, eventType={}, msg={}", stay.getStayNo(), eventType, e.getMessage()); + } + } + + /** + * 重算房间占用:一房可住多人,仍有在住单就保持「在住」并记录最近一单, + * 全部退房后才置「空闲」并清空冗余字段。 + */ + private void refreshRoomOccupancy(Long roomId) + { + if (roomId == null) + { + return; + } + List active = stayMapper.selectActiveStaysByRoomId(roomId); + if (active.isEmpty()) + { + roomMapper.updateRoomOccupancy(roomId, ROOM_STATUS_FREE, null, null); + return; + } + LockStay latest = active.get(0); + roomMapper.updateRoomOccupancy(roomId, ROOM_STATUS_OCCUPIED, latest.getStayId(), latest.getPersonNo()); + } + + private void validateCheckin(LockStayCheckinBody body) + { + if (body == null || body.getRoomId() == null) + { + throw new IllegalArgumentException("请选择房间"); + } + if (body.getBeginTime() == null || body.getEndTime() == null) + { + throw new IllegalArgumentException("请填写入住开始与结束时间"); + } + if (!body.getEndTime().after(body.getBeginTime())) + { + throw new IllegalArgumentException("预计退房时间必须晚于入住开始时间"); + } + } + + /** 入住类型:优先取下拉值,未填时按人员类型映射(0住户/1员工 → 内部,其余 → 外部) */ + private String resolveGuestType(String guestType, LockPerson person) + { + if (StringUtils.isNotEmpty(StringUtils.trim(guestType))) + { + return StringUtils.trim(guestType); + } + String personType = person.getPersonType(); + return PERSON_TYPE_RESIDENT.equals(personType) || PERSON_TYPE_STAFF.equals(personType) ? "0" : "1"; + } + + private String nextStayNo() + { + return "STAY" + new SimpleDateFormat("yyyyMMddHHmmss").format(new Date()) + + StringUtils.substring(IdUtils.fastSimpleUUID(), 0, 4).toUpperCase(); + } +} \ No newline at end of file diff --git a/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/openapi/service/impl/OpenStayServiceImpl.java b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/openapi/service/impl/OpenStayServiceImpl.java new file mode 100644 index 0000000..5b69aff --- /dev/null +++ b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/openapi/service/impl/OpenStayServiceImpl.java @@ -0,0 +1,770 @@ +package com.ruoyi.iot.openapi.service.impl; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Base64; +import java.util.Date; +import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import com.ruoyi.common.config.RuoYiConfig; +import com.ruoyi.common.constant.Constants; +import com.ruoyi.common.utils.DateUtils; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.common.utils.uuid.IdUtils; +import com.ruoyi.iot.lock.LockConstants; +import com.ruoyi.iot.lock.domain.LockDevice; +import com.ruoyi.iot.lock.domain.LockFace; +import com.ruoyi.iot.lock.domain.LockFaceFeature; +import com.ruoyi.iot.lock.domain.LockPersonRef; +import com.ruoyi.iot.lock.domain.LockRoom; +import com.ruoyi.iot.lock.domain.LockStay; +import com.ruoyi.iot.lock.domain.LockStayCheckinBody; +import com.ruoyi.iot.lock.mapper.LockDeviceMapper; +import com.ruoyi.iot.lock.mapper.LockPersonRefMapper; +import com.ruoyi.iot.lock.mapper.LockRoomMapper; +import com.ruoyi.iot.lock.mapper.LockStayMapper; +import com.ruoyi.iot.lock.service.ILockFaceService; +import com.ruoyi.iot.lock.service.ILockStayService; +import com.ruoyi.iot.openapi.OpenApiCodes; +import com.ruoyi.iot.openapi.OpenApiException; +import com.ruoyi.iot.openapi.config.OpenAppRegistry; +import com.ruoyi.iot.openapi.config.OpenApiProperties.App; +import com.ruoyi.iot.openapi.domain.OpenStayCheckinRequest; +import com.ruoyi.iot.openapi.domain.OpenStayCheckoutRequest; +import com.ruoyi.iot.openapi.domain.OpenStayExtendRequest; +import com.ruoyi.iot.openapi.domain.OpenStayResult; +import com.ruoyi.iot.openapi.service.IOpenStayService; +import com.ruoyi.iot.person.domain.LockPerson; +import com.ruoyi.iot.person.service.ILockPersonService; + +/** + * 开放接口-住宿业务服务实现 + *

+ * 复用住宿模块:入住/退房/续住最终都落到 {@link ILockStayService},本类只负责 + * 对外契约相关的三件事——按 {@code appCode} 隔离与幂等、外部人员标识映射、 + * 头像解析与按门锁算法版本提取特征。 + *

+ * 人员建档与特征提取在入住事务之外(提取是外部 HTTP 调用),失败时人员与人脸 + * 记录保留,便于对方修正照片后用同一 {@code bizNo} 之外的新单重试。 + * + * @author ruoyi + */ +@Service +public class OpenStayServiceImpl implements IOpenStayService +{ + private static final Logger log = LoggerFactory.getLogger(OpenStayServiceImpl.class); + + /** 对外时间格式 */ + private static final String TIME_PATTERN = "yyyy-MM-dd HH:mm:ss"; + + /** 单据来源:接口办理 */ + private static final String STAY_SOURCE_API = "2"; + + /** 入住状态:在住 */ + private static final String STAY_STATUS_ACTIVE = "1"; + + /** 房态:维修 / 停用(不可办理入住) */ + private static final String ROOM_STATUS_REPAIR = "2"; + + private static final String ROOM_STATUS_DISABLED = "3"; + + /** 人员类型默认值:访客 */ + private static final String PERSON_TYPE_VISITOR = "2"; + + /** 人脸记录状态:正常 */ + private static final String FACE_STATUS_NORMAL = "0"; + + /** base64 头像解码后上限(2MB) */ + private static final int PHOTO_MAX_BYTES = 2 * 1024 * 1024; + + /** 删除标志:已删除 */ + private static final String DEL_FLAG_DELETED = "2"; + + @Autowired + private LockStayMapper stayMapper; + + @Autowired + private LockRoomMapper roomMapper; + + @Autowired + private LockDeviceMapper deviceMapper; + + @Autowired + private LockPersonRefMapper personRefMapper; + + @Autowired + private ILockStayService stayService; + + @Autowired + private ILockPersonService personService; + + @Autowired + private ILockFaceService faceService; + + @Autowired + private OpenAppRegistry openAppRegistry; + + @Override + public OpenStayResult checkin(String appCode, OpenStayCheckinRequest request) + { + if (request == null) + { + throw new OpenApiException(OpenApiCodes.PARAM_INVALID, "请求体不能为空"); + } + String bizNo = requireText(request.getBizNo(), "bizNo"); + String externalPersonId = requireText(request.getExternalPersonId(), "externalPersonId"); + String personName = requireText(request.getPersonName(), "personName"); + String roomNo = requireText(request.getRoomNo(), "roomNo"); + Date beginTime = parseTime(request.getBeginTime(), "beginTime"); + Date endTime = parseTime(request.getEndTime(), "endTime"); + if (!endTime.after(beginTime)) + { + throw new OpenApiException(OpenApiCodes.TIME_INVALID, "endTime 必须晚于 beginTime"); + } + + // 幂等:同一应用下 bizNo 只建一单,重复提交返回首次结果与当前状态 + LockStay existing = stayMapper.selectStayByBiz(appCode, bizNo); + if (existing != null) + { + return repeatCheckin(existing, appCode, bizNo, externalPersonId, roomNo); + } + // 每次入住都必须带照片:平台不再沿用历史照片,缺参直接在建档前拒绝,避免留下孤立人员 + if (StringUtils.isEmpty(StringUtils.trim(request.getPhotoUrl())) + && StringUtils.isEmpty(StringUtils.trim(request.getPhotoBase64()))) + { + throw new OpenApiException(OpenApiCodes.PARAM_INVALID, "photoUrl 与 photoBase64 必须提供一个"); + } + + LockRoom room = requireUniqueRoom(roomNo); + requireCheckinable(room); + LockDevice device = requireRoomDevice(room); + + String personNo = resolvePerson(appCode, externalPersonId, personName, request, device); + // 同房多人允许办理,但要让对方知道该人员在本房间已有在住单 + boolean duplicate = hasActiveStayInRoom(personNo, room.getRoomId()); + + LockStayCheckinBody body = new LockStayCheckinBody(); + body.setRoomId(room.getRoomId()); + body.setPersonNo(personNo); + body.setGuestType(request.getGuestType()); + body.setBeginTime(beginTime); + body.setEndTime(endTime); + body.setRemark(request.getRemark()); + body.setBizNo(bizNo); + body.setBizSystem(appCode); + body.setSource(STAY_SOURCE_API); + + LockStay stay; + try + { + stay = stayService.checkin(body); + } + catch (DuplicateKeyException e) + { + // 并发下同一 bizNo 已被另一请求建单:回查后按幂等返回,不重复建房 + LockStay winner = stayMapper.selectStayByBiz(appCode, bizNo); + if (winner == null) + { + throw new OpenApiException(OpenApiCodes.INTERNAL_ERROR, "入住单写入冲突,请重试"); + } + log.warn("入住并发撞业务号, appCode={}, bizNo={}, stayNo={}", appCode, bizNo, winner.getStayNo()); + return toResult(winner, appCode); + } + catch (IllegalArgumentException e) + { + throw translateStayError(e.getMessage()); + } + // 入住单落库后回读,拿到联表的房号与回写后的授权状态 + LockStay saved = stayMapper.selectStayById(stay.getStayId()); + OpenStayResult result = toResult(saved == null ? stay : saved, appCode); + if (duplicate) + { + result.setDuplicatePersonInRoom(Boolean.TRUE); + } + log.info("开放接口入住受理, appCode={}, bizNo={}, stayNo={}, personNo={}, roomNo={}, authStatus={}", appCode, + bizNo, result.getStayNo(), personNo, roomNo, result.getAuthStatus()); + return result; + } + + @Override + public OpenStayResult checkout(String appCode, OpenStayCheckoutRequest request) + { + if (request == null) + { + throw new OpenApiException(OpenApiCodes.PARAM_INVALID, "请求体不能为空"); + } + LockStay stay = locateStay(appCode, request.getBizNo(), request.getPersonNo(), request.getExternalPersonId(), + request.getRoomNo()); + if (!STAY_STATUS_ACTIVE.equals(stay.getStayStatus())) + { + // 已退房/已换房:幂等返回当前状态,不重复下发删除 + log.info("开放接口退房幂等返回, appCode={}, stayNo={}, stayStatus={}", appCode, stay.getStayNo(), + stay.getStayStatus()); + return toResult(stay, appCode); + } + try + { + stayService.checkout(stay.getStayId()); + } + catch (IllegalArgumentException e) + { + throw new OpenApiException(OpenApiCodes.STAY_NOT_FOUND, e.getMessage()); + } + LockStay latest = stayMapper.selectStayById(stay.getStayId()); + log.info("开放接口退房受理, appCode={}, stayNo={}, personNo={}, roomNo={}", appCode, stay.getStayNo(), + stay.getPersonNo(), stay.getRoomNo()); + return toResult(latest == null ? stay : latest, appCode); + } + + @Override + public OpenStayResult extend(String appCode, OpenStayExtendRequest request) + { + if (request == null) + { + throw new OpenApiException(OpenApiCodes.PARAM_INVALID, "请求体不能为空"); + } + Date endTime = parseTime(request.getEndTime(), "endTime"); + LockStay stay = locateStay(appCode, request.getBizNo(), request.getPersonNo(), request.getExternalPersonId(), + request.getRoomNo()); + if (!STAY_STATUS_ACTIVE.equals(stay.getStayStatus())) + { + throw new OpenApiException(OpenApiCodes.STAY_NOT_FOUND, + "入住单不是在住状态,不能续住,stayNo=" + stay.getStayNo()); + } + if (!endTime.after(new Date())) + { + throw new OpenApiException(OpenApiCodes.TIME_INVALID, "endTime 必须晚于当前时间"); + } + Date previous = stay.getEndTime(); + if (previous != null && endTime.compareTo(previous) == 0) + { + // 与当前结束时间一致:幂等成功,不重复下发授权 + OpenStayResult result = toResult(stay, appCode); + result.setPreviousEndTime(previous); + return result; + } + if (previous != null && endTime.before(previous)) + { + throw new OpenApiException(OpenApiCodes.TIME_INVALID, + "endTime 必须晚于原结束时间 " + formatTime(previous)); + } + try + { + stayService.extend(stay.getStayId(), endTime); + } + catch (IllegalArgumentException e) + { + throw translateStayError(e.getMessage()); + } + LockStay latest = stayMapper.selectStayById(stay.getStayId()); + OpenStayResult result = toResult(latest == null ? stay : latest, appCode); + result.setPreviousEndTime(previous); + log.info("开放接口续住受理, appCode={}, stayNo={}, previousEndTime={}, endTime={}", appCode, stay.getStayNo(), + formatTime(previous), formatTime(endTime)); + return result; + } + + /** + * 定位入住单:优先 {@code bizNo},否则「人员标识 + 房号」。 + * 两条路径都带 {@code appCode} 过滤,跨应用的单号/人员一律视为不存在。 + */ + private LockStay locateStay(String appCode, String bizNo, String personNo, String externalPersonId, String roomNo) + { + String biz = StringUtils.trim(bizNo); + if (StringUtils.isNotEmpty(biz)) + { + LockStay stay = stayMapper.selectStayByBiz(appCode, biz); + if (stay == null) + { + throw new OpenApiException(OpenApiCodes.STAY_NOT_FOUND, + "入住单不存在或不属于当前应用,bizNo=" + biz); + } + return stay; + } + String resolvedPersonNo = resolvePersonNo(appCode, personNo, externalPersonId); + String no = StringUtils.trim(roomNo); + if (StringUtils.isEmpty(no)) + { + throw new OpenApiException(OpenApiCodes.PARAM_INVALID, "bizNo 或「personNo/externalPersonId + roomNo」必须提供其一"); + } + LockRoom room = requireUniqueRoom(no); + LockStay active = stayMapper.selectActiveStayByPersonAndRoom(resolvedPersonNo, room.getRoomId(), appCode); + if (active != null) + { + return active; + } + // 无在住单:已退房时按幂等返回,从未入住才是「不存在」 + LockStay latest = stayMapper.selectLatestStayByPersonAndRoom(resolvedPersonNo, room.getRoomId(), appCode); + if (latest == null) + { + throw new OpenApiException(OpenApiCodes.STAY_NOT_FOUND, + "未找到该人员在本应用下该房间的入住单,roomNo=" + no); + } + return latest; + } + + /** 重复提交同一 bizNo:人员与房间一致才按幂等返回,否则说明对方串号 */ + private OpenStayResult repeatCheckin(LockStay existing, String appCode, String bizNo, String externalPersonId, + String roomNo) + { + if (DEL_FLAG_DELETED.equals(existing.getDelFlag())) + { + throw new OpenApiException(OpenApiCodes.BIZ_NO_CONFLICT, + "bizNo 已被一条已删除的入住单占用,请更换 bizNo:" + bizNo); + } + LockPersonRef ref = personRefMapper.selectByAppAndExternalId(appCode, externalPersonId); + boolean samePerson = ref != null && ref.getPersonNo().equals(existing.getPersonNo()); + boolean sameRoom = roomNo.equals(existing.getRoomNo()); + if (!samePerson || !sameRoom) + { + throw new OpenApiException(OpenApiCodes.BIZ_NO_CONFLICT, + "bizNo 已存在且人员或房间与首次请求不一致,bizNo=" + bizNo); + } + log.info("开放接口入住幂等返回, appCode={}, bizNo={}, stayNo={}, stayStatus={}", appCode, bizNo, + existing.getStayNo(), existing.getStayStatus()); + return toResult(existing, appCode); + } + + /** + * 解析人员:外部标识已映射则复用平台人员,否则建档并写映射。 + *

+ * 映射存在但人员已被退房清理时,按本次入住重新建档并改指映射——外部系统无需感知 + * 平台内部的档案生命周期。无论新旧人员,都确保其在目标门锁算法版本下有可用特征。 + */ + private String resolvePerson(String appCode, String externalPersonId, String personName, + OpenStayCheckinRequest request, LockDevice device) + { + LockPersonRef ref = personRefMapper.selectByAppAndExternalId(appCode, externalPersonId); + LockPerson person; + if (ref == null) + { + person = createOpenApiPerson(appCode, externalPersonId, personName, request); + String mapped = savePersonRef(appCode, externalPersonId, person); + if (!mapped.equals(person.getPersonNo())) + { + // 并发首次入住:另一请求先写了映射,改用它的人员,本次新建的档案留待人工合并 + person = requireMappedPerson(mapped, externalPersonId); + } + } + else + { + person = personService.selectPersonByNo(ref.getPersonNo()); + if (person == null) + { + // 上次退房时已清理该人员:重新建档并把映射改指过去 + person = createOpenApiPerson(appCode, externalPersonId, personName, request); + String mapped = rebindPersonRef(appCode, externalPersonId, ref, person); + if (!mapped.equals(person.getPersonNo())) + { + person = requireMappedPerson(mapped, externalPersonId); + } + } + } + validatePersonUsable(person); + ensureFaceFeature(person, request, device); + return person.getPersonNo(); + } + + /** 按开放接口请求建档,人员编号由人员服务生成 */ + private LockPerson createOpenApiPerson(String appCode, String externalPersonId, String personName, + OpenStayCheckinRequest request) + { + LockPerson person = new LockPerson(); + person.setPersonName(personName); + person.setPersonType(StringUtils.isEmpty(StringUtils.trim(request.getPersonType())) ? PERSON_TYPE_VISITOR + : StringUtils.trim(request.getPersonType())); + person.setPhone(StringUtils.trim(request.getPhone())); + person.setIdCardNo(StringUtils.trim(request.getIdCardNo())); + person.setStatus(LockConstants.PERSON_STATUS_NORMAL); + person.setCreateBy(LockConstants.CREATE_BY_OPENAPI + ":" + appCode); + person.setRemark("开放接口建档(应用 " + appCode + ",外部标识 " + externalPersonId + ")"); + personService.insertPerson(person); + return person; + } + + /** + * 把外部标识映射改指到新档案(退房清理后重新入住)。 + *

+ * 条件更新带旧人员编号:并发重新入住时只有一方改指成功,另一方回查后改用现有人员, + * 其新建的档案与首次建档撞键的处理一致,留待人工合并。 + */ + private String rebindPersonRef(String appCode, String externalPersonId, LockPersonRef ref, LockPerson person) + { + int rows = personRefMapper.rebindPerson(ref.getRefId(), ref.getPersonNo(), person.getPersonId(), + person.getPersonNo()); + if (rows > 0) + { + log.info("外部人员重新建档并改指映射, appCode={}, externalId={}, oldPersonNo={}, newPersonNo={}", appCode, + externalPersonId, ref.getPersonNo(), person.getPersonNo()); + return person.getPersonNo(); + } + LockPersonRef winner = personRefMapper.selectByAppAndExternalId(appCode, externalPersonId); + if (winner == null) + { + throw new OpenApiException(OpenApiCodes.INTERNAL_ERROR, "人员映射更新冲突,请重试"); + } + log.warn("外部人员重新建档并发冲突, appCode={}, externalId={}, 本次建档={}, 已映射={}", appCode, externalPersonId, + person.getPersonNo(), winner.getPersonNo()); + return winner.getPersonNo(); + } + + private LockPerson requireMappedPerson(String personNo, String externalPersonId) + { + LockPerson person = personService.selectPersonByNo(personNo); + if (person == null) + { + throw new OpenApiException(OpenApiCodes.PERSON_INVALID, + "外部标识对应的人员档案不存在,请联系平台核查,externalPersonId=" + externalPersonId); + } + return person; + } + + /** 停用或不在有效期的人员不允许办理入住 */ + private void validatePersonUsable(LockPerson person) + { + if (!LockConstants.PERSON_STATUS_NORMAL.equals(person.getStatus())) + { + throw new OpenApiException(OpenApiCodes.PERSON_INVALID, "人员已停用,无法办理入住:" + person.getPersonNo()); + } + Date now = new Date(); + if (person.getValidFrom() != null && person.getValidFrom().after(now)) + { + throw new OpenApiException(OpenApiCodes.PERSON_INVALID, + "人员尚未到生效时间,无法办理入住:" + person.getPersonNo()); + } + if (person.getValidTo() != null && person.getValidTo().before(now)) + { + throw new OpenApiException(OpenApiCodes.PERSON_INVALID, "人员已过有效期,无法办理入住:" + person.getPersonNo()); + } + } + + /** 写入外部标识映射;并发撞唯一键时以已存在的映射为准,返回其 personNo */ + private String savePersonRef(String appCode, String externalPersonId, LockPerson person) + { + LockPersonRef ref = new LockPersonRef(); + ref.setAppCode(appCode); + ref.setExternalId(externalPersonId); + ref.setPersonId(person.getPersonId()); + ref.setPersonNo(person.getPersonNo()); + ref.setCreateBy(LockConstants.CREATE_BY_OPENAPI + ":" + appCode); + ref.setRemark("开放接口入住自动建档"); + try + { + personRefMapper.insertRef(ref); + return person.getPersonNo(); + } + catch (DuplicateKeyException e) + { + LockPersonRef winner = personRefMapper.selectByAppAndExternalId(appCode, externalPersonId); + if (winner == null) + { + throw new OpenApiException(OpenApiCodes.INTERNAL_ERROR, "人员映射写入冲突,请重试"); + } + log.warn("外部人员映射并发冲突, appCode={}, externalId={}, 本次建档={}, 已映射={}", appCode, externalPersonId, + person.getPersonNo(), winner.getPersonNo()); + return winner.getPersonNo(); + } + } + + /** + * 确保人员在目标门锁算法版本下有可用特征。 + *

+ * 照片未变且已有该版本特征时直接复用,避免每次入住都打一次提取服务; + * 其余情况按门锁版本重新提取,提取失败即拒绝受理(不下发一份错误特征)。 + */ + private void ensureFaceFeature(LockPerson person, OpenStayCheckinRequest request, LockDevice device) + { + String version = StringUtils.trim(device.getFaceAlgoVersion()); + // 入住必须带照片(入口已校验),这里直接落库或更新,不再沿用历史照片 + String photoUrl = resolvePhotoUrl(request); + LockFace face = faceService.selectFaceByPersonNo(person.getPersonNo()); + boolean photoChanged; + if (face == null) + { + face = new LockFace(); + face.setPersonNo(person.getPersonNo()); + face.setPersonName(person.getPersonName()); + face.setPhotoUrl(photoUrl); + face.setStatus(FACE_STATUS_NORMAL); + face.setCreateBy(LockConstants.CREATE_BY_OPENAPI); + faceService.insertFace(face); + photoChanged = true; + } + else + { + photoChanged = !photoUrl.equals(face.getPhotoUrl()); + if (photoChanged) + { + LockFace update = new LockFace(); + update.setFaceId(face.getFaceId()); + update.setPhotoUrl(photoUrl); + update.setUpdateBy(LockConstants.CREATE_BY_OPENAPI); + faceService.updateFace(update); + face.setPhotoUrl(photoUrl); + } + } + LockFaceFeature feature = faceService.selectFeature(person.getPersonNo(), version); + boolean usable = feature != null && StringUtils.isNotEmpty(feature.getFeatureData()); + if (usable && !photoChanged) + { + return; + } + String extracted = faceService.extract(face.getFaceId(), device.getDeviceId()); + if (StringUtils.isNotEmpty(extracted)) + { + return; + } + LockFace latest = faceService.selectFaceById(face.getFaceId()); + String reason = latest == null ? null : latest.getExtractMsg(); + if (StringUtils.isEmpty(version)) + { + throw new OpenApiException(OpenApiCodes.FACE_EXTRACT_FAIL, + "门锁未配置人脸算法版本(face_algo_version),请先在门锁页维护后再办理入住,deviceSn=" + + device.getDeviceSn()); + } + throw new OpenApiException(OpenApiCodes.FACE_EXTRACT_FAIL, "人脸特征提取失败(门锁算法版本 " + version + "):" + + (StringUtils.isEmpty(reason) ? "请检查照片质量后重试" : reason)); + } + + /** + * 头像来源:photoUrl 直接用,photoBase64 落盘后转成平台内部地址。 + * 入住入口已校验两者至少一个非空,这里不会再返回 null。 + */ + private String resolvePhotoUrl(OpenStayCheckinRequest request) + { + String photoUrl = StringUtils.trim(request.getPhotoUrl()); + if (StringUtils.isNotEmpty(photoUrl)) + { + return photoUrl; + } + String base64 = StringUtils.trim(request.getPhotoBase64()); + if (StringUtils.isEmpty(base64)) + { + return null; + } + return saveBase64Photo(base64); + } + + /** + * base64 头像落盘:文件名由平台生成(不含调用方输入),只写 profile 目录下的 + * openapi 子目录,返回 {@code /profile/...} 相对地址供特征提取读取。 + */ + private String saveBase64Photo(String base64) + { + String raw = base64; + if (raw.startsWith("data:")) + { + int comma = raw.indexOf(','); + if (comma < 0) + { + throw new OpenApiException(OpenApiCodes.PARAM_INVALID, "photoBase64 的 data URL 格式不正确"); + } + raw = raw.substring(comma + 1); + } + byte[] bytes; + try + { + bytes = Base64.getDecoder().decode(raw.trim()); + } + catch (IllegalArgumentException e) + { + throw new OpenApiException(OpenApiCodes.PARAM_INVALID, "photoBase64 不是合法的 base64 编码"); + } + if (bytes.length == 0) + { + throw new OpenApiException(OpenApiCodes.PARAM_INVALID, "photoBase64 解码后为空"); + } + if (bytes.length > PHOTO_MAX_BYTES) + { + throw new OpenApiException(OpenApiCodes.PARAM_INVALID, "photoBase64 解码后不能超过 2MB"); + } + String extension = imageExtension(bytes); + String relative = "/openapi/" + DateUtils.dateTimeNow("yyyy/MM/dd") + "/" + IdUtils.fastSimpleUUID() + + extension; + try + { + // 与 LockFaceServiceImpl.loadImageBytes 的读取方式保持一致:profile + 相对路径 + Path target = Paths.get(RuoYiConfig.getProfile() + relative); + Files.createDirectories(target.getParent()); + Files.write(target, bytes); + } + catch (IOException e) + { + log.error("开放接口头像落盘失败, relative={}", relative, e); + throw new OpenApiException(OpenApiCodes.INTERNAL_ERROR, "头像保存失败,请重试或改用 photoUrl"); + } + return Constants.RESOURCE_PREFIX + relative; + } + + /** 按文件头判定图片类型,只接受 JPG/PNG */ + private String imageExtension(byte[] bytes) + { + if (bytes.length > 3 && (bytes[0] & 0xFF) == 0xFF && (bytes[1] & 0xFF) == 0xD8 && (bytes[2] & 0xFF) == 0xFF) + { + return ".jpg"; + } + if (bytes.length > 4 && (bytes[0] & 0xFF) == 0x89 && bytes[1] == 'P' && bytes[2] == 'N' && bytes[3] == 'G') + { + return ".png"; + } + throw new OpenApiException(OpenApiCodes.PARAM_INVALID, "photoBase64 仅支持 JPG/PNG 图片"); + } + + /** 房号全局唯一:查不到或查到多条都按 40003 处理,避免误选房间 */ + private LockRoom requireUniqueRoom(String roomNo) + { + List rooms = roomMapper.selectRoomsByRoomNo(roomNo); + if (rooms == null || rooms.isEmpty()) + { + throw new OpenApiException(OpenApiCodes.ROOM_NOT_FOUND, "房间不存在:" + roomNo); + } + if (rooms.size() > 1) + { + throw new OpenApiException(OpenApiCodes.ROOM_NOT_FOUND, + "房间号不唯一(" + rooms.size() + " 条),请联系平台核对房间档案:" + roomNo); + } + return rooms.get(0); + } + + /** 不限制入住人数,只拒绝维修/停用房间 */ + private void requireCheckinable(LockRoom room) + { + if (ROOM_STATUS_REPAIR.equals(room.getRoomStatus()) || ROOM_STATUS_DISABLED.equals(room.getRoomStatus())) + { + throw new OpenApiException(OpenApiCodes.ROOM_UNAVAILABLE, + "房间处于维修/停用状态,不可办理入住:" + room.getRoomNo()); + } + } + + private LockDevice requireRoomDevice(LockRoom room) + { + if (room.getDeviceId() == null) + { + throw new OpenApiException(OpenApiCodes.ROOM_UNAVAILABLE, + "房间未分配门锁,请联系平台先绑定门锁:" + room.getRoomNo()); + } + LockDevice device = deviceMapper.selectDeviceById(room.getDeviceId()); + if (device == null) + { + throw new OpenApiException(OpenApiCodes.ROOM_UNAVAILABLE, "房间绑定的门锁不存在:" + room.getRoomNo()); + } + return device; + } + + /** 人员标识解析:personNo 优先,其次外部标识查映射;映射缺失按「入住单不存在」处理 */ + private String resolvePersonNo(String appCode, String personNo, String externalPersonId) + { + String no = StringUtils.trim(personNo); + if (StringUtils.isNotEmpty(no)) + { + return no; + } + String external = StringUtils.trim(externalPersonId); + if (StringUtils.isEmpty(external)) + { + throw new OpenApiException(OpenApiCodes.PARAM_INVALID, "bizNo 或「personNo/externalPersonId + roomNo」必须提供其一"); + } + LockPersonRef ref = personRefMapper.selectByAppAndExternalId(appCode, external); + if (ref == null) + { + throw new OpenApiException(OpenApiCodes.STAY_NOT_FOUND, + "外部人员标识未在本应用建档,externalPersonId=" + external); + } + return ref.getPersonNo(); + } + + private boolean hasActiveStayInRoom(String personNo, Long roomId) + { + for (LockStay stay : stayMapper.selectActiveStaysByRoomId(roomId)) + { + if (personNo.equals(stay.getPersonNo())) + { + return true; + } + } + return false; + } + + private OpenStayResult toResult(LockStay stay, String appCode) + { + OpenStayResult result = new OpenStayResult(); + result.setBizNo(stay.getBizNo()); + result.setStayNo(stay.getStayNo()); + result.setPersonNo(stay.getPersonNo()); + result.setRoomCode(stay.getRoomCode()); + result.setRoomNo(stay.getRoomNo()); + result.setDeviceSn(stay.getDeviceSn()); + result.setBeginTime(stay.getBeginTime()); + result.setEndTime(stay.getEndTime()); + result.setStayStatus(stay.getStayStatus()); + result.setAuthStatus(stay.getAuthStatus()); + result.setAccepted(Boolean.TRUE); + App app = openAppRegistry.getApp(appCode); + if (app != null) + { + boolean enabled = StringUtils.isNotEmpty(StringUtils.trim(app.getCallbackUrl())); + result.setCallback(new OpenStayResult.Callback(enabled, enabled ? app.getCallbackUrl().trim() : null)); + } + return result; + } + + /** + * 住宿服务的可读异常按对外业务码归类。 + * 房间/门锁类归 40004、人员类归 40005、特征类归 50001,其余按参数错误 40001。 + */ + private OpenApiException translateStayError(String message) + { + String msg = StringUtils.isEmpty(message) ? "住宿业务处理失败" : message; + if (msg.contains("人脸特征") || msg.contains("照片")) + { + return new OpenApiException(OpenApiCodes.FACE_EXTRACT_FAIL, msg); + } + if (msg.contains("人员")) + { + return new OpenApiException(OpenApiCodes.PERSON_INVALID, msg); + } + if (msg.contains("房间") || msg.contains("门锁")) + { + return new OpenApiException(OpenApiCodes.ROOM_UNAVAILABLE, msg); + } + return new OpenApiException(OpenApiCodes.PARAM_INVALID, msg); + } + + private String requireText(String value, String field) + { + String text = StringUtils.trim(value); + if (StringUtils.isEmpty(text)) + { + throw new OpenApiException(OpenApiCodes.PARAM_INVALID, field + " 不能为空"); + } + return text; + } + + private Date parseTime(String value, String field) + { + String text = requireText(value, field); + SimpleDateFormat format = new SimpleDateFormat(TIME_PATTERN); + format.setLenient(false); + try + { + return format.parse(text); + } + catch (ParseException e) + { + throw new OpenApiException(OpenApiCodes.PARAM_INVALID, field + " 时间格式不正确,应为 " + TIME_PATTERN); + } + } + + private String formatTime(Date date) + { + return date == null ? null : new SimpleDateFormat(TIME_PATTERN).format(date); + } +} diff --git a/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/person/service/ILockPersonService.java b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/person/service/ILockPersonService.java new file mode 100644 index 0000000..67488cf --- /dev/null +++ b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/person/service/ILockPersonService.java @@ -0,0 +1,48 @@ +package com.ruoyi.iot.person.service; + +import java.util.List; +import com.ruoyi.iot.person.domain.LockPerson; + +/** + * 人员主数据服务 + * + * @author ruoyi + */ +public interface ILockPersonService +{ + List selectPersonList(LockPerson person); + + /** 下拉选项:仅正常状态人员 */ + List selectPersonOption(); + + LockPerson selectPersonById(Long personId); + + LockPerson selectPersonByNo(String personNo); + + int insertPerson(LockPerson person); + + int updatePerson(LockPerson person); + + int deletePersonByIds(Long[] personIds); + + /** + * 直接删除人员记录(物理删除),跳过人脸/授权引用校验。 + *

+ * 供退房清理外部接口自动建档人员使用:调用方已确认该人员无其它在住单与有效授权, + * 人脸与特征由调用方先行删除。人工删除仍走 {@link #deletePersonByIds(Long[])}。 + * + * @param personId 人员ID + * @return 受影响行数 + */ + int deletePersonRecord(Long personId); + + /** + * 生成人员编号:2 位字母前缀 + 1 位人员类型 + 5 位序列(如 AA000001) + *

+ * v1 人员不挂分区,前缀固定 AA;分区维度落地后由分区决定前缀。 + * + * @param personType 人员类型 0-3,为空按 0 处理 + * @return 8 位人员编号 + */ + String nextPersonNo(String personType); +} diff --git a/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/person/service/impl/LockPersonServiceImpl.java b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/person/service/impl/LockPersonServiceImpl.java new file mode 100644 index 0000000..82589c4 --- /dev/null +++ b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/person/service/impl/LockPersonServiceImpl.java @@ -0,0 +1,225 @@ +package com.ruoyi.iot.person.service.impl; + +import java.util.Date; +import java.util.List; +import java.util.regex.Pattern; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import com.ruoyi.common.core.redis.RedisCache; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.iot.lock.LockConstants; +import com.ruoyi.iot.lock.mapper.LockAuthMapper; +import com.ruoyi.iot.lock.mapper.LockFaceMapper; +import com.ruoyi.iot.person.domain.LockPerson; +import com.ruoyi.iot.person.mapper.LockPersonMapper; +import com.ruoyi.iot.person.service.ILockPersonService; + +/** + * 人员主数据服务实现 + * + * @author ruoyi + */ +@Service +public class LockPersonServiceImpl implements ILockPersonService +{ + private static final Logger log = LoggerFactory.getLogger(LockPersonServiceImpl.class); + + /** 人员编号:2 位字母 + 6 位数字,与设备协议 / 开门记录解析保持一致 */ + private static final Pattern PERSON_NO_PATTERN = Pattern.compile("^[A-Z]{2}\\d{6}$"); + + /** v1 人员不挂分区,编号前缀固定 */ + private static final String PERSON_NO_PREFIX = "AA"; + + private static final String PERSON_NO_SEQ_KEY = "lock:person:no:"; + + private static final int PERSON_NO_MAX_SEQ = 99999; + + @Autowired + private LockPersonMapper personMapper; + + @Autowired + private LockFaceMapper faceMapper; + + @Autowired + private LockAuthMapper authMapper; + + @Autowired(required = false) + private RedisCache redisCache; + + @Override + public List selectPersonList(LockPerson person) + { + return personMapper.selectPersonList(person); + } + + @Override + public List selectPersonOption() + { + return personMapper.selectPersonOption(); + } + + @Override + public LockPerson selectPersonById(Long personId) + { + return personMapper.selectPersonById(personId); + } + + @Override + public LockPerson selectPersonByNo(String personNo) + { + if (StringUtils.isEmpty(StringUtils.trim(personNo))) + { + return null; + } + return personMapper.selectPersonByNo(StringUtils.trim(personNo)); + } + + @Override + public int insertPerson(LockPerson person) + { + if (person == null) + { + throw new IllegalArgumentException("人员信息不能为空"); + } + String personNo = StringUtils.trim(person.getPersonNo()); + if (StringUtils.isEmpty(personNo)) + { + personNo = nextPersonNo(person.getPersonType()); + } + person.setPersonNo(personNo); + validatePersonNo(personNo); + person.setPersonType(normalizePersonType(person.getPersonType())); + validateValidRange(person.getValidFrom(), person.getValidTo()); + if (personMapper.countPersonNo(personNo, null) > 0) + { + throw new IllegalArgumentException("人员编号已存在:" + personNo); + } + if (StringUtils.isEmpty(person.getStatus())) + { + person.setStatus(LockConstants.PERSON_STATUS_NORMAL); + } + return personMapper.insertPerson(person); + } + + @Override + public int updatePerson(LockPerson person) + { + if (person == null || person.getPersonId() == null) + { + throw new IllegalArgumentException("人员ID不能为空"); + } + LockPerson old = personMapper.selectPersonById(person.getPersonId()); + if (old == null) + { + throw new IllegalArgumentException("人员不存在"); + } + // person_no 已随授权下发到设备,设备侧用它索引特征与权限,改号等于换成另一个人 + String personNo = StringUtils.trim(person.getPersonNo()); + if (StringUtils.isNotEmpty(personNo) && !personNo.equals(old.getPersonNo())) + { + throw new IllegalArgumentException("人员编号已下发到设备,不可变更"); + } + if (StringUtils.isNotEmpty(person.getPersonType())) + { + person.setPersonType(normalizePersonType(person.getPersonType())); + } + Date validFrom = person.getValidFrom() == null ? old.getValidFrom() : person.getValidFrom(); + Date validTo = person.getValidTo() == null ? old.getValidTo() : person.getValidTo(); + validateValidRange(validFrom, validTo); + return personMapper.updatePerson(person); + } + + @Override + @Transactional + public int deletePersonByIds(Long[] personIds) + { + for (Long personId : personIds) + { + LockPerson person = personMapper.selectPersonById(personId); + if (person == null) + { + continue; + } + int faces = faceMapper.countFaceByPersonId(personId); + if (faces > 0) + { + throw new IllegalArgumentException("人员 " + person.getPersonNo() + " 已有人脸特征,请先删除人脸数据"); + } + int auths = authMapper.countAuthsByPersonNo(person.getPersonNo()); + if (auths > 0) + { + throw new IllegalArgumentException( + "人员 " + person.getPersonNo() + " 仍有 " + auths + " 条授权,请先撤销授权"); + } + } + return personMapper.deletePersonByIds(personIds); + } + + @Override + public int deletePersonRecord(Long personId) + { + if (personId == null) + { + return 0; + } + return personMapper.deletePersonByIds(new Long[] { personId }); + } + + @Override + public String nextPersonNo(String personType) + { + String type = normalizePersonType(personType); + if (redisCache == null) + { + throw new IllegalArgumentException("人员编号自动生成不可用,请手工填写人员编号"); + } + String seqKey = PERSON_NO_SEQ_KEY + PERSON_NO_PREFIX + type; + try + { + Long seq = (Long) redisCache.redisTemplate.opsForValue().increment(seqKey); + if (seq == null || seq > PERSON_NO_MAX_SEQ) + { + throw new IllegalArgumentException("人员编号序列已用尽,请手工填写人员编号"); + } + return PERSON_NO_PREFIX + type + String.format("%05d", seq); + } + catch (IllegalArgumentException e) + { + throw e; + } + catch (Exception e) + { + log.warn("人员编号生成失败, type={}, msg={}", type, e.getMessage()); + throw new IllegalArgumentException("人员编号自动生成失败,请手工填写人员编号"); + } + } + + private void validatePersonNo(String personNo) + { + if (!PERSON_NO_PATTERN.matcher(personNo).matches()) + { + throw new IllegalArgumentException("人员编号格式错误,须为 2 位字母 + 6 位数字(如 AA000003):" + personNo); + } + } + + private String normalizePersonType(String personType) + { + String type = StringUtils.isEmpty(StringUtils.trim(personType)) ? "0" : StringUtils.trim(personType); + if (!"0".equals(type) && !"1".equals(type) && !"2".equals(type) && !"3".equals(type)) + { + throw new IllegalArgumentException("人员类型不合法:" + personType); + } + return type; + } + + private void validateValidRange(Date validFrom, Date validTo) + { + if (validFrom != null && validTo != null && validFrom.after(validTo)) + { + throw new IllegalArgumentException("有效期起不能晚于有效期止"); + } + } +} diff --git a/base/RuoYi-Vue-master/ruoyi-iot/src/main/resources/mapper/lock/LockAuthMapper.xml b/base/RuoYi-Vue-master/ruoyi-iot/src/main/resources/mapper/lock/LockAuthMapper.xml new file mode 100644 index 0000000..9683692 --- /dev/null +++ b/base/RuoYi-Vue-master/ruoyi-iot/src/main/resources/mapper/lock/LockAuthMapper.xml @@ -0,0 +1,252 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + select auth_id, auth_no, person_no, person_name, device_id, device_sn, gateway_id, group_id, + auth_type, auth_value, auth_value_md5, begin_time, end_time, auth_time, sync_status, + sync_time, sync_msg, retry_count, status, del_flag, create_by, create_time, + update_by, update_time, remark from lock_auth + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + update lock_auth + set person_name = #{personName}, + device_sn = #{deviceSn}, + gateway_id = #{gatewayId}, + group_id = #{groupId}, + auth_value = #{authValue}, + auth_value_md5 = #{authValueMd5}, + begin_time = #{beginTime}, + end_time = #{endTime}, + auth_time = #{authTime}, + remark = #{remark}, + status = #{status}, + del_flag = '0', + sync_status = '0', + sync_time = null, + sync_msg = null, + retry_count = 0, + update_by = #{updateBy}, + update_time = CURRENT_TIMESTAMP + where auth_id = #{authId} + + + + insert into lock_auth ( + auth_no, person_no, person_name, device_id, device_sn, gateway_id, group_id, auth_type, + auth_value, auth_value_md5, begin_time, end_time, auth_time, sync_status, retry_count, + status, del_flag, create_by, create_time, remark + ) values ( + #{authNo}, #{personNo}, #{personName}, #{deviceId}, #{deviceSn}, #{gatewayId}, #{groupId}, + #{authType}, #{authValue}, #{authValueMd5}, #{beginTime}, #{endTime}, #{authTime}, + '0', 0, #{status}, '0', #{createBy}, CURRENT_TIMESTAMP, #{remark} + ) + + + + update lock_auth + + person_no = #{personNo}, + person_name = #{personName}, + device_id = #{deviceId}, + device_sn = #{deviceSn}, + gateway_id = #{gatewayId}, + group_id = #{groupId}, + auth_type = #{authType}, + auth_value = #{authValue}, + auth_value_md5 = #{authValueMd5}, + begin_time = #{beginTime}, + end_time = #{endTime}, + auth_time = #{authTime}, + status = #{status}, + remark = #{remark}, + update_by = #{updateBy}, update_time = CURRENT_TIMESTAMP + + where auth_id = #{authId} + + + + update lock_auth set sync_status = #{syncStatus}, sync_time = CURRENT_TIMESTAMP + , sync_msg = #{syncMsg} + where auth_id = #{authId} + + + + update lock_auth + set sync_status = '3', sync_time = CURRENT_TIMESTAMP, sync_msg = #{syncMsg} + where device_id = #{deviceId} + and auth_type = '01' + and del_flag = '0' + and sync_status not in ('4', '6') + + + + update lock_auth set del_flag = '2' where auth_id in + + #{authId} + + + + + delete from lock_auth where device_id = #{deviceId} + + + diff --git a/base/RuoYi-Vue-master/ruoyi-iot/src/main/resources/mapper/lock/LockFaceMapper.xml b/base/RuoYi-Vue-master/ruoyi-iot/src/main/resources/mapper/lock/LockFaceMapper.xml new file mode 100644 index 0000000..b284420 --- /dev/null +++ b/base/RuoYi-Vue-master/ruoyi-iot/src/main/resources/mapper/lock/LockFaceMapper.xml @@ -0,0 +1,158 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + select f.face_id, f.person_id, f.person_no, p.person_name, p.person_type, p.phone, + f.photo_url, f.photo_md5, f.feature_md5, f.feature_algo_version, f.feature_size, + f.extract_status, f.extract_time, f.extract_msg, f.status, f.del_flag, + f.create_by, f.create_time, f.update_by, f.update_time, f.remark + from lock_face f + left join lock_person p on p.person_id = f.person_id + + + + select f.face_id, f.person_id, f.person_no, p.person_name, p.person_type, p.phone, + f.photo_url, f.photo_md5, f.feature_data, f.feature_md5, f.feature_algo_version, + f.feature_size, f.extract_status, f.extract_time, f.extract_msg, f.status, f.del_flag, + f.create_by, f.create_time, f.update_by, f.update_time, f.remark + from lock_face f + left join lock_person p on p.person_id = f.person_id + + + + + + + + + + + + + + + + + + update lock_face + set person_id = #{personId}, person_no = #{personNo}, photo_url = #{photoUrl}, + photo_md5 = #{photoMd5}, extract_status = '0', extract_time = null, extract_msg = null, + feature_data = null, feature_md5 = null, feature_algo_version = null, feature_size = 0, + status = #{status}, del_flag = '0', update_by = #{updateBy}, update_time = CURRENT_TIMESTAMP + where face_id = #{faceId} + + + + insert into lock_face ( + person_id, person_no, photo_url, photo_md5, + extract_status, status, del_flag, create_by, create_time, remark + ) values ( + #{personId}, #{personNo}, #{photoUrl}, #{photoMd5}, + '0', #{status}, '0', #{createBy}, CURRENT_TIMESTAMP, #{remark} + ) + + + + update lock_face + + photo_url = #{photoUrl}, + photo_md5 = #{photoMd5}, + status = #{status}, + remark = #{remark}, + update_by = #{updateBy}, update_time = CURRENT_TIMESTAMP + + where face_id = #{faceId} + + + + + update lock_face + set extract_status = #{extractStatus}, extract_time = CURRENT_TIMESTAMP, + extract_msg = #{extractMsg} + , feature_data = #{featureData} + , feature_md5 = #{featureMd5} + , feature_algo_version = #{featureAlgoVersion} + , feature_size = #{featureSize} + where face_id = #{faceId} + + + + delete from lock_face where face_id in + + #{faceId} + + + + diff --git a/base/RuoYi-Vue-master/ruoyi-iot/src/main/resources/mapper/lock/LockPersonRefMapper.xml b/base/RuoYi-Vue-master/ruoyi-iot/src/main/resources/mapper/lock/LockPersonRefMapper.xml new file mode 100644 index 0000000..cd1959d --- /dev/null +++ b/base/RuoYi-Vue-master/ruoyi-iot/src/main/resources/mapper/lock/LockPersonRefMapper.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + insert into lock_person_ref ( + person_id, person_no, app_code, external_id, create_by, create_time, remark + ) values ( + #{personId}, #{personNo}, #{appCode}, #{externalId}, #{createBy}, CURRENT_TIMESTAMP, #{remark} + ) + + + + + update lock_person_ref + set person_id = #{personId}, person_no = #{personNo} + where ref_id = #{refId} and person_no = #{oldPersonNo} + + + diff --git a/base/RuoYi-Vue-master/ruoyi-iot/src/main/resources/mapper/lock/LockStayMapper.xml b/base/RuoYi-Vue-master/ruoyi-iot/src/main/resources/mapper/lock/LockStayMapper.xml new file mode 100644 index 0000000..4ef7e2f --- /dev/null +++ b/base/RuoYi-Vue-master/ruoyi-iot/src/main/resources/mapper/lock/LockStayMapper.xml @@ -0,0 +1,237 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + select s.stay_id, s.stay_no, s.biz_no, s.biz_system, s.source, + s.person_id, s.person_no, s.person_name, s.guest_type, + s.room_id, s.room_code, r.room_no, s.device_id, s.device_sn, s.auth_id, + s.begin_time, s.end_time, s.checkin_time, s.checkout_time, s.change_from_id, + s.stay_status, s.auth_status, s.auth_msg, s.del_flag, + s.create_by, s.create_time, s.update_by, s.update_time, s.remark + from lock_stay s + left join lock_room r on r.room_id = s.room_id and r.del_flag = '0' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + update lock_stay + set stay_status = '2', checkout_time = end_time, update_time = CURRENT_TIMESTAMP + where stay_id = #{stayId} and stay_status = '1' + + + + insert into lock_stay ( + stay_no, biz_no, biz_system, source, person_id, person_no, person_name, guest_type, + room_id, room_code, device_id, device_sn, auth_id, + begin_time, end_time, checkin_time, change_from_id, + stay_status, auth_status, auth_msg, del_flag, create_by, create_time, remark + ) values ( + #{stayNo}, #{bizNo,jdbcType=VARCHAR}, #{bizSystem,jdbcType=VARCHAR}, #{source}, + #{personId}, #{personNo}, #{personName}, #{guestType}, + #{roomId}, #{roomCode}, #{deviceId}, #{deviceSn}, #{authId}, + #{beginTime}, #{endTime}, #{checkinTime}, #{changeFromId}, + #{stayStatus}, #{authStatus}, #{authMsg}, '0', #{createBy}, CURRENT_TIMESTAMP, #{remark} + ) + + + + update lock_stay + set stay_status = '2', checkout_time = #{checkoutTime}, update_time = CURRENT_TIMESTAMP + where stay_id = #{stayId} and stay_status = '1' + + + + update lock_stay + set stay_status = '3', checkout_time = #{checkoutTime}, update_time = CURRENT_TIMESTAMP + where stay_id = #{stayId} and stay_status = '1' + + + + update lock_stay + set end_time = #{endTime}, update_time = CURRENT_TIMESTAMP + where stay_id = #{stayId} and stay_status = '1' + + + + update lock_stay + set auth_status = #{authStatus}, auth_msg = #{authMsg}, update_time = CURRENT_TIMESTAMP + where auth_id = #{authId} and del_flag = '0' + + + \ No newline at end of file diff --git a/base/RuoYi-Vue-master/ruoyi-iot/src/main/resources/mapper/person/LockPersonMapper.xml b/base/RuoYi-Vue-master/ruoyi-iot/src/main/resources/mapper/person/LockPersonMapper.xml new file mode 100644 index 0000000..96409a7 --- /dev/null +++ b/base/RuoYi-Vue-master/ruoyi-iot/src/main/resources/mapper/person/LockPersonMapper.xml @@ -0,0 +1,149 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + select p.person_id, p.person_no, p.person_name, p.person_type, p.dept_id, + d.dept_name, p.sys_user_id, u.user_name, u.nick_name, p.phone, + p.valid_from, p.valid_to, p.status, p.del_flag, p.create_by, p.create_time, + p.update_by, p.update_time, p.remark + from lock_person p + left join sys_dept d on d.dept_id = p.dept_id + left join sys_user u on u.user_id = p.sys_user_id + + + + + + + + + + + + + + + insert into lock_person ( + person_no, person_name, person_type, dept_id, sys_user_id, id_card_no, phone, + valid_from, valid_to, status, del_flag, create_by, create_time, remark + ) values ( + #{personNo}, #{personName}, #{personType}, #{deptId}, #{sysUserId}, #{idCardNo}, #{phone}, + #{validFrom}, #{validTo}, #{status}, '0', #{createBy}, CURRENT_TIMESTAMP, #{remark} + ) + + + + update lock_person + + person_name = #{personName}, + person_type = #{personType}, + dept_id = #{deptId}, + sys_user_id = #{sysUserId}, + id_card_no = #{idCardNo}, + phone = #{phone}, + valid_from = #{validFrom}, + valid_to = #{validTo}, + status = #{status}, + remark = #{remark}, + update_by = #{updateBy}, update_time = CURRENT_TIMESTAMP + + where person_id = #{personId} + + + + delete from lock_person where person_id in + + #{personId} + + + + diff --git a/doc/对外开放房间门禁服务接口设计.md b/doc/对外开放房间门禁服务接口设计.md new file mode 100644 index 0000000..c70f66d --- /dev/null +++ b/doc/对外开放房间门禁服务接口设计.md @@ -0,0 +1,819 @@ +# 对外开放房间门禁服务接口设计 + +> 状态:**P1、P2、P3 已实现(代码就绪)**——P1:多应用鉴权、入住/退房/续住接口、外部人员映射、 +> `lock_stay` 扩展与房间门锁历史表;P2:回调任务落库、HMAC 签名投递、退避重试、死信与人工重发、 +> 回调任务查询页面;P3:开锁记录查询(含换锁前的历史门锁),均已通过全量编译;**P4 安全加固 +> (IP 白名单 / 限流 / `photoUrl` 防 SSRF / 接口侧签名)仍未实现**。 +> 数据库升级脚本 `sql/upgrade_d20_openapi_stay.sql`(P1)与 `sql/upgrade_d20_p2_callback.sql`(P2) +> 已交付但**未在任何环境执行**,接口与回调也**未做实机联调**(含门锁下发与业务系统对接)。P3 无新增脚本, +> 复用 P1 的 `lock_room_device_history`。 +> 依赖能力:住宿管理模块(`lock_room` / `lock_stay`)、授权下发(`lock_auth` + `lock_command`)、 +> 开放接口鉴权(`/openapi/iot` + `X-API-Key`)。 +> 相关文档:《住宿管理模块设计》《门锁系统架构设计》《厂家设备控制协议整理与设计说明》。 + +--- + +## 1. 范围与角色 + +本系统向外部业务系统(公寓/酒店 PMS、访客系统、园区平台等)开放房间门禁能力:由对方系统发起入住、退房、续住,本系统负责人员建档、人脸提取、门锁授权下发,并把下发结果回调给业务系统。 + +| 角色 | 职责 | +|---|---| +| 业务系统(调用方) | 发起入住/退房/续住/查询;提供人员标识、房间号、入住起止时间、头像;接收结果回调 | +| 本系统(门禁平台) | 人员建档与人脸特征提取、房间与门锁授权、指令下发与重试、结果回调、开锁记录查询 | +| 门锁/网关 | 执行人脸新增/删除,按授权有效期控制开门 | + +### 1.1 不在本期范围 + +- 不做订单、房价、押金等 PMS 业务。 +- 不开放门锁配置、网关管理、命令重放等运维能力。 +- 不提供按业务系统自助开通/在线换 Key 的管理页面(多应用配置由运维在配置文件中维护,见 §2.6)。 + +### 1.2 已确认的业务约定(2026-10-31) + +| # | 约定 | 设计影响 | +|---|---|---| +| 1 | **回调地址由系统配置固定,每个应用各自配置上报地址** | 请求中不再传 `callbackUrl`;回调地址从应用配置读取 | +| 2 | **支持多业务系统隔离,每个业务系统使用独立 API Key** | 鉴权由单 Key 改为"按 Key 识别应用",数据按 `appCode` 隔离 | +| 3 | **房间号全局唯一** | 接口只需 `roomNo`,不需要 `groupCode` | +| 4 | **假定外部人员标识稳定** | `(appCode, externalPersonId)` 作为人员映射唯一键,不处理标识变更 | +| 5 | **开锁记录查询包含历史门锁** | 新增房间-门锁绑定历史,查询覆盖时间段内绑定过的所有门锁 | + +--- + +## 2. 总体设计 + +### 2.1 交互模式:同步受理 + 异步回调 + +门锁授权依赖 MQTT 与 LoRa 链路,门锁离线时下发可能延迟数分钟,**接口不做同步等待**: + +``` +业务系统 门禁平台 门锁/网关 + │ ① 入住请求 │ │ + │──────────────────────►│ 校验/建档/提取 → 建入住单+授权 │ + │◄── 200 accepted ──────│(返回 stayNo / personNo) │ + │ │──────── facefeature add ──────►│ + │ │◄──── 回执(成功/失败/超时)─────│ + │◄── 结果回调 ──────────│(事件 STAY_CHECKIN_RESULT) │ +``` + +- 接口返回 `accepted=true` 表示**业务已受理**,不代表门锁已生效。 +- 最终结果以回调为准;业务系统也可通过 `GET /openapi/iot/stay/result` 主动查询(可选扩展)。 + +### 2.2 鉴权与多应用 + +复用现有开放接口机制并扩展为多应用: + +- 路径前缀仍为 `/openapi/iot`,由 `OpenApiAuthFilter` 校验请求头 `X-API-Key`。 +- **每个业务系统一个 API Key**:Key 命中哪个应用,就以该应用的 `appCode` 作为本次请求的业务系统身份。 +- 未启用开放接口返回 404;Key 缺失或未匹配任何应用返回 401。 +- 鉴权通过后,应用身份写入请求属性(如 `openAppCode`),Controller/Service 据此做数据隔离。 + +数据隔离规则: + +| 对象 | 隔离方式 | +|---|---| +| 人员映射 | `lock_person_ref (app_code, external_id) → person_no` | +| 入住单 | `lock_stay.biz_system = appCode`;`biz_no` 仅在应用内唯一 | +| 退房/续住/查询 | 只能操作 `biz_system = 当前 appCode` 的入住单,跨系统访问返回 40300 | + +### 2.3 幂等 + +| 场景 | 幂等键 | 行为 | +|---|---|---| +| 入住 | `(appCode, bizNo)` | 已存在同一应用下的 `bizNo`:不重复建单,返回原 `stayNo/personNo` 与当前状态;参数不一致返回 40007 | +| 退房 | `bizNo` 或 `personNo + roomNo` | 已退房返回当前状态(成功),不重复删除 | +| 续住 | `bizNo` | 新结束时间与当前一致视为幂等成功;否则按新时间执行 | +| 回调 | 本系统生成 `eventId`(UUID) | 对方按 `eventId` 去重;结果变更时同一 `bizNo` 用递增 `revision` | + +> 不同应用可以使用相同的 `bizNo`,互不影响;同一应用内 `bizNo` 唯一。 + +### 2.4 对外标识映射 + +业务系统的人员标识(`externalPersonId`)格式不受本系统 `person_no`(2 字母 + 6 数字)约束,因此新增映射表: + +``` +lock_person_ref(app_code, external_id) → person_no +``` + +- 首次入住:按 `(appCode, externalPersonId)` 查映射;不存在则建档(自动生成 `person_no`)并写映射。 +- 返回给对方的 `personNo` 为本系统人员编号,后续退房/续住可直接使用。 +- 外部人员标识假定稳定不变;同一人员在不同应用接入时各自建档、互不影响。 +- **人员生命周期**(D23):退房(含到期自动退房)时,若该人员为开放接口自动建档(`create_by=openapi:*`、未绑定平台账号),且名下无其它未结束入住单与未清除授权,则同步删除本地照片文件(`/profile` 下平台保存的照片)、人脸、各版本特征与人员档案(人脸/特征/人员均**物理删除**,不留逻辑删除行);映射行保留。外部 http(s) 照片由对方系统保管,平台不删除。 +- 再次入住:映射仍在但人员已删除时按本次请求重新建档,并把映射改指新档案;对方无需感知。**每次入住都必须传 `photoUrl` 或 `photoBase64`**,平台不再沿用人脸库历史照片,缺参返回 40001。`lock_face` 唯一键不含 `del_flag`,重新建档时由 `insertFace` 复用该人员的历史人脸行(兼容存量逻辑删除数据;D23 后的删除均为物理删除,正常不会留下历史行)并重置为在用。 +- 若本单清除指令未发出(如门锁无路由),人员同样清理;`lock_auth` 行保留人员编号与姓名,可在授权管理页人工重发删除。 + +### 2.5 结果回调时机 + +| 事件 | 触发时机 | 结果取值 | +|---|---|---| +| `STAY_CHECKIN_RESULT` | 入住授权的下发命令进入终态(成功 / 失败 / 超时) | `SUCCESS` / `FAILED` | +| `STAY_CHECKOUT_RESULT` | 退房删除命令进入终态 | `SUCCESS` / `FAILED` | +| `STAY_EXTEND_RESULT` | 续住重新下发命令进入终态 | `SUCCESS` / `FAILED` | + +- 回调失败(对方 5xx/超时)进入重试队列(§8.2)。 +- 删除失败后进入「待清除(6)」补偿,**补偿成功后允许再次回调同一 `bizNo`**,`revision` 递增,对方按 `revision` 取最新。 +- **P2 实现口径**:事件在住宿操作受理时登记(此时才知道是入住、续住还是退房),授权/清除命令 + 进入终态时再生成报文。受理时授权已同步失败(无路由、缺特征)的直接生成失败报文;异步回执 + 到达时填充等待位。等待位超过 24 小时仍无回执,按 `FAILED`(原因「等待设备回执超时」)上报, + 而不是静默丢弃。平台手工单(`biz_no` 为空)不产生回调。 + +### 2.6 多应用配置模型 + +> **D21(2026-11-01)起应用清单以数据表 `lock_open_app` 为准**,由平台「开放管理 › 应用接入」维护 +> (新增/停用/重置密钥/改回调即时生效,无需重启)。下面的 yml 配置只在两种场景使用: +> 首次启动且表为空时原样导入(外部系统不用换 Key),以及表不可用(D21 脚本未执行)时降级运行。 +> 导入完成后 yml 中的 `apps` 段可删除,只保留 `enabled` 与 `path-prefix`。详见 +> [开放平台接入管理设计](开放平台接入管理设计.md)。 + +```yaml +iot: + openapi: + enabled: true + path-prefix: /openapi/iot + # 单应用兼容配置:apps 为空时作为默认应用(appCode = DEFAULT),并作为首次导入源 + api-key: please-change-me + # 多应用配置(仅首次导入与降级用) + apps: + - app-code: PMS + app-name: 公寓 PMS + api-key: <每个应用独立> + callback-url: https://pms.example.com/hook/lock + callback-secret: <回调签名密钥> + enabled: true + - app-code: VISITOR + app-name: 访客系统 + api-key: <另一个独立 Key> + callback-url: https://visitor.example.com/api/lock-callback + callback-secret: <另一个签名密钥> + enabled: true +``` + +- `app-code` 写入 `lock_stay.biz_system` 与 `lock_person_ref.app_code`,是数据隔离的唯一依据; + 在管理页**创建后不可修改**,删除前要求该应用没有任何入住数据(有数据只能停用)。 +- 各应用 `api-key` 在表上唯一;管理页新增/重置时校验重复,避免两个系统串到同一份数据。 + 停用(`status=1`)后该 Key 的新请求返回 401/`40100`,已受理入住单的回调仍照常投递。 +- `callback-url` 为该应用**固定的结果上报地址**,接口请求不再传地址;签名密钥取该应用的 + `callback-secret`。`callback-url` 为空视为未开通回调;配置了地址却没有密钥时,事件直接落死信 + (避免发送无签名报文),补齐密钥后可人工重发。 +- 运行时读取走 `OpenAppRegistry`(内存快照 + 30 秒 TTL,管理页写操作后立即 `reload()`); + `OpenApiAuthFilter` 按 Key → 应用查找,并把 `appCode` 通过请求属性(`openAppCode`)传给业务层。 +- `ip-whitelist`(P4)尚未实现,表与配置类均未包含该字段。 + +--- + +## 3. 通用约定 + +### 3.1 请求 + +| 项 | 约定 | +|---|---| +| 方法/路径 | `POST /openapi/iot/stay/*`、`GET /openapi/iot/stay/unlock-records`、`GET /openapi/iot/ping` | +| Content-Type | `application/json;charset=UTF-8` | +| 鉴权头 | `X-API-Key: `;Key 决定所属应用(appCode),无需额外传应用标识 | +| 时间格式 | `yyyy-MM-dd HH:mm:ss`(服务器本地时区) | +| 字符集 | UTF-8 | +| 图片 | `photoUrl` 或 `photoBase64` 二选一;base64 单张 ≤ 2MB,仅 JPG/PNG | +| 请求追踪 | 建议调用方传 `requestId`,本系统回写日志便于排查 | + +### 3.2 统一响应 + +```json +{ + "code": 0, + "msg": "success", + "requestId": "调用方传入或系统生成", + "data": { } +} +``` + +| code | 含义 | +|---|---| +| 0 | 成功(受理成功) | +| 40001 | 参数缺失或格式错误 | +| 40002 | 时间非法(结束不大于开始等) | +| 40003 | 房间不存在或房间号不唯一 | +| 40004 | 房间不可入住(维修/停用)或门锁未绑定 | +| 40005 | 人员标识冲突 / 人员已停用 | +| 40006 | 入住单不存在或状态不允许该操作 | +| 40007 | 重复业务号且参数与首次不一致 | +| 40100 | API Key 无效(由过滤器返回) | +| 40300 | 访问被拒绝(IP 白名单/限流) | +| 42900 | 请求过于频繁 | +| 50001 | 人脸提取失败(附原因) | +| 50002 | 系统内部错误 | + +HTTP 状态码:成功 200;参数类 400;鉴权 401/403;限流 429;服务端 500。 + +### 3.3 连通性测试(`GET /openapi/iot/ping`) + +联调自检接口:外部系统用它确认**网络可达、API Key 有效、平台时钟与本机时钟的偏差**,不产生任何业务数据。 +与其它接口一致走 `X-API-Key` 鉴权;Key 无效/缺失或应用已停用,仍由过滤器返回 401/`40100`。 + +| 项 | 约定 | +|---|---| +| 方法/路径 | `GET /openapi/iot/ping` | +| 鉴权头 | `X-API-Key: `(必填) | +| 查询参数 | `requestId`,可选,回写到响应便于对账 | +| 副作用 | 无(只读、不落库、不触发回调) | + +响应示例: + +```json +{ + "code": 0, + "msg": "success", + "requestId": "req-20261101-0001", + "data": { + "serverName": "106基地门锁管理平台", + "apiVersion": "v1", + "serverTime": "2026-11-01 10:20:30", + "serverTimestamp": 1793509230000, + "timeZone": "Asia/Shanghai", + "app": { + "appCode": "HOTEL_A", + "appName": "某某酒店", + "enabled": true, + "callbackEnabled": true + } + } +} +``` + +字段说明: + +| 字段 | 说明 | +|---|---| +| `serverTime` / `serverTimestamp` | 平台服务器当前时间(`yyyy-MM-dd HH:mm:ss` / 毫秒时间戳),两者取自同一次请求 | +| `timeZone` | 服务器默认时区,用于对方换算时间窗 | +| `apiVersion` | 对外契约版本,接口有破坏性变更时递增 | +| `app.appCode` / `app.appName` | 本次 Key 对应的应用,可用于核对是否用了正确应用的密钥 | +| `app.enabled` | 应用是否启用(停用的应用已在过滤器被拒,正常返回恒为 `true`) | +| `app.callbackEnabled` | 是否已配置回调地址与签名密钥,便于判断能否收到结果回调 | + +不返回 `apiKey`、`callbackUrl`、`callbackSecret`(密钥与地址不经接口外泄)。 + +--- + +## 4. 接口一:办理入住 + +### 4.1 接口描述 + +业务系统提交入住信息,本系统完成:人员建档(或复用)→ 头像下载/解析 → 按目标门锁算法版本提取人脸特征 → 创建入住单 → 创建人脸授权并下发。接口同步返回受理结果,下发结果走回调。 + +### 4.2 接口定义 + +``` +POST /openapi/iot/stay/checkin +X-API-Key: +Content-Type: application/json +``` + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| bizNo | String(64) | 是 | 业务序列号,**应用内唯一**,用于幂等与结果关联 | +| externalPersonId | String(64) | 是 | 对方系统人员标识(假定稳定) | +| personName | String(64) | 是 | 人员姓名 | +| personType | String(2) | 否 | 人员类型:0住户 1员工 2访客 3其他,默认 2 | +| phone | String(20) | 否 | 联系电话 | +| idCardNo | String(64) | 否 | 证件号(v1 明文存储,见架构 D10 决策) | +| roomNo | String(32) | 是 | 入住房间号(**全局唯一**) | +| beginTime | String | 是 | 入住开始时间(授权生效起) | +| endTime | String | 是 | 入住结束时间(授权失效止),必须晚于 beginTime | +| photoUrl | String(512) | 二选一 | 头像图片 URL(http/https,需在域名白名单内) | +| photoBase64 | String | 二选一 | 头像图片 base64(不含 data: 前缀) | +| guestType | String(1) | 否 | 0内部 1外部;默认按 personType 推导 | +| remark | String(500) | 否 | 备注 | + +> 结果回调地址由应用配置固定(§2.6),不通过接口参数传入。 + +### 4.3 响应 + +```json +{ + "code": 0, + "msg": "accepted", + "requestId": "REQ-20261101-0001", + "data": { + "bizNo": "PMS202611010001", + "stayNo": "STAY202611011400001A2B", + "personNo": "AB001234", + "roomCode": "FFFA26102918-1203", + "roomNo": "1203", + "deviceSn": "80745D91", + "beginTime": "2026-11-01 14:00:00", + "endTime": "2026-11-03 12:00:00", + "accepted": true, + "authStatus": "1", + "callback": { "enabled": true, "url": "https://pms.example.com/hook/lock" } + } +} +``` + +`authStatus`:0 待下发 / 1 已下发 / 2 成功 / 3 下发失败 / 4 已清除 / 6 待清除(受理瞬间通常为 1)。 +`callback.url` 来自该应用配置,仅用于回显,调用方无需传入。 + +### 4.4 示例 + +请求: + +```json +POST /openapi/iot/stay/checkin +X-API-Key: 7f3c...9a +{ + "bizNo": "PMS202611010001", + "externalPersonId": "G2026001", + "personName": "张三", + "personType": "2", + "phone": "13800000000", + "roomNo": "1203", + "beginTime": "2026-11-01 14:00:00", + "endTime": "2026-11-03 12:00:00", + "photoUrl": "https://pms.example.com/photo/G2026001.jpg", + "remark": "访客入住" +} +``` + +成功响应:见 §4.3。 + +失败响应(房间不存在): + +```json +{ "code": 40003, "msg": "房间不存在:1203", "requestId": "REQ-...", "data": null } +``` + +### 4.5 业务规则 + +1. `(appCode, bizNo)` 已存在:若 `externalPersonId`、`roomNo` 与首次一致,直接返回原结果(幂等);不一致返回 40007。 +2. 人员解析:先按 `(appCode, externalPersonId)` 查映射;无则建档并写映射;映射人员已被退房清理(D23)时重新建档并改指映射;人员停用或超出人员有效期返回 40005。 +3. 房间定位:**房间号全局唯一**,直接按 `roomNo` 定位;不存在返回 40003。 +4. 房间必须已绑门锁;维修/停用返回 40004。**不限制房间入住人数**,同房间可多人入住。 +5. 人脸:`photoUrl`/`photoBase64` **必须提供一个**,两者都缺时在校验阶段即返回 40001(缺料归调用方),不建人员、不建人脸;按目标门锁 `face_algo_version` 提取,门锁未配置版本时提取失败(提示先维护门锁算法版本)。**实现口径**:照片与库中已有照片不同(或新建档)则重新提取,照片未变且该版本特征可用时复用;提取失败返回 50001。D23 起自动建档人员退房即清理照片、特征与档案,复住必须重新传照片。 +6. 授权有效期 = `beginTime` ~ `endTime`;`beginTime` 早于当前时间时,授权生效起取当前时间,入住单仍记录对方传入的时间。 +7. 同一人员在同一房间已有在住单:允许(如家属同住),但返回体附加 `duplicatePersonInRoom=true` 供对方判断。 +8. 授权创建后立即尝试下发;命令失败由命令重试 + 授权补偿处理,最终结果回调到该应用配置的地址。 +9. **数据隔离**:创建的入住单 `biz_system = 当前 appCode`;后续退房/续住/查询只能操作本应用数据。 + +--- + +## 5. 接口二:办理退房 + +### 5.1 接口描述 + +结束入住单、释放房间占用,并下发该人员在本房门锁上的人脸删除指令。删除结果回调。 + +### 5.2 接口定义 + +``` +POST /openapi/iot/stay/checkout +X-API-Key: +``` + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| bizNo | String(64) | 条件必填 | 原入住业务序列号;与 `personNo+roomNo` 二选一 | +| personNo | String(32) | 条件必填 | 本系统人员编号(入住接口返回) | +| externalPersonId | String(64) | 条件必填 | 对方人员标识,可与 `personNo` 二选一 | +| roomNo | String(32) | 条件必填 | 入住房间号(全局唯一) | +| remark | String(500) | 否 | 备注 | + +### 5.3 响应 + +```json +{ + "code": 0, + "msg": "accepted", + "data": { + "bizNo": "PMS202611010001", + "stayNo": "STAY202611011400001A2B", + "personNo": "AB001234", + "roomCode": "FFFA26102918-1203", + "stayStatus": "2", + "authStatus": "1", + "accepted": true + } +} +``` + +### 5.4 业务规则 + +1. 优先按 `(appCode, bizNo)` 定位入住单;否则按 `personNo/externalPersonId + roomNo` 定位**该房间在本应用下的在住单**。 +2. 未找到在住单:已退房则幂等返回 `stayStatus=2`;从未入住返回 40006。 +3. 退房即结束入住、释放房间占用(房间还有其他人时房态保持「在住」)。 +4. 删除指令按「人员 + 本房门锁」下发,**不影响该人在其它房间的授权**。 +5. 删除失败进入「待清除(6)」,补偿每 5 分钟重发 delete,成功后再次回调到该应用配置的地址。 +6. **数据隔离**:只能操作 `biz_system = 当前 appCode` 的入住单;其它应用的 `bizNo/personNo` 视为不存在(40006),避免跨系统越权。 + +--- + +## 6. 接口三:办理续住 + +### 6.1 接口描述 + +延长指定入住的有效期并重新下发授权,结束后回调。 + +### 6.2 接口定义 + +``` +POST /openapi/iot/stay/extend +X-API-Key: +``` + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| bizNo | String(64) | 条件必填 | 原入住业务序列号;与 `personNo+roomNo` 二选一 | +| personNo / externalPersonId | String | 条件必填 | 人员标识 | +| roomNo | String(32) | 条件必填 | 房间号(全局唯一) | +| endTime | String | 是 | 新的结束时间,必须晚于原结束时间 | +| remark | String(500) | 否 | 备注 | + +### 6.3 响应 + +```json +{ + "code": 0, + "msg": "accepted", + "data": { + "bizNo": "PMS202611010001", + "stayNo": "STAY202611011400001A2B", + "personNo": "AB001234", + "previousEndTime": "2026-11-03 12:00:00", + "endTime": "2026-11-10 12:00:00", + "accepted": true + } +} +``` + +### 6.4 业务规则 + +1. 仅「在住」状态的入住单可续住;已退房/已换房返回 40006。 +2. 新结束时间必须晚于当前结束时间且晚于当前时间,否则 40002。 +3. 更新入住单 `end_time` 后重新下发人脸授权(add 帧携带新有效期)。 +4. 下发失败按授权补偿重试,最终结果回调到该应用配置的地址。 +5. 只能续住本应用(当前 `appCode`)的入住单。 + +--- + +## 7. 接口四:查询开锁记录 + +> 实现状态:**P3 已实现(代码就绪)**,未执行脚本、未联调。落点 +> `OpenStayApiController#unlockRecords` → `OpenUnlockRecordServiceImpl`,数据来自 +> `lock_access_log`;房间→门锁取并集的 SQL 在 `LockRoomDeviceHistoryMapper.selectDeviceSnsByRoomAndTime`。 +> P3 不新增表、不新增脚本,依赖 P1 已交付的 `lock_room_device_history` 与存量回填。 + +### 7.1 接口描述 + +按「房间号 + 人员标识 + 时间段」查询开锁记录,用于业务系统核对进出情况。 + +### 7.2 接口定义 + +``` +GET /openapi/iot/stay/unlock-records?roomNo=1203 + &personNo=AB001234&beginTime=2026-11-01 00:00:00&endTime=2026-11-03 23:59:59 + &pageNum=1&pageSize=20 +``` + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| roomNo | String(32) | 是 | 房间号(全局唯一) | +| personNo | String(32) | 否 | 本系统人员编号 | +| externalPersonId | String(64) | 否 | 对方人员标识(与 personNo 二选一,都传时以 personNo 为准) | +| beginTime / endTime | String | 是 | 查询时间段,跨度 ≤ 31 天 | +| openType | String(2) | 否 | 开门方式(设备码,见字典 `lock_open_type`) | +| pageNum / pageSize | int | 否 | 默认 1 / 20,pageSize ≤ 100 | +| requestId | String(64) | 否 | 请求追踪号,回写日志与响应 | + +### 7.3 响应 + +```json +{ + "code": 0, + "msg": "success", + "data": { + "total": 2, + "rows": [ + { + "openTime": "2026-11-01 18:20:31", + "personNo": "AB001234", + "personName": "张三", + "roomNo": "1203", + "deviceSn": "80745D91", + "openType": "7", + "openTypeName": "人脸", + "openResult": "1", + "openResultName": "成功", + "raw": "…" + } + ] + } +} +``` + +### 7.4 业务规则 + +1. 房间 → 门锁取**该时间段内绑定过的所有门锁**:当前绑定(`lock_room.device_id`)∪ 房间门锁绑定历史(`lock_room_device_history`,与查询时间段有重叠的记录)∪ 该房间入住单中记录的 `device_id`(兜底)。 +2. 按上述门锁 SN 集合查询 `lock_access_log`,因此**房间更换过门锁时历史记录仍能查到**。 +3. 时间跨度超过 31 天返回 40001。 +4. 只返回该房间门锁上的记录,不返回其它房间。 +5. 人员过滤:`personNo` 或 `externalPersonId`(按映射转成 `personNo`),人员属于本应用。 +6. 结果按开门时间倒序,分页返回。 + +**P3 实现口径**(代码就绪,未联调): + +| 点 | 处理 | +|---|---| +| 时间窗语义 | `open_time` 落在 `[beginTime, endTime]` 闭区间;历史表重叠判定为 `bind_time <= endTime && (unbind_time is null \|\| unbind_time >= beginTime)` | +| 入住单兜底 | 取该房间 `del_flag='0'` 且 `[begin_time, coalesce(checkout_time, end_time)]` 与查询时段重叠的入住单 `device_sn` 快照,防历史表未回填时漏查 | +| 房间无锁 | 并集为空时直接返回 `total=0` 空列表,不报 40004(查询不是入住,无锁也应有确定结果) | +| 人员隔离 | `personNo` 直接传入时按 `lock_person_ref(app_code, person_no)` 校验,未在本应用建档返回 40005;`externalPersonId` 按 `(app_code, external_id)` 反查,缺失同样 40005 | +| 参数校验 | `openType` 只接受字典值 2/3/4/5/7/9,其它返回 40001;`pageNum < 1` 或 `pageSize ∉ [1,100]` 返回 40001;`endTime <= beginTime` 返回 40002 | +| 字段最小化 | 只返回 `openTime/personNo/personName/roomNo/deviceSn/openType(-Name)/openResult(-Name)/raw`,不返回卡号、设备 ID、网关 | +| 名称口径 | `openTypeName` / `openResultName` 用与字典 `lock_open_type` / `lock_open_result` 一致的静态映射;未知新码原样回填,不吞成 null | +| 响应 msg | 查询成功 `msg=success`(受理类接口仍为 `accepted`) | + +--- + +## 8. 结果回调(出站:上报入住/退房/续住结果) + +> 实现状态:**P2 已实现(代码就绪)**,未执行升级脚本、未与业务系统实机联调。 +> 落库表 `lock_callback_task`(脚本 `sql/upgrade_d20_p2_callback.sql`)、定时投递 +> `LockCallbackRetryTask`(30 秒一轮 + Redis 分布式锁)、页面入口「门禁管理 › 回调任务」 +> (查询 + 详情 + 人工重发)均已交付。P1 三个接口响应里的 `callback.enabled` / `callback.url` +> 保留,用于对方自检配置是否生效。 + +### 8.1 回调请求(本系统 → 业务系统) + +``` +POST {该应用配置的 callbackUrl} +Content-Type: application/json +X-Callback-Id: +X-Timestamp: 1793599999 +X-Signature: +``` + +> 回调地址与签名密钥随应用(`appCode`)固定配置(§2.6),接口请求不传地址;不同业务系统上报到各自地址。 + +```json +{ + "eventId": "3f2a9c1e-...", + "eventType": "STAY_CHECKIN_RESULT", + "revision": 1, + "bizNo": "PMS202611010001", + "stayNo": "STAY202611011400001A2B", + "personNo": "AB001234", + "externalPersonId": "G2026001", + "roomNo": "1203", + "roomCode": "FFFA26102918-1203", + "deviceSn": "80745D91", + "result": "SUCCESS", + "message": "人脸授权下发成功", + "occurredAt": "2026-11-01 14:02:11" +} +``` + +| 字段 | 说明 | +|---|---| +| eventType | `STAY_CHECKIN_RESULT` / `STAY_CHECKOUT_RESULT` / `STAY_EXTEND_RESULT` | +| revision | 同一 bizNo 的结果版本,结果变更(如失败后补偿成功)时递增 | +| result | `SUCCESS` / `FAILED` | +| message | 失败原因,成功时可为空 | + +业务系统需返回 HTTP 200 + `{"code":0}` 表示接收成功;其它视为失败。 + +### 8.2 重试与投递 + +| 项 | 约定 | +|---|---| +| 重试次数 | 5 次(首次投递失败后再补发 5 次,共 6 次请求) | +| 退避 | 1s / 5s / 30s / 2min / 10min | +| 落库 | `lock_callback_task`(app_code、eventId、eventType、bizNo、callbackUrl、payload、status、revision、retry_count、next_retry_time、last_error);`callbackUrl` 取事件产生时该应用的配置快照 | +| 执行 | 定时任务每 30 秒扫描到期任务(分布式锁,参照现有任务写法) | +| 超时 | 单次连接 3s、读取 5s | +| 最终失败 | 状态置死信,页面/日志告警,支持人工重发 | + +**P2 实现口径(代码即事实)**: + +| 项 | 实现 | +|---|---| +| 任务状态 | `0 待结果`(已登记,等设备回执)/ `1 待发送` / `2 已成功` / `3 死信`;字典 `lock_callback_status` | +| 接收判定 | HTTP 200 且响应体可解析出 `code=0`;其余(含 200 但 body 非 JSON)均为失败 | +| 重试计数 | `retry_count` = 已失败次数;第 6 次失败(`attempt > 5`)置死信 | +| 等待超时 | 超过 24 小时未收到设备回执,生成 `FAILED` 报文(message=等待设备回执超时)转入投递 | +| 配置缺失 | `callback-url` 为空:不登记任何任务;`callback-secret` 为空:事件直接落死信并记录原因 | +| 结果版本 | 同一 `auth_id` 同一事件已有任务时,只有结果发生变化(失败→成功)才新建 `revision+1`;重复同结果回执不重复上报 | +| 人工重发 | 死信/已成功任务可置回待发送并清零 `retry_count`;等待结果或缺报文的任务拒绝重发并返回原因 | +| 签名 | `X-Signature = hex(HMAC-SHA256(timestamp + "." + 原始报文, callback-secret))`,报文原文参与签名,重发使用同一 `payload` 重新生成时间戳与签名 | + +### 8.3 幂等与顺序 + +- 对方按 `eventId` 去重;同一 `bizNo` 以 `revision` 最大者为最终结果。 +- 回调不保证严格顺序,业务系统不得依赖到达顺序。 + +--- + +## 9. 数据模型变更(P1、P2 已出脚本,均未执行;P3 不涉及) + +| 表 | 变更 | 说明 | +|---|---|---| +| `lock_stay` | 新增 `biz_no varchar(64)`、`biz_system varchar(32)`、`source char(1)`(1平台 2接口) | 唯一索引 `uk_lock_stay_biz (biz_system, biz_no)`:业务号在应用内唯一,平台手工单两者为 NULL;不放回调地址 | +| `lock_person_ref` | 新增表:`ref_id, person_id, person_no, app_code, external_id, create_time`,唯一 `(app_code, external_id)` | 外部人员标识映射(按应用隔离) | +| `lock_callback_task` | 新增表:`task_id, app_code, event_id, event_type, biz_no, revision, auth_id, callback_url, payload, status, retry_count, next_retry_time, last_error, create_time, update_time`;唯一键 `(app_code, biz_no, event_type, revision)` 与 `event_id` | **P2 已交付脚本** `sql/upgrade_d20_p2_callback.sql`(含两个字典与菜单,幂等,未执行);`callback_url` 落事件产生时的配置快照 | +| `lock_room_device_history` | 新增表:`history_id, room_id, device_id, device_sn, bind_time, unbind_time, create_by, create_time` | 房间-门锁绑定历史,支撑"查询开锁记录包含历史门锁";在房间绑定/换锁/解绑时写入 | +| `lock_auth` / `lock_command` | 不改 | 回调通过 `lock_stay.auth_id → lock_command.biz_id` 关联 | + +升级脚本: +- P1:`sql/upgrade_d20_openapi_stay.sql`(幂等,含存量房间绑定关系回填与只读核对语句)。 +- P2:`sql/upgrade_d20_p2_callback.sql`(幂等,建 `lock_callback_task`、两个字典、回调任务菜单; + 依赖 P1 的 `lock_stay.biz_no/biz_system`,须在 P1 脚本之后执行)。 +- P3:**无脚本**。开锁记录查询只读 `lock_access_log` 与 P1 的 `lock_room_device_history`, + 不建表、不加索引(`lock_access_log` 已有 `device_sn` 与 `open_time` 单列索引)。 +**两个脚本均已交付但未在任何环境执行**;MySQL 不支持 `add column if not exists`,重复执行时 +`lock_stay` 加列段会报 `Duplicate column name`,按现有升级脚本惯例忽略后继续执行即可。 + +> `lock_stay.biz_no` 唯一索引允许 NULL(平台手工入住的单没有业务号),MySQL 唯一索引对多 NULL 生效。 +> 房间绑定/换锁写历史:现有 `LockRoomServiceImpl.bindDevice` 在更新 `lock_room.device_id` 前后各写一条记录(旧锁写 `unbind_time`,新锁写 `bind_time`),历史表只增不改。 + +--- + +## 10. 安全与合规 + +| 项 | 措施 | +|---|---| +| 鉴权 | 每应用独立 `X-API-Key`;Key 与 `appCode` 绑定,业务层全部按 `appCode` 隔离数据 | +| 网络 | 每应用可配 IP 白名单(`apps[].ip-whitelist`),命中不通过返回 40300 | +| 照片 URL | 域名白名单,禁止内网地址与重定向到非白名单域名(防 SSRF);超时 5s、大小 ≤ 5MB | +| 人脸数据 | 特征仅落库不下发日志;报文留痕走现有 `YdlinkPayloadMasker` 脱敏 | +| 数据最小化 | 查询接口只返回必要字段,不返回身份证等敏感信息 | +| 限流 | **按应用**(API Key)每分钟 120 次、单 IP 每分钟 60 次(令牌桶) | +| 审计 | 所有开放接口调用写 `@Log` 或专用开放接口日志(appCode、bizNo、耗时、结果码) | +| 越权 | 查询/退房/续住必须校验 `biz_system = appCode`;禁止仅凭 personNo 跨房间、跨应用操作 | +| 回放 | 回调带 `X-Timestamp` 与 HMAC 签名(P2 已实现),对方校验时间窗(建议 5 分钟)并验签;接口侧签名留 P4 | + +> **P1 落地情况**:已实现「每应用独立 Key + 按 `appCode` 全链路隔离」「退房/续住/查询必须命中 +> `biz_system = appCode`」「`photoBase64` 只接受 JPG/PNG、解码后 ≤ 2MB、文件名由平台生成(不含调用方输入, +> 无路径穿越)」「调用日志记录 appCode / bizNo / requestId / 结果码 / 耗时」。 +> **P2 落地情况**:出站回调已带 `X-Callback-Id`、`X-Timestamp`、`X-Signature` +> (HMAC-SHA256(timestamp + "." + 报文, `callback-secret`));回调地址来自平台配置 +> (非调用方输入),但平台会主动 POST 该地址,**若配置被恶意篡改可改为内网地址**。 +> D21 起 `api-key` 与 `callback-secret` 明文存于 `lock_open_app`(按 D10 暂缓加密),页面只展示掩码, +> 完整值仅在新增/重置时返回一次;回调地址变更走「开放管理」页面并留操作日志,不再需要发布流程。 +> **尚未实现(P4)**:`apps[].ip-whitelist` IP 白名单、令牌桶限流、`photoUrl` 域名白名单与 SSRF 防护 +> (当前 `photoUrl` 直接交给特征提取侧读取,**存在被指向内网地址的风险,上线前须由网络侧限制或补 P4**)、 +> 接口侧 HMAC 签名。 + +--- + +## 11. 监控与运维 + +- 指标:接口 QPS/成功率/耗时、人脸提取成功率、授权下发成功率、回调成功率与积压数。 +- 告警:回调死信数 > 0、单房间连续下发失败 ≥ 3 次、人脸提取失败率 > 10%。 +- 对账:每日按 `biz_no` 统计「受理 / 授权成功 / 回调成功」三态差异,输出报表。 +- 运维入口(内部):回调任务列表与手工重发、按 bizNo 查询入住单与命令轨迹。 + +--- + +## 12. 测试用例 + +> 前置:`iot.openapi.enabled=true`;配置两个应用 `PMS`、`VISITOR`(各自 API Key 与回调地址); +> 公寓 A(`FFFA26102918`)下房间 1203(已绑门锁 `80745D91`)、1204(未绑门锁)、1301(维修)。 + +### 12.1 办理入住 + +| # | 场景 | 请求要点 | 预期 | +|---|---|---|---| +| T01 | 正常入住(照片 URL) | bizNo=B001,roomNo=1203,有效起止 | code=0,返回 stayNo/personNo,房间出现该入住单,授权状态 1/2 | +| T02 | 正常入住(base64) | 同上,photoBase64 | 同 T01 | +| T03 | 幂等重放 | 原样重复 T01 | code=0,返回同一 stayNo/personNo,不新增入住单 | +| T04 | bizNo 重复但参数不同 | 同 B001,换 roomNo=1204 | code=40007 | +| T05 | 缺照片 | 不传 photoUrl/photoBase64(首次与复住同一规则) | code=40001(缺料),不建档、不新增入住单 | +| T06 | 时间非法 | endTime 早于 beginTime | code=40002 | +| T07 | 房间不存在 | roomNo=9999 | code=40003 | +| T08 | 房间号全局唯一 | 不传 groupCode,仅 roomNo=1203 | code=0,唯一命中该房间 | +| T09 | 房间未绑门锁 | roomNo=1204 | code=40004 | +| T10 | 房间维修 | roomNo=1301 | code=40004 | +| T11 | 人员停用 | externalPersonId 指向已停用人员 | code=40005 | +| T12 | 同人同房重复入住 | 同 externalPersonId 再办一次(新 bizNo) | code=0,返回 `duplicatePersonInRoom=true` | +| T13 | 无 API Key | 不带 X-API-Key | HTTP 401 | +| T14 | 开放接口未启用 | `enabled=false` | HTTP 404 | +| T15 | 门锁离线 | 断开网关后入住 | code=0(受理),授权状态 1/3,回调 FAILED 或后续补偿成功 | +| T16 | 照片 URL 不在白名单 | photoUrl 指向内网 | P4 未实现域名白名单,当前会被直接读取(见 §10 风险),上线前由网络侧限制 | +| T17 | 跨应用查询/退房 | 用 VISITOR 的 Key 操作 PMS 的 bizNo | code=40006,不泄露数据 | +| T18 | 回调地址按应用 | PMS 入住、VISITOR 入住 | 回调分别发往各自配置地址,不串号 | +| T19 | 两个应用使用相同 bizNo | PMS 与 VISITOR 各提交 bizNo=B001 | 各自成功,互不幂等、互不覆盖 | + +### 12.2 办理退房 + +| # | 场景 | 请求要点 | 预期 | +|---|---|---|---| +| T21 | 按 bizNo 退房 | bizNo=B001 | code=0,入住单状态 2,房间释放(无其他人时) | +| T22 | 按人员+房间退房 | personNo+roomNo | 同 T21 | +| T23 | 重复退房 | 再调一次 | code=0,返回已退房状态(幂等) | +| T24 | 入住单不存在 | 随机 personNo+roomNo | code=40006 | +| T25 | 一人多房只退一间 | 该人在 1203、1205 均有在住,退 1203 | 仅 1203 结束,1205 不受影响 | +| T26 | 删除失败 | 旧房门锁离线 | code=0(受理),删除命令重试;最终回调 FAILED;授权进入 6 待清除,补偿只重发 delete | +| T27 | 删除失败后补偿成功 | 门锁恢复 | 回调 revision+1,result=SUCCESS | + +### 12.3 办理续住 + +| # | 场景 | 请求要点 | 预期 | +|---|---|---|---| +| T31 | 正常续住 | bizNo=B001,endTime 延后 7 天 | code=0,入住单 end_time 更新,授权重新下发 | +| T32 | 新结束时间早于当前 | endTime 提前 | code=40002 | +| T33 | 已退房续住 | 对 T21 的单续住 | code=40006 | +| T34 | 幂等续住 | 重复提交相同 endTime | code=0,不重复下发 | +| T35 | 续住下发失败 | 门锁离线 | code=0(受理),回调最终结果 | + +### 12.4 查询开锁记录 + +> 用例状态:**P3 代码就绪,全部待联调验证**(依赖 P1 脚本与房间门锁历史回填)。 + +| # | 场景 | 请求要点 | 预期 | +|---|---|---|---| +| T41 | 正常查询 | 房间+人员+当天 | 返回该房间门锁上该人员的记录 | +| T42 | 只传房间 | 不传人员 | 返回该房间全部记录 | +| T43 | 时间段超限 | 跨度 40 天 | code=40001 | +| T44 | 无记录 | 未来时间段 | total=0,rows=[] | +| T45 | 越权查询 | 用别人 personNo 查该房间 | `personNo` 未在本应用建档直接 40005;已建档的只返回该房间记录,不返回其它房间数据 | +| T46 | 分页边界 | pageSize=200 | code=40001(超过 100) | +| T47 | 包含历史门锁 | 房间先绑锁 A 再换锁 B,查询覆盖换锁前后的时间段 | 同时返回锁 A 与锁 B 上的开锁记录 | +| T48 | 跨应用查询隔离 | VISITOR 用 Key 查 PMS 人员的记录 | 按 `lock_person_ref` 判定不属于本应用,返回 40005 | +| T49 | 房间未绑锁 | 查询从未绑定门锁的房间 | total=0,rows=[](不返回 40004) | +| T50 | 非法开门方式 | openType=1 | code=40001,提示可选值 | + +### 12.5 回调 + +> 用例状态:**P2 代码就绪,全部待联调验证**(需先执行脚本并配置两个应用的回调地址与密钥)。 + +| # | 场景 | 预期 | +|---|---|---| +| T51 | 回调成功 | 对方返回 200 `{"code":0}`,任务状态 2 已成功,不重试 | +| T52 | 对方 500 | 按 1s/5s/30s/2min/10min 重试 5 次后置死信(共 6 次请求) | +| T53 | 对方超时 | 同上(单次连接 3s、读取 5s) | +| T54 | 签名错误 | 对方验签失败返回非 200 或非 `code=0`,本系统按失败重试 | +| T55 | 重复投递 | 同一 eventId 投递两次,对方按 eventId 去重业务不受影响 | +| T56 | 应用未配置回调地址 | `callback-url` 为空:接口照常受理,响应 `callback.enabled=false`,不产生回调任务 | +| T57 | 应用未配置签名密钥 | `callback-url` 有值但 `callback-secret` 为空:事件落死信,页面可见原因;补齐密钥后可重发 | +| T58 | 设备长时间无回执 | 等待位超过 24 小时:生成 FAILED 报文并投递(原因=等待设备回执超时) | +| T59 | 死信人工重发 | 页面对死信点击重发:状态回到 1 待发送、重试次数归零,30 秒内重新投递 | +| T60 | 结果变更上报新版本 | 退房删除失败(revision=1 FAILED)→ 补偿成功:生成 revision=2 SUCCESS 任务 | + +### 12.6 通用 + +| # | 场景 | 预期 | +|---|---|---| +| T61 | 限流 | 超过 120 次/分钟返回 429 | +| T62 | 请求体超大(base64 > 2MB) | code=40001 | +| T63 | 非法时间格式 | code=40001 | +| T64 | 并发同一 bizNo | 只有一条入住单,另一请求幂等返回 | + +### 12.7 连通性测试 + +| # | 场景 | 预期 | +|---|---|---| +| T65 | 正确 Key 调 `GET /openapi/iot/ping` | code=0,返回 `serverTime`/`serverTimestamp`/`timeZone`/`apiVersion` 与 `app.appCode/appName` | +| T66 | 缺失或错误 Key 调 ping | HTTP 401,code=40100 | +| T67 | 两个应用的 Key 分别调 ping | 各自返回自己的 `appCode`/`appName`,不串号 | +| T68 | 停用应用后调 ping | HTTP 401,code=40100 | + +--- + +## 13. 已确认结论与剩余问题 + +### 13.1 已确认(2026-10-31 业务确认) + +| # | 结论 | 文档落点 | +|---|---|---| +| 1 | 回调地址由系统配置固定,每个应用各自配置上报地址 | §2.6、§8.1 | +| 2 | 支持多业务系统隔离,每个业务系统使用独立 API Key | §2.2、§2.6 | +| 3 | 房间号全局唯一,接口只需 `roomNo` | §4.2、§5.2、§6.2、§7.2 | +| 4 | 外部人员标识稳定,`(appCode, externalPersonId)` 作为映射唯一键 | §2.4 | +| 5 | 开锁记录查询包含历史门锁,新增 `lock_room_device_history` | §7.4、§9 | + +### 13.2 仍需确认 + +P1 已按下表「建议默认」实现,**结论未经业务方确认**;若与对方预期不符需回改代码。 + +| # | 问题 | 影响 | 建议默认 | P1 实现口径 | +|---|---|---|---|---| +| Q1 | 是否需要「入住结果查询」接口(不依赖回调主动查) | 对方容错能力 | 二期增加 `GET /openapi/iot/stay/{bizNo}` | 未实现;重复调用入住接口(同 `bizNo`)即幂等返回当前状态,可临时当查询用 | +| Q2 | 接口侧是否一期就上 HMAC 签名(不只是回调) | 安全成本 | 一期 API Key + IP 白名单,二期签名 | 只做 API Key;签名与 IP 白名单均留 P4 | +| Q3 | 限流阈值(应用 120 次/分钟)是否符合对方实际 TPS | 容量规划 | 按实测调整 | 未实现限流(P4) | +| Q4 | 多应用配置变更(换 Key / 换回调地址)是否可接受重启 | 运维方式 | 一期配置文件 + 重启;二期配置表热加载 | **D21 已改为配置表热加载**:应用清单存 `lock_open_app`,管理页改完立即生效(30 秒 TTL 兜底),yml 仅作首次导入与降级 | +| Q5 | 外部人员是否需要在平台「人员管理」列表可见 | 数据治理 | 可见,备注标注来源应用 | 可见;`create_by=openapi:{appCode}`,`remark` 记录来源应用与外部标识。D23 起退房后人员、人脸与特征直接删除(物理删除),只在住期间可见(历史单仍保留姓名快照) | +| Q6 | 回调报文是否需要携带开锁记录、照片等更多字段 | 对方对账便利性 | 首版只带业务与设备字段 | P2 已按首版字段实现(eventId/eventType/revision/bizNo/stayNo/personNo/externalPersonId/roomNo/roomCode/deviceSn/result/message/occurredAt);扩展字段待对方确认后加版本 | + +--- + +## 14. 实施拆分建议 + +| 阶段 | 内容 | 验收 | 状态(2026-11-01) | +|---|---|---|---| +| P1 | 多应用配置与鉴权改造(Key → appCode)+ 入住/退房/续住接口 + 人员映射 + `lock_stay` 扩展 + `lock_room_device_history` | 两个应用各自入住/退房成功,数据互不可见 | **代码就绪**,全量编译 BUILD SUCCESS;升级脚本未执行、未实机联调,验收项待环境验证 | +| P2 | 按应用的结果回调(事件、签名、重试、落库、死信) | 回调成功;断网重试与死信可查;地址按应用分流 | **代码就绪**,全量编译 BUILD SUCCESS;脚本 `upgrade_d20_p2_callback.sql` 未执行、未与业务系统联调,验收项待环境验证 | +| P3 | 开锁记录查询(含历史门锁) | 换锁前后的记录都能查到 | **代码就绪**,全量编译 BUILD SUCCESS;无新增脚本(复用 P1 历史表);未联调,验收项待环境验证 | +| P4 | 安全加固(IP 白名单、限流、签名升级)与对账报表 | 压测与安全测试通过 | 未实现 | + +> P1 交付明细见《项目管理看板》§4.19,P2 见 §4.20,P3 见 §4.21,D21 开放管理见 §4.25,ping 连通性测试见 §4.27。上线前仍需:先执行 P1 再执行 P2 升级脚本 → +> 执行 `upgrade_d21_openapp.sql`(应用配置落表,首次启动自动导入 yml 中的应用)→ +> 打开「开放管理 › 应用接入」配置两个应用的 Key、回调地址与签名密钥 → 按 §12 用例实机联调 → +> 与业务方确认 §13.2。 \ No newline at end of file diff --git a/doc/对外开放接口Apifox测试用例.md b/doc/对外开放接口Apifox测试用例.md new file mode 100644 index 0000000..0aa2ee4 --- /dev/null +++ b/doc/对外开放接口Apifox测试用例.md @@ -0,0 +1,646 @@ +# 对外开放接口 Apifox 测试用例(D20) + +> 适用接口:`POST /openapi/iot/stay/checkin|checkout|extend`、`GET /openapi/iot/stay/unlock-records` +> 以及出站结果回调(P2)。 +> 对应实现:P1 住宿受理、P2 结果回调、P3 开锁记录查询。 +> 文档日期:2026-11-01。 + +**执行前提(重要)** + +1. 本文只描述测试用例与断言,**不包含任何数据库变更**。`sql/upgrade_d19_stay.sql`、 + `sql/upgrade_d20_openapi_stay.sql`、`sql/upgrade_d20_p2_callback.sql` 需由测试环境维护人员 + 按《数据库迁移指引》顺序手工执行,执行完成后再跑本用例。 +2. 人脸提取服务、Redis(回调定时任务分布式锁)必须可用,否则入住类用例与回调类用例无法通过。 +3. 用例里的时间统一用 `yyyy-MM-dd HH:mm:ss`,与服务器同一时区。 + +--- + +## 1. 测试准备 + +### 1.1 Apifox 环境变量 + +在 Apifox「环境管理」中新建环境 `D20-测试`,添加以下变量: + +| 变量名 | 示例值 | 用途 | +|---|---|---| +| `baseUrl` | `http://127.0.0.1:8080` | 平台地址,不带结尾 `/` | +| `pmsKey` | `pms-test-key-0001` | 应用 PMS 的 `X-API-Key` | +| `visitorKey` | `visitor-test-key-0001` | 应用 VISITOR 的 `X-API-Key`(未开通回调) | +| `roomNo` | `1203` | 正常房间:已绑门锁、房态正常、门锁已配置 `face_algo_version` | +| `roomNoOther` | `1401` | 正常房间:已绑门锁,用于复住/换房场景 | +| `roomNoNoLock` | `1301` | 房间存在但未绑门锁 | +| `roomNoRepair` | `1302` | 房间房态为维修或停用 | +| `roomNoNoAlgo` | `1303` | 已绑门锁但门锁 `face_algo_version` 为空 | +| `roomNoEmpty` | `1501` | 正常房间,查询时间段内无开锁记录 | +| `roomNoNotFound` | `9999` | 不存在的房号 | +| `deviceSn` | `80745D91` | `roomNo` 绑定的门锁 SN | +| `photoUrl` | `https://pms.example.com/photo/face-01.jpg` | 平台服务器能访问到的人脸照片 | +| `photoBase64` | `/<生成方式见 §1.5>/` | JPG 或 PNG 的 base64,不含 `data:` 前缀 | +| `visitorExtId` | 每次运行唯一 | 未开通回调应用首次入住用的人员标识 | +| `reqId` | 前置脚本生成 | 请求追踪号 | +| `bizNo` | 前置脚本生成 | 入住业务号(应用内唯一) | +| `stayNo` | 用例回写 | 平台入住单号 | +| `personNo` | 用例回写 | 平台人员编号 | +| `extPersonId` | 前置脚本生成 | 本次测试人员的对方标识 | +| `oldEndTime` | 用例回写 | 续住前的结束时间 | + +> `roomNo`、`roomNoOther`、`roomNoEmpty` 建议单独建测试房间,不要与生产房间混用。 +> 同一房号可以有多张在住单,重复跑用例不会互相阻塞,但人员与 `bizNo` 每次运行应重新生成。 + +### 1.2 应用配置(服务端) + +回调地址与签名密钥按应用固定配置,接口请求不传。测试环境 `application.yml` 示例: + +```yaml +iot: + openapi: + enabled: true + apps: + - app-code: PMS + app-name: 酒店管理系统 + api-key: pms-test-key-0001 + callback-url: http://127.0.0.1:9099/hook/lock + callback-secret: pms-callback-secret-0001 + enabled: true + - app-code: VISITOR + app-name: 访客系统 + api-key: visitor-test-key-0001 + # 故意留空:验证“未开通回调”分支 + callback-url: + callback-secret: + enabled: true +``` + +- 配置在启动时加载,**修改后需要重启服务**。 +- 多应用模式下 Key 必须互不重复,否则启动即失败。 +- 需要验证 `enabled: false` 时,把某个应用的 `enabled` 改为 `false` 再重启。 + +### 1.3 平台测试数据 + +| 数据 | 要求 | +|---|---| +| 房间 `roomNo` | 已绑门锁 `deviceSn`,房态空闲/在住均可,门锁 `face_algo_version` 必须非空 | +| 房间 `roomNoOther` | 已绑同算法版本的门锁,用于复住重新入住(须重新传照片) | +| 房间 `roomNoNoLock` | `device_id` 为空 | +| 房间 `roomNoRepair` | 房态为 2 维修或 3 停用 | +| 房间 `roomNoNoAlgo` | 已绑门锁,但门锁 `face_algo_version` 为空 | +| 房间 `roomNoEmpty` | 正常绑锁,但查询时间段内没有任何开门记录 | +| 门锁 `deviceSn` | 在线、授权/开锁命令能正常下发 | +| 人脸提取服务 | 可访问,能对测试照片返回特征 | +| Redis | 可访问(回调定时任务 `callback-retry` 需要分布式锁) | +| 管理端账号 | 具备「门禁管理 › 回调任务」查询与重发权限 | +| 开锁记录 | 由真实开门产生,或由测试环境维护人员向 `lock_access_log` 造数(不由开发直接执行) | + +### 1.4 Apifox 目录与执行顺序 + +``` +D20 开放接口 +├── 00 鉴权 A01–A05 +├── 00 连通性 P01–P04 +├── 01 入住 C01–C30 +├── 02 退房 K01–K11(依赖在住单) +├── 03 续住 E01–E11(依赖在住单) +├── 04 开锁记录 U01–U23 +├── 05 回调 B01–B12 +└── 99 串联 CH01–CH02 +``` + +- 建议执行顺序:`01 入住 → 03 续住 → 02 退房 → 04 开锁记录`。 + 续住必须在退房之前跑,否则单子已结束,E01/E02/E10 会返回 40006;E05 是例外, + 它需要一张已退房单,放到 K01 之后单独跑。 +- 单接口用例可独立执行;带「依赖」的用例需要先跑对应前置用例,或在环境里预置 `bizNo`: + - C04/C05/C06/C28/C29/C30 依赖 C01 生成的人员/业务号; + - E01–E04、E08–E11 依赖一张**在住**单;E05 依赖一张**已退房**单(可在 K01 之后跑); + - K01/K02 使用 C01 的单;K03 使用 C05 的单;K04 使用 C06 的单;K09 在 K03 之后跑; + - U01/U03/U04/U10 依赖真实开锁记录或造数。 +- 未特别说明时,所有 POST 请求头都带 `Content-Type: application/json` 与 + `X-API-Key: {{pmsKey}}`。 +- **所有用例都带 `requestId`**:POST 放请求体,GET 放 Query,值 `{{reqId}}`。 + §2.1/§2.3 的 requestId 回显断言依赖这一点;个别不传的用例请删掉该断言。 +- 依赖 C01 的用例(C04–C06、C28–C30)**不要重跑公共前置脚本**,否则 `extPersonId/bizNo` + 会变。需要新业务号的用脚本预置的 `bizNoB/bizNoC/bizNoD`。 +- 首次使用先跑一次 C01(或手动执行一次公共前置脚本),初始化 `reqId` 等环境变量; + 独立用例用 `{{$timestamp}}` 生成唯一业务号/人员标识(若当前 Apifox 版本不支持 + `{{$timestamp}}`,改用手填唯一常量)。 +- 入住幂等检查在人员/房间校验之前:同一 `bizNo` 复用时会直接走幂等分支, + 因此“人员停用、照片失败”等用例必须用**未使用过的新 bizNo**。 + +### 1.5 照片 base64 生成 + +```bash +node -e "console.log(require('fs').readFileSync('face.jpg').toString('base64'))" +``` + +把输出粘贴到环境变量 `photoBase64`。照片要求:JPG/PNG、解码后 ≤ 2MB。 +`photoUrl` 可以是 `http(s)://` 地址,也可以是平台内的 `/profile/...` 相对地址; +平台会从**服务器侧**下载或读取,所以本机 localhost 地址只有在平台同机部署时可用。 + +### 1.6 导入 OpenAPI 定义 + +Apifox「项目设置 → 导入数据 → OpenAPI/Swagger」选择 +[apifox/iot-stay-openapi.json](./apifox/iot-stay-openapi.json),可一次性生成 4 个接口、 +请求体字段与响应示例。导入后: + +1. 按 §1.1 建环境,把 `{{baseUrl}}`、`{{pmsKey}}`、`{{visitorKey}}` 等填上; +2. 按 §1.4 建目录,把本文用例补成「接口用例」; +3. 把 §2 的断言脚本粘贴到各用例的后置操作。 + +> OpenAPI 只描述接口契约,不含用例与断言;用例仍以本文为准。 + +--- + +## 2. 断言模板 + +Apifox 的「后置操作 → 自定义脚本」支持 `pm.*` API。把下面对应模板粘贴进去即可。 + +### 2.1 受理成功(checkin / checkout / extend) + +```js +const body = pm.response.json(); +pm.test('HTTP 200', () => pm.expect(pm.response.code).to.eql(200)); +pm.test('code=0', () => pm.expect(body.code).to.eql(0)); +pm.test('msg=accepted', () => pm.expect(body.msg).to.eql('accepted')); +pm.test('data.accepted=true', () => pm.expect(body.data.accepted).to.eql(true)); +pm.test('requestId 回显', () => pm.expect(body.requestId).to.eql(pm.environment.get('reqId'))); +pm.test('业务号非空', () => pm.expect(body.data.bizNo).to.be.a('string').and.not.empty); + +// 仅 C01 追加下面两行:保存后续用例依赖的人员/单号。 +// C02/C03 等独立用例不要回写,否则会覆盖 C01 的人员变量。 +// pm.environment.set('stayNo', body.data.stayNo); +// pm.environment.set('personNo', body.data.personNo); +``` + +### 2.2 查询成功(unlock-records) + +```js +const body = pm.response.json(); +pm.test('HTTP 200', () => pm.expect(pm.response.code).to.eql(200)); +pm.test('code=0', () => pm.expect(body.code).to.eql(0)); +pm.test('msg=success', () => pm.expect(body.msg).to.eql('success')); +pm.test('分页结构', () => { + pm.expect(body.data).to.have.property('total'); + pm.expect(body.data).to.have.property('rows'); + pm.expect(body.data.rows).to.be.an('array'); +}); +``` + +### 2.3 业务失败(可复用到所有失败用例) + +把首行改成该用例的预期值,其余不动: + +```js +const expected = { http: 400, code: 40001, msgLike: 'bizNo 不能为空' }; + +const body = pm.response.json(); +pm.test(`HTTP ${expected.http}`, () => pm.expect(pm.response.code).to.eql(expected.http)); +pm.test(`code=${expected.code}`, () => pm.expect(body.code).to.eql(expected.code)); +pm.test('msg 命中', () => pm.expect(body.msg).to.include(expected.msgLike)); +pm.test('data=null', () => pm.expect(body.data).to.eql(null)); +pm.test('requestId 回显', () => pm.expect(body.requestId).to.eql(pm.environment.get('reqId'))); +``` + +### 2.4 鉴权失败(过滤器直接返回,无 requestId) + +```js +const body = pm.response.json(); +pm.test('HTTP 401', () => pm.expect(pm.response.code).to.eql(401)); +pm.test('code=40100', () => pm.expect(body.code).to.eql(40100)); +pm.test('msg 命中', () => pm.expect(body.msg).to.include('API Key')); +pm.test('过滤器不返回 requestId', () => pm.expect(body).to.not.have.property('requestId')); +``` + +### 2.5 公共前置脚本 + +把脚本挂到 **C01 的前置操作**,每次回归开头跑一次即可;其它依赖 C01 的用例不要再挂, +否则 `bizNo/extPersonId` 会被换掉。需要独立新值的用例(C02/C03/C22–C27)直接使用 +`{{$timestamp}}` 拼在业务号/人员标识里,或手填一个未使用过的常量。 + +```js +const pad = n => String(n).padStart(2, '0'); +const fmt = d => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` + + `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; +const base = Date.now(); + +pm.environment.set('reqId', 'REQ-' + base); +pm.environment.set('bizNo', 'TC' + base); +pm.environment.set('bizNoB', 'TC' + base + 'B'); +pm.environment.set('bizNoC', 'TC' + base + 'C'); +pm.environment.set('bizNoD', 'TC' + base + 'D'); +pm.environment.set('extPersonId', 'EXT' + base); +pm.environment.set('visitorExtId', 'VIS' + base); +pm.environment.set('now', fmt(new Date(base))); +pm.environment.set('plus1d', fmt(new Date(base + 86400000))); +pm.environment.set('plus2d', fmt(new Date(base + 2 * 86400000))); +pm.environment.set('plus7d', fmt(new Date(base + 7 * 86400000))); +pm.environment.set('minus1d', fmt(new Date(base - 86400000))); +pm.environment.set('plus31d', fmt(new Date(base + 31 * 86400000))); +pm.environment.set('plus31d1s', fmt(new Date(base + 31 * 86400000 + 1000))); +``` + +> 需要幂等的用例(重复同一 `bizNo`)不要在新请求里重跑前置脚本,直接把 `bizNo` +> 写成上一请求保存下来的值即可。 + +### 2.6 响应字段口径 + +- 成功响应统一 `{ code, msg, requestId, data }`,`code=0`。 +- 受理类接口 `msg=accepted`,开锁记录查询 `msg=success`。 +- 结果对象上为 `null` 的字段**不会出现**(`@JsonInclude(NON_NULL)`), + 断言不存在用 `pm.expect(body.data).to.not.have.property('previousEndTime')`, + 不要断言其等于 `null`。 +- 过滤器(401/404)返回体没有 `requestId`,只有 `{code,msg,data}`。 + +--- + +## 3. 鉴权用例(A) + +| 编号 | 场景 | 请求 | 预期 | 断言要点 | +|---|---|---|---|---| +| A01 | 不带 API Key | 任意接口,去掉 `X-API-Key` | HTTP 401 / `40100` / `API Key 无效或缺失` | §2.4 模板 | +| A02 | 错误 Key | `X-API-Key: wrong-key` | HTTP 401 / `40100` | §2.4 | +| A03 | 应用被禁用 | 把 VISITOR 的 `enabled` 改为 false 后重启,用 `{{visitorKey}}` 查开锁记录 | HTTP 401 / `40100` | §2.4 | +| A04 | 开放接口关闭 | `iot.openapi.enabled=false` 后重启,任意接口 | HTTP 404 / `404` / `开放接口未启用` | 断言 `pm.response.code===404`、`body.code===404` | +| A05 | 正确 Key 放行 | 正常调用 `GET /unlock-records` | HTTP 200 / `0` | §2.2 | + +### 3.1 连通性测试(P) + +请求路径:`GET {{baseUrl}}/openapi/iot/ping?requestId={{reqId}}`,请求头 `X-API-Key: {{pmsKey}}`。 +响应断言模板直接复用 §2.2(查询成功),额外检查 `data.serverTime` 与 `data.app.appCode`。 + +| 编号 | 场景 | 请求 | 预期 | 断言要点 | +|---|---|---|---|---| +| P01 | PMS Key 联通 | `X-API-Key: {{pmsKey}}`,`requestId={{reqId}}` | 200 / `0` / `success` | `data.serverName`、`data.apiVersion`、`data.serverTime`(`yyyy-MM-dd HH:mm:ss`)、`data.serverTimestamp`(毫秒)、`data.timeZone` 均存在;`data.app.appCode` 为 PMS 应用编码;`requestId` 原样回显 | +| P02 | 访客 Key 联通 | `X-API-Key: {{visitorKey}}` | 200 / `0` | `data.app.appCode` 为 VISITOR 应用编码,与 P01 不同(不串号) | +| P03 | 不带 Key | 去掉 `X-API-Key` | HTTP 401 / `40100` | §2.4 模板 | +| P04 | 停用应用 | 在「开放管理 › 应用接入」停用该应用后立即调 ping | HTTP 401 / `40100` | 停用后 30 秒内生效(管理页改动即时刷新) | + +补充断言(可选):比对 `data.serverTimestamp` 与本机时间差,确认平台时钟偏差在可接受范围; +响应中不应出现 `apiKey`、`callbackUrl`、`callbackSecret` 字段。 + +--- + +## 4. 入住用例(C) + +请求路径:`POST {{baseUrl}}/openapi/iot/stay/checkin`,请求头 `X-API-Key: {{pmsKey}}`。 + +### 4.1 正常与幂等 + +| 编号 | 场景 | 请求要点 | 预期 | 断言要点 | +|---|---|---|---|---| +| C01 | 首次入住,photoUrl | §2.5 生成值;`bizNo={{bizNo}}`、`externalPersonId={{extPersonId}}`、`personName=张三`、`roomNo={{roomNo}}`、`beginTime={{now}}`、`endTime={{plus2d}}`、`photoUrl={{photoUrl}}`、`requestId={{reqId}}` | 200 / `0` / `accepted` | §2.1;`data.callback.enabled=true`、`callback.url` 非空;`stayStatus` 存在且 `authStatus` 通常为 `1` | +| C02 | 首次入住,photoBase64 | 独立新值:`bizNo=TC-C02-{{$timestamp}}`、`externalPersonId=EXT-C02-{{$timestamp}}`;其余同 C01,去掉 `photoUrl`,加 `"photoBase64": "{{photoBase64}}"` | 200 / `0` | §2.1;响应 `deviceSn={{deviceSn}}` | +| C03 | 首次入住,带 `data:` 前缀 | 同 C02,`bizNo=TC-C03-{{$timestamp}}`、`externalPersonId=EXT-C03-{{$timestamp}}`,`photoBase64` 传 `data:image/jpeg;base64,<...>` | 200 / `0` | 前缀带逗号可接受 | +| C04 | bizNo 幂等 | 复制 C01 请求体,`bizNo`/`externalPersonId` 用 C01 实际发送值,**不要重跑 C01 的前置脚本** | 200 / `0` | `stayNo` 与 C01 相同;不新增入住单(看板/列表核对) | +| C05 | 复住人员重新入住 | 用 C01 的 `externalPersonId`,`roomNo={{roomNoOther}}`、`bizNo={{bizNoB}}`,**必须带 `photoUrl` 或 `photoBase64`**(D23:退房后人员已清理,每次入住都要传照片) | 200 / `0` | 不传照片返回 40001(见 C22),且不新建人员 | +| C06 | 同人同房重复入住 | 用 C01 的 `externalPersonId`、`roomNo={{roomNo}}`、`bizNo={{bizNoC}}` | 200 / `0` | `data.duplicatePersonInRoom === true` | +| C07 | 未开通回调的应用入住 | 用 `X-API-Key: {{visitorKey}}`、`externalPersonId={{visitorExtId}}`、正常 photoUrl | 200 / `0` | `data.callback.enabled === false`;不产生回调任务 | +| C08 | 同一 bizNo 跨应用 | 用 `{{visitorKey}}` + `{{visitorExtId}}` 重放 PMS 的 `bizNo`(若未跑 C07 需带 photoUrl) | 200 / `0` | 返回的 `stayNo` 与 PMS 不同(业务号按应用隔离) | + +### 4.2 参数校验 + +| 编号 | 场景 | 请求要点 | 预期 | +|---|---|---|---| +| C09 | 空业务对象 | 请求体 `{"requestId":"{{reqId}}"}` | 400 / `40001` / `bizNo 不能为空` | +| C10 | 缺 externalPersonId | 其余齐全 | 400 / `40001` / `externalPersonId 不能为空` | +| C11 | 缺 personName | 其余齐全 | 400 / `40001` / `personName 不能为空` | +| C12 | 缺 roomNo | 其余齐全 | 400 / `40001` / `roomNo 不能为空` | +| C13 | 缺 beginTime | 其余齐全 | 400 / `40001` / `beginTime 不能为空` | +| C14 | 缺 endTime | 其余齐全 | 400 / `40001` / `endTime 不能为空` | +| C15 | 时间格式错误 | `beginTime=2026/11/01 10:00:00` | 400 / `40001` / `时间格式不正确` | +| C16 | 结束等于开始 | `endTime` 与 `beginTime` 相同 | 400 / `40002` / `endTime 必须晚于 beginTime` | +| C17 | 结束早于开始 | `endTime={{minus1d}}` | 400 / `40002` | +| C18 | 不存在的房间 | `roomNo={{roomNoNotFound}}` | 400 / `40003` / `房间不存在` | +| C19 | 房号重复(可选) | 管理端另建一条同 `room_no` 的房间后调用 | 400 / `40003` / `房间号不唯一` | +| C20 | 维修/停用房间 | `roomNo={{roomNoRepair}}` | 400 / `40004` / `不可办理入住` | +| C21 | 房间未绑门锁 | `roomNo={{roomNoNoLock}}` | 400 / `40004` / `未分配门锁` | +| C22 | 不带照片(首次/复住同规则) | `bizNo=TC-C22-{{$timestamp}}`、`externalPersonId=EXT-C22-{{$timestamp}}`,不传 `photoUrl`/`photoBase64` | 400 / `40001` / `必须提供 photoUrl 或 photoBase64`;不新建人员 | + +### 4.3 照片校验 + +以下用例都要用**新的 `externalPersonId` 和新的 `bizNo`**(例:`TC-Cxx-{{$timestamp}}`、 +`EXT-Cxx-{{$timestamp}}`),否则已有特征会被直接复用、或直接走幂等分支,请求根本走不到校验逻辑。 + +| 编号 | 场景 | 请求要点 | 预期 | +|---|---|---|---| +| C23 | base64 非法 | `photoBase64=not-base64!!` | 400 / `40001` / `不是合法的 base64` | +| C24 | 非 JPG/PNG | 传 GIF 文件头(`R0lGODlh...`)的 base64 | 400 / `40001` / `仅支持 JPG/PNG` | +| C25 | 超过 2MB | 解码后 > 2MB 的 base64 | 400 / `40001` / `不能超过 2MB` | +| C26 | 照片不可访问/提取失败 | `photoUrl` 指向 404 地址,或用模糊到无法提取的照片 | 500 / `50001` / 含提取失败原因 | +| C27 | 门锁未配算法版本 | `roomNo={{roomNoNoAlgo}}`,新人员 + 新照片 | 500 / `50001` / 消息含 `face_algo_version` | + +### 4.4 业务冲突 + +| 编号 | 场景 | 请求要点 | 预期 | +|---|---|---|---| +| C28 | bizNo 重复但换房间 | 用 C01 的 `bizNo`,`roomNo` 换成 `{{roomNoOther}}` | 400 / `40007` / `bizNo 已存在且人员或房间与首次请求不一致` | +| C29 | bizNo 重复但换人员 | 用 C01 的 `bizNo`,`externalPersonId` 换成新值 | 400 / `40007` | +| C30 | 人员已停用 | 先把 `extPersonId` 映射的人员在管理端停用,再用同一标识、`roomNo={{roomNoOther}}`、`bizNo={{bizNoD}}` 入住 | 400 / `40005` / `已停用` | + +> C30 依赖人员有效期/停用数据,属于管理端造数场景;若测试环境不便造数可标注「人工验证」。 +> C19 需要管理端允许创建重复房号,若产品已限制唯一则标注「不适用」。 + +--- + +## 5. 退房用例(K) + +请求路径:`POST {{baseUrl}}/openapi/iot/stay/checkout`。 +`bizNo` 与「`personNo`/`externalPersonId` + `roomNo`」二选一;`bizNo` 优先。 + +| 编号 | 场景 | 请求要点 | 预期 | 断言要点 | +|---|---|---|---|---| +| K01 | 按 bizNo 退房 | `bizNo={{bizNo}}`(C01 生成) | 200 / `0` | `stayStatus="2"`、`accepted=true` | +| K02 | 重复退房 | 重放 K01 | 200 / `0` | 幂等,`stayStatus="2"`,不重复产生退房事件 | +| K03 | 按 personNo+roomNo 退房 | 用 C05 的在住单:`personNo={{personNo}}`、`roomNo={{roomNoOther}}` | 200 / `0` | `stayStatus="2"` | +| K04 | 按 externalPersonId+roomNo 退房 | 用 C06 的在住单:`externalPersonId={{extPersonId}}`、`roomNo={{roomNo}}` | 200 / `0` | 与 K03 等价 | +| K05 | bizNo 不存在 | `bizNo=NOT-EXIST-001` | 400 / `40006` / `入住单不存在或不属于当前应用` | — | +| K06 | 跨应用 bizNo | 用 `{{visitorKey}}` 退 PMS 的 `bizNo` | 400 / `40006` | 数据隔离 | +| K07 | 既无 bizNo 也无人员 | 只传 `roomNo` | 400 / `40001` / `bizNo` 或 `personNo` 必须提供其一 | — | +| K08 | 有人员但从未入住 | 新 `externalPersonId` + `roomNo` | 400 / `40006` / `外部人员标识未在本应用建档` | 已建档但无单时消息为「未找到…入住单」 | +| K09 | 已退房后按人员+房间再退 | 重放 K03 | 200 / `0` | `stayStatus="2"`(已退房幂等返回) | +| K10 | 有人员无房号 | 只传 `personNo` | 400 / `40001` | — | +| K11 | bizNo 优先 | 同时传 `bizNo` 与错误的 `personNo/roomNo` | 200 / `0` | 以 `bizNo` 定位,另外两个参数被忽略 | + +> K01 之后 `authStatus` 可能为 `1`(删除命令已下发)或 `6`(待清除),都属正常; +> 断言只校验 `stayStatus="2"`。清除失败时补偿每 5 分钟重发一次,最终结果另发回调。 +> K03/K04 分别复用 C05/C06 留下的在住单,因此必须先跑 C05、C06,且不要提前跑 K03/K04。 +> K11 会真实结束 `bizNo` 对应的在住单,建议使用一张专用在住单(放在 K01 之前), +> 放在 K01 之后只会看到幂等返回,体现不出「bizNo 优先」。 +> K06 要选一个 **VISITOR 从未用过**的 PMS 业务号(例如 C01 的 `bizNo`), +> 否则 VISITOR 会命中自己的同名单,返回幂等 200。 + +--- + +## 6. 续住用例(E) + +请求路径:`POST {{baseUrl}}/openapi/iot/stay/extend`。 + +| 编号 | 场景 | 请求要点 | 预期 | 断言要点 | +|---|---|---|---|---| +| E01 | 正常续住 | 先建在住单,`bizNo={{bizNo}}`、`endTime={{plus7d}}` | 200 / `0` | `previousEndTime` = 原结束时间、`endTime={{plus7d}}`;保存 `oldEndTime` | +| E02 | 结束时间不变 | 重放 E01 的 `endTime` | 200 / `0` | 幂等,不新增授权/回调;`previousEndTime === endTime` | +| E03 | 早于原结束时间 | 取一个晚于当前时间、但早于当前结束时间的值(如 `{{plus1d}}`,需先跑 E01 把结束时间改到 `{{plus7d}}`) | 400 / `40002` / `必须晚于原结束时间` | — | +| E04 | 早于当前时间 | `endTime={{minus1d}}` | 400 / `40002` / `必须晚于当前时间` | — | +| E05 | 已退房单续住 | 对 K01 的单续住 | 400 / `40006` / `不是在住状态` | — | +| E06 | bizNo 不存在 | `bizNo=NOT-EXIST-002` | 400 / `40006` | — | +| E07 | 跨应用 | `{{visitorKey}}` 续 PMS 单 | 400 / `40006` | — | +| E08 | 缺 endTime | 其余齐全 | 400 / `40001` / `endTime 不能为空` | — | +| E09 | 时间格式错误 | `endTime=2026/11/10` | 400 / `40001` / `时间格式不正确` | — | +| E10 | 按人员+房号续住 | 用 C05 的单:`personNo={{personNo}}`、`roomNo={{roomNoOther}}`、新 `endTime={{plus7d}}` | 200 / `0` | 与 E01 等价;须在 K03 之前跑 | +| E11 | 既无 bizNo 也无人员 | 只传 `endTime` | 400 / `40001` | — | + +> E03/E04 的执行顺序:接口先定位入住单、校验在住状态,再校验时间,所以这两个用例必须 +> 使用**有效在住单**,否则会先返回 40006。 +> E01–E04、E08–E11 都需要**在住**单,请在 C 系列之后、K 系列之前执行;E05 需要**已退房**单, +> 放到 K01 之后跑。E02 重放 E01 时不要重跑前置脚本,`endTime` 用 E01 保存的值。 + +--- + +## 7. 开锁记录查询用例(U) + +请求路径:`GET {{baseUrl}}/openapi/iot/stay/unlock-records`,参数走 Query。 +时间参数中的空格由 Apifox 自动编码,手工拼接时注意 `%20`。 + +### 7.1 正常查询 + +| 编号 | 场景 | 参数 | 预期 | 断言要点 | +|---|---|---|---|---| +| U01 | 房间+人员+时间段 | `roomNo={{roomNo}}&personNo={{personNo}}&beginTime={{minus1d}}&endTime={{plus1d}}` | 200 / `0` / `success` | `total>=1`;`rows[].roomNo={{roomNo}}`;`openTypeName/openResultName` 非空 | +| U02 | 只按房间+时间段 | 去掉 `personNo` | 200 | 可返回该房间所有人的记录 | +| U03 | 按 externalPersonId | 用 `externalPersonId` 代替 `personNo` | 200 | 结果与 U01 一致 | +| U04 | 按开门方式过滤 | 加 `openType=7` | 200 | `rows` 中所有 `openType==="7"`、`openTypeName==="人脸"` | +| U05 | 分页 | 参数同 U01,另加 `pageNum=2&pageSize=1` | 200 | `rows.length<=1`,`total` 与 U01 相同 | +| U06 | 默认分页 | 不传分页参数 | 200 | 默认第 1 页、最多 20 条 | +| U07 | 31 天整 | `beginTime={{now}}&endTime={{plus31d}}` | 200 | 边界值:恰好 31 天应放行 | +| U08 | 无记录房间 | `roomNo={{roomNoEmpty}}` | 200 / total=0 | `rows=[]` | +| U09 | 房间从未绑锁 | `roomNo={{roomNoNoLock}}` | 200 / total=0 | 不报 40004 | +| U10 | 换锁后查历史(可选) | 房间换锁前的时段 | 200 | 结果含旧 `deviceSn`;依赖 `lock_room_device_history` 有数据 | + +### 7.2 参数与权限校验 + +| 编号 | 场景 | 参数 | 预期 | +|---|---|---|---| +| U11 | 跨 31 天 | `beginTime={{now}}&endTime={{plus31d1s}}` | 400 / `40001` / `不能超过 31 天` | +| U12 | 结束不晚于开始 | `endTime` 与 `beginTime` 相同 | 400 / `40002` / `endTime 必须晚于 beginTime` | +| U13 | 缺 roomNo | 只传时间 | 400 / `40001` / `roomNo 不能为空` | +| U14 | 缺 beginTime | 只传 `roomNo/endTime` | 400 / `40001` / `beginTime 不能为空` | +| U15 | 缺 endTime | 只传 `roomNo/beginTime` | 400 / `40001` / `endTime 不能为空` | +| U16 | 时间格式错误 | `beginTime=2026-11-01` | 400 / `40001` / `时间格式不正确` | +| U17 | 不存在的房间 | `roomNo={{roomNoNotFound}}` | 400 / `40003` / `房间不存在` | +| U18 | 非法开门方式 | `openType=1` | 400 / `40001` / `可选值` | +| U19 | pageSize 超限 | `pageSize=101` | 400 / `40001` / `取值范围 1–100` | +| U20 | pageNum 非法 | `pageNum=0` | 400 / `40001` / `pageNum 必须大于 0` | +| U21 | 人员属于其他应用 | 用 `{{visitorKey}}` 查 PMS 的 `personNo` | 400 / `40005` / `人员不属于当前应用` | +| U22 | externalPersonId 未映射 | `externalPersonId=NOT-MAPPED-001` | 400 / `40005` / `未在本应用建档` | +| U23 | personNo 与 externalPersonId 同时传 | `personNo` 属于其他应用,`externalPersonId` 属于本应用 | 400 / `40005` | 以 `personNo` 为准,不做回退 | + +> U01/U03/U04 依赖 `lock_access_log` 中确实有对应记录,若没有请先真实开门或由测试环境维护人造数。 +> U10 需要先在该房间完成一次换锁操作,使 `lock_room_device_history` 产生记录。 + +--- + +## 8. 结果回调用例(B) + +### 8.1 回调契约 + +平台在处理结果到达后向应用配置的 `callback-url` 发起: + +``` +POST {callback-url} +Content-Type: application/json;charset=UTF-8 +X-Callback-Id: +X-Timestamp: <秒级时间戳> +X-Signature: hex(HMAC-SHA256(timestamp + "." + 原始报文, callback-secret)) +``` + +报文(字段为空的不会出现): + +```json +{ + "eventId": "b7f1...", + "eventType": "STAY_CHECKIN_RESULT", + "revision": 1, + "bizNo": "TC1762000000000", + "stayNo": "STAY202611011400001A2B", + "personNo": "AB001234", + "externalPersonId": "EXT1762000000000", + "roomNo": "1203", + "roomCode": "FFFA26102918-1203", + "deviceSn": "80745D91", + "result": "SUCCESS", + "message": "…", + "occurredAt": "2026-11-01 14:02:11" +} +``` + +- `eventType`:`STAY_CHECKIN_RESULT` / `STAY_CHECKOUT_RESULT` / `STAY_EXTEND_RESULT`。 +- 接收成功条件:**HTTP 200 且响应体 `code == 0`**,两个条件缺一不可。 +- 重试:失败后按 1s / 5s / 30s / 2min / 10min 退避,最多补发 5 次,之后置死信。 + 定时任务每 30 秒扫一轮,所以从首次失败到死信约 **13–16 分钟**。 +- 同一授权结果不变时不重复上报;失败后补偿成功会以 `revision+1` 再报一次。 +- `callback-url` 为空:不登记任务;有地址但缺 `callback-secret`:直接落死信。 + +### 8.2 接收端准备 + +**方式一:本地 Node 接收器(推荐,可验签、可记日志)** + +```js +// callback-mock.js → node callback-mock.js +const http = require('http'); +const crypto = require('crypto'); +const fs = require('fs'); + +const PORT = 9099; +const SECRET = process.env.CALLBACK_SECRET || 'pms-callback-secret-0001'; +const MODE = process.env.MOCK_MODE || 'ok'; // ok | bizfail | http500 + +http.createServer((req, res) => { + let raw = ''; + req.on('data', c => { raw += c; }); + req.on('end', () => { + const ts = req.headers['x-timestamp'] || ''; + const sign = req.headers['x-signature'] || ''; + const calc = crypto.createHmac('sha256', SECRET).update(ts + '.' + raw).digest('hex'); + const ok = sign.length === calc.length && + crypto.timingSafeEqual(Buffer.from(sign), Buffer.from(calc)); + + fs.appendFileSync('callbacks.ndjson', JSON.stringify({ + at: new Date().toISOString(), + eventId: req.headers['x-callback-id'], + timestamp: ts, + signatureValid: ok, + body: raw + }) + '\n'); + + if (!ok) { res.writeHead(401); return res.end('bad signature'); } + if (MODE === 'http500') { res.writeHead(500); return res.end('fail'); } + if (MODE === 'bizfail') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + return res.end('{"code":1,"msg":"rejected"}'); + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('{"code":0}'); + }); +}).listen(PORT, '0.0.0.0', () => console.log('callback mock on', PORT, 'mode', MODE)); +``` + +启动: + +```bash +node callback-mock.js +# 失败场景:MOCK_MODE=bizfail node callback-mock.js +# HTTP 500 场景:MOCK_MODE=http500 node callback-mock.js +``` + +把 `callback-url` 配成 `http://<平台可达的IP>:9099/hook/lock` 并重启服务。 +运行记录写入 `callbacks.ndjson`,可直接核对 `signatureValid`、`eventId`、`revision`。 + +**方式二:Apifox Mock** + +在 Apifox 中建一个接口(如 `POST /hook/lock`),用「高级 Mock」设置响应: + +- 成功:状态码 200,Body `{"code":0}` +- 业务失败:状态码 200,Body `{"code":1}` +- HTTP 失败:状态码 500 + +把 Mock 地址填入 `callback-url`。Apifox Mock 不能验签,只用于验证投递与重试, +签名正确性依赖方式一或业务系统侧验签日志。 + +### 8.3 用例 + +| 编号 | 场景 | 操作 | 预期 | +|---|---|---|---| +| B01 | 入住成功回调 | 跑 C01,接收器 `ok` | 通常 30–90 秒内收到 `STAY_CHECKIN_RESULT`;`result=SUCCESS`;`X-Callback-Id` 非空;`signatureValid=true`;任务状态 → `2 已成功` | +| B02 | 退房回调 | 跑 K01 | 收到 `STAY_CHECKOUT_RESULT`;`result=SUCCESS`;`revision=1` | +| B03 | 续住回调 | 跑 E01 | 收到 `STAY_EXTEND_RESULT`;`revision=1`;报文 `bizNo/stayNo/personNo` 与入住一致 | +| B04 | 结果变更追加版本 | 对同一次下发先失败后补偿成功(可断开网关制造失败再恢复) | 收到同一 `eventType` 的 `revision=2` 报文,`result` 变化 | +| B05 | 接收端返回 code≠0 | `MOCK_MODE=bizfail` 后触发入住 | 任务 `retry_count` 递增,按 1/5/30/120/600 秒重试;最终 `3 死信`(`retry_count=5`) | +| B06 | 接收端 HTTP 500 | `MOCK_MODE=http500` 后触发入住 | 同 B05 | +| B07 | 接收端不可达 | 停掉接收器后触发入住 | 同 B05,错误原因含连接失败/超时 | +| B08 | 签名缺失(配置) | 应用配置 `callback-secret` 置空后重启并触发入住 | 任务直接 `3 死信`,原因含 `callback-secret`;补齐密钥后人工重发生效 | +| B09 | 未开通回调 | 用 `{{visitorKey}}` 入住(`callback-url` 为空) | 不产生回调任务;响应 `callback.enabled=false` | +| B10 | 重试不换 eventId | 在 B05 过程中观察 `callbacks.ndjson` | 同一次事件的多次投递 `eventId` 相同、`X-Timestamp`/`X-Signature` 每次不同;接收端可按 `eventId` 去重 | +| B11 | 人工重发 | 接收器恢复 `ok` 后,在管理端「门禁管理 › 回调任务」对死信点重发,或 `POST /lock/callback/{taskId}/resend` | 返回「已重新加入投递队列」,`retry_count` 归零,下一轮投递成功 | +| B12 | 等待回执超时 | 不实际执行(需等 24 小时) | 超时后按 `FAILED` 报文投递,`message` 含「等待设备回执超时」;建议改配置/改时钟在专用环境验证 | + +> B05–B07 是耗时用例(约 15 分钟),建议单独建「回调重试」目录,回归时按需执行。 +> 重试期间可反复查询任务列表确认 `status/retry_count/next_retry_time/last_error` 变化。 + +### 8.4 回调任务核对 + +管理端入口:**门禁管理 › 回调任务**。 +接口:`GET /lock/callback/list`(需管理端 Token 与 `lock:callback:list` 权限)。 + +重点核对: + +| 字段 | 期望 | +|---|---| +| `status` | 正常链路 `0 待结果 → 1 待发送 → 2 已成功` | +| `event_type` | 与操作对应,入住/退房/续住各一条 | +| `biz_no` | 等于接口请求的 `bizNo` | +| `revision` | 同一事件首次为 1,结果变化才递增 | +| `retry_count` | 成功为 0;失败按重试次数递增 | +| `callback_url` | 事件产生时的配置快照,后续改配置不影响历史任务 | +| `payload` | 与接收端实际报文一致 | + +> `0 待结果` 只在设备回执到达前短暂出现,轮询慢时可能直接看到 `1`;这不是异常。 +> 若结果同步失败(如无路由),任务会直接以 `1 待发送` 落库。 + +--- + +## 9. 串联场景(CH) + +### 9.1 CH01 入住 → 查询 → 续住 → 退房 → 三次回调 + +| 步骤 | 用例 | 携带变量 | +|---|---|---| +| 1 | C01 入住 | 生成 `bizNo/extPersonId`;启用 C01 后置脚本的 `stayNo/personNo` 回写 | +| 2 | 等待 B01 回调 | 接收器收到 `STAY_CHECKIN_RESULT` | +| 3 | U01 查询开锁记录 | `personNo` 来自 C01 | +| 4 | E01 续住 | `bizNo` 来自 C01,后置保存 `oldEndTime` | +| 5 | 等待 B03 回调 | `STAY_EXTEND_RESULT` | +| 6 | K01 退房 | `bizNo` 来自 C01 | +| 7 | 等待 B02 回调 | `STAY_CHECKOUT_RESULT` | +| 8 | U01 再查一次 | 时间段覆盖退房前,结果仍可查到 | + +通过标准:三次回调的 `bizNo/stayNo/personNo` 完全一致,`eventId` 互不相同, +任务列表三条任务均为 `2 已成功`。 + +### 9.2 CH02 跨应用隔离 + +| 步骤 | 操作 | 预期 | +|---|---|---| +| 1 | PMS 用 `bizNo=X` 入住 | 200,记下 `stayNo`/`personNo` | +| 2 | VISITOR 用同一个 `bizNo=X` 入住(不同人员标识,带 photoUrl) | 200,`stayNo` 与 PMS 不同 | +| 3 | VISITOR 用 `bizNo=X` 退房 | 200,只结束 VISITOR 自己的单 | +| 4 | VISITOR 用 PMS 的 `personNo` 查开锁记录 | 400 / `40005` | +| 5 | VISITOR 用**仅属于 PMS 的另一个** `bizNo=Y` 退房 | 400 / `40006` | + +> 步骤 5 的 Y 需要由 PMS 单独建一张在住单(可用新业务号再调一次入住),且 VISITOR 从未用过该号。 +> 若 VISITOR 也用过同一个 Y,会命中它自己的单,返回的是幂等 200 而不是 40006。 + +--- + +## 10. 执行清单 + +- [ ] 升级脚本已执行(D19 → D20 P1 → D20 P2),表结构核对通过 +- [ ] 两个应用 Key 已配置且互不重复,服务已重启 +- [ ] 测试房间/门锁/算法版本/人脸提取服务/Redis 就绪 +- [ ] 回调接收器已启动,`callback-url` 可达且已重启生效 +- [ ] A01–A05 鉴权全部通过 +- [ ] P01–P04 连通性通过(时钟偏差、应用识别、停用拒绝) +- [ ] C01–C30 入住通过(照片类用例每次换新人员) +- [ ] E01–E11 续住通过(在退房之前执行) +- [ ] K01–K11 退房通过 +- [ ] U01–U23 查询通过(含跨应用隔离与 31 天边界) +- [ ] B01–B12 回调通过(B05–B07 可单列回归) +- [ ] CH01、CH02 串联通过 +- [ ] 回调任务列表状态与报文核对无误 +- [ ] 平台日志无未预期异常,开放接口响应时间在可接受范围 + +## 11. 未覆盖项(P4 范围) + +以下能力尚未实现,本用例集不覆盖,避免把「未实现」误判为缺陷: + +- IP 白名单 / 限流(`40300`、`42900` 仅有契约定义) +- `photoUrl` 域名白名单 / SSRF 防护 +- 接口级 HMAC 签名(当前仅回调出站签名) +- 回调可观测(投递成功率、积压告警) diff --git a/doc/项目管理看板.md b/doc/项目管理看板.md new file mode 100644 index 0000000..6d67f31 --- /dev/null +++ b/doc/项目管理看板.md @@ -0,0 +1,1024 @@ +# 智能门锁 v1 迭代 · 项目管理看板 + +> 更新:2026-11-01 | 当前迭代:D20 对外房间门禁接口 P1–P3(代码就绪)+ Apifox 用例包(文档就绪) | 基线:v1.3(12 张 `lock_*` 表)+ D19/D20 升级表 +> 相关文档:[门锁系统架构设计](门锁系统架构设计.md) · [数据库迁移指引](数据库迁移指引.md) · [厂家设备控制协议整理与设计说明](厂家设备控制协议整理与设计说明.md) · [对外开放接口 Apifox 测试用例](对外开放接口Apifox测试用例.md) + +状态口径:✅ 已落地(代码可编译、有验证) | 🟡 部分落地 | ⏸ 暂缓 | ⬜ 未开始 +“已落地”指本仓库代码与脚本就绪,**不代表已在生产库执行或完成实机联调**。 + +--- + +## 1. 项目概况 + +| 维度 | 数量 | 说明 | +|---|---|---| +| 改进项总数 | 17 | D1–D17,来源《门锁系统架构设计》§12.1 差距清单 | +| ✅ 已落地 | 13 | D1 D3 D5 D6 D7 D8 D9 D11 D12 D13 D14 D16 D17 | +| 🟡 部分落地 | 2 | D2(前缀配置化)、D4(仅记录解析分支) | +| ⏸ 暂缓 | 1 | D10(凭证明文存储,业务决定) | +| ⬜ 未开始 | 1 | D15(离线判定) | +| 完全落地率 | 13/17(76%) | 按“✅ 已落地”计;部分落地不计入 | + +里程碑: + +| 里程碑 | 计划目标 | 实际进展 | 状态 | +|---|---|---|---| +| v1.0 基线 | 9 张 `lock_*` 表 | 已交付 | ✅ | +| v1.1 人员主数据(D8) | `lock_person` 独立人员域 | 代码与存量脚本就绪 | ✅ | +| v1.1 字典归一(D13) | 开门方式单一口径 | 代码与存量脚本就绪 | ✅ | +| v1.2 一锁多网关(D17) | 11 张表,归属表 + R1–R4 选路 | 代码/脚本/文档完成,**待上线联调** | 🟡 | +| v1.3 多算法版本(D9) | 12 张表,`lock_face_feature` 按 `algo_version` 一人多份特征 | 代码/脚本/文档完成,**待上线联调** | 🟡 | +| v1.4 网关门锁管理(D18) | 网关页门锁列表/分配/清除,列表由 `lockrssi` 上报驱动 | 代码/文档完成,**待上线联调** | 🟡 | +| v1.5 住宿管理(D19,S1–S4) | `lock_room` / `lock_stay`,入住/退房/换房/续住/到期任务翻译为 `lock_auth` 操作 | 代码/脚本/前端就绪,**脚本未执行、待联调** | 🟡 | +| v1.6 对外房间门禁接口(D20,P1) | 多应用 Key→appCode 隔离、入住/退房/续住对外接口、外部人员映射、房间门锁历史 | 代码/脚本就绪,**脚本未执行、未实机联调**;P4 加固未做 | 🟡 | +| v1.7 对外接口结果回调(D20,P2) | `lock_callback_task` 事件落库、按应用 HMAC 签名投递、退避重试与死信、人工重发、回调任务页 | 代码/脚本/前端就绪,**脚本未执行、未与对方系统联调**;依赖 P1 先上线 | 🟡 | +| v1.8 开锁记录查询(D20,P3) | `GET /openapi/iot/stay/unlock-records`,按房间覆盖换锁前历史门锁,人员按应用隔离 | 代码就绪,**无新增脚本、未联调** | 🟡 | +| v2.0 目标架构 | 13 张表,凭证/特征/授权拆分 | 未启动,A5 凭证迁移被 D10 阻塞 | ⬜ | + +--- + +## 2. 改进开发计划(计划 vs 实际) + +计划来源:架构设计 §12.1 的 W1 → W2 → W3 推进顺序;实际按“架构治理优先”执行,W3 的 D17 先于 P2 的 D15 完成,W2 的 D9 在 D17 之后补做。 + +| 阶段 | 覆盖改进项 | 计划目标 | 实际进展 | 完成度 | +|---|---|---|---|---| +| W1 协议闭环 | D1 D2 D4 D5 D6 D7 D12 D15 D16 | 上行主题全覆盖、结果码/记录解析正确、`seq` 分布式、重试与超时匹配、报文脱敏 | D1/D5/D6/D7/D12/D16 已落地;D2 仅前缀配置化;D4 仅记录解析分支;D15 未开始 | 6/9 | +| W2 授权 / 人脸 / 安全 | D9 D10 D11 | 特征多算法版本、特征服务韧性、凭证加密 | D9/D11 已落地;D10 暂缓 | 2/3 | +| W3 架构与数据治理 | D3 D8 D13 D14 D17 | 路由与身份拆分、人员主数据、字典归一、任务分布式锁、一锁多网关 | 5 项全部落地;D17 代码完成待上线 | 5/5 | +| v2.0 迁移 | 13 表目标架构 | 凭证/特征/授权拆表、命令状态重排 | 未启动,依赖 D10 | 0/1 | + +计划偏差与原因: + +- **D17 先于 D15 完成**:D17 阻塞跨分区下发与归属准确性,优先级实际高于 P2 的 D15。 +- **D10 暂缓**:业务方 2026-10-31 决定 v1 明文存储,直接阻塞 v2 的 A5(凭证迁移)与 A6(授权迁移)。 +- **D9 规则 4 推迟到 v2**:算法版本选特征已在 v1 闭环(规则 1–3);“统一到 `lock_biometric`、以版本拆行”属 v2 表结构改造(规则 4),随 A6 一起做。 +- **D2/D4 属“半程”**:可运行但未收口,换厂家/改前缀或下行 `devVer` 仍会踩坑。 + +--- + +## 3. 改进项台账 + +| # | 阶段 | 事项 | 优先级 | 状态 | 实际进展 / 证据 | +|---|---|---|---|---|---| +| D1 | W1 | 上行主题覆盖不全 | P0 | ✅ | 8 主题全覆盖,启动/重连恢复订阅(`lock/mqtt`) | +| D2 | W1 | 主题前缀硬编码 | P1 | 🟡 | 前缀/固定段已配置化(`application*.yml`),未做多厂家抽象 | +| D3 | W3 | 路由标识与设备身份混用 | P0 | ✅ | `RouteKey`/`DeviceRef` 拆分 + 实时选路(`lock/route`) | +| D4 | W1 | 无 `protocolVer` 分支 | P0 | 🟡 | 记录解析已分支;下行 `devVer` 仍写死(`AccessRecordHandler`) | +| D5 | W1 | 结果码靠位置猜测 | P1 | ✅ | 按 op 模板取结果码(`lock/command`) | +| D6 | W1 | `seq` 多实例撞号 | P0 | ✅ | Redis `INCR` 分布式取号(`lock/command`) | +| D7 | W1 | 重试退避与超时不匹配 | P1 | ✅ | 秒级退避 + 按类型超时(`lock/command`) | +| D8 | W3 | 人员主数据缺失 | P1 | ✅ | `lock_person` + `com.ruoyi.iot.person`(`upgrade_d8_lock_person.sql`) | +| D9 | W2 | 特征无算法版本 | P0 | ✅ | `lock_face_feature` 按 `algo_version` 多版本存储;下发按门锁版本选特征,缺版本阻断,详见第 4 节 | +| D10 | W2 | 凭证明文存储 | P0 | ⏸ | 2026-10-31 业务决定 v1 不加密;脱敏/掩码/鉴权保留 | +| D11 | W2 | 特征服务无重试/熔断 | P1 | ✅ | 提取重试、熔断、失败原因落库(`lock/face`) | +| D12 | W1 | V1/V2 记录未分支 | P0 | ✅ | 按协议版本解析(`AccessRecordHandler`) | +| D13 | W3 | 开门方式两套字典 | P1 | ✅ | `LockOpenType` 统一设备码 + 远程 9(`upgrade_d13_open_type.sql`) | +| D14 | W3 | 定时任务无分布式锁 | P1 | ✅ | Redis `SET NX PX` + Lua 释放(`DistributedTaskLock`) | +| D15 | W1 | 离线判定不可靠 | P2 | ⬜ | 仍忽略 `onoff_line` 报文体、LWT 未启用(`lock/mqtt`) | +| D16 | W1 | 报文日志未掩码 | P0 | ✅ | 上下行留痕与通行记录脱敏(`lock/mqtt`) | +| D17 | W3 | 无法表达一锁多网关 | P1 | ✅ | `lock_device_gateway` + R1–R4 实时选路,详见第 5 节 | + +--- + +## 4. 迭代交付记录(按交付顺序编号,最新见 4.35) + +### 4.1 交付内容 + +- [x] 特征版本表 `lock_face_feature`:`unique(face_id, algo_version)`,一人一版本一行,记录特征体、MD5、大小、提取状态/时间/原因 +- [x] 存量升级脚本 `upgrade_d9_face_feature.sql`:建表 + 将 `lock_face` 现有特征幂等回填为一行 +- [x] 提取按版本执行:单张提取以门锁 `face_algo_version` 为准,取不到版本直接失败并落原因(不再默认版本) +- [x] 批量提取覆盖人员涉及的全部算法版本(按门锁当前版本 + 该人员已有授权对应版本求并集),全部成功才计成功 +- [x] 成功后回写 `lock_face.feature_*` 镜像,并自动重推该人员该版本下 `sync_status in ('0','3')` 的授权 +- [x] 下发选特征:子表 `(person_no, algo_version)` → 旧字段兜底(仅当旧字段版本与门锁版本一致)→ 阻断并写 `sync_msg` +- [x] 门锁算法版本变更联动:`updateDevice` 检测到真实变化时,把该锁 `auth_type='01'` 的授权置 `sync_status='3'` 待重推 +- [x] 信号/电量上报改用部分实体更新,避免心跳报文误触发版本比对与全量覆盖 +- [x] 开放接口 `/open/auth` 人脸授权同样按门锁版本取特征,缺失时提示具体版本 +- [x] 新增 `GET /lock/face/{faceId}/features` 查询一人多版本特征 +- [x] 架构文档 §6.3/§7.3/§9/§12、迁移指引 A/B/C 路径、FAQ 同步更新 + +### 4.2 关键文件 + +新增: + +| 文件 | 说明 | +|---|---| +| `sql/upgrade_d9_face_feature.sql` | 建表 + 存量回填 + 核对查询 + 回滚说明 | +| `lock/domain/LockFaceFeature.java` | 特征版本实体(`toString` 不含特征体) | +| `lock/mapper/LockFaceFeatureMapper.java` + `resources/mapper/lock/LockFaceFeatureMapper.xml` | 按人员+版本取可用特征、按人脸取版本列表、`upsert`、随人脸删除 | + +主要修改:`LockFaceServiceImpl`、`LockFaceController`、`LockAuthServiceImpl`、`LockAuthMapper(.xml)`、`LockDeviceServiceImpl`、`LockSignalHandler`、`OpenAuthApiController`、`LockFaceMapper(.xml)`、`LockConstants`、`lock.sql`。 + +### 4.3 验证结果(2026-10-31) + +| 项 | 结果 | +|---|---| +| 全工程编译(8 模块) | ✅ `BUILD SUCCESS` | +| Mapper 接口 ↔ XML 语句对齐(`LockFaceFeature`/`LockAuth`/`LockFace`) | ✅ 无缺失、XML 解析通过 | +| 关键分支走查(选版优先级、缺版本阻断、失败不覆盖旧特征、版本变更只在值变化时触发) | ✅ 代码走查通过 | +| 数据库脚本执行 | ⬜ **未执行**(未连接任何库) | +| MQTT 实机联调(多版本锁下发) | ⬜ **未进行** | + +### 4.4 上线步骤 + +- [ ] 代码评审(重点:选版优先级、缺版本阻断、版本变更联动范围) +- [ ] 备份 `lock_face` 与 `lock_device` +- [ ] 测试库执行 `upgrade_d9_face_feature.sql`,按迁移指引 3.6 核对(行数 = 有特征的人脸数、`(face_id, algo_version)` 无重复) +- [ ] 与 D9 代码**同批次**发布(无子表时按旧字段兜底,但仍需先建表避免回填遗漏) +- [ ] 发布后对存在多算法门锁的人员执行 `batchExtract`,补齐目标版本特征 +- [ ] 按迁移指引 3.7 完成 12 项冒烟(含多版本选特征、缺版本阻断、换版本重提) +- [ ] 生产灰度后再全量 + +### 4.5 遗留与后续 + +- [ ] **前端页面**:前端工程为 `base/RuoYi-Vue2-master`;人脸档案的“多版本特征列表/按版本重提”需在该工程对接 `GET /lock/face/{faceId}/features` +- [ ] **自动重提任务**:版本变更后只做“标记待重推”,特征缺失仍需人工或接口触发 `batchExtract`,未做定时自动补提 +- [ ] **多版本覆盖率监控**:多算法门锁数量、缺版本阻断次数、重推成功率尚未埋点 +- [ ] **v2 规则 4**:`lock_face`/`lock_face_feature` 合并为 `lock_biometric`(`unique(person_id, bio_type, algo_version)`),随 A6 授权迁移一起做 + +### 4.6 联调问题修复:并发下发人脸被回 gw busy(2026-10-31) + +**现象**:一次下发 2 条人脸授权,第 2 条被网关回 `gw busy` 判失败,要等挺久才随补偿重发成功。 + +**原因**(4 条叠加,越往后越慢): + +1. 授权下发没有串行控制,两条 `data/batch` 同时发往同一分组主题,网关同一时刻只处理一条; +2. `pushInternal` 先 `send()` 再写 `sync_status='1'`,快速到达的 busy 回执把授权置 `3` 后又被覆盖回 `1`,而授权补偿只捞 `0/3`,这笔授权从此没人管,只能等命令层慢路径; +3. busy 与普通失败共用 `5/15/45` 秒退避,补偿任务 15 秒一轮,实际恢复动辄几十秒; +4. 命令终结为 `TIMEOUT` 从不回写 `lock_auth`,无回执时授权永远停在「已下发」。 + +**修复**: + +- 下发前查同分组在途授权命令(`LockCommandMapper.countInflightAuthCommands`),有则排队:授权置 `sync_status='0'`,`sync_msg` 写「同网关有在途授权命令,已排队等待」,由补偿任务接着发; +- `sync_status='1'` 改为发布前写入,杜绝回执被覆盖; +- 结果码 `11` 统一识别为 busy(`LockCommandPolicy.isBusy`):命令 3 秒后重排,并跳过同网关仍有在途命令的重试;授权状态保持「已下发」不变,避免补偿再造一条命令重复下发; +- 命令重试用尽或超时终结时回写授权 `sync_status='3'`(仅授权下发,不含清除,否则会把授权重新加上),补偿任务 15 秒 → 5 秒一轮。 + +**改后时序**:2 条人脸 → 第 1 条下发 → 收到回执后第 2 条在 ≤5 秒内自动接着发,不再出现 gw busy。 + +**第二轮(同日实机复测)**:第二条被正确排队(`sync_status=0`),但补偿任务每轮 `selectPendingAuths` 都是 0 行,授权永远排不上队。 +原因是 `lock_auth.status` 实际存的是 `NULL`:授权表单没有状态项,`insertAuth` 显式列出 `status` 列写入 null,列默认值 `'0'` 不生效, +而补偿查询条件是 `status='0'`。已修:插入缺省补 `'0'`(`LockAuthServiceImpl.insertAuth`),查询把 `NULL` 视为正常, +存量数据用 `sql/upgrade_hotfix_auth_status.sql` 修复(`update lock_auth set status='0' where status is null`)。 + +主要修改:`LockAuthServiceImpl`、`LockCommandServiceImpl`、`LockCommandPolicy`、`LockCommandMapper(.java/.xml)`、`LockAuthMapper.xml`、`LockConstants`、`CommandRespHandler`、`LockCommandRetryTask`。 + +| 项 | 结果 | +|---|---| +| `ruoyi-iot` 编译 | ✅ `BUILD SUCCESS` | +| 代码走查(串行闸门、SENT 写入顺序、busy 重排、超时回写、补偿周期) | ✅ 通过 | +| MQTT 实机回归(连续下发多条人脸、断网补偿、清除与下发交错) | ⬜ **未进行** | + +### 4.7 新功能:网关配置页门锁管理(2026-10-31,待联调) + +**需求**:网关配置页增加该网关下的门锁管理——门锁列表、分配、清除;列表可从网关上报的状态消息获得;清空该网关全部门锁走 `clear` 指令。 + +**实现**: + +- 列表来源:`lockrssi`(`data/batch_report`)按 `ReportGatewayResolver` 识别上报网关后,`upsertReportedRelation` 自动登记 `(门锁, 网关)` 归属(新行 `bind_status=1`、`bind_msg="网关上报自动登记"`);陌生锁SN 补建占位门锁(名称暂用SN,备注记录来源网关)。 +- 查询接口:`GET /lock/gateway/{gatewayId}/devices`(归属视角,含信号/电量/在线/`bind_status`/最近上报)、`GET /lock/gateway/{gatewayId}/candidates`(未归属该网关的正常门锁)。 +- 页面:网关列表新增「门锁」入口,弹窗内可筛选、分配(多选候选 → `bind`)、移除(`unbind`)、清除全部(`clear`);按钮受 `lock:gateway:query` / `lock:gateway:bind` 控制。 +- 下行报文改为厂家 `op=locklist`(原 `LockGatewayServiceImpl` 用的是 `op=config` + `params[0]=bind/unbind/clear`,与协议和项目命令矩阵不符):`add` 为 `[add, 分组编号, 数量, 锁ID1, 锁ID2, ...]`(锁ID 逐项独立、数量为数字),`clear` 为 `[clear]`;分组编号取网关分组,缺失回退 `"0"`。 +- 锁ID 格式:报文要求 12 位,等于 `CCEE` + 8 位门锁SN(库内 `device_sn` 保持 8 位);下发时统一补前缀,已为 12 位的原样使用。上报 SN 若带 `CCEE` 前缀,自动登记时先去前缀匹配已有门锁,避免重复建占位。 +- 实测确认 `add` 是**整表覆盖**(会清掉网关现有锁列表,分配前必须按归属表重算全量),且网关在 `add` 回执 `params:["add","0"]` 之后**重启**才生效: + - 分配只发一条 `add`,不再先发 `clear`(多一条命令只会在重启窗口被丢弃); + - 移除时剩余非空重下 `add` 全量,剩余为空才发 `clear`;清除全部只发 `clear`; + - `add` 回执到达即视为已落地,重启窗口内的后续下发失败由命令重试兜底,不重复下发。 +- `clear` 回执实测为 `{"op":"locklist","params":["clear","0"]}`,结果码仍在 [1],按 `locklist` 统一解析;末项结果码不再被当成人读失败原因。 +- 回执主题差异:`clear` 回执 topic 第 2 段是网关 SN,`add` 回执实测是分组编号(`ydlink/FFFA26092912/thing/property/set_response`);报文留痕按信封 `devId` 归 `gateway_sn`、topic 第 2 段归 `group_code`。 +- 页面提示:门锁弹窗增加说明条,分配前确认「以本次清单覆盖网关现有锁列表、网关重启后生效」。 +- 清除/移除后网关回执前仍可能上报该锁并再次登记,回执后稳定;这是"列表跟随网关实际持有"的预期行为。 +- 遗留:`LockDeviceServiceImpl.bindGateway/unbindGateway` 仍用旧 `config` 帧,当前无页面调用,后续统一或废弃。 + +| 项 | 结果 | +|---|---| +| 全工程编译(8 模块) | ✅ `BUILD SUCCESS` | +| 前端 SFC/JS 语法解析 | ✅ 模板无错误、脚本 parse ok | +| 前端实机(分配/移除/清除、字典显示) | ⬜ **未进行** | +| MQTT 实机(陌生SN自动登记、clear 后不再重现) | ⬜ **未进行** | +| MQTT 实机(add 整表覆盖、回执后网关重启、锁ID CCEE 前缀) | ⬜ **未进行** | + +### 4.8 缺陷修复:授权新增撞唯一键 `uk_lock_auth_person_lock`(2026-10-31) + +**现象**:新增授权报 `Duplicate entry 'AA000001-1-01' for key 'lock_auth.uk_lock_auth_person_lock'`(`person_no-device_id-auth_type`)。 + +**根因**:唯一键 `(person_no, device_id, auth_type)` **不含 `del_flag`**,`deleteAuthByIds` 又是逻辑删除: + +- 同人同锁同类型重复提交 → 直接撞键; +- 删除后再新增同类型授权 → 已删除行仍占用键值,同样撞键; +- `insertAuth` 未做任何预检,SQL 异常直接抛到页面。 + +**修复**(无需改表): + +- 新增 `selectAuthByUniqueKey`(**不过滤 `del_flag`**)与 `reviveAuth`(复用原行、保留 `auth_id/auth_no`、重置 `sync_status='0'`/`retry_count=0`/清空 `sync_msg`); +- `insertAuth` 改为幂等语义:命中已有行则覆盖凭据并重新下发,未命中才插入;并发撞键时捕获 `DuplicateKeyException` 回查后同样按覆盖处理; +- `updateAuth` 增加同键预检,改成与他人相同的 (人员, 门锁, 类型) 时返回可读提示而非 SQL 异常。 + +**影响**:同人同锁同类型只保留一条,重复新增等同"换凭据";OpenAPI 重复推送也变为幂等。 + +### 4.9 智能门锁页:电量与信号强度排查(2026-10-31) + +**需求**:门锁页展示电池电量、信号强度,支持按两者范围筛选与排序,便于排查信号差、电量低。 + +**实现**: + +- 列表列新增「电量」「信号强度」「最近上报」:电量 ≤20% 标红,信号 ≤-85 dBm 标红、≤-70 dBm 标橙。 +- 查询条件新增 `batteryMin/batteryMax`、`signalMin/signalMax`;`signal_strength` 以字符串存储(含负号),范围比较用 `cast(nullif(signal_strength,'') as signed)`,空串/未上报不参与范围命中。 +- 排序:表头对电量、信号开启 `sortable="custom"`,参数 `sortField=battery|signal` + `sortOrder=asc|desc`,**SQL 用白名单 `` 固定排序表达式**,不拼接前端列名;未登记值回退 `device_id desc`。 +- 导出复用同一查询,筛选与排序条件一并生效。 + +| 项 | 结果 | +|---|---| +| 全工程编译(8 模块) | ✅ `BUILD SUCCESS` | +| 前端 SFC/JS 语法解析 | ✅ 模板无错误、脚本 parse ok | +| 前端实机(范围筛选、排序、颜色、分页保持排序) | ⬜ **未进行** | + +### 4.10 人脸授权清除入口迁移到门锁页(2026-10-31) + +**需求**:门锁上的人脸授权整锁清除改在智能门锁页操作(支持多选门锁),授权页不再提供批量清除入口;整锁清空后平台侧该锁的人脸授权直接置「已清除」,不再自动下发。 + +**实现**: + +- 后端新增 `POST /lock/device/batch/clear-face-auth`(入参 `deviceIds`,权限沿用 `lock:auth:clear`): + - 每把锁下发**一条**整锁清空命令:`["delete","00000000", 门锁SN, 门锁密码]`(人员编号固定 `00000000` 表示清空该锁全部人脸,实测确认),不按人逐条 delete; + - 下发前先把该锁本地所有人脸授权 `sync_status` 置为「已清除」(4),补偿与特征补发只捞 0/3,这些授权**不会再被自动重新推送人脸**; + - 命令不绑定单条授权(`bizId` 为空),回执只落命令表,不会把本地状态改回中间态;单条锁下发失败不影响其它锁,本地仍保持已清除; + - 与本地是否有授权记录无关:选中门锁即下发,设备上是否存在人脸都能清空。 +- 门锁页工具栏新增「清空人脸授权」(多选门锁启用),确认文案说明本地会置为已清除且不再自动下发。 +- 授权页移除批量「清除」按钮及 `handleBatchClear`;行内单条「删除人脸」保留。 + +| 项 | 结果 | +|---|---| +| 全工程编译(8 模块) | ✅ `BUILD SUCCESS` | +| 前端 SFC/JS 语法解析 | ✅ 两个页面模板与脚本均通过 | +| 实机(多锁批量清空、`delete 00000000` 生效、本地状态保持已清除、补偿不复活) | ⬜ **未进行** | + +> 注意:整锁清空是「平台以已清除为准」的不可逆动作 —— 即使设备侧删除失败,本地也不会再自动补发;需要恢复时在授权页手工重下。 + +### 4.11 住宿管理模块设计稿(2026-10-31,未实现) + +新增房间与入住单两个业务对象,入住/退房/换房翻译为对现有 `lock_auth` 的操作(复用有效期、幂等新增、单人/整锁删除),不新建"住宿授权表"。 + +- 文档:[住宿管理模块设计.md](./住宿管理模块设计.md) +- 规划表:`lock_room`、`lock_stay`;字典 `lock_room_status` / `lock_guest_type` / `lock_stay_status`;菜单 2120/2130 +- 迁移脚本 `sql/upgrade_d19_stay.sql` 仅规划,**未创建、未执行** +- 业务已确认:一房一锁;同一人可入住多个房间;外部人员在入住页一步完成建档+拍照提取;退房立即删人脸 +- 仍待确认:房态看板、是否同时下发密码/房卡、旧房删脸失败强提示、补录、提取失败能否先占房(设计文档 §13.2) + +### 4.12 住宿管理 S1:房间管理(2026-10-31,代码就绪) + +**范围**:房间建档 + 门锁分配,不含入住/退房(S2 起)。 + +- 脚本 `sql/upgrade_d19_stay.sql`(幂等,**未执行**):建 `lock_room`/`lock_stay`、字典 `lock_room_status`/`lock_guest_type`/`lock_stay_status`、房间菜单 2120+按钮;按 `lock_device.room_no` 回填房间并绑锁(同编码保留最早一把,其余人工处理)。 +- 后端:`LockRoom`、`LockRoomMapper(+XML)`、`ILockRoomService/LockRoomServiceImpl`、`LockRoomController`;接口含列表/详情/可入住房间/候选门锁/增删改/分配解绑/导出。 +- 前端:`api/iot/room.js`、`views/iot/room/index.vue`(筛选、CRUD、分配/解绑门锁、导出)。 +- 规则:一房一锁;绑定/改名/解绑/删房同步 `lock_device.room_no`;在住房间禁止改房态/换锁/删除;候选门锁排除已占用。 + +| 项 | 结果 | +|---|---| +| 全工程编译(8 模块) | ✅ `BUILD SUCCESS` | +| 前端 SFC/JS 语法解析 | ✅ 房间页与 API 通过 | +| 脚本执行 | ⬜ **未执行**(按约定只交付幂等脚本) | +| 前端实机(增删改、分配/解绑、回填核对) | ⬜ **未进行** | + +### 4.13 住宿管理 S2:入住 / 退房 / 授权联动(2026-10-31,代码就绪) + +**范围**:入住登记(含外部人员一步建档+拍照提取)、退房、授权状态联动;换房/续住留到 S3。 + +- 后端:`LockStay`、`LockStayCheckinBody`、`LockStayMapper(+XML)`、`ILockStayService/LockStayServiceImpl`、`LockStayController`;接口含列表/详情/某人在住/房间历史/入住/退房/导出。 +- 前端:`api/iot/stay.js`、`views/iot/stay/index.vue`(入住弹窗支持"已有人员/新建外部人员"两种来源、照片上传、可入住房间下拉、退房、详情)。 +- 入住流程:人员与特征提取在事务外(`insertPerson` → `insertFace` → `extract`),住宿事务内房间行锁 → 复核房态 → 建人脸授权(有效期=入住起止)→ 建入住单 → 房态置在住。 +- 退房:结束入住单 → 释放房态 → 单人 `facefeature delete` 删本房门锁人脸(失败不影响退房)。 +- 授权联动:回执按 `authId` 回写入住单 `auth_status/auth_msg`。 +- 安全联动:**不限制房间入住人数,同一房间可为多人重复办理入住**;有在住人员的门锁禁止整锁清空人脸;房间有在住人员时禁止换锁/删房/改房态。 +- 脚本:`upgrade_d19_stay.sql` 追加住宿菜单 2130 与按钮。 + +| 项 | 结果 | +|---|---| +| 全工程编译(8 模块) | ✅ `BUILD SUCCESS` | +| 前端 SFC/JS 语法解析 | ✅ 住宿页、房间页、API 均通过 | +| 脚本执行 | ⬜ **未执行** | +| 实机(入住下发/退房删脸/外部人员提取/授权回写) | ⬜ **未进行** | + +### 4.14 住宿管理 S3:换房 / 续住(2026-10-31,代码就绪) + +- 换房接口 `POST /lock/stay/{stayId}/change-room`(`{roomId,endTime}`):预检目标房间空闲、已绑锁、该人员在目标门锁版本下有可用人脸特征;事务内锁新房 → 建新授权 → 建新单(`change_from_id`)→ 占新房 → 旧单置已换房 → 释放旧房 → 删旧房人脸。 +- 续住接口 `POST /lock/stay/{stayId}/extend`(`{endTime}`):更新入住单结束时间并 `updateAuth` 重新下发授权有效期;新时间必须晚于当前预计退房。 +- 住宿页操作列新增「换房」「续住」(仅住态);换房弹窗提供目标房间与预计退房,续住弹窗校验时间递增并展示原结束时间。 +- 一人多房:换房/续住只影响本房间,其它房间授权不受影响。 + +| 项 | 结果 | +|---|---| +| 全工程编译(8 模块) | ✅ `BUILD SUCCESS` | +| 前端 SFC/JS 语法解析 | ✅ 住宿页与 API 通过 | +| 实机(换房双锁授权/旧房删脸、续住有效期刷新) | ⬜ **未进行** | + +> **2026-10-31 修复:住宿页白屏**。控制台报 `TypeError: Cannot read properties of undefined (reading 'personName')`。 +> 原因是入住弹窗的 `form` 初始为 `{}`,而 `el-dialog` 隐藏时仍会渲染内容:首次渲染 `form.personMode` 为 `undefined`, +> 走 `v-else`(新建外部人员)分支后访问 `form.newPerson.personName`,`form.newPerson` 不存在直接抛错,导致整个 `` render 失败、页面空白。 +> 已把 `form` / `changeForm` / `extendForm` 初始化为完整结构。**前端需重新构建或重启 dev server 才生效。** + +### 4.15 住宿管理 S4:到期任务 / 房态统计 / 异常追踪(2026-10-31,代码就绪) + +- 新增定时任务 `LockStayExpireTask`:每 10 分钟(分布式锁 `stay-expire`,单批 200 条)把已过预计退房时间的在住单置为已退房,释放房态,并把授权置「已清除」以停止补偿;不向门锁下发命令(设备按有效期自动失效)。 +- 到期释放房态用条件更新 `current_stay_id = stayId`,避免误清后来入住的房间。 +- 房间页顶部新增房态统计卡片(空闲/在住/维修/停用),接口 `GET /lock/room/statistics`。 +- 住宿页新增「授权状态」筛选,可快速定位入住但人脸下发失败(auth_status=3)的单子。 + +| 项 | 结果 | +|---|---| +| 全工程编译(8 模块) | ✅ `BUILD SUCCESS` | +| 前端 SFC/JS 语法解析 | ✅ 住宿页、房间页通过 | +| 实机(到期自动退房、房态释放、补偿不复活、统计数字) | ⬜ **未进行** | + +> 住宿模块 S1–S4 代码已全部就绪;`sql/upgrade_d19_stay.sql` 仍未执行,前端与实机联调未做。 + +### 4.16 住宿业务调整:不限制房间入住人数(2026-10-31) + +业务确认:一个房间可住多人,可重复办理入住,**不限制入住人数**(`capacity` 仅参考)。 + +- 入住/换房不再校验房间空闲与在住单数,只拒绝「维修 / 停用」房间。 +- 房态改为动态重算:入住/退房/到期/换房后按该房间在住单数刷新——有在住单即为「在住」(记录最近一单),全部退完才置「空闲」。 +- 房间列表新增「在住人数」(`lock_stay` 子查询统计,tooltip 显示最近入住人);住宿页房间下拉显示「在住 N 人」。 +- 到期任务同步改为按剩余在住单重算房态,避免误清同房间其他住客。 + +| 项 | 结果 | +|---|---| +| 全工程编译(8 模块) | ✅ `BUILD SUCCESS` | +| 前端 SFC/JS 语法解析 | ✅ 住宿页、房间页通过 | +| 实机(同房多人入住/单个退房房态保持/最后一人退房置空闲) | ⬜ **未进行** | + +### 4.17 缺陷修复:清除失败被当作下发失败(2026-10-31) + +**现象**:换房后新房门锁授权已下发,旧房门锁删除失败;旧房授权还会"复活"。 + +**根因**:`sync_status=3` 同时表示「人脸 add 失败」和「人脸 delete 失败」,补偿任务统一按 add 重发,删除失败被当成待下发,把人脸重新加回旧房。 + +**修复**: + +- 新增状态 `6 待清除`(字典 `lock_sync_status`;5 留给 v2 的「已过期」); +- 清除命令失败/超时写 6,补偿只对该状态重发 `delete`(5 分钟退避),`0/3` 仍走 add; +- 算法版本变更联动排除 4/6; +- 历史数据脚本 `sql/upgrade_hotfix_sync_clear_fail.sql`(幂等、未执行):把「最近命令为 0012 且状态为 3」的授权改为 6。 + +| 项 | 结果 | +|---|---| +| 全工程编译(8 模块) | ✅ `BUILD SUCCESS` | +| 脚本执行 | ⬜ **未执行** | +| 实机(换房删旧脸失败后补偿只重发 delete) | ⬜ **未进行** | + +### 4.18 对外房间门禁服务接口设计稿(2026-10-31,未实现) + +面向外部业务系统开放入住、退房、续住、开锁记录查询与人脸下发结果回调。复用现有 `/openapi/iot` + `X-API-Key` 鉴权机制;同步受理 + 异步回调。 + +- 文档:[对外开放房间门禁服务接口设计.md](./对外开放房间门禁服务接口设计.md) +- 规划变更(未建表):`lock_stay` 加 `biz_no/biz_system/source`(唯一键 `(biz_system,biz_no)`);新增 `lock_person_ref`(外部人员映射)、`lock_callback_task`(回调重试)、`lock_room_device_history`(房间-门锁绑定历史) +- 已确认:回调地址按应用固定配置、每个业务系统独立 API Key 隔离、房间号全局唯一、外部人员标识稳定、开锁记录查询包含历史门锁 +- 配置模型:`iot.openapi.apps[]`(app-code/api-key/callback-url/callback-secret/ip-whitelist),未配置时兼容现有单 Key(appCode=DEFAULT) +- 脚本规划:`sql/upgrade_d20_openapi_stay.sql`,**未创建、未执行** +- 仍需确认:结果查询接口、接口侧 HMAC 签名、限流阈值、多应用配置热更新等(设计文档 §13.2) + +### 4.19 对外房间门禁服务接口 D20 P1(2026-10-31,代码就绪) + +**范围**(设计稿 §14 P1):多应用配置与鉴权改造(Key → appCode)+ 入住/退房/续住三个对外接口 + 外部人员标识映射 + `lock_stay` 扩展 + 房间门锁绑定历史。**P2 回调、P3 开锁记录查询、P4 安全加固不在本期**。 + +- 脚本 `sql/upgrade_d20_openapi_stay.sql`(幂等,**未执行**):`lock_stay` 加 `biz_no/biz_system/source` 与唯一键 `uk_lock_stay_biz (biz_system, biz_no)`;新建 `lock_person_ref`(唯一 `(app_code, external_id)`)、`lock_room_device_history`;含存量房间绑定关系回填与只读核对语句。**不含 `lock_callback_task`**(P2 再建)。 +- 鉴权:`OpenApiAuthFilter` 由「校验单个 Key」改为「Key → 应用」,把 `appCode` 写入请求属性;`OpenApiProperties` 支持 `apps[]`(app-code/app-name/api-key/callback-url/enabled),未配置 apps 时退回单 Key(`appCode=DEFAULT`);启动时校验 `app-code`/`api-key` 非空且唯一,重复直接启动失败。 +- 接口:`OpenStayApiController` → `POST /openapi/iot/stay/{checkin,checkout,extend}`,响应信封为设计稿 §3.2 的 `{code,msg,requestId,data}`(`code=0` 成功,业务码 40001–40007/40100/40300/50001/50002 并映射 HTTP 状态码)。**与既有 `/openapi/iot/auth` 的 `AjaxResult`(200/500) 分开**,不改动已对接方的契约。 +- 业务实现:`OpenStayServiceImpl` 只做对外契约相关的三件事——按 `appCode` 隔离与幂等、外部人员标识映射、头像解析与按门锁算法版本提取特征;入住/退房/续住最终仍落到 `LockStayServiceImpl`(复用房间行锁、房态重算、授权下发与回执回写)。 +- 房间门锁历史:`LockRoomServiceImpl.bindDevice` / `deleteRoomByIds` 在解绑、换锁、删房时关闭旧绑定(写 `unbind_time`),新绑定写一条历史(含 `device_sn` 快照),历史表只增不改,为 P3「查询包含历史门锁」铺路。 + +关键实现决策(均为规避本平台已踩过的坑): + +| 点 | 处理 | +|---|---| +| `bizNo` 幂等 | 重复提交同 `bizNo`:人员与房间一致才幂等返回当前状态,否则 40007;`selectStayByBiz` **不过滤 `del_flag`**(唯一键不含 `del_flag`,过滤掉会让重复提交直接撞键抛 SQL 异常),已删除行占用 `bizNo` 转成可读的 40007 | +| 并发 | `insertStay` 撞唯一键捕获 `DuplicateKeyException` 后回查按幂等返回;`lock_person_ref` 并发首次入住撞键后**以已存在的映射人员为准**,避免两条人员档案指向同一外部标识(本次新建档案留日志待人工合并) | +| 换房继承 | 新单继承 `biz_system/source` 但**不继承 `biz_no`**(会与原单撞唯一键),对方仍可按「人员 + 房间」定位换房后的在住单 | +| `source` 默认值 | `insertStay` 显式列出 `source` 列,为空会写 NULL 而不是列默认 `'1'`,故在 `createStay` 内补默认(与 §4.6 `lock_auth.status` 写 NULL 导致补偿捞不到同类问题) | +| 数据隔离 | 退房/续住定位入住单的三条查询都带 `biz_system = appCode` 过滤,跨应用的单号/人员一律「不存在」(40006) | +| 头像 | 每次入住必须传 `photoUrl` 或 `photoBase64`,缺参在校验阶段返回 40001(不建档);照片与库中不同或新建档则重新提取,照片未变且该版本特征可用时复用;`photoBase64` 只接受 JPG/PNG(按文件头判定)、解码后 ≤ 2MB、文件名由平台生成(不含调用方输入,无路径穿越),落 `profile/openapi/yyyy/MM/dd/` | +| 门锁无算法版本 | 提取前即阻断,返回 50001 并提示先在门锁页维护 `face_algo_version`,不下发一份错误特征 | + +| 项 | 结果 | +|---|---| +| 全工程编译(8 模块) | ✅ `BUILD SUCCESS` | +| Mapper 接口 ↔ XML 语句对齐(`LockPersonRef`/`LockRoomDeviceHistory`/`LockStay`/`LockRoom`) | ✅ 逐条核对一致 | +| 脚本执行 | ⬜ **未执行**(按约定只交付幂等脚本) | +| 实机联调(两应用各自入住/退房、数据互不可见、门锁下发与回执) | ⬜ **未进行**,设计稿 §14 P1 验收项待环境验证 | +| 前端 | 无改动(P1 不涉及页面) | + +上线步骤:① 执行 `sql/upgrade_d20_openapi_stay.sql`(重复执行时 `lock_stay` 加列段报 `Duplicate column name` 可忽略)→ ② 跑脚本第 5 节只读核对(历史表回填条数 = 已绑锁房间数)→ ③ 在 `application.yml` 配置 `iot.openapi.apps[]` 两个应用的独立 Key(`api-key` 兼容项可留空)→ ④ 重启后按设计稿 §12 用例联调(重点:同 `bizNo` 重放、跨应用 `bizNo` 互不可见、缺算法版本阻断、缺照片 40001、复住须重新传照片)。 + +遗留与风险: + +- **P2 回调当时未实现**(已于 2026-11-01 补齐,见 §4.20):P1 单上线时响应只回显 `callback.enabled/url`,平台不主动上报下发结果;P2 未上线前对接方需以同步响应 + 后续查询(P3 之前可重放入住接口取幂等结果)为准。 +- **P4 安全项未实现**:`apps[].ip-whitelist`、令牌桶限流、接口侧 HMAC 签名均缺;**`photoUrl` 未做域名白名单与 SSRF 防护**,平台会按调用方给的地址去取图,上线前须由网络侧限制出站或补 P4。 +- 设计稿 §13.2 的 Q1–Q6 按「建议默认」实现,**结论未经业务方确认**,不一致需回改(详见设计稿 §13.2 的「P1 实现口径」列)。 + +### 4.20 对外接口结果回调 D20 P2(2026-11-01,代码就绪) + +**范围**(设计稿 §14 P2):授权/清除命令进入终态后按应用回调结果——事件落库、HMAC-SHA256 签名投递、退避重试与死信、人工重发、回调任务页。**P3 开锁记录查询、P4 安全加固仍不在本期**。 + +- 脚本 `sql/upgrade_d20_p2_callback.sql`(幂等,**未执行**):建 `lock_callback_task`(唯一 `uk_lock_callback_event(event_id)`、`uk_lock_callback_rev(app_code,biz_no,event_type,revision)`)、字典 `lock_callback_status` / `lock_callback_event`、菜单 2140/2141/2142(`lock:callback:list/query/resend`)。**必须在 P1 的 `upgrade_d20_openapi_stay.sql` 之后执行**,且与本批代码同批发布——缺表会让接口单在事务内报错回滚(平台手工单不受影响)。 +- 契约:`POST` 应用配置的 `callback-url`,请求头 `X-Callback-Id`(= `event_id`,对方据此去重)、`X-Timestamp`(秒)、`X-Signature` = hex(HMAC-SHA256(`timestamp + "." + 原始报文`, `callback-secret`));HTTP 200 且响应体 `code=0` 记为成功。报文含 `eventId/eventType/revision/bizNo/stayNo/personNo/externalPersonId/roomNo/roomCode/deviceSn/result/message/occurredAt`。 +- 事件:`STAY_CHECKIN_RESULT` / `STAY_CHECKOUT_RESULT` / `STAY_EXTEND_RESULT`;任务状态 `0 待结果 / 1 待发送 / 2 已成功 / 3 死信`。 +- 重试:5 次,间隔 1s / 5s / 30s / 2min / 10min(`CallbackConstants.RETRY_BACKOFF_SECONDS`),耗尽转死信,页面可查并人工重发。 +- 落点:`LockCallbackServiceImpl`(登记 / 回填 / 投递 / 退避 / 超时 / 重发)+ `LockCallbackRetryTask`(30s 扫描,`DistributedTaskLock` 单实例)+ `/lock/callback` 查询与重发接口 + `src/views/iot/callback/index.vue`。 + +关键实现决策(均为绕开本平台已踩过的坑): + +| 点 | 处理 | +|---|---| +| 事件类型识别 | 命令终态无法区分入住/续住/补偿(都是 `0011`),故**在入住/退房/续住操作时先登记事件**(待结果),命令回执时再按 `authId` 回填结果。回执侧不猜业务语义 | +| 异步竞态 | 登记后命令可能已到终态(MQTT 回执快于事务提交),插入待结果任务后**回读授权状态**,已在终态立即转待发送,不留给下一轮扫描 | +| 结果重复上报 | 同一授权同一结果重复回执不产生新任务;只有结果变化(失败 → 成功,如补偿后)才 `revision+1` 新建任务,对方按 `revision` 最大者为最终结果 | +| 等待超时 | 24h 仍未收到回执的待结果任务**不转死信**,而是按「失败 + 超时原因」生成报文投递,避免对方永远收不到结果 | +| 配置缺失 | 未配 `callback-url` = 未开通回调(不建任务,接口照常成功);配了地址但缺 `callback-secret` = 该条直接死信并写明原因,补齐后可重发 | +| 平台手工单 | 无 `biz_no` / `biz_system` 的入住单(平台页面办理)不产生回调 | +| 地址快照 | 任务落库时快照 `callback-url`,后续改配置不影响已产生的事件 | +| 投递隔离 | 单批 100 条,连接 3s / 读取 5s 超时,**不跟随重定向**;单条异常不影响同批其它任务 | +| 失败不阻塞业务 | 登记与回填都包在业务侧的 try/catch 内,回调异常只写 `warn` 日志,不回滚入住/退房/续住 | + +| 项 | 结果 | +|---|---| +| 全工程编译(8 模块) | ✅ `BUILD SUCCESS` | +| Mapper 接口 ↔ XML 语句对齐(`LockCallbackTask` / `LockStay.selectStayByAuthId` / `LockPersonRef.selectByAppAndPersonNo`) | ✅ 逐条核对一致 | +| 前端语法(`callback.js`、`index.vue` script) | ✅ `node --check` 通过,template 标签配平 | +| 脚本执行 | ⬜ **未执行**(按约定只交付幂等脚本) | +| 联调(真实回调地址、断网重试、死信重发、两应用分流) | ⬜ **未进行**,设计稿 §12.5 T51–T60 待环境验证 | +| 前端页面实测 | ⬜ **未进行**(后端与库未起,仅静态校验) | + +上线步骤:① 先执行 `upgrade_d20_openapi_stay.sql`(P1)→ ② 执行 `upgrade_d20_p2_callback.sql`,跑文末只读核对(`show create table`、各状态计数)→ ③ 在 `iot.openapi.apps[]` 为每个应用补 `callback-url` 和 `callback-secret`(与对方约定同一密钥)→ ④ 与代码同批次发布 → ⑤ 按设计稿 §12.5 联调,重点验「断网后按 1s/5s/30s 重试」「连续失败进死信并能页面重发」「两个应用各自只收到自己的事件」。 + +遗留与风险: + +- **回调失败无主动告警**:死信只落库,需人工看回调任务页;成功率、重试次数、死信数均未埋点。 +- **`callback-secret` 明文**写在 `application.yml`(与 D10 凭证明文同口径),配置文件权限与读取角色需控制。 +- **`callback-url` 未做域名白名单**:地址由运维在配置中固定,不走调用方输入,SSRF 风险低于 P1 的 `photoUrl`,但仍须保证配置项只由运维维护。 +- 回调侧不做业务重量校验(不回调里查单),对方需**按 `eventId` 去重、按 `revision` 取最新**;24h 超时按失败上报后若设备迟到成功回执,会再产生一条 `revision+1` 的成功回调。 +- 设计稿 §13.2 的 Q6(回调字段集)按「建议默认」实现,**未经对接方确认**,字段增删需回改。 + +### 4.21 开锁记录查询 D20 P3(2026-11-01,代码就绪) + +**范围**(设计稿 §14 P3):`GET /openapi/iot/stay/unlock-records`——按房间号 + 人员标识 + 时间段查询开锁记录,房间换过锁时同时返回换锁前门锁上的记录。**无新增表、无新增脚本**,复用 P1 的 `lock_room_device_history`。 + +- 接口:`OpenStayApiController#unlockRecords`(`@Anonymous`,与三个受理接口共用 `OpenApiAuthFilter` 的 Key → appCode 鉴权),响应 `{code, msg:"success", requestId, data:{total, rows}}`。 +- 房间→门锁并集:`LockRoomDeviceHistoryMapper.selectDeviceSnsByRoomAndTime` = 当前绑定(`lock_room.device_id`)∪ 与查询时段重叠的绑定历史 ∪ 该房间时段的入住单 `device_sn` 快照(历史表未回填时的兜底)。 +- 记录查询:`LockAccessLogMapper.selectAccessLogListByDeviceSns`(`device_sn in (...)` + `open_time` 区间 + 可选 `person_no`/`open_type`),`PageHelper` 分页,按 `open_time desc, log_id desc`。 +- 结果字段:`openTime / personNo / personName / roomNo / deviceSn / openType / openTypeName / openResult / openResultName / raw`;不返回卡号、设备 ID、网关。 + +关键实现决策: + +| 点 | 处理 | +|---|---| +| 并集而非当前锁 | 只查 `lock_room.device_id` 会漏掉换锁前记录,故按三段 union 取 SN 集合;`device_sn` 为空的行全部排除,避免 `in ('')` | +| 房间无锁 | 并集为空直接返回 `total=0`,不报 40004——查询不是入住,无锁也应有确定结果 | +| 人员隔离 | `personNo` 直接传入时按 `lock_person_ref(app_code, person_no)` 校验,未在本应用建档返回 40005;`externalPersonId` 按 `(app_code, external_id)` 反查,缺失同样 40005;两者都传以 `personNo` 为准 | +| 时间窗 | `open_time` 取 `[beginTime, endTime]` 闭区间;历史重叠判定 `bind_time <= endTime && (unbind_time is null || unbind_time >= beginTime)`;跨度 > 31 天返回 40001(按设计稿口径) | +| 参数校验 | `openType` 只接受字典值 2/3/4/5/7/9,其它 40001 并回显可选值;`pageNum < 1`、`pageSize ∉ [1,100]` 均 40001;`endTime <= beginTime` 返回 40002 | +| 名称口径 | `openTypeName` / `openResultName` 用与字典 `lock_open_type` / `lock_open_result` 一致的静态映射;字典没有的新码原样返回,不吞成 null | +| 响应 msg | 查询成功 `msg=success`;受理类接口保持 `accepted`,`OpenApiResult` 增了带 msg 的重载,旧行为不变 | +| 分页实现 | `PageHelper.startPage` 紧贴查询语句(中间不插其它 DB 调用),`PageInfo.total` 取分页总数 | + +| 项 | 结果 | +|---|---| +| 全工程编译(8 模块) | ✅ `BUILD SUCCESS`(2026-11-01) | +| Mapper 接口 ↔ XML 对齐(`LockRoomDeviceHistory` / `LockAccessLog`) | ✅ 逐条核对一致 | +| 脚本执行 | ⬜ 无新脚本;P1 的 `upgrade_d20_openapi_stay.sql` **仍未执行** | +| 联调(换锁前后记录、人员隔离、分页、时间跨度) | ⬜ **未进行**,设计稿 §12.4 T41–T50 待环境验证 | +| 前端 | 无改动(对外接口,无平台页面) | + +上线步骤:① 随 P1/P2 同批发布(无新脚本);② 确认 P1 脚本已执行且 `lock_room_device_history` 回填完成;③ 按设计稿 §12.4 T41–T50 联调,重点验「换锁前后记录都返回」「非本应用人员返回 40005」「pageSize=200 返回 40001」。 + +遗留与风险: + +- **并集查询无专用索引**:三段 union 分别走 `lock_room` 主键、历史表 `idx_lock_room_device_history(room_id, bind_time, unbind_time)`、入住单 `idx_lock_stay_room`;开锁记录侧靠 `idx_lock_access_log_device(device_sn)` + `idx_lock_access_log_time(open_time)`,单房间 31 天数据量可控,**未做实测压测**。 +- 只有精确匹配能力:不支持姓名模糊、开门结果过滤、多房间批量查询(契约未要求)。 +- `raw` 直接回传设备原始 params,可能含设备私有字段;对方若不需要可去掉,不影响其它字段。 +- 时间按服务器本地时区解析与比较,跨时区对账需双方确认(设计稿 §3.1)。 + +--- + +### 4.22 D20 联调用例包:Apifox 测试用例(2026-11-01,文档就绪) + +**范围**:为 P1 三个受理接口 + P2 回调 + P3 开锁记录查询整理可直接执行的联调用例,**不改代码、不执行任何数据库脚本**。 + +交付物: + +| 文件 | 内容 | +|---|---| +| [对外开放接口Apifox测试用例.md](./对外开放接口Apifox测试用例.md) | 环境变量、平台配置样例、测试数据清单、断言模板、A/C/K/E/U/B/CH 共 90+ 条用例、回调接收器脚本与执行清单 | +| [apifox/iot-stay-openapi.json](./apifox/iot-stay-openapi.json) | OpenAPI 3.0.3:4 个接口 + 11 个 Schema + 错误响应,可在 Apifox 直接导入生成目录与请求 | + +关键内容: + +| 点 | 处理 | +|---|---| +| 鉴权用例 | 缺失/错误 Key → 401/40100(无 requestId);`enabled=false` → 404/404;应用禁用 → 401/40100 | +| 入住用例 | 正常(photoUrl/base64/data URL)、幂等、复住重新传照片、`duplicatePersonInRoom`、缺照片 40001、照片与算法版本失败、bizNo 冲突(40007)、跨应用同 bizNo | +| 退房/续住 | 双定位路径(bizNo 优先,其次人员+房号)、已退房幂等、跨应用 40006、时间与状态校验顺序 | +| 开锁记录 | 人员/房间/时间/openType/分页校验、31 天边界、无锁返回 total=0、跨应用人员 40005、personNo 优先 | +| 回调 | 验签接收器(Node)、Apifox Mock 方案、成功判据 HTTP 200 + body code=0、1s/5s/30s/2min/10min 退避与约 13–16 分钟死信、重发、未开通回调 | +| 断言 | 受理 `msg=accepted`、查询 `msg=success`、`NON_NULL` 字段缺失不判 null、过滤器无 requestId | + +验证:OpenAPI JSON 已用 Node 解析并校验 31 处 `$ref` 全部可解析;用例预期值与 +`OpenApiCodes`、`OpenStayServiceImpl`、`OpenUnlockRecordServiceImpl`、`LockCallbackServiceImpl`、 +`OpenApiAuthFilter` 逐条对照。 + +状态:**文档就绪,未在任何环境执行**。用例中的 40005/40007/50001、跨应用隔离、 +回调死信需先完成 D19/D20 P1/P2 脚本执行与实机联调;P4(IP 白名单/限流/photoUrl 白名单/ +接口签名)未实现,用例集已明确标注不覆盖。 + +--- + +### 4.23 修复:平台入住第二次办理撞唯一键(2026-11-01,代码就绪) + +**现象**:入住管理页办理入住报 +`Duplicate entry '' for key 'lock_stay.uk_lock_stay_biz'`。 + +**原因**:`uk_lock_stay_biz(biz_system, biz_no)` 由 D20 P1 引入,MySQL 唯一索引允许多行 +`NULL` 但只允许一行空串。平台手工单本应两者为 `NULL`(`upgrade_d20_openapi_stay.sql` +§1 注释即按此设计),但 `LockStayServiceImpl.createStay` 用 `StringUtils.trim` 直接赋值, +页面传入 `bizNo=""`/`bizSystem=""` 时落库为空串,第二单必然撞键。对外接口入住不受影响 +(`bizNo` 为空在 `OpenStayServiceImpl` 已按 40001 拒绝)。 + +**修复**: + +| 文件 | 改动 | +|---|---| +| `ruoyi-iot/.../lock/service/impl/LockStayServiceImpl.java` | `createStay` 中空串归一为 `NULL` 后再写 `biz_no`/`biz_system` | +| `sql/upgrade_d20_openapi_stay.sql` | 新增 §5.5 存量修复:把平台单残留的空串 `biz_no`/`biz_system` 改回 `NULL`(幂等,可重复执行) | + +**验证**:全工程 8 模块 `BUILD SUCCESS`;**脚本未执行、页面未实测**。 +上线时需在已执行 D20 P1 脚本的库上补跑 §5.5 两段 `update`,否则页面仍可能撞上已存在的空串行。 + +--- + +### 4.24 住宿管理:办理平台筛选与外部单据详情(2026-11-01,代码就绪) + +**范围**:区分「本平台办理」与「外部系统办理」的入住单,外部单据可查看对方单号等详情。不新增表、不新增脚本、不新增权限点(复用 `lock:stay:query`)。 + +**后端**: + +| 文件 | 改动 | +|---|---| +| `resources/mapper/lock/LockStayMapper.xml` | `selectStayList` 增加 `source` 筛选;`coalesce(nullif(s.source,''),'1')` 让 D20 之前无来源值的老单据归入「本平台」 | +| `lock/domain/LockStayExternalDetail.java`(新增) | 外部详情只读视图:业务系统编码/名称、业务单号、外部人员标识 + 入住单上下文与授权状态 | +| `lock/service/impl/LockStayServiceImpl.java` | `selectExternalDetail`:读入住单 → 按 `biz_system` 取 `iot.openapi.apps[].app-name`、按 `(appCode, personNo)` 反查 `lock_person_ref.external_id`;应用已下线时名称为空 | +| `lock/controller/LockStayController.java` | `GET /lock/stay/{stayId}/external`(`lock:stay:query`) | + +**前端**(`RuoYi-Vue2-master`): + +| 文件 | 改动 | +|---|---| +| `src/api/iot/stay.js` | 新增 `getStayExternal(stayId)` | +| `src/views/iot/stay/index.vue` | 查询条件「办理平台」(本平台/外部系统)、列「办理平台」标签与「外部单号」、外部行操作「外部详情」弹窗;`source` 为空且无 `bizNo` 视为本平台 | + +**验证**:后端全工程 `BUILD SUCCESS`;页面通过 `vue-template-compiler` + `@babel/parser` 语法校验; +**未启动服务、未做接口/页面实测**。外部系统名称取自接入配置(D21 起为 `lock_open_app`),删除了的应用只显示编码。 + +--- + +### 4.25 开放管理:应用配置落表 + 接入管理页(D21,2026-11-01,代码就绪) + +**范围**:把 `iot.openapi.apps[]` 从 `application.yml` 迁到 `lock_open_app`,新增「开放管理 › 应用接入」页面, +支持配置维护、启停、重置密钥与入住统计。不含 IP 白名单、限流、密钥加密(仍在 P4/D10)。 + +**后端**: + +| 文件 | 改动 | +|---|---| +| `sql/upgrade_d21_openapp.sql`(新增) | `lock_open_app` 建表(`app_code`/`api_key` 唯一、无 `del_flag`);菜单 2200/2210–2216;核对与回滚语句 | +| `lock/domain/LockOpenApp.java`(新增) | 配置实体 + 4 个联表统计字段(累计/在住/今日/最近登记)+ 派生 `callbackEnabled` | +| `lock/mapper/LockOpenAppMapper.java` / `LockOpenAppMapper.xml`(新增) | 列表(编码/名称/状态筛选 + 相关子查询统计)、按编码/全量查询、密钥占用检查、增改、启停、改 Key、删除 | +| `lock/mapper/LockStayMapper.java` / `LockStayMapper.xml` | 新增 `countStaysByBizSystem`,删除应用前的入住数据校验 | +| `openapi/config/OpenAppRegistry.java`(新增) | 以表为事实源的内存快照:30 秒 TTL + 写后 `reload()`;`resolveApp` 只返回启用应用,`getApp` 不看状态(回调要用);表为空时从 yml 导入,表不可用时降级 yml 并告警 | +| `openapi/config/OpenApiProperties.java` | 只保留 `enabled`、`path-prefix` 与 yml 导入源;移除 `resolveApp/getApp` | +| `OpenApiAuthFilter`、`LockCallbackServiceImpl`(3 处)、`OpenStayServiceImpl`、`LockStayServiceImpl` | 应用信息一律改从 `OpenAppRegistry` 读取 | +| `lock/service/ILockOpenAppService.java` / `impl/LockOpenAppServiceImpl.java`(新增) | 列表/详情返回掩码;新增可自动生成 Key;`appCode` 不可改、`apiKey` 只能重置;回调密钥留空=不修改;删除前校验入住数据;写后刷新注册表 | +| `lock/controller/LockOpenAppController.java`(新增) | `GET /lock/openapp/list`、`GET /{appId}`、`POST`、`PUT`、`PUT /{appId}/status`、`POST /{appId}/reset-key`、`DELETE /{appIds}`,权限 `lock:openapp:*` | + +**前端**(`RuoYi-Vue2-master`): + +| 文件 | 改动 | +|---|---| +| `src/api/iot/openapp.js`(新增) | 列表/详情/新增/修改/启停/重置密钥/删除 | +| `src/views/iot/openapp/index.vue`(新增) | 编码/名称/状态筛选;密钥掩码列、状态开关、回调标签、四个统计列;新增/编辑弹窗;重置密钥二次确认 + 完整密钥一次性弹窗(可复制);删除提示 | + +**关键口径**: +- 配置以表为准,管理页改完立即生效(直接改库最多 30 秒);yml 只在首次导入与脚本未执行时使用。 +- 密钥明文落库(D10 口径),列表/详情只回掩码,完整值仅在新增与重置时返回一次。 +- 停用即拒绝新请求(401/`40100`),已受理入住单的回调继续投递;有入住数据的应用禁止删除,只能停用。 + +**上线步骤**:备份 `sys_menu` → 执行 `upgrade_d21_openapp.sql` → 发布本批代码(启动日志出现「已从 application.yml 导入 N 个应用」即迁移完成)→ 打开「开放管理 › 应用接入」核对配置与统计 → 确认后删除 yml 中 `iot.openapi.apps[]`(保留 `enabled`、`path-prefix`)。 + +**验证**:后端全工程 `BUILD SUCCESS`;`openapp/index.vue` 通过 `vue-template-compiler` + `@babel/parser` 语法校验; +**脚本未执行、未启动服务、未做接口/页面实测**。 + +--- + +### 4.26 住宿管理:入住单详情查看本次开门记录(2026-11-01,代码就绪) + +**范围**:住宿管理 → 入住单详情弹窗下半部新增「本次入住开门记录」,只看本单入住人、本单房间门锁、本次入住时段内的开锁日志。 +不需要改表、不需要新菜单/新权限。 + +**数据口径**(三条同时满足): +- 人:`lock_access_log.person_no = lock_stay.person_no`(平台手工单无人员编号 → 空列表)。 +- 时间:`open_time` 在 `begin_time` ~ `coalesce(checkout_time, end_time)`;在住单按预计退房时间收口,不混入后续住客的记录。 +- 门锁:本单房间的门锁集合 = 当前 `lock_room`→`lock_device` ∪ 与该时段重叠的 `lock_room_device_history` ∪ 该房间入住单的 `device_sn` 快照(兜底)。 + +**后端**: + +| 文件 | 改动 | +|---|---| +| `lock/mapper/LockAccessLogMapper.java` | 新增 `selectAccessLogListByStay(stayId, openType, openResult)` | +| `mapper/lock/LockAccessLogMapper.xml` | 单条 SQL:`join lock_stay`,门锁集合用 `union` 内联,可按开门方式/结果过滤,`open_time desc` | +| `lock/service/ILockStayService.java` / `impl/LockStayServiceImpl.java` | `selectStayAccessLogs`,只做空值判断后转发(不预查入住单/门锁) | +| `lock/controller/LockStayController.java` | `GET /lock/stay/{stayId}/access-records`,`startPage()` + `getDataTable`,权限复用 `lock:stay:query` | + +**前端**(`RuoYi-Vue2-master`): + +| 文件 | 改动 | +|---|---| +| `src/api/iot/stay.js` | 新增 `listStayAccessRecords(stayId, query)` | +| `src/views/iot/stay/index.vue` | 详情弹窗加宽至 920px;记录区含开门方式/结果筛选、迷你表格(时间/门锁SN/方式/结果/方向/卡号)、独立分页与空态;每次打开详情重置筛选与分页 | + +**分页陷阱**:`startPage()` 只作用于之后第一条 SQL。若服务层先查入住单或门锁历史,PageHelper 会把分页消耗在辅助查询上导致列表分页错乱; +因此本期把入住单与门锁集合全部内联进同一条 select,服务层只调用一次。 + +**验证**:后端全工程 `BUILD SUCCESS`;Mapper XML 与 `stay/index.vue`、`stay.js` 语法校验通过; +**未连库执行、未启动服务、未做页面实测**(无 DDL/DML,无需执行脚本)。 + +--- + +### 4.27 对外开放接口:连通性测试 ping(2026-11-01,代码就绪) + +**范围**:新增 `GET /openapi/iot/ping`,供外部系统联调时确认网络可达、API Key 有效、平台时钟与本机时钟的偏差。 +无新增表、无新增菜单/权限(沿用开放接口的 `X-API-Key` 鉴权)。 + +**响应口径**: + +| 字段 | 内容 | +|---|---| +| `serverName` / `apiVersion` | 平台名称 / 对外契约版本(当前 `v1`) | +| `serverTime` / `serverTimestamp` | 服务器当前时间(`yyyy-MM-dd HH:mm:ss`)与毫秒时间戳 | +| `timeZone` | 服务器默认时区 | +| `app.appCode` / `app.appName` | 本次 Key 对应的应用,可用于核对是否用错密钥 | +| `app.enabled` / `app.callbackEnabled` | 应用启用状态;是否已配齐回调地址与签名密钥 | + +不返回 `apiKey`、`callbackUrl`、`callbackSecret`。Key 无效或应用停用仍由过滤器返回 HTTP 401 / `40100`。 + +**交付**: + +| 文件 | 改动 | +|---|---| +| `openapi/controller/OpenTestApiController.java`(新增) | `GET /ping`,从请求属性取应用配置,回显时间与应用信息 | +| `doc/对外开放房间门禁服务接口设计.md` | 新增 §3.3 接口说明与示例、§3.1 路径清单、§12.7 用例 T65–T68 | +| `doc/对外开放接口Apifox测试用例.md` | 新增 §3.1 连通性用例 P01–P04、目录与执行清单 | +| `doc/apifox/iot-stay-openapi.json` | 新增 ping 路径、`连通性` 标签与 `PingApiResult` 结构 | + +**验证**:后端全工程 `BUILD SUCCESS`;OpenAPI JSON 解析与 `$ref` 校验通过; +**未启动服务、未用真实 Key 调用实测**。 + +--- + +### 4.28 门禁管理:服务器系统时间校时(D22,2026-11-01,代码就绪) + +**背景**:开门授权按绝对时间窗下发,门锁按自身时钟判断有效期;平台到期任务、通行/命令日志、回调时间戳都取服务器时间。 +系统将部署到 Linux 服务器、基地内网缺少时间源,时钟漂移会直接影响授权与排障,因此提供管理端校时。 + +**交付**: + +| 层 | 文件 / 配置 | 改动 | +|---|---|---| +| 配置 | `application.yml` → `iot.time.*` | `control-enabled`(默认 false,只读)、`use-sudo`(非 root 时用免密 sudo)、`timeout-ms` | +| 后端 | `config/SystemTimeProperties.java`(新增) | 配置绑定 | +| 后端 | `lock/domain/SystemTimeInfo.java`、`SystemTimeSetBody.java`(新增) | 时间信息 / 校时请求体 | +| 后端 | `lock/service/ILockSystemTimeService.java`、`impl/LockSystemTimeServiceImpl.java`(新增) | `timedatectl` 读取与执行:严格解析时间/时区后以独立参数传进程(不走 shell),超时可杀,失败回传原命令输出 | +| 后端 | `lock/controller/LockSystemTimeController.java`(新增) | `GET /lock/time`、`POST /lock/time/set`、`POST /lock/time/ntp`,分别 `lock:time:query` / `lock:time:set` / `lock:time:ntp`,写操作带 `@Log` | +| 后端 | `LockStayMapper` + XML | `countActiveStays()`,在住单数量用于风险提示 | +| 前端 | `api/iot/systime.js`、`views/iot/systime/index.vue`(新增) | 服务器时间卡片、手动校时表单、NTP 开关;不可用时整体禁用并说明原因 | +| 脚本 | `sql/upgrade_d22_systime.sql`(新增) | **只加菜单** 2150–2152,不建表、不改业务表;含 D20 P2 回调菜单 2140 的修复段 | + +**安全闸门**:默认关闭 → 权限 `lock:time:set/ntp` → 仅 Linux + timedatectl → root/免密 sudo → 存在在住单时返回 `601` 警告,前端二次确认后带 `force=true` 重试。 + +**口径说明**:已下发到门锁的授权带绝对时间窗,不随平台改时间而变;受影响的是平台侧到期任务、日志与回调时间戳、后续入住登记。 +开启 NTP 时拒绝手动校时(需先关 NTP);`set` 先改时区再改时间。 + +**验证**:后端全工程 `BUILD SUCCESS`;`systime/index.vue`、`systime.js` 语法校验通过;Mapper XML 校验通过; +**未启动服务、未在 Linux 实测(无 root/sudo 环境)**。部署与免密 sudo 配置见《系统时间校时设计》。 + +**号段修正(2026-11-01)**:首版脚本误用 2140–2142,与 D20 P2 回调任务号段冲突,执行报 `Duplicate entry '2141'`, +且 `delete ... menu_id = 2140` 会删掉「回调任务」父菜单。已改为 2150–2152,并在脚本第 0 段按 `lock:time:%` 清理残留、 +用 `on duplicate key` 补回 2140 回调任务父菜单;脚本可重复执行,重跑一次即完成修复与建菜单。 + +--- + +### 4.29 现场基础数据导入脚本(2026-11-01,脚本就绪) + +**来源**:`doc/锁-房间号对应表配置数据.xlsx`(现场提供的分组/楼层/房号/锁ID/开门方向/网关名称/IP/网关SN/分组)。 +**产出**:`sql/import_site_rooms_devices.sql`,8 分组 · 8 网关 · 62 门锁 · 62 房间 · 62 条门锁-网关归属。 + +| 字段口径 | 说明 | +|---|---| +| 锁ID | 8 位门锁 SN → `lock_device.device_sn`(下发 `locklist` 时由代码补 `CCEE` 前缀,库内不存 12 位) | +| 分组 | `FFFA26102911…18` → `lock_group.group_code`,与网关一一对应,同时作为 `locklist` 组号 | +| 网关SN / IP | → `lock_gateway.gateway_sn` / `gateway_ip`,按分组归入 `group_id` | +| 房号 | `room_code = 分组编号-房号`(如 `FFFA26102911-3201`),一房一锁,初始空闲 | +| 开门方向 | 表结构无对应字段,写入 `lock_room.remark`(左开 35 / 右开 27) | + +**幂等**:全程按自然键判断、不删除任何数据;分组/网关/门锁已存在则对齐名称与归属,归属关系与房间已存在则保留运行态(`bind_status`、房态、在住)。 +**未填字段**:门锁/网关密码、`dev_type`、`dev_ver`、`face_algo_version`(Excel 未提供);密码空值下发时按 `"0"`,人脸授权前需补 `face_algo_version`。 +**验证**:脚本结构校验通过(10 条语句 = `set names` + 5 条 insert + 4 条 update;62 行×9 列源数据内联为派生表;列名与 DDL 全部匹配);**未对任何数据库执行**。执行前置为 D19(`lock_room`)与 D17(`lock_device_gateway`)已就绪。 + +**修正(2026-11-01,collation)**:首版用会话临时表装源数据,执行报 +`1267 - Illegal mix of collations (utf8mb4_general_ci,IMPLICIT) and (utf8mb4_0900_ai_ci,IMPLICIT)`—— +临时表按 `default charset=utf8mb4` 建表时继承了连接默认排序规则,与业务表的 `utf8mb4_general_ci` 在 `=` 比较时冲突。 +已改为**不使用临时表**,源数据内联为 `union all` 派生表,并在所有「派生列 vs 业务列」比较处显式 `collate utf8mb4_general_ci` +(含 `lock_room` 反连接里的 `concat(...)`)。首版失败发生在第 1 条插入语句,临时表为会话级、连接断开即销毁,**未留下任何数据**;脚本可直接重跑。 + +--- + +### 4.30 工作台首页改版:设备设施与运营统计(2026-11-01,代码就绪) + +**入口**:`/index`(`views/index.vue`),原若依介绍页整页替换;近 7 天 / 30 天区间切换。 + +**后端接口**:`GET /lock/dashboard/overview?days=7|30`(登录即可访问,只返回聚合数字,不含人员姓名、凭证、报文) + +| 文件 | 说明 | +|---|---| +| `lock/controller/LockDashboardController.java`(新增) | 首页聚合接口,`days` 默认 7、上限 90 | +| `lock/service/impl/LockDashboardServiceImpl.java`(新增) | 缺省日期补 0、开门方式编码转中文、分组排行取前 8 | +| `lock/mapper/LockDashboardMapper.java` + `resources/mapper/lock/LockDashboardMapper.xml`(新增) | 10 条聚合查询:门锁/网关/房间/入住/今日开门 + 开门趋势/入住趋势/开门方式/分组排行 | +| `src/api/iot/dashboard.js`(新增) | 前端调用封装 | +| `src/views/index.vue`(重写) | 4 张 KPI + 8 项概览条 + 5 张 ECharts(开门趋势、开门方式环图、入住趋势、房态环图、分组排行) | + +**统计口径**: + +| 指标 | 来源 | +|---|---| +| 门锁在线 / 停用 / 未绑定 | `lock_device.online_status`(0在线 1离线 2未知)、`status`、`gateway_id is null` | +| 网关在线 | `lock_gateway.online_status` | +| 房间在住 / 入住率 | `lock_room.room_status='1'` ÷ 房间总数(0空闲 1在住 2维修 3停用) | +| 今日开门 / 成功率 | `lock_access_log.open_time` ∈ `[curdate, curdate+1)`,`open_result` 1成功 0失败 | +| 入住 / 退房趋势 | `lock_stay.checkin_time` / `checkout_time` 按天计数(换房也计入退房) | +| 开门方式分布 | `open_type` 按 `lock_open_type` 字典文案聚合,未知编码归「其它」 | +| 分组排行 | 按 `group_id` 关联 `lock_group.group_name`,关联不到归「未分组」 | + +**权限**:首页对所有登录用户可见,故接口不挂菜单权限;返回内容全部为聚合计数,避免低权限账号借首页取数。 + +**依赖**:`lock_room` / `lock_stay` 需先执行 D19;开门曲线依赖 `lock_access_log`(`lock.sql`)。 + +**验证**:后端 `BUILD SUCCESS`;Mapper XML 的 10 个 select 与接口方法一一对应;`index.vue` 模板编译 0 error 0 tip,脚本解析、SCSS 编译通过;桩渲染校验 4 张 KPI、8 项概览、5 张图表配置与空数据分支;`npm run build:prod` 构建成功(仅 echarts 等既有体积告警)。 +**未启动服务、未连库实测**(本机无数据库),页面需在有数据的库上开箱验证一次。 + +--- + +### 4.31 授权下发密码:移除 BA 前缀限制(2026-11-01,代码就绪) + +**现象**:授权页选“密码/指纹/IC卡”时,人员编号必须手填 `BA` 开头的 8 位字符串,而人员主数据自动生成的是 `AA + 类型 + 5 位序号`(如 `AA000001`),导致页面上无法为默认人员下发密码。 + +**根因**:BA 规则只存在于前端 `views/iot/auth/index.vue` 的 `PERSON_NO_PATTERN`,后端 `fillPerson()` 只校验人员在 `lock_person` 中存在、不限前缀;厂商协议把密码帧 `params[1]` 定义为“用户标识(手机号/自定义,变长)”,并无 BA 约定;`lock_auth` 唯一键为 `(person_no, device_id, auth_type)`,本可一人多凭据。按 BA 提示另建人员会把同一个人拆成两份人员档案,人脸、住宿、授权等按人员维度的数据随之割裂。 + +**改动**: + +| 文件 | 说明 | +|---|---| +| `src/views/iot/auth/index.vue` | 删除 `PERSON_NO_PATTERN` 与 BA 校验;密码/指纹/IC卡人员改为从人员主数据下拉选择(`/lock/person/optionselect`),查询筛选同步改用全量人员并与人脸库去重;人脸授权仍取人脸库(提取特征需要 `faceId`) | +| `src/views/iot/auth/index.vue` | 编辑已有非人脸授权时授权内容可留空(“留空表示不修改原授权内容”),仅调整有效期等不再强制重填密码 | +| `lock/service/impl/LockAuthServiceImpl.java` | `updateAuth` 在授权内容为空且授权类型未变时回读库中原值再重推,避免只改有效期被判定“授权内容为空”而置 `sync_status=3` | +| `openapi/domain/OpenAuthRequest.java` | 修正“密码授权 BA 开头编号”的过期注释 | + +**权限前置**:授权页相关角色需勾选 `lock:person:option`(菜单 ID 2106,`sql/upgrade_d8_lock_person.sql` 已建),否则人员下拉 403。 + +**影响与兼容**:新下发密码的 `person_no` 统一为 `AA` 编号,人员主数据不再因授权类型产生重复档案,人脸、住宿、授权共用同一身份;已有的 `BA` 人员/授权不删除、仍可正常查询与解绑,不做数据迁移。**注意**:密码开门上报的 `openData` 是密码本身而非人员编号(`AccessRecordHandler.fillCredential` 对密码类型直接跳过、不落凭证),因此密码开门记录的 `person_no` 始终为空、无法按人关联;这是设备协议限制,与本次改动无关,如需按人统计需设备侧上报用户编号。 + +**待实测**:`AA` 编号作为 `password` 帧 `params[1]` 门锁是否接受;同一人在同一把锁上同时存在人脸与密码时是否互不覆盖;密码长度/字符要求是否与页面 32 位上限一致。 + +**验证**:后端离线编译 `BUILD SUCCESS`;`auth/index.vue` 模板编译 0 error 0 tip,脚本解析通过,无残留 BA/旧方法引用;`npm run build:prod` 构建成功(仅既有体积告警)。**未启动服务、未连库、未下发实测**,门锁侧接受度需按上面三项在实机验证。 + +--- + +### 4.32 第三方平台外链跳转集成可行性分析(2026-11-01,仅分析稿) + +**需求**:第三方应用平台(同为 RuoYi 框架)通过外链跳到本系统门禁页面,跳转时默认携带 appkey 与用户身份,在外部系统侧呈现为完整方案。 + +**结论**: + +1. 「外链跳转 + 免登录」目标可行;「URL 明文带 appkey + 用户身份」不可行、不可接受。 +2. 断点在于本系统有两条互不相通的鉴权通道:页面只认 `Authorization: Bearer `(`JwtAuthenticationTokenFilter` / `TokenService`),`X-API-Key` 只覆盖 `/openapi/iot/**` 且只标识应用(`OpenApiAuthFilter`),不产生 `LoginUser`;前端路由守卫只读 `Admin-Token` Cookie,无令牌直接跳登录页。 +3. 推荐**一次性授权码换会话**:外部平台后端用 `appCode + ssoSecret` 换 60 秒一次性 `code`,浏览器只带 `code` 跳转,本系统换 RuoYi 令牌;appkey、密钥、用户身份均不进地址栏。 +4. 若需求本质只是「外部系统能办门禁业务」,则无需 SSO,沿用已就绪的开放接口(入住/退房/续住、授权/人脸下发、开锁记录查询、回调、D21 应用管理)成本最低。 + +**产出**:[第三方平台外链跳转集成分析](第三方平台外链跳转集成分析.md)(含现状证据、时序、接口契约、身份映射、安全清单、6 种方案对比、8 个待确认问题、风险表)。 + +**状态**:未改代码、未建表、未执行脚本、未联调。待 Q1–Q4(appkey 定义、身份口径、目标页面、外部后端能否服务端调用)确认后再决定是否立项。 + +--- + +### 4.33 住宿管理:外部单据显示外部系统名称并支持筛选(2026-11-01,代码就绪) + +**需求**:住宿列表对接口办理的单据,不再统一显示“外部系统”,改为展示实际应用名称;查询条件支持按外部系统过滤。 + +**实现**: + +| 层 | 文件 | 说明 | +|---|---|---| +| 领域 | `lock/domain/LockStay.java` | 新增非持久化字段 `bizSystemName`(应用已删除时为空,前端回退编码) | +| 数据访问 | `resources/mapper/lock/LockStayMapper.xml` | `selectStayList` 增加 `bizSystem` 精确过滤(按 `lock_stay.biz_system`) | +| 服务 | `lock/service/impl/LockStayServiceImpl.java` | `selectStayList` 结果按 `OpenAppRegistry.getApp()` 回填 `bizSystemName`;列表与导出共用 | +| 服务 | `ILockOpenAppService` / `LockOpenAppServiceImpl` | 新增 `selectAppOptions()`:只取 `app_code/app_name/status`,含停用应用,不带 `api_key`、`callback_secret` | +| 接口 | `LockStayController` | 新增 `GET /lock/stay/app-options`,权限 `lock:stay:list`,返回脱敏下拉选项 | +| 前端 | `src/api/iot/stay.js`、`views/iot/stay/index.vue` | 新增“外部系统”筛选下拉(停用应用标注“(停用)”);“办理平台”列外部单据显示 `bizSystemName || bizSystem || 外部系统` | + +**兼容性**:无 DDL、无脚本;平台手工单名称字段为空,仍显示“本平台”;应用名称回填走内存注册表(30 秒 TTL),管理页改后刷新即生效;按应用删除受限(有入住数据的应用只能停用),历史单名称不受影响。 + +**验证**:后端离线编译 `BUILD SUCCESS`;`stay/index.vue` 模板编译 0 error、脚本与 `api/iot/stay.js` 解析通过;Mapper XML 结构校验通过。**未启动服务、未连库**,下拉联动与列展示需在页面实测。 + +--- + +### 4.34 登录页美化:铁路站房主题背景与品牌区(2026-11-01,代码就绪) + +**需求**:结合智能门禁系统与铁路公寓、站房使用场景美化登录页,背景图更换为铁路站房 + 门禁主题(图片可生成)。 + +**实现**: + +| 文件 | 说明 | +|---|---| +| `src/assets/images/login-railway-bg.jpg` | 生成并裁掉右下角水印的站房夜景背景(1792×972,约 344 KB),画面含站房雨棚、站台、列车与门禁识别终端 | +| `src/views/login.vue` | 左侧品牌区(系统名、场景标题、三条能力点)+ 右侧白色登录卡片;深色渐变遮罩保证文字可读;表单字段、验证码、记住密码、登录逻辑保持不变,新增密码可见切换 | +| `src/views/login.vue` | 响应式:≤1180px 收窄间距与卡片,≤960px 隐藏品牌区并居中卡片,≤480px 收紧内边距;补充 `prefers-reduced-motion` 时关闭入场动画 | + +**视觉与验证**:用无头 Edge 按 1600×900、1280×800 与 CDP 设备模拟 420×860 三种视口渲染核对,布局、遮罩、表单与页脚均正常,420px 下卡片左右各留 18px;`npm run build:prod` 构建成功。**未连接后端**,验证码图与真实登录未联调。 + +**备注**:备选背景(铁路公寓入口 + 人脸终端)保留在 `vibe_images/railway-apartment-access-bg_*.png`,如需换图替换 `login-railway-bg.jpg` 即可。 + +--- + +### 4.35 对外接口入住人员:退房同步清理,再次入住重新建档(2026-11-01,代码就绪) + +**需求**:外部系统推送入住单时平台自动建立的人员,退房后同步删除(人员、人脸照片、各版本特征);外部系统每次推送入住都必须重新传照片,未传视为参数不合法不予受理,通过后重新建档,对方无需感知平台内部的档案生命周期。 + +**清理规则(退房 / 到期自动退房触发)**: + +| 条件 | 说明 | +|---|---| +| 单据带业务系统 | `lock_stay.biz_system` 非空,即对外接口办理的单(换房后的新单同样带 biz_system) | +| 自动建档人员 | `lock_person.create_by` 以 `openapi` 开头,人工建档人员一律不动 | +| 未绑定平台账号 | `sys_user_id` 为空,避免删掉登录账号关联的人员 | +| 无其它未结束单 | 除本单外无 `stay_status in ('0','1')` 的单(一房多人 / 一人多房都保留) | +| 无其它有效授权 | 除本单授权外无 `sync_status != '4'` 的授权;本单刚发起清除、回执未到,不计入 | + +**实现**: + +| 层 | 文件 | 说明 | +|---|---|---| +| 常量 | `lock/LockConstants.java` | 新增 `CREATE_BY_OPENAPI`,统一开放接口建档的 `create_by` 前缀 | +| 服务 | `lock/service/impl/LockStayServiceImpl.java` | `checkout` 改为事务模板执行,事务提交后调用 `cleanupExternalPerson`(清理失败只记日志,不回滚退房);`expireStays` 到期单同样清理 | +| 服务 | `ILockPersonService` / `LockPersonServiceImpl` | 新增 `deletePersonRecord(personId)`:直接物理删除人员,跳过人脸/授权引用校验(人工删除仍走原 `deletePersonByIds`,同样物理删除) | +| 服务 | `ILockFaceService` / `LockFaceServiceImpl` | 新增 `deleteLocalPhotoFile(photoUrl)`:只删 `/profile` 下平台自有照片(`normalize` + `startsWith(profile)` 防路径穿越);同一照片仍被其它人脸引用时只删记录不删文件;外部 http(s) 照片不删 | +| 服务 | `LockFaceServiceImpl.insertFace` | `lock_face` 唯一键(`uk_lock_face_person_id` / `uk_lock_face_person_no`)**不含 `del_flag`**,存量软删行会挡住重新建档:插入前按 `person_id`/`person_no` 查历史行(含已逻辑删除),命中则 `reviveFace` 重置为在用并清空上次提取结果,不再抛 `Duplicate entry`。新删除均已改为物理删除,此逻辑只用于兼容存量数据 | +| 数据访问 | `LockFaceMapper` | 新增 `selectReusableFace(含软删)`、`reviveFace`;`countFaceByPhotoUrl` 供照片文件删除前判重 | +| 数据访问 | `LockStayMapper` / `LockAuthMapper` | 新增 `countActiveStaysByPersonNo(排除本单)`、`countActiveAuthsByPersonNo(排除本单授权)` | +| 数据访问 | `LockPersonRefMapper` | 新增 `rebindPerson`:带旧人员编号做条件更新,映射改指新档案 | +| 服务 | `openapi/OpenStayServiceImpl` | 映射存在但人员已被清理时重新建档并改指映射;`checkin` 在幂等判断之后强校验 `photoUrl`/`photoBase64` 至少一个非空,缺参 40001 且不建档;照片变化即重新提取特征 | + +**删除范围**:本地照片文件(`/profile` 下,先删文件再删记录,否则再也定位不到) + `lock_face` 物理删除 + `lock_face_feature` 物理删除 + `lock_person` 物理删除(2026-11-01 按业务方要求,人脸/特征/人员一律不保留逻辑删除行)。**`lock_person_ref` 保留**:退房回调可能在设备回执后才生成报文,仍需按旧 `person_no` 解析 `externalPersonId`;下次入住时再改指新档案。 + +**兼容性与已知窗口**: + +- 无 DDL、无新脚本;`selectExpiredStays` 补选 `person_id/biz_system`,到期单按同一规则清理。 +- **对外契约变化**:入住接口由「复住可不传照片」改为「每次必须传 `photoUrl` 或 `photoBase64`」,缺参 40001;对接方需在每次入住时携带照片(已同步设计稿 §2.4/§4.5/§12.1 与 Apifox 用例 C05/C22)。 +- **2026-11-01 线上 50002 复盘**:`Duplicate entry '6' for key 'lock_face.uk_lock_face_person_id'`。原因是两个唯一键都不含 `del_flag`,而清理/人工删人脸是逻辑删,重新建档 insert 撞上历史行;已由 `insertFace` 复用历史行(`reviveFace`)修复,并按业务方要求把人脸/特征/人员删除统一改为**物理删除**,后续不再产生占用唯一键的历史行。失败请求已提交的人员与映射会保留,重启后重试同一请求即可继续(会复用历史人脸行),无需手工清数据。 +- 换房(旧单置 `stay_status=3`)不触发清理,人员随新单继续使用。 +- 若外部系统在退房后长时间未再入住,旧单仍可在住宿详情/回调中看到 `externalPersonId`;重新入住改指映射后,旧单的外部标识不再可解析(回调若恰在此后生成,报文缺 `externalPersonId`)。 +- 同 `bizNo` 的重复入住请求在人员已重建后会按「人员不一致」返回 `BIZ_NO_CONFLICT`(旧单重复提交场景,属已知行为)。 +- 本单授权不计入「其它有效授权」,清除回执未到或指令未发出(路由缺失)都不阻塞清理;此时 `lock_auth` 行仍保留人员编号与姓名,可在授权管理页人工重发删除。 + +**验证**:后端离线编译 `BUILD SUCCESS`;4 个 Mapper XML 结构校验通过。**未启动服务、未连库、未实机联调**,退房清理与再次入住需在测试环境按「入住 → 退房 → 再入住」实测。 + +--- + +## 5. 上一迭代:D17 一锁多网关(待上线) + +### 5.1 交付内容 + +- [x] 归属事实源 `lock_device_gateway`(`unique(device_id, gateway_id)`,逐网关记录信号/在线/绑定状态) +- [x] `PrimaryGatewaySelector` 实现 R1–R4,并列时按 `last_signal_time` → `rel_id` 稳定兜底 +- [x] `RouteResolver` 每轮下发实时选路,回写 `lock_device.gateway_id/group_id` 缓存 +- [x] 绑定/解绑改为网关粒度;新增 `GET /lock/device/{deviceId}/gateways` +- [x] 上行报文按信封 `devId` 反查上报网关(`ReportGatewayResolver`);`lockrssi` 缺关系时按上报自动登记(2026-10-31 随 D18 调整,原为不建关系) +- [x] `locklist` 回执统一刷新该网关待下发关系的 `bind_status`(字典 `lock_bind_status`) +- [x] 网关/门锁离线分别向下传播到关系行 +- [x] 存量升级脚本 `upgrade_d17_device_gateway.sql`(幂等) +- [x] 架构文档 §3.2.2/§9/§12、迁移指引 A/B/C 路径同步更新 + +### 5.2 关键文件 + +新增: + +| 文件 | 说明 | +|---|---| +| `sql/upgrade_d17_device_gateway.sql` | 建表 + 回填 + 校正缓存 + 字典 | +| `lock/domain/LockDeviceGateway.java` | 归属关系实体 | +| `lock/mapper/LockDeviceGatewayMapper.java` + `resources/mapper/lock/LockDeviceGatewayMapper.xml` | 关系 CRUD、信号/在线/绑定状态刷新、离线传播 | +| `lock/route/PrimaryGatewaySelector.java` | R1–R4 纯内存判选 | +| `lock/mqtt/handler/ReportGatewayResolver.java` | 上报网关识别 | + +主要修改:`RouteResolver`、`LockDeviceServiceImpl`、`LockGatewayServiceImpl`、`LockCommandServiceImpl`、`LockDeviceController`、`LockSignalHandler`、`AccessRecordHandler`、`LockConstants`、`lock.sql`、`LockDeviceMapper.xml`、`LockGatewayMapper.xml`、`LockGroupMapper.xml`。 + +### 5.3 验证结果(2026-10-31) + +| 项 | 结果 | +|---|---| +| 全工程编译(8 模块) | ✅ `BUILD SUCCESS` | +| 选路桩程序(R1–R4、信号并列、非法信号、全离线降级、顺序无关) | ✅ 18/18 通过,桩文件已清理 | +| 数据库脚本执行 | ⬜ **未执行**(未连接任何库) | +| MQTT 实机联调 | ⬜ **未进行** | + +### 5.4 上线步骤 + +- [ ] 代码评审(重点:选路回写缓存、绑定回执的目标范围、上行归属不建关系) +- [ ] 备份 `lock_device` / `lock_gateway` / 字典表 +- [ ] 测试库执行 `upgrade_d17_device_gateway.sql`,按迁移指引 3.5 核对 +- [ ] 与 D17 代码**同批次**发布(关系表为空会导致所有下发被拒) +- [ ] 按迁移指引 3.7 完成 12 项冒烟(多网关绑定/解绑、跨分区选路、全离线降级) +- [ ] 生产灰度后再全量 + +### 5.5 遗留与后续 + +- [ ] **网关 `locklist` 对账反查**:`lockrssi` 上报已能自动补建关系行(D18),仍缺网关上线时主动查询网关 `locklist`(`getslaveinfo`)做双向对账 +- [ ] **前端页面**:网关侧门锁管理页已补齐(D18,见 §4.7);门锁详情页的"所属网关列表/按网关解绑"仍待实现 +- [x] **人员管理页**:`src/views/iot/person/index.vue` + `src/api/iot/person.js`(2026-10-31 补齐,对应菜单 2100) +- [ ] **监控指标**:多网关选路切换次数、全离线降级次数、`bind_status=2` 数量尚未埋点统计 + +--- + +## 6. 决策记录 + +| 日期 | 决策 | 影响 | +|---|---|---| +| 2026-10-31 | 凭证按明文落库,D10 加密改造暂缓 | `lock_credential` 加密迁移(v2 A5/A6)继续阻塞;展示脱敏、日志掩码、接口鉴权、库权限控制仍必须保留 | +| 2026-10-31 | v1 保留 `lock_device.gateway_id/group_id` 作选路缓存,不跟随 v2 改名 | 旧代码/旧页面兼容;v2 迁移只需改名 `primary_gateway_id` 并删 `group_id` | +| 2026-10-31 | 分组级上行一律按信封 `devId` 反查网关,不把 groupCode 当网关 | 识别不到网关时只刷新门锁聚合值,不自动建关系,避免脏归属(D18 起 `lockrssi` 例外,见下条) | +| 2026-10-31 | 网关门锁列表由 `lockrssi` 上报自动登记(D18) | 网关能上报即视为已持有该锁,关系行直接记 `bind_status=1`,陌生SN 补建占位门锁;识别不到上报网关时仍不建关系;清除/移除后若网关仍上报会再次登记,回执后稳定 | +| 2026-10-31 | `locklist(bind)` 回执按整个网关刷新待下发关系 | 协议为整网关批量命令,无法逐锁回执;`bind_status=1` 回填依赖"旧锁已实际在网关"的假设 | +| 2026-10-31 | `locklist(add)` 按整表覆盖处理,且网关回执后重启才生效 | 分配/移除一律按归属表重算全量后单发 `add`,不再先发 `clear`;网关现有列表会被本次清单替换(平台未登记的锁一并清掉);重启窗口内的后续下发失败交给重试,不重复下发 | +| 2026-10-31 | 人脸特征以 `lock_face_feature` 为事实源,`lock_face.feature_*` 降级为“最近一次成功提取”的镜像 | 列表/详情/开放接口沿用旧字段不破坏兼容;下发只认子表,v2 统一迁到 `lock_biometric` | +| 2026-10-31 | 选不到与门锁版本匹配的特征时阻断下发,不回退到任意版本 | 避免把错误算法特征写入门锁;失败原因写入 `sync_msg` 并提示所需版本;旧字段仅在版本一致时兜底 | +| 2026-11-01 | 回调事件在业务操作时登记,命令回执只回填结果,不在回执侧推断业务语义 | `0011` 同时承载入住/续住/补偿,回执无法区分;新增事件类型只需在操作侧登记,不改回执逻辑 | +| 2026-11-01 | 24h 未收到设备回执的待结果任务按“失败 + 超时原因”投递,不转死信 | 死信只意味平台发不出去;超时属业务结果,必须让对方知道;迟到成功回执会以 `revision+1` 再补一条 | +| 2026-11-01 | 开锁记录按「当前绑定 ∪ 历史重叠 ∪ 入住单快照」取门锁并集,房间无锁返回空列表而非 40004 | 换锁后不漏查;查询对空数据保持确定性,避免对方按入住类错误码处理 | + +--- + +## 7. 风险与关注 + +| 风险 | 说明 | 缓解 | +|---|---|---| +| 升级顺序错误(D17) | 先发代码后跑脚本 → 所有下发提示"门锁未登记到网关" | 脚本与代码同批次,发布前核对关系表行数 | +| 回填假设偏差(D17) | 存量关系 `bind_status` 直接置 1 | 不确定时统一改 0 并重下发 `locklist` | +| 全离线降级(D17) | R3 仍会尝试下发,可能持续失败 | 依赖命令重试与超时策略;监控降级次数 | +| 多网关归属膨胀(D17) | 手工插关系但未下发 `locklist(add)`,网关侧无此锁 | 绑定只走接口;对账任务后续补齐 | +| 多版本特征缺失(D9) | 存量回填只覆盖 `lock_face` 当前版本,切过算法的门锁仍缺目标版本 | 上线后按门锁版本批量 `batchExtract`;下发失败提示具体版本 | +| 镜像字段歧义(D9) | `lock_face.feature_algo_version` 是最近成功提取版本,不等于门锁版本 | 前端标注"最近提取版本";以 `/lock/face/{faceId}/features` 为准 | +| 版本误变更(D9) | 全量编辑门锁传空/旧值会触发重提取标记 | 仅在值真实变化时触发;心跳/信号上报用部分实体;前端提交需带原值 | +| 凭证明文 | D10 暂缓带来的合规风险 | 输出脱敏、掩码、最小权限,待加密方案排期 | +| 回调缺位(D20 P1) | P1 阶段平台不主动上报下发结果,对方可能把「受理成功」当成「门锁已生效」 | P2 已补回调(代码就绪、待联调);P2 上线前仍以 `authStatus` 与重放同 `bizNo` 的幂等结果为准 | +| `photoUrl` SSRF(D20 P1) | 平台会按调用方给的地址取图,未做域名白名单与内网地址拦截 | 上线前由网络侧限制出站;P4 补域名白名单、超时与大小限制 | +| 多应用 Key 串号(D20 P1) | 两个应用配同一 Key 会共用 `appCode`,数据隔离失效 | `OpenApiProperties.validate()` 启动即校验 app-code/api-key 唯一,重复直接启动失败 | +| `bizNo` 被已删除单占用(D20 P1) | 唯一键不含 `del_flag`,删单后同号无法复用 | 查询不过滤 `del_flag`,撞号返回 40007 并提示换号,不静默改键 | +| 缺表回滚(D20 P2) | P2 代码在入住/退房/续住事务内写 `lock_callback_task`,脚本未执行时接口单会整体回滚 | 脚本与代码同批次发布,发布前 `show tables like 'lock_callback_task'` 核对 | +| 回调静默失败(D20 P2) | 登记/投递异常只写 `warn` 日志,业务侧无感;死信无告警 | 上线后每日看回调任务页与日终死信数,后续补「回调可观测」指标与告警 | +| 密钥明文(D20 P2) | `callback-secret` 明文写在 `application.yml` | 与 D10 同口径;限制配置文件读取角色,P4 一并纳入加密方案 | +| 重复消费(D20 P2) | 网络重试或对方超时重发会让同一事件到达多次 | 契约要求对方按 `X-Callback-Id` 去重、按 `revision` 取最新;平台侧保证同事件同结果不新增 revision | +| 历史门锁漏查(D20 P3) | 开锁记录按「当前绑定 ∪ 历史 ∪ 入住单快照」取并集,历史表未回填或换锁未走 `bindDevice` 时只有入住单兜底 | P1 脚本回填后核对条数;换锁必须走房间绑定接口,直接改库不会写历史 | +| 查询无压测(D20 P3) | 单房间 31 天为上限,但并集 + 分页未做大数据量实测 | 上线后观察慢日志;必要时给 `lock_access_log(device_sn, open_time)` 建联合索引 | + +--- + +## 8. 下阶段建议 + +建议下一迭代(N+1)范围,按依赖顺序排列: + +| 优先级 | 事项 | 计划内容 | 依赖 / 前置 | 完成标准 | +|---|---|---|---|---| +| P0 | D17 上线 | 备份 → 测试库跑 `upgrade_d17_device_gateway.sql` → 同批次发布 → 12 项冒烟 | 代码评审通过 | 多网关绑定/解绑、跨分区选路、全离线降级均通过 | +| P0 | D9 上线 | 备份 → 测试库跑 `upgrade_d9_face_feature.sql` → 同批次发布 → 补提多版本特征 → 12 项冒烟 | D17 上线(同库顺序:D17 → D9) | 同一人员在不同算法锁上均能下发;缺版本时阻断并提示 | +| P1 | D2 收口 | 主题前缀/固定段抽成协议档案,支持换厂家 | — | 改配置即可切换前缀,无需改代码 | +| P1 | D4 收口 | 下行 `devVer` 按协议版本显式传递 | D2 | V2 报文不再默认写死 `"1"` | +| P1 | 多网关对账 | 网关上线/每日任务按 `locklist` 反查补建缺失归属 | D17 上线 | 对账差异可告警、可一键重下发 | +| P1 | D19 + D20 P1 + P2 + P3 上线联调 | 依次跑 `upgrade_d19_stay.sql` → `upgrade_d20_openapi_stay.sql` → `upgrade_d20_p2_callback.sql` → 配 `iot.openapi.apps[]` 两个应用 Key 与 `callback-url`/`callback-secret` → 按设计稿 §12 用例联调 | 代码评审通过;同库顺序 D8 → D13 → D17 → D9 → D19 → D20(P1) → D20(P2);P3 无脚本 | 平台侧入住/退房/换房/续住通过;两个应用各自入住退房成功且数据互不可见;结果回调按应用分流、断网可重试、死信可重发;换锁前后的开锁记录都能查到 | +| P2 | D20 P4 安全加固 | IP 白名单、按应用限流、`photoUrl` 域名白名单(防 SSRF)、接口侧 HMAC 签名 | — | 压测与安全测试通过 | +| P2 | 回调可观测 | 回调成功率/重试次数/死信数埋点,死信超阈值告警 | P2 上线 | 死信不再依赖人工巡页 | +| P2 | D15 离线判定 | 解析 `onoff_line` 报文体、启用 LWT | — | 网关/门锁离线可即时感知 | +| — | v2 迁移 A5/A6 | 凭证加密迁移 + 授权/特征拆表(含 D9 规则 4) | D10 解禁 | 按迁移指引路径 C 执行 | + +说明: + +- D17 与 D9 均已完成代码与脚本,当前最高优先级是把两项**同批次上线并完成联调**;脚本顺序 D8 → D13 → D17 → D9。 +- v2 迁移继续以 [数据库迁移指引](数据库迁移指引.md) 路径 C 为准,A5 仍被凭证加密阻塞;D10 待业务方给出加密与迁移排期后再启动。 +- D19 住宿与 D20 对外接口的脚本均未执行;D20 依赖 D19 的 `lock_room`/`lock_stay`,必须**先 D19 后 D20**,且两者都要求门锁已维护 `face_algo_version`(缺版本时入住接口直接阻断,返回 50001)。 +- D19/D20 升级脚本是**顺序依赖**:`upgrade_d19_stay.sql` → `upgrade_d20_openapi_stay.sql`(P1)→ `upgrade_d20_p2_callback.sql`(P2)。P2 脚本可以晚于 P1 代码上线(缺表只影响接口单,平台手工入住不受影响),但**P2 代码与 P2 脚本必须同批次**。 +- D20 P3 开锁记录查询**没有新增脚本**,依赖 P1 的 `lock_room_device_history` 与回填结果;P1 未执行完时接口能查到当前绑定门锁,但查不到换锁前的记录。