fix(lock): 外部入住人员退房清理改为物理删除并强制每次入住传照片
- 退房/到期清理只针对外部接口自动建档人员(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
This commit is contained in:
1 parent
271e4ca77c
commit
aae952ec13
19 files changed
+6160
No files matched your search
@@ -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";
|
||||
|
||||
/**
|
||||
* 同步状态:待清除(清除指令失败,等待补偿重发删除)。
|
||||
* <p>
|
||||
* 不能与 {@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 前缀。
|
||||
* <p>
|
||||
* 退房清理只针对以此为前缀的人员,人工建档人员无论是否办理过外部单都不动。
|
||||
*/
|
||||
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";
|
||||
|
||||
/**
|
||||
* 设备响应结果码:网关已接收并转发(中间态)
|
||||
* <p>
|
||||
* 门锁类下发会收到两次响应:网关先回执(本码),门锁执行完再回最终结果码。
|
||||
* 中间态只留痕,不能据此判定命令完成,否则会覆盖门锁的真实结果。
|
||||
*/
|
||||
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()
|
||||
{
|
||||
}
|
||||
}
|
||||
+70
@@ -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<LockAuth> selectAuthList(LockAuth auth);
|
||||
|
||||
LockAuth selectAuthById(Long authId);
|
||||
|
||||
List<LockAuth> selectAuthsByDeviceId(Long deviceId);
|
||||
|
||||
List<LockAuth> selectAuthsByPersonNo(String personNo);
|
||||
|
||||
/** 人员未撤销的授权数,删除人员前的引用校验 */
|
||||
int countAuthsByPersonNo(String personNo);
|
||||
|
||||
/**
|
||||
* 人员除指定授权外未清除的授权数。
|
||||
* <p>
|
||||
* 已清除(4)的授权不再有设备侧权限,不计入;下发失败(3)与清除失败(6)都保留,
|
||||
* 前者可能仍能补偿重发,后者说明人脸还在设备上。
|
||||
*/
|
||||
int countActiveAuthsByPersonNo(@Param("personNo") String personNo, @Param("excludeAuthId") Long excludeAuthId);
|
||||
|
||||
/** 按分组查询待下发或下发失败的授权,用于补偿下发 */
|
||||
List<LockAuth> selectPendingAuths(@Param("groupId") Long groupId);
|
||||
|
||||
/** 待清除授权(清除指令失败),补偿必须重发删除而不是人脸下发 */
|
||||
List<LockAuth> selectPendingClearAuths();
|
||||
|
||||
/** 人员人脸授权涉及的门锁算法版本集合(去重),批量提取用 */
|
||||
List<String> selectFaceAlgoVersionsByPersonNo(String personNo);
|
||||
|
||||
/** 指定版本下待下发/下发失败的人脸授权,特征就绪后自动补发 */
|
||||
List<LockAuth> selectFaceAuthsForRepush(@Param("personNo") String personNo,
|
||||
@Param("algoVersion") String algoVersion);
|
||||
|
||||
int insertAuth(LockAuth auth);
|
||||
|
||||
/**
|
||||
* 按唯一键 (person_no, device_id, auth_type) 查授权。
|
||||
* <p>
|
||||
* 唯一键不含 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);
|
||||
}
|
||||
+40
@@ -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<LockFace> 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);
|
||||
}
|
||||
+31
@@ -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);
|
||||
|
||||
/**
|
||||
* 把映射改指到新人员(外部人员退房清理后重新入住)。
|
||||
* <p>
|
||||
* 带旧人员编号做条件更新:并发下只有先改成功的请求生效,另一方回查映射后
|
||||
* 改用现有人员,与首次建档撞键的处理保持一致。
|
||||
*/
|
||||
int rebindPerson(@Param("refId") Long refId, @Param("oldPersonNo") String oldPersonNo,
|
||||
@Param("personId") Long personId, @Param("personNo") String personNo);
|
||||
}
|
||||
+74
@@ -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<LockStay> selectStayList(LockStay stay);
|
||||
|
||||
LockStay selectStayById(Long stayId);
|
||||
|
||||
/** 某人在住订单(一人多房,返回列表) */
|
||||
List<LockStay> selectActiveStaysByPersonNo(String personNo);
|
||||
|
||||
/** 某人未结束的入住单数(退房清理人员前排除当前单的占用校验) */
|
||||
int countActiveStaysByPersonNo(@Param("personNo") String personNo, @Param("excludeStayId") Long excludeStayId);
|
||||
|
||||
/** 房间入住历史 */
|
||||
List<LockStay> selectStaysByRoomId(Long roomId);
|
||||
|
||||
/** 房间当前在住单(一房可多人),按入住先后倒序 */
|
||||
List<LockStay> 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<LockStay> 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);
|
||||
}
|
||||
+66
@@ -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<LockFace> selectFaceList(LockFace face);
|
||||
|
||||
LockFace selectFaceById(Long faceId);
|
||||
|
||||
LockFace selectFaceByPersonNo(String personNo);
|
||||
|
||||
/**
|
||||
* 删除平台本地保存的人脸照片文件。
|
||||
* <p>
|
||||
* 只处理 {@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<LockFaceFeature> 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);
|
||||
}
|
||||
+460
@@ -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<LockFace> 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<LockFaceFeature> 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按指定算法版本提取并落库
|
||||
* <p>
|
||||
* 同一张人脸的不同版本各占 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取失败落库
|
||||
* <p>
|
||||
* 该版本已有可用特征时不降级:重复提取失败不应让原本可下发的特征失效,
|
||||
* 只把本次失败原因写到 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<LockAuth> 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
|
||||
* <p>
|
||||
* 该参数在每把智能门锁上维护(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<LockAuth> 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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取人脸图片内容
|
||||
* <p>
|
||||
* 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<byte[]> 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<String> 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<String> 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<String> 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);
|
||||
}
|
||||
}
|
||||
+831
@@ -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;
|
||||
|
||||
/**
|
||||
* 入住单服务实现
|
||||
* <p>
|
||||
* 入住 / 退房都翻译成既有授权操作:
|
||||
* <ul>
|
||||
* <li>入住:lock_auth 新增(人脸,有效期=入住起止)并下发;</li>
|
||||
* <li>退房:锁定人员删除(facefeature delete,按人员+本房门锁),平台授权置已清除。</li>
|
||||
* </ul>
|
||||
* 人员建档与特征提取在入住事务之外执行,提取失败只回滚入住,不回滚已建的人员/人脸。
|
||||
*
|
||||
* @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<LockStay> selectStayList(LockStay stay)
|
||||
{
|
||||
List<LockStay> 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<LockStay> selectActiveStaysByPersonNo(String personNo)
|
||||
{
|
||||
return stayMapper.selectActiveStaysByPersonNo(StringUtils.trim(personNo));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<LockStay> 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<LockAccessLog> 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<LockStay> 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 退房(含到期自动退房)清理外部接口自动建档的人员:本地照片文件、人脸、各版本特征、人员档案
|
||||
* 一并删除,外部标识映射保留,下次入住时重新建档并改指。
|
||||
* <p>
|
||||
* 只处理开放接口建档(create_by=openapi:*)、未绑定平台账号,且名下已无其它未结束入住单
|
||||
* 与未清除授权的人员;任一守卫不满足都保留档案,避免误删仍要下发或仍在设备上生效的授权。
|
||||
* 映射不删的原因:退房回调可能在设备回执后才生成报文,仍需按旧 person_no 解析 externalPersonId。
|
||||
* <p>
|
||||
* 在退房事务提交后独立执行,失败只记日志,不影响已完成的退房。
|
||||
*/
|
||||
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<LockStay> 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();
|
||||
}
|
||||
}
|
||||
+770
@@ -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;
|
||||
|
||||
/**
|
||||
* 开放接口-住宿业务服务实现
|
||||
* <p>
|
||||
* 复用住宿模块:入住/退房/续住最终都落到 {@link ILockStayService},本类只负责
|
||||
* 对外契约相关的三件事——按 {@code appCode} 隔离与幂等、外部人员标识映射、
|
||||
* 头像解析与按门锁算法版本提取特征。
|
||||
* <p>
|
||||
* 人员建档与特征提取在入住事务之外(提取是外部 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析人员:外部标识已映射则复用平台人员,否则建档并写映射。
|
||||
* <p>
|
||||
* 映射存在但人员已被退房清理时,按本次入住重新建档并改指映射——外部系统无需感知
|
||||
* 平台内部的档案生命周期。无论新旧人员,都确保其在目标门锁算法版本下有可用特征。
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把外部标识映射改指到新档案(退房清理后重新入住)。
|
||||
* <p>
|
||||
* 条件更新带旧人员编号:并发重新入住时只有一方改指成功,另一方回查后改用现有人员,
|
||||
* 其新建的档案与首次建档撞键的处理一致,留待人工合并。
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保人员在目标门锁算法版本下有可用特征。
|
||||
* <p>
|
||||
* 照片未变且已有该版本特征时直接复用,避免每次入住都打一次提取服务;
|
||||
* 其余情况按门锁版本重新提取,提取失败即拒绝受理(不下发一份错误特征)。
|
||||
*/
|
||||
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<LockRoom> 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);
|
||||
}
|
||||
}
|
||||
+48
@@ -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<LockPerson> selectPersonList(LockPerson person);
|
||||
|
||||
/** 下拉选项:仅正常状态人员 */
|
||||
List<LockPerson> selectPersonOption();
|
||||
|
||||
LockPerson selectPersonById(Long personId);
|
||||
|
||||
LockPerson selectPersonByNo(String personNo);
|
||||
|
||||
int insertPerson(LockPerson person);
|
||||
|
||||
int updatePerson(LockPerson person);
|
||||
|
||||
int deletePersonByIds(Long[] personIds);
|
||||
|
||||
/**
|
||||
* 直接删除人员记录(物理删除),跳过人脸/授权引用校验。
|
||||
* <p>
|
||||
* 供退房清理外部接口自动建档人员使用:调用方已确认该人员无其它在住单与有效授权,
|
||||
* 人脸与特征由调用方先行删除。人工删除仍走 {@link #deletePersonByIds(Long[])}。
|
||||
*
|
||||
* @param personId 人员ID
|
||||
* @return 受影响行数
|
||||
*/
|
||||
int deletePersonRecord(Long personId);
|
||||
|
||||
/**
|
||||
* 生成人员编号:2 位字母前缀 + 1 位人员类型 + 5 位序列(如 AA000001)
|
||||
* <p>
|
||||
* v1 人员不挂分区,前缀固定 AA;分区维度落地后由分区决定前缀。
|
||||
*
|
||||
* @param personType 人员类型 0-3,为空按 0 处理
|
||||
* @return 8 位人员编号
|
||||
*/
|
||||
String nextPersonNo(String personType);
|
||||
}
|
||||
+225
@@ -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<LockPerson> selectPersonList(LockPerson person)
|
||||
{
|
||||
return personMapper.selectPersonList(person);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<LockPerson> 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("有效期起不能晚于有效期止");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper
|
||||
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.ruoyi.iot.lock.mapper.LockAuthMapper">
|
||||
|
||||
<resultMap type="LockAuth" id="LockAuthResult">
|
||||
<id property="authId" column="auth_id" />
|
||||
<result property="authNo" column="auth_no" />
|
||||
<result property="personNo" column="person_no" />
|
||||
<result property="personName" column="person_name" />
|
||||
<result property="deviceId" column="device_id" />
|
||||
<result property="deviceSn" column="device_sn" />
|
||||
<result property="gatewayId" column="gateway_id" />
|
||||
<result property="groupId" column="group_id" />
|
||||
<result property="authType" column="auth_type" />
|
||||
<result property="authValue" column="auth_value" />
|
||||
<result property="authValueMd5" column="auth_value_md5" />
|
||||
<result property="beginTime" column="begin_time" />
|
||||
<result property="endTime" column="end_time" />
|
||||
<result property="authTime" column="auth_time" />
|
||||
<result property="syncStatus" column="sync_status" />
|
||||
<result property="syncTime" column="sync_time" />
|
||||
<result property="syncMsg" column="sync_msg" />
|
||||
<result property="retryCount" column="retry_count" />
|
||||
<result property="status" column="status" />
|
||||
<result property="delFlag" column="del_flag" />
|
||||
<result property="createBy" column="create_by" />
|
||||
<result property="createTime" column="create_time" />
|
||||
<result property="updateBy" column="update_by" />
|
||||
<result property="updateTime" column="update_time" />
|
||||
<result property="remark" column="remark" />
|
||||
</resultMap>
|
||||
|
||||
<sql id="selectAuthVo">
|
||||
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
|
||||
</sql>
|
||||
|
||||
<select id="selectAuthList" parameterType="LockAuth" resultMap="LockAuthResult">
|
||||
<include refid="selectAuthVo"/>
|
||||
<where>
|
||||
del_flag = '0'
|
||||
<if test="authNo != null and authNo != ''">
|
||||
and auth_no like concat('%', #{authNo}, '%')
|
||||
</if>
|
||||
<if test="personNo != null and personNo != ''">
|
||||
and person_no like concat('%', #{personNo}, '%')
|
||||
</if>
|
||||
<if test="personName != null and personName != ''">
|
||||
and person_name like concat('%', #{personName}, '%')
|
||||
</if>
|
||||
<if test="deviceId != null">
|
||||
and device_id = #{deviceId}
|
||||
</if>
|
||||
<if test="deviceSn != null and deviceSn != ''">
|
||||
and device_sn = #{deviceSn}
|
||||
</if>
|
||||
<if test="groupId != null">
|
||||
and group_id = #{groupId}
|
||||
</if>
|
||||
<if test="authType != null and authType != ''">
|
||||
and auth_type = #{authType}
|
||||
</if>
|
||||
<if test="syncStatus != null and syncStatus != ''">
|
||||
and sync_status = #{syncStatus}
|
||||
</if>
|
||||
<if test="status != null and status != ''">
|
||||
and status = #{status}
|
||||
</if>
|
||||
<if test="params.beginTime != null and params.beginTime != ''">
|
||||
and date_format(create_time,'%Y%m%d') >= date_format(#{params.beginTime},'%Y%m%d')
|
||||
</if>
|
||||
<if test="params.endTime != null and params.endTime != ''">
|
||||
and date_format(create_time,'%Y%m%d') <= date_format(#{params.endTime},'%Y%m%d')
|
||||
</if>
|
||||
</where>
|
||||
order by auth_id desc
|
||||
</select>
|
||||
|
||||
<select id="selectAuthById" parameterType="Long" resultMap="LockAuthResult">
|
||||
<include refid="selectAuthVo"/>
|
||||
where auth_id = #{authId}
|
||||
</select>
|
||||
|
||||
<select id="selectAuthsByDeviceId" parameterType="Long" resultMap="LockAuthResult">
|
||||
<include refid="selectAuthVo"/>
|
||||
where device_id = #{deviceId} and del_flag = '0'
|
||||
order by auth_id desc
|
||||
</select>
|
||||
|
||||
<select id="selectAuthsByPersonNo" parameterType="String" resultMap="LockAuthResult">
|
||||
<include refid="selectAuthVo"/>
|
||||
where person_no = #{personNo} and del_flag = '0'
|
||||
order by auth_id desc
|
||||
</select>
|
||||
|
||||
<select id="countAuthsByPersonNo" parameterType="String" resultType="int">
|
||||
select count(1) from lock_auth where person_no = #{personNo} and del_flag = '0'
|
||||
</select>
|
||||
|
||||
<!-- 已清除(4)不再有设备侧权限;3/6 保留:前者仍可能被补偿重发,后者人脸还在设备上 -->
|
||||
<select id="countActiveAuthsByPersonNo" resultType="int">
|
||||
select count(1) from lock_auth
|
||||
where person_no = #{personNo} and del_flag = '0'
|
||||
and (sync_status is null or sync_status != '4')
|
||||
<if test="excludeAuthId != null">
|
||||
and auth_id != #{excludeAuthId}
|
||||
</if>
|
||||
</select>
|
||||
|
||||
<!-- status 为 null 视为正常:历史数据由 insert 显式写 null 产生,不能因此漏掉补偿 -->
|
||||
<select id="selectPendingAuths" resultMap="LockAuthResult">
|
||||
<include refid="selectAuthVo"/>
|
||||
where del_flag = '0' and (status = '0' or status is null) and sync_status in ('0', '3')
|
||||
<if test="groupId != null">
|
||||
and group_id = #{groupId}
|
||||
</if>
|
||||
order by auth_id
|
||||
</select>
|
||||
|
||||
<!-- 清除失败(6):只重发 delete;带 5 分钟退避,避免门锁长期离线时每轮补偿都建命令 -->
|
||||
<select id="selectPendingClearAuths" resultMap="LockAuthResult">
|
||||
<include refid="selectAuthVo"/>
|
||||
where del_flag = '0' and (status = '0' or status is null) and sync_status = '6'
|
||||
and (sync_time is null or sync_time <= date_sub(now(), interval 5 minute))
|
||||
order by auth_id
|
||||
</select>
|
||||
|
||||
<select id="selectFaceAlgoVersionsByPersonNo" parameterType="String" resultType="String">
|
||||
select distinct trim(d.face_algo_version)
|
||||
from lock_auth a
|
||||
join lock_device d on d.device_id = a.device_id
|
||||
where a.person_no = #{personNo}
|
||||
and a.auth_type = '01'
|
||||
and a.del_flag = '0'
|
||||
and d.del_flag = '0'
|
||||
and d.face_algo_version is not null
|
||||
and trim(d.face_algo_version) <> ''
|
||||
order by trim(d.face_algo_version)
|
||||
</select>
|
||||
|
||||
<select id="selectFaceAuthsForRepush" resultMap="LockAuthResult">
|
||||
select a.auth_id, a.auth_no, a.person_no, a.person_name, a.device_id, a.device_sn, a.gateway_id,
|
||||
a.group_id, a.auth_type, a.auth_value, a.auth_value_md5, a.begin_time, a.end_time,
|
||||
a.auth_time, a.sync_status, a.sync_time, a.sync_msg, a.retry_count, a.status,
|
||||
a.del_flag, a.create_by, a.create_time, a.update_by, a.update_time, a.remark
|
||||
from lock_auth a
|
||||
join lock_device d on d.device_id = a.device_id
|
||||
where a.person_no = #{personNo}
|
||||
and a.auth_type = '01'
|
||||
and a.del_flag = '0'
|
||||
and a.sync_status in ('0', '3')
|
||||
and d.del_flag = '0'
|
||||
and trim(coalesce(d.face_algo_version, '')) = #{algoVersion}
|
||||
order by a.auth_id
|
||||
</select>
|
||||
|
||||
<!-- 唯一键 (person_no, device_id, auth_type) 不含 del_flag,逻辑删除行仍占用键值,不过滤 -->
|
||||
<select id="selectAuthByUniqueKey" resultMap="LockAuthResult">
|
||||
<include refid="selectAuthVo"/>
|
||||
where person_no = #{personNo} and device_id = #{deviceId} and auth_type = #{authType}
|
||||
limit 1
|
||||
</select>
|
||||
|
||||
<!-- 同人同锁同类型只保留一行:重复新增或删除后重新添加时复用原行并重置下发状态 -->
|
||||
<update id="reviveAuth" parameterType="LockAuth">
|
||||
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}
|
||||
</update>
|
||||
|
||||
<insert id="insertAuth" parameterType="LockAuth" useGeneratedKeys="true" keyProperty="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}
|
||||
)
|
||||
</insert>
|
||||
|
||||
<update id="updateAuth" parameterType="LockAuth">
|
||||
update lock_auth
|
||||
<set>
|
||||
<if test="personNo != null and personNo != ''">person_no = #{personNo},</if>
|
||||
<if test="personName != null">person_name = #{personName},</if>
|
||||
<if test="deviceId != null">device_id = #{deviceId},</if>
|
||||
<if test="deviceSn != null">device_sn = #{deviceSn},</if>
|
||||
<if test="gatewayId != null">gateway_id = #{gatewayId},</if>
|
||||
<if test="groupId != null">group_id = #{groupId},</if>
|
||||
<if test="authType != null and authType != ''">auth_type = #{authType},</if>
|
||||
<if test="authValue != null">auth_value = #{authValue},</if>
|
||||
<if test="authValueMd5 != null">auth_value_md5 = #{authValueMd5},</if>
|
||||
<if test="beginTime != null">begin_time = #{beginTime},</if>
|
||||
<if test="endTime != null">end_time = #{endTime},</if>
|
||||
<if test="authTime != null">auth_time = #{authTime},</if>
|
||||
<if test="status != null and status != ''">status = #{status},</if>
|
||||
<if test="remark != null">remark = #{remark},</if>
|
||||
update_by = #{updateBy}, update_time = CURRENT_TIMESTAMP
|
||||
</set>
|
||||
where auth_id = #{authId}
|
||||
</update>
|
||||
|
||||
<update id="updateSyncStatus">
|
||||
update lock_auth set sync_status = #{syncStatus}, sync_time = CURRENT_TIMESTAMP
|
||||
<if test="syncMsg != null">, sync_msg = #{syncMsg}</if>
|
||||
where auth_id = #{authId}
|
||||
</update>
|
||||
|
||||
<update id="markFaceAuthsForReextract">
|
||||
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>
|
||||
|
||||
<update id="deleteAuthByIds" parameterType="Long">
|
||||
update lock_auth set del_flag = '2' where auth_id in
|
||||
<foreach collection="array" item="authId" open="(" separator="," close=")">
|
||||
#{authId}
|
||||
</foreach>
|
||||
</update>
|
||||
|
||||
<delete id="deleteAuthByDeviceId" parameterType="Long">
|
||||
delete from lock_auth where device_id = #{deviceId}
|
||||
</delete>
|
||||
|
||||
</mapper>
|
||||
@@ -0,0 +1,158 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper
|
||||
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.ruoyi.iot.lock.mapper.LockFaceMapper">
|
||||
|
||||
<resultMap type="LockFace" id="LockFaceResult">
|
||||
<id property="faceId" column="face_id" />
|
||||
<result property="personId" column="person_id" />
|
||||
<result property="personNo" column="person_no" />
|
||||
<result property="personName" column="person_name" />
|
||||
<result property="personType" column="person_type" />
|
||||
<result property="phone" column="phone" />
|
||||
<result property="photoUrl" column="photo_url" />
|
||||
<result property="photoMd5" column="photo_md5" />
|
||||
<result property="featureData" column="feature_data" />
|
||||
<result property="featureMd5" column="feature_md5" />
|
||||
<result property="featureAlgoVersion" column="feature_algo_version" />
|
||||
<result property="featureSize" column="feature_size" />
|
||||
<result property="extractStatus" column="extract_status" />
|
||||
<result property="extractTime" column="extract_time" />
|
||||
<result property="extractMsg" column="extract_msg" />
|
||||
<result property="status" column="status" />
|
||||
<result property="delFlag" column="del_flag" />
|
||||
<result property="createBy" column="create_by" />
|
||||
<result property="createTime" column="create_time" />
|
||||
<result property="updateBy" column="update_by" />
|
||||
<result property="updateTime" column="update_time" />
|
||||
<result property="remark" column="remark" />
|
||||
</resultMap>
|
||||
|
||||
<!-- 人员维度只在 lock_person 维护,本表通过 person_id 关联读取 -->
|
||||
<sql id="selectFaceListVo">
|
||||
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
|
||||
</sql>
|
||||
|
||||
<sql id="selectFaceVo">
|
||||
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
|
||||
</sql>
|
||||
|
||||
<!-- 列表不返回 feature_data,避免大字段传输 -->
|
||||
<select id="selectFaceList" parameterType="LockFace" resultMap="LockFaceResult">
|
||||
<include refid="selectFaceListVo"/>
|
||||
<where>
|
||||
f.del_flag = '0'
|
||||
<if test="personNo != null and personNo != ''">
|
||||
and f.person_no like concat('%', #{personNo}, '%')
|
||||
</if>
|
||||
<if test="personName != null and personName != ''">
|
||||
and p.person_name like concat('%', #{personName}, '%')
|
||||
</if>
|
||||
<if test="personType != null and personType != ''">
|
||||
and p.person_type = #{personType}
|
||||
</if>
|
||||
<if test="extractStatus != null and extractStatus != ''">
|
||||
and f.extract_status = #{extractStatus}
|
||||
</if>
|
||||
<if test="status != null and status != ''">
|
||||
and f.status = #{status}
|
||||
</if>
|
||||
<if test="params.beginTime != null and params.beginTime != ''">
|
||||
and date_format(f.create_time,'%Y%m%d') >= date_format(#{params.beginTime},'%Y%m%d')
|
||||
</if>
|
||||
<if test="params.endTime != null and params.endTime != ''">
|
||||
and date_format(f.create_time,'%Y%m%d') <= date_format(#{params.endTime},'%Y%m%d')
|
||||
</if>
|
||||
</where>
|
||||
order by f.face_id desc
|
||||
</select>
|
||||
|
||||
<select id="selectFaceById" parameterType="Long" resultMap="LockFaceResult">
|
||||
<include refid="selectFaceVo"/>
|
||||
where f.face_id = #{faceId}
|
||||
</select>
|
||||
|
||||
<select id="selectFaceByPersonNo" parameterType="String" resultMap="LockFaceResult">
|
||||
<include refid="selectFaceVo"/>
|
||||
where f.person_no = #{personNo} and f.del_flag = '0'
|
||||
</select>
|
||||
|
||||
<select id="countFaceByPersonId" parameterType="Long" resultType="int">
|
||||
select count(1) from lock_face where person_id = #{personId} and del_flag = '0'
|
||||
</select>
|
||||
|
||||
<select id="countFaceByPhotoUrl" parameterType="String" resultType="int">
|
||||
select count(1) from lock_face where photo_url = #{photoUrl} and del_flag = '0'
|
||||
</select>
|
||||
|
||||
<!-- 唯一键 uk_lock_face_person_id / uk_lock_face_person_no 均不含 del_flag:
|
||||
重新建档(退房清理、人工删人脸后再次入住)要先看历史行,命中即复用而不是再插一条 -->
|
||||
<select id="selectReusableFace" resultMap="LockFaceResult">
|
||||
<include refid="selectFaceListVo"/>
|
||||
where f.person_id = #{personId} or f.person_no = #{personNo}
|
||||
order by case when f.person_id = #{personId} then 0 else 1 end, f.face_id desc
|
||||
limit 1
|
||||
</select>
|
||||
|
||||
<update id="reviveFace" parameterType="LockFace">
|
||||
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}
|
||||
</update>
|
||||
|
||||
<insert id="insertFace" parameterType="LockFace" useGeneratedKeys="true" keyProperty="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}
|
||||
)
|
||||
</insert>
|
||||
|
||||
<update id="updateFace" parameterType="LockFace">
|
||||
update lock_face
|
||||
<set>
|
||||
<if test="photoUrl != null">photo_url = #{photoUrl},</if>
|
||||
<if test="photoMd5 != null">photo_md5 = #{photoMd5},</if>
|
||||
<if test="status != null and status != ''">status = #{status},</if>
|
||||
<if test="remark != null">remark = #{remark},</if>
|
||||
update_by = #{updateBy}, update_time = CURRENT_TIMESTAMP
|
||||
</set>
|
||||
where face_id = #{faceId}
|
||||
</update>
|
||||
|
||||
<!-- 特征列只写非空值:提取失败不应该把上一次成功的镜像清空 -->
|
||||
<update id="updateExtractResult" parameterType="LockFace">
|
||||
update lock_face
|
||||
set extract_status = #{extractStatus}, extract_time = CURRENT_TIMESTAMP,
|
||||
extract_msg = #{extractMsg}
|
||||
<if test="featureData != null">, feature_data = #{featureData}</if>
|
||||
<if test="featureMd5 != null">, feature_md5 = #{featureMd5}</if>
|
||||
<if test="featureAlgoVersion != null">, feature_algo_version = #{featureAlgoVersion}</if>
|
||||
<if test="featureSize != null">, feature_size = #{featureSize}</if>
|
||||
where face_id = #{faceId}
|
||||
</update>
|
||||
|
||||
<delete id="deleteFaceByIds" parameterType="Long">
|
||||
delete from lock_face where face_id in
|
||||
<foreach collection="array" item="faceId" open="(" separator="," close=")">
|
||||
#{faceId}
|
||||
</foreach>
|
||||
</delete>
|
||||
|
||||
</mapper>
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper
|
||||
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.ruoyi.iot.lock.mapper.LockPersonRefMapper">
|
||||
|
||||
<resultMap type="LockPersonRef" id="LockPersonRefResult">
|
||||
<id property="refId" column="ref_id" />
|
||||
<result property="personId" column="person_id" />
|
||||
<result property="personNo" column="person_no" />
|
||||
<result property="appCode" column="app_code" />
|
||||
<result property="externalId" column="external_id" />
|
||||
<result property="createBy" column="create_by" />
|
||||
<result property="createTime" column="create_time" />
|
||||
<result property="remark" column="remark" />
|
||||
</resultMap>
|
||||
|
||||
<select id="selectByAppAndExternalId" resultMap="LockPersonRefResult">
|
||||
select ref_id, person_id, person_no, app_code, external_id, create_by, create_time, remark
|
||||
from lock_person_ref
|
||||
where app_code = #{appCode} and external_id = #{externalId}
|
||||
limit 1
|
||||
</select>
|
||||
|
||||
<!-- 回调用:同一人员在同一应用下只有一条映射,取最新一条防脏数据报错 -->
|
||||
<select id="selectByAppAndPersonNo" resultMap="LockPersonRefResult">
|
||||
select ref_id, person_id, person_no, app_code, external_id, create_by, create_time, remark
|
||||
from lock_person_ref
|
||||
where app_code = #{appCode} and person_no = #{personNo}
|
||||
order by ref_id desc
|
||||
limit 1
|
||||
</select>
|
||||
|
||||
<insert id="insertRef" parameterType="LockPersonRef" useGeneratedKeys="true" keyProperty="refId">
|
||||
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}
|
||||
)
|
||||
</insert>
|
||||
|
||||
<!-- 旧人员编号做条件:同一外部标识并发重新入住时只有一方能改指成功 -->
|
||||
<update id="rebindPerson">
|
||||
update lock_person_ref
|
||||
set person_id = #{personId}, person_no = #{personNo}
|
||||
where ref_id = #{refId} and person_no = #{oldPersonNo}
|
||||
</update>
|
||||
|
||||
</mapper>
|
||||
@@ -0,0 +1,237 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper
|
||||
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.ruoyi.iot.lock.mapper.LockStayMapper">
|
||||
|
||||
<resultMap type="LockStay" id="LockStayResult">
|
||||
<id property="stayId" column="stay_id" />
|
||||
<result property="stayNo" column="stay_no" />
|
||||
<result property="bizNo" column="biz_no" />
|
||||
<result property="bizSystem" column="biz_system" />
|
||||
<result property="source" column="source" />
|
||||
<result property="personId" column="person_id" />
|
||||
<result property="personNo" column="person_no" />
|
||||
<result property="personName" column="person_name" />
|
||||
<result property="guestType" column="guest_type" />
|
||||
<result property="roomId" column="room_id" />
|
||||
<result property="roomCode" column="room_code" />
|
||||
<result property="roomNo" column="room_no" />
|
||||
<result property="deviceId" column="device_id" />
|
||||
<result property="deviceSn" column="device_sn" />
|
||||
<result property="authId" column="auth_id" />
|
||||
<result property="beginTime" column="begin_time" />
|
||||
<result property="endTime" column="end_time" />
|
||||
<result property="checkinTime" column="checkin_time" />
|
||||
<result property="checkoutTime" column="checkout_time" />
|
||||
<result property="changeFromId" column="change_from_id"/>
|
||||
<result property="stayStatus" column="stay_status" />
|
||||
<result property="authStatus" column="auth_status" />
|
||||
<result property="authMsg" column="auth_msg" />
|
||||
<result property="delFlag" column="del_flag" />
|
||||
<result property="createBy" column="create_by" />
|
||||
<result property="createTime" column="create_time" />
|
||||
<result property="updateBy" column="update_by" />
|
||||
<result property="updateTime" column="update_time" />
|
||||
<result property="remark" column="remark" />
|
||||
</resultMap>
|
||||
|
||||
<sql id="selectStayVo">
|
||||
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'
|
||||
</sql>
|
||||
|
||||
<select id="selectStayList" parameterType="LockStay" resultMap="LockStayResult">
|
||||
<include refid="selectStayVo"/>
|
||||
<where>
|
||||
s.del_flag = '0'
|
||||
<if test="personNo != null and personNo != ''">
|
||||
and s.person_no like concat('%', #{personNo}, '%')
|
||||
</if>
|
||||
<if test="personName != null and personName != ''">
|
||||
and s.person_name like concat('%', #{personName}, '%')
|
||||
</if>
|
||||
<if test="roomId != null">
|
||||
and s.room_id = #{roomId}
|
||||
</if>
|
||||
<if test="roomCode != null and roomCode != ''">
|
||||
and s.room_code like concat('%', #{roomCode}, '%')
|
||||
</if>
|
||||
<if test="deviceId != null">
|
||||
and s.device_id = #{deviceId}
|
||||
</if>
|
||||
<if test="guestType != null and guestType != ''">
|
||||
and s.guest_type = #{guestType}
|
||||
</if>
|
||||
<!-- 老数据没有来源列值时按平台单处理,避免筛选「本平台」漏掉 D20 之前的单据 -->
|
||||
<if test="source != null and source != ''">
|
||||
and coalesce(nullif(s.source, ''), '1') = #{source}
|
||||
</if>
|
||||
<if test="bizSystem != null and bizSystem != ''">
|
||||
and s.biz_system = #{bizSystem}
|
||||
</if>
|
||||
<if test="stayStatus != null and stayStatus != ''">
|
||||
and s.stay_status = #{stayStatus}
|
||||
</if>
|
||||
<if test="authStatus != null and authStatus != ''">
|
||||
and s.auth_status = #{authStatus}
|
||||
</if>
|
||||
<if test="params.beginTime != null and params.beginTime != ''">
|
||||
and date_format(s.begin_time,'%Y%m%d') >= date_format(#{params.beginTime},'%Y%m%d')
|
||||
</if>
|
||||
<if test="params.endTime != null and params.endTime != ''">
|
||||
and date_format(s.begin_time,'%Y%m%d') <= date_format(#{params.endTime},'%Y%m%d')
|
||||
</if>
|
||||
</where>
|
||||
order by s.stay_id desc
|
||||
</select>
|
||||
|
||||
<select id="selectStayById" parameterType="Long" resultMap="LockStayResult">
|
||||
<include refid="selectStayVo"/>
|
||||
where s.stay_id = #{stayId} and s.del_flag = '0'
|
||||
</select>
|
||||
|
||||
<select id="selectActiveStaysByPersonNo" parameterType="String" resultMap="LockStayResult">
|
||||
<include refid="selectStayVo"/>
|
||||
where s.person_no = #{personNo} and s.stay_status = '1' and s.del_flag = '0'
|
||||
order by s.stay_id desc
|
||||
</select>
|
||||
|
||||
<!-- 退房清理人员前判断是否还有其它未结束的单:待入住(0)与在住(1)都算占用 -->
|
||||
<select id="countActiveStaysByPersonNo" resultType="int">
|
||||
select count(1) from lock_stay
|
||||
where person_no = #{personNo} and del_flag = '0' and stay_status in ('0', '1')
|
||||
<if test="excludeStayId != null">
|
||||
and stay_id != #{excludeStayId}
|
||||
</if>
|
||||
</select>
|
||||
|
||||
<select id="selectStaysByRoomId" parameterType="Long" resultMap="LockStayResult">
|
||||
<include refid="selectStayVo"/>
|
||||
where s.room_id = #{roomId} and s.del_flag = '0'
|
||||
order by s.stay_id desc
|
||||
</select>
|
||||
|
||||
<select id="selectActiveStaysByRoomId" parameterType="Long" resultMap="LockStayResult">
|
||||
select stay_id, person_no, person_name, room_id, room_code, device_id, device_sn, auth_id,
|
||||
begin_time, end_time, checkin_time, stay_status, auth_status
|
||||
from lock_stay
|
||||
where room_id = #{roomId} and stay_status = '1' and del_flag = '0'
|
||||
order by stay_id desc
|
||||
</select>
|
||||
|
||||
<!--
|
||||
按业务号定位:不过滤 del_flag。唯一键 uk_lock_stay_biz (biz_system, biz_no) 不含 del_flag,
|
||||
已逻辑删除的单仍占用键值;若这里过滤掉,接口重复提交会直接撞唯一键抛 SQL 异常。
|
||||
-->
|
||||
<select id="selectStayByBiz" resultMap="LockStayResult">
|
||||
<include refid="selectStayVo"/>
|
||||
where s.biz_system = #{bizSystem} and s.biz_no = #{bizNo}
|
||||
order by s.stay_id desc
|
||||
limit 1
|
||||
</select>
|
||||
|
||||
<select id="selectActiveStayByPersonAndRoom" resultMap="LockStayResult">
|
||||
<include refid="selectStayVo"/>
|
||||
where s.person_no = #{personNo} and s.room_id = #{roomId} and s.stay_status = '1'
|
||||
and s.del_flag = '0'
|
||||
<if test="bizSystem != null and bizSystem != ''">
|
||||
and s.biz_system = #{bizSystem}
|
||||
</if>
|
||||
order by s.stay_id desc
|
||||
limit 1
|
||||
</select>
|
||||
|
||||
<select id="selectLatestStayByPersonAndRoom" resultMap="LockStayResult">
|
||||
<include refid="selectStayVo"/>
|
||||
where s.person_no = #{personNo} and s.room_id = #{roomId} and s.del_flag = '0'
|
||||
<if test="bizSystem != null and bizSystem != ''">
|
||||
and s.biz_system = #{bizSystem}
|
||||
</if>
|
||||
order by s.stay_id desc
|
||||
limit 1
|
||||
</select>
|
||||
|
||||
<!-- 回调用:授权下的入住单。不过滤 del_flag,删单后的回调仍需回填业务单号 -->
|
||||
<select id="selectStayByAuthId" parameterType="Long" resultMap="LockStayResult">
|
||||
<include refid="selectStayVo"/>
|
||||
where s.auth_id = #{authId}
|
||||
order by s.stay_id desc
|
||||
limit 1
|
||||
</select>
|
||||
|
||||
<select id="countActiveStaysByDeviceId" parameterType="Long" resultType="int">
|
||||
select count(1) from lock_stay
|
||||
where device_id = #{deviceId} and stay_status = '1' and del_flag = '0'
|
||||
</select>
|
||||
|
||||
<select id="countStaysByBizSystem" parameterType="String" resultType="int">
|
||||
select count(1) from lock_stay where biz_system = #{bizSystem}
|
||||
</select>
|
||||
|
||||
<select id="countActiveStays" resultType="int">
|
||||
select count(1) from lock_stay where stay_status = '1' and del_flag = '0'
|
||||
</select>
|
||||
|
||||
<!-- 带上 person_id / biz_system:到期退房后按同一套规则清理外部接口建档人员 -->
|
||||
<select id="selectExpiredStays" resultMap="LockStayResult">
|
||||
select stay_id, biz_system, person_id, person_no, person_name, room_id, room_code, device_id, device_sn,
|
||||
auth_id, begin_time, end_time, checkin_time, stay_status, auth_status, del_flag
|
||||
from lock_stay
|
||||
where stay_status = '1' and del_flag = '0' and end_time <= #{endTime}
|
||||
order by end_time asc
|
||||
limit #{limit}
|
||||
</select>
|
||||
|
||||
<update id="updateExpire" parameterType="Long">
|
||||
update lock_stay
|
||||
set stay_status = '2', checkout_time = end_time, update_time = CURRENT_TIMESTAMP
|
||||
where stay_id = #{stayId} and stay_status = '1'
|
||||
</update>
|
||||
|
||||
<insert id="insertStay" parameterType="LockStay" useGeneratedKeys="true" keyProperty="stayId">
|
||||
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}
|
||||
)
|
||||
</insert>
|
||||
|
||||
<update id="updateCheckout">
|
||||
update lock_stay
|
||||
set stay_status = '2', checkout_time = #{checkoutTime}, update_time = CURRENT_TIMESTAMP
|
||||
where stay_id = #{stayId} and stay_status = '1'
|
||||
</update>
|
||||
|
||||
<update id="updateChanged">
|
||||
update lock_stay
|
||||
set stay_status = '3', checkout_time = #{checkoutTime}, update_time = CURRENT_TIMESTAMP
|
||||
where stay_id = #{stayId} and stay_status = '1'
|
||||
</update>
|
||||
|
||||
<update id="updateEndTime">
|
||||
update lock_stay
|
||||
set end_time = #{endTime}, update_time = CURRENT_TIMESTAMP
|
||||
where stay_id = #{stayId} and stay_status = '1'
|
||||
</update>
|
||||
|
||||
<update id="updateAuthStatusByAuthId">
|
||||
update lock_stay
|
||||
set auth_status = #{authStatus}, auth_msg = #{authMsg}, update_time = CURRENT_TIMESTAMP
|
||||
where auth_id = #{authId} and del_flag = '0'
|
||||
</update>
|
||||
|
||||
</mapper>
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper
|
||||
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.ruoyi.iot.person.mapper.LockPersonMapper">
|
||||
|
||||
<resultMap type="LockPerson" id="LockPersonResult">
|
||||
<id property="personId" column="person_id" />
|
||||
<result property="personNo" column="person_no" />
|
||||
<result property="personName" column="person_name" />
|
||||
<result property="personType" column="person_type" />
|
||||
<result property="deptId" column="dept_id" />
|
||||
<result property="deptName" column="dept_name" />
|
||||
<result property="sysUserId" column="sys_user_id" />
|
||||
<result property="userName" column="user_name" />
|
||||
<result property="nickName" column="nick_name" />
|
||||
<result property="idCardNo" column="id_card_no" />
|
||||
<result property="phone" column="phone" />
|
||||
<result property="validFrom" column="valid_from" />
|
||||
<result property="validTo" column="valid_to" />
|
||||
<result property="status" column="status" />
|
||||
<result property="delFlag" column="del_flag" />
|
||||
<result property="createBy" column="create_by" />
|
||||
<result property="createTime" column="create_time" />
|
||||
<result property="updateBy" column="update_by" />
|
||||
<result property="updateTime" column="update_time" />
|
||||
<result property="remark" column="remark" />
|
||||
</resultMap>
|
||||
|
||||
<sql id="selectPersonVo">
|
||||
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
|
||||
</sql>
|
||||
|
||||
<!-- 列表不返回证件号,避免敏感信息随导出/翻页扩散 -->
|
||||
<select id="selectPersonList" parameterType="LockPerson" resultMap="LockPersonResult">
|
||||
<include refid="selectPersonVo"/>
|
||||
<where>
|
||||
p.del_flag = '0'
|
||||
<if test="personNo != null and personNo != ''">
|
||||
and p.person_no like concat('%', #{personNo}, '%')
|
||||
</if>
|
||||
<if test="personName != null and personName != ''">
|
||||
and p.person_name like concat('%', #{personName}, '%')
|
||||
</if>
|
||||
<if test="personType != null and personType != ''">
|
||||
and p.person_type = #{personType}
|
||||
</if>
|
||||
<if test="deptId != null">
|
||||
and p.dept_id = #{deptId}
|
||||
</if>
|
||||
<if test="sysUserId != null">
|
||||
and p.sys_user_id = #{sysUserId}
|
||||
</if>
|
||||
<if test="phone != null and phone != ''">
|
||||
and p.phone like concat('%', #{phone}, '%')
|
||||
</if>
|
||||
<if test="status != null and status != ''">
|
||||
and p.status = #{status}
|
||||
</if>
|
||||
<if test="params.beginTime != null and params.beginTime != ''">
|
||||
and date_format(p.create_time,'%Y%m%d') >= date_format(#{params.beginTime},'%Y%m%d')
|
||||
</if>
|
||||
<if test="params.endTime != null and params.endTime != ''">
|
||||
and date_format(p.create_time,'%Y%m%d') <= date_format(#{params.endTime},'%Y%m%d')
|
||||
</if>
|
||||
</where>
|
||||
order by p.person_no
|
||||
</select>
|
||||
|
||||
<select id="selectPersonOption" resultMap="LockPersonResult">
|
||||
select p.person_id, p.person_no, p.person_name, p.person_type, p.dept_id,
|
||||
d.dept_name, p.status
|
||||
from lock_person p
|
||||
left join sys_dept d on d.dept_id = p.dept_id
|
||||
where p.del_flag = '0' and p.status = '0'
|
||||
order by p.person_no
|
||||
</select>
|
||||
|
||||
<select id="selectPersonById" parameterType="Long" resultMap="LockPersonResult">
|
||||
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.id_card_no, 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
|
||||
where p.person_id = #{personId}
|
||||
</select>
|
||||
|
||||
<select id="selectPersonByNo" parameterType="String" resultMap="LockPersonResult">
|
||||
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
|
||||
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
|
||||
where p.person_no = #{personNo} and p.del_flag = '0'
|
||||
</select>
|
||||
|
||||
<select id="countPersonNo" resultType="int">
|
||||
select count(1) from lock_person
|
||||
where person_no = #{personNo}
|
||||
<if test="excludeId != null">
|
||||
and person_id != #{excludeId}
|
||||
</if>
|
||||
</select>
|
||||
|
||||
<insert id="insertPerson" parameterType="LockPerson" useGeneratedKeys="true" keyProperty="personId">
|
||||
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}
|
||||
)
|
||||
</insert>
|
||||
|
||||
<update id="updatePerson" parameterType="LockPerson">
|
||||
update lock_person
|
||||
<set>
|
||||
<if test="personName != null">person_name = #{personName},</if>
|
||||
<if test="personType != null and personType != ''">person_type = #{personType},</if>
|
||||
<if test="deptId != null">dept_id = #{deptId},</if>
|
||||
<if test="sysUserId != null">sys_user_id = #{sysUserId},</if>
|
||||
<if test="idCardNo != null">id_card_no = #{idCardNo},</if>
|
||||
<if test="phone != null">phone = #{phone},</if>
|
||||
<if test="validFrom != null">valid_from = #{validFrom},</if>
|
||||
<if test="validTo != null">valid_to = #{validTo},</if>
|
||||
<if test="status != null and status != ''">status = #{status},</if>
|
||||
<if test="remark != null">remark = #{remark},</if>
|
||||
update_by = #{updateBy}, update_time = CURRENT_TIMESTAMP
|
||||
</set>
|
||||
where person_id = #{personId}
|
||||
</update>
|
||||
|
||||
<delete id="deletePersonByIds" parameterType="Long">
|
||||
delete from lock_person where person_id in
|
||||
<foreach collection="array" item="personId" open="(" separator="," close=")">
|
||||
#{personId}
|
||||
</foreach>
|
||||
</delete>
|
||||
|
||||
</mapper>
|
||||
@@ -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>`;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: <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: <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: <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: <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: <eventId>
|
||||
X-Timestamp: 1793599999
|
||||
X-Signature: <HMAC-SHA256(timestamp + "." + rawBody, 该应用 callback-secret) 的十六进制>
|
||||
```
|
||||
|
||||
> 回调地址与签名密钥随应用(`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。
|
||||
@@ -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: <eventId>
|
||||
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 签名(当前仅回调出站签名)
|
||||
- 回调可观测(投递成功率、积压告警)
|
||||
+1024
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user