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:
zfjia committed 2026-11-01 19:45:36 +08:00
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()
{
}
}
@@ -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);
}
@@ -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);
}
@@ -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);
}
@@ -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);
}
@@ -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);
}
@@ -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);
}
}
@@ -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();
}
}
@@ -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);
}
}
@@ -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);
}
@@ -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') &gt;= date_format(#{params.beginTime},'%Y%m%d')
</if>
<if test="params.endTime != null and params.endTime != ''">
and date_format(create_time,'%Y%m%d') &lt;= 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 &lt;= 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) &lt;&gt; ''
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') &gt;= 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') &lt;= 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>
@@ -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') &gt;= 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') &lt;= 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 &lt;= #{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>
@@ -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') &gt;= 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') &lt;= 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。
+646
View File
@@ -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 签名(当前仅回调出站签名)
- 回调可观测(投递成功率、积压告警)
File diff suppressed because it is too large. Load diff