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>