Compare commits

2 Commits
Author SHA1 Message Date
zfjia 20b2f8c4b8 feat(lock): 通行日志补通行人与房间号,日志类数据统一保留 30 天
- 通行日志列表/详情/导出补充通行人姓名与房间号,支持按两者查询;房间号按门锁当前绑定推导,姓名以人员主数据为准
- 新增 LockDataRetentionTask(每天 03:30、分布式锁)统一清理 30 天前的通行日志、MQTT 报文、终态命令、已退房/换房/取消的住宿单、已清除/删除的授权、已完成或超期未投递的回调任务
- 回调任务待结果(0)/待发送(1) 超过保留期按创建时间一并清理
2026-11-04 18:24:21 +08:00
zfjia 8438fb6b8e feat(lock): 按实测报文解析锁内授权并对无通道授权置终态
- readkey 应答按实测格式解析:类型码区分密码/人脸,人脸凭据为人员ID,时间为8位十六进制Unix秒
- lock_device_auth 增加 auth_type 快照字段,对账支持按类型匹配
- 授权无下发通道(未登记网关/网关无分组)置 sync_status=7,补偿不再重复尝试
- 整表清除后网关置重启中,收到上行再恢复在线
- 同步更新协议与设计文档、幂等升级脚本(D23/D24/D25)
2026-11-04 14:31:01 +08:00
69 changed files with 6309 additions and 68 deletions

No files matched your search

+8
View File
@@ -19,6 +19,14 @@ out/
*.dmp
*.zip
# 本地工程快照与库转储
base/1103版本/
base/RuoYi-Vue-master/sql/20*.sql
base/RuoYi-Vue-master/sql/iotlocks.sql
base/RuoYi-Vue-master/sql/dump_lock_auth.sql
106部署现场/*.sql
doc/*.tar.gz
# 系统文件
.DS_Store
Thumbs.db
@@ -0,0 +1,108 @@
# 本机调试配置(IDEA / mvn spring-boot:run 默认激活,见 application.yml 的 spring.profiles.active)
# 激活方式:默认即可;如需显式指定,加参数 --spring.profiles.active=druid,local
# 这里只放「本机与服务器不同」的项,公共配置仍在 application.yml
ruoyi:
# 上传文件目录(Windows 本机;数据库里存的是相对路径,换目录后旧附件需自行迁移)
profile: C:/Users/zfjia/work/projects/ruoyi/uploadPath
logging:
level:
# 本机看全量业务日志;需要看某模块 SQL 时把对应 mapper 包改成 debug
com.ruoyi: debug
file:
# 本机日志目录,避免写到工程目录或服务器路径
path: C:/Users/zfjia/work/projects/ruoyi/logs
spring:
# 本机开启热部署,改完自动重启
devtools:
restart:
enabled: true
datasource:
druid:
master:
# 本机 MySQL;如连测试库改这三行即可
url: jdbc:mysql://localhost:3306/iotlocks?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
username: iotlocks
password: psw.db1234
# IOT设备接入(MQTT)
iot:
# 门锁协议主题:ydlink/{id}/thing/...,换厂家或改前缀时只改这里
lock:
protocol:
prefix: ydlink
thing: thing
# 人脸特征提取服务(数据库 lock_service_config 中 config_type=02 的配置优先级更高)
face:
endpoint: http://192.168.1.202:8802/bdws_api/Third/Api/Easen/getFaceFeatureData
# 调用超时(毫秒)
timeout: 10000
# 第三方开放接口:关闭时接口返回 404;启用后调用方需带请求头 X-API-Key
openapi:
enabled: true
# 应用清单以数据表 lock_open_app 为准,由「开放管理 › 应用接入」维护(改完即时生效,不用重启)。
# 本段 api-key / apps 仅作两用:首次启动且表为空时原样导入;表不可用(未执行 D21 脚本)时降级使用。
# 导入完成后可删除本段,只保留 enabled 与 path-prefix。
# 单应用兼容配置:apps 为空时以此 Key 导入,appCode 记为 DEFAULT
api-key: please-change-me
path-prefix: /openapi/iot
# 多应用配置(仅首次导入/降级用):每个业务系统一个独立 Key,
# 命中哪个应用就以该应用的 app-code 作为数据隔离身份(写入 lock_stay.biz_system)。
# 各应用 api-key 不得重复(重复或为空时启动即失败)。
# callback-secret 用于回调签名 HMAC-SHA256(timestamp + "." + body),为空表示不开通回调。示例:
# apps:
# - app-code: PMS
# app-name: 公寓 PMS
# api-key: <每个应用独立>
# callback-url: https://pms.example.com/hook/lock
# callback-secret: <回调签名密钥>
# enabled: true
# - app-code: VISITOR
# app-name: 访客系统
# api-key: <另一个独立 Key>
# callback-url: https://visitor.example.com/api/lock-callback
# callback-secret: <回调签名密钥>
# enabled: true
# 服务器系统时间:开门授权依赖时间窗,内网离线部署时可在此开放管理端校时。
# 高危操作:需 Linux + systemd(timedatectl),服务以 root 运行或配置免密 sudo。
# control-enabled=false 时接口只读;非 root 运行且已为该用户配置 NOPASSWD 时把 use-sudo 设为 true。
time:
control-enabled: false
use-sudo: false
timeout-ms: 8000
mqtt:
# 开关,false 时不创建任何连接
enabled: true
# MQTT服务器地址,ssl 场景使用 ssl://host:8883
broker-url: tcp://192.168.1.202:1883
# 客户端ID,同一服务器下必须唯一;为空时自动生成
client-id:
username: admin4
password: admin4
# false 时服务端保留离线消息,需配合固定 client-id
clean-session: true
connection-timeout: 30
keep-alive-interval: 60
# 断线自动重连
automatic-reconnect: true
max-reconnect-delay: 30000
# 连接健康检查周期(毫秒),0 表示不做定时重连
health-check-interval: 30000
# 发布消息等待服务端确认时间(毫秒),0 表示不等待
publish-timeout: 3000
# 消息持久化目录,为空使用内存
persistence-directory:
default-qos: 1
default-retained: false
# 通用静态订阅;门禁上行主题由 LockTopicSubscriber 按 iot.lock.protocol 前缀动态登记,
# 这里不要再写一遍,否则改前缀时会出现两套主题并存
topics: []
# 遗嘱消息:无法区分主动断开与异常掉线,门禁场景建议关闭(离线由 GatewayOfflineCheckTask 判定)
# will:
# topic: ydlink/status
# payload: offline
# qos: 1
# retained: true
@@ -0,0 +1,108 @@
# 服务器部署配置
# 激活方式:启动参数 --spring.profiles.active=druid,prod
#(或环境变量 SPRING_PROFILES_ACTIVE=druid,prod;druid 不能丢,否则数据源不加载)
# 这里只放「服务器与本机不同」的项,公共配置仍在 application.yml
# 部署目录与《Linux部署与开机自启》保持一致;换目录时同步改下面两处
ruoyi:
# 上传文件目录(Linux;需保证运行用户可写)
profile: /home/railwaysystem/service/doorcontrol/uploadPath
logging:
level:
# 生产只记 info,避免 debug 日志撑满磁盘;排查问题时临时改成 debug 并重启
com.ruoyi: info
file:
# 日志目录,logback-spring.xml 从这里取
path: /home/railwaysystem/service/doorcontrol/logs
spring:
# 生产禁用热部署(devtools 打包后本就不生效,这里显式关闭)
devtools:
restart:
enabled: false
datasource:
druid:
master:
# 生产库;不在本机时改 host,口令与外置配置不一致时以外部配置为准
url: jdbc:mysql://192.168.106.24:3306/iotlocks?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
username: iotlocks
password: psw.db1234
# IOT设备接入(MQTT)
iot:
# 门锁协议主题:ydlink/{id}/thing/...,换厂家或改前缀时只改这里
lock:
protocol:
prefix: ydlink
thing: thing
# 人脸特征提取服务(数据库 lock_service_config 中 config_type=02 的配置优先级更高)
face:
endpoint: http://192.168.106.24:8802/bdws_api/Third/Api/Easen/getFaceFeatureData
# 调用超时(毫秒)
timeout: 10000
# 第三方开放接口:关闭时接口返回 404;启用后调用方需带请求头 X-API-Key
openapi:
enabled: true
# 应用清单以数据表 lock_open_app 为准,由「开放管理 › 应用接入」维护(改完即时生效,不用重启)。
# 本段 api-key / apps 仅作两用:首次启动且表为空时原样导入;表不可用(未执行 D21 脚本)时降级使用。
# 导入完成后可删除本段,只保留 enabled 与 path-prefix。
# 单应用兼容配置:apps 为空时以此 Key 导入,appCode 记为 DEFAULT
api-key: please-change-me
path-prefix: /openapi/iot
# 多应用配置(仅首次导入/降级用):每个业务系统一个独立 Key,
# 命中哪个应用就以该应用的 app-code 作为数据隔离身份(写入 lock_stay.biz_system)。
# 各应用 api-key 不得重复(重复或为空时启动即失败)。
# callback-secret 用于回调签名 HMAC-SHA256(timestamp + "." + body),为空表示不开通回调。示例:
# apps:
# - app-code: PMS
# app-name: 公寓 PMS
# api-key: <每个应用独立>
# callback-url: https://pms.example.com/hook/lock
# callback-secret: <回调签名密钥>
# enabled: true
# - app-code: VISITOR
# app-name: 访客系统
# api-key: <另一个独立 Key>
# callback-url: https://visitor.example.com/api/lock-callback
# callback-secret: <回调签名密钥>
# enabled: true
# 服务器系统时间:开门授权依赖时间窗,内网离线部署时可在此开放管理端校时。
# 高危操作:需 Linux + systemd(timedatectl),服务以 root 运行或配置免密 sudo。
# control-enabled=false 时接口只读;非 root 运行且已为该用户配置 NOPASSWD 时把 use-sudo 设为 true。
time:
control-enabled: false
use-sudo: false
timeout-ms: 8000
mqtt:
# 开关,false 时不创建任何连接
enabled: true
# MQTT服务器地址,ssl 场景使用 ssl://host:8883
broker-url: tcp://192.168.106.24:1883
# 客户端ID,同一服务器下必须唯一;为空时自动生成
client-id:
username: admin6
password: admin6
# false 时服务端保留离线消息,需配合固定 client-id
clean-session: true
connection-timeout: 30
keep-alive-interval: 60
# 断线自动重连
automatic-reconnect: true
max-reconnect-delay: 30000
# 连接健康检查周期(毫秒),0 表示不做定时重连
health-check-interval: 30000
# 发布消息等待服务端确认时间(毫秒),0 表示不等待
publish-timeout: 3000
# 消息持久化目录,为空使用内存
persistence-directory:
default-qos: 1
default-retained: false
# 通用静态订阅;门禁上行主题由 LockTopicSubscriber 按 iot.lock.protocol 前缀动态登记,
# 这里不要再写一遍,否则改前缀时会出现两套主题并存
topics: []
# 遗嘱消息:无法区分主动断开与异常掉线,门禁场景建议关闭(离线由 GatewayOfflineCheckTask 判定)
# will:
# topic: ydlink/status
# payload: offline
# qos: 1
# retained: true
@@ -27,6 +27,16 @@ public final class LockCommandPolicy
/** 失败后不自动重发的命令类型:人工操作重发可能造成重复执行 */
private static final Set<String> NO_RETRY = new HashSet<String>();
/**
* locklist 整表命令:网关回执即终态,失败后不自动重发
* <p>
* 实测网关对 add/clear 直接回 {@code params:["add","0"]} / {@code ["clear","0"]},
* 结果 0 就表示整表操作完成,不存在「先 10 再等门锁终态」的第二段回执。
* 超时后自动重发只会让 add 反复重建锁列表并重启网关、让 clear 重复清空,
* 因此只保留首次下发;网关忙(11)仍走排队重发——那时报文并没有被处理。
*/
private static final Set<String> WHOLE_LIST = new HashSet<String>();
/** 第 n 次重试的延迟(秒),超出后固定用最后一档 */
private static final int[] RETRY_DELAY_SECONDS = { 5, 15, 45, 120, 300 };
@@ -60,6 +70,10 @@ public final class LockCommandPolicy
NO_RETRY.add(LockConstants.CMD_SYNC_TIME);
NO_RETRY.add(LockConstants.CMD_SWITCH);
NO_RETRY.add(LockConstants.CMD_REBOOT);
WHOLE_LIST.add(LockConstants.CMD_BIND);
WHOLE_LIST.add(LockConstants.CMD_UNBIND);
WHOLE_LIST.add(LockConstants.CMD_CLEAR_DEVICE);
}
private LockCommandPolicy()
@@ -99,6 +113,22 @@ public final class LockCommandPolicy
return commandType != null && NO_RETRY.contains(commandType);
}
/**
* 是否是 locklist 整表命令(add/clear):网关回执即终态
*/
public static boolean isWholeListCommand(String commandType)
{
return commandType != null && WHOLE_LIST.contains(commandType);
}
/**
* 等待超时/失败后是否禁止自动重发:人工操作类命令与 locklist 整表命令都只发一次
*/
public static boolean isResendDisabled(String commandType)
{
return isRetryDisabled(commandType) || isWholeListCommand(commandType);
}
/**
* 设备回执是否是「正忙」
* <p>
@@ -115,6 +115,14 @@ public class LockConstants
*/
public static final String SYNC_CLEAR_FAIL = "6";
/**
* 同步状态:配置失败(门锁未分配网关 / 网关无分组,没有下发通道)。
* <p>
* 终态:重试不会自愈,补偿任务只捞 0/3,不能把这类行反复建命令;
* 修正网关归属后由页面手动重新下发。取 7:5 规划为已过期、6 已用于待清除。
*/
public static final String SYNC_CONFIG_FAIL = "7";
/** locklist 下发状态:待下发 */
public static final String BIND_PENDING = "0";
@@ -37,7 +37,7 @@ public class LockAccessLogController extends BaseController
public TableDataInfo list(LockAccessLog accessLog)
{
startPage();
List<LockAccessLog> list = lockAccessLogService.selectAccessLogList(accessLog);
List<LockAccessLog> list = lockAccessLogService.selectAccessLogListEnriched(accessLog);
return getDataTable(list);
}
@@ -69,7 +69,7 @@ public class LockAccessLogController extends BaseController
@PostMapping("/export")
public void export(HttpServletResponse response, LockAccessLog accessLog)
{
List<LockAccessLog> list = lockAccessLogService.selectAccessLogList(accessLog);
List<LockAccessLog> list = lockAccessLogService.selectAccessLogListEnriched(accessLog);
ExcelUtil<LockAccessLog> util = new ExcelUtil<LockAccessLog>(LockAccessLog.class);
util.exportExcel(response, list, "门禁通行记录");
}
@@ -304,7 +304,8 @@ public class LockAccessLog implements Serializable
{
return new ToStringBuilder(this, ToStringStyle.MULTI_LINE_STYLE).append("logId", getLogId())
.append("deviceSn", getDeviceSn()).append("roomNo", getRoomNo())
.append("personNo", getPersonNo()).append("cardNo", getCardNo())
.append("personNo", getPersonNo()).append("personName", getPersonName())
.append("cardNo", getCardNo())
.append("openType", getOpenType()).append("openResult", getOpenResult())
.append("openTime", getOpenTime()).append("recvTime", getRecvTime()).append("seq", getSeq())
.toString();
@@ -90,7 +90,7 @@ public class LockAuth extends BaseEntity
/** 查询:授权编号 / 人员编号 / 人员姓名模糊匹配(外部授权列表) */
private String keyword;
/** 下发同步状态(0待下发 1已下发 2下发成功 3下发失败 4已清除) */
/** 下发同步状态(0待下发 1已下发 2下发成功 3下发失败 4已清除 6待清除 7配置失败) */
@Excel(name = "同步状态", readConverterExp = "0=待下发,1=已下发,2=下发成功,3=下发失败,4=已清除")
private String syncStatus;
@@ -0,0 +1,107 @@
package com.ruoyi.iot.lock.domain;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
/**
* 多锁批量授权请求
* <p>
* 一条请求 = 一个授权模板 + 多把门锁,每把门锁生成一条独立的 lock_auth。
*
* @author ruoyi
*/
public class LockAuthBatchRequest
{
/** 人员编号 */
private String personNo;
/** 授权类型(01人脸 02密码 03指纹 04IC卡) */
private String authType;
/** 授权内容;人脸不传,由下发时按各门锁算法版本取特征 */
private String authValue;
/** 生效开始时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date beginTime;
/** 生效结束时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date endTime;
/** 备注 */
private String remark;
/** 授权门锁ID集合 */
private Long[] deviceIds;
public String getPersonNo()
{
return personNo;
}
public void setPersonNo(String personNo)
{
this.personNo = personNo;
}
public String getAuthType()
{
return authType;
}
public void setAuthType(String authType)
{
this.authType = authType;
}
public String getAuthValue()
{
return authValue;
}
public void setAuthValue(String authValue)
{
this.authValue = authValue;
}
public Date getBeginTime()
{
return beginTime;
}
public void setBeginTime(Date beginTime)
{
this.beginTime = beginTime;
}
public Date getEndTime()
{
return endTime;
}
public void setEndTime(Date endTime)
{
this.endTime = endTime;
}
public String getRemark()
{
return remark;
}
public void setRemark(String remark)
{
this.remark = remark;
}
public Long[] getDeviceIds()
{
return deviceIds;
}
public void setDeviceIds(Long[] deviceIds)
{
this.deviceIds = deviceIds;
}
}
@@ -0,0 +1,179 @@
package com.ruoyi.iot.lock.domain;
import java.util.ArrayList;
import java.util.List;
/**
* 多锁批量授权结果
*
* @author ruoyi
*/
public class LockAuthBatchResult
{
/** 新建 */
public static final String ACTION_CREATED = "CREATED";
/** 已存在,覆盖并重推 */
public static final String ACTION_OVERWRITTEN = "OVERWRITTEN";
/** 建档失败 */
public static final String ACTION_FAILED = "FAILED";
/** 提交门锁数 */
private int total;
/** 新建行数 */
private int created;
/** 覆盖行数 */
private int overwritten;
/** 失败行数 */
private int failed;
/** 逐锁结果 */
private List<Item> items = new ArrayList<Item>();
public int getTotal()
{
return total;
}
public void setTotal(int total)
{
this.total = total;
}
public int getCreated()
{
return created;
}
public void setCreated(int created)
{
this.created = created;
}
public int getOverwritten()
{
return overwritten;
}
public void setOverwritten(int overwritten)
{
this.overwritten = overwritten;
}
public int getFailed()
{
return failed;
}
public void setFailed(int failed)
{
this.failed = failed;
}
public List<Item> getItems()
{
return items;
}
public void setItems(List<Item> items)
{
this.items = items;
}
/**
* 单把门锁的处理结果
*/
public static class Item
{
private Long deviceId;
private String deviceSn;
private String deviceName;
/** CREATED / OVERWRITTEN / FAILED */
private String action;
private Long authId;
/** 建档后的同步状态,仅代表命令已提交或排队 */
private String syncStatus;
/** 同步说明或失败原因 */
private String syncMsg;
public Long getDeviceId()
{
return deviceId;
}
public void setDeviceId(Long deviceId)
{
this.deviceId = deviceId;
}
public String getDeviceSn()
{
return deviceSn;
}
public void setDeviceSn(String deviceSn)
{
this.deviceSn = deviceSn;
}
public String getDeviceName()
{
return deviceName;
}
public void setDeviceName(String deviceName)
{
this.deviceName = deviceName;
}
public String getAction()
{
return action;
}
public void setAction(String action)
{
this.action = action;
}
public Long getAuthId()
{
return authId;
}
public void setAuthId(Long authId)
{
this.authId = authId;
}
public String getSyncStatus()
{
return syncStatus;
}
public void setSyncStatus(String syncStatus)
{
this.syncStatus = syncStatus;
}
public String getSyncMsg()
{
return syncMsg;
}
public void setSyncMsg(String syncMsg)
{
this.syncMsg = syncMsg;
}
}
}
@@ -0,0 +1,224 @@
package com.ruoyi.iot.lock.domain;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
/**
* 人员维度授权汇总
* <p>
* 授权列表按人员聚合成一行:一人可有多把门锁、多种授权类型,
* 页面只展示汇总计数,具体门锁在编辑弹窗的目录树中维护。
*
* @author ruoyi
*/
public class LockAuthPerson
{
/** 查询:人员编号或姓名模糊匹配 */
private String keyword;
/** 查询:只统计该门锁上的授权 */
private Long deviceId;
/** 查询:只统计该分组下的授权 */
private Long groupId;
/** 查询:只统计该授权类型 */
private String authType;
/** 查询:只统计该同步状态 */
private String syncStatus;
private String personNo;
private String personName;
/** 逗号分隔的授权类型,如 01,02 */
private String authTypes;
/** 授权总数(不含已清除) */
private int authCount;
/** 涉及门锁数 */
private int deviceCount;
/** 下发成功数 */
private int issuedCount;
/** 待下发/下发中/待清除数 */
private int pendingCount;
/** 下发失败数 */
private int failedCount;
/** 最早生效时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date beginTime;
/** 最晚失效时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date endTime;
/** 最近一次维护时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date updateTime;
public String getKeyword()
{
return keyword;
}
public void setKeyword(String keyword)
{
this.keyword = keyword;
}
public Long getDeviceId()
{
return deviceId;
}
public void setDeviceId(Long deviceId)
{
this.deviceId = deviceId;
}
public Long getGroupId()
{
return groupId;
}
public void setGroupId(Long groupId)
{
this.groupId = groupId;
}
public String getAuthType()
{
return authType;
}
public void setAuthType(String authType)
{
this.authType = authType;
}
public String getSyncStatus()
{
return syncStatus;
}
public void setSyncStatus(String syncStatus)
{
this.syncStatus = syncStatus;
}
public String getPersonNo()
{
return personNo;
}
public void setPersonNo(String personNo)
{
this.personNo = personNo;
}
public String getPersonName()
{
return personName;
}
public void setPersonName(String personName)
{
this.personName = personName;
}
public String getAuthTypes()
{
return authTypes;
}
public void setAuthTypes(String authTypes)
{
this.authTypes = authTypes;
}
public int getAuthCount()
{
return authCount;
}
public void setAuthCount(int authCount)
{
this.authCount = authCount;
}
public int getDeviceCount()
{
return deviceCount;
}
public void setDeviceCount(int deviceCount)
{
this.deviceCount = deviceCount;
}
public int getIssuedCount()
{
return issuedCount;
}
public void setIssuedCount(int issuedCount)
{
this.issuedCount = issuedCount;
}
public int getPendingCount()
{
return pendingCount;
}
public void setPendingCount(int pendingCount)
{
this.pendingCount = pendingCount;
}
public int getFailedCount()
{
return failedCount;
}
public void setFailedCount(int failedCount)
{
this.failedCount = failedCount;
}
public Date getBeginTime()
{
return beginTime;
}
public void setBeginTime(Date beginTime)
{
this.beginTime = beginTime;
}
public Date getEndTime()
{
return endTime;
}
public void setEndTime(Date endTime)
{
this.endTime = endTime;
}
public Date getUpdateTime()
{
return updateTime;
}
public void setUpdateTime(Date updateTime)
{
this.updateTime = updateTime;
}
}
@@ -0,0 +1,201 @@
package com.ruoyi.iot.lock.domain;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
/**
* 锁内授权查询快照 lock_device_auth
* <p>
* 只保存设备 getkey 原样返回的权限行(密码、人脸),不保存对账结论;
* 比对在查询快照时实时与 lock_auth 完成,避免平台授权变化后结论过期。
*
* @author ruoyi
*/
public class LockDeviceAuth
{
/** 设备侧权限记录ID */
private Long deviceAuthId;
/** 门锁ID */
private Long deviceId;
/** 门锁SN */
private String deviceSn;
/** 查询命令seq */
private String commandNo;
/** 设备返回的索引 */
private Integer rowIndex;
/** 设备返回的总数 */
private Integer rowTotal;
/** 设备返回的授权类型码(实测 02 密码、07 人脸;与平台 auth_type 编码不同) */
private String authType;
/** 密码原文 / 人脸人员ID */
private String credential;
/** 生效时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date beginTime;
/** 失效时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date endTime;
/** 设备当前时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date deviceTime;
/** 原始行参数 */
private String rawParams;
/** 清除该行下发的delete命令seq */
private String clearCommandNo;
/** 写入时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime;
public Long getDeviceAuthId()
{
return deviceAuthId;
}
public void setDeviceAuthId(Long deviceAuthId)
{
this.deviceAuthId = deviceAuthId;
}
public Long getDeviceId()
{
return deviceId;
}
public void setDeviceId(Long deviceId)
{
this.deviceId = deviceId;
}
public String getDeviceSn()
{
return deviceSn;
}
public void setDeviceSn(String deviceSn)
{
this.deviceSn = deviceSn;
}
public String getCommandNo()
{
return commandNo;
}
public void setCommandNo(String commandNo)
{
this.commandNo = commandNo;
}
public Integer getRowIndex()
{
return rowIndex;
}
public void setRowIndex(Integer rowIndex)
{
this.rowIndex = rowIndex;
}
public Integer getRowTotal()
{
return rowTotal;
}
public void setRowTotal(Integer rowTotal)
{
this.rowTotal = rowTotal;
}
public String getCredential()
{
return credential;
}
public void setCredential(String credential)
{
this.credential = credential;
}
public String getAuthType()
{
return authType;
}
public void setAuthType(String authType)
{
this.authType = authType;
}
public Date getBeginTime()
{
return beginTime;
}
public void setBeginTime(Date beginTime)
{
this.beginTime = beginTime;
}
public Date getEndTime()
{
return endTime;
}
public void setEndTime(Date endTime)
{
this.endTime = endTime;
}
public Date getDeviceTime()
{
return deviceTime;
}
public void setDeviceTime(Date deviceTime)
{
this.deviceTime = deviceTime;
}
public String getRawParams()
{
return rawParams;
}
public void setRawParams(String rawParams)
{
this.rawParams = rawParams;
}
public String getClearCommandNo()
{
return clearCommandNo;
}
public void setClearCommandNo(String clearCommandNo)
{
this.clearCommandNo = clearCommandNo;
}
public Date getCreateTime()
{
return createTime;
}
public void setCreateTime(Date createTime)
{
this.createTime = createTime;
}
}
@@ -0,0 +1,35 @@
package com.ruoyi.iot.lock.domain;
/**
* 锁内授权清除请求
*
* @author ruoyi
*/
public class LockDeviceAuthClearRequest
{
/** 设备侧记录ID */
private Long deviceAuthId;
/** 凭据类型(02 密码 / 04 IC卡;能匹配平台授权时可不传) */
private String authType;
public Long getDeviceAuthId()
{
return deviceAuthId;
}
public void setDeviceAuthId(Long deviceAuthId)
{
this.deviceAuthId = deviceAuthId;
}
public String getAuthType()
{
return authType;
}
public void setAuthType(String authType)
{
this.authType = authType;
}
}
@@ -0,0 +1,221 @@
package com.ruoyi.iot.lock.domain;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonFormat;
/**
* 锁内授权对账快照
* <p>
* 取该门锁最近一次 0019(getkey)查询命令返回的设备侧权限行,与平台 lock_auth 实时比对后返回。
*
* @author ruoyi
*/
public class LockDeviceAuthSnapshot
{
private Long deviceId;
private String deviceSn;
private String deviceName;
/** 最近一次查询命令seq */
private String commandNo;
/** 查询命令状态(0待发送 1已发送 2成功 3失败 4超时;无查询记录时为空) */
private String queryStatus;
/** 查询命令状态名称 */
private String queryStatusName;
/** 查询说明/失败原因 */
private String queryMsg;
/** 查询下发时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date queryTime;
/** 设备侧返回条数 */
private int deviceCount;
/** 平台未清除授权条数 */
private int platformCount;
/** 双方一致条数(密码/人脸) */
private int matchedCount;
/** 仅设备有(孤儿)条数 */
private int deviceOnlyCount;
/** 仅平台有(缺失)条数 */
private int platformOnlyCount;
/** 平台有指纹等设备不支持查询的条数 */
private int notQueryableCount;
/** 协议限制说明 */
private String notice;
private List<LockDeviceAuthView> rows = new ArrayList<LockDeviceAuthView>();
public Long getDeviceId()
{
return deviceId;
}
public void setDeviceId(Long deviceId)
{
this.deviceId = deviceId;
}
public String getDeviceSn()
{
return deviceSn;
}
public void setDeviceSn(String deviceSn)
{
this.deviceSn = deviceSn;
}
public String getDeviceName()
{
return deviceName;
}
public void setDeviceName(String deviceName)
{
this.deviceName = deviceName;
}
public String getCommandNo()
{
return commandNo;
}
public void setCommandNo(String commandNo)
{
this.commandNo = commandNo;
}
public String getQueryStatus()
{
return queryStatus;
}
public void setQueryStatus(String queryStatus)
{
this.queryStatus = queryStatus;
}
public String getQueryStatusName()
{
return queryStatusName;
}
public void setQueryStatusName(String queryStatusName)
{
this.queryStatusName = queryStatusName;
}
public String getQueryMsg()
{
return queryMsg;
}
public void setQueryMsg(String queryMsg)
{
this.queryMsg = queryMsg;
}
public Date getQueryTime()
{
return queryTime;
}
public void setQueryTime(Date queryTime)
{
this.queryTime = queryTime;
}
public int getDeviceCount()
{
return deviceCount;
}
public void setDeviceCount(int deviceCount)
{
this.deviceCount = deviceCount;
}
public int getPlatformCount()
{
return platformCount;
}
public void setPlatformCount(int platformCount)
{
this.platformCount = platformCount;
}
public int getMatchedCount()
{
return matchedCount;
}
public void setMatchedCount(int matchedCount)
{
this.matchedCount = matchedCount;
}
public int getDeviceOnlyCount()
{
return deviceOnlyCount;
}
public void setDeviceOnlyCount(int deviceOnlyCount)
{
this.deviceOnlyCount = deviceOnlyCount;
}
public int getPlatformOnlyCount()
{
return platformOnlyCount;
}
public void setPlatformOnlyCount(int platformOnlyCount)
{
this.platformOnlyCount = platformOnlyCount;
}
public int getNotQueryableCount()
{
return notQueryableCount;
}
public void setNotQueryableCount(int notQueryableCount)
{
this.notQueryableCount = notQueryableCount;
}
public String getNotice()
{
return notice;
}
public void setNotice(String notice)
{
this.notice = notice;
}
public List<LockDeviceAuthView> getRows()
{
return rows;
}
public void setRows(List<LockDeviceAuthView> rows)
{
this.rows = rows;
}
}
@@ -0,0 +1,242 @@
package com.ruoyi.iot.lock.domain;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
/**
* 锁内授权对账行
* <p>
* 由「设备 getkey 快照 + 平台 lock_auth」实时合并而来:
* 设备实测可返回密码与人员ID(人脸),指纹类只展示平台账本。
*
* @author ruoyi
*/
public class LockDeviceAuthView
{
/** 设备侧记录ID(仅设备侧行有值,清除时回传) */
private Long deviceAuthId;
/** 来源:DEVICE 设备侧 / PLATFORM 仅平台账本 */
private String source;
/** 设备返回的索引 */
private Integer rowIndex;
/** 展示用凭据(密码原文 / 人脸人员ID) */
private String credential;
/** 授权类型(平台编码:01人脸 02密码 03指纹 04IC卡;无法判断时为空) */
private String authType;
/** 授权类型名称 */
private String authTypeName;
/** 人员编号 */
private String personNo;
/** 人员姓名 */
private String personName;
/** 平台授权同步状态(仅平台侧行有值) */
private String syncStatus;
/** 生效时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date beginTime;
/** 失效时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date endTime;
/**
* 对账状态:
* MATCHED 双方都有、DEVICE_ONLY 仅设备有、PLATFORM_ONLY 仅平台有、
* NOT_QUERYABLE 平台有但设备不支持查询
*/
private String matchStatus;
/** 对账状态名称 */
private String matchStatusName;
/** 清除状态:0未清除 1清除中 2已清除 3清除失败 */
private String clearStatus;
/** 清除状态名称 */
private String clearStatusName;
/** 是否允许清除(仅设备侧、未清除的行) */
private boolean clearable;
/** 说明 */
private String remark;
public Long getDeviceAuthId()
{
return deviceAuthId;
}
public void setDeviceAuthId(Long deviceAuthId)
{
this.deviceAuthId = deviceAuthId;
}
public String getSource()
{
return source;
}
public void setSource(String source)
{
this.source = source;
}
public Integer getRowIndex()
{
return rowIndex;
}
public void setRowIndex(Integer rowIndex)
{
this.rowIndex = rowIndex;
}
public String getCredential()
{
return credential;
}
public void setCredential(String credential)
{
this.credential = credential;
}
public String getAuthType()
{
return authType;
}
public void setAuthType(String authType)
{
this.authType = authType;
}
public String getAuthTypeName()
{
return authTypeName;
}
public void setAuthTypeName(String authTypeName)
{
this.authTypeName = authTypeName;
}
public String getPersonNo()
{
return personNo;
}
public void setPersonNo(String personNo)
{
this.personNo = personNo;
}
public String getPersonName()
{
return personName;
}
public void setPersonName(String personName)
{
this.personName = personName;
}
public String getSyncStatus()
{
return syncStatus;
}
public void setSyncStatus(String syncStatus)
{
this.syncStatus = syncStatus;
}
public Date getBeginTime()
{
return beginTime;
}
public void setBeginTime(Date beginTime)
{
this.beginTime = beginTime;
}
public Date getEndTime()
{
return endTime;
}
public void setEndTime(Date endTime)
{
this.endTime = endTime;
}
public String getMatchStatus()
{
return matchStatus;
}
public void setMatchStatus(String matchStatus)
{
this.matchStatus = matchStatus;
}
public String getMatchStatusName()
{
return matchStatusName;
}
public void setMatchStatusName(String matchStatusName)
{
this.matchStatusName = matchStatusName;
}
public String getClearStatus()
{
return clearStatus;
}
public void setClearStatus(String clearStatus)
{
this.clearStatus = clearStatus;
}
public String getClearStatusName()
{
return clearStatusName;
}
public void setClearStatusName(String clearStatusName)
{
this.clearStatusName = clearStatusName;
}
public boolean isClearable()
{
return clearable;
}
public void setClearable(boolean clearable)
{
this.clearable = clearable;
}
public String getRemark()
{
return remark;
}
public void setRemark(String remark)
{
this.remark = remark;
}
}
@@ -20,7 +20,7 @@ public interface LockAccessLogMapper
int deleteAccessLogByIds(Long[] logIds);
int deleteAccessLogBefore(String keepDate);
int deleteAccessLogBefore(@Param("keepDate") java.util.Date keepDate, @Param("limit") int limit);
/** 同一设备同一报文序号是否已存在(幂等去重) */
int countByDeviceAndSeq(@Param("deviceSn") String deviceSn, @Param("seq") String seq);
@@ -1,5 +1,6 @@
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.LockAuth;
@@ -95,4 +96,7 @@ public interface LockAuthMapper
int deleteAuthByIds(Long[] authIds);
int deleteAuthByDeviceId(Long deviceId);
/** 保留策略:删除已清除(4)或已删除(del_flag=2)且结束超过保留期的授权记录 */
int deleteFinishedBefore(@Param("keepDate") Date keepDate, @Param("limit") int limit);
}
@@ -56,4 +56,7 @@ public interface LockCallbackTaskMapper
/** 人工重发:死信/已成功都置回待发送,重试次数归零 */
int resetForResend(@Param("taskId") Long taskId, @Param("nextRetryTime") Date nextRetryTime);
/** 保留策略:已成功(2)/死信(3)按更新时间、待结果(0)/待发送(1)按创建时间,超过保留期即删 */
int deleteFinishedBefore(@Param("keepDate") Date keepDate, @Param("limit") int limit);
}
@@ -39,6 +39,10 @@ public interface LockCommandMapper
/** 指定业务对象是否已有指定类型的在途命令(锁内授权查询按门锁去重) */
int countInflightByBizId(@Param("commandType") String commandType, @Param("bizId") Long bizId);
/** 已发送、等待回执的某类命令,可按分组过滤,按命令ID倒序 */
List<LockCommand> selectSentCommands(@Param("commandType") String commandType,
@Param("groupId") Long groupId);
/** 指定业务对象最近一次指定类型的命令 */
LockCommand selectLatestByBizId(@Param("commandType") String commandType, @Param("bizId") Long bizId);
@@ -53,4 +57,7 @@ public interface LockCommandMapper
int updateCommand(LockCommand command);
int deleteCommandByIds(Long[] commandIds);
/** 保留策略:删除终态命令(成功/失败/超时/已取消)中下发时间早于 keepDate 的分批数据 */
int deleteFinishedBefore(@Param("keepDate") Date keepDate, @Param("limit") int limit);
}
@@ -0,0 +1,28 @@
package com.ruoyi.iot.lock.mapper;
import java.util.List;
import org.apache.ibatis.annotations.Param;
import com.ruoyi.iot.lock.domain.LockDeviceAuth;
/**
* 锁内授权查询快照 数据层
*
* @author ruoyi
*/
public interface LockDeviceAuthMapper
{
/** 按查询命令取全部设备侧权限行,按索引升序 */
List<LockDeviceAuth> selectByCommandNo(String commandNo);
LockDeviceAuth selectById(Long deviceAuthId);
/** 已收集的行数(跨实例安全:应答可能被不同实例消费) */
int countByCommandNo(String commandNo);
/** 按 (command_no, row_index) 幂等写入,重传只刷新内容 */
int upsert(LockDeviceAuth row);
/** 记录该行下发的清除命令 */
int updateClearCommand(@Param("deviceAuthId") Long deviceAuthId,
@Param("clearCommandNo") String clearCommandNo);
}
@@ -1,6 +1,8 @@
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.LockMqttLog;
/**
@@ -18,5 +20,5 @@ public interface LockMqttLogMapper
int deleteMqttLogByIds(Long[] logIds);
int deleteMqttLogBefore(String keepDate);
int deleteMqttLogBefore(@Param("keepDate") Date keepDate, @Param("limit") int limit);
}
@@ -27,6 +27,9 @@ public interface LockRoomMapper
/** 门锁当前绑定的房间(为空表示未被占用) */
LockRoom selectRoomByDeviceId(Long deviceId);
/** 批量取门锁当前绑定的房间(通行日志回填房间号,避免逐条查询) */
List<LockRoom> selectRoomsByDeviceIds(@Param("deviceIds") List<Long> deviceIds);
/**
* 按房号精确查询(对外接口约定房号全局唯一)。
* 返回列表以便调用方识别「房号不唯一」的脏数据。
@@ -71,4 +71,7 @@ public interface LockStayMapper
/** 授权回执回写入住单的人脸授权状态 */
int updateAuthStatusByAuthId(@Param("authId") Long authId, @Param("authStatus") String authStatus,
@Param("authMsg") String authMsg);
/** 保留策略:删除已退房/已换房/已取消且结束时间早于 keepDate 的入住单 */
int deleteFinishedBefore(@Param("keepDate") Date keepDate, @Param("limit") int limit);
}
@@ -7,18 +7,22 @@ import org.springframework.stereotype.Component;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.iot.core.IMqttMessageListener;
import com.ruoyi.iot.lock.LockConstants;
import com.ruoyi.iot.lock.domain.LockGateway;
import com.ruoyi.iot.lock.domain.LockMqttLog;
import com.ruoyi.iot.lock.mqtt.handler.AccessRecordHandler;
import com.ruoyi.iot.lock.mqtt.handler.LockSignalHandler;
import com.ruoyi.iot.lock.mqtt.handler.CommandRespHandler;
import com.ruoyi.iot.lock.mqtt.handler.GatewayOnlineHandler;
import com.ruoyi.iot.lock.mqtt.handler.ReportGatewayResolver;
import com.ruoyi.iot.lock.protocol.YdlinkCodec;
import com.ruoyi.iot.lock.protocol.YdlinkFrameDTO;
import com.ruoyi.iot.lock.protocol.YdlinkOp;
import com.ruoyi.iot.lock.protocol.YdlinkPayloadMasker;
import com.ruoyi.iot.lock.protocol.YdlinkTopicBuilder;
import com.ruoyi.iot.lock.protocol.YdlinkTopicDTO;
import com.ruoyi.iot.lock.protocol.YdlinkTopicType;
import com.ruoyi.iot.lock.service.ILockDeviceAuthService;
import com.ruoyi.iot.lock.service.ILockGatewayService;
import com.ruoyi.iot.lock.service.ILockMqttLogService;
import com.ruoyi.iot.domain.MqttMessageDTO;
@@ -47,6 +51,12 @@ public class LockMessageListener implements IMqttMessageListener
@Autowired
private LockSignalHandler lockSignalHandler;
@Autowired
private ReportGatewayResolver reportGatewayResolver;
@Autowired
private ILockGatewayService gatewayService;
@Autowired
private ILockDeviceAuthService deviceAuthService;
@@ -87,6 +97,7 @@ public class LockMessageListener implements IMqttMessageListener
frame.getMode());
return;
}
touchGatewayPresence(topic, frame);
String result;
String status = LockConstants.HANDLE_SUCCESS;
try
@@ -156,10 +167,39 @@ public class LockMessageListener implements IMqttMessageListener
recordLog(topic, frame, message, status, result, cost(start));
}
/**
* 上行报文即网关存活证明:离线(含整表清除后的重启等待)的网关收到上行后恢复在线。
* <p>
* 上线通知由 {@link GatewayOnlineHandler} 更新;locklist 回执先于重启到达,不算复通信号。
*/
private void touchGatewayPresence(YdlinkTopicDTO topic, YdlinkFrameDTO frame)
{
if (topic.getTopicType() == YdlinkTopicType.GATEWAY_ONLINE || YdlinkOp.LOCKLIST.equals(frame.getOp()))
{
return;
}
try
{
LockGateway gateway = reportGatewayResolver.resolve(topic, frame);
// 在线时是常态,不再回查数据库;只有离线才需要判断是否恢复
if (gateway != null && LockConstants.OFFLINE.equals(gateway.getOnlineStatus()))
{
gatewayService.touchOnlineByUplink(gateway.getGatewaySn());
}
}
catch (Exception e)
{
log.warn("上行归属网关识别失败, topic={}, msg={}", topic.getTopic(), e.getMessage());
}
}
private String handleKeyReport(YdlinkTopicDTO topic, YdlinkFrameDTO frame, String payload)
{
// 门锁逐条数据行 seq 恒为 0,用上报网关所属分组认领在途读取命令
LockGateway gateway = reportGatewayResolver.resolve(topic, frame);
return deviceAuthService.handleQueryReport(topic.getTopic(), frame.getSeq(),
YdlinkCodec.toRecordList(frame.getParams()), payload);
YdlinkCodec.toRecordList(frame.getParams()), payload,
gateway == null ? null : gateway.getGroupId());
}
private void recordLog(YdlinkTopicDTO topic, YdlinkFrameDTO frame, MqttMessageDTO message, String handleStatus,
@@ -79,6 +79,8 @@ public class CommandRespHandler
}
boolean gatewayAck = resp.getStage() == YdlinkRespSpec.Stage.GATEWAY_ACK;
boolean success = resp.getStage() == YdlinkRespSpec.Stage.SUCCESS;
// locklist 的 add/clear 实测直接回 ["add","0"] / ["clear","0"]:结果码 0 即整表操作完成,
// 不存在「网关 10 之后再等门锁终态」的第二段回执,命令层按终态收口且不再自动重发
if (resp.getStage() == YdlinkRespSpec.Stage.GATEWAY_BUSY)
{
// 网关忙表示报文没有转发到门锁,不能按门锁失败处理:
@@ -0,0 +1,90 @@
package com.ruoyi.iot.lock.protocol;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;
import com.ruoyi.common.utils.StringUtils;
/**
* getkey 应答时间解码
* <p>
* 实测应答中的时间字段是 8 位十六进制 Unix 秒(不带 0x),例如
* {@code 6AB80000} → 2026-09-26 前后:
* <pre>
* ["readkey","1","2","02","FFF54321","6AB80000","6AC80000",null,"6AC89C4D"]
* </pre>
* 协议文档 §4.6.5 曾描述为 5 字节压缩位域,与真实报文不符,已按实测改为
* 十六进制秒;文档中的位域公式不再使用。部分固件可能直接返回时间字符串,
* 这里一并兼容。
*
* @author ruoyi
*/
public final class YdlinkKeyTimeCodec
{
private static final String[] TEXT_PATTERNS = { "yyyy-MM-dd HH:mm:ss", "yyyy/MM/dd HH:mm:ss", "yyyyMMddHHmmss" };
/** 合理时间窗口,用来排除把普通数字误当 Unix 秒 */
private static final long MIN_EPOCH_SECONDS = 946684800L;
private static final long MAX_EPOCH_SECONDS = 4102444800L;
private YdlinkKeyTimeCodec()
{
}
/**
* 解析 getkey 应答中的时间字段,无法识别时返回 null
*/
public static Date decode(String raw)
{
if (StringUtils.isEmpty(raw))
{
return null;
}
String value = raw.trim();
if (value.contains("-") || value.contains("/") || (value.length() == 14 && value.chars().allMatch(Character::isDigit)))
{
return parseText(value);
}
if (value.length() == 8 && value.matches("[0-9a-fA-F]{8}"))
{
long epoch = Long.parseLong(value, 16);
if (isPlausible(epoch))
{
return new Date(epoch * 1000L);
}
}
if (value.length() <= 10 && value.chars().allMatch(Character::isDigit))
{
long epoch = Long.parseLong(value);
if (isPlausible(epoch))
{
return new Date(epoch * 1000L);
}
}
return null;
}
private static boolean isPlausible(long epochSeconds)
{
return epochSeconds >= MIN_EPOCH_SECONDS && epochSeconds < MAX_EPOCH_SECONDS;
}
private static Date parseText(String value)
{
for (String pattern : TEXT_PATTERNS)
{
SimpleDateFormat format = new SimpleDateFormat(pattern);
format.setLenient(false);
try
{
return format.parse(value);
}
catch (ParseException ignored)
{
// 继续尝试下一种格式
}
}
return null;
}
}
@@ -43,8 +43,8 @@ public final class YdlinkPayloadMasker
SECRET_INDEXES.put(YdlinkOp.IC_CARD_MIX, new int[] { 2, 4 });
// 配置服务器:账号 [6]、密码 [7]
SECRET_INDEXES.put(YdlinkOp.CONFIG, new int[] { 6, 7 });
// getkey:请求 [3]门锁密码;应答 [3]卡号/密码
SECRET_INDEXES.put(YdlinkOp.GET_KEY, new int[] { 3 });
// getkey:请求 [3]门锁密码;应答 [4]密码/人员ID(实测类型位在 [3])
SECRET_INDEXES.put(YdlinkOp.GET_KEY, new int[] { 3, 4 });
}
private YdlinkPayloadMasker()
@@ -14,6 +14,14 @@ public interface ILockAccessLogService
{
List<LockAccessLog> selectAccessLogList(LockAccessLog accessLog);
/**
* 平台通行日志列表:在 {@link #selectAccessLogList} 基础上补充房间号与通行人姓名
* <p>
* 房间号按门锁当前绑定关系、姓名按人员主数据推导,只给平台页面与导出用;
* 开放接口 /openapi/iot/accesslog/list 仍走原始查询。
*/
List<LockAccessLog> selectAccessLogListEnriched(LockAccessLog accessLog);
LockAccessLog selectAccessLogById(Long logId);
/**
@@ -41,7 +49,4 @@ public interface ILockAccessLogService
/** 按天统计通行量 */
List<Map<String, Object>> statistics(LockAccessLog accessLog);
/** 清理历史数据 */
int cleanBefore(String keepDate);
}
@@ -0,0 +1,51 @@
package com.ruoyi.iot.lock.service;
import java.util.List;
import com.ruoyi.iot.lock.domain.LockCommand;
import com.ruoyi.iot.lock.domain.LockDeviceAuthSnapshot;
/**
* 锁内授权查询与对账服务
* <p>
* 覆盖协议 getkey 实测可读到的密码与人脸权限:设备侧快照落在 lock_device_auth,
* 对账结论在读取时与平台 lock_auth 实时比对。
*
* @author ruoyi
*/
public interface ILockDeviceAuthService
{
/**
* 下发读取锁内授权指令(op=getkey,动作 readkey)
*
* @param deviceId 门锁ID
* @return 已下发的查询命令
*/
LockCommand startQuery(Long deviceId);
/**
* 最近一次查询快照 + 平台授权对账结果
*/
LockDeviceAuthSnapshot selectSnapshot(Long deviceId);
/**
* 清除设备侧的一条密码/IC卡授权(人脸请在授权管理中按人员清除)
*
* @param deviceId 门锁ID
* @param deviceAuthId 设备侧记录ID
* @param authType 凭据类型(02 密码 / 04 IC卡;设备返回类型码能识别时可空)
* @return 已下发的清除命令
*/
LockCommand clearCredential(Long deviceId, Long deviceAuthId, String authType);
/**
* 处理 getkey 应答:逐行落库,收到结束标记或条数到齐后结束查询命令
*
* @param topic 应答主题
* @param seq 报文seq(门锁逐条数据行为 0)
* @param records 应答参数行
* @param payload 应答原文
* @param groupId 上报网关所属分组,用于 seq=0 时认领在途读取命令
* @return 处理说明
*/
String handleQueryReport(String topic, String seq, List<List<String>> records, String payload, Long groupId);
}
@@ -53,6 +53,24 @@ public interface ILockGatewayService
/** 更新在线状态(由上行报文驱动) */
void updateOnlineStatus(String gatewaySn, String onlineStatus);
/**
* 整表清除后网关会重启:先置离线,收到上线通知或心跳后再恢复可用
*
* @param gatewayId 网关ID
* @param gatewaySn 网关SN
* @param reason 触发原因,仅用于日志
*/
void markRestarting(Long gatewayId, String gatewaySn, String reason);
/**
* 上行报文证明网关存活:离线状态下收到该网关的上行即恢复在线。
* <p>
* 刚下发整表命令后的短暂窗口内回执/门锁上报可能先于重启到达,该窗口内的上行不作数。
*
* @param gatewaySn 网关SN
*/
void touchOnlineByUplink(String gatewaySn);
/** 离线检测:超过指定分钟未上报的网关置为离线 */
int markOffline(int offlineMinutes);
}
@@ -17,6 +17,4 @@ public interface ILockMqttLogService
int insertMqttLog(LockMqttLog mqttLog);
int deleteMqttLogByIds(Long[] logIds);
int cleanBefore(String keepDate);
}
@@ -0,0 +1,20 @@
package com.ruoyi.iot.lock.service;
import java.util.Date;
import java.util.Map;
/**
* 数据保留策略:清理已完成且超过保留期的日志类与流水类数据
*
* @author ruoyi
*/
public interface ILockRetentionService
{
/**
* 分批清理各表超过保留期的数据
*
* @param keepDate 保留起点,早于该时间的已完成数据会被删除
* @return 各表实际删除条数(键为表的中文名)
*/
Map<String, Integer> cleanExpired(Date keepDate);
}
@@ -1,8 +1,13 @@
package com.ruoyi.iot.lock.service.impl;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Date;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.ruoyi.common.utils.StringUtils;
@@ -11,10 +16,14 @@ import com.ruoyi.iot.lock.LockOpenType;
import com.ruoyi.iot.lock.domain.LockAccessLog;
import com.ruoyi.iot.lock.domain.LockDevice;
import com.ruoyi.iot.lock.domain.LockGateway;
import com.ruoyi.iot.lock.domain.LockRoom;
import com.ruoyi.iot.lock.mapper.LockAccessLogMapper;
import com.ruoyi.iot.lock.mapper.LockDeviceMapper;
import com.ruoyi.iot.lock.mapper.LockGatewayMapper;
import com.ruoyi.iot.lock.mapper.LockRoomMapper;
import com.ruoyi.iot.lock.service.ILockAccessLogService;
import com.ruoyi.iot.person.domain.LockPerson;
import com.ruoyi.iot.person.mapper.LockPersonMapper;
/**
* 门禁通行日志服务实现
@@ -24,6 +33,9 @@ import com.ruoyi.iot.lock.service.ILockAccessLogService;
@Service
public class LockAccessLogServiceImpl implements ILockAccessLogService
{
/** 批量补充信息的 IN 查询上限(导出会一次传入大量日志) */
private static final int QUERY_BATCH_SIZE = 500;
@Autowired
private LockAccessLogMapper accessLogMapper;
@@ -33,16 +45,35 @@ public class LockAccessLogServiceImpl implements ILockAccessLogService
@Autowired
private LockGatewayMapper gatewayMapper;
@Autowired
private LockRoomMapper roomMapper;
@Autowired
private LockPersonMapper personMapper;
@Override
public List<LockAccessLog> selectAccessLogList(LockAccessLog accessLog)
{
return accessLogMapper.selectAccessLogList(accessLog);
}
@Override
public List<LockAccessLog> selectAccessLogListEnriched(LockAccessLog accessLog)
{
List<LockAccessLog> list = accessLogMapper.selectAccessLogList(accessLog);
enrich(list);
return list;
}
@Override
public LockAccessLog selectAccessLogById(Long logId)
{
return accessLogMapper.selectAccessLogById(logId);
LockAccessLog accessLog = accessLogMapper.selectAccessLogById(logId);
if (accessLog != null)
{
enrich(List.of(accessLog));
}
return accessLog;
}
@Override
@@ -114,9 +145,101 @@ public class LockAccessLogServiceImpl implements ILockAccessLogService
return accessLogMapper.statisticsByDay(accessLog);
}
@Override
public int cleanBefore(String keepDate)
/**
* 补充房间号与通行人姓名
* <p>
* 两项都不在 lock_access_log 落库:房间号取门锁当前绑定的房间,姓名取人员主数据。
* 按页批量查询后在内存回填,不逐条查库。
*/
private void enrich(List<LockAccessLog> logs)
{
return accessLogMapper.deleteAccessLogBefore(keepDate);
if (logs == null || logs.isEmpty())
{
return;
}
Map<Long, String> roomNoByDeviceId = loadRoomNoByDevice(logs);
Map<String, String> nameByPersonNo = loadPersonName(logs);
for (LockAccessLog log : logs)
{
if (log.getDeviceId() != null)
{
log.setRoomNo(roomNoByDeviceId.get(log.getDeviceId()));
}
String personName = log.getPersonNo() == null ? null : nameByPersonNo.get(log.getPersonNo());
// 人员主数据是姓名的权威来源;主数据查不到时保留入库时的快照
if (StringUtils.isNotEmpty(personName))
{
log.setPersonName(personName);
}
}
}
private Map<Long, String> loadRoomNoByDevice(List<LockAccessLog> logs)
{
Set<Long> deviceIds = new LinkedHashSet<>();
for (LockAccessLog log : logs)
{
if (log.getDeviceId() != null)
{
deviceIds.add(log.getDeviceId());
}
}
Map<Long, String> roomNoByDeviceId = new HashMap<>();
for (List<Long> batch : partition(deviceIds))
{
for (LockRoom room : roomMapper.selectRoomsByDeviceIds(batch))
{
// 脏数据可能多个房间绑同一把锁,保留先查到的一条
if (room.getDeviceId() != null && room.getRoomNo() != null)
{
roomNoByDeviceId.putIfAbsent(room.getDeviceId(), room.getRoomNo());
}
}
}
return roomNoByDeviceId;
}
private Map<String, String> loadPersonName(List<LockAccessLog> logs)
{
Set<String> personNos = new LinkedHashSet<>();
for (LockAccessLog log : logs)
{
if (StringUtils.isNotEmpty(log.getPersonNo()))
{
personNos.add(log.getPersonNo());
}
}
Map<String, String> nameByPersonNo = new HashMap<>();
for (List<String> batch : partition(personNos))
{
for (LockPerson person : personMapper.selectPersonNamesByNos(batch))
{
if (StringUtils.isNotEmpty(person.getPersonName()))
{
nameByPersonNo.put(person.getPersonNo(), person.getPersonName());
}
}
}
return nameByPersonNo;
}
private <T> List<List<T>> partition(Collection<T> ids)
{
List<List<T>> batches = new ArrayList<>();
List<T> batch = new ArrayList<>(QUERY_BATCH_SIZE);
for (T id : ids)
{
batch.add(id);
if (batch.size() >= QUERY_BATCH_SIZE)
{
batches.add(batch);
batch = new ArrayList<>(QUERY_BATCH_SIZE);
}
}
if (!batch.isEmpty())
{
batches.add(batch);
}
return batches;
}
}
@@ -927,7 +927,8 @@ public class LockAuthServiceImpl implements ILockAuthService
}
catch (IllegalArgumentException e)
{
updateSyncFail(auth, e.getMessage());
// 未分配网关/网关无分组属于配置问题,重发不会自愈:置终态,补偿任务不再重试
updateConfigFail(auth, e.getMessage());
return;
}
String personFailReason = resolvePersonFailReason(auth);
@@ -1002,6 +1003,18 @@ public class LockAuthServiceImpl implements ILockAuthService
log.warn("授权下发跳过, authId={}, reason={}", auth.getAuthId(), reason);
}
/**
* 配置类失败:门锁没有下发通道(未登记到任何网关、网关无分组)。
* <p>
* 与 {@link #updateSyncFail} 的区别是置 7 而不是 3:补偿任务只捞 0/3,
* 这类行不会被反复重发;修正归属后由页面手动下发。
*/
private void updateConfigFail(LockAuth auth, String reason)
{
authMapper.updateSyncStatus(auth.getAuthId(), LockConstants.SYNC_CONFIG_FAIL, reason);
log.warn("授权无下发通道, authId={}, deviceId={}, reason={}", auth.getAuthId(), auth.getDeviceId(), reason);
}
/**
* 人员可用性校验:人员可能在建授权之后被停用或过期,下发前必须再判一次。
* 人员主数据缺失同样拒绝——授权下发到哪里开门,必须先能回答「是谁」。
@@ -125,7 +125,21 @@ public class LockCommandServiceImpl implements ILockCommandService
}
if (isFinished(command))
{
// 已终结的命令不再被后续回执改写
// 已终结的命令不再被后续回执改写;locklist 例外:网关回执 0 表示整表操作已完成,
// 即使此前因等不到回执被判失败/超时,也要按这条迟到的成功回执纠正结果
boolean recoverable = LockConstants.STATUS_FAIL.equals(command.getCommandStatus())
|| LockConstants.STATUS_TIMEOUT.equals(command.getCommandStatus());
if (success && recoverable && LockCommandPolicy.isWholeListCommand(command.getCommandType()))
{
command.setRespTopic(topic);
command.setRespPayload(YdlinkPayloadMasker.maskPayload(payload));
command.setRespTime(new Date());
command.setDeviceAck(LockConstants.ACK_YES);
command.setDeviceResult(LockConstants.DEVICE_RESULT_SUCCESS);
log.info("收到 locklist 迟到的成功回执,纠正命令结果, seq={}, commandType={}", seq,
command.getCommandType());
return finish(command, true, message);
}
return null;
}
command.setRespTopic(topic);
@@ -357,9 +371,10 @@ public class LockCommandServiceImpl implements ILockCommandService
update.setCommandStatus(LockConstants.STATUS_TIMEOUT);
update.setErrorMsg("网关已接收,等待门锁回执超时");
}
else if (LockCommandPolicy.isRetryDisabled(command.getCommandType()))
else if (LockCommandPolicy.isResendDisabled(command.getCommandType()))
{
// 该类命令不自动重发,超时即终态
// 该类命令不自动重发,超时即终态;locklist 的 add/clear 回执即整表操作完成,
// 重发会导致网关反复重建锁列表并重启(add)或重复清空(clear)
update.setCommandStatus(LockConstants.STATUS_TIMEOUT);
update.setErrorMsg("等待响应超时(该命令不自动重发)");
}
@@ -461,9 +476,9 @@ public class LockCommandServiceImpl implements ILockCommandService
}
continue;
}
if (LockCommandPolicy.isRetryDisabled(command.getCommandType()))
if (LockCommandPolicy.isResendDisabled(command.getCommandType()))
{
// 时间同步、远程开关锁失败后不自动重发
// 时间同步、远程开关锁、locklist 整表命令失败后不自动重发
continue;
}
if (commandMapper.countOtherInflightByTopic(command.getTopic(), command.getCommandId()) > 0)
@@ -532,6 +547,12 @@ public class LockCommandServiceImpl implements ILockCommandService
{
throw new IllegalArgumentException("命令不存在");
}
if (LockCommandPolicy.isWholeListCommand(command.getCommandType())
&& LockConstants.STATUS_SUCCESS.equals(command.getCommandStatus()))
{
// locklist 回执 0 已表示整表操作完成,再发一次会重建列表并触发网关重启
throw new IllegalArgumentException("该 locklist 指令已成功,无需重发");
}
LockCommand update = new LockCommand();
update.setCommandId(commandId);
Integer retryCount = command.getRetryCount() == null ? 0 : command.getRetryCount();
@@ -0,0 +1,985 @@
package com.ruoyi.iot.lock.service.impl;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
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.alibaba.fastjson2.JSON;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.iot.lock.LockConstants;
import com.ruoyi.iot.lock.domain.LockAuth;
import com.ruoyi.iot.lock.domain.LockCommand;
import com.ruoyi.iot.lock.domain.LockDevice;
import com.ruoyi.iot.lock.domain.LockDeviceAuth;
import com.ruoyi.iot.lock.domain.LockDeviceAuthSnapshot;
import com.ruoyi.iot.lock.domain.LockDeviceAuthView;
import com.ruoyi.iot.lock.mapper.LockAuthMapper;
import com.ruoyi.iot.lock.mapper.LockCommandMapper;
import com.ruoyi.iot.lock.mapper.LockDeviceAuthMapper;
import com.ruoyi.iot.lock.mapper.LockDeviceMapper;
import com.ruoyi.iot.lock.protocol.YdlinkCodec;
import com.ruoyi.iot.lock.protocol.YdlinkFrameDTO;
import com.ruoyi.iot.lock.protocol.YdlinkKeyTimeCodec;
import com.ruoyi.iot.lock.protocol.YdlinkOp;
import com.ruoyi.iot.lock.protocol.YdlinkRespSpec;
import com.ruoyi.iot.lock.protocol.YdlinkTopicBuilder;
import com.ruoyi.iot.lock.protocol.YdlinkTopicType;
import com.ruoyi.iot.lock.route.RouteKey;
import com.ruoyi.iot.lock.route.RouteResolver;
import com.ruoyi.iot.lock.route.RouteTarget;
import com.ruoyi.iot.lock.service.ILockCommandService;
import com.ruoyi.iot.lock.service.ILockDeviceAuthService;
/**
* 锁内授权查询与对账服务实现
*
* @author ruoyi
*/
@Service
public class LockDeviceAuthServiceImpl implements ILockDeviceAuthService
{
private static final Logger log = LoggerFactory.getLogger(LockDeviceAuthServiceImpl.class);
/** getkey 请求首项固定为 readkey */
private static final String READ_KEY_ACTION = "readkey";
/** 协议模板中的操作用户占位 */
private static final String READ_KEY_USER = "admin";
/** 对账来源 */
private static final String SOURCE_DEVICE = "DEVICE";
private static final String SOURCE_PLATFORM = "PLATFORM";
/** 设备返回的类型码:密码 */
private static final String DEV_TYPE_PASSWORD = "02";
/** 设备返回的类型码:人脸(与平台 auth_type=01 编码不同) */
private static final String DEV_TYPE_FACE = "07";
/** 数据行在动作名之后至少要有:索引、总数、类型、凭据、起、止 */
private static final int MIN_DATA_FIELDS = 6;
/** 查询类授权:设备只能返回密码/IC卡 */
private static final String AUTH_TYPE_NAME_PASSWORD = "密码";
private static final String AUTH_TYPE_NAME_IC = "IC卡";
@Autowired
private LockDeviceMapper deviceMapper;
@Autowired
private LockDeviceAuthMapper deviceAuthMapper;
@Autowired
private LockAuthMapper authMapper;
@Autowired
private LockCommandMapper commandMapper;
@Autowired
private RouteResolver routeResolver;
@Autowired
private ILockCommandService commandService;
@Override
@Transactional
public LockCommand startQuery(Long deviceId)
{
LockDevice device = loadDevice(deviceId);
if (commandMapper.countInflightByBizId(LockConstants.CMD_READ_AUTH, deviceId) > 0)
{
throw new IllegalArgumentException("该门锁已有读取指令在途,请等待设备返回后再试");
}
RouteTarget target;
RouteKey route;
try
{
target = routeResolver.forDevice(device);
route = target.routeFor(YdlinkTopicType.GROUP_DATA_BATCH);
}
catch (IllegalArgumentException e)
{
throw new IllegalArgumentException(e.getMessage());
}
String deviceSn = StringUtils.defaultString(device.getDeviceSn());
String devicePsw = StringUtils.defaultString(device.getDevicePassword());
YdlinkFrameDTO frame = YdlinkCodec.buildDownFrame(YdlinkOp.GET_KEY,
target.getDeviceRef().groupDevId(), devicePsw, StringUtils.defaultString(device.getDevVer()),
StringUtils.defaultString(device.getDevType()));
frame.setFlatParams(Arrays.asList(READ_KEY_ACTION, READ_KEY_USER, deviceSn, devicePsw));
LockCommand command = new LockCommand();
command.setCommandNo(frame.getSeq());
command.setCommandType(LockConstants.CMD_READ_AUTH);
command.setTargetType(LockConstants.TARGET_GROUP);
command.setTargetId(target.getDeviceRef().getGroupId());
command.setTargetCode(target.getDeviceRef().getGroupCode());
// 查询命令没有对应的 lock_auth 行,biz_id 复用为门锁ID;授权结果回写按命令类型过滤,不会误更新授权
command.setBizId(deviceId);
command.setMaxRetry(1);
command.setTopic(YdlinkTopicBuilder.build(route, YdlinkTopicType.GROUP_DATA_BATCH));
command.setPayload(YdlinkCodec.toJson(frame));
commandService.send(command);
log.info("锁内授权读取已下发, deviceSn={}, commandNo={}", deviceSn, command.getCommandNo());
return command;
}
@Override
public LockDeviceAuthSnapshot selectSnapshot(Long deviceId)
{
LockDevice device = loadDevice(deviceId);
LockCommand latest = commandMapper.selectLatestByBizId(LockConstants.CMD_READ_AUTH, deviceId);
LockCommand rowCommand = latest;
boolean fallback = false;
List<LockDeviceAuth> deviceRows = new ArrayList<LockDeviceAuth>();
if (latest != null)
{
deviceRows = deviceAuthMapper.selectByCommandNo(latest.getCommandNo());
if (deviceRows.isEmpty() && isTerminalFailure(latest.getCommandStatus()))
{
// 最近一次查询失败时展示上次有数据的查询,避免一次离线重查把对账结果冲掉
LockCommand withRows = commandMapper.selectLatestWithRowsByBizId(LockConstants.CMD_READ_AUTH,
deviceId);
if (withRows != null && !withRows.getCommandNo().equals(latest.getCommandNo()))
{
deviceRows = deviceAuthMapper.selectByCommandNo(withRows.getCommandNo());
rowCommand = withRows;
fallback = true;
}
}
}
List<LockAuth> platformAuths = authMapper.selectAuthsByDeviceId(deviceId);
LockDeviceAuthSnapshot snapshot = new LockDeviceAuthSnapshot();
snapshot.setDeviceId(deviceId);
snapshot.setDeviceSn(device.getDeviceSn());
snapshot.setDeviceName(device.getDeviceName());
snapshot.setCommandNo(rowCommand == null ? null : rowCommand.getCommandNo());
if (latest != null)
{
snapshot.setQueryStatus(latest.getCommandStatus());
snapshot.setQueryStatusName(commandStatusName(latest.getCommandStatus()));
snapshot.setQueryMsg(resolveQueryMsg(latest));
snapshot.setQueryTime(latest.getReqTime());
}
buildRows(snapshot, deviceRows, platformAuths);
snapshot.setNotice(buildNotice(latest, fallback, snapshot, deviceRows));
return snapshot;
}
@Override
@Transactional
public LockCommand clearCredential(Long deviceId, Long deviceAuthId, String authType)
{
LockDevice device = loadDevice(deviceId);
LockDeviceAuth row = deviceAuthMapper.selectById(deviceAuthId);
if (row == null || !deviceId.equals(row.getDeviceId()))
{
throw new IllegalArgumentException("设备侧授权记录不存在或不属于该门锁");
}
ensureCurrentRow(deviceId, row);
ensureNotClearing(row);
String rowPlatformType = platformAuthType(row.getAuthType());
if (LockConstants.AUTH_FACE.equals(rowPlatformType))
{
throw new IllegalArgumentException("人脸授权请在授权管理中按人员清除");
}
LockAuth matched = matchPlatformAuth(deviceId, row.getCredential(), rowPlatformType);
LockAuth effectiveMatch = matched;
String actualType = StringUtils.isNotEmpty(rowPlatformType) ? rowPlatformType : StringUtils.trim(authType);
if (matched != null)
{
if (StringUtils.isNotEmpty(actualType) && !actualType.equals(matched.getAuthType()))
{
throw new IllegalArgumentException("凭据类型与平台授权不一致,请刷新后重试");
}
actualType = matched.getAuthType();
if (LockConstants.SYNC_CLEARED.equals(matched.getSyncStatus()))
{
// 平台账本已清除时只清设备侧残留,不再回写已删除的授权行
effectiveMatch = null;
}
else if (commandMapper.countInflightAuthByBizId(matched.getAuthId()) > 0)
{
throw new IllegalArgumentException("该平台授权仍有下发/清除指令在途,请稍后再操作");
}
}
else if (!LockConstants.AUTH_PASSWORD.equals(actualType) && !LockConstants.AUTH_IC_CARD.equals(actualType))
{
throw new IllegalArgumentException("无法判断凭据类型,请选择按密码或IC卡清除");
}
RouteTarget target;
RouteKey route;
try
{
target = routeResolver.forDevice(device);
route = target.routeFor(YdlinkTopicType.GROUP_DATA_BATCH);
}
catch (IllegalArgumentException e)
{
throw new IllegalArgumentException(e.getMessage());
}
String op = LockConstants.AUTH_IC_CARD.equals(actualType) ? YdlinkOp.IC_CARD : YdlinkOp.PASSWORD;
String deviceSn = StringUtils.defaultString(device.getDeviceSn());
String devicePsw = StringUtils.defaultString(device.getDevicePassword());
YdlinkFrameDTO frame = YdlinkCodec.buildDownFrame(op, target.getDeviceRef().groupDevId(), devicePsw,
StringUtils.defaultString(device.getDevVer()), StringUtils.defaultString(device.getDevType()));
// 密码/IC卡删除比人脸多一个凭据位:["delete", 凭据, 凭据, 门锁SN, 门锁密码]
frame.setFlatParams(Arrays.asList("delete", row.getCredential(), row.getCredential(), deviceSn, devicePsw));
LockCommand command = new LockCommand();
command.setCommandNo(frame.getSeq());
command.setCommandType(LockConstants.CMD_AUTH_CLEAR);
command.setTargetType(LockConstants.TARGET_GROUP);
command.setTargetId(target.getDeviceRef().getGroupId());
command.setTargetCode(target.getDeviceRef().getGroupCode());
command.setBizId(effectiveMatch == null ? null : effectiveMatch.getAuthId());
command.setTopic(YdlinkTopicBuilder.build(route, YdlinkTopicType.GROUP_DATA_BATCH));
command.setPayload(YdlinkCodec.toJson(frame));
if (effectiveMatch != null)
{
// 能匹配到平台账本时一并回写同步状态,避免平台仍显示「下发成功」而设备已删除
authMapper.updateSyncStatus(effectiveMatch.getAuthId(), LockConstants.SYNC_SENT, "清除指令已下发");
}
commandService.send(command);
deviceAuthMapper.updateClearCommand(row.getDeviceAuthId(), command.getCommandNo());
log.info("锁内授权清除已下发, deviceSn={}, deviceAuthId={}, authType={}, authId={}, commandNo={}", deviceSn,
deviceAuthId, actualType, command.getBizId(), command.getCommandNo());
return command;
}
@Override
@Transactional
public String handleQueryReport(String topic, String seq, List<List<String>> records, String payload, Long groupId)
{
if (records == null || records.isEmpty())
{
return "读取应答为空,命令保持等待";
}
LockCommand command = resolveQueryCommand(seq, groupId);
if (command == null)
{
// 门锁逐条数据行的 seq 恒为 0,只能按网关分组认领在途命令;认不到就保持等待,避免写错门锁
log.warn("锁内授权读取应答未匹配到在途命令, seq={}, groupId={}, rows={}", seq, groupId, records.size());
return "读取应答未匹配到在途命令, seq=" + seq;
}
Long deviceId = command.getBizId();
if (deviceId == null)
{
return "读取命令缺少门锁ID, seq=" + command.getCommandNo();
}
LockDevice device = deviceMapper.selectDeviceById(deviceId);
String deviceSn = device == null ? null : device.getDeviceSn();
int saved = 0;
Integer total = null;
boolean ended = false;
boolean progressAck = false;
for (int i = 0; i < records.size(); i++)
{
List<String> row = records.get(i);
if (row == null || row.isEmpty())
{
continue;
}
// 协议模板首项是动作名 readkey;个别固件可能省略,按首项是否为 readkey 决定字段偏移
int offset = READ_KEY_ACTION.equalsIgnoreCase(YdlinkCodec.get(row, 0)) ? 1 : 0;
if (isEndMarker(row, offset))
{
ended = true;
continue;
}
Integer index = parseInteger(YdlinkCodec.get(row, offset));
if (index == null || row.size() < offset + MIN_DATA_FIELDS)
{
// 网关中间态:["readkey","10","ad","null"],只表示已转发,不是授权行
if (YdlinkRespSpec.CODE_GATEWAY_ACK.equals(YdlinkCodec.get(row, offset))
&& StringUtils.isNotEmpty(seq))
{
commandService.handleGatewayAck(topic, seq, payload, "网关已接收,等待门锁返回");
progressAck = true;
}
continue;
}
Integer rowTotal = parseInteger(YdlinkCodec.get(row, offset + 1));
String deviceType = YdlinkCodec.get(row, offset + 2);
String credential = YdlinkCodec.get(row, offset + 3);
if (rowTotal != null)
{
total = rowTotal;
}
if (StringUtils.isEmpty(credential) || isNullText(credential))
{
continue;
}
LockDeviceAuth entity = new LockDeviceAuth();
entity.setDeviceId(deviceId);
entity.setDeviceSn(deviceSn);
entity.setCommandNo(command.getCommandNo());
entity.setRowIndex(index < 0 ? i + 1 : index);
entity.setRowTotal(rowTotal);
entity.setAuthType(deviceType);
entity.setCredential(credential);
entity.setBeginTime(YdlinkKeyTimeCodec.decode(YdlinkCodec.get(row, offset + 4)));
entity.setEndTime(YdlinkKeyTimeCodec.decode(YdlinkCodec.get(row, offset + 5)));
if (row.size() > offset + 6)
{
// [7] 是固定空占位,设备当前时间在末位
entity.setDeviceTime(YdlinkKeyTimeCodec.decode(YdlinkCodec.get(row, row.size() - 1)));
}
entity.setRawParams(StringUtils.substring(JSON.toJSONString(row), 0, 1000));
try
{
deviceAuthMapper.upsert(entity);
saved++;
}
catch (Exception e)
{
log.warn("锁内授权行写入失败, deviceSn={}, commandNo={}, index={}, msg={}", deviceSn,
command.getCommandNo(), index, e.getMessage());
}
}
if (saved == 0 && !ended && !progressAck)
{
// 没有任何可解析行:命令保持等待,由超时给出可见状态
log.warn("锁内授权读取应答无法解析, commandNo={}, rows={}", command.getCommandNo(), records.size());
return "读取应答无法解析,命令保持等待";
}
int collected = deviceAuthMapper.countByCommandNo(command.getCommandNo());
String message = total == null ? "已收集 " + collected + " 条"
: "设备返回 " + total + " 条,已收集 " + collected + " 条";
boolean allArrived = total != null && total > 0 && collected >= total;
if (ended || allArrived)
{
// ["readkey","NULL"] 是读取结束标记;条数到齐也直接收口,避免结束标记丢失时命令空等超时
commandService.handleResponse(topic, command.getCommandNo(), payload, true, message);
}
log.info("锁内授权读取应答已收集, commandNo={}, seq={}, saved={}, collected={}, total={}, ended={}",
command.getCommandNo(), seq, saved, collected, total, ended);
return message;
}
/**
* 应答归属:带命令seq的网关回执直接匹配;门锁逐条数据 seq=0,
* 按「上报网关所属分组已发送的读取命令」认领。只认已发送(SENT)的命令,
* 因网关忙被退回待发送的命令还没转发到门锁,不能认领数据行。
*/
private LockCommand resolveQueryCommand(String seq, Long groupId)
{
if (StringUtils.isNotEmpty(seq) && !"0".equals(seq.trim()))
{
LockCommand bySeq = commandMapper.selectCommandByNo(seq.trim());
if (bySeq != null && LockConstants.CMD_READ_AUTH.equals(bySeq.getCommandType()))
{
return bySeq;
}
}
List<LockCommand> sent = commandMapper.selectSentCommands(LockConstants.CMD_READ_AUTH, groupId);
if (sent.isEmpty() && groupId == null)
{
// 无法确认上报网关时,只有全局唯一一条已发送读取命令才认领,避免串锁
sent = commandMapper.selectSentCommands(LockConstants.CMD_READ_AUTH, null);
}
if (sent.isEmpty())
{
return null;
}
if (sent.size() > 1)
{
log.warn("同一分组存在多条已发送读取命令,按最新一条认领, groupId={}, count={}", groupId, sent.size());
}
return sent.get(0);
}
/** 结束标记 ["readkey","NULL"] */
private boolean isEndMarker(List<String> row, int offset)
{
return "NULL".equalsIgnoreCase(YdlinkCodec.get(row, offset));
}
private boolean isNullText(String value)
{
return "null".equalsIgnoreCase(StringUtils.trim(value));
}
/**
* 合并设备快照与平台账本,生成对账行
*/
private void buildRows(LockDeviceAuthSnapshot snapshot, List<LockDeviceAuth> deviceRows, List<LockAuth> platformAuths)
{
Map<String, LockCommand> clearCommands = loadClearCommands(deviceRows);
boolean[] consumed = new boolean[platformAuths.size()];
List<LockDeviceAuthView> rows = new ArrayList<LockDeviceAuthView>();
int matched = 0;
int deviceOnly = 0;
int deviceCount = 0;
for (LockDeviceAuth deviceRow : deviceRows)
{
if (isNullText(deviceRow.getCredential()))
{
// 早期版本把网关进度回执写成了凭据 "null",这类脏行不参与对账
continue;
}
deviceCount++;
String platformType = platformAuthType(deviceRow.getAuthType());
int matchIndex = findPlatformIndex(platformAuths, deviceRow, platformType, consumed);
LockDeviceAuthView view = new LockDeviceAuthView();
view.setSource(SOURCE_DEVICE);
view.setDeviceAuthId(deviceRow.getDeviceAuthId());
view.setRowIndex(deviceRow.getRowIndex());
view.setAuthType(platformType);
view.setAuthTypeName(platformType == null ? unknownTypeName(deviceRow.getAuthType())
: authTypeName(platformType));
view.setBeginTime(deviceRow.getBeginTime());
view.setEndTime(deviceRow.getEndTime());
List<String> remarks = new ArrayList<String>();
if (platformType == null && StringUtils.isNotEmpty(deviceRow.getAuthType()))
{
remarks.add("设备类型码 " + deviceRow.getAuthType() + " 未映射");
}
if (matchIndex >= 0)
{
consumed[matchIndex] = true;
LockAuth auth = platformAuths.get(matchIndex);
matched++;
view.setCredential(deviceRow.getCredential());
view.setAuthType(auth.getAuthType());
view.setAuthTypeName(authTypeName(auth.getAuthType()));
view.setPersonNo(auth.getPersonNo());
view.setPersonName(auth.getPersonName());
view.setSyncStatus(auth.getSyncStatus());
view.setMatchStatus("MATCHED");
view.setMatchStatusName("一致");
if (!sameTime(deviceRow.getBeginTime(), auth.getBeginTime())
|| !sameTime(deviceRow.getEndTime(), auth.getEndTime()))
{
remarks.add("有效期与平台不一致");
}
if (LockConstants.SYNC_CLEARED.equals(auth.getSyncStatus()))
{
remarks.add("平台授权标记为已清除,设备侧仍存在");
}
}
else
{
deviceOnly++;
view.setCredential(deviceRow.getCredential());
view.setMatchStatus("DEVICE_ONLY");
view.setMatchStatusName("仅设备有(孤儿)");
remarks.add(LockConstants.AUTH_FACE.equals(platformType)
? "平台无该人员ID的人脸授权,可能为手工录入或历史遗留"
: "平台无对应授权,可能是手工录入或历史遗留");
}
if (isExpired(deviceRow.getEndTime()))
{
remarks.add("设备侧已过期");
}
boolean faceRow = LockConstants.AUTH_FACE.equals(platformType);
if (faceRow)
{
remarks.add("人脸授权请在授权管理中按人员清除");
}
applyClearState(view, deviceRow, clearCommands);
if (faceRow)
{
// 本页只支持密码/IC卡删除报文,人脸走授权管理的人员维度删除
view.setClearable(false);
}
view.setRemark(joinRemarks(remarks));
rows.add(view);
}
int platformOnly = 0;
int notQueryable = 0;
int platformActive = 0;
for (int i = 0; i < platformAuths.size(); i++)
{
LockAuth auth = platformAuths.get(i);
if (LockConstants.SYNC_CLEARED.equals(auth.getSyncStatus()))
{
continue;
}
platformActive++;
if (consumed[i])
{
continue;
}
LockDeviceAuthView view = new LockDeviceAuthView();
view.setSource(SOURCE_PLATFORM);
view.setAuthType(auth.getAuthType());
view.setAuthTypeName(authTypeName(auth.getAuthType()));
view.setPersonNo(auth.getPersonNo());
view.setPersonName(auth.getPersonName());
view.setSyncStatus(auth.getSyncStatus());
view.setBeginTime(auth.getBeginTime());
view.setEndTime(auth.getEndTime());
view.setClearStatus("0");
view.setClearStatusName("未清除");
view.setClearable(false);
if (LockConstants.AUTH_PASSWORD.equals(auth.getAuthType())
|| LockConstants.AUTH_IC_CARD.equals(auth.getAuthType()))
{
platformOnly++;
view.setCredential(auth.getAuthValue());
view.setMatchStatus("PLATFORM_ONLY");
view.setMatchStatusName("仅平台有(缺失)");
view.setRemark("平台已授权,设备未返回该凭据");
}
else if (LockConstants.AUTH_FACE.equals(auth.getAuthType()))
{
// 设备人脸行返回的是人员ID,可用人员编号直接比对
platformOnly++;
view.setCredential(StringUtils.defaultIfEmpty(auth.getPersonNo(), "—"));
view.setMatchStatus("PLATFORM_ONLY");
view.setMatchStatusName("仅平台有(缺失)");
view.setRemark("平台已授权,设备未返回该人员ID的人脸记录");
}
else
{
notQueryable++;
view.setCredential("—");
view.setMatchStatus("NOT_QUERYABLE");
view.setMatchStatusName("设备不支持查询");
view.setRemark("实测协议仅返回密码与人脸,指纹按平台账本展示");
}
rows.add(view);
}
snapshot.setRows(rows);
snapshot.setDeviceCount(deviceCount);
snapshot.setPlatformCount(platformActive);
snapshot.setMatchedCount(matched);
snapshot.setDeviceOnlyCount(deviceOnly);
snapshot.setPlatformOnlyCount(platformOnly);
snapshot.setNotQueryableCount(notQueryable);
}
private Map<String, LockCommand> loadClearCommands(List<LockDeviceAuth> deviceRows)
{
List<String> commandNos = new ArrayList<String>();
for (LockDeviceAuth row : deviceRows)
{
if (StringUtils.isNotEmpty(row.getClearCommandNo()))
{
commandNos.add(row.getClearCommandNo());
}
}
Map<String, LockCommand> result = new HashMap<String, LockCommand>();
if (commandNos.isEmpty())
{
return result;
}
for (LockCommand command : commandMapper.selectCommandsByNos(commandNos))
{
result.put(command.getCommandNo(), command);
}
return result;
}
/**
* 清除状态按清除命令的终态推导:未下发/下发中/成功/失败
*/
private void applyClearState(LockDeviceAuthView view, LockDeviceAuth deviceRow, Map<String, LockCommand> clearCommands)
{
LockCommand command = StringUtils.isEmpty(deviceRow.getClearCommandNo()) ? null
: clearCommands.get(deviceRow.getClearCommandNo());
String status = "0";
String name = "未清除";
if (command != null)
{
if (LockConstants.STATUS_SUCCESS.equals(command.getCommandStatus()))
{
status = "2";
name = "已清除";
}
else if (LockConstants.STATUS_PENDING.equals(command.getCommandStatus())
|| LockConstants.STATUS_SENT.equals(command.getCommandStatus()))
{
status = "1";
name = "清除中";
}
else
{
status = "3";
name = "清除失败";
}
}
view.setClearStatus(status);
view.setClearStatusName(name);
view.setClearable(!"1".equals(status) && !"2".equals(status));
}
private int findPlatformIndex(List<LockAuth> platformAuths, LockDeviceAuth deviceRow, String platformType,
boolean[] consumed)
{
String credential = deviceRow.getCredential();
for (int i = 0; i < platformAuths.size(); i++)
{
LockAuth auth = platformAuths.get(i);
if (consumed[i])
{
continue;
}
if (LockConstants.AUTH_FACE.equals(platformType))
{
// 人脸以人员ID比对,设备返回的凭据就是 person_no
if (LockConstants.AUTH_FACE.equals(auth.getAuthType())
&& sameText(credential, auth.getPersonNo()))
{
return i;
}
continue;
}
if (!LockConstants.AUTH_PASSWORD.equals(auth.getAuthType())
&& !LockConstants.AUTH_IC_CARD.equals(auth.getAuthType()))
{
continue;
}
if (platformType != null && !platformType.equals(auth.getAuthType()))
{
continue;
}
if (matchesCredential(credential, auth.getAuthValue(), auth.getAuthType()))
{
return i;
}
}
return -1;
}
/**
* 凭据反查平台授权:优先未清除的行,避免误绑到已清除的历史授权
*/
private LockAuth matchPlatformAuth(Long deviceId, String credential, String platformType)
{
List<LockAuth> auths = authMapper.selectAuthsByDeviceId(deviceId);
LockAuth cleared = null;
for (LockAuth auth : auths)
{
if (!LockConstants.AUTH_PASSWORD.equals(auth.getAuthType())
&& !LockConstants.AUTH_IC_CARD.equals(auth.getAuthType()))
{
continue;
}
if (platformType != null && !platformType.equals(auth.getAuthType()))
{
continue;
}
if (!matchesCredential(credential, auth.getAuthValue(), auth.getAuthType()))
{
continue;
}
if (LockConstants.SYNC_CLEARED.equals(auth.getSyncStatus()))
{
cleared = cleared == null ? auth : cleared;
continue;
}
return auth;
}
return cleared;
}
/**
* 设备类型码转平台授权类型:实测 02 密码、07 人脸;其余类型未验证,返回 null 只展示类型码
*/
private String platformAuthType(String deviceType)
{
String code = StringUtils.trim(deviceType);
if (DEV_TYPE_PASSWORD.equals(code))
{
return LockConstants.AUTH_PASSWORD;
}
if (DEV_TYPE_FACE.equals(code))
{
return LockConstants.AUTH_FACE;
}
return null;
}
private String unknownTypeName(String deviceType)
{
String code = StringUtils.trim(deviceType);
return StringUtils.isEmpty(code) ? "未知" : "未知(" + code + ")";
}
private boolean matchesCredential(String deviceValue, String platformValue, String authType)
{
if (LockConstants.AUTH_PASSWORD.equals(authType))
{
return samePassword(deviceValue, platformValue);
}
return sameCredential(deviceValue, platformValue);
}
/**
* 密码不足 8 位时设备侧在前面补 F,比对时先按原值比,再去掉补位比
*/
private boolean samePassword(String left, String right)
{
if (StringUtils.isEmpty(left) || StringUtils.isEmpty(right))
{
return false;
}
String a = left.trim();
String b = right.trim();
if (a.equals(b))
{
return true;
}
String strippedA = stripPasswordPadding(a);
String strippedB = stripPasswordPadding(b);
return !strippedA.isEmpty() && strippedA.equals(strippedB);
}
private String stripPasswordPadding(String value)
{
if (value.length() != 8)
{
return value;
}
int start = 0;
while (start < value.length() && (value.charAt(start) == 'F' || value.charAt(start) == 'f'))
{
start++;
}
return value.substring(start);
}
/**
* 凭据比对:先精确比较;十六进制卡号允许忽略大小写,密码不做大小写归一
*/
private boolean sameCredential(String left, String right)
{
if (StringUtils.isEmpty(left) || StringUtils.isEmpty(right))
{
return false;
}
String a = left.trim();
String b = right.trim();
if (a.equals(b))
{
return true;
}
return isHexCredential(a) && isHexCredential(b) && a.equalsIgnoreCase(b);
}
private boolean sameText(String left, String right)
{
if (StringUtils.isEmpty(left) || StringUtils.isEmpty(right))
{
return false;
}
return left.trim().equalsIgnoreCase(right.trim());
}
private boolean isHexCredential(String value)
{
return value.length() >= 6 && value.matches("[0-9a-fA-F]+");
}
private Integer parseInteger(String value)
{
if (StringUtils.isEmpty(value))
{
return null;
}
try
{
return Integer.valueOf(value.trim());
}
catch (NumberFormatException e)
{
return null;
}
}
private LockDevice loadDevice(Long deviceId)
{
if (deviceId == null)
{
throw new IllegalArgumentException("门锁ID不能为空");
}
LockDevice device = deviceMapper.selectDeviceById(deviceId);
if (device == null)
{
throw new IllegalArgumentException("门锁不存在");
}
return device;
}
/**
* 只允许清除当前快照中的行:重新查询后旧行可能已不存在,避免误删设备上的新凭据
*/
private void ensureCurrentRow(Long deviceId, LockDeviceAuth row)
{
LockCommand latest = commandMapper.selectLatestByBizId(LockConstants.CMD_READ_AUTH, deviceId);
boolean current = latest != null && latest.getCommandNo().equals(row.getCommandNo());
if (!current && latest != null)
{
LockCommand withRows = commandMapper.selectLatestWithRowsByBizId(LockConstants.CMD_READ_AUTH, deviceId);
current = withRows != null && withRows.getCommandNo().equals(row.getCommandNo());
}
if (!current)
{
throw new IllegalArgumentException("查询结果已刷新,请重新读取锁内授权后再清除");
}
}
private void ensureNotClearing(LockDeviceAuth row)
{
if (StringUtils.isEmpty(row.getClearCommandNo()))
{
return;
}
LockCommand command = commandMapper.selectCommandByNo(row.getClearCommandNo());
if (command != null && (LockConstants.STATUS_PENDING.equals(command.getCommandStatus())
|| LockConstants.STATUS_SENT.equals(command.getCommandStatus())))
{
throw new IllegalArgumentException("该条授权正在清除,请等待设备返回");
}
}
private boolean isTerminalFailure(String status)
{
return LockConstants.STATUS_FAIL.equals(status) || LockConstants.STATUS_TIMEOUT.equals(status)
|| LockConstants.STATUS_CANCELED.equals(status);
}
private String resolveQueryMsg(LockCommand command)
{
if (StringUtils.isNotEmpty(command.getErrorMsg()))
{
return command.getErrorMsg();
}
if (LockConstants.STATUS_SUCCESS.equals(command.getCommandStatus()))
{
return "读取成功";
}
if (LockConstants.STATUS_PENDING.equals(command.getCommandStatus())
|| LockConstants.STATUS_SENT.equals(command.getCommandStatus()))
{
return "读取指令已下发,等待设备返回";
}
return null;
}
private String buildNotice(LockCommand latest, boolean fallback, LockDeviceAuthSnapshot snapshot,
List<LockDeviceAuth> deviceRows)
{
StringBuilder notice = new StringBuilder();
notice.append("设备实测可返回密码与人脸授权:密码按原文展示,人脸按人员ID比对;指纹暂未验证,按平台账本展示。");
if (latest == null)
{
notice.append(" 当前尚未读取设备,请点击「读取锁内授权」。");
return notice.toString();
}
if (fallback)
{
notice.append(" 最近一次读取未返回数据,当前展示上次读取结果。");
}
if (LockConstants.STATUS_PENDING.equals(latest.getCommandStatus())
|| LockConstants.STATUS_SENT.equals(latest.getCommandStatus()))
{
notice.append(" 读取指令已下发,可稍后刷新查看。");
}
Integer total = null;
for (LockDeviceAuth row : deviceRows)
{
if (row.getRowTotal() != null)
{
total = row.getRowTotal();
}
}
if (total != null && total > snapshot.getDeviceCount())
{
notice.append(" 设备声明 ").append(total).append(" 条,已收到 ").append(snapshot.getDeviceCount())
.append(" 条,可能仍在传输。");
}
return notice.toString();
}
private boolean sameTime(Date left, Date right)
{
if (left == null || right == null)
{
return true;
}
return left.getTime() / 1000 == right.getTime() / 1000;
}
private boolean isExpired(Date endTime)
{
return endTime != null && endTime.before(new Date());
}
private String joinRemarks(List<String> remarks)
{
if (remarks.isEmpty())
{
return null;
}
StringBuilder builder = new StringBuilder();
for (String remark : remarks)
{
if (builder.length() > 0)
{
builder.append(";");
}
builder.append(remark);
}
return builder.toString();
}
private String authTypeName(String authType)
{
if (LockConstants.AUTH_FACE.equals(authType))
{
return "人脸";
}
if (LockConstants.AUTH_PASSWORD.equals(authType))
{
return AUTH_TYPE_NAME_PASSWORD;
}
if (LockConstants.AUTH_FINGERPRINT.equals(authType))
{
return "指纹";
}
if (LockConstants.AUTH_IC_CARD.equals(authType))
{
return AUTH_TYPE_NAME_IC;
}
return "未知";
}
private String commandStatusName(String status)
{
if (LockConstants.STATUS_PENDING.equals(status))
{
return "待发送";
}
if (LockConstants.STATUS_SENT.equals(status))
{
return "已发送";
}
if (LockConstants.STATUS_SUCCESS.equals(status))
{
return "成功";
}
if (LockConstants.STATUS_FAIL.equals(status))
{
return "失败";
}
if (LockConstants.STATUS_TIMEOUT.equals(status))
{
return "超时";
}
if (LockConstants.STATUS_CANCELED.equals(status))
{
return "已取消";
}
return "未知";
}
}
@@ -6,11 +6,13 @@ import java.util.Arrays;
import java.util.Calendar;
import java.util.Date;
import java.util.List;
import java.util.concurrent.TimeUnit;
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.domain.LockCommand;
@@ -71,6 +73,19 @@ public class LockGatewayServiceImpl implements ILockGatewayService
/** locklist 锁ID 总长度 */
private static final int LOCKLIST_ID_LENGTH = 12;
/** 重启等待标记:存下发时间戳,多实例共享 */
private static final String RESTART_KEY_PREFIX = "lock:gateway:restart:";
/**
* 重启等待期内不把上行当作复通信号。
* <p>
* 清除回执与门锁周期上报都可能比网关真正重启更早到达,直接置在线会让分配指令又落进重启窗口。
*/
private static final long RESTART_GRACE_MILLIS = 15000L;
/** 标记保留时长:到期仍未等到上行时,网关保持离线,由运维确认 */
private static final int RESTART_MARK_TTL_SECONDS = 600;
@Autowired
private LockGatewayMapper gatewayMapper;
@@ -86,6 +101,10 @@ public class LockGatewayServiceImpl implements ILockGatewayService
@Autowired
private ILockCommandService commandService;
/** 重启等待标记;Redis 不可用时退化为只看上线通知 */
@Autowired(required = false)
private RedisCache redisCache;
@Override
public List<LockGateway> selectGatewayList(LockGateway gateway)
{
@@ -210,6 +229,11 @@ public class LockGatewayServiceImpl implements ILockGatewayService
public void bindDevices(Long gatewayId, Long[] deviceIds)
{
LockGateway gateway = requireGateway(gatewayId);
// 离线网关(含整表清除后的重启等待)收不到整表 add,下发即丢,必须等其上线后再分配
if (LockConstants.OFFLINE.equals(gateway.getOnlineStatus()))
{
throw new IllegalArgumentException("网关当前离线(可能正在重启),请等待其重新上线后再分配门锁");
}
int assigned = 0;
for (Long deviceId : deviceIds)
{
@@ -360,9 +384,98 @@ public class LockGatewayServiceImpl implements ILockGatewayService
gatewayMapper.updateOnlineStatusOnly(gateway.getGatewayId(), onlineStatus);
return;
}
// 上线通知已证明网关重启完成,重启等待标记不再需要
clearRestartMark(gatewaySn);
gatewayMapper.updateOnlineStatus(gateway.getGatewayId(), onlineStatus, new Date());
}
@Override
public void markRestarting(Long gatewayId, String gatewaySn, String reason)
{
gatewayMapper.updateOnlineStatusOnly(gatewayId, LockConstants.OFFLINE);
markRestartingFlag(gatewaySn);
log.info("网关进入重启等待, gatewaySn={}, reason={}", gatewaySn, reason);
}
@Override
public void touchOnlineByUplink(String gatewaySn)
{
if (StringUtils.isEmpty(gatewaySn))
{
return;
}
LockGateway gateway = gatewayMapper.selectGatewayBySn(gatewaySn);
if (gateway == null || !LockConstants.OFFLINE.equals(gateway.getOnlineStatus()))
{
return;
}
if (inRestartGrace(gatewaySn))
{
log.debug("网关重启等待期内收到上行,暂不置在线, gatewaySn={}", gatewaySn);
return;
}
clearRestartMark(gatewaySn);
gatewayMapper.updateOnlineStatus(gateway.getGatewayId(), LockConstants.ONLINE, new Date());
log.info("网关收到上行报文,恢复在线, gatewaySn={}", gatewaySn);
}
private void markRestartingFlag(String gatewaySn)
{
if (redisCache == null || StringUtils.isEmpty(gatewaySn))
{
return;
}
try
{
redisCache.setCacheObject(RESTART_KEY_PREFIX + gatewaySn, System.currentTimeMillis(),
RESTART_MARK_TTL_SECONDS, TimeUnit.SECONDS);
}
catch (Exception e)
{
log.warn("重启等待标记写入失败, gatewaySn={}, msg={}", gatewaySn, e.getMessage());
}
}
private boolean inRestartGrace(String gatewaySn)
{
if (redisCache == null)
{
return false;
}
try
{
Object value = redisCache.getCacheObject(RESTART_KEY_PREFIX + gatewaySn);
if (value == null)
{
return false;
}
long markedAt = value instanceof Number ? ((Number) value).longValue()
: Long.parseLong(String.valueOf(value));
return System.currentTimeMillis() - markedAt < RESTART_GRACE_MILLIS;
}
catch (Exception e)
{
log.warn("重启等待标记读取失败, gatewaySn={}, msg={}", gatewaySn, e.getMessage());
return false;
}
}
private void clearRestartMark(String gatewaySn)
{
if (redisCache == null || StringUtils.isEmpty(gatewaySn))
{
return;
}
try
{
redisCache.deleteObject(RESTART_KEY_PREFIX + gatewaySn);
}
catch (Exception e)
{
log.warn("重启等待标记清除失败, gatewaySn={}, msg={}", gatewaySn, e.getMessage());
}
}
@Override
@Transactional
public int markOffline(int offlineMinutes)
@@ -388,6 +501,9 @@ public class LockGatewayServiceImpl implements ILockGatewayService
List<Object> params = new ArrayList<Object>();
params.add(PARAM_LOCKLIST_CLEAR);
dispatchLockList(target, gateway, params, LockConstants.CMD_CLEAR_DEVICE, bizId);
// 网关处理 clear 后会重启:立即置离线,避免重启窗口内继续分配锁导致指令丢失;
// 收到上线通知或重启完成后的心跳再恢复在线
markRestarting(gateway.getGatewayId(), gateway.getGatewaySn(), "locklist clear");
}
/**
@@ -41,10 +41,4 @@ public class LockMqttLogServiceImpl implements ILockMqttLogService
{
return mqttLogMapper.deleteMqttLogByIds(logIds);
}
@Override
public int cleanBefore(String keepDate)
{
return mqttLogMapper.deleteMqttLogBefore(keepDate);
}
}
@@ -0,0 +1,90 @@
package com.ruoyi.iot.lock.service.impl;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.ruoyi.iot.lock.mapper.LockAccessLogMapper;
import com.ruoyi.iot.lock.mapper.LockAuthMapper;
import com.ruoyi.iot.lock.mapper.LockCallbackTaskMapper;
import com.ruoyi.iot.lock.mapper.LockCommandMapper;
import com.ruoyi.iot.lock.mapper.LockMqttLogMapper;
import com.ruoyi.iot.lock.mapper.LockStayMapper;
import com.ruoyi.iot.lock.service.ILockRetentionService;
/**
* 数据保留策略实现
* <p>
* 各表条件由 Mapper 保证「只删已完成/已结束的数据」:
* 命令看终态、入住单看已退房/已换房/已取消、授权看已清除或已删除、
* 回调看已成功或死信,未投递成功的超过保留期同样清理。
* 每轮删除限量,积压较多时由后续轮次继续,避免一次长事务锁表。
*
* @author ruoyi
*/
@Service
public class LockRetentionServiceImpl implements ILockRetentionService
{
/** 单轮删除行数 */
private static final int BATCH_SIZE = 5000;
/** 单表单次最多执行的轮数,积压过多时留到下一轮任务 */
private static final int MAX_ROUNDS = 10;
@Autowired
private LockAccessLogMapper accessLogMapper;
@Autowired
private LockMqttLogMapper mqttLogMapper;
@Autowired
private LockCommandMapper commandMapper;
@Autowired
private LockStayMapper stayMapper;
@Autowired
private LockAuthMapper authMapper;
@Autowired
private LockCallbackTaskMapper callbackTaskMapper;
@Override
public Map<String, Integer> cleanExpired(Date keepDate)
{
Map<String, Integer> result = new LinkedHashMap<String, Integer>();
if (keepDate == null)
{
return result;
}
result.put("通行日志", cleanBatches(accessLogMapper::deleteAccessLogBefore, keepDate));
result.put("报文日志", cleanBatches(mqttLogMapper::deleteMqttLogBefore, keepDate));
result.put("命令记录", cleanBatches(commandMapper::deleteFinishedBefore, keepDate));
result.put("住宿单", cleanBatches(stayMapper::deleteFinishedBefore, keepDate));
result.put("授权记录", cleanBatches(authMapper::deleteFinishedBefore, keepDate));
result.put("回调任务", cleanBatches(callbackTaskMapper::deleteFinishedBefore, keepDate));
return result;
}
private int cleanBatches(BatchDelete delete, Date keepDate)
{
int total = 0;
for (int round = 0; round < MAX_ROUNDS; round++)
{
int deleted = delete.delete(keepDate, BATCH_SIZE);
total += deleted;
if (deleted < BATCH_SIZE)
{
break;
}
}
return total;
}
@FunctionalInterface
private interface BatchDelete
{
int delete(Date keepDate, int limit);
}
}
@@ -0,0 +1,79 @@
package com.ruoyi.iot.lock.task;
import java.util.Date;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import com.ruoyi.common.utils.DateUtils;
import com.ruoyi.iot.lock.service.ILockRetentionService;
/**
* 数据保留任务:日志类与流水类数据最多保留 30 天,每天凌晨清理已完成的部分。
* <p>
* 清理范围:通行日志、MQTT 报文日志、终端态命令、已退房/已换房/已取消的入住单、
* 已清除或已删除的授权、已成功/死信或超过保留期仍未投递的开放接口回调任务。
* 在途数据(待发送命令、在住单、生效中授权)永不清理;待结果/待发送的回调仅在超过保留期后清理。
*
* @author ruoyi
*/
@Component
public class LockDataRetentionTask
{
private static final Logger log = LoggerFactory.getLogger(LockDataRetentionTask.class);
/** 保留天数 */
private static final int KEEP_DAYS = 30;
/** 任务锁名与存活时长:多表分批删除可能持续数分钟,留足余量防止并发清理 */
private static final String LOCK_NAME = "data-retention";
private static final long LOCK_TTL_SECONDS = 1800L;
@Autowired
private ILockRetentionService retentionService;
@Autowired
private DistributedTaskLock taskLock;
@Scheduled(cron = "0 30 3 * * ?")
public void execute()
{
DistributedTaskLock.LockHandle lock = taskLock.tryLock(LOCK_NAME, LOCK_TTL_SECONDS);
if (lock == null)
{
return;
}
try
{
Date keepDate = DateUtils.addDays(new Date(), -KEEP_DAYS);
Map<String, Integer> counts = retentionService.cleanExpired(keepDate);
StringBuilder summary = new StringBuilder();
for (Map.Entry<String, Integer> entry : counts.entrySet())
{
if (entry.getValue() > 0)
{
if (summary.length() > 0)
{
summary.append(", ");
}
summary.append(entry.getKey()).append(' ').append(entry.getValue());
}
}
if (summary.length() > 0)
{
log.info("数据保留任务完成, 保留起点={}, 删除 {}", keepDate, summary);
}
}
catch (Exception e)
{
log.error("数据保留任务失败", e);
}
finally
{
lock.release();
}
}
}
@@ -490,8 +490,9 @@ public class LockCallbackServiceImpl implements ILockCallbackService
/**
* 授权同步状态是否已到终态。
* <p>
* 下发类以成功(2)/失败(3)为终态;清除类以已清除(4)/下发失败(3)/待清除(6)为终态,
* 其中 3 表示清除指令根本没发出去(路由缺失等),同样属于失败结果。
* 下发类以成功(2)/失败(3)/配置失败(7)为终态——门锁未分配网关时不会再重试,
* 必须把失败结果回调出去,否则外部系统会一直等待;
* 清除类以已清除(4)/下发失败(3)/待清除(6)为终态。
*/
private boolean isTerminal(String eventType, String syncStatus)
{
@@ -500,7 +501,8 @@ public class LockCallbackServiceImpl implements ILockCallbackService
return LockConstants.SYNC_FAIL.equals(syncStatus) || LockConstants.SYNC_CLEARED.equals(syncStatus)
|| LockConstants.SYNC_CLEAR_FAIL.equals(syncStatus);
}
return LockConstants.SYNC_SUCCESS.equals(syncStatus) || LockConstants.SYNC_FAIL.equals(syncStatus);
return LockConstants.SYNC_SUCCESS.equals(syncStatus) || LockConstants.SYNC_FAIL.equals(syncStatus)
|| LockConstants.SYNC_CONFIG_FAIL.equals(syncStatus);
}
private boolean isSuccess(String eventType, String syncStatus)
@@ -20,6 +20,9 @@ public interface LockPersonMapper
LockPerson selectPersonByNo(String personNo);
/** 按人员编号批量取编号与姓名(通行日志回填通行人姓名) */
List<LockPerson> selectPersonNamesByNos(@Param("personNos") List<String> personNos);
/** 人员编号唯一性校验,excludeId 用于修改时排除自身 */
int countPersonNo(@Param("personNo") String personNo, @Param("excludeId") Long excludeId);
@@ -42,6 +42,13 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="deviceSn != null and deviceSn != ''">
and device_sn = #{deviceSn}
</if>
<!-- 房间号不在日志表内,按门锁当前绑定的房间反查 -->
<if test="roomNo != null and roomNo != ''">
and device_id in (
select r.device_id from lock_room r
where r.del_flag = '0' and r.room_no like concat('%', #{roomNo}, '%')
)
</if>
<if test="deviceId != null">
and device_id = #{deviceId}
</if>
@@ -54,8 +61,13 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="personNo != null and personNo != ''">
and person_no = #{personNo}
</if>
<!-- 姓名可能只在人员主数据里(入库时快照为空),按人员编号反查兜底 -->
<if test="personName != null and personName != ''">
and person_name like concat('%', #{personName}, '%')
and (person_name like concat('%', #{personName}, '%')
or person_no in (
select p.person_no from lock_person p
where p.del_flag = '0' and p.person_name like concat('%', #{personName}, '%')
))
</if>
<if test="cardNo != null and cardNo != ''">
and card_no like concat('%', #{cardNo}, '%')
@@ -196,9 +208,12 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
</foreach>
</delete>
<delete id="deleteAccessLogBefore" parameterType="String">
delete from lock_access_log where
open_time &lt; #{keepDate}
<!-- 保留最近一个月的通行记录:按开门时间分批删除(走 idx_lock_access_log_open_time) -->
<delete id="deleteAccessLogBefore">
delete from lock_access_log
where open_time &lt; #{keepDate}
order by open_time
limit #{limit}
</delete>
</mapper>
@@ -116,7 +116,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
count(distinct a.device_id) as device_count,
sum(case when a.sync_status = '2' then 1 else 0 end) as issued_count,
sum(case when a.sync_status is null or a.sync_status in ('0', '1', '6') then 1 else 0 end) as pending_count,
sum(case when a.sync_status = '3' then 1 else 0 end) as failed_count,
sum(case when a.sync_status in ('3', '7') then 1 else 0 end) as failed_count,
min(a.begin_time) as begin_time,
max(a.end_time) as end_time,
max(a.update_time) as update_time,
@@ -493,4 +493,15 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
delete from lock_auth where device_id = #{deviceId}
</delete>
<!--
保留策略:只删已清除(4)与已删除(del_flag=2)的授权记录。
待下发/已下发/下发成功/下发失败/配置失败/待清除 都保留,避免丢失授权现状与待处理项。
-->
<delete id="deleteFinishedBefore">
delete from lock_auth
where (del_flag = '2' or sync_status = '4')
and coalesce(sync_time, update_time, create_time) &lt; #{keepDate}
limit #{limit}
</delete>
</mapper>
@@ -160,4 +160,14 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
where task_id = #{taskId}
</update>
<!-- 保留策略:已成功(2)/死信(3) 按最后更新时间;待结果(0)/待发送(1) 超过保留期按创建时间清掉,避免卡死任务长期堆积 -->
<delete id="deleteFinishedBefore">
delete from lock_callback_task
where (
(status in ('2', '3') and coalesce(update_time, create_time) &lt; #{keepDate})
or (status in ('0', '1') and create_time &lt; #{keepDate})
)
limit #{limit}
</delete>
</mapper>
@@ -139,6 +139,16 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
and command_status in ('0', '1')
</select>
<select id="selectSentCommands" resultMap="LockCommandResult">
<include refid="selectCommandVo"/>
where command_type = #{commandType}
and command_status = '1'
<if test="groupId != null">
and target_id = #{groupId}
</if>
order by command_id desc
</select>
<select id="selectLatestByBizId" resultMap="LockCommandResult">
<include refid="selectCommandVo"/>
where command_type = #{commandType}
@@ -152,7 +162,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<include refid="selectCommandVo"/>
where command_type = #{commandType}
and biz_id = #{bizId}
and exists (select 1 from lock_device_auth a where a.command_no = lock_command.command_no)
and exists (select 1 from lock_device_auth a
where a.command_no = lock_command.command_no
and a.credential is not null
and a.credential &lt;&gt; ''
and lower(a.credential) &lt;&gt; 'null')
order by command_id desc
limit 1
</select>
@@ -208,4 +222,13 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
</foreach>
</delete>
<!-- 保留策略:只删终态命令(2成功/3失败/4超时/5已取消);待发送(0)、已发送(1)永不清理 -->
<delete id="deleteFinishedBefore">
delete from lock_command
where command_status in ('2', '3', '4', '5')
and req_time &lt; #{keepDate}
order by req_time
limit #{limit}
</delete>
</mapper>
@@ -0,0 +1,69 @@
<?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.LockDeviceAuthMapper">
<resultMap type="LockDeviceAuth" id="LockDeviceAuthResult">
<id property="deviceAuthId" column="device_auth_id" />
<result property="deviceId" column="device_id" />
<result property="deviceSn" column="device_sn" />
<result property="commandNo" column="command_no" />
<result property="rowIndex" column="row_index" />
<result property="rowTotal" column="row_total" />
<result property="authType" column="auth_type" />
<result property="credential" column="credential" />
<result property="beginTime" column="begin_time" />
<result property="endTime" column="end_time" />
<result property="deviceTime" column="device_time" />
<result property="rawParams" column="raw_params" />
<result property="clearCommandNo" column="clear_command_no" />
<result property="createTime" column="create_time" />
</resultMap>
<sql id="selectDeviceAuthVo">
select device_auth_id, device_id, device_sn, command_no, row_index, row_total, auth_type, credential,
begin_time, end_time, device_time, raw_params, clear_command_no, create_time
from lock_device_auth
</sql>
<select id="selectByCommandNo" parameterType="String" resultMap="LockDeviceAuthResult">
<include refid="selectDeviceAuthVo"/>
where command_no = #{commandNo}
order by row_index
</select>
<select id="selectById" parameterType="Long" resultMap="LockDeviceAuthResult">
<include refid="selectDeviceAuthVo"/>
where device_auth_id = #{deviceAuthId}
</select>
<select id="countByCommandNo" parameterType="String" resultType="int">
select count(distinct row_index) from lock_device_auth where command_no = #{commandNo}
</select>
<insert id="upsert" parameterType="LockDeviceAuth">
insert into lock_device_auth (
device_id, device_sn, command_no, row_index, row_total, auth_type, credential,
begin_time, end_time, device_time, raw_params, create_time
) values (
#{deviceId}, #{deviceSn}, #{commandNo}, #{rowIndex}, #{rowTotal}, #{authType}, #{credential},
#{beginTime}, #{endTime}, #{deviceTime}, #{rawParams}, CURRENT_TIMESTAMP
)
on duplicate key update
row_total = values(row_total),
auth_type = values(auth_type),
credential = values(credential),
begin_time = values(begin_time),
end_time = values(end_time),
device_time = values(device_time),
raw_params = values(raw_params)
</insert>
<update id="updateClearCommand">
update lock_device_auth
set clear_command_no = #{clearCommandNo}
where device_auth_id = #{deviceAuthId}
</update>
</mapper>
@@ -90,9 +90,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
</foreach>
</delete>
<delete id="deleteMqttLogBefore" parameterType="String">
delete from lock_mqtt_log where
recv_time &lt; #{keepDate}
<!-- 保留策略:按接收时间分批删除历史报文 -->
<delete id="deleteMqttLogBefore">
delete from lock_mqtt_log
where recv_time &lt; #{keepDate}
limit #{limit}
</delete>
</mapper>
@@ -117,6 +117,17 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
limit 1
</select>
<!-- 通行日志回填房间号:一次取回一页日志涉及的所有门锁的绑定房间 -->
<select id="selectRoomsByDeviceIds" resultMap="LockRoomResult">
select r.room_id, r.room_no, r.device_id
from lock_room r
where r.del_flag = '0'
and r.device_id in
<foreach collection="deviceIds" item="deviceId" open="(" separator="," close=")">
#{deviceId}
</foreach>
</select>
<!-- 房号精确匹配(对外接口约定房号全局唯一):返回列表以识别房号重复的脏数据 -->
<select id="selectRoomsByRoomNo" parameterType="String" resultMap="LockRoomResult">
<include refid="selectRoomVo"/>
@@ -234,4 +234,18 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
where auth_id = #{authId} and del_flag = '0'
</update>
<!--
保留策略:只删已完成的三类单据(2已退房/3已换房/4已取消),
待入住(0)与在住(1)永不清理;再兜一层排除房态冗余仍指向的单据。
-->
<delete id="deleteFinishedBefore">
delete from lock_stay
where stay_status in ('2', '3', '4')
and coalesce(checkout_time, update_time, create_time) &lt; #{keepDate}
and stay_id not in (
select current_stay_id from lock_room where current_stay_id is not null
)
limit #{limit}
</delete>
</mapper>
@@ -103,6 +103,17 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
where p.person_no = #{personNo} and p.del_flag = '0'
</select>
<!-- 通行日志回填姓名:一次取回本页涉及的人员,不做联表 -->
<select id="selectPersonNamesByNos" resultMap="LockPersonResult">
select p.person_no, p.person_name
from lock_person p
where p.del_flag = '0'
and p.person_no in
<foreach collection="personNos" item="personNo" open="(" separator="," close=")">
#{personNo}
</foreach>
</select>
<select id="countPersonNo" resultType="int">
select count(1) from lock_person
where person_no = #{personNo}
+2 -1
View File
@@ -315,7 +315,7 @@ create table lock_auth (
source char(1) default '1' comment '授权来源(1平台 2开放接口)',
source_app_code varchar(32) default null comment '来源应用编码(source=2,lock_open_app.app_code)',
source_biz_no varchar(64) default null comment '来源业务单号(可空;外部入住为 biz_no)',
sync_status char(1) default '0' comment '下发同步状态(0待下发 1已下发 2下发成功 3下发失败 4已清除 6待清除;5 预留给 v2 的已过期)',
sync_status char(1) default '0' comment '下发同步状态(0待下发 1已下发 2下发成功 3下发失败 4已清除 6待清除 7配置失败;5 预留给 v2 的已过期)',
sync_time datetime comment '最后下发/响应时间',
sync_msg varchar(512) default null comment '下发结果说明',
retry_count int(11) default 0 comment '下发重试次数',
@@ -572,6 +572,7 @@ insert into sys_dict_data (dict_code, dict_sort, dict_label, dict_value, dict_ty
(173, 4, '下发失败', '3', 'lock_sync_status', '', 'danger', 'N', '0', 'admin', CURRENT_TIMESTAMP, ''),
(174, 5, '已清除', '4', 'lock_sync_status', '', 'info', 'N', '0', 'admin', CURRENT_TIMESTAMP, ''),
(175, 6, '待清除', '6', 'lock_sync_status', '', 'warning', 'N', '0', 'admin', CURRENT_TIMESTAMP, '清除指令失败,等待补偿重发删除'),
(176, 7, '配置失败', '7', 'lock_sync_status', '', 'danger', 'N', '0', 'admin', CURRENT_TIMESTAMP, '门锁未分配网关或网关无分组,无下发通道,不自动重试'),
-- 报文处理状态
(180, 1, '待处理', '0', 'lock_handle_status', '', 'info', 'Y', '0', 'admin', CURRENT_TIMESTAMP, ''),
(181, 2, '成功', '1', 'lock_handle_status', '', 'success', 'N', '0', 'admin', CURRENT_TIMESTAMP, ''),
@@ -0,0 +1,111 @@
-- =========================================================================
-- D23 授权来源归属(lock_auth.source / source_app_code / source_biz_no)
--
-- 背景:
-- 外部业务系统通过 OpenAPI 下发授权(直接授权、外部入住/换房)后,
-- lock_auth 没有来源标记,平台页面无法区分平台授权与外部授权,
-- 也无法按应用隔离开放接口的查询/下发/取消/删除。
--
-- 目标:
-- 1. lock_auth 增加来源三列与组合索引;
-- 2. 外部入住/换房产生的授权自动回填为 source='2'(A 类);
-- 3. 其余存量行保持 source='1'(平台,B 类);现场确认不存在直接接口授权(C 类);
-- 4. 提供核对语句,确认来源分布与归属一致性。
--
-- 执行顺序:
-- 备份 lock_auth、lock_stay → 加列加索引 → 回填 → 核对 → 发布后端/前端。
-- **必须先执行本脚本再发布代码**,否则新增的 source 列会让授权查询报错。
--
-- 兼容说明:
-- MySQL 不支持 add column if not exists。重复执行时「加列」段会报
-- Duplicate column name,「加索引」段会报 Duplicate key name,按现有升级脚本惯例
-- 忽略这些错误继续执行后面的回填与核对即可(回填语句本身幂等)。
-- 所有 DML 建议先在只读副本预演并核对影响行数,再在生产低峰执行。
-- =========================================================================
-- =========================================================================
-- 1、加列:source 与 lock_stay.source 同编码(1 平台、2 开放接口)
-- =========================================================================
alter table lock_auth
add column source char(1) default '1' comment '授权来源(1平台 2开放接口)' after auth_time,
add column source_app_code varchar(32) default null comment '来源应用编码(source=2,lock_open_app.app_code)' after source,
add column source_biz_no varchar(64) default null comment '来源业务单号(可空;外部入住为 biz_no)' after source_app_code;
alter table lock_auth
add key idx_lock_auth_source (source, source_app_code);
-- =========================================================================
-- 2、存量回填 A 类:外部入住/换房产生的授权
-- 判定依据:lock_stay.auth_id = lock_auth.auth_id 且 lock_stay.source='2'
-- =========================================================================
-- 2.1 在住单:直接回填,无覆盖风险
update lock_auth a
join lock_stay s on s.auth_id = a.auth_id
set a.source = '2',
a.source_app_code = s.biz_system,
a.source_biz_no = s.biz_no
where s.source = '2'
and s.stay_status = '1'
and s.del_flag = '0'
and s.biz_system is not null
and a.del_flag = '0'
and a.source = '1';
-- 2.2 已退房/已换房的历史单:仅在退房后没有被再次覆盖时回填
-- auth_id 会被「同人同锁同类型」复用:退房后若平台或接口又覆盖过该行,
-- update_time 会晚于退房时间,此时按平台保留,避免误标。
-- 初始插入不写 update_time,为空的存量行视为从未被覆盖。
update lock_auth a
join lock_stay s on s.auth_id = a.auth_id
set a.source = '2',
a.source_app_code = s.biz_system,
a.source_biz_no = s.biz_no
where s.source = '2'
and s.stay_status in ('2', '3', '4')
and s.del_flag = '0'
and s.biz_system is not null
and a.del_flag = '0'
and a.source = '1'
and (a.update_time is null or a.update_time <= coalesce(s.checkout_time, s.update_time));
-- 2.3 兜底:空值归一为平台(B 类)
update lock_auth set source = '1' where source is null or source = '';
-- =========================================================================
-- 3、核对(执行后逐条确认)
-- =========================================================================
-- 3.1 来源分布:现场应只有 1(平台)与 2(外部)
-- select source, count(1) from lock_auth where del_flag = '0' group by source;
-- 3.2 在住外部单对应授权应全部归属外部,结果应为 0
-- select count(1)
-- from lock_auth a
-- join lock_stay s on s.auth_id = a.auth_id
-- where s.source = '2' and s.stay_status = '1' and s.del_flag = '0'
-- and s.biz_system is not null
-- and a.del_flag = '0' and a.source <> '2';
-- 3.3 来源值只允许 1/2,结果应为 0
-- select count(1) from lock_auth where del_flag = '0' and source not in ('1', '2');
-- 3.4 外部授权必须有应用编码,结果应为 0
-- (历史单 biz_system 为空的行不会回填,若有需人工确认)
-- select count(1) from lock_auth
-- where del_flag = '0' and source = '2' and (source_app_code is null or source_app_code = '');
-- =========================================================================
-- 4、回滚
-- 回滚只撤销表结构;已回填的 source 值随列删除一并消失,不影响授权业务数据。
-- 如需保留来源数据再回滚,先导出 auth_id/source/source_app_code/source_biz_no。
-- =========================================================================
-- alter table lock_auth drop key idx_lock_auth_source;
-- alter table lock_auth
-- drop column source_biz_no,
-- drop column source_app_code,
-- drop column source;
@@ -0,0 +1,42 @@
-- =========================================================================
-- D24 锁内授权查询快照(lock_device_auth)
--
-- 背景:
-- 门锁管理需要“查看锁内授权 → 与平台 lock_auth 比对 → 清除设备侧凭据”。
-- 协议只定义了 getkey(readkey) 查询卡/密码类权限,且应答是逐条/整包的多行数据,
-- 现有 lock_command 只能存一条回执,无法承载清单,故单独建快照表。
--
-- 设计口径:
-- 1. 一行对应 getkey(readkey) 返回的一条权限记录,按 (command_no, row_index) 幂等;
-- 2. 只存设备侧原始数据,不存比对结论——对账时实时与 lock_auth 比对,
-- 避免平台授权变化后快照结论过期;
-- 3. auth_type 保存设备类型码(实测 02 密码、07 人脸),credential 为密码原文
-- 或人脸人员ID(v1 凭据明文口径,页面直接展示);
-- 4. clear_command_no 记录针对该行下发的 delete 命令 seq,清除状态从 lock_command 读取;
-- 5. 历史快照保留,页面默认取该门锁最近一次 0019 查询命令对应的一组行。
--
-- 兼容说明:
-- MySQL 5.7/8.0 均支持 create table if not exists,可重复执行。
-- 本脚本不执行任何 DML,不修改存量表。
-- 已建表的环境请另执行 upgrade_d24_device_auth_type.sql 补 auth_type 列。
-- =========================================================================
create table if not exists lock_device_auth (
device_auth_id bigint(20) not null auto_increment comment '设备侧权限记录ID',
device_id bigint(20) not null comment '门锁ID(lock_device.device_id)',
device_sn varchar(32) default null comment '门锁SN(冗余,便于排查)',
command_no varchar(64) not null comment '查询命令seq(lock_command.command_no)',
row_index int(11) not null comment '设备返回的索引',
row_total int(11) default null comment '设备返回的总数',
auth_type varchar(8) default null comment '设备返回的授权类型码(实测 02密码 07人脸)',
credential varchar(255) not null comment '密码原文 / 人脸人员ID(设备原样返回)',
begin_time datetime default null comment '生效时间(8位十六进制Unix秒)',
end_time datetime default null comment '失效时间(8位十六进制Unix秒)',
device_time datetime default null comment '设备当前时间(8位十六进制Unix秒)',
raw_params varchar(1000) default null comment '原始行参数(JSON,排查协议差异用)',
clear_command_no varchar(64) default null comment '清除该行下发的delete命令seq',
create_time datetime default null comment '写入时间',
primary key (device_auth_id),
unique key uk_device_auth_cmd_index (command_no, row_index),
key idx_device_auth_device (device_id, command_no)
) engine=innodb default charset=utf8mb4 comment = '锁内授权查询快照表';
@@ -0,0 +1,36 @@
-- =========================================================================
-- D24 补充:锁内授权快照记录设备类型码(lock_device_auth.auth_type)
--
-- 背景:
-- getkey(readkey) 实测应答每行是
-- ["readkey","索引","总数","类型","凭据","起始时间","结束时间",null,"当前时间"],
-- 其中「类型」是设备自己的编码:02 密码、07 人脸(与平台 lock_auth.auth_type
-- 的 01 人脸 / 02 密码不同)。旧实现把类型位当成凭据,无法区分密码与人脸。
--
-- 目标:
-- 1. 增加 auth_type 列保存设备原始类型码,便于按类型对账;
-- 2. 存量行保持 NULL,代码按「未知类型」展示,不猜测。
--
-- 执行顺序:
-- 备份 lock_device_auth → 加列 → 确认列存在 → 发布后端。
--
-- 兼容说明:
-- MySQL 不支持 add column if not exists,重复执行会报 Duplicate column name,
-- 按现有升级脚本惯例忽略该错误即可(后续语句不受影响)。
-- 本脚本不执行任何 DML,不修改存量数据。
-- =========================================================================
alter table lock_device_auth
add column auth_type varchar(8) default null comment '设备返回的授权类型码(实测 02密码 07人脸)' after row_total;
-- 核对:列已存在即可
select column_name, column_type, column_comment
from information_schema.columns
where table_schema = database()
and table_name = 'lock_device_auth'
and column_name = 'auth_type';
-- 只读排查:旧版本把网关进度回执 ["readkey","10","ad","null"] 写成了凭据 "null",
-- 代码展示时已过滤,如需清理历史脏行请人工确认后执行(本脚本默认不执行):
-- delete from lock_device_auth where credential is not null and lower(credential) = 'null';
@@ -0,0 +1,53 @@
-- =========================================================================
-- D25:新增授权同步状态 7 = 配置失败(无下发通道,不自动重试)
--
-- 背景:
-- 门锁没有登记到任何网关(或归属网关没有分组)时,授权下发拿不到分组主题,
-- 旧实现把 sync_status 置 3(下发失败),补偿任务每 5 秒按 3 捞回重发,
-- 但缺网关属于配置问题,重发永远不会成功,只会持续产生告警与无效命令。
--
-- 处理:
-- 1) 字典 lock_sync_status 增加 7 = 配置失败;
-- 2) 代码在路由解析失败时置 7,补偿只捞 0/3,因此不会重复尝试;
-- 3) 修正门锁网关归属后,由页面「重新下发」手动重发。
--
-- 执行前置:备份 sys_dict_data;可重复执行。
-- 说明:脚本只交付,由 DBA 在窗口期执行;应用需同步升级到包含本修复的版本。
-- =========================================================================
set names utf8mb4;
-- =========================================================================
-- 1、字典:lock_sync_status 增加 7 = 配置失败
-- =========================================================================
delete from sys_dict_data where dict_type = 'lock_sync_status' and dict_value = '7';
insert into sys_dict_data (dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, remark) values
(7, '配置失败', '7', 'lock_sync_status', '', 'danger', 'N', '0', 'admin', now(), '门锁未分配网关或网关无分组,无下发通道,不自动重试');
-- =========================================================================
-- 2、只读排查:历史上因「未登记到任何网关」置 3 的授权
-- 代码升级后这些行仍会被补偿重发,可在确认原因后人工改为 7。
-- 本脚本默认不执行 DML,避免误改超时等可重试的失败。
-- =========================================================================
-- select a.auth_id, a.person_no, a.device_sn, a.sync_status, a.sync_msg, a.update_time
-- from lock_auth a
-- where a.sync_status = '3'
-- and a.del_flag = '0'
-- and a.sync_msg like '%未登记到任何网关%';
--
-- 确认后如需收敛(先核对条数):
-- update lock_auth
-- set sync_status = '7', sync_time = current_timestamp
-- where sync_status = '3'
-- and del_flag = '0'
-- and sync_msg like '%未登记到任何网关%';
-- =========================================================================
-- 3、执行后核对(只读)
-- =========================================================================
-- select dict_value, dict_label, list_class from sys_dict_data
-- where dict_type = 'lock_sync_status' order by dict_sort;
+47
View File
@@ -0,0 +1,47 @@
# ============================================================
# 若依前端(智能门禁管理系统)Nginx 部署配置
# 将本文件内容合并到 nginx.conf 的 http 块,或放到
# /etc/nginx/conf.d/ruoyi.conf 后执行 nginx -s reload
# ============================================================
server {
listen 80;
server_name localhost; # 改成你的域名或服务器IP,如 192.168.1.100
# 开启 gzip(dist 已预压缩 .gz,可直接用)
gzip on;
gzip_min_length 1k;
gzip_comp_level 6;
gzip_types text/plain text/css application/json application/javascript application/xml image/svg+xml;
gzip_vary on;
# 前端静态资源根目录,指向打包后的 dist 文件夹
root /usr/share/nginx/html/ruoyi; # ← 改成你服务器上 dist 的实际路径
index index.html;
# 前端单页路由:未命中文件时回退到 index.html
location / {
try_files $uri $uri/ /index.html;
}
# 后端接口代理:生产环境前缀为 /prod-api(见 .env.production)
# 将请求转发到后端 Spring Boot 服务(默认 8080)
location /prod-api/ {
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://127.0.0.1:8080/; # ← 改成后端实际地址(IP:端口)
}
# 可选:后端 Swagger / 文档
location /v3/api-docs/ {
proxy_pass http://127.0.0.1:8080/v3/api-docs/;
}
# 静态资源缓存
location ~* \.(js|css|png|jpg|jpeg|gif|svg|ico|woff2?)$ {
expires 30d;
add_header Cache-Control "public, no-transform";
}
}
@@ -0,0 +1,37 @@
# 智能门禁管理系统 - 部署说明(前端)
## 一、打包产物
- 已通过 `npm run build:prod` 生成生产包,位于项目根目录 `dist/`
- 共 251 个文件,约 7.19 MB(含 `.gz` 预压缩文件)
- 网页标题:智能门禁管理系统
- 后端接口前缀:`/prod-api`(见 `.env.production`)
## 二、服务器部署步骤
### 方式 A:Nginx 静态托管(推荐)
1. 将本项目的 `dist/` 文件夹整体上传到服务器,例如 `/usr/share/nginx/html/ruoyi/`
(保证 `dist/index.html` 在 `ruoyi/` 根目录下)
2. 将 `deploy/nginx.conf` 配置写入服务器 Nginx(`conf.d/ruoyi.conf` 或主配置),注意修改:
- `server_name`:你的域名或服务器 IP
- `root`:指向 `dist` 实际路径
- `proxy_pass http://127.0.0.1:8080/`:指向后端服务地址
3. 校验并重载:`nginx -t && nginx -s reload`
### 方式 B:后端 Spring Boot 内嵌 Tomcat 直接托管
若后端 `ruoyi-admin` 已配置 `spring.resources.static-locations` 指向前端,
将 `dist/` 内容放到后端静态资源目录后启动 jar 即可,无需 Nginx。
## 三、后端配合(重要)
当前目录仅包含**前端**。完整运行还需要若依后端(Spring Boot):
- 后端默认端口 8080,部署后需与 Nginx 的 `/prod-api` 代理地址一致
- 若后端地址/端口变化,请同步修改 `deploy/nginx.conf` 中的 `proxy_pass`
## 四、重新打包命令(本机)
```bash
# Node 17+ 需兼容旧版 OpenSSL(webpack4)
export NODE_OPTIONS=--openssl-legacy-provider # Windows(PowerShell):
# $env:NODE_OPTIONS="--openssl-legacy-provider"
npm install
npm run build:prod
# 产物输出到 dist/
```
@@ -10,6 +10,24 @@
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="房间号" prop="roomNo">
<el-input
v-model="queryParams.roomNo"
placeholder="请输入房间号"
clearable
style="width: 160px"
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="通行人" prop="personName">
<el-input
v-model="queryParams.personName"
placeholder="请输入姓名"
clearable
style="width: 160px"
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="人员编号" prop="personNo">
<el-input
v-model="queryParams.personNo"
@@ -103,6 +121,12 @@
<el-table-column type="selection" width="55" align="center" />
<el-table-column label="记录ID" align="center" prop="logId" width="80" />
<el-table-column label="门锁SN" align="center" prop="deviceSn" :show-overflow-tooltip="true" />
<el-table-column label="房间号" align="center" prop="roomNo" width="100">
<template slot-scope="scope">
<!-- 房间号按门锁当前绑定关系带出,未绑定房间时为空 -->
<span>{{ scope.row.roomNo || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="人员编号/密码" align="center" prop="personNo" min-width="140">
<template slot-scope="scope">
<!-- 密码开锁的记录中,人员编号位实际存放的是开锁时使用的密码 -->
@@ -111,6 +135,12 @@
<span v-else>{{ scope.row.personNo }}</span>
</template>
</el-table-column>
<el-table-column label="姓名" align="center" prop="personName" min-width="100" :show-overflow-tooltip="true">
<template slot-scope="scope">
<!-- 姓名取人员主数据,人员已删除或非人员凭证(如 IC 卡)时为空 -->
<span>{{ scope.row.personName || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="卡号" align="center" prop="cardNo" :show-overflow-tooltip="true" />
<el-table-column label="开门方式" align="center" prop="openType">
<template slot-scope="scope">
@@ -167,7 +197,9 @@
<el-dialog :title="detailTitle" :visible.sync="detailOpen" width="700px" append-to-body>
<el-descriptions :column="2" border>
<el-descriptions-item label="门锁SN">{{ detail.deviceSn }}</el-descriptions-item>
<el-descriptions-item label="房间号">{{ detail.roomNo || '-' }}</el-descriptions-item>
<el-descriptions-item :label="isPasswordOpen(detail) ? '开锁密码' : '人员编号'">{{ detail.personNo }}</el-descriptions-item>
<el-descriptions-item label="姓名">{{ detail.personName || '-' }}</el-descriptions-item>
<el-descriptions-item label="卡号">{{ detail.cardNo }}</el-descriptions-item>
<el-descriptions-item label="开门时间">{{ detail.openTime }}</el-descriptions-item>
<el-descriptions-item label="报文序号">{{ detail.seq }}</el-descriptions-item>
@@ -212,6 +244,8 @@ export default {
pageNum: 1,
pageSize: 10,
deviceSn: undefined,
roomNo: undefined,
personName: undefined,
personNo: undefined,
openType: undefined,
openResult: undefined
@@ -1317,7 +1317,7 @@ export default {
if (status === "2") {
return "success";
}
if (status === "3") {
if (status === "3" || status === "7") {
return "danger";
}
if (status === "1" || status === "6") {
@@ -1987,7 +1987,7 @@ export default {
if (status === "2") {
return "success";
}
if (status === "3") {
if (status === "3" || status === "7") {
return "danger";
}
if (status === "4") {
@@ -341,7 +341,7 @@
<el-table-column label="索引" width="60" align="center">
<template slot-scope="scope">{{ scope.row.rowIndex == null ? '-' : scope.row.rowIndex }}</template>
</el-table-column>
<el-table-column label="凭据(脱敏)" prop="credential" min-width="140" show-overflow-tooltip />
<el-table-column label="凭据/人员ID" prop="credential" min-width="140" show-overflow-tooltip />
<el-table-column label="类型" prop="authTypeName" width="80" align="center" />
<el-table-column label="人员" width="170" show-overflow-tooltip>
<template slot-scope="scope">
@@ -213,7 +213,7 @@
</el-dialog>
<!-- 网关门锁管理对话框 -->
<el-dialog :title="lockTitle" :visible.sync="lockOpen" width="1080px" append-to-body>
<el-dialog :title="lockTitle" :visible.sync="lockOpen" width="1080px" append-to-body @close="stopGatewayPoll">
<el-alert
type="info"
:closable="false"
@@ -221,6 +221,14 @@
title="列表以平台登记的门锁归属为准;分配会以本次清单覆盖网关现有锁列表,网关回执后重启生效。"
style="margin-bottom: 10px"
/>
<el-alert
v-if="lockGateway.onlineStatus === '1'"
type="warning"
:closable="false"
show-icon
title="网关当前离线或正在重启,此时下发锁分配指令不会被处理;请等待网关重新上线(本页自动刷新)后再分配门锁。"
style="margin-bottom: 10px"
/>
<el-form :model="lockQuery" size="small" :inline="true" label-width="68px">
<el-form-item label="门锁SN">
<el-input
@@ -252,6 +260,7 @@
plain
icon="el-icon-plus"
size="mini"
:disabled="lockGateway.onlineStatus === '1'"
@click="handleAssign"
v-hasPermi="['lock:gateway:bind']"
>分配门锁</el-button>
@@ -414,6 +423,7 @@ export default {
lockOpen: false,
lockLoading: false,
lockGateway: {},
gatewayPollTimer: null,
lockList: [],
lockTotal: 0,
lockIds: [],
@@ -452,6 +462,9 @@ export default {
created() {
this.getList();
},
beforeDestroy() {
this.stopGatewayPoll();
},
methods: {
getList() {
this.loading = true;
@@ -542,6 +555,49 @@ export default {
this.lockIds = [];
this.lockOpen = true;
this.getLockList();
this.refreshLockGateway();
},
// 重新读取网关状态:清除后网关会重启,置离线直到其重新上线
refreshLockGateway() {
if (!this.lockGateway.gatewayId) {
return;
}
getGateway(this.lockGateway.gatewayId).then(response => {
this.lockGateway = response.data;
if (this.lockOpen && this.lockGateway.onlineStatus === "1") {
this.startGatewayPoll();
} else {
this.stopGatewayPoll();
}
});
},
// 离线期间轮询网关状态,上线后自动恢复分配按钮
startGatewayPoll() {
if (this.gatewayPollTimer) {
return;
}
this.gatewayPollTimer = setInterval(() => {
if (!this.lockOpen || !this.lockGateway.gatewayId) {
this.stopGatewayPoll();
return;
}
getGateway(this.lockGateway.gatewayId).then(response => {
if (response.data.onlineStatus !== "1") {
this.lockGateway = response.data;
this.stopGatewayPoll();
this.$modal.msgSuccess("网关已重新上线,可以继续分配门锁");
this.getList();
}
}).catch(() => {
this.stopGatewayPoll();
});
}, 5000);
},
stopGatewayPoll() {
if (this.gatewayPollTimer) {
clearInterval(this.gatewayPollTimer);
this.gatewayPollTimer = null;
}
},
getLockList() {
this.lockLoading = true;
@@ -563,6 +619,10 @@ export default {
this.lockIds = selection.map(item => item.deviceId);
},
handleAssign() {
if (this.lockGateway.onlineStatus === "1") {
this.$modal.msgWarning("网关当前离线或正在重启,请等待其重新上线后再分配门锁");
return;
}
this.candidateQuery = { pageNum: 1, pageSize: 10, deviceSn: undefined, deviceName: undefined };
this.candidateIds = [];
this.assignOpen = true;
@@ -595,6 +655,7 @@ export default {
this.assignOpen = false;
this.getLockList();
this.getList();
this.refreshLockGateway();
}).catch(() => {});
},
handleRemoveLocks() {
@@ -604,15 +665,17 @@ export default {
this.$modal.msgSuccess("移除指令已下发");
this.getLockList();
this.getList();
this.refreshLockGateway();
}).catch(() => {});
},
handleClearLocks() {
this.$modal.confirm('确认清除网关"' + this.lockGateway.gatewaySn + '"下的全部门锁(' + this.lockTotal + ' 台)?将下发 clear 指令清空网关锁列表,并删除平台侧归属关系。').then(() => {
return clearDevices(this.lockGateway.gatewayId);
}).then(() => {
this.$modal.msgSuccess("清除指令已下发");
this.$modal.msgSuccess("清除指令已下发,网关将重启;请等待其重新上线后再分配门锁");
this.getLockList();
this.getList();
this.refreshLockGateway();
}).catch(() => {});
},
handleReboot(row) {
+331
View File
@@ -0,0 +1,331 @@
# Linux 部署与开机自启
> 状态:文档就绪(2026-11-02)。依据本仓库 `ruoyi-admin`(Spring Boot 4.1.0,`java.version=17`)与 `RuoYi-Vue2-master`(Vue2 + Element UI)的实际构建产物编写;**未在目标 Linux 服务器实测**。
## 1. 部署形态
| 组件 | 产物 | 运行方式 | 端口 |
|---|---|---|---|
| 后端 | `ruoyi-admin/target/ruoyi-admin.jar` | systemd 托管 `java -jar` | 8080(`server.port`) |
| 前端 | `RuoYi-Vue2-master/dist` | Nginx 静态站点 | 80 / 443 |
| MySQL | 外部依赖 | 由 DBA 或系统服务提供 | 3306 |
| Redis | 外部依赖 | 同上 | 6379 |
| MQTT Broker | 外部依赖(当前 `192.168.1.202:1883`) | 不由本系统托管 | 1883 |
后端启动时 Redis 不可用会启动失败(`Restart=on-failure` 会自动重试);MySQL 不可用同样失败。MQTT 连不上只记错误日志,不影响进程存活,重连由客户端自动进行。
> 网关的局域网 UDP 配置通道需要**服务器与网关同网段**,且 UDP 8787 只能由一个节点绑定。多实例部署时只在其中一个节点开启(或依赖分布式任务锁),Docker 部署必须使用 `--network host`。
## 2. 目录规划
```text
/home/ruoyi/
├── ruoyi-admin.jar # 后端可执行 jar
├── logs/ # logging.file.path
└── uploadPath/ # ruoyi.profile(上传文件)
/etc/ruoyi/ # 外部配置(root:ruoyi 750)
├── application-prod.yml # 环境差异项
├── application-druid.yml # 生产数据源(含口令,640)
└── ruoyi.env # systemd EnvironmentFile
/usr/share/nginx/html/ # 前端 dist 内容
```
```bash
useradd -r -s /sbin/nologin ruoyi 2>/dev/null || true
mkdir -p /home/ruoyi/{logs,uploadPath} /etc/ruoyi
chown -R ruoyi:ruoyi /home/ruoyi
chown root:ruoyi /etc/ruoyi
chmod 750 /etc/ruoyi
```
## 3. 运行环境
| 项 | 要求 | 说明 |
|---|---|---|
| JDK | 17 或 21 | 项目按 17 编译,可用 21 运行;**不要用 8/11** |
| 时区 | `Asia/Shanghai` | 授权有效期、到期任务、回调时间都依赖服务器时间 |
`ry.sh` / `ry.bat` 中的 JVM 参数是旧版 JDK 的写法,**在 JDK 17 上启动即失败**:
- `-XX:+PrintGCDateStamps`、`-XX:+PrintGCDetails` 在 JDK 9+ 已移除;
- `-XX:+UseParallelOldGC` 在 JDK 15 已移除。
因此下面的 systemd 单元不沿用这套参数。若仍要使用 `ry.sh`,需先删掉这些选项。
## 4. 配置来源与外部配置文件
jar 内的配置按环境拆成三份,公共项只维护一处:
| 文件 | 内容 | 何时生效 |
|---|---|---|
| `application.yml` | 公共项(端口、Redis、MyBatis、`iot.*`、日志级别基线) | 始终 |
| `application-druid.yml` | 数据源结构、连接池、Druid 监控(含主库兜底值) | profile 含 `druid` 时 |
| `application-local.yml` | 本机差异项:Windows 上传/日志目录、本地库、debug 日志、热部署 | profile 含 `local` 时 |
| `application-prod.yml` | 服务器差异项:Linux 上传/日志目录、生产库、info 日志、关闭热部署 | profile 含 `prod` 时 |
`application.yml` 默认 `spring.profiles.active: druid,local`,所以用 IDEA 直接启动就是本机配置;服务器必须切到 `druid,prod`:
```bash
java -jar ruoyi-admin.jar --spring.profiles.active=druid,prod
```
多个 profile 同时激活时**后列出的覆盖先列出的**(所以 `druid,prod` 中 prod 覆盖 druid 里的主库兜底值),且彼此没有继承关系。
启动时也可以从外部指定配置文件,jar 内的 yml 只作为默认值。优先级由高到低:
| 来源 | 写法 | 适用场景 |
|---|---|---|
| 命令行参数 | `--spring.config.additional-location=…`、`--spring.profiles.active=…` | 换环境不改包 |
| JVM 参数 | `-Dspring.config.additional-location=…` | 同上,`ps` 可见 |
| 环境变量 | `LOGGING_FILE_PATH`、`RUOYI_PROFILE`、`SPRING_PROFILES_ACTIVE` | systemd `EnvironmentFile` |
| jar 内置 yml | 打包时写入 | 默认值 |
### 4.1 外部配置目录
约定 `/etc/ruoyi/` 放外部配置,升级只替换 jar,不动这里:
```text
/etc/ruoyi/
├── application-prod.yml # 覆盖 jar 内的同名配置项
├── application-druid.yml # 生产数据源(含口令)
└── ruoyi.env # systemd EnvironmentFile:JVM 参数、启动参数
```
```text
# 启动参数(等价的 JVM 写法:-Dspring.config.additional-location=optional:file:/etc/ruoyi/)
--spring.config.additional-location=optional:file:/etc/ruoyi/
```
- `additional-location` 是**追加**:外部同名文件在 jar 内文件之后加载,同名配置项以外部为准;
- 不要改用 `spring.config.location`,那是**替换**,会把 jar 内的 Redis、MyBatis、`iot.*` 等默认配置整体丢掉;
- `optional:` 前缀保证目录暂时不存在时仍能启动,缺配置时表现为默认值而不是启动失败;
- 只指向目录时,Spring 会按 `application.yml` / `application-{profile}.yml` 的命名约定自动加载,不要写文件名。
### 4.2 profile 必须带上 druid
数据源结构、连接池与 Druid 监控写在 `application-druid.yml`。新增生产 profile 时要同时激活:
```text
--spring.profiles.active=druid,prod
```
只写 `prod` 会丢掉 `application-druid.yml`(连接池、监控页配置缺失)。写出 `prod,druid` 时 druid 反而会覆盖 prod 里的主库设置,因此**顺序固定为 druid 在前、环境 profile 在后**。
### 4.3 路径类配置
`application-local.yml` 用 Windows 路径,`application-prod.yml` 已写好 Linux 路径(默认 `/home/railwaysystem/service/doorcontrol/{uploadPath,logs}`)。换部署目录时只改 prod 文件,不必碰公共配置。
| 配置项 | 所在文件 | 环境变量 | JVM 参数 |
|---|---|---|---|
| `logging.file.path` | `application-{local,prod}.yml` | `LOGGING_FILE_PATH` | `-Dlogging.file.path=` |
| `ruoyi.profile` | `application-{local,prod}.yml` | `RUOYI_PROFILE` | `-Druoyi.profile=` |
环境变量与 JVM 参数的优先级高于 jar 内 yml,适合在不重新打包的情况下临时改目录。公共配置里的默认值是 `${RUOYI_PROFILE:./uploadPath}` 与 `${LOGGING_FILE_PATH:logs}`,两个变量都不设置、profile 也不对时才会落到相对目录。
日志路径能被覆盖,是因为 `logback-spring.xml` 已改为 `<springProperty source="logging.file.path" defaultValue="/home/ruoyi/logs"/>`;普通 `logback.xml` 不支持该机制,文件名不要改回去。
### 4.4 权限与生效
```bash
chmod 640 /etc/ruoyi/application-druid.yml /etc/ruoyi/ruoyi.env
```
- 配置只在启动时读取,改完执行 `systemctl restart ruoyi`,`reload` 无效;
- 数据库口令放 `application-druid.yml`,不要写进 `Environment=`,后者可被 `systemctl show ruoyi -p Environment` 读到;
- `ruoyi.env` 只放 JVM 参数与启动参数,同样按 640 收敛权限。
## 5. systemd 单元
新建 `/etc/systemd/system/ruoyi.service`:
```ini
[Unit]
Description=RuoYi Lock Server
After=network-online.target mysql.service redis.service
Wants=network-online.target
[Service]
Type=simple
User=ruoyi
Group=ruoyi
WorkingDirectory=/home/ruoyi
# 默认值;同名的 EnvironmentFile 变量会覆盖这里的值(后者优先级更高)
Environment="JAVA_OPTS=-Xms512m -Xmx1024m -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/home/ruoyi/logs"
Environment="SPRING_ARGS=--spring.config.additional-location=optional:file:/etc/ruoyi/ --spring.profiles.active=druid,prod"
Environment="LOGGING_FILE_PATH=/home/ruoyi/logs"
Environment="RUOYI_PROFILE=/home/ruoyi/uploadPath"
# 前缀 - 表示文件不存在也继续启动;存在时用于覆盖上面的默认值
EnvironmentFile=-/etc/ruoyi/ruoyi.env
# 用绝对路径的 java,避免 systemd 的 PATH 与登录 shell 不一致;
# $JAVA_OPTS / $SPRING_ARGS 按空白拆成多个参数,JVM 选项与启动参数都从这里注入
ExecStart=/usr/bin/java -Duser.timezone=Asia/Shanghai -Dlogging.file.path=${LOGGING_FILE_PATH} -Druoyi.profile=${RUOYI_PROFILE} $JAVA_OPTS -jar /home/ruoyi/ruoyi-admin.jar $SPRING_ARGS
SuccessExitStatus=143
Restart=on-failure
RestartSec=10
# 留出 MQTT 断开、在途事务与连接池关闭的时间
TimeoutStopSec=60
KillSignal=SIGTERM
LimitNOFILE=65535
StandardOutput=journal
StandardError=journal
SyslogIdentifier=ruoyi
[Install]
WantedBy=multi-user.target
```
`/etc/ruoyi/ruoyi.env` 示例(按环境改完只需重启服务,不用碰 unit 文件):
```ini
JAVA_OPTS=-Xms1g -Xmx2g -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/home/ruoyi/logs
SPRING_ARGS=--spring.config.additional-location=optional:file:/etc/ruoyi/ --spring.profiles.active=druid,prod
LOGGING_FILE_PATH=/home/ruoyi/logs
RUOYI_PROFILE=/home/ruoyi/uploadPath
```
换一套配置目录时也只改 `SPRING_ARGS`,例如测试环境 `optional:file:/etc/ruoyi/test/` 与 `--spring.profiles.active=druid,test`;环境文件里不要写 `export`,等号两侧不留空格。
说明:
- `Type=simple` + 前台运行,systemd 直接持有 PID,比 `nohup`/`ry.sh` 的 `ps | grep` 方式可靠;
- `SuccessExitStatus=143` 让 `kill -TERM` 退出不被视为失败;
- `After=` 只声明顺序,不表示强依赖;MySQL/Redis 在别的机器上时把对应单元名删掉;
- `Environment=` 写默认值、`EnvironmentFile=` 写环境差异,两处同名时以文件为准,避免升级 unit 时覆盖现场配置。
## 6. 启用与验证
```bash
systemd-analyze verify /etc/systemd/system/ruoyi.service # 先校验语法,避免 enable 后才发现拼错
systemctl daemon-reload
systemctl enable --now ruoyi
systemctl status ruoyi
journalctl -u ruoyi -f
curl -I http://127.0.0.1:8080/ # 后端存活
ls -l /home/ruoyi/logs # sys-info.log / sys-error.log
```
确认启动参数与配置来源已生效:
```bash
# 实际生效的命令行与环境变量(变量展开后的结果,需 root)
tr '\0' ' ' < /proc/$(systemctl show -p MainPID --value ruoyi)/cmdline; echo
tr '\0' '\n' < /proc/$(systemctl show -p MainPID --value ruoyi)/environ | grep -E 'JAVA_OPTS|SPRING_ARGS|LOGGING|RUOYI'
# Spring 启动日志中的激活 profile,应包含 druid,prod
journalctl -u ruoyi | grep -i "profile is active"
# 外部配置目录是否可读(用运行用户验证,避免权限问题被忽略)
sudo -u ruoyi test -r /etc/ruoyi/application-prod.yml && echo readable
```
`profile is active` 里看不到 `druid` 时,数据源不会加载,先检查 `SPRING_ARGS` 是否被环境文件整体覆盖(`EnvironmentFile` 的值会整行替换同名变量,不会追加)。
确认开机自启的软链已创建:
```bash
ls -l /etc/systemd/system/multi-user.target.wants/ruoyi.service
```
重启验证:
```bash
reboot
# 登录后
systemctl is-active ruoyi
```
常用运维命令:
```bash
systemctl restart ruoyi # 重启
systemctl stop ruoyi # 停止(SIGTERM,优雅退出)
journalctl -u ruoyi --since "10 min ago"
```
## 7. 前端部署(Nginx)
```bash
cd RuoYi-Vue2-master
npm ci
npm run build:prod # 输出 dist/
rsync -a --delete dist/ /usr/share/nginx/html/
```
`/etc/nginx/conf.d/ruoyi.conf`:
```nginx
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# 上传单文件上限 10MB、单请求 20MB,Nginx 默认 1m 会先于后端拒绝
client_max_body_size 20m;
location / {
try_files $uri $uri/ /index.html;
}
# .env.production 中 VUE_APP_BASE_API = '/prod-api'
location /prod-api/ {
proxy_pass http://127.0.0.1:8080/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s;
}
}
```
```bash
nginx -t && systemctl reload nginx
```
若对外暴露开放平台接口(`iot.openapi.enabled=true`,前缀 `/openapi/iot`),同样需要一条 `location /openapi/` 反代到 8080,并按调用方做 IP 白名单或网关限流;API Key 由「开放管理 › 应用接入」维护在 `lock_open_app` 表。
## 8. 升级与回滚
```bash
systemctl stop ruoyi
cp ruoyi-admin-new.jar /home/ruoyi/ruoyi-admin.jar.new
mv /home/ruoyi/ruoyi-admin.jar /home/ruoyi/ruoyi-admin.jar.bak
mv /home/ruoyi/ruoyi-admin.jar.new /home/ruoyi/ruoyi-admin.jar
chown ruoyi:ruoyi /home/ruoyi/ruoyi-admin.jar
systemctl start ruoyi
```
升级前先执行对应版本的数据库脚本(见《数据库迁移指引》),脚本均需幂等、可重复执行;本仓库不提供直连生产库执行的能力。
## 9. 常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
| 启动报 `Unrecognized VM option 'PrintGCDateStamps'` | 沿用了 `ry.sh` 的旧 JVM 参数 | 删除 JDK 9+ 已移除的 GC 参数 |
| 日志写到 `./C:/Users/...` | 启动时没带 `prod` profile,用了 `application-local.yml` 的 Windows 路径 | 加 `--spring.profiles.active=druid,prod`,或用 `-Dlogging.file.path` / `-Druoyi.profile` 覆盖 |
| 上传超过 1MB 报 413 | Nginx 默认 `client_max_body_size 1m` | 调到 20m,与 `spring.servlet.multipart` 对齐 |
| 页面 404(刷新后) | 前端未配 history 回退 | `try_files $uri $uri/ /index.html` |
| 接口 502 | 后端未起或端口不符 | `systemctl status ruoyi`、`ss -lntp \| grep 8080` |
| 定时任务在多实例重复执行 | 未启用分布式锁 | 只在一个节点运行 Quartz,或确认 `DistributedTaskLock` 生效 |
| UDP 8787 绑定失败 | 端口被占用 / 容器非 host 网络 / 多实例 | 单节点开启局域网配置,Docker 用 `--network host` |
| 日志里 SQL 全打印 | 把 mapper 包调成了 debug | 见 `application.yml` 中 `logging.level` 的 mapper 包说明 |
| 启动报 `Failed to configure a DataSource` | `--spring.profiles.active=prod` 漏掉了 `druid` | 改成 `druid,prod` |
| 外部配置没生效 | 用了 `spring.config.location`、路径缺少 `optional:file:` 前缀、或 `/etc/ruoyi` 对 `ruoyi` 用户不可读 | 改 `additional-location`,并用 `sudo -u ruoyi test -r ...` 验证 |
| 改了 `ruoyi.env` 没反应 | 配置只在进程启动时读取,`EnvironmentFile` 不热加载 | `systemctl restart ruoyi` |
| `systemctl show` 看不到变量 | unit 未 `daemon-reload`,或变量写在错误段落 | `systemctl daemon-reload` 后复查 |
## 10. 待确认
| # | 事项 | 影响 |
|---|---|---|
| 1 | 外部配置目录是否固定为 `/etc/ruoyi` | 决定其余环境(测试/预发)是换目录还是换 `SPRING_ARGS` |
| 2 | MySQL/Redis 是否同机部署 | 决定 `After=` 依赖与重启顺序 |
| 3 | 是否用 HTTPS / 域名 | 影响 `X-Forwarded-Proto` 与开放平台回调地址 |
| 4 | 多实例部署与 UDP 8787 归属 | 影响网关局域网配置与定时任务去重 |
@@ -487,21 +487,46 @@ IC 卡 / 密码:
#### 4.6.5 `getkey` —— 读取开门权限列表
请求:`["readkey","操作用户","设备ID","设备密码"]`
应答:`["readkey","索引","总数","卡号/密码","起始时间","结束时间","当前时间"]`
请求(主题 `thing/data/batch`,`mode=s2d`):`["readkey","操作用户","设备ID","设备密码"]`
**时间是 5 字节压缩位域**(非字符串),解析公式:
```
YEAR = (a & 0xFC000000) >> 26
MONTH = (a & 0x03C00000) >> 22
DAY = (a & 0x003E0000) >> 17
HOUR = (a & 0x0001F000) >> 12
MINUTE = (a & 0x00000FC0) >> 6
SECOND = a & 0x0000003F
```json
{"devId":"FFFA26092912","devPsw":"02234006","devVer":"1","ac":"req","at":"1793757002",
"seq":"1793757002652","op":"getkey","mode":"s2d","chksum":"xx",
"params":["readkey","admin","807104B5","02234006"]}
```
> 与其余协议的字符串时间格式**完全不同**,需独立的编解码器。
**网关进度应答**(`thing/data/batch_report`,seq 为原命令 seq,仅表示已转发):
```json
{"devId":"E8F7AFF69483","seq":"1793757002652","op":"getkey","params":["readkey","10","ad","null"]}
```
**门锁逐条数据**(`thing/data/batch_report`,**seq 恒为 `"0"`**):
```json
["readkey","1","2","02","FFF54321","6AB80000","6AC80000",null,"6AC89C4D"]
["readkey","2","2","07","AA000001","6AC20000","6AFC0000",null,"6AC89C4D"]
```
**结束标记**:`["readkey","NULL"]`
| 位置 | 字段 | 说明 |
|---|---|---|
| 0 | 动作 | 固定 `readkey` |
| 1 | 索引 | 从 1 开始 |
| 2 | 总数 | 本次读取的权限条数 |
| 3 | 类型 | 设备编码:`02` 密码、`07` 人脸(与平台 `auth_type` 的 01/02 不同) |
| 4 | 凭据 | 密码原文(不足 8 位前面补 `F`,`FFF54321` 表示 `54321`);人脸为人员ID(如 `AA000001`) |
| 5 | 生效时间 | 8 位十六进制 Unix 秒 |
| 6 | 失效时间 | 同上 |
| 7 | 空占位 | 实测为 `null`,解析需容忍 |
| 8 | 设备当前时间 | 同上 |
**时间是 8 位十六进制 Unix 秒**(无 `0x` 前缀),例如 `6AB80000` = 1790443520 → 2026-09-27 01:25:20 (+08:00)。
> 旧文档 §4.6.5 写的「5 字节压缩位域」与真实报文不符,真实值按十六进制秒解析;
> 该位域公式已废弃。门锁数据行 seq=0 无法直接匹配命令,平台按上报网关所属分组的
> 已发送读取命令认领。
---
@@ -635,7 +660,7 @@ SECOND = a & 0x0000003F
|---|---|---|
| 13 位毫秒时间戳(数字或字符串) | `1676557176079`、`"1686187224838"` | 信封 `at` |
| `yyyy-MM-dd HH:mm:ss` | `2023-07-06 12:21:00` | 凭据有效期、开门记录时间、校时 `systime` |
| 5 字节压缩位域 | `5B0C0000`、`5B1CB21F` | `getkey` 权限时间 |
| 8 位十六进制 Unix 秒 | `6AB80000`、`6AC89C4D` | `getkey` 权限时间 |
> 另有示例中出现中文冒号 `18:00:00`(全角),解析需做全角转半角兜底。
@@ -668,7 +693,7 @@ SECOND = a & 0x0000003F
| `facefeature`/`fingerfeature` | `add`/`delete` | data/batch | ↓ | data/batch_report(两段) | `[动作,锁ID,结果,人faceID,msg]` | 生物特征下发 |
| `getslaveinfo` | `lock`/`powersw` | data/batch | ↓ | data/batch(`ac=resp`) | `[命令,锁ID,状态,电量,RSSI]` | 查状态 |
| `getrecord` | `readrecord` | data/batch | ↓ | 同上 + 异步 `lockrecord` ↑ | `[命令,错误码,用户,msg]` | 读记录 |
| `getkey` | `readkey` | data/batch | ↓ | 同上 | `[命令,索引,总数,卡号,起,止,当前]` | 读权限列表 |
| `getkey` | `readkey` | data/batch | ↓ | data/batch_report(进度 10 + seq=0 逐条 + NULL 结束) | `[命令,索引,总数,类型,凭据,起,止,空,当前]` | 读权限列表(02密码/07人脸) |
| `lockrssi` | — | data/batch_report | ↑ | data/batch_report(`{"0":""}`) | 对象 | 电量/信号/状态上报 |
| `eventinfo` | `lock`/`powersw` | event/trigger | ↑ | (无显式应答) | — | 状态变化通知 |
| `lockrecord` | — | event/trigger | ↑ | (V2 有 `{"0":""}` 应答) | — | 开门记录 |
@@ -819,7 +844,7 @@ V1 无应答;V2 有应答。平台应**以 `(锁ID, 时间, 类型, 数据)`
5. **两段式应答 + 中间态**状态机(`10` → `0`/`11`/`12`)。
6. **路由标识与身份标识分离**(topic 的 `{deviceId}` vs payload `devId`)。
7. **协议版本分支**(`protocalVer=1/2` 记录字段语义不同)。
8. **3 种时间格式**编解码(毫秒戳、`yyyy-MM-dd HH:mm:ss`、5 字节位域)。
8. **4 种时间格式**编解码(毫秒戳、`yyyy-MM-dd HH:mm:ss`、5 字节位域、8 位十六进制 Unix 秒——后者用于 `readkey` 上行)。
9. **分组/网关/锁三级寻址**,网关 ≤16 锁的容量约束。
10. **幂等与去重**:`seq` 关联请求,业务键去重记录。
+443
View File
@@ -0,0 +1,443 @@
# 授权管理多锁批量授权设计(代码就绪,未联调)
> 状态:**代码就绪,未联调**。Q1–Q7 按 §10 推荐项实施;无 DDL、无脚本。后端离线编译通过,前端模板/脚本/样式校验通过,未启动服务、未连库。
> 2026-11-03 界面改版:门锁选择改为「分组 → 门锁」两级勾选树,授权信息统一放在树上方,并支持编辑时按差异回收/补发(详见 §4)。
> 相关文档:《门锁系统架构设计》《住宿管理模块设计》《项目管理看板》。
> 外部系统(开放平台 OpenAPI)下发的授权展示与操作,见《授权页外部系统授权管理设计》。
> 基线代码:`ruoyi-iot` v1 结构,`lock_auth` 仍是授权事实源,前端为 `RuoYi-Vue2-master`。
---
## 1. 目标与范围
### 1.1 需求
授权管理页当前一次只能给一把门锁授权。给同一人员开通多把锁(如多层办公区、多个出入口)时,需要重复录入人员、类型、有效期,操作次数与门锁数成正比。
本期在授权管理页增加**多锁批量授权**:选择一次人员、授权类型、有效期,勾选多把门锁,一次提交生成多条授权,并逐锁下发。
### 1.2 目标
- 一次提交为同一人员在 N 把门锁上建立同类型授权,生成 N 条独立的 `lock_auth`。
- 每把锁独立展示同步状态与失败原因,部分失败不影响其它锁。
- 重复提交幂等,不产生重复授权行。
### 1.3 非目标
- 不新建「授权组 / 授权模板」持久化对象。批量授权只是**创建时的便捷入口**,生成后每行独立。
- 不改变单锁新增、修改、删除、下发、清除的既有语义与接口。
- 不支持一次提交多种授权类型(人脸 + 密码需分两次提交)。
- 不改设备协议、不改表结构、不新增权限点。
---
## 2. 现状与约束
| # | 现状 | 代码位置 | 对批量授权的影响 |
|---|---|---|---|
| 1 | 一条授权 = 人员 × 门锁 × 类型,唯一键 `uk_lock_auth_person_lock(person_no, device_id, auth_type)` | `sql/lock.sql`(`lock_auth` DDL) | 多锁必须拆成 N 行;同人同锁同类型不能重复 |
| 2 | 唯一键不含 `del_flag`,重复新增命中时复用原行覆盖并重推 | `LockAuthServiceImpl.insertAuth`、`reviveAuth` | 批量里已存在的锁可直接覆盖,无需单独判重分支 |
| 3 | 冗余字段逐行补齐(`device_sn/gateway_id/group_id`),姓名以人员主数据为准 | `LockAuthServiceImpl.fillAuth/fillPerson` | 每行必须按各自 `deviceId` 重新填充,不能共用一份快照 |
| 4 | 下发按行选路,回执按 `bizId=authId` 回写 | `pushInternal`、`handleResult` | N 行天然独立回执、独立补偿 |
| 5 | 网关同一时刻只处理一条 `data/batch`,在途时置待下发排队 | `pushInternal` 中 `countInflightAuthCommands` | 同一网关的多把锁是**串行排队**,不是并发下发 |
| 6 | 授权补偿任务 5 秒一轮,续发排队的授权 | `LockCommandRetryTask`(`fixedDelay=5000`) | N 把锁同一网关时,全部下发完成约需 `N × 5 秒` |
| 7 | 人脸按目标门锁的 `face_algo_version` 从 `lock_face_feature` 选值 | `resolveAuthValue` | 不同锁可能用不同版本特征,**不能把一份 Base64 复用到所有锁** |
| 8 | 人脸缺版本特征时该行置失败并写明版本;补提后自动补发 | `resolveFaceMissingReason`、`LockFaceServiceImpl.repushPendingFaceAuths` | 批量人脸可先建档、后补特征,无需提交时强制提取 |
| 9 | 人员停用/未生效/过期不阻断建档,只在下发时判失败 | `fillPerson`、`resolvePersonFailReason` | 保留「提前授权、到点生效」能力,批量不应提前整批拒绝 |
---
## 3. 方案总览
```
提交模板(人员/类型/内容/有效期/备注) + deviceIds[]
│
├─ 校验人员已建档(整批前置)
├─ deviceIds 去空、去重、限上限
│
└─ 逐锁:唯一键预查 → 新建 / 复用覆盖
→ insertAuth(逐行事务、逐行下发)
→ 回读 sync_status 作为结果
```
关键取舍:
1. **一锁一行,不建授权组**。生成后与单锁授权完全一样,可单独修改、下发、删除;要统一改有效期,再次批量提交覆盖即可。
2. **覆盖而非跳过**。命中唯一键时沿用 `insertAuth` 现有语义:保留原 `auth_id/auth_no`,覆盖内容与有效期,重置为待下发并重新下发。
3. **人脸内容不进模板**。提交时 `authValue` 置空,下发时由后端按每把锁的算法版本取特征,避免版本错配。
4. **不做整批事务**。下发会发布 MQTT(不可回滚),且单锁失败不应拖累其它锁;每行独立事务、独立结果。
---
## 4. 交互设计
### 4.1 入口
> 列表已改为「一人一行」的人员维度展示,入口随之调整,详见 §11。
1. 授权管理工具栏「新增授权」按钮(`v-hasPermi="['lock:auth:add']"`):空白进入,选定「人员 + 授权类型」后自动回显该人员已有的同类型授权;已有授权时直接进入编辑态。
2. 人员行操作「编辑授权」:带入该行 `personNo`(弹窗内人员锁定),按当前授权类型回显该人员在该类型下的全部门锁勾选,可增删门锁或切换类型。
单锁授权不再单独保留入口,新增/编辑统一走同一套弹窗。
### 4.2 弹窗布局与字段
弹窗分上下两块:**上方授权信息,下方分组-门锁勾选树**。所有勾选门锁共用同一份授权信息,不再逐锁设置有效期。
上方字段:
| 字段 | 控件 | 说明 |
|---|---|---|
| 授权类型 | 下拉(`lock_auth_type`) | 默认人脸;切换类型清空人员、授权内容并作废已有授权基准 |
| 人员 | 可搜索下拉 | 人脸取人脸库;其它类型取人员主数据。切换人员后重新拉取已有授权 |
| 授权内容 | 单行输入 | 仅密码/指纹/IC卡显示且必填;编辑时若原各锁内容一致则回填,不一致留空要求重填 |
| 人脸特征 | 只读信息块 | 展示人脸库记录状态;特征值在下发时按每把锁算法版本自动取用 |
| 生效 / 失效时间 | 日期时间 | 全部勾选门锁统一使用;失效留空表示永久;校验失效不得早于生效 |
| 备注 | 文本域 | 统一写入每行 `remark` |
下方门锁树:
- `el-tree` 两级目录:**分组为父节点,门锁为叶子**;`show-checkbox`,勾选分组即选中其下全部门锁,部分勾选时父节点呈半选。
- 叶子展示门锁名称、SN、房间号、在线状态;已有授权的门锁加「已授权」标签。
- 工具条:关键字搜索(分组名 / 门锁名 / SN / 房间号)、全选、清空、展开、收起;标题右侧实时显示「已选 N 把 · M 个分组」。
- 单次最多 100 把(沿用后端上限),超限由表单校验拦截。
交互细节:
- 编辑态顶部提示已有授权数量,并说明「取消勾选回收、新勾选下发、提交后统一有效期」。
- 已有授权的有效期不一致时,带入最近一条并显示告警:提交后全部统一为上方时间。
- 门锁选项来自现有 `listDevice({pageNum:1,pageSize:500})`;本期沿用 500 条上限。
- 提交中禁用按钮,避免重复提交。
### 4.3 提交结果
提交成功后关闭弹窗,展示结果面板(表格):
| 列 | 内容 |
|---|---|
| 门锁 | 名称(SN) |
| 动作 | 新增 / 覆盖 / 下发失败 / 已回收 / 回收失败 |
| 同步状态 | `lock_sync_status` 字典标签(回收行无) |
| 说明 | `sync_msg`(如排队、缺特征版本、未绑网关) |
顶部汇总:**本次勾选 N 把 · 下发 x 把(新增 a / 覆盖 b / 失败 c)· 回收 d 把(失败 e)**。关闭结果后按当前人员编号过滤刷新列表。
### 4.4 编辑时的差异处理
以「该人员 + 类型下已有授权」为基准,与最终勾选集合比对:
| 变化 | 动作 | 调用 |
|---|---|---|
| 新勾选的门锁 | 新下发 | `POST /lock/auth/batch`(仅新增项) |
| 已勾选且有效期/内容/备注有变化 | 覆盖并重推,保证统一 | `POST /lock/auth/batch`(全部勾选项) |
| 取消勾选的门锁 | 回收平台授权并下发删除帧 | `DELETE /lock/auth/{authIds}` |
| 勾选与内容均无变化 | 不提交,提示「授权配置未发生变化」 | — |
执行顺序:**先下发、后回收**。批量接口整批拒绝(如人员不存在)时不执行回收,避免撤销后无法补发;回收失败不回滚已下发结果,在结果面板标「回收失败」并可再次提交。
全部门锁取消勾选是允许的:编辑态下等价于回收该人员在该类型下的全部授权。
---
## 5. 接口设计
### 5.1 新增接口
`POST /lock/auth/batch`
- 权限:复用 `lock:auth:add`(不新增权限点、不改菜单脚本)。
- 日志:`@Log(title = "门锁授权批量新增", businessType = BusinessType.INSERT)`。
- 鉴权、脱敏、导出等既有约束不变。
请求体:
```json
{
"personNo": "P0001",
"authType": "01",
"authValue": null,
"beginTime": "2026-11-03 00:00:00",
"endTime": null,
"remark": "三号楼出入口",
"deviceIds": [12, 15, 18]
}
```
| 字段 | 必填 | 说明 |
|---|---|---|
| personNo | 是 | 人员编号,必须在人员主数据中存在 |
| authType | 是 | `01` 人脸 / `02` 密码 / `03` 指纹 / `04` IC卡 |
| authValue | 非人脸必填 | 人脸忽略该字段,后端强制置空 |
| beginTime / endTime | 否 | `yyyy-MM-dd HH:mm:ss` |
| remark | 否 | 写入每行 |
| deviceIds | 是 | 去重后至少 1 个,建议上限 100 |
响应体:
```json
{
"code": 200,
"msg": "操作成功",
"data": {
"total": 3,
"created": 2,
"overwritten": 1,
"failed": 0,
"items": [
{
"deviceId": 12,
"deviceSn": "AA0000000012",
"deviceName": "1F 东门",
"action": "CREATED",
"authId": 901,
"syncStatus": "1",
"syncMsg": null
}
]
}
}
```
- `action`:`CREATED` 新建 / `OVERWRITTEN` 覆盖 / `FAILED` 建档失败。
- `syncStatus/syncMsg` 为提交后回读值,只代表「命令已提交或排队」,不代表设备已确认;设备确认后由 `handleResult` 回写。
- 人员不存在、`deviceIds` 为空、超过上限等参数错误走统一异常处理返回错误提示,不产生任何行。
### 5.2 前端 API
`src/api/iot/auth.js` 新增:
```js
// 多锁批量授权
export function batchAddAuth(data) {
return request({ url: '/lock/auth/batch', method: 'post', data })
}
```
### 5.3 新增 DTO
项目没有 `lock/dto` 包,按现有 `lock/domain` 风格新增两个类:
- `lock/domain/LockAuthBatchRequest`:`personNo/authType/authValue/beginTime/endTime/remark/deviceIds`。
- `lock/domain/LockAuthBatchResult`:`deviceId/deviceSn/deviceName/action/authId/syncStatus/syncMsg`,外加汇总计数字段。
不新增 `@Excel` 注解,不参与导出。
---
## 6. 服务实现要点
### 6.1 流程
`ILockAuthService.batchInsertAuth(LockAuthBatchRequest request)`:
1. 校验 `personNo` 非空且人员存在(不存在则整批拒绝,避免生成 N 行无姓名快照的数据)。
2. `deviceIds` 去空、去重(保持选择顺序),校验非空与上限。
3. 对每个 `deviceId`:
- 预查唯一键,记录 `CREATED` / `OVERWRITTEN`;
- 复制模板构造新的 `LockAuth`,**不得复制 `authId/authNo`**,设置 `deviceId`;
- 人脸类型强制 `authValue=null`、`authValueMd5=null`;
- 调用 `insertAuth`(现有逻辑负责补齐快照、覆盖/新增、置状态、下发/排队);
- 回读该行 `sync_status/sync_msg` 填入结果;
- 单条异常捕获为 `FAILED` 并记录原因,继续处理下一把锁。
### 6.2 事务边界
- 不使用整批 `@Transactional`。
- `insertAuth` 保留 `@Transactional`,内部委托给私有 `doInsertAuth`;批量方法逐行直接调用 `doInsertAuth`,**刻意不经过事务代理**,每行独立提交,单锁异常不回滚其它锁。
- 插入语句固定写 `sync_status='0'`;批量 `catch` 中发现行已生成 `authId` 时回写 `sync_status='3'` 并记录原因,补偿任务可继续重发,避免停在「已下发」不再重试。
- 不在批量方法内等待设备回执。下发失败/排队由 `sync_status` 与补偿任务承接。
### 6.3 与现有能力的复用
| 能力 | 复用点 |
|---|---|
| 授权编号生成 | `fillAuth`(时间戳 + 6 位随机,逐行独立) |
| 人员姓名快照 | `fillPerson` |
| 门锁/网关/分组快照 | `fillAuth` 按各自 `deviceId` 补齐 |
| 重复覆盖 | `insertAuth` + `reviveAuth` |
| 路由与下发 | `pushInternal`(每行实时选路) |
| 同网关排队 | `countInflightAuthCommands` |
| 人脸按版本取特征 | `resolveAuthValue` |
| 失败补偿 | `LockCommandRetryTask` → `retryPending` |
| 下发结果回写 | `handleResult`(按 `bizId=authId`) |
---
## 7. 失败与边界
| 场景 | 行为 |
|---|---|
| 人员未建档 | 整批拒绝,提示先建人员档案;不生成任何行 |
| 人员停用 / 未到生效 / 已过期 | 正常建档,`sync_status=3` 并写入原因;人员恢复或到点后补偿自动下发(沿用单锁语义) |
| 门锁记录不存在 | 建档前即判失败,结果为 `FAILED`,不生成授权行;正常入口的选项来自有效门锁列表,出现即数据异常 |
| 门锁未绑网关 / 分组缺失 | 建档成功、下发失败,结果说明原因;补绑后补偿自动恢复 |
| 人脸缺少某算法版本特征 | 该锁失败并提示所需版本;补提该版本特征后 `repushPendingFaceAuths` 自动重发 |
| 同一网关多把锁 | 首条立即下发,其余置「待下发」排队;完成时间约 `N × 5 秒`,结果面板显示排队说明 |
| 重复提交同一批 | 第二次全部为 `OVERWRITTEN`,不产生重复行 |
| 并发提交同一批 | 唯一键兜底,`DuplicateKeyException` 回查后按覆盖处理(`insertAuth` 已有) |
| `deviceIds` 重复 | 去重后只处理一次 |
| 设备超过 500 把 | 现有选项加载上限 500,超出的锁无法选择(见待确认 Q5) |
| 编辑时取消勾选 | 调用 `deleteAuthByIds`:逐行下发 delete 帧后删除平台授权;无 `lock:auth:remove` 权限时前端阻断并提示 |
| 编辑时全部取消勾选 | 允许,等价于回收该人员在该类型下的全部授权 |
| 编辑时批量下发整批被拒(人员不存在等) | 不执行回收,保留原授权,弹窗不关闭,由接口提示原因 |
| 编辑时回收失败 | 已下发结果不回滚;结果面板标注「回收失败」,可再次提交重试 |
---
## 8. 影响文件
| 层 | 文件 | 改动 |
|---|---|---|
| 接口 | `ruoyi-iot/.../lock/controller/LockAuthController.java` | 新增 `POST /batch` |
| 服务 | `.../lock/service/ILockAuthService.java`、`.../service/impl/LockAuthServiceImpl.java` | 新增 `batchInsertAuth`;`insertAuth` 抽出私有 `doInsertAuth` 供逐行调用 |
| 领域 | `.../lock/domain/LockAuthBatchRequest.java`、`LockAuthBatchResult.java` | 新增 |
| 前端 API | `base/RuoYi-Vue2-master/src/api/iot/auth.js` | 新增 `batchAddAuth` |
| 前端页面 | `base/RuoYi-Vue2-master/src/views/iot/auth/index.vue` | 多锁授权入口、行内「多锁配置」、分组-门锁勾选树、统一授权信息、编辑差异回收/补发、结果面板与样式 |
| 数据库 | — | 无 DDL、无脚本 |
| 权限/菜单 | — | 无新增,复用 `lock:auth:add` |
---
## 9. 测试与验收
### 9.1 验证方式
- 后端:离线编译(`mvn -o -q -pl ruoyi-iot -am -DskipTests compile`)。
- 前端:模板编译 + 脚本解析(沿用本项目现有校验方式)。
- 不启动服务、不连库;设备侧行为需在测试环境实测。
### 9.2 用例
| # | 场景 | 预期 |
|---|---|---|
| 1 | 同网关 3 把锁,新建密码授权 | 生成 3 行,首条已下发,其余待下发并逐步下发成功 |
| 2 | 跨 2 个网关各 2 把锁 | 每个网关各一条在途,其余排队 |
| 3 | 其中 1 把锁已有同人同类型授权 | 该行 `OVERWRITTEN`,`auth_id` 不变,有效期被覆盖并重推 |
| 4 | 人脸授权,3 把锁 2 个算法版本且均有特征 | 3 行按各自版本下发成功 |
| 5 | 人脸授权,其中 1 把锁缺对应版本特征 | 该锁失败并提示版本,补提后自动补发 |
| 6 | 人员已停用 | 生成 N 行、全部待补偿;人员恢复后自动下发 |
| 7 | 人员不存在 | 整批失败,无新增行 |
| 8 | `deviceIds` 含重复值 | 只生成一行 |
| 9 | 门锁未绑网关 | 该行下发失败并说明原因,其它行不受影响 |
| 10 | 重复提交同一批 | 全部覆盖,无重复行 |
| 11 | 无 `lock:auth:add` 权限 | 接口 403,按钮不可见 |
| 12 | 超过上限提交 | 参数错误,无新增行 |
| 13 | 勾选分组节点 | 该分组下全部门锁被选中,取消分组则全部取消 |
| 14 | 打开行内「多锁配置」 | 回显该人员该类型下已有授权,已授权门锁带标签并勾选 |
| 15 | 编辑时取消 2 把、新增 1 把 | 新增锁走批量下发,取消锁下发 delete 帧后删除平台授权;未变化的锁不重推 |
| 16 | 编辑时只改统一失效时间 | 全部勾选锁被覆盖并重推,有效期一致 |
| 17 | 编辑时全部取消勾选 | 全部授权被回收,结果面板显示回收数量 |
| 18 | 编辑时无任何变化直接提交 | 提示「授权配置未发生变化」,不产生任何下发 |
| 19 | 无 `lock:auth:remove` 权限但取消勾选 | 前端阻断并提示缺少删除权限,不下发任何指令 |
### 9.3 验收标准
- 一次提交生成 N 条授权,列表按人员可查。
- 每行可独立下发、修改、删除,状态互不覆盖。
- 覆盖场景不产生重复行。
- 人脸各自按门锁版本取特征。
- 部分失败时成功行不受影响,失败原因可在结果面板和列表 `sync_msg` 看到。
- 单锁新增、批量下发、批量清除、补偿任务行为不变。
---
## 10. 待确认问题
| # | 问题 | 推荐 |
|---|---|---|
| Q1 | 入口形态:独立「多锁授权」按钮,还是把「新增」的门锁改成多选? | 独立按钮,改动小、不影响单锁人脸提取流程 |
| Q2 | 已存在同人同锁同类型授权时:覆盖还是跳过? | 覆盖并重推,沿用现有幂等语义;结果中标注「覆盖」 |
| Q3 | 人脸特征:提交时按算法版本逐版本提取,还是先建档、缺特征时再补提自动重发? | v1 采用后者,接口简单;提取耗时与失败处理都不进入批量接口 |
| Q4 | 单次提交上限 | 100 把;同一网关排队约 `N × 5 秒`,过大时用户等待时间长 |
| Q5 | 门锁选项超过 500 把是否需要远程搜索 | 先沿用 500 条上限;若现场设备更多,再改为远程搜索 |
| Q6 | 结果展示:弹窗结果表格,还是仅 toast 汇总 | 结果表格(含状态与原因),仅汇总无法定位失败锁 |
| Q7 | 是否需要「按人员批量改有效期 / 批量删除全部锁授权」入口 | 已由本次界面支持:勾选树 + 统一有效期;取消勾选或全部取消即回收 |
本期按以上推荐项实施。若需要改为「跳过已存在」「提交时逐版本提取特征」「超过 500 把远程搜索」等方案,按对应条目调整即可,不影响一锁一行的数据模型。
---
## 11. 人员维度授权页面改版
### 11.1 背景与目标
原列表按 `lock_auth` 一行一条授权展示,同一人员授权多把门锁时会占多行,无法直接看到「该人一共授权了多少把、下发成功了多少把」。本次改为**一人一行**:
- 合并展示该人员所有有效授权的数量与已下发数量。
- 行内进入编辑,可新增、删除该人员的门锁授权。
- 人员名下授权全部取消或回收后,该人员不再出现在列表中。
- 删除该人员时,删除其名下全部锁授权(含设备侧清除帧),不只是单条。
数据模型不变:底层仍是一人一锁一类型一行,仅列表聚合口径与入口变化,**无 DDL、无新增权限点**。
### 11.2 列表聚合口径
新增查询 `selectPersonAuthList`,按 `person_no` 分组:
| 列 | 口径 |
|---|---|
| `auth_count` | 该人员有效授权行数(`del_flag='0'` 且 `sync_status<>'4'`) |
| `device_count` | `count(distinct device_id)`,即授权门锁把数 |
| `issued_count` | `sync_status='2'`(已成功下发)的行数 |
| `pending_count` | `sync_status` 为空或 `0/1/6`(待下发、已下发待确认、清除失败) |
| `failed_count` | `sync_status='3'` |
| `auth_types` | `group_concat(distinct auth_type)`,前端渲染为类型标签 |
| `begin_time` / `end_time` | `min(begin_time)` / `max(end_time)`,展示有效期区间 |
| `update_time` | `max(update_time)`,用于默认排序 |
- `sync_status='4'`(已清除)不计入,因此人员授权被全部清除后自然从列表消失。
- 分页在 SQL 层完成:`PageHelper.startPage()` 包裹 `group by` 查询,按人员分页。
- 查询条件 `keyword`(编号/姓名)、`deviceId`、`groupId`、`authType`、`syncStatus` 会先过滤授权行再聚合,因此多条件筛选时显示的计数是**满足条件的子集**;默认无筛选时即全量。
### 11.3 接口
| 方法 | 路径 | 权限 | 说明 |
|---|---|---|---|
| GET | `/lock/auth/person/list` | `lock:auth:list` | 人员维度分页列表,入参 `LockAuthPerson` |
| POST | `/lock/auth/person/remove` | `lock:auth:remove` | 请求体为 `personNo` 数组,删除这些人员名下全部授权 |
| POST | `/lock/auth/person/push` | `lock:auth:push` | 请求体为 `personNo` 数组,批量下发这些人员的授权 |
服务实现:
- `deleteAuthsByPersonNos`:先按人员查出全部 `del_flag='0'` 的 `auth_id`(含 `sync_status='4'`),再复用 `deleteAuthByIds`,逐条发送门锁清除帧后逻辑删除。
- `batchPushByPersonNos`:只挑选可下发行(排除 `4`/`6`),复用 `batchPush` 逐条下发,单条失败不影响其余。
- 人员编号统一 `trim` 并用 `LinkedHashSet` 去重,空数组直接返回。
- 编辑时的增删仍走原有 `/lock/auth/batch`(新增/覆盖)与 `/lock/auth/{authIds}`(删除),语义见 §4.4。
### 11.4 前端页面
列表列:人员编号、人员姓名、授权类型(标签)、授权门锁数、授权数量、已下发(`issued / total`)、下发状态(有失败显示「失败 N」、有待处理显示「待处理 N」、否则「正常」)、有效期区间、最近更新、操作。
- 工具栏:新增授权 / 下发(选中人员)/ 删除授权(选中人员);移除原「导出」与单条授权的新增、修改、下发、删除人脸入口。
- 行操作:编辑授权 / 下发 / 删除。
- 编辑弹窗复用 §4.2 的分组-门锁树与统一有效期:
- 从行进入时人员锁定,默认带出该人员的第一种授权类型,顶部显示该人员各类型授权数量汇总标签,可切换类型继续编辑。
- 打开时按 `personNo` 精确拉取该人员已有授权(`listAuth` 的 `personNo` 是模糊匹配,前端按精确值再过滤,并排除 `sync_status='4'`),回填勾选门锁、有效期与备注。
- 提交沿用差异处理:取消勾选 → 删除;新勾选或有效期/内容变化 → 新增/覆盖后重推;先下发后回收;无变化提示「授权配置未发生变化」。
- 无 `lock:auth:remove` 权限时不允许取消已有门锁,前端直接阻断。
- 删除确认文案明确「删除后将回收该人员名下全部 N 条门锁授权」。
### 11.5 影响文件
| 文件 | 变更 |
|---|---|
| `ruoyi-iot/.../domain/LockAuthPerson.java` | 新增人员聚合 DTO |
| `ruoyi-iot/.../mapper/LockAuthMapper.java` / `LockAuthMapper.xml` | 新增聚合查询与按人员取 `auth_id` 的两个查询 |
| `ruoyi-iot/.../service/ILockAuthService.java` / `LockAuthServiceImpl.java` | 新增人员列表、按人员删除、按人员下发 |
| `ruoyi-iot/.../controller/LockAuthController.java` | 新增三个人员维度接口 |
| `RuoYi-Vue2/src/api/iot/auth.js` | 新增三个前端 API |
| `RuoYi-Vue2/src/views/iot/auth/index.vue` | 列表与入口改为人员维度 |
### 11.6 验证情况
- 后端 `mvn -o -B -pl ruoyi-iot -am -DskipTests compile` 通过(167 个源文件)。
- 前端通过 `vue-template-compiler` 模板编译、`@babel/parser` 脚本解析、`sass` 样式编译。
- **尚未启动联调、未在真实数据库验证**,聚合 SQL 与分页需在联调环境确认。
### 11.7 待确认
| # | 问题 | 当前实现 |
|---|---|---|
| Q8 | 已下发数量是否包含「已下发待确认(1)」 | 只统计成功(2);有待确认时以「待处理」提示 |
| Q9 | 多类型人员编辑时的默认类型 | 取字典序最小(`group_concat ... order by`)的第一种,可手动切换 |
| Q10 | 筛选条件下计数是否应为全量 | 当前为筛选子集,符合「先筛授权再看人员」的直觉 |
@@ -0,0 +1,496 @@
# 授权页外部系统授权管理设计(代码就绪,未联调)
> 状态:**代码就绪,未联调、未上线**。后端、前端与 D23 脚本已按本设计实现,后端离线编译通过、前端模板/脚本/SCSS 校验通过;尚未在真实环境执行脚本或联调。
> 目标:在授权管理页展示开放平台(OpenAPI)下发的授权,支持查看状态、修改有效期、重新下发、取消、删除。
> 相关文档:《授权管理多锁批量授权设计》《对外开放房间门禁服务接口设计》《开放平台接入管理设计》《项目管理看板》。
> 基线:`lock_auth` 为授权事实源;开放接口由 `OpenApiAuthFilter` 按 `X-API-Key` 识别 `appCode`;接入应用配置在 `lock_open_app`。
---
## 1. 背景与现状
### 1.1 需求
外部业务系统(PMS、访客、园区平台等)通过开放平台 OpenAPI 给人员下发门锁授权后,平台管理员需要在授权管理页看到这些授权,并能:
- 查看授权状态(下发成功 / 失败 / 待下发 / 已取消 / 待清除)及失败原因。
- 修改授权有效期。
- 重新下发。
- 取消授权(回收门锁侧权限,平台留痕)。
- 删除授权(回收门锁侧权限并删除平台记录)。
### 1.2 现状
| # | 现状 | 问题 |
|---|---|---|
| 1 | 外部授权有两个入口:`POST /openapi/iot/auth` 直接建授权;外部入住/换房由 `LockStayServiceImpl` 建人脸授权 | 两种入口都调用同一个 `insertAuth`,`lock_auth` 没有任何来源字段 |
| 2 | 平台手工授权、外部直接授权、外部入住授权三者混在同一张表 | 页面无法区分“这条授权是谁下发的”,无法按应用排查 |
| 3 | 当前人员维度列表查询全部 `lock_auth` | 外部授权其实已经混在人员行里,但只体现为数量,无法识别来源,编辑时还可能被平台误删 |
| 4 | `sync_status='4'`(已清除)被人员聚合排除 | 外部授权被取消后从页面完全消失,无法查看取消结果 |
| 5 | `OpenAuthApiController` 的 `list/push/clear/remove` 只校验 API Key 有效,不校验授权归属 | 任一有效应用可凭 `authId` 操作其它应用甚至平台的授权;`list` 还会返回全量授权及 `auth_value`(人脸特征/密码) |
| 6 | 外部入住退房调用 `clear`,授权只置为已清除 | 授权行保留但无来源标记,退房后的历史授权无法按应用追溯 |
结论:不是“外部授权没有显示”,而是**已混入列表但不可辨识、不可控,且开放接口存在跨应用越权**。本期需要先补来源归属,再做页面与接口隔离。
### 1.3 不在本期范围
- 不改变“一条授权 = 一人一锁一类型”的数据模型与唯一键。
- 不新增外部系统主动回调(平台修改/取消外部授权后不回推外部系统)。
- 不实现外部授权的新增入口(外部授权仍只能由 OpenAPI 或外部入住产生)。
- 不改造人脸特征提取、下发队列、补偿任务和命令回执逻辑。
- 不做历史直接授权接口(无来源标记)的强制归属迁移。
---
## 2. 方案总览
1. **补来源归属**:`lock_auth` 增加 `source`、`source_app_code`、`source_biz_no` 三列,在创建授权时写入;存量按可判定规则回填。
2. **入口隔离**:平台入人口径只看 `source='1'`;外部授权单独查询 `source='2'`,互不混排。
3. **接口隔离**:开放接口的查询、下发、取消、删除都必须校验 `source='2'` 且 `source_app_code = 当前 appCode`;列表不再返回授权凭据。
4. **页面双页签**:
- 「平台授权」:沿用现有“一人一行”的人员聚合,只统计平台授权。
- 「外部授权」:按授权行展示外部授权,带来源应用与业务单号,提供状态查看与操作。
5. **操作复用**:修改有效期后重推;取消复用 `clear`(置 `4`);删除复用 `deleteAuthByIds`(先清除后逻辑删除);重新下发复用 `push`。
不新增权限点,沿用 `lock:auth:list/query/edit/push/clear/remove`。
---
## 3. 数据模型
### 3.1 新增字段(建议 D23)
```sql
alter table lock_auth
add column source char(1) default '1' comment '授权来源(1平台 2开放接口)' after auth_time,
add column source_app_code varchar(32) default null comment '来源应用编码(source=2,iot.openapi.apps[].app-code)' after source,
add column source_biz_no varchar(64) default null comment '来源业务单号(可空,外部入住为 bizNo)' after source_app_code;
alter table lock_auth
add key idx_lock_auth_source (source, source_app_code);
```
说明:
- `source` 与 `lock_stay.source` 保持同一编码(1 平台、2 接口),便于对账。
- `source_app_code` 对应 `lock_open_app.app_code`,不加外键;应用删除后历史授权仍保留编码。
- `source_biz_no` 是为直接授权接口预留的业务号;外部入住授权可同时通过 `lock_stay.auth_id` 反查 `stay_no/biz_no`。
- 不改唯一键 `uk_lock_auth_person_lock (person_no, device_id, auth_type)`。
- 新增列有默认值,存量行加列后自动为 `1`,再按 3.2 回填外部行。
### 3.2 存量数据分类与处理
存量授权按可判定程度分为三类:
| 类别 | 判定依据 | 处理 |
|---|---|---|
| A 外部入住/换房产生 | `lock_stay.auth_id = lock_auth.auth_id` 且 `lock_stay.source='2'` | 自动回填为外部授权 |
| B 平台产生 | 不属于 A(本次现场无 C) | 保持 `source='1'` |
| C 直接开放接口产生 | 库内没有可靠标记(`create_by` 为空、`remark` 由调用方自定义) | 不能自动判定;先出候选清单,再人工确认补标 |
处理顺序:**备份 → 加列 → A 类自动回填 → 其余归平台 → 核对**。所有 DML 先在只读副本预演并核对影响行数,再在生产低峰执行。
> **现场口径(2026-11-03 确认)**:当前库内只有 A(外部入住/换房)和 B(平台)两类,**不存在 C 类直接接口授权**。因此本次迁移跳过 §3.2.2 的候选清单与人工补标,A 类按 §3.2.1 自动回填后,其余存量行直接归平台即可。§3.2.2 保留,供后续接入或其它环境出现直接接口授权时使用。
#### 3.2.1 A 类:外部入住/换房授权(自动回填)
在住单直接回填:
```sql
update lock_auth a
join lock_stay s on s.auth_id = a.auth_id
set a.source = '2',
a.source_app_code = s.biz_system,
a.source_biz_no = s.biz_no
where s.source = '2'
and s.stay_status = '1'
and s.del_flag = '0'
and s.biz_system is not null
and a.del_flag = '0'
and a.source = '1';
```
已退房/已换房的历史授权,在“没有在退房后被平台重新覆盖”的前提下回填:
```sql
update lock_auth a
join lock_stay s on s.auth_id = a.auth_id
set a.source = '2',
a.source_app_code = s.biz_system,
a.source_biz_no = s.biz_no
where s.source = '2'
and s.stay_status in ('2', '3', '4')
and s.del_flag = '0'
and s.biz_system is not null
and a.del_flag = '0'
and a.source = '1'
-- auth_id 会被同人同锁同类型复用:退房后若平台或接口又覆盖过该行,
-- update_time 会晚于退房时间,此时按平台保留,避免误标;
-- 初始插入不写 update_time,为空的存量行视为从未被覆盖
and (a.update_time is null or a.update_time <= coalesce(s.checkout_time, s.update_time));
```
说明:
- 历史分支故意保守:宁可漏标,也不把平台后来复用的授权误标成外部。
- 漏标的历史行仍保留为平台授权,可按入住单 `auth_id` 追溯,或在确认后人工补标。
- 已逻辑删除(`del_flag='2'`)的授权不处理,页面本来也不展示。
#### 3.2.2 C 类:直接开放接口授权(本次现场无此数据,跳过)
`POST /openapi/iot/auth` 创建的授权没有落任何来源标记,`create_by` 对平台和接口都是空,`remark` 由调用方自定义,因此**不能仅凭库内数据可靠区分**。处理顺序:
1. **首选:向外部系统索取授权清单。** 直接授权接口在新增时已返回 `authId`,要求对方导出 `authId → appCode` 清单,按清单补标,这是唯一权威依据。
2. **没有清单时:生成候选清单人工复核。** 用“人员由开放接口建档 / 存在外部人员映射 / 没有关联入住单”作为线索,只做候选,不自动改:
```sql
select a.auth_id, a.auth_no, a.person_no, a.person_name,
a.device_id, a.device_sn, a.auth_type, a.sync_status,
a.begin_time, a.end_time, a.create_time, a.update_time,
p.create_by, r.app_code, r.external_id, a.remark
from lock_auth a
join lock_person p on p.person_no = a.person_no
left join lock_person_ref r on r.person_no = a.person_no
where a.del_flag = '0'
and a.source = '1'
and (p.create_by like 'openapi:%' or r.ref_id is not null)
and not exists (select 1 from lock_stay s where s.auth_id = a.auth_id)
order by a.create_time, a.auth_id;
```
候选清单不等于结论:平台也可能给外部建档人员手工授权。必须结合外部系统清单、操作记录或业务确认逐条判断。
人工补标按应用分批执行,并限定只改尚未归属的行:
```sql
update lock_auth
set source = '2', source_app_code = 'PMS'
where auth_id in (/* 人工确认的 authId 清单 */)
and source = '1';
```
#### 3.2.3 默认与核对
未被 A 类命中、也未被人工补标的行,统一保持 `source='1'`(平台)。这样不会出现“授权从页面消失”或“平台权限被误标为外部”的情况。
```sql
-- 兜底:空值归一为平台
update lock_auth set source = '1' where source is null or source = '';
-- 核对 1:来源分布
select source, count(1) from lock_auth where del_flag = '0' group by source;
-- 核对 2:在住外部单对应授权应全部归属外部,结果应为 0
select count(1)
from lock_auth a
join lock_stay s on s.auth_id = a.auth_id
where s.source = '2' and s.stay_status = '1' and s.del_flag = '0'
and s.biz_system is not null
and a.del_flag = '0' and a.source <> '2';
-- 核对 3:来源值只允许 1/2
select count(1) from lock_auth where del_flag = '0' and source not in ('1', '2');
```
#### 3.2.4 已知限制与建议
- **历史直接接口授权可能仍显示为平台授权**:这是数据缺失,不是程序错误。若必须完整呈现,唯一可靠办法是取得外部系统的 `authId` 清单并补标。
- **A 类历史分支会漏掉部分行**:例如外部入住退房后,平台又用同一 `auth_id` 建过授权,该行按平台保留,避免误标。
- **不建议默认把存量全部标成“未知”**:这会让现有授权从平台页签消失,或迫使页面新增第三套状态与操作口径;除非业务明确要求“存量待归属”工作台,否则按“A 自动 + C 人工 + 其余平台”处理。
- 补标脚本必须按 `auth_id` 精确执行并留操作记录;不要用人员、时间范围等模糊条件批量改来源。
### 3.3 归属写入规则
`source` 一经写入,平台侧修改不改变归属;覆盖(同人同锁同类型复用原行)时按调用方与已有归属决定。
| 调用方 | 已有行 | 处理 |
|---|---|---|
| 平台新增/批量新增/平台入住 | 无 | `source='1'`,应用与业务号为空 |
| 平台新增 | 外部行 | 保留原外部归属,只更新内容/有效期并重推(平台不夺取归属) |
| 外部直接授权接口 | 无 | `source='2'`,`source_app_code=当前 appCode` |
| 外部直接授权接口 | 同应用外部行 | 允许覆盖,保留同应用归属 |
| 外部直接授权接口 | 其他应用外部行 | 拒绝:`该人员在此门锁上已有其他应用的授权` |
| 外部直接授权接口 | 平台行 | **建议拒绝**:`该人员在此门锁上已有平台授权,请先在授权管理处理`(防止任意外部应用凭 personNo + deviceId 夺取平台授权) |
| 外部入住/换房 | 无 | `source='2'`,应用取入住单 `biz_system`,业务号取 `biz_no` |
| 外部入住/换房 | 平台行 | **建议接管**:入住是完整业务动作,且房间门锁、外部人员映射均由平台控制;将归属改为外部并重推 |
| 外部入住/换房 | 同应用外部行 | 允许覆盖 |
| 外部入住/换房 | 其他应用外部行 | 拒绝,提示已有其他应用授权 |
并发下若预检后仍撞唯一键,捕获 `DuplicateKeyException` 后回查,再按上表重新判定;不能直接覆盖不同归属的行。
> 待确认:外部直接授权接口遇到平台行是“拒绝”还是“接管”。推荐拒绝,理由是直接接口的 `personNo/deviceId` 完全由调用方提供,接管风险高。
---
## 4. 后端接口
### 4.1 平台端:外部授权管理
| 方法 | 路径 | 权限 | 说明 |
|---|---|---|---|
| GET | `/lock/auth/external/list` | `lock:auth:list` | 外部授权分页列表,固定 `source='2'` |
| GET | `/lock/auth/external/{authId}` | `lock:auth:query` | 外部授权详情,返回应用名、入住单号;不返回 `auth_value` |
| PUT | `/lock/auth/external/{authId}` | `lock:auth:edit` | 修改生效/失效时间与备注;有效授权保存后自动重推 |
| POST | `/lock/auth/external/{authId}/push` | `lock:auth:push` | 重新下发;已取消授权可借此恢复 |
| POST | `/lock/auth/external/{authId}/clear` | `lock:auth:clear` | 取消授权:清除设备侧权限,状态置 `4`,记录保留 |
| DELETE | `/lock/auth/external/{authIds}` | `lock:auth:remove` | 删除:先下发清除,再逻辑删除;批量中混入非外部授权则整批拒绝 |
列表查询参数:
| 参数 | 说明 |
|---|---|
| `keyword` | 授权编号 / 人员编号 / 人员姓名 |
| `appCode` | 来源应用 |
| `deviceId` / `groupId` | 门锁 / 分组 |
| `authType` | 授权类型 |
| `syncStatus` | 同步状态;可选 `4`(已取消) |
| `params.beginTime` / `params.endTime` | 创建时间范围 |
| `pageNum` / `pageSize` | 分页 |
列表返回字段:`authId, authNo, personNo, personName, authType, deviceId, deviceName, deviceSn, onlineStatus, sourceAppCode, sourceAppName, sourceBizNo, stayNo, beginTime, endTime, syncStatus, syncTime, syncMsg, retryCount, createTime, updateTime`。不返回 `authValue`。
### 4.2 平台端:人员列表口径调整
`selectPersonAuthList` 增加 `a.source = '1'`,即平台授权页签只统计平台授权。外部授权不进入人员聚合,避免:
- 平台人员编辑时把外部授权当作“未勾选”而误删;
- 已取消的外部授权被 `sync_status<>'4'` 过滤后,数量与外部页签口径不一致。
发布后,回填命中的外部入住授权会从人员页签移到外部页签;历史直接授权因无法识别,仍留在平台页签(见 §3.2)。
### 4.3 开放接口:归属与隔离改造
| 接口 | 改造 |
|---|---|
| `POST /openapi/iot/auth` | 读取过滤器写入的 `appCode`,建授权时写 `source='2'`、`source_app_code=appCode`;可选接收 `bizNo` 写入 `source_biz_no` |
| `GET /openapi/iot/auth/list` | 只返回 `source='2'` 且 `source_app_code=当前 appCode` 的授权;移除 `authValue/authValueMd5` |
| `POST /openapi/iot/auth/{authId}/push` | 校验授权属于当前应用,否则按“不存在”返回 |
| `POST /openapi/iot/auth/{authId}/clear` | 同上 |
| `DELETE /openapi/iot/auth/{authId}` | 同上 |
`OpenAuthRequest` 建议新增可空的 `bizNo`(≤64),不改变现有字段与调用方式;不传时 `source_biz_no` 为空。
对外响应可继续返回 `authId/syncStatus`,不需要暴露内部来源字段。
### 4.4 开放接口:入住/换房授权归属
| 场景 | 写入 |
|---|---|
| 平台入住 `createStay` | `source='1'` |
| 外部入住 `createStay`(`body.source='2'`) | `source='2'`,`source_app_code=bizSystem`,`source_biz_no=bizNo` |
| 换房 `doChangeRoom` | 继承原入住单 `source/biz_system`;`biz_no` 不继承(唯一键约束),授权业务号为空,详情通过 `stay_no` 关联展示 |
---
## 5. 服务实现要点
### 5.1 查询
- `selectExternalAuthList`:固定 `a.source='2' and a.del_flag='0'`,左连 `lock_open_app` 取 `app_name`(应用已删除时展示编码),左连 `lock_stay` 取 `stay_no`,左连 `lock_device` 取名称与在线状态。
- 分页在 SQL 层完成,使用现有 `startPage()`。
- 人员页签查询增加 `source='1'`,其余聚合口径不变。
### 5.2 修改有效期
新增专用方法,不复用平台的 `updateAuth`:
1. 按 `authId` 读取授权,校验 `source='2'`,否则返回“非外部授权”。
2. 校验 `beginTime <= endTime`;允许 `endTime` 置空(永久有效)。
3. 使用专用更新语句显式写 `begin_time/end_time/remark/update_by/update_time`,确保能把 `end_time` 更新为 `NULL`;现有 `updateAuth` 的 `<if test="endTime != null">` 无法清空。
4. 状态不是 `4` 时调用现有下发逻辑重推(人脸仍按门锁算法版本取特征);状态为 `4` 时只保存不重推,页面提示“已取消,需重新下发后生效”。
5. 更新与下发的事务边界与现有 `updateAuth` 一致:先落库,再提交命令;下发失败写入 `sync_msg`,不回滚有效期修改。
### 5.3 重新下发
- 校验归属后调用现有 `push(authId)`。
- 已取消(`4`)、下发失败(`3`)、待清除(`6`)均可重新下发,成功后状态回到 `0/1/2`。
- 同网关在途命令仍按现有排队规则处理,页面通过 `sync_msg` 展示“已排队”。
### 5.4 取消
- 调用现有 `clear(authId)`,状态置 `4`,设备侧删除,平台记录保留。
- 已取消的授权不重复下发取消;页面隐藏“取消”按钮。
- 提示:取消只回收门锁权限,不结束关联入住单,也不回推外部系统。
### 5.5 删除
- 调用现有 `deleteAuthByIds`:逐条下发清除后逻辑删除。
- 若关联在住单,确认弹窗必须提示“不会终止入住单,外部系统不会收到通知”。
- 已取消(`4`)的行删除时清除帧可能重复,属幂等操作,不做特殊分支。
---
## 6. 前端设计
### 6.1 页签结构
授权管理页顶部增加 `el-tabs`:
| 页签 | 数据 | 说明 |
|---|---|---|
| 平台授权 | 人员维度聚合,仅 `source='1'` | 保留现有新增、编辑、批量下发、按人员删除 |
| 外部授权 | 授权行维度,仅 `source='2'` | 新增页签,只读来源,操作见下 |
两个页签各自维护查询对象、分页和加载状态,切换时按需加载;外部页签不提供“新增授权”入口。
### 6.2 外部授权列表
列:
| 列 | 内容 |
|---|---|
| 授权编号 | `authNo` |
| 来源应用 | `sourceAppName(sourceAppCode)`,应用缺失时仅显示编码 |
| 人员 | 姓名 + 编号 |
| 门锁 | 名称 + SN;可显示离线状态 |
| 授权类型 | 字典标签 |
| 有效期 | 生效时间 ~ 失效时间(空为永久) |
| 同步状态 | 字典 `lock_sync_status`;外部页签把 `4` 展示为「已取消」、`6` 展示为「待清除」 |
| 同步说明 | `syncMsg` 悬浮提示,附带 `syncTime`、重试次数 |
| 最近更新 | `updateTime` |
| 操作 | 详情 / 修改 / 重新下发 / 取消 / 删除 |
筛选:来源应用(复用 `/lock/openapp/list`)、人员关键字、门锁、分组、类型、同步状态、创建时间范围。
状态展示:
- 默认查询不含 `4`(已取消),避免退房产生的大量历史行淹没列表。
- 状态筛选提供“已取消”,用于查看取消结果与重新下发。
- 提供“全部状态”选项。
### 6.3 详情
弹窗或抽屉展示:
- 授权编号、来源应用、来源业务号、关联入住单号(如有)。
- 人员、门锁、授权类型、生效/失效时间。
- 同步状态、同步时间、同步说明、重试次数、创建/更新时间。
- 不展示 `authValue`;人脸特征与密码均不回显。
### 6.4 修改
- 可编辑:生效时间、失效时间、备注。
- 不可编辑:来源应用、人员、门锁、授权类型(避免撞唯一键和破坏外部业务归属)。
- 关联在住单时提示:`该授权来自入住单,修改有效期不会回写入住单;后续续住/退房仍以入住单为准。`
- 状态为 `4` 时保存不重推,提示需重新下发。
- 授权内容(密码/IC 卡/指纹)本期不在平台重置;需要更换凭据时由外部系统重新下发或删除后重建。待确认是否补密码重置。
### 6.5 操作与权限
| 操作 | 权限 | 交互 |
|---|---|---|
| 查看列表/详情 | `lock:auth:list` / `lock:auth:query` | 无权限隐藏页签或操作 |
| 修改 | `lock:auth:edit` | 保存后有效授权自动重推 |
| 重新下发 | `lock:auth:push` | 已取消时二次确认“将恢复门锁权限” |
| 取消 | `lock:auth:clear` | 二次确认“仅清除门锁权限,记录保留为已取消” |
| 删除 | `lock:auth:remove` | 二次确认;关联在住单时追加提示 |
### 6.6 样式
沿用现有授权页的卡片、标签、状态色与紧凑表格风格;来源应用用 `el-tag`,已取消用 `info`,失败用 `danger`,待处理用 `warning`。外部页签不引入新的全局样式。
---
## 7. 边界与失败
| # | 场景 | 处理 |
|---|---|---|
| 1 | 外部授权被取消后 | 状态 `4`,默认不在列表;筛选“已取消”可见;可重新下发或删除 |
| 2 | 修改已取消授权 | 只保存,不重推;提示需重新下发 |
| 3 | 外部入住单退房 | 现有逻辑 `clear`,授权变 `4`;外部页签可查历史,人员页签不显示 |
| 4 | 平台删除外部授权 | 下发清除并逻辑删除;不结束入住单、不回推外部系统 |
| 5 | 外部应用跨应用操作 `authId` | 统一返回“授权不存在”,不暴露其他应用数据 |
| 6 | 同一人同锁同类型冲突 | 按 §3.3 判定;跨应用一律拒绝 |
| 7 | 应用已删除 | 历史授权保留,`source_app_code` 仍在;页面显示编码,应用名显示“-” |
| 8 | 历史直接授权无法归属 | 保留为平台授权;如需补标,按 `auth_id` 人工处理 |
| 9 | 下发失败/排队 | 有效期修改已落库,`sync_msg` 记录原因;可重新下发 |
| 10 | 外部入住换房 | 新授权继承来源应用;`source_biz_no` 为空,详情显示新 `stay_no` |
| 11 | 开放接口列表返回凭据 | 改造后不再返回 `authValue/authValueMd5`,避免人脸/密码外泄 |
---
## 8. 影响文件(实施时)
| 文件 | 变更 |
|---|---|
| `sql/upgrade_d23_auth_source.sql` | 新增(建议名):加列、索引、存量回填、核对与回滚说明;本轮不创建、不执行 |
| `ruoyi-iot/.../lock/LockConstants.java` | 增加授权来源常量(1 平台、2 开放接口) |
| `ruoyi-iot/.../lock/domain/LockAuth.java` | 增加 `source/sourceAppCode/sourceBizNo`,以及展示用 `sourceAppName/stayNo/deviceName/onlineStatus` |
| `ruoyi-iot/.../lock/mapper/LockAuthMapper.java/.xml` | 外部授权列表/详情、有效期更新、人员聚合增加来源过滤 |
| `ruoyi-iot/.../lock/service/ILockAuthService.java` / `LockAuthServiceImpl.java` | 外部授权查询、改期、归属写入与覆盖判定、按应用校验 |
| `ruoyi-iot/.../lock/controller/LockAuthController.java` | 外部授权接口 |
| `ruoyi-iot/.../openapi/controller/OpenAuthApiController.java` | 写入 `appCode` 归属;列表与单条操作按应用隔离;列表脱敏 |
| `ruoyi-iot/.../openapi/domain/OpenAuthRequest.java` | 可选 `bizNo` |
| `ruoyi-iot/.../lock/service/impl/LockStayServiceImpl.java` | 入住/换房创建授权时写来源 |
| `RuoYi-Vue2/src/api/iot/auth.js` | 外部授权列表、详情、改期、下发、取消、删除 |
| `RuoYi-Vue2/src/views/iot/auth/index.vue` | 页签、外部授权列表/详情/修改弹窗、权限控制与样式 |
无新增菜单、无新增权限点、无新建表。
---
## 9. 部署顺序
1. 备份 `lock_auth`、`lock_stay`。
2. 低峰期执行 `alter table lock_auth` 加列与索引。
3. 执行存量回填,核对:
- `select source, count(1) from lock_auth group by source;`
- 外部在住单对应授权应为 `source='2'`,其余为 `1`。
4. 发布后端与前端。**必须先执行 DDL 再发代码**,否则新查询会因缺列失败。
5. 抽查:外部入住后外部页签出现授权;平台入住不出现;开放接口用其他应用 Key 操作该 `authId` 返回不存在。
---
## 10. 测试与验收
### 10.1 数据与隔离
| # | 场景 | 预期 |
|---|---|---|
| T01 | 外部直接授权接口新增 | 外部页签出现,来源应用正确,状态随回执变化 |
| T02 | 外部入住创建人脸授权 | 外部页签出现,关联入住单号;平台页签不显示 |
| T03 | 平台手工新增授权 | 平台页签显示,外部页签不显示 |
| T04 | 应用 A 用应用 B 的 authId 查询/下发/取消/删除 | 均返回不存在,数据不变 |
| T05 | 开放接口 `list` | 只返回本应用授权,不含 `authValue` |
| T06 | 历史外部直接授权 | 保持平台归属(已知限制),不误入外部页签 |
### 10.2 操作
| # | 场景 | 预期 |
|---|---|---|
| T07 | 修改有效授权有效期 | 落库并重推,设备侧有效期更新,状态回执正确 |
| T08 | 清空失效时间 | 落库为 `NULL`,页面显示永久有效,重推成功 |
| T09 | 修改已取消授权 | 只保存,状态仍为 `4`,提示重新下发 |
| T10 | 重新下发已取消授权 | 设备侧恢复权限,状态回到待下发/已下发/成功 |
| T11 | 取消授权 | 状态 `4`,设备侧删除,记录保留;默认列表隐藏,筛选可见 |
| T12 | 删除授权 | 下发清除后逻辑删除,列表不再出现 |
| T13 | 删除关联在住单的授权 | 二次确认含提示;删除后入住单不受影响 |
| T14 | 无对应权限账号 | 列表可见但操作按钮隐藏;直接调接口返回 403 |
| T15 | 外部页签批量删除混入平台授权 | 整批拒绝,不删除任何行 |
### 10.3 验收标准
- 外部授权可按应用、人员、门锁、类型、状态查询,状态与 `sync_msg` 可见。
- 修改、重新下发、取消、删除均作用于设备侧且结果可追踪。
- 平台页签与外部页签口径互不混排。
- 开放接口不存在跨应用读写,列表不泄露授权凭据。
- 存量平台授权行为不变;住宿、补偿、回调逻辑不受影响。
---
## 11. 待确认问题
> 下列问题在实现时按“推荐”列落地(Q1–Q10 均取推荐方案),不再作为阻塞项;如现场验证有不同结论,再按实际调整。
| # | 问题 | 推荐 |
|---|---|---|
| Q1 | 页面形态:双页签,还是在人员列表加“来源”列混排 | 双页签。外部授权按行管理,混排会让人编辑平台授权时误删外部行 |
| Q2 | 外部授权列表粒度:一人一行聚合,还是一授权一行 | 一授权一行。外部授权带应用、业务单号与独立状态,聚合后无法精确操作 |
| Q3 | “修改授权”的范围 | 本期只改有效期与备注;人员/门锁/类型/凭据不可改。需要换凭据时删除重建或由外部系统重发 |
| Q4 | 已取消授权默认是否展示 | 默认不展示,提供“已取消/全部状态”筛选。否则退房历史会淹没在用授权 |
| Q5 | 外部直接授权接口遇到平台已有授权:拒绝还是接管 | 拒绝,防止外部应用凭 `personNo + deviceId` 夺取平台授权;外部入住可接管 |
| Q6 | 历史直接授权接口的归属 | 无法可靠识别,保留为平台;需要时按 `auth_id` 清单人工补标 |
| Q7 | 平台修改/取消外部授权后是否回调外部系统 | 本期不回调;外部系统可通过 `GET /openapi/iot/auth/list` 查询最新状态。若业务要求,再增授权事件回调 |
| Q8 | 外部入住授权在平台改期后,入住单结束时间不同步 | 允许改期但明确提示;续住/退房仍以入住单为准。若要求强一致,应改用「续住」接口 |
| Q9 | 直接授权接口是否增加 `bizNo` | 建议增加可空字段,便于对账与页面追溯;不影响存量调用 |
| Q10 | 是否需要外部授权批量下发/取消 | 本期单行操作 + 批量删除即可;如现场量大,再补批量下发/取消 |
@@ -0,0 +1,833 @@
# 锁内授权查询能力与 100 容量下发设计
> 日期:2026-11-03
> 状态:**D24 已落地(代码就绪,未实机联调)**,2026-11-04 按实测 `readkey` 报文修正了解析、时间编码与人脸对账。§1–§9 为设计稿;§11 记录实际实现、与设计稿的差异及待实机确认项。100 容量账本(P1–P3)仍未实现。
> 关联文档:[门锁系统架构设计](门锁系统架构设计.md) · [厂家设备控制协议整理与设计说明](厂家设备控制协议整理与设计说明.md) · [授权管理多锁批量授权设计](授权管理多锁批量授权设计.md) · [授权页外部系统授权管理设计](授权页外部系统授权管理设计.md)
> 标记口径:✅ 现有代码/文档已确认 | 📄 协议文档有定义、未实测 | ❓ 待厂商或业务确认 | 🧭 本文建议
---
## 0. 结论摘要
### 0.1 三个直接回答
**1)协议是否支持查询锁内既有授权,包括数量和人员编号?**
**可以拿到条数、凭据、类型和有效期;2026-11-03 抓包确认还会返回人脸。**
- 权限查询是 `getkey`(请求动作 `readkey`,主题 `thing/data/batch`):
- 请求:`["readkey","操作用户","设备ID","设备密码"]`
- 数据行(逐条推送,`seq` 恒为 `0`):`["readkey","索引","总数","类型","凭据","起始时间","结束时间",,"当前时间"]`
- 结束行:`["readkey","NULL"]`;网关先回一条进度行 `["readkey","10","ad","null"]`。
- 类型码实测:`02` = 密码(不足 8 位左补 `F`),`07` = 人脸(凭据是人员 ID,如 `AA000001`);时间字段是 8 位十六进制 Unix 秒。
- 人脸因此**可以按人员 ID 对账**,但设备不返回姓名等信息;指纹查询能力仍未验证(按平台账本展示)。
- `getslaveinfo` / `lockrssi` 给的是在线、电量、信号;`getrecord` / `lockrecord` 给的是开门记录;都不含授权清单。
- 因此:**设备侧能给出全部(或绝大部分)授权条数与凭据,但信息量有限、不能直接映射业务人员。** 平台 `lock_auth` 仍是容量与授权归属的权威账本,设备侧用于对账和发现孤儿。
**2)在“单锁授权总数 100”的约束下,如何有效下发?**
核心不是“发得更快”,而是**账本准、不超卖、能回收**:
1. 以平台账本为准,授权创建/批量授权时**原子预占槽位**,超过 100 直接拒绝,不做静默覆盖。
2. 槽位分状态管理:预占、已安装、释放中、已释放、未知;**未知和释放中也占容量**,只有设备删除成功才释放。
3. 下发顺序固定为:**撤销/删除 → 入住新增/续期 → 批量补发 → 清理对账**;同网关串行、同锁 FIFO。
4. 到期授权按“先删后加”回收;删除未确认前不得把槽位让给新授权。
5. 命令终态到达后**立即触发同网关下一条**,不要只依赖 5 秒补偿轮询;否则 100 条授权会被轮询周期拖到十几分钟。
6. 密码/人脸用 `getkey` 做差集对账(人脸按人员 ID 比对);指纹尚未验证查询能力,只能靠平台账本 + 命令回执 + 人工重建工具,禁止自动清空。
**3)当前实现距离这个目标还差什么?**
必须先补三个缺口,否则“100 容量”只是前端提示,设备和账本会持续漂移:
- **删除失败即失联**:`deleteAuthByIds` 先发清除指令、随后立即逻辑删除;而清除补偿只捞 `del_flag='0'` 且 `sync_status='6'` 的行。清除失败/超时的授权会变成设备侧孤儿,既不重试、也不计入容量。
- **失败状态语义混用**:`sync_status='3'` 同时表示“下发前校验失败/设备明确拒绝”和“命令超时、结果未知”。前者确定不占槽位,后者可能已占,容量口径无法区分。
- **没有任何容量校验**:创建、批量授权、重发都不校验锁容量;`lock_device` 也没有容量字段(`lock_v2.sql` 中的 `capacity_*` 尚未进入 v1 表)。
### 0.2 关键判断表
| # | 问题 | 结论 | 依据 | 待确认 |
|---|---|---|---|---|
| 1 | 查询密码/卡权限列表 | ✅ 可以 | 抓包实测 `getkey(readkey)`,类型码 `02` | 卡号对应哪个类型码 |
| 2 | 查询人脸授权 | ✅ 可以(按人员 ID) | 抓包实测类型码 `07`,凭据为 `AA000001` | 是否所有固件版本一致 |
| 3 | 查询指纹授权 | ❓ 未验证 | `fingerfeature` 仅 add/delete/learn,`query` 无模板 | 是否有未公开查询能力 |
| 4 | 查询权限对应的人员编号 | ⚠️ 人脸直接有,密码/卡需反查 | 人脸凭据即人员 ID;密码/卡靠 `lock_auth.auth_value` 反查 | 平台外写入的凭据无法映射 |
| 5 | 查询锁内授权总数 | ✅ 可以(数据行第 3 位) | 抓包 `["readkey","1","2",...]` 中 `2` 为总数 | 是否含已过期未删除项 |
| 6 | 查询剩余容量 | ❌ 不支持 | 无对应 op / 字段 | 厂商是否可增加 |
| 7 | 设备满时的行为 | ❓ 未知 | 文档未定义 | 错误码、是否覆盖最旧 |
| 8 | 到期后设备是否释放槽位 | ❓ 未知 | 协议未说明 | 必须实测确认 |
| 9 | `getkey` 结果码位置 | ✅ 已解决 | 数据行无结果码;进度码在 `params[1]`(`10`),结束行是 `["readkey","NULL"]` | — |
### 0.3 一句话结论
> **协议能“看见”密码、卡和人脸(人脸直接给人员 ID),指纹仍待确认;100 容量必须由平台账本兜底,设备侧用于对账与发现孤儿。**
---
## 1. 需求、术语与口径
### 1.1 需求
- 单锁授权总数上限 **100**(2026-11-03 业务提出)。
- 需要回答:锁内现在有多少条授权、分别属于谁、是否还能继续下发。
- 需要在 100 上限下,保证入住/退房/批量授权仍能稳定下发,不超卖、不产生无法追溯的孤儿凭据。
### 1.2 术语
| 术语 | 含义 |
|---|---|
| **授权记录** | 平台 `lock_auth` 的一行,唯一键 `(person_no, device_id, auth_type)` |
| **设备槽位** | 门锁内部实际保存的一条凭证(人脸/密码/指纹/卡)所占的存储位置 |
| **总数 100** | 业务口径的单锁槽位上限;本文按“全部凭证类型合计”设计,若厂商按类型分别限制,模型改为容量向量即可,预占算法不变 |
| **预占(RESERVED)** | 平台已创建授权,但设备尚未确认安装;为保证不超卖,仍计入已用 |
| **已安装(INSTALLED)** | 设备回执成功,槽位确定被占用 |
| **释放中(RELEASING)** | 已发删除指令,设备尚未确认;仍计入已用 |
| **未知(UNKNOWN)** | 命令超时或回执无法判定,设备可能已安装;按最保守口径计入已用 |
| **孤儿(ORPHAN)** | 设备上存在、平台账本没有对应记录的凭据;由 `getkey` 或人工盘点发现 |
### 1.3 容易混淆的两个“100”
| 名称 | 含义 | 现状 |
|---|---|---|
| **批量选锁 100 把** | 一次批量授权最多选择 100 把门锁 | ✅ 已实现:前端 `BATCH_MAX=100`,后端 `BATCH_MAX_DEVICES=100` |
| **单锁授权 100 条** | 每把门锁内部最多保存 100 条凭证 | ❓ 本文讨论的业务约束,当前代码没有校验 |
> 两者没有关系。前者限制一次操作的范围,后者限制每把锁的存储容量。
### 1.4 容量口径假设(需业务确认)
本文按以下口径设计,若与厂商/业务不一致,需要调整计数规则:
1. 100 是**单锁全部凭证类型合计**,不是“人脸 100 + 密码 100 + 卡 100”。
2. 同一人在同一把锁上办理人脸 + 密码,算 **2 条**。
3. 同一人同一类型在同一把锁上只有一条(与现有唯一键一致)。
4. **已过期但设备尚未删除的授权仍占槽位**;除非厂商确认设备会自动清除到期凭证。
5. 指纹是否计入 100、一个人多枚指纹算几条,需要确认。
---
## 2. 协议查询能力盘点
### 2.1 能力矩阵
| 查询目标 | 协议通道 | 能拿到的字段 | 能否满足 | 结论 |
|---|---|---|---|---|
| 密码/卡/人脸权限列表 | `data/batch` + `op=getkey`,动作 `readkey` | 索引、总数、类型码、凭据、起始时间、结束时间、当前时间 | ✅ | 实测可用;人脸凭据即人员 ID |
| 指纹权限列表 | `finger` / `fingerfeature` | `add`/`delete`/`learn`/`freeze`/`update`;`query` 无模板 | ❓ | 未验证 `readkey` 是否返回指纹 |
| 设备在线/电量/信号 | `getslaveinfo`、`lockrssi` | 状态、电量、RSSI | ❌ | 与授权无关 |
| 开门记录 | `getrecord`、`lockrecord` | 开门时间、方式、凭据 | ❌ | 与授权清单无关 |
| 网关下挂锁列表 | `locklist`、`getgwinfo` | 锁 ID 列表 | ❌ | 是“有哪些锁”,不是“锁里有哪些授权” |
| 剩余容量/已用容量 | 无 | — | ❌ | 协议未定义,只能由 `总数` 推断 |
### 2.2 `getkey` 实测报文与字段
2026-11-03 抓包(主题 `ydlink/{网关SN}/thing/data/batch`,应答 `.../batch_report`):
```text
请求
{"devId":"FFFA26092912","devPsw":"02234006","devVer":"1","ac":"req","at":"1793757002",
"seq":"1793757002652","op":"getkey","mode":"s2d","chksum":"xx",
"params":["readkey","admin","807104B5","02234006"]}
网关进度回执(结果码 10,回显命令 seq)
{"devId":"E8F7AFF69483","seq":"1793757002652","op":"getkey","params":["readkey","10","ad","null"]}
数据行 1(密码)
{"devId":"E8F7AFF69483","seq":"0","op":"getkey",
"params":["readkey","1","2","02","FFF54321","6AB80000","6AC80000",,"6AC89C4D"]}
数据行 2(人脸)
{"devId":"E8F7AFF69483","seq":"0","op":"getkey",
"params":["readkey","2","2","07","AA000001","6AC20000","6AFC0000",,"6AC89C4D"]}
结束行
{"devId":"E8F7AFF69483","seq":"0","op":"getkey","params":["readkey","NULL"]}
```
字段表(数据行,`params[0]` 恒为 `readkey`):
| 位置 | 含义 | 实测值示例 | 说明 |
|---|---|---|---|
| 1 | 索引 | `1`、`2` | 1 基 |
| 2 | 总数 | `2` | 本条查询返回的授权总条数 |
| 3 | 类型码 | `02` / `07` | `02` 密码,`07` 人脸;卡/指纹类型码未采集到 |
| 4 | 凭据 | `FFF54321` / `AA000001` | 密码不足 8 位左补 `F`;人脸为人员 ID |
| 5 | 起始时间 | `6AB80000` | 8 位十六进制 Unix 秒 |
| 6 | 结束时间 | `6AC80000` | 同上 |
| 7 | 空 | — | 报文中为省略值 |
| 8 | 当前时间 | `6AC89C4D` | 设备当前时间,同样为十六进制 Unix 秒 |
关键差异:
- **数据行 `seq` 恒为 `"0"`**,不能靠 `seq` 认领命令,需用上报网关所属分组 + 最近一条“已发送”的 `0019` 命令关联。
- **时间不是 5 字节压缩位域**,旧文档的 `YEAR=(a&0xFC000000)>>26` 解码方式已废弃。
- **进度行 `["readkey","10","ad","null"]` 不是授权行**,必须过滤,否则会落成一条 `credential='null'` 的假数据(历史脚本 `sql/202611031911.sql` 中已出现该现象)。
- 结束标志是 `["readkey","NULL"]`,不是“索引 == 总数”。
16 进制 Unix 秒换算示例:`0x6AB80000` = `1790443520` = 2026-09-27 01:25:20(+08:00)。
### 2.3 `getkey` 仍然存在的限制
**限制一:密码/卡的凭据不等于人员。**
- 人脸行直接给人员 ID(`AA000001`),可直接与 `lock_auth.person_no` 比对。
- 密码/卡行只有凭据值,需要拿 `auth_value` 反查平台授权;平台外手工录入、旧系统迁移、厂商调试写入的凭据无法映射人员。
- 唯一键 `(person_no, device_id, auth_type)` 不约束 `auth_value`,同一密码理论上可分配给多人,反查可能多义。
**限制二:指纹与部分卡类型未验证。**
- 实测只采到 `02`(密码)与 `07`(人脸);指纹、IC卡的类型码及是否参与 `readkey` 枚举均未确认。
- 在这些类型码确认前,设备侧 `总数` 只能视为“已知类型之和”,不能直接当作 100 容量的已用数。
**限制三:数据行无法按 `seq` 归属。**
- 所有数据行 `seq` 都是 `"0"`,设备不回传锁 SN。只能以上报网关所属分组 + 最近一条“已发送”的 `0019` 命令认领。
- 因此同一网关(分组)同一时刻必须只允许一个 `0019` 在途,否则行无法区分属于哪次查询。
**限制四:枚举没有分页参数。**
- 请求无 `startindex`/`num`,只能整锁全量推送;100 条授权会连续产生 100 条上行报文,网关通道占用与耗时需按实测评估(见 §4.5)。
### 2.4 结果码规范冲突的处理(已落地)
`YdlinkRespSpec` 曾把 `getkey` 的结果码位置登记为 `params[2]`:
```java
RESULT_INDEX.put(YdlinkOp.GET_KEY, new int[] { 2 });
```
实测数据行的 `params[2]` 是**总数**,不是结果码:
```text
["readkey","索引","总数","类型","凭据","起始","结束",,"当前"]
[1] [2]
```
若沿用,总数为 `"0"` 时会被当成成功码,非 `0` 时 `resolve()` 返回 `null` 一直等到超时。
处理方式:**移除 `GET_KEY` 的结果位登记**,由 `LockDeviceAuthServiceImpl` 专用解析:
- `params[1] == "10"` 且无索引 → 网关进度回执,交给 `handleGatewayAck` 留痕,不算授权行;
- 可解析出索引 → 数据行,落库;
- `params[1] == "NULL"` → 结束行,命令置成功;
- 已收集条数达到 `总数` → 同样置成功(防止结束行丢失)。
### 2.5 对容量设计的含义
| 结论 | 设计动作 |
|---|---|
| 设备侧能给出条数与密码/人脸凭据 | 可用 `getkey` 做差集对账,但仍以平台账本为容量权威 |
| 人脸凭据即人员 ID | 人脸可直接按 `person_no` 对账,不再一律标“不可查询” |
| 指纹/卡类型码未验证 | 这些类型暂按平台账本展示,不参与设备侧匹配计数 |
| 数据行 `seq=0` | 以“上报网关分组 + 最近已发送 `0019`”认领,分组内串行查询 |
| 查询类应答无统一结果码 | 专用解析器,不复用 `YdlinkRespSpec` |
---
## 3. 平台侧授权账本
### 3.1 现状
`lock_auth` 当前字段(`sql/dump_lock_auth.sql` / `sql/lock.sql`):
| 字段 | 作用 |
|---|---|
| `person_no` / `device_id` / `auth_type` | 唯一键,一人一锁一类型一行 |
| `auth_value` | 人脸特征 / 密码 / 指纹特征 / 卡号(v1 明文,D10 暂缓) |
| `begin_time` / `end_time` | 授权有效期 |
| `sync_status` | 0 待下发 / 1 已下发 / 2 成功 / 3 失败 / 4 已清除 / 6 待清除 |
| `sync_msg` / `sync_time` / `retry_count` | 下发追踪 |
| `del_flag` | 逻辑删除,`2` 为删除 |
| `source` / `source_app_code` / `source_biz_no` | 平台/开放接口来源 |
当前**没有**:
- 锁容量字段(`lock_device` 也没有);
- 槽位预占/释放状态;
- 容量查询接口;
- 非住宿类授权的到期清理任务;
- `getkey` 查询实现(只有常量与结果码登记)。
### 3.2 状态与设备占用的映射(建议口径)
现有 `sync_status` 是“传输状态”,不是“容量状态”。两者不能混为一谈。建议的占用口径:
| `sync_status` | 当前含义 | 设备是否可能已占用 | 建议容量口径 |
|---|---|---|---|
| `0` 待下发 | 已创建、尚未发或排队 | 否 | **占用**(预占) |
| `1` 已下发 | 命令已发、未终态 | 可能 | **占用** |
| `2` 成功 | 设备确认成功 | 是 | **占用** |
| `3` 失败 | 校验失败/设备拒绝/超时**混用** | 不确定 | 需拆分:确定失败→释放;超时→未知占用 |
| `7` 配置失败 | 门锁未分配网关/网关无分组,无下发通道 | 否 | **释放**(终态,不自动重试;修正配置后手动重发) |
| `4` 已清除 | 设备确认已清除,或平台按到期直接置位 | 否(若设备确实清) | **释放** |
| `6` 待清除 | 删除失败,等待重发 delete | 是 | **占用** |
| `del_flag='2'` | 逻辑删除 | 可能(若清除失败) | **必须单独盘点** |
> 关键点:`sync_status='4'` 在住宿到期场景是“平台直接置为已清除”,并没有发删除指令(`LockStayServiceImpl.expireOne`)。如果门锁不会自动删除到期凭证,这批授权仍占设备槽位,但平台已把它们排除在容量之外——这是最隐蔽的容量泄漏点。
### 3.3 现有实现缺口(代码走查证据,非运行实测)
| # | 缺口 | 证据 | 对 100 容量的影响 |
|---|---|---|---|
| G1 | 删除授权先发清除、随后立即逻辑删除 | `LockAuthServiceImpl.deleteAuthByIds`(`LockAuthServiceImpl.java:509`);`LockAuthMapper.xml` 的 `deleteAuthByIds` 直接 `set del_flag='2'`(约 485 行) | 清除失败/超时后行已不可见,补偿捞不到 |
| G2 | 清除补偿只捞未删除行 | `selectPendingClearAuths` 条件 `del_flag='0' and sync_status='6'`(`LockAuthMapper.xml:345`) | 失败删除永远不重试,设备侧留下孤儿 |
| G3 | 失败状态混用 | `updateSyncFail` 置 3(`LockAuthServiceImpl.java:993`);`handleResult` 失败也置 3(约 781 行);`markAuthTerminal` 超时也置 3(`LockCommandServiceImpl.java:245`) | 无法判断槽位是否真的释放 |
| G4 | 创建/批量无容量校验 | `doInsertAuth`(`LockAuthServiceImpl.java:229`)、`batchInsertAuth`(约 326 行)只校验人员、类型、设备存在 | 可以无限超卖 |
| G5 | 住宿到期不发删除 | `LockStayServiceImpl.expireOne`(约 529-533 行)直接置 `SYNC_CLEARED` | 依赖设备自动到期,未验证前会漏算 |
| G6 | 补偿周期 5 秒 | `LockCommandRetryTask` 的 `@Scheduled(fixedDelay = 5000)`(约 39 行) | 大量授权逐条下发时,每条至少等一个轮询周期 |
| G7 | 同网关串行按“分组”排队 | `pushInternal` 检查 `countInflightAuthCommands(groupId)`(`LockAuthServiceImpl.java:950`);SQL 按 `target_id=groupId` 统计(`LockCommandMapper.xml:95`) | 单锁 100 条会被同组其它锁一起排住 |
| G8 | 清除不下排队 | `pushClear`(约 1140 行)没有 `countInflightAuthCommands` 检查,直接发布 | 与新增并发时可能收到 `gw busy`,增加失败 |
| G9 | `getkey` 查询已实现但未联调 | D24 已实现 `0019` 与专用解析;尚缺实机验证 | 对账能力已有,结论仍需实测校准 |
| G10 | 查询结果码规范冲突 | 已移除 `YdlinkRespSpec` 的 `GET_KEY` 结果位,改专用解析(§2.4) | 已解决 |
### 3.4 推荐账本模型
#### 方案对比
| 方案 | 做法 | 优点 | 缺点 | 建议 |
|---|---|---|---|---|
| A. 最小改动 | 不加字段,创建/下发前在 `lock_device` 行锁内 `count(lock_auth)` | 改动小 | `sync_status=3` 语义混用、逻辑删除行难统计、无法表达“未知” | 仅作过渡 |
| B. 显式槽位状态 | `lock_auth` 增加 `slot_state`(或独立 `lock_auth_slot` 表) | 语义清晰、可审计、可对账 | 需要迁移存量数据 | **推荐** |
| C. 计数字段 | `lock_device.auth_used` 原子增减 | 预占快、无需 count | 任何漏更新都会漂移,需要每日重算 | 规模大时叠加使用 |
本文推荐 **B 为主,C 可选**。当前规模(约 8,000 把锁、8 万授权)下,行锁 + count 足够,不需要引入复杂计数器。
#### 字段建议(仅设计,不建表、不出脚本)
| 表 | 字段(建议) | 说明 |
|---|---|---|
| `lock_device` | `capacity_total` int,默认 100 | 0 表示未知/不校验;默认值由业务配置 |
| `lock_device` | `capacity_face` / `capacity_pwd` / `capacity_card` / `capacity_finger` int,默认 0 | 可选,用于分类型展示;0 表示未知 |
| `lock_auth` | `slot_state` char(1) | 0 预占、1 已安装、2 释放中、3 已释放、4 未知、5 孤儿 |
| `lock_auth` | `slot_time` / `slot_msg` | 最近一次槽位状态变化时间与原因 |
| `lock_auth` | 索引 `(device_id, slot_state)` | 容量统计与清理扫描 |
| 可选 | `lock_auth_reconcile` | 对账批次、锁、差异类型、差异内容、处理状态 |
#### 槽位状态机
```text
创建/恢复授权
│
▼
┌─────────┐
│ RESERVED│ 预占(计入已用)
└────┬────┘
add 成功 │ add 前校验失败/设备明确拒绝
┌───────────┴───────────┐
▼ ▼
┌──────────┐ ┌──────────┐
│INSTALLED │ │ RELEASED │ 释放(不计入)
└────┬─────┘ └──────────┘
│
删除请求 │ add 超时/回执无法判定
▼ │
┌───────────┐ ▼
│ RELEASING │ ┌──────────┐
└─────┬─────┘ │ UNKNOWN │ 未知(仍计入)
│ └────┬─────┘
delete 成功│ 对账确认不存在
▼ │
┌──────────┐ ▼
│ RELEASED │◄───────────────┘
└──────────┘
```
规则:
1. 只有 `RELEASED` 不占容量。
2. `RESERVED`、`INSTALLED`、`RELEASING`、`UNKNOWN` 全部占容量。
3. `UNKNOWN` 不能自动释放,必须由对账或人工确认后转 `RELEASED`。
4. 删除失败继续 `RELEASING`,重试 delete,绝不回到 add 通道。
### 3.5 存量数据初始化(设计建议)
迁移时按现有 `sync_status` 推导 `slot_state`:
| 现有状态 | 初始 `slot_state` | 说明 |
|---|---|---|
| `0` 待下发 | `0` 预占 | 尚未上设备,但为防超卖仍占 |
| `1` 已下发 | `1` 已安装 | 保守按已装处理 |
| `2` 成功 | `1` 已安装 | — |
| `3` 失败 | `4` 未知 | 无法区分“未发/拒绝/超时”,先按未知占位,后续对账释放 |
| `4` 已清除 | `3` 已释放 | 注意:住宿到期是平台直接置 4,需用 `getkey`/人工确认设备侧是否真的没有 |
| `6` 待清除 | `2` 释放中 | 必须继续重试 delete |
| `del_flag='2'` 且曾下发成功 | `4`/`5` 未知/孤儿 | 需要一次性盘点,不能默认已释放 |
> 存量初始化不能只看 `sync_status=4`。住宿到期置 4 的那批授权是重点核查对象;密码/人脸可用 `getkey` 盘点,指纹等未验证类型只能人工或重建。
---
## 4. 100 容量下的下发策略
### 4.1 总原则
1. **平台账本是唯一事实源**:设备侧查询只用于对账,不用于实时容量判断。
2. **先预占,再下发**:创建授权时就把槽位算进去,不能等下发了才发现满。
3. **先删后加**:删除/撤销/过期回收优先于新增,及时释放槽位。
4. **宁拒绝,不静默覆盖**:容量满时返回明确错误,绝不覆盖别人的授权。
5. **未知按已占处理**:命令超时、回执无法判定时,不得把槽位让给新授权。
6. **同网关串行、同锁 FIFO**:遵守网关 `data/batch` 单通道约束,避免 `gw busy`。
7. **终态驱动**:收到终态立即发下一条,补偿轮询只作兜底。
### 4.2 下发前容量预检(算法)
#### 单条授权
```text
createAuth(personNo, deviceId, authType):
begin short transaction
device = select lock_device where device_id=? for update // 行锁,串行化同一把锁的预占
capacity = device.capacity_total // 0 = 未知,跳过校验但告警
used = count(lock_auth where device_id=? and slot_state in ('0','1','2','4','5'))
delta = isNewAuth ? 1 : 0 // 覆盖/续期不新增槽位
if capacity > 0 and used + delta > capacity:
throw CapacityFull(used, capacity)
insert/update lock_auth set slot_state='0'
commit
enqueuePush(authId) // MQTT 发布不放在事务里
```
要点:
- **覆盖已有授权不新增槽位**:`doInsertAuth` 会复用同键行(含逻辑删除行),`delta=0`。
- **续期不新增槽位**:只改 `end_time`,仍是一条。
- **修改授权类型/人员编号**:旧凭据仍在设备上,不能直接改。必须“先删除旧类型/旧人员,再新增”,或明确禁止修改这两个字段。
- **批量授权**:每把锁独立预占、独立事务;单锁满只让该锁失败,不回滚其它锁(与现有 `batchInsertAuth` 的逐锁语义一致)。
#### 批量授权(多锁)
```text
preflight(deviceIds):
for each deviceId:
capacity, used = readCapacity(deviceId) // 不加锁,仅用于前端提示
return 满/接近满的锁列表
execute(deviceIds, auth):
sort deviceIds ascending // 固定加锁顺序,避免死锁
for each deviceId:
try:
reserveInShortTx(deviceId, auth) // 行锁 + count + insert
enqueuePush(authId)
item = 成功
catch CapacityFull:
item = 失败(容量已满)
catch other:
item = 失败(原因)
return per-lock result
```
建议:
- 执行前先做一次 `preflight`,前端把满锁标红,避免用户提交后大面积失败。
- 执行时仍要逐锁重新预占,因为预检之后容量可能变化。
- 批量结果保留每把锁的状态与原因;不要整批回滚。
### 4.3 槽位生命周期与触发点
| 事件 | 槽位动作 | 触发位置(未来落点) |
|---|---|---|
| 新建授权 | `RESERVED` | `doInsertAuth` 插入前 |
| 覆盖/续期 | 保持原状态,不新增 | `reviveAndPush` / `updateAuth` |
| add 命令发出 | 保持 `RESERVED` | `pushInternal` |
| add 成功回执 | `INSTALLED` | `handleResult` |
| add 前校验失败 | `RELEASED` | `updateSyncFail` |
| 设备明确拒绝(如 11/12 或明确错误码) | `RELEASED`;若是容量满则标记锁满 | `handleResult` / `markAuthTerminal` |
| add 超时/回执无法判定 | `UNKNOWN` | `markAuthTerminal` |
| 用户删除授权 | `RELEASING`,行保持可见(建议不再立即 `del_flag=2`) | `deleteAuthByIds` |
| delete 成功 | `RELEASED`,之后可逻辑删除 | `handleResult` |
| delete 失败/超时 | 保持 `RELEASING`,补偿重发 | `handleResult` / `markAuthTerminal` |
| 授权到期 | `RELEASING`(宽限期后) | 新增到期清理任务 |
| 对账确认设备不存在 | `RELEASED` | 对账任务 |
| 对账发现设备多出 | `ORPHAN` | 对账任务 |
### 4.4 下发队列与优先级
#### 队列键
- **组级**:`lock:dispatch:group:{groupId}` —— 网关同一时刻只处理一条 `data/batch`。
- **锁级**:`lock:dispatch:device:{deviceId}` —— 同一把锁的授权命令按 FIFO 串行,避免同一人员/同一锁的状态互相覆盖。
现有实现只有组级在途检查(`countInflightAuthCommands(groupId)`),没有显式队列。建议增加“待调度”查询,按优先级 + `auth_id` 排序。
#### 优先级
| 优先级 | 类型 | 原因 |
|---|---|---|
| P0 | 撤销/删除/退房清除 | 安全优先,且能立即释放槽位 |
| P1 | 入住新增/续期 | 业务实时性要求最高 |
| P2 | 批量补发/批量授权 | 可排队,允许分钟级 |
| P3 | 到期清理/对账补发 | 后台任务,避让前台 |
同一把锁内:**P0 全部处理完,再处理 P1**;同一网关内按“组级 FIFO + 优先级”调度。
#### 终态驱动
现状:`LockCommandRetryTask` 每 5 秒扫一轮,`pushInternal` 发现组内有在途就置回 `sync_status=0`,等下一轮。
问题:100 条授权 = 100 次“5 秒轮询 + 命令往返”,仅轮询就至少 500 秒。
建议:
1. 授权命令收到终态(成功/失败/超时)后,立即触发同组下一条待下发授权。
2. 保留 5 秒补偿轮询作为兜底(进程重启、事件丢失)。
3. 增加组级最小发送间隔(可配置,如 1 秒),避免瞬间打满网关。
4. 队列积压时按优先级调度,避免批量补发把入住新增饿死。
> 这是 100 容量场景下**收益最大的单项优化**,优先级高于任何协议查询能力。
### 4.5 吞吐测算
设:
- `R` = 单条授权命令从发出到门锁终态的往返时间;
- `Q` = 排队等待时间;
- 单网关同一时刻只有 1 条 `data/batch` 在途。
| 场景 | 当前(5 秒轮询) | 优化后(终态驱动) |
|---|---|---|
| 单锁 100 条,R≈2s | 约 100 × (5+2) = 700s ≈ 12 分钟 | 约 100 × 2 = 200s ≈ 3.3 分钟 |
| 16 锁 × 100 条,同一网关 | 约 1600 × 7 = 11200s ≈ 3.1 小时 | 约 1600 × 2 = 3200s ≈ 53 分钟 |
| 1000 条分散在 10 个网关 | 约 100 × 7 = 700s ≈ 12 分钟 | 约 100 × 2 = 200s ≈ 3.3 分钟 |
结论:
- 架构文档 §11.2 的“批量 1000 条 ≤ 5 分钟”**只在授权分散到足够多网关时成立**;如果集中在一两个网关,受单通道串行限制,达不到。
- 需要把“批量授权”的 SLA 改为“按网关分布和单锁容量分段承诺”,并在前端展示预计排队时间。
- `R` 目前没有实测数据,需要用 `lock_command` 的 `req_time` / `resp_time` 统计 P50/P95。
### 4.6 到期回收
#### 现状
- 退房:`LockStayServiceImpl.doCheckout` 调用 `authService.clear(stay.getAuthId())`,会发 delete。
- 到期自动退房:`expireOne` 直接把授权置 `sync_status=4`,**不发 delete**,假设设备按 `end_time` 自动失效。
- 平台手工创建的授权、外部系统直接授权:没有统一的到期清理任务。
#### 建议
1. **增加授权到期清理任务**(独立于住宿到期):
- 扫描 `end_time < now - grace` 且 `slot_state in ('1','2')` 的授权;
- 置 `RELEASING`,发 delete;
- delete 成功 → `RELEASED`;失败 → 保持 `RELEASING` 重试。
2. **宽限期**:建议 10–30 分钟,避免“刚续期就被删”。
3. **续期优先**:清理任务执行前再次检查 `end_time`,若已被续期则跳过。
4. **住宿到期**:如果厂商确认设备会自动删除到期凭证并释放槽位,可以保留现状;否则应改为“到期也发 delete”,或至少在下一次入住/容量不足时补删。
5. **禁止用 `00000000` 做日常回收**:整锁清空人脸会波及所有住客,只能作为人工维护操作,且必须二次确认。
### 4.7 失败、重试与幂等
#### 回执分类
| 场景 | 回执 | 槽位动作 | 重试策略 |
|---|---|---|---|
| 网关已接收 | `10` | 保持 `RESERVED` | 等待门锁终态,不重发 |
| 门锁成功 | `0` | `INSTALLED` | — |
| 设备忙 | `11` | 保持 | 3 秒重排(现有 `LockCommandPolicy.busyRetryTime`) |
| 设备不存在 | `12` | `RELEASED` | 不重试,人工处理绑定关系 |
| 容量已满(若厂商有明确码) | 待确认 | `RELEASED` + 标记锁满 | 不重试,提示清理 |
| 凭据非法/鉴权失败 | 待确认 | `RELEASED` | 不重试,提示修正 |
| 超时 | 无终态 | `UNKNOWN` | 可重发 add;达到 `max_retry` 后保持 UNKNOWN + 告警 |
| 回执无法判定 | 无法解析 | `UNKNOWN` | 先对账,不盲目重发 |
#### 幂等要求(需厂商确认)
- **同人同锁同类型重复 add**:设备是“更新原记录”还是“新增一条”?
- 更新 → 幂等,容量不增;
- 新增 → 会重复占槽位,必须由平台保证不重发。
- **删除不存在的凭据**:返回成功还是失败?
- 若返回失败,而平台把它当失败继续重试,会形成永久 `RELEASING`;
- 建议约定“删除不存在 = 成功”,或平台识别“not found”并按成功处理。
- **重发授权**:现有 `push` 会新建一条命令、新 `seq`,不新增授权行;容量不应变化。重发前应检查 `slot_state`,避免对 `RELEASING` 的行重新 add。
#### 状态与重试的对应
| 槽位状态 | 允许重发 add | 允许重发 delete | 说明 |
|---|---|---|---|
| `RESERVED` | ✅ | ❌ | 尚未安装 |
| `INSTALLED` | ✅(覆盖/续期) | ✅ | — |
| `RELEASING` | ❌ | ✅ | 绝不能回到 add,否则删掉又被加回 |
| `UNKNOWN` | ⚠️ 先对账 | ⚠️ 先对账 | 避免重复占槽 |
| `RELEASED` | ❌(除非重新创建) | ❌ | 已无设备权限 |
| `ORPHAN` | ❌ | ✅ | 设备多出的,删除即可 |
### 4.8 并发与多实例
1. **预占**:`SELECT ... FOR UPDATE` 锁 `lock_device` 行,或原子条件更新计数器;不要只用“先 count 再 insert”。
2. **批量**:按 `device_id` 升序加锁,避免多请求交叉死锁。
3. **发布**:MQTT 发布不放在数据库事务内;先提交预占,再入队发布。发布失败由补偿任务捞 `sync_status=0`。
4. **补偿任务**:继续使用 `DistributedTaskLock`,保证多实例只有一个执行。
5. **命令序号**:继续使用 Redis `INCR`,多实例不撞号。
6. **幂等键**:同一 `(authId, add/delete)` 在短时间内重复触发时,先查在途命令,避免重复发布。
### 4.9 批量授权的交互语义
| 场景 | 建议行为 |
|---|---|
| 批量选择 100 把锁,其中 3 把已满 | 预检阶段标红 3 把;用户可取消勾选或选择“跳过已满锁” |
| 执行过程中某把锁变满 | 该锁返回失败,其它锁继续;结果表展示“容量已满(100/100)” |
| 同一人已有同类型授权 | 按覆盖处理,不新增槽位;结果标记“已覆盖” |
| 同一人新增不同类型 | 新增一条,占用新槽位;若只剩 1 个槽位需提示 |
| 批量授权 100 把锁,每把 1 条 | 这是 100 条设备槽位,不是“1 条授权”;容量按每把锁分别计算 |
| 批量重发 | 不新增槽位;只对 `RESERVED`/`INSTALLED` 的行重发 |
建议在批量提交前增加一次**容量预检接口**,返回每把锁的 `used/capacity`,前端在树上直接显示“已用 87/100”。
### 4.10 对账
#### 密码/人脸类(可用 `getkey`)
| 差异 | 判定 | 动作 |
|---|---|---|
| 设备有、平台无 | 孤儿 | 若凭据值能映射到已删除授权 → 自动 delete;否则告警,人工确认 |
| 平台有、设备无 | 缺失 | 槽位允许则重发 add;否则告警 |
| 条数不一致 | 漂移 | 记录对账差异,告警;不自动清空 |
| 有效期不一致 | 漂移 | 以平台为准重发 add(覆盖有效期) |
人脸行按 `person_no` 直接比对,孤儿人脸不在「锁内授权」页删除,需到授权管理按人员处理。
#### 指纹/未验证类型(无可用查询)
- 以平台账本为准,靠命令回执闭环。
- 对账只能做“命令级核对”:
- 每条 `INSTALLED` 的授权必须有成功回执;
- 每条 `RELEASING` 的授权必须有 delete 成功回执,否则保持占用。
- 兜底手段:
1. 人工“整锁重建”:低峰期 `delete 00000000` 清空人脸,再按平台账本逐条 add;必须二次确认,且提前冻结该锁的新增授权。
2. 厂商确认指纹查询能力后再纳入 `readkey` 自动对账。
- **禁止**把“整锁清空 + 重建”做成每日自动任务。
#### 对账节奏
- 网关上线时:低优先级触发,不阻塞配置同步。
- 每日低峰(如 02:00–05:00):分批执行,单网关串行。
- 限速:每锁查询间隔 2–5 秒,避免影响开门。
- 对账期间发现容量已满:暂停该锁的新增下发,先清理孤儿。
### 4.11 监控与告警
| 指标 | 口径 | 告警阈值(建议) |
|---|---|---|
| 单锁已用率 | `used / capacity_total` | ≥80% 预警,≥95% 严重,=100% 阻断新增 |
| 未知槽位 | `slot_state='4'` | >0 持续 1 小时 |
| 释放中积压 | `slot_state='2'` 且超过 1 小时 | >0 |
| 孤儿数量 | `ORPHAN` | >0 |
| 删除失败 | delete 命令失败/超时 | 连续 3 次 |
| 命令积压 | `sync_status=0` 且等待 > 5 分钟 | >10 条 |
| 对账差异 | 设备/平台差集 | 任何差异都记录,按锁聚合告警 |
建议在设备列表/详情展示容量条,并在工作台增加“容量最满的 20 把锁”。
---
## 5. 前端与接口建议(仅设计,不实现)
### 5.1 页面
| 位置 | 展示/交互 |
|---|---|
| 门锁列表 | 增加“授权容量”列:`已用/100`,≥90% 橙色,100% 红色 |
| 门锁详情 | 容量卡片:总容量、已用、按类型分布、预占/释放中/未知数量、最近对账时间 |
| 编辑人员授权弹窗 | 门锁树节点显示 `已用/100`;已满的锁禁用勾选,悬浮提示“容量已满,请先清理到期授权” |
| 批量授权结果 | 每把锁一行:成功/覆盖/跳过(容量满)/失败(原因) |
| 全部授权页 | 可增加“容量”筛选或在设备列显示该锁容量;不改变现有四操作(状态/重发/删除授权/删除记录) |
| 容量清理入口 | 对满锁提供“清理到期授权”动作;执行前展示将删除的条数与人员,二次确认 |
### 5.2 接口建议
| 接口 | 用途 |
|---|---|
| `GET /lock/device/{deviceId}/capacity` | 单锁容量详情(总数、已用、按类型、预占/释放中/未知、最近对账) |
| `GET /lock/auth/capacity?deviceIds=...` | 批量预检,返回每把锁是否可授权 |
| `POST /lock/auth/reclaim` | 清理指定锁的到期授权(后台任务,返回任务号) |
| `GET /lock/auth/reconcile/{deviceId}` | 查询最近一次对账结果(后续 0019 落地后) |
> 接口只返回计数与状态,不返回 `auth_value`;继续遵守凭据脱敏要求。
---
## 6. 分期落地建议
| 阶段 | 目标 | 主要工作 | 验收 | 依赖 |
|---|---|---|---|---|
| **P0 协议确认** | 把“未知”变成“已知” | 向厂商确认 §7 的问题;抓 `getkey` 真实报文;统计命令 RTT | 形成书面答复;`getkey` 解析模板确定 | 厂商 |
| **P1 容量账本** | 不超卖 | `capacity_total` + `slot_state` + 创建/批量预占 + 容量接口 + 前端展示 | 100/100 时新增被拒绝;并发创建不超 100 | 无 |
| **P2 回收闭环** | 不漏算 | 两阶段删除(删除中不消失)、到期清理任务、`UNKNOWN` 对账释放、`pushClear` 排队 | 删除失败可重试;到期后容量下降 | P1 |
| **P3 下发吞吐** | 批量可用 | 终态驱动下一条、优先级队列、组级限速、批量预检 | 单锁 100 条 ≤ 5 分钟(R≈2s);批量结果可解释 | P1/P2 |
| **P4 设备对账** | 可发现漂移 | 0019 `getkey` 查询、密码/人脸差集、孤儿告警、指纹重建工具 | 能发现并处理密码/人脸孤儿;指纹有兜底 | P0/P1 |
> 本文只到设计为止。P1 之后才涉及代码/表结构。`getkey` 的枚举方式、类型码、时间编码已由 2026-11-03 实测报文确认(见 §2.2、§11),P4 的查询代码(D24)已按实测实现。
---
## 7. 待确认清单
### 7.1 必须向厂商确认
| # | 问题 | 为什么重要 | 当前假设 |
|---|---|---|---|
| Q11 | 单锁 100 是“全部凭证合计”,还是“人脸/密码/卡/指纹各自 100”? | 决定容量模型是标量还是向量 | 合计 100 |
| Q12 | 同一人同锁同时有人脸+密码,算 1 条还是 2 条? | 决定“授权数”与“人数”的换算 | 2 条 |
| Q13 | 达到 100 时再 add,设备返回什么?是否覆盖最旧? | 决定满容量处理与重试策略 | 拒绝新增,不覆盖 |
| Q14 | `getkey` 一次查询最多能返回多少条?100 条时总耗时与网关占用如何? | 决定对账时间窗与限速 | 逐条推送,实测 2 条 |
| Q15 | `getkey` 的“总数”是否包含指纹/IC卡?是否包含已过期未删除的权限? | 决定能否作为容量校验 | 只含已实测的密码/人脸 |
| Q16 | IC 卡、指纹的类型码分别为多少,是否参与 `readkey`? | 决定剩余类型能否对账 | 未采集到 |
| Q17 | §4.6.1 列出的 `query` 动作:请求/应答参数是什么?支持哪些 op? | 可能是比 `getkey` 更完整的查询入口 | 未定义,暂不可用 |
| Q18 | 同人同类型重复 add:更新原记录还是新增一条? | 决定重发是否幂等、是否漏算容量 | 更新原记录 |
| Q19 | 删除不存在的凭据:返回成功还是失败? | 决定 `RELEASING` 能否收敛 | 返回成功 |
| Q20 | 授权到期后,设备是否自动删除记录并释放槽位? | 决定住宿到期是否需要补发 delete | 不自动释放(保守) |
| Q21 | 单网关/单锁的建议命令间隔、最大并发是多少? | 决定队列限速与批量 SLA | 单网关串行,间隔 1s |
| Q22 | `getkey` 执行期间是否影响开门/会增加功耗? | 决定对账时间窗与频率 | 影响很小 |
| Q23 | 是否提供“剩余容量/已用容量”查询字段或 op? | 可显著简化容量设计 | 不提供 |
| Q24 | 整锁人脸清空 `00000000` 的作用范围是否仅限该锁?是否有确认机制? | 决定重建工具的安全性 | 仅该锁,立即生效 |
### 7.2 需要业务确认
| # | 问题 | 建议 |
|---|---|---|
| B1 | 100 的口径(合计/分类型、人数/凭证数) | 按“全类型合计、凭证条数”先设计 |
| B2 | 软阈值/硬阈值 | 90% 预警、100% 拒绝;是否预留 5 个运维槽位由业务定 |
| B3 | 满锁时是“拒绝新增”还是“自动清理最旧/到期” | 建议拒绝 + 引导清理,不自动删除他人授权 |
| B4 | 是否允许修改已有授权的类型/人员 | 建议禁止;如允许,必须“先删后加” |
| B5 | 人脸整锁重建的审批人与时间窗 | 建议仅管理员、低峰期、二次确认 |
| B6 | 批量授权 SLA | 建议按网关分布分段承诺,不承诺统一的“1000 条 5 分钟” |
---
## 8. 代码落点索引(D24 已实施项标注现状)
| 能力 | 现有落点 | 现状 | 后续改动点(建议) |
|---|---|---|---|
| `getkey` 常量 | `lock/protocol/YdlinkOp.java:53` | ✅ 已用 | — |
| `getkey` 结果码 | `lock/protocol/YdlinkRespSpec.java` | ✅ 已移除 `GET_KEY` 结果位 | 见 §2.4 专用解析 |
| 查询上行分发 | `lock/mqtt/LockMessageListener.java`(`handleKeyReport`) | ✅ 已增加 `getkey` 分支 | — |
| 命令类型 | `lock/LockConstants.java` | ✅ 已新增 `CMD_READ_AUTH=0019` | — |
| 命令超时/重试 | `lock/LockCommandPolicy.java` | ✅ 0019 超时 30s、允许一次重试 | 按实测 RTT 调整 |
| 授权新增 | `lock/service/impl/LockAuthServiceImpl.java:229`(`doInsertAuth`) | 无容量校验 | 插入前预占;批量逐锁短事务 |
| 批量授权 | `lock/service/impl/LockAuthServiceImpl.java:326`(`batchInsertAuth`) | 逐锁 try/catch,无容量校验 | 预检 + 逐锁预占 + 每锁结果 |
| 授权下发 | `lock/service/impl/LockAuthServiceImpl.java:913`(`pushInternal`) | 组级在途检查,排队等 5s 补偿 | 增加锁级 FIFO、优先级、终态触发 |
| 授权清除 | `lock/service/impl/LockAuthServiceImpl.java:1140`(`pushClear`) | 不检查组内在途 | 与 add 共用队列,避免 `gw busy` |
| 删除授权 | `lock/service/impl/LockAuthServiceImpl.java:509`(`deleteAuthByIds`) | 发清除后立即逻辑删除 | 两阶段删除:`RELEASING` 保持可见 |
| 清除补偿 | `lock/service/impl/LockAuthServiceImpl.java:811`(`retryPending`)+ `LockAuthMapper.xml:345` | 只捞 `del_flag='0'` 的 6 | 增加对已删除但未清除行的补偿 |
| 回执处理 | `lock/service/impl/LockAuthServiceImpl.java:749`(`handleResult`) | 成功→2/4,失败→3/6 | 增加槽位状态迁移 |
| 超时处理 | `lock/service/impl/LockCommandServiceImpl.java:245`(`markAuthTerminal`) | add 超时→3,clear 超时→6 | add 超时→`UNKNOWN`,不直接释放 |
| 住宿到期 | `lock/service/impl/LockStayServiceImpl.java:529-533` | 直接置 4,不发 delete | 确认设备行为后决定是否补删 |
| 补偿任务 | `lock/task/LockCommandRetryTask.java:39` | 5 秒固定轮询 | 终态驱动 + 5 秒兜底 |
| 在途统计 | `mapper/lock/LockCommandMapper.xml:95` | 按 `groupId` 统计 0011/0012 | 增加锁级在途与优先级查询 |
| 授权表 | `sql/dump_lock_auth.sql:24` / `sql/lock.sql` | 无容量/槽位字段 | P1 增加 `slot_state` 等(本文不出脚本) |
| 门锁表 | `sql/lock.sql:158` | 无容量字段 | P1 增加 `capacity_total`(本文不出脚本) |
| v2 容量设计 | `sql/lock_v2.sql:168-170` | 已有 `capacity_face/pwd/card` 草案 | 作为参考,不直接用于 v1 |
| 批量选锁上限 | `views/iot/auth/index.vue:1095` / `LockAuthServiceImpl.java:68` | 100 把锁 | 与单锁 100 条授权区分展示 |
| 授权树状态 | `views/iot/auth/index.vue`(`authSyncLabel` 等) | 已显示每条授权同步状态 | 可叠加“该锁容量已用/上限” |
---
## 9. 附:`getkey` 解析规则(已按实测报文定稿)
以下规则已在 `LockDeviceAuthServiceImpl#handleQueryReport` 实现并通过桩数据验证:
1. **不复用 `YdlinkRespSpec`**:`params[2]` 是总数,不是结果码。
2. **命令归属**:数据行 `seq="0"`,用上报网关的 `groupId` + 最近一条 `command_status='1'` 的 `0019` 认领;网关未知时才全局兜底,并在多于一条时打告警。
3. **识别进度回执**:`params[1] == "10"` 且无可解析索引 → `handleGatewayAck`,不落授权行。
4. **识别数据行**:`params[0]=="readkey"` 时偏移 1;`params.length >= 偏移+6` 且 `params[偏移]` 是整数才按授权行解析。
5. **结束标志**:`params[偏移]` 等于 `NULL`(忽略大小写)→ 结束,命令置成功。
6. **结束兜底**:已收集条数 ≥ 数据行第 2 位总数也置成功,避免结束行丢失导致超时。
7. **去重**:`(command_no, row_index)` 幂等 upsert,网关重传只刷新内容。
8. **时间解码**:8 位十六进制 → Unix 秒;同时兼容 `yyyy-MM-dd HH:mm:ss`、`yyyy/MM/dd...`、14 位数字和 10 位十进制秒,无法识别返回 `null`。
9. **空值过滤**:凭据为空或字面量 `null` 的行不展示、不参与对账。
10. **单向查询**:同一网关分组同一时刻只允许一个 `0019` 在途。
11. **对账落库**:`lock_device_auth` 保存原始行与类型码,对账结论每次实时计算;原始报文继续走现有掩码留痕。
---
## 10. 本文没有做的事(设计阶段)
- 没有修改任何 Java / Vue / XML / SQL 文件;
- 没有新建表、没有写迁移脚本、没有执行数据库语句;
- 没有启动服务、没有连接 MQTT、没有做实机验证;
- 文中的容量字段、状态机、接口均为设计建议,落地前需业务与厂商确认。
> 上述限制只描述 §1–§9 的设计阶段。D24 的实际落地见 §11。
---
## 11. D24 锁内授权查询与对账落地(2026-11-03 首版,2026-11-04 按实测报文修正,未联调)
### 11.1 交付范围
在「智能门锁」页新增「锁内授权」入口,按锁读取设备侧密码/人脸授权并与平台 `lock_auth` 实时比对,支持对设备侧密码类单条凭据下发清除。
| 层 | 交付 |
|---|---|
| 数据库 | `sql/upgrade_d24_device_auth.sql`(幂等,**未执行**):新建快照表 `lock_device_auth`,一行对应 `getkey` 返回的一条授权;唯一键 `(command_no,row_index)`,索引 `(device_id,command_no)`。`sql/upgrade_d24_device_auth_type.sql`(幂等,**未执行**):存量环境补 `auth_type` 列 |
| 协议 | 命令类型 `0019`(读取锁内授权,`op=getkey`、动作 `readkey`);超时 30 秒;`YdlinkRespSpec` 移除 `GET_KEY` 结果位登记,改由专用解析 |
| 上行 | `LockMessageListener` 在 `data/batch` 与 `data/batch_report` 两个入口拦截 `getkey`,交给 `LockDeviceAuthServiceImpl#handleQueryReport`;数据行 `seq=0` 时按上报网关分组认领命令 |
| 数据访问 | `LockDeviceAuthMapper` + XML:按命令取行、按 ID 取行、按命令计数、`(command_no,row_index)` upsert、回写清除命令号;`LockCommandMapper` 增加在途计数、最近命令、按命令号批量查询、按类型+分组取已发送命令 |
| 服务 | `ILockDeviceAuthService` / `LockDeviceAuthServiceImpl`:发起查询、快照对账、单条清除、上行行解析与落库;`YdlinkKeyTimeCodec` 按十六进制 Unix 秒解码时间 |
| 接口 | `POST /lock/device/{deviceId}/auth/query`(`lock:auth:list`)、`GET /lock/device/{deviceId}/auth/snapshot`(`lock:auth:list`)、`POST /lock/device/{deviceId}/auth/clear`(`lock:auth:clear`) |
| 前端 | `views/iot/device/index.vue` 新增「锁内授权」对话框:读取/刷新、轮询查询结果、汇总标签、对账明细表(列名「凭据/人员ID」)、孤儿行按类型清除、清除状态展示;`api/iot/device.js` 增加三个接口封装 |
快照字段:`device_id / device_sn / command_no / row_index / row_total / auth_type / credential / begin_time / end_time / device_time / raw_params / clear_command_no / create_time`。`auth_type` 存设备原始类型码(实测 `02` 密码、`07` 人脸)。快照只存设备原始数据,**不存对账结论**,每次打开页面实时与 `lock_auth` 比对。
### 11.2 关键实现决策
| # | 决策 | 原因 |
|---|---|---|
| 1 | `0019` 使用专用解析,不复用 `YdlinkRespSpec` | 协议应答第 2 位是“总数”,按结果码解析会把 `0` 条误判成功、非 0 一直等到超时(设计稿 §2.4) |
| 2 | 应答行兼容首项带/不带 `readkey` 两种形态 | 按首项是否为 `readkey` 决定字段偏移,避免不同固件解析错位 |
| 3 | 收到结束行 `["readkey","NULL"]` 或已收集条数达到 `总数` 即把命令置成功,后续重传行继续 upsert | 覆盖两种收口方式:正常结束行存在时不依赖计数;结束行丢失时也能及时收口,不必等 30 秒超时 |
| 4 | 行落库用 `(command_no,row_index)` 幂等 upsert | 网关重传/多实例消费不会产生重复行,不依赖内存状态 |
| 5 | 数据行 `seq` 恒为 `0`,用“上报网关分组 + 最近一条已发送 `0019`”认领;网关未知时才全局兜底 | 设备不回传锁 SN,只能靠网关归属;限定在 `command_status='1'` 可避免认领到网关忙重排的待发送命令 |
| 6 | 清除只允许当前快照中的设备侧行 | 重新查询后旧行可能已不存在,防止误删设备上的新凭据 |
| 7 | 类型优先取设备返回的类型码(`02`→密码、`07`→人脸),孤儿行沿用该码 | 设备已返回类型,不再靠人工选择;不会把密码按 IC 卡删除帧下发 |
| 8 | 匹配到平台授权且其已“已清除”时,清除命令不回写该授权行(`bizId=null`) | 避免把逻辑上已删除的历史授权重新拉回中间态;设备侧残留仍可清理 |
| 9 | 密码类清除帧为 `["delete", 凭据, 凭据, 门锁SN, 门锁密码]` | 与 §4.45 的下发/删除口径一致;人脸须在授权管理按人员清除,本页不提供 |
| 10 | 查询命令允许一次后台重试(`maxRetry=1`),不加入“不重试”集合 | 设计中建议不自动重试;实现保留一次重试以吸收网关瞬时丢失,同时用较短超时避免长期占用通道 |
| 11 | 页面明文展示密码,MQTT 报文留痕继续掩码 | 业务要求「密码直接显示,不用脱密」;日志/留痕仍按安全约束脱敏 |
| 12 | 最近一次查询失败时回退展示上次有数据的快照 | 一次离线重查不应把已有对账结果冲成空白;页面提示“当前展示上次读取结果” |
| 13 | 人脸行参与对账,凭据为人员 ID,匹配 `lock_auth.person_no` | 实测 `07` 行为人员 ID;比对成功即 `MATCHED`,但本页不提供人脸清除 |
| 14 | 时间字段按 8 位十六进制 Unix 秒解码,兼容文本/十进制 | 实测 `6AB80000` 即 2026-09-27,旧 5 字节位域算法已废弃 |
| 15 | 进度回执 `["readkey","10","ad","null"]` 显式过滤 | 历史脚本已出现把它落成 `credential='null'` 的假行;过滤后同时调用 `handleGatewayAck` 留痕 |
| 16 | 快照表新增 `auth_type` 列 | 对账需要用设备类型码判断密码/人脸,不能只看凭据形态 |
### 11.3 对账口径
| 对账状态 | 判定 | 页面表现 |
|---|---|---|
| `MATCHED` | 设备行按类型与同一把锁的平台授权匹配:密码比对 `auth_value`(不足 8 位左补 `F` 后归一),人脸比对 `person_no` | 显示人员、类型、平台同步状态;有效期不一致追加说明 |
| `DEVICE_ONLY` | 设备有、平台无对应授权 | 标为孤儿,可清除(密码);人脸提示到授权管理按人员处理 |
| `PLATFORM_ONLY` | 平台有密码/人脸授权、设备未返回 | 标为缺失,仅提示,不提供清除 |
| `NOT_QUERYABLE` | 平台授权为指纹,或类型码尚未验证 | 凭据显示“—”,只按平台账本展示 |
清除状态由 `clear_command_no` 对应命令的终态推导:`0 未清除 / 1 清除中 / 2 已清除 / 3 清除失败`;清除中与已清除不可重复下发。人脸行在本页 `clearable=false`。
### 11.4 验证结果(2026-11-04)
| 项 | 结果 |
|---|---|
| 后端离线编译(`ruoyi-iot -am`,JDK 21) | ✅ `BUILD SUCCESS` |
| 解析桩验证(4 类实测报文行 + 时间码) | ✅ 进度行被过滤;密码/人脸行索引、总数、类型、凭据、三个时间全部正确;结束行识别;`6AB80000` → 2026-09-27 01:25:20 |
| 前端 SFC 模板/样式/脚本语法 | ✅ 模板 0 error、SCSS 编译通过、脚本语法通过 |
| Mapper 接口 ↔ XML 对齐 | ✅ 新增语句逐条核对 |
| 数据库脚本执行 | ⬜ **未执行**(按约定只交付幂等脚本) |
| MQTT / 实机联调 | ⬜ **未进行** |
| 页面实测 | ⬜ **未启动前后端、未连库** |
### 11.5 待实机确认(未实测假设)
1. 类型码 `02`/`07` 之外的取值(IC 卡、指纹)及其是否参与 `readkey` 枚举。
2. 人脸行凭据是否始终等于平台 `person_no`(示例 `AA000001` 符合编码规则,但需用真实人员核对)。
3. `总数` 是否含已过期未删除的权限;到期后设备是否自动释放槽位。
4. 无授权时的报文形态(是否直接 `["readkey","NULL"]` 或先回 `["readkey","0"]`)。
5. 设备满额(100 条)时的返回与新增行为。
6. 一次查询 100 条时的总耗时、网关通道占用及对开门的影响。
7. 请求中的操作用户 `"admin"` 是否被所有固件接受。
8. 密码删除帧 `["delete", 凭据, 凭据, 门锁SN, 门锁密码]` 与实测设备一致(目前沿用已下发成功的口径)。
### 11.6 上线步骤
- [ ] 备份 `lock_device` / `lock_auth` / `lock_device_auth`
- [ ] 存量环境先执行 `sql/upgrade_d24_device_auth_type.sql` 补 `auth_type` 列(幂等;新环境建表脚本已包含该列)
- [ ] 与代码同批次发布;`lock:auth:list` / `lock:auth:clear` 沿用现有授权菜单权限,无需新增菜单
- [ ] 选一把在线门锁点击「读取锁内授权」,核对 `lock_command` 中 `0019` 的状态与 `lock_device_auth` 行数、类型码、时间
- [ ] 确认页面上密码为原文、人脸显示人员 ID,且进度行不再产生 `credential='null'` 的假数据
- [ ] 抓包复核 §11.5 的剩余项后,再放开孤儿清除操作
+8 -1
View File
@@ -493,6 +493,13 @@ public interface ProtocolAdapter {
- `onoff_line` 的 `message` **不作为唯一依据**(英文文案脆弱,D8),需同时解析"上线/离线"并校验 `devId` 长度(12 hex)。
- 离线只降级"可下发"标志,**不清除授权数据**;重新上线后触发一次对账(§5.6)。
v1 实现(D15,2026-11-02):
- L2 由 `GatewayOfflineCheckTask` 每分钟聚合 `lock_device_gateway.last_signal_time`,把 10 分钟内有上报的网关写回 `lock_gateway.online_status/last_online_time`(`LockGatewayMapper.refreshOnlineByReport`)。网关只在连接时发一次 `onoff_line`,此前该字段 10 分钟后必然被误判离线。
- L1 的 `onoff_line` 按 `message` 含 `will be back` 判离线,其余按在线,原文写入日志与 `lock_mqtt_log.handle_msg`;离线通知不改 `last_online_time`。
- `onoff_line` 报文体 `devId` 只留痕:v1 未建 `gateway_mac` 列(见 v2 脚本),无法反查;主题第 2 段与 `gateway_sn` 不一致时不更新任何行,改为打 warn,便于现场定位。
- 未挂任何门锁的新网关仍只能靠 `onoff_line`(或后续的主动探测)确认在线。
### 4.3 设备状态模型(归一)
协议原始状态是位域(《协议整理》§5.3),领域层归一为:
@@ -1168,7 +1175,7 @@ onoff_line(上线) 或 巡检发现由离线转在线
| D12 | V1/V2 `lockrecord` 未分支解析 | 开门方向/结果错乱 | P0 |
| D13 | 开锁方式两套字典并存(`01/02` 与 `7/2`) | 报表口径不一 | P1(v1 已落地) |
| D14 | 定时任务无分布式锁 | 多实例重复下发 | P1(v1 已落地) |
| D15 | `onoff_line` 忽略报文体一律置在线,LWT 被注释 | 离线不能即时感知 | P2 |
| D15 | `onoff_line` 忽略报文体一律置在线,LWT 被注释 | 离线不能即时感知 | P2(v1 已落地) |
| D16 | 报文日志不掩码,特征/密码可能入日志 | 敏感信息泄露 | P0 |
| D17 | `lock_device` 只有单个 `gateway_id`/`group_id`,无法表达一锁多网关 | 同一把锁被两个网关登记时后写入覆盖前值,信号择优与跨分区下发无从谈起 | P1(v1 已落地) |
+348 -4
View File
@@ -1,7 +1,7 @@
# 智能门锁 v1 迭代 · 项目管理看板
> 更新:2026-11-01 | 当前迭代:D20 对外房间门禁接口 P1–P3(代码就绪)+ Apifox 用例包(文档就绪) | 基线:v1.3(12 张 `lock_*` 表)+ D19/D20 升级表
> 相关文档:[门锁系统架构设计](门锁系统架构设计.md) · [数据库迁移指引](数据库迁移指引.md) · [厂家设备控制协议整理与设计说明](厂家设备控制协议整理与设计说明.md) · [对外开放接口 Apifox 测试用例](对外开放接口Apifox测试用例.md)
> 更新:2026-11-03 | 当前迭代:D24 锁内授权查询与对账(门锁页)| 基线:v1.3(12 张 `lock_*` 表)+ D19/D20 升级表
> 相关文档:[门锁系统架构设计](门锁系统架构设计.md) · [数据库迁移指引](数据库迁移指引.md) · [厂家设备控制协议整理与设计说明](厂家设备控制协议整理与设计说明.md) · [对外开放接口 Apifox 测试用例](对外开放接口Apifox测试用例.md) · [Linux 部署与开机自启](Linux部署与开机自启.md) · [授权管理多锁批量授权设计](授权管理多锁批量授权设计.md) · [锁内授权查询与100容量下发设计](锁内授权查询与100容量下发设计.md)
状态口径:✅ 已落地(代码可编译、有验证) | 🟡 部分落地 | ⏸ 暂缓 | ⬜ 未开始
“已落地”指本仓库代码与脚本就绪,**不代表已在生产库执行或完成实机联调**。
@@ -75,13 +75,13 @@
| D12 | W1 | V1/V2 记录未分支 | P0 | ✅ | 按协议版本解析(`AccessRecordHandler`) |
| D13 | W3 | 开门方式两套字典 | P1 | ✅ | `LockOpenType` 统一设备码 + 远程 9(`upgrade_d13_open_type.sql`) |
| D14 | W3 | 定时任务无分布式锁 | P1 | ✅ | Redis `SET NX PX` + Lua 释放(`DistributedTaskLock`) |
| D15 | W1 | 离线判定不可靠 | P2 | ⬜ | 仍忽略 `onoff_line` 报文体、LWT 未启用(`lock/mqtt`) |
| D15 | W1 | 离线判定不可靠 | P2 | ✅ | 网关在线改由「`onoff_line` + 归属门锁周期上报」双来源:检测任务每分钟按 `lock_device_gateway.last_signal_time` 聚合刷新,`onoff_line` 按 `message` 判离线(`lock/mqtt`、`lock/offline`);代码就绪,未实机验证 |
| D16 | W1 | 报文日志未掩码 | P0 | ✅ | 上下行留痕与通行记录脱敏(`lock/mqtt`) |
| D17 | W3 | 无法表达一锁多网关 | P1 | ✅ | `lock_device_gateway` + R1–R4 实时选路,详见第 5 节 |
---
## 4. 迭代交付记录(按交付顺序编号,最新见 4.35)
## 4. 迭代交付记录(按交付顺序编号,最新见 4.46)
### 4.1 交付内容
@@ -900,6 +900,343 @@
---
### 4.36 授权管理多锁批量授权(2026-11-02,代码就绪)
**需求**:授权管理页支持一次选择多把门锁,为同一人员批量建立同类型授权,减少重复录入。
**结论**:
1. 一锁一行,不建「授权组 / 授权模板」对象;批量只是创建入口,生成后每行独立修改、下发、删除。
2. 复用 `lock_auth` 唯一键 `(person_no, device_id, auth_type)` 的现有幂等:已存在则覆盖并重推,不新增重复行。
3. 新增 `POST /lock/auth/batch`,请求为「模板 + deviceIds[]」,逐行调用 `insertAuth`;不做整批事务(下发是外部副作用,单锁失败不应拖累其它锁)。
4. 人脸不传 `authValue`,由 `resolveAuthValue` 按各锁 `face_algo_version` 取特征;缺版本时该行失败,补提后自动补发。
5. 前端新增独立「多锁授权」入口与结果面板,展示每把锁的新增/覆盖/失败与同步状态。
**现状约束**:网关同一时刻只处理一条 `data/batch`,同一网关 N 把锁为串行排队,按补偿任务 5 秒一轮估算约 `N × 5 秒` 完成;设备下拉沿用 500 条上限。
**实现**:
| 层 | 文件 | 说明 |
|---|---|---|
| 接口 | `lock/controller/LockAuthController.java` | 新增 `POST /lock/auth/batch`,权限复用 `lock:auth:add` |
| 领域 | `lock/domain/LockAuthBatchRequest.java`、`LockAuthBatchResult.java` | 请求模板 + 逐锁结果与汇总 |
| 服务 | `ILockAuthService` / `LockAuthServiceImpl` | 新增 `batchInsertAuth`:人员前置校验、deviceIds 去重与 100 上限、逐锁调用 `doInsertAuth`(不包整批事务)、回读同步状态 |
| 前端 API | `src/api/iot/auth.js` | 新增 `batchAddAuth` |
| 前端页面 | `src/views/iot/auth/index.vue` | 「多锁授权」按钮、多选门锁按分组的弹窗、结果面板(新增/覆盖/失败 + 同步状态 + 说明) |
**产出**:[授权管理多锁批量授权设计](授权管理多锁批量授权设计.md)(接口契约、交互、失败边界、影响文件、12 条测试用例、7 个待确认问题)。
**验证**:后端离线编译 `BUILD SUCCESS`;前端 `vue-template-compiler` 模板编译与 `@babel/parser` 脚本解析通过。**未启动服务、未连库、未实机联调**,多锁排队下发、人脸多版本取特征、覆盖重推需在测试环境按设计稿 §9.2 用例实测。
**状态**:代码就绪,未联调。Q1–Q7 按设计稿推荐项实施;无 DDL、无脚本、无新增权限点。
---
### 4.37 环境配置拆分 local / prod(2026-11-03,代码就绪)
**需求**:本机调试与服务器部署使用不同配置(路径、数据源、日志级别、热部署),避免每次切换手动改 `application.yml`。
**结论**:
1. 公共项留在 `application.yml`;环境差异项拆到 `application-local.yml`(默认)与 `application-prod.yml`。
2. `spring.profiles.active` 默认 `druid,local`;服务器启动加 `--spring.profiles.active=druid,prod`(`druid` 必须保留,且须在前,后列出的 profile 覆盖先列出的)。
3. 数据源连接(url/username/password)在 local/prod 中各自维护,`application-druid.yml` 只保留连接池、监控与兜底值。
4. 路径仍可用 `RUOYI_PROFILE` / `LOGGING_FILE_PATH` / `-Druoyi.profile` / `-Dlogging.file.path` 覆盖,优先级高于 jar 内配置。
**文件**:
| 文件 | 内容 |
|---|---|
| `ruoyi-admin/.../application.yml` | 公共配置;profile 默认 `druid,local`;路径改为可覆盖占位 |
| `ruoyi-admin/.../application-local.yml`(新增) | Windows 上传/日志目录、本机库、`com.ruoyi=debug`、热部署开启 |
| `ruoyi-admin/.../application-prod.yml`(新增) | `/home/railwaysystem/service/doorcontrol/{uploadPath,logs}`、生产库、`com.ruoyi=info`、热部署关闭 |
| `ruoyi-admin/.../application-druid.yml` | 主库连接值降为兜底,注明由 profile 覆盖 |
**产出**:《Linux 部署与开机自启》§4 配置来源按三份文件重写,systemd 与 `ruoyi.env` 示例改为 `druid,prod`。
**验证**:四份 YAML 经解析校验通过;**未启动服务验证 profile 合并结果**,上线前需确认启动日志中 `profile is active` 含 `druid,prod`。
---
### 4.38 多锁授权界面改版:分组-门锁树 + 编辑差异回收(2026-11-03,代码就绪)
**需求**:多人授权界面按「分组 → 门锁」两级目录勾选,勾选分组即选中该组全部门锁;授权类型、人员、特征、统一起止时间放在树上方;编辑时取消/新增的授权要分别回收或下发。
**结论**:
1. 门锁选择由多选下拉改为 `el-tree` 两级勾选树:分组为父节点、门锁为叶子,父节点全选/半选由 Element UI 联动;支持搜索、全选、清空、展开/收起,标题旁显示已选门锁数与分组数。
2. 上方统一维护授权类型、人员、授权内容/人脸特征、生效与失效时间、备注;所有勾选门锁共用一份配置,不再逐锁设置。
3. 选定「人员 + 类型」后自动拉取该人员该类型的已有授权(`listAuth` 精确过滤),回显勾选并带入最近一条有效期;有效期不一致时给出统一提示。
4. 提交时做差异:新勾选走 `POST /lock/auth/batch`;有效期/内容变化则全部勾选锁覆盖重推;取消勾选走 `DELETE /lock/auth/{ids}` 回收并下发 delete 帧;无变化不提交。先下发后回收,整批被拒时不执行回收。
5. 新增行内「多锁配置」入口,带入该行人员与类型;编辑态允许全部取消(等价回收全部),无 `lock:auth:remove` 权限时前端阻断。
**实现**:仅改前端 `views/iot/auth/index.vue`(模板、脚本与样式),复用既有批量新增/删除接口,无后端、无 DDL、无脚本。
**验证**:`vue-template-compiler` 模板编译无错、`@babel/parser` 脚本解析通过、SCSS 编译通过;**未启动前后端、未连库、未实机点选**,树勾选联动与差异回收需在测试环境按设计稿 §9.2 用例 13–19 实测。
---
### 4.39 授权管理改版:一人一行的聚合列表(2026-11-03,代码就绪)
**需求**:移除按单条授权展示的方式,改为按人员展示,合并显示该人员全部授权数量与已下发数量;行内进入编辑可增删授权;人员授权全部取消后不再显示;删除人员即删除其名下全部锁授权。
**结论**:
1. 后端新增人员维度聚合查询 `selectPersonAuthList`:按 `person_no` 分组,统计授权数、门锁数、已下发(`sync_status='2'`)、待处理(空/0/1/6)、失败(3),有效期取 `min(begin_time)` / `max(end_time)`,类型用 `group_concat` 汇总;排除 `sync_status='4'`,人员授权被全部清除后自然消失。分页由 PageHelper 在 SQL 层完成。
2. 新增接口:`GET /lock/auth/person/list`、`POST /lock/auth/person/remove`、`POST /lock/auth/person/push`,沿用 `lock:auth:list/remove/push` 权限。
3. 按人员删除复用 `deleteAuthByIds`,逐条下发设备侧清除帧后逻辑删除,覆盖该人员所有 `del_flag='0'` 行(含已清除);按人员下发排除 `4`/`6`,单条失败不影响其余。
4. 前端列表改为人员列(编号、姓名、类型标签、门锁数、授权数、已下发、下发状态、有效期区间、最近更新);工具栏保留新增/下发/删除,移除导出与单条授权操作;编辑弹窗复用分组-门锁树,从行进入时人员锁定,顶部展示各类型授权汇总,可切换类型继续编辑;提交仍走原有差异下发/回收。
5. 无 DDL、无新增权限点,底层数据模型不变。
**实现**:新增 `LockAuthPerson.java`;改 `LockAuthMapper.java/.xml`、`ILockAuthService`、`LockAuthServiceImpl`、`LockAuthController`、`api/iot/auth.js`、`views/iot/auth/index.vue`。
**验证**:后端 `mvn -o -B -pl ruoyi-iot -am -DskipTests compile` 通过(167 个源文件);前端模板编译、脚本解析、SCSS 编译通过;**未启动联调、未连库**,聚合 SQL、分页与一人多类型编辑需在测试环境验证。
**文档**:《授权管理多锁批量授权设计》新增 §11。
---
### 4.40 授权页外部系统授权管理(2026-11-03,代码就绪,未联调)
**需求**:授权管理页展示开放平台 OpenAPI 下发的外部系统授权,可查看状态、修改授权、重新下发、取消、删除。
**现状问题**:`lock_auth` 无来源字段,平台/外部直接授权/外部入住授权不可区分;外部授权混在人员聚合里但无法识别,取消后(`sync_status='4'`)完全不可见;`OpenAuthApiController` 的 `list/push/clear/remove` 不校验授权归属,任一有效 Key 可操作任意 `authId`,`list` 还返回 `auth_value`。
**方案要点**:
1. `lock_auth` 增加 `source`(1 平台 2 接口)、`source_app_code`、`source_biz_no` 与索引;存量按 `lock_stay` 回填外部入住/换房授权(历史单用 `update_time` 防止误标已被覆盖的行),其余归平台。
2. 平台人员页签只统计 `source='1'`;新增「外部授权」页签按授权行展示 `source='2'` 数据,支持按应用/人员/门锁/类型/状态筛选,已取消默认隐藏但可筛选查看。
3. 外部授权操作为:改有效期(有效授权保存后自动重推,已取消只保存)、重新下发(可恢复已取消)、取消(`clear` 置 4、记录保留)、删除(清除后逻辑删除)。
4. 开放接口按 `appCode` 隔离:查询只返回本应用且不含凭据;下发/取消/删除校验归属;外部入住/换房创建授权时写入来源应用;直接授权接口新增可选 `bizNo`。
5. 覆盖规则:平台不夺取外部归属;外部直接接口遇到其他应用或平台授权拒绝;外部入住可接管平台授权。详见设计稿 §3.3。
6. 无新增菜单与权限点;**先执行 `upgrade_d23_auth_source.sql` 再发代码**。
**代码落点**:
| 层 | 文件 |
|---|---|
| DDL | `sql/upgrade_d23_auth_source.sql`(新增列/索引/回填/核对)、`sql/lock.sql` 同步基线 |
| 后端 | `LockAuth` / `LockAuthMapper(.xml)` / `ILockAuthService` / `LockAuthServiceImpl` / `LockAuthController`;`OpenAuthApiController` / `OpenAuthRequest`;`LockStayServiceImpl` |
| 前端 | `api/iot/auth.js`;`views/iot/auth/index.vue`(双页签、外部授权列表/详情/改期/下发/取消/删除) |
**验证**:后端离线编译 `BUILD SUCCESS`;前端模板/脚本/SCSS 校验通过;Mapper XML 格式校验通过。**未执行 SQL、未启动服务、未做接口与页面联调。**
**文档**:[授权页外部系统授权管理设计.md](授权页外部系统授权管理设计.md)。
**状态**:代码就绪,未联调、未上线。
---
### 4.41 人员授权编辑树显示并刷新下发状态(2026-11-03,代码就绪)
**需求**:编辑人员授权弹窗中,门锁节点除显示在线/离线外,还要显示该人员当前授权类型在该锁上的下发状态;状态会随异步下发实时变化,需要手动刷新入口。
**实现**:`views/iot/auth/index.vue` 的门锁树节点读取当前类型已有授权的 `syncStatus/syncMsg`,显示字典标签(待下发/已下发/下发成功/下发失败/待清除),下发失败可用悬浮提示查看 `syncMsg`;未下发或状态为空按“待下发”展示。切换授权类型时随已有授权重新计算。设备工具栏新增「刷新状态」按钮(`refreshPersonAuthStatus`),重新拉取该人员授权并只回写状态与已有授权基准,不重设勾选、不回填表单,避免覆盖尚未提交的修改。
**验证**:前端模板/脚本/SCSS 校验通过。未启动页面实测。
---
### 4.42 授权页新增「全部授权」明细页签(2026-11-03,代码就绪)
**需求**:授权页除人员聚合的平台授权、外部授权外,再提供一授权一行的全量明细,便于逐条查询每条授权的下发情况,并支持按条查看状态、重发与删除。
**实现**:
- 后端新增 `GET /lock/auth/all/list`(权限 `lock:auth:list`),`LockAuthMapper.selectAllAuthList` 不限定来源,支持授权编号/人员/门锁/分组/类型/来源/同步状态/创建时间筛选;复用授权明细 SQL(原 `selectExternalAuthVo` 更名 `selectAuthDetailVo`),带应用名、门锁名与在线状态、关联单号,不返回 `auth_value`。
- 新增 `DELETE /lock/auth/record/{authIds}`(`deleteAuthRecordsByIds`):仅逻辑删除记录、不下发清除,且只允许设备侧已清除(`sync_status='4'`)的行;其余状态在服务端直接拒绝,避免门锁上留下无法追溯的权限。
- 前端 `views/iot/auth/index.vue` 新增「全部授权」页签,默认展示全部同步状态(含已清除),列为授权编号、来源、人员、门锁、类型、有效期、同步状态与说明、关联单号、创建时间、最近更新;每次进入页签自动刷新。
- 每行操作:状态(详情弹窗,展示同步状态/说明/同步时间/重试次数与授权信息,可刷新)、重发(待清除 6 禁用,避免与清除指令冲突)、删除授权(先下发清除再删除)、删除记录(仅 `sync_status='4'` 显示)。
- `api/iot/auth.js` 新增 `listAllAuth`、`delAuthRecord`。
**验证**:`mvn -pl ruoyi-iot -am -DskipTests compile` 通过;Vue 模板/脚本/SCSS 校验通过。未启动页面实测。
---
### 4.43 锁内授权查询与 100 容量下发设计(2026-11-03,仅文档)
**需求**:研究门锁协议能否查询锁内既有授权(数量、人员编号等),并设计单锁授权总数 100 时的下发策略。
**结论**:
- 协议只有 `getkey(readkey)` 明确查询权限列表,返回索引/总数/卡号或密码/起止时间,**不含人员编号,也不含人脸/指纹**;`facefeature`/`fingerfeature` 无查询,文档列出的 `query` 动作无报文模板。设备侧不能作为完整授权账本。(**2026-11-04 实测修正**:数据行含类型码,`07` 为人脸且凭据即人员 ID,见 §4.47。)
- 现有 `YdlinkRespSpec` 把 `getkey` 结果码取在第 2 位,与协议应答第 2 位“总数”冲突;0019 不能复用现有结果码解析。
- 100 容量必须由平台 `lock_auth` 兜底:创建时原子预占、删除两阶段释放、到期清理、先删后加、同网关串行、终态驱动下一条。
- 走查发现三个前置缺口:`deleteAuthByIds` 立即逻辑删除导致清除失败无法补偿;`sync_status=3` 混用“明确失败/超时未知”;创建与批量均无容量校验。住宿到期直接置“已清除”但未发 delete,是否释放设备槽位待厂商确认。
**产出**:[锁内授权查询与100容量下发设计](锁内授权查询与100容量下发设计.md),含协议能力矩阵、容量账本与状态机、预占算法、下发队列/吞吐测算、对账与告警、分期建议、Q11–Q24 待确认清单、代码落点索引。
**验证**:仅文档研究,未改代码、未建表、未执行数据库、未做实机联调。
---
### 4.44 授权下发串行与网关忙重排修复(2026-11-03,代码就绪,未实机验证)
**问题**:向同一网关批量下发人脸授权时,网关回 `10` 后仍会很快重发/发下一条,出现大量失败。
**抓包结论**(`541abc4d-...txt`,网关 `FFFA26102919`,门锁 `80745DA1`):
- `11` 的实际报文是 `["add","FFFFFFFF","11","gw busy"]`,`FFFFFFFF` 表示网关自身忙,**报文未转发到门锁**;同一 `seq` 随后仍会回 `["add","80745DA1","0","AA000001","msg received"]`。
- 旧逻辑把 `11` 当门锁终态失败,命令立即置失败;后到的 `0` 因 `isFinished` 被丢弃。授权停在失败态,补偿任务每 5 秒重发同一 `seq`,抓包中同一命令被重发 2–3 次。
- 命令被提前判失败后不再计入在途,批量队列随即发下一条,进一步加剧 `gw busy`。
**改动**:
- `YdlinkRespSpec` 新增 `GATEWAY_BUSY` 阶段:`11` 不再归为终态失败,`12` 仍为设备不存在终态。
- 新增 `ILockCommandService.handleGatewayBusy`:命令回到「待发送」并按 8 秒重排,期间仍算在途,同网关后续授权不得插队;重排次数(授权 12 次、其它 3 次)用尽才判失败并回写授权。
- `markTimeout`:已收到 `10` 的命令改按网关回执时间起算 180 秒门锁终态等待,期间不重发、不判失败;超时直接判终态,不再循环重发。
- `retryPending`:先处理到期的忙排队命令(`selectDeferredCommands` 按先来先发),每轮每个主题只放行一条、且同主题无已发送命令才重发;普通失败重发同样先查同主题在途命令;重发前清除上一次的 `gateway_ack/device_ack`,避免旧失败结果被当成新结果。
- `pushInternal` 增加按 `biz_id` 的在途去重,避免命令重试与授权补偿并行给同一张脸建两条命令。
- `LockAuthServiceImpl.handleResult`:网关忙重排用尽时把授权置失败交给补偿,不再因消息含 `busy` 而永久停在「已下发」。
**验证**:`mvn -o -pl ruoyi-iot -am -DskipTests compile` 通过。未执行数据库、未做实机联调;需现场观察 `lock_command` 中 `gateway_ack/device_ack/command_status` 的流转确认。
---
### 4.45 密码/IC卡下发改用凭据标识(2026-11-03,代码就绪,未实机验证)
**规则**:协议里只有人脸(及指纹)按人员编号建索引;密码和 IC 卡由凭据本身标识,下发帧不再携带人员编号。此前按人员编号前缀(AA/BA/CA)推导授权类型的方案已撤回。
**改动**(`LockAuthServiceImpl.buildAuthFrame`):
- 人脸:保持 8 项 `[add, 人员编号, 特征值, 人员编号(授权锁标识), 开始, 结束, 锁SN, 锁密码]`;删除 4 项。
- 指纹:保持按人员编号下发 `[add, 人员编号, 特征值, 开始, 结束, 锁SN, 锁密码]`。
- 密码/IC卡下发:改为 `[add, 凭据, 凭据, 开始, 结束, 锁SN, 锁密码]`,第 1、2 项均为密码/卡号。
- 密码/IC卡删除:按协议 5 项 `[delete, 凭据, 凭据, 锁SN, 锁密码]`(原代码对人脸/密码统一发 4 项,非人脸删除会定位不到凭据)。
- 平台侧 `lock_auth` 仍保留 `person_no` 作为归属人员,仅报文标识改为凭据;唯一键 `(person_no, device_id, auth_type)` 与列表聚合不受影响。
**验证**:`mvn -o -pl ruoyi-iot -am -DskipTests compile` 通过。未实机验证,上线前需抓包确认密码/IC卡 add、delete 的参数个数与第 1 项取值;若厂商实际要求第 1 项为空串或仍为人员标识,只需调整该两处。
---
### 4.46 D24 锁内授权查询与对账(2026-11-03,代码就绪,未实机验证)
> 2026-11-04 按实测 `readkey` 报文修正了解析、时间编码、人脸对账与页面展示,详见 §4.47。
**需求**:在智能门锁管理中查看锁内授权列表(人脸、密码、IC 卡等),与平台 `lock_auth` 比对便于对账;对读取到的设备侧授权支持清除。
**协议结论**:`getkey(readkey)` 可查询设备侧权限;2026-11-04 抓包确认数据行含类型码,`02`=密码、`07`=人脸(人脸凭据即人员 ID)。密码/人脸可对账,指纹与卡的类型码尚未验证,按平台账本展示。
**实现**:
| 层 | 改动 |
|---|---|
| 脚本 | `sql/upgrade_d24_device_auth.sql`(幂等,**未执行**):快照表 `lock_device_auth`,一行对应设备返回的一条权限,唯一键 `(command_no,row_index)` |
| 协议/命令 | 新增命令类型 `0019`(`op=getkey`,动作 `readkey`),超时 30 秒、允许一次重试;`YdlinkRespSpec` 移除 `GET_KEY` 结果位登记 |
| 上行 | `LockMessageListener` 在两个 `data/batch` 入口拦截 `getkey`;数据行 `seq=0`,按上报网关分组 + 最近一条已发送 `0019` 认领命令,逐行 upsert;结束行 `["readkey","NULL"]` 或计数达总数即判查询成功 |
| 服务 | `ILockDeviceAuthService` / `LockDeviceAuthServiceImpl`:发起查询(同分组只允许一个在途)、实时对账、单条清除、行解析与时间解码 |
| 接口 | `POST /lock/device/{deviceId}/auth/query`、`GET /lock/device/{deviceId}/auth/snapshot`(`lock:auth:list`)、`POST /lock/device/{deviceId}/auth/clear`(`lock:auth:clear`) |
| 前端 | `views/iot/device/index.vue` 新增「锁内授权」对话框:读取/刷新、2 秒轮询、汇总标签、明细对账表(列名「凭据/人员ID」)、孤儿行按设备类型码清除;`api/iot/device.js` 增加接口封装 |
| 脱敏 | 页面按业务要求明文展示密码与人脸人员 ID;MQTT 报文留痕仍对 `getkey` 请求/应答凭据位掩码;库内按 v1 明文口径存设备原值 |
**对账口径**:`MATCHED` 一致(密码按凭据、人脸按 `person_no`)、`DEVICE_ONLY` 仅设备有(孤儿,密码可清除)、`PLATFORM_ONLY` 仅平台有(缺失,仅提示)、`NOT_QUERYABLE` 指纹等未验证类型。清除状态按清除命令终态显示为未清除/清除中/已清除/清除失败。
**关键决策**:查询类应答不复用通用结果码解析(协议第 2 位是“总数”);行按 `(command_no,row_index)` 幂等落库以兼容网关重传与多实例;只允许清除当前快照中的设备侧行;匹配到已“已清除”的平台授权时只清设备残留、不回写该授权;孤儿类型取设备返回的类型码,不再人工选择。
**验证**:后端离线编译 `BUILD SUCCESS`(`ruoyi-iot -am`);前端模板/SCSS/脚本语法校验通过;Mapper 接口与 XML 逐条核对。**脚本未执行、未启动服务、未连 MQTT、未做页面与实机联调。**
**待实机确认**:IC卡/指纹类型码、总数是否含过期项、空列表与满额报文、100 条查询耗时、操作用户 `admin` 的接受度、密码 5 项删除帧是否符合现场固件。
**文档**:[锁内授权查询与100容量下发设计](锁内授权查询与100容量下发设计.md) 新增 §11 落地记录。
---
### 4.47 锁内授权查询:按实测 readkey 报文修正(2026-11-04,代码就绪,未联调)
**触发**:现场抓包补齐了 `getkey` 的真实请求、进度回执、数据行与结束行,推翻了 D24 首版的三处假设。
**实测报文**:
| 类型 | 报文要点 |
|---|---|
| 请求 | `op=getkey`,`params=["readkey","admin","807104B5","02234006"]` |
| 网关进度 | `seq` 回显命令号,`params=["readkey","10","ad","null"]`,**不是授权行** |
| 数据行 | `seq="0"`,`params=["readkey","索引","总数","类型","凭据","起始","结束",,"当前"]`;`02`=密码(不足 8 位左补 `F`)、`07`=人脸(人员 ID) |
| 结束行 | `params=["readkey","NULL"]` |
| 时间 | 8 位十六进制 Unix 秒(`6AB80000` = 2026-09-27 01:25:20 +08:00),不是 5 字节位域 |
**改动**:
| 层 | 改动 |
|---|---|
| 解析 | `handleQueryReport` 按 `readkey` 偏移解析类型/凭据/三个时间;过滤进度行并转 `handleGatewayAck`;`NULL` 行或计数达总数置成功 |
| 命令归属 | 数据行 `seq=0` 无法关联,改为「上报网关分组 + 最近一条 `command_status='1'` 的 `0019`」,`LockCommandMapper.selectSentCommands` 新增;分组未知才全局兜底 |
| 时间 | `YdlinkKeyTimeCodec` 重写为十六进制 Unix 秒,兼容文本与十进制秒,非法返回 `null` |
| 快照 | `lock_device_auth` 新增 `auth_type` 存设备类型码;`sql/upgrade_d24_device_auth_type.sql`(幂等,**未执行**)补列 |
| 对账 | 人脸按 `person_no` 匹配,不再标“不可查询”;`NOT_QUERYABLE` 仅剩指纹等未验证类型;凭据为空或 `null` 的行不展示 |
| 清除 | 类型取设备类型码,密码走 5 项删除帧;人脸行提示到授权管理按人员清除,本页 `clearable=false` |
| 展示 | 页面密码明文(业务要求「不用脱密」);列名改「凭据/人员ID」;MQTT 报文留痕继续掩码 |
| 文档 | 协议文档 §4.6.5/§5.7 与设计稿 §0/§2/§9/§11 同步为实测口径 |
**验证**:离线编译 `BUILD SUCCESS`;桩数据跑通四类报文(进度行过滤、两条数据行字段与时间、结束行);前端模板 0 error、SCSS 与脚本解析通过。**未执行 SQL、未连 MQTT、未实机联调。**
**上线前置**:存量库先执行 `sql/upgrade_d24_device_auth_type.sql`;否则 `lock_device_auth` 缺少 `auth_type` 会导致查询落库失败。
---
### 4.48 授权下发:门锁未分配网关置配置失败,不再重试(2026-11-04,代码就绪,未联调)
**问题**:门锁没有登记到任何网关时,`routeResolver.forDevice` 抛「门锁未登记到任何网关」,旧代码置 `sync_status='3'`;补偿任务只按 `0/3` 捞数据,于是每 5 秒重发一次,永远失败。
**改动**:
| 层 | 改动 |
|---|---|
| 状态 | `LockConstants` 新增 `SYNC_CONFIG_FAIL='7'`(配置失败,终态) |
| 下发 | `pushInternal` 路由解析失败改置 7(`updateConfigFail`),补偿任务不再捞取;修正网关归属后在页面手动「重新下发」 |
| 回调 | `LockCallbackServiceImpl.isTerminal`:入住类失败把 7 也算终态,外部系统能收到失败结果而不是一直等待 |
| 统计 | 人员维度聚合的 `failed_count` 统计 `3 + 7` |
| 前端 | 授权页状态标签对 7 显示 danger;标签文字走字典 |
| 脚本 | `sql/upgrade_d25_sync_config_fail.sql`(幂等,**未执行**):字典增加 7;脚本内附只读排查 SQL,不自动改历史数据 |
**未改动**:网关离线仍按 R3 降级下发并由命令层重试;删除/清除路径的 `sync_status='6'` 语义不变。
**验证**:`mvn -o -pl ruoyi-iot -am -DskipTests compile` BUILD SUCCESS;授权页 SFC 模板/SCSS/脚本校验通过。**未执行 SQL、未实机联调。**
---
### 4.49 通行日志:房间号、通行人姓名与保留一个月(2026-11-04,代码就绪,未联调)
**需求**:通行日志增加通行人姓名与房间号,并支持按通行人、房间号查询;日志最多保留最近一个月。不涉及同行人。
**改动**:
| 层 | 改动 |
|---|---|
| 房间号 | 不新增列:`lock_access_log` 只记录门锁,房间号按 `lock_room.device_id` 当前绑定关系带出;新增 `LockRoomMapper.selectRoomsByDeviceIds` 按页批量查,未绑定房间显示 `-` |
| 通行人姓名 | 不新增列:按 `person_no` 从 `lock_person` 取姓名,新增 `LockPersonMapper.selectPersonNamesByNos` 批量查;主数据查不到时保留入库时的 `person_name` 快照;IC 卡等无人员编号的记录没有姓名 |
| 查询 | 列表新增两个条件:`roomNo` 用 `device_id in (select ... from lock_room ...)` 反查;`personName` 同时匹配日志快照与人员主数据姓名(`person_no in (...)`),兼容入库时姓名为空的历史数据 |
| 服务 | `LockAccessLogServiceImpl.selectAccessLogListEnriched()` 为平台列表/详情/导出统一补充信息;房间与人员两条补充查询按 500 个 ID 分批;开放接口 `/openapi/iot/accesslog/list` 仍走原始 `selectAccessLogList` |
| 实体 | `roomNo`、`personName` 均已有 `@Excel` 注解,导出自动带这两列 |
| 保留期 | 通行日志与其它流水数据统一由 `LockDataRetentionTask` 清理(见 4.50),保留 30 天 |
| 前端 | 搜索区新增「房间号」「通行人」;列表新增「房间号」「姓名」列;详情弹窗同步展示 |
**不需要 SQL 升级**:房间号与姓名均为查询时推导,`lock_access_log` 表结构不变。
**验证**:`mvn -o -pl ruoyi-iot -am -DskipTests compile` BUILD SUCCESS;四个 Mapper XML 格式校验通过;通行日志页 SFC 模板 0 error、脚本 babel 解析通过。**未执行 SQL、未连库、未实机联调。**
**待确认**:清理按 30 天固定保留期(见 4.50),不在本节点单独调参。
---
### 4.50 日志类数据统一保留 30 天(2026-11-04,代码就绪,未执行删除)
**需求**:命令记录、报文日志、住宿管理、授权记录及对外回调记录等流水/日志类数据,已完成的只保留 30 天,定时清理。
**实现**:新增 `LockDataRetentionTask`(每天 03:30,分布式锁 `data-retention`,TTL 1800s)+ `LockRetentionService.cleanExpired(keepDate)` 统一调度;每表每轮 5000 条、最多 10 轮,积压留到下次。保留起点 `now - 30 天`。
| 表 | 清理条件 | 保留的数据 |
|---|---|---|
| `lock_access_log` 通行日志 | `open_time < keepDate` | — |
| `lock_mqtt_log` 报文日志 | `recv_time < keepDate` | — |
| `lock_command` 命令记录 | `command_status in ('2','3','4','5')` 且 `req_time < keepDate` | 待发送(0)/已发送(1) |
| `lock_stay` 住宿单 | `stay_status in ('2','3','4')` 且 `coalesce(checkout_time,update_time,create_time) < keepDate`,并排除 `lock_room.current_stay_id` 指向的单 | 待入住(0)/在住(1) |
| `lock_auth` 授权记录 | `(del_flag='2' or sync_status='4')` 且 `coalesce(sync_time,update_time,create_time) < keepDate` | 待下发/已下发/成功/失败/配置失败/待清除 |
| `lock_callback_task` 回调任务 | 已成功(2)/死信(3):`coalesce(update_time,create_time) < keepDate`;待结果(0)/待发送(1):`create_time < keepDate` | 无(超期未投递也清理) |
**移除**:原 `LockAccessLogCleanTask` 及 `ILockAccessLogService.cleanBefore`、`ILockMqttLogService.cleanBefore`,清理入口收敛到 `ILockRetentionService`;`deleteAccessLogBefore` / `deleteMqttLogBefore` 改为 `(Date, limit)` 分批签名。
**注意**:住宿单与授权记录属业务流水,30 天后物理删除不可恢复;如需延长,改 `LockDataRetentionTask.KEEP_DAYS` 一个常量即可。`lock_device_auth` 锁内授权快照、房间分配历史 `lock_room_device_history` 记录设备现状/绑定沿革,不在清理范围。回调任务的待结果(0)/待发送(1) 超过 30 天也一并删除,卡死的投递任务因此不再支持人工重发;如需保留可单独放宽该条件。
**验证**:`mvn -o -pl ruoyi-iot -am -DskipTests compile` BUILD SUCCESS;8 个 Mapper XML 格式校验通过。**未执行任何 DELETE、未连库、未实机验证。**
---
## 5. 上一迭代:D17 一锁多网关(待上线)
### 5.1 交付内容
@@ -995,6 +1332,10 @@
| 重复消费(D20 P2) | 网络重试或对方超时重发会让同一事件到达多次 | 契约要求对方按 `X-Callback-Id` 去重、按 `revision` 取最新;平台侧保证同事件同结果不新增 revision |
| 历史门锁漏查(D20 P3) | 开锁记录按「当前绑定 ∪ 历史 ∪ 入住单快照」取并集,历史表未回填或换锁未走 `bindDevice` 时只有入住单兜底 | P1 脚本回填后核对条数;换锁必须走房间绑定接口,直接改库不会写历史 |
| 查询无压测(D20 P3) | 单房间 31 天为上限,但并集 + 分页未做大数据量实测 | 上线后观察慢日志;必要时给 `lock_access_log(device_sn, open_time)` 建联合索引 |
| `getkey` 剩余未知(D24) | 数据行与进度行已实测确认;IC卡/指纹类型码、空列表/满额报文、100 条耗时仍未知 | 先只读查询对账,补齐抓包后再放开清除;清除失败保持设备侧行可重试 |
| `getkey` 命令归属(D24) | 数据行 `seq=0`,只能靠上报网关分组认领;分组识别失败时全局兜底可能误配 | 按分组串行查询;全局兜底多于一条时打告警;上线后核对 `lock_device_auth.command_no` 与 `lock_command` |
| 设备侧清除误判(D24) | 密码删除帧按 5 项凭据位实现,若固件要求不同会定位不到凭据 | 上线前抓包核对 delete 参数;失败时页面显示“清除失败”,不自动改平台账本 |
| 查询占用网关(D24) | `getkey` 与授权命令共用 `data/batch` 单通道,查询在途会挤占下发 | 查询超时 30 秒、同分组仅一个在途;对账避开入住高峰 |
---
@@ -1010,6 +1351,7 @@
| P1 | D4 收口 | 下行 `devVer` 按协议版本显式传递 | D2 | V2 报文不再默认写死 `"1"` |
| P1 | 多网关对账 | 网关上线/每日任务按 `locklist` 反查补建缺失归属 | D17 上线 | 对账差异可告警、可一键重下发 |
| P1 | D19 + D20 P1 + P2 + P3 上线联调 | 依次跑 `upgrade_d19_stay.sql` → `upgrade_d20_openapi_stay.sql` → `upgrade_d20_p2_callback.sql` → 配 `iot.openapi.apps[]` 两个应用 Key 与 `callback-url`/`callback-secret` → 按设计稿 §12 用例联调 | 代码评审通过;同库顺序 D8 → D13 → D17 → D9 → D19 → D20(P1) → D20(P2);P3 无脚本 | 平台侧入住/退房/换房/续住通过;两个应用各自入住退房成功且数据互不可见;结果回调按应用分流、断网可重试、死信可重发;换锁前后的开锁记录都能查到 |
| P1 | D24 锁内授权对账上线 | 执行 `upgrade_d24_device_auth.sql` + `upgrade_d24_device_auth_type.sql` → 抓包补齐类型码/空列表/满额报文 → 抽样门锁对账 → 再放开清除操作 | D24 代码就绪 | 密码/人脸差异可定位;孤儿清除后页面与设备侧状态一致;进度行不再产生假数据 |
| P2 | D20 P4 安全加固 | IP 白名单、按应用限流、`photoUrl` 域名白名单(防 SSRF)、接口侧 HMAC 签名 | — | 压测与安全测试通过 |
| P2 | 回调可观测 | 回调成功率/重试次数/死信数埋点,死信超阈值告警 | P2 上线 | 死信不再依赖人工巡页 |
| P2 | D15 离线判定 | 解析 `onoff_line` 报文体、启用 LWT | — | 网关/门锁离线可即时感知 |
@@ -1022,3 +1364,5 @@
- D19 住宿与 D20 对外接口的脚本均未执行;D20 依赖 D19 的 `lock_room`/`lock_stay`,必须**先 D19 后 D20**,且两者都要求门锁已维护 `face_algo_version`(缺版本时入住接口直接阻断,返回 50001)。
- D19/D20 升级脚本是**顺序依赖**:`upgrade_d19_stay.sql` → `upgrade_d20_openapi_stay.sql`(P1)→ `upgrade_d20_p2_callback.sql`(P2)。P2 脚本可以晚于 P1 代码上线(缺表只影响接口单,平台手工入住不受影响),但**P2 代码与 P2 脚本必须同批次**。
- D20 P3 开锁记录查询**没有新增脚本**,依赖 P1 的 `lock_room_device_history` 与回填结果;P1 未执行完时接口能查到当前绑定门锁,但查不到换锁前的记录。
- D24 锁内授权查询按实测报文支持**密码 + 人脸**对账(人脸凭据即人员 ID);指纹与 IC 卡类型码未验证,页面按平台账本展示;数据行 `seq=0` 需按网关分组认领,空列表/满额报文与删除帧参数仍需实机确认后再放开清除。