This commit is contained in:
zfjia committed 2026-11-03 18:31:01 +08:00
1 parent 6e529c7ef7
commit eb5ad9518d
36 files changed
+4195 -581

No files matched your search

@@ -4,7 +4,9 @@ spring:
type: com.alibaba.druid.pool.DruidDataSource
driverClassName: com.mysql.cj.jdbc.Driver
druid:
# 主库数据源
# 主库数据源:这里是兜底默认值(本机);
# application-local.yml / application-prod.yml 会按环境覆盖 master 的 url/username/password
# (profile 顺序中靠后的生效,默认 druid,local;服务器用 druid,prod)
master:
url: jdbc:mysql://localhost:3306/iotlocks?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
username: iotlocks
@@ -6,8 +6,9 @@ ruoyi:
version: 3.9.2
# 版权年份
copyrightYear: 2026
# 文件路径 示例( Windows配置D:/ruoyi/uploadPath,Linux配置 /home/ruoyi/uploadPath)
profile: C:/Users/zfjia/work/projects/ruoyi/uploadPath
# 文件路径:按环境在 application-local.yml / application-prod.yml 中设置;
# 也可用环境变量 RUOYI_PROFILE 或 -Druoyi.profile= 覆盖(优先级更高)
profile: ${RUOYI_PROFILE:./uploadPath}
# 获取ip地址开关
addressEnabled: false
# 验证码类型 math 数字计算 char 字符验证
@@ -44,9 +45,10 @@ logging:
com.ruoyi.iot.lock.mapper: info
com.ruoyi.iot.person.mapper: info
file:
# 日志输出目录(logback-spring.xml 通过 springProperty 读取;Linux 示例 /home/ruoyi/logs)
# 也可用环境变量 LOGGING_FILE_PATH 覆盖,无需改配置文件
path: C:/Users/zfjia/work/projects/ruoyi/logs
# 日志输出目录(logback-spring.xml 通过 springProperty 读取)
# 按环境在 application-local.yml / application-prod.yml 中设置;
# 也可用环境变量 LOGGING_FILE_PATH 或 -Dlogging.file.path= 覆盖(优先级更高)
path: ${LOGGING_FILE_PATH:logs}
# 用户配置
user:
@@ -63,7 +65,7 @@ spring:
# 国际化资源文件路径
basename: i18n/messages
profiles:
active: druid
active: druid,prod
# 文件上传
servlet:
multipart:
@@ -157,81 +159,3 @@ xss:
# 匹配链接
urlPatterns: /system/*,/monitor/*,/tool/*
# 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: admin5
password: admin5
# 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
@@ -30,6 +30,15 @@ public final class LockCommandPolicy
/** 第 n 次重试的延迟(秒),超出后固定用最后一档 */
private static final int[] RETRY_DELAY_SECONDS = { 5, 15, 45, 120, 300 };
/** 网关忙后的重排间隔(秒):网关处理完当前 data/batch 需要时间,过短只会再次撞忙 */
private static final int BUSY_RETRY_SECONDS = 8;
/** 授权命令网关忙的重排次数上限,约 8 秒一轮;超过则认为链路异常,按失败终态放行后续命令 */
private static final int AUTH_MAX_BUSY_RETRY = 12;
/** 其它命令网关忙的重排次数上限 */
private static final int DEFAULT_MAX_BUSY_RETRY = 3;
static
{
TIMEOUT_SECONDS.put(LockConstants.CMD_BIND, 60);
@@ -45,6 +54,8 @@ public final class LockCommandPolicy
TIMEOUT_SECONDS.put(LockConstants.CMD_CLEAR_DEVICE, 60);
TIMEOUT_SECONDS.put(LockConstants.CMD_AUTH_PUSH, 90);
TIMEOUT_SECONDS.put(LockConstants.CMD_AUTH_CLEAR, 90);
// 查询只用于对账,首行返回即视为有响应;等不到响应时短超时避免长期占用网关
TIMEOUT_SECONDS.put(LockConstants.CMD_READ_AUTH, 30);
NO_RETRY.add(LockConstants.CMD_SYNC_TIME);
NO_RETRY.add(LockConstants.CMD_SWITCH);
@@ -105,15 +116,42 @@ public final class LockCommandPolicy
}
/**
* 设备忙时的重试时间点:秒级重排,不占用普通退避档位
* 网关忙时的重试时间点:秒级重排,不占用普通退避档位
*/
public static Date busyRetryTime()
{
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.SECOND, 3);
calendar.add(Calendar.SECOND, BUSY_RETRY_SECONDS);
return calendar.getTime();
}
/**
* 网关忙的重排次数上限
*/
public static int maxBusyRetry(String commandType)
{
if (LockConstants.CMD_AUTH_PUSH.equals(commandType) || LockConstants.CMD_AUTH_CLEAR.equals(commandType))
{
return AUTH_MAX_BUSY_RETRY;
}
return DEFAULT_MAX_BUSY_RETRY;
}
/**
* 网关已回 10 之后,等待门锁终态的时长(秒)
* <p>
* 10 只代表网关已接收并转发,门锁执行结果随后才会到;这段时间内既不能重发,
* 也不能沿用下发超时,否则 90 秒一到就被判失败、队列提前放行下一条。
*/
public static int deviceWaitSeconds(String commandType)
{
if (LockConstants.CMD_AUTH_PUSH.equals(commandType) || LockConstants.CMD_AUTH_CLEAR.equals(commandType))
{
return 180;
}
return timeoutSeconds(commandType);
}
/**
* 第 retryCount 次重试的时间点
*/
@@ -52,6 +52,9 @@ public class LockConstants
/** 命令类型:清除授权 */
public static final String CMD_AUTH_CLEAR = "0012";
/** 命令类型:读取锁内卡/密码授权列表(op=getkey,动作 readkey) */
public static final String CMD_READ_AUTH = "0019";
/** 命令状态:待发送 */
public static final String STATUS_PENDING = "0";
@@ -70,6 +73,12 @@ public class LockConstants
/** 命令状态:已取消 */
public static final String STATUS_CANCELED = "5";
/** 授权来源:平台维护(与 lock_stay.source 同编码) */
public static final String AUTH_SOURCE_PLATFORM = "1";
/** 授权来源:开放接口(直接授权与外部入住/换房,与 lock_stay.source 同编码) */
public static final String AUTH_SOURCE_OPENAPI = "2";
/** 授权类型:人脸 */
public static final String AUTH_FACE = "01";
@@ -18,7 +18,10 @@ import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.iot.lock.LockConstants;
import com.ruoyi.iot.lock.domain.LockAuth;
import com.ruoyi.iot.lock.domain.LockAuthBatchRequest;
import com.ruoyi.iot.lock.domain.LockAuthPerson;
import com.ruoyi.iot.lock.service.ILockAuthService;
/**
@@ -37,11 +40,97 @@ public class LockAuthController extends BaseController
@GetMapping("/list")
public TableDataInfo list(LockAuth auth)
{
// 平台页签只统计平台授权,外部授权走 /external/list
auth.setSource(LockConstants.AUTH_SOURCE_PLATFORM);
startPage();
List<LockAuth> list = lockAuthService.selectAuthList(auth);
return getDataTable(list);
}
/** 外部授权分页列表:一授权一行,固定 source='2' */
@PreAuthorize("@ss.hasPermi('lock:auth:list')")
@GetMapping("/external/list")
public TableDataInfo externalList(LockAuth query)
{
startPage();
List<LockAuth> list = lockAuthService.selectExternalAuthList(query);
return getDataTable(list);
}
/** 全部授权分页列表:一授权一行,平台与外部授权都包含,用于逐条排查下发情况 */
@PreAuthorize("@ss.hasPermi('lock:auth:list')")
@GetMapping("/all/list")
public TableDataInfo allList(LockAuth query)
{
startPage();
List<LockAuth> list = lockAuthService.selectAllAuthList(query);
return getDataTable(list);
}
/** 外部授权详情:不返回授权凭据 */
@PreAuthorize("@ss.hasPermi('lock:auth:query')")
@GetMapping("/external/{authId}")
public AjaxResult externalInfo(@PathVariable Long authId)
{
return success(lockAuthService.selectExternalAuthById(authId));
}
/** 修改外部授权有效期;有效授权保存后自动重推 */
@PreAuthorize("@ss.hasPermi('lock:auth:edit')")
@Log(title = "外部授权修改", businessType = BusinessType.UPDATE)
@PutMapping("/external/{authId}")
public AjaxResult externalEdit(@PathVariable Long authId, @RequestBody LockAuth auth)
{
auth.setAuthId(authId);
auth.setUpdateBy(getUsername());
return toAjax(lockAuthService.updateExternalAuth(authId, auth));
}
@PreAuthorize("@ss.hasPermi('lock:auth:push')")
@Log(title = "外部授权下发", businessType = BusinessType.UPDATE)
@PostMapping("/external/{authId}/push")
public AjaxResult externalPush(@PathVariable Long authId)
{
if (lockAuthService.selectExternalAuthById(authId) == null)
{
return error("外部授权不存在,authId=" + authId);
}
lockAuthService.push(authId);
return success();
}
@PreAuthorize("@ss.hasPermi('lock:auth:clear')")
@Log(title = "外部授权取消", businessType = BusinessType.UPDATE)
@PostMapping("/external/{authId}/clear")
public AjaxResult externalClear(@PathVariable Long authId)
{
if (lockAuthService.selectExternalAuthById(authId) == null)
{
return error("外部授权不存在,authId=" + authId);
}
lockAuthService.clear(authId);
return success();
}
/** 删除外部授权:混入非外部授权整批拒绝 */
@PreAuthorize("@ss.hasPermi('lock:auth:remove')")
@Log(title = "外部授权删除", businessType = BusinessType.DELETE)
@DeleteMapping("/external/{authIds}")
public AjaxResult externalRemove(@PathVariable Long[] authIds)
{
return toAjax(lockAuthService.deleteExternalAuthByIds(authIds));
}
/** 人员维度授权列表:一人一行,便于按人查看与维护其全部门锁授权 */
@PreAuthorize("@ss.hasPermi('lock:auth:list')")
@GetMapping("/person/list")
public TableDataInfo personList(LockAuthPerson query)
{
startPage();
List<LockAuthPerson> list = lockAuthService.selectPersonAuthList(query);
return getDataTable(list);
}
@PreAuthorize("@ss.hasPermi('lock:auth:query')")
@GetMapping(value = "/{authId}")
public AjaxResult getInfo(@PathVariable Long authId)
@@ -63,6 +152,14 @@ public class LockAuthController extends BaseController
return toAjax(lockAuthService.insertAuth(auth));
}
@PreAuthorize("@ss.hasPermi('lock:auth:add')")
@Log(title = "门锁授权批量新增", businessType = BusinessType.INSERT)
@PostMapping("/batch")
public AjaxResult batchAdd(@RequestBody LockAuthBatchRequest request)
{
return success(lockAuthService.batchInsertAuth(request));
}
@PreAuthorize("@ss.hasPermi('lock:auth:edit')")
@Log(title = "门禁授权", businessType = BusinessType.UPDATE)
@PutMapping
@@ -79,6 +176,34 @@ public class LockAuthController extends BaseController
return toAjax(lockAuthService.deleteAuthByIds(authIds));
}
/** 仅删除授权记录:设备侧已清除的行可从平台移除,不再下发清除指令 */
@PreAuthorize("@ss.hasPermi('lock:auth:remove')")
@Log(title = "授权记录删除", businessType = BusinessType.DELETE)
@DeleteMapping("/record/{authIds}")
public AjaxResult removeRecords(@PathVariable Long[] authIds)
{
return toAjax(lockAuthService.deleteAuthRecordsByIds(authIds));
}
/** 按人员删除:回收该人员名下所有门锁授权 */
@PreAuthorize("@ss.hasPermi('lock:auth:remove')")
@Log(title = "按人员删除门禁授权", businessType = BusinessType.DELETE)
@PostMapping("/person/remove")
public AjaxResult removeByPerson(@RequestBody String[] personNos)
{
return toAjax(lockAuthService.deleteAuthsByPersonNos(personNos));
}
/** 按人员重新下发:下发其名下所有可下发的授权 */
@PreAuthorize("@ss.hasPermi('lock:auth:push')")
@Log(title = "按人员下发授权", businessType = BusinessType.UPDATE)
@PostMapping("/person/push")
public AjaxResult pushByPerson(@RequestBody String[] personNos)
{
lockAuthService.batchPushByPersonNos(personNos);
return success();
}
@PreAuthorize("@ss.hasPermi('lock:auth:push')")
@Log(title = "授权下发", businessType = BusinessType.UPDATE)
@PostMapping("/{authId}/push")
@@ -120,6 +245,7 @@ public class LockAuthController extends BaseController
@PostMapping("/export")
public void export(HttpServletResponse response, LockAuth auth)
{
auth.setSource(LockConstants.AUTH_SOURCE_PLATFORM);
List<LockAuth> list = lockAuthService.selectAuthList(auth);
ExcelUtil<LockAuth> util = new ExcelUtil<LockAuth>(LockAuth.class);
util.exportExcel(response, list, "门禁授权数据");
@@ -1,5 +1,6 @@
package com.ruoyi.iot.lock.controller;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import jakarta.servlet.http.HttpServletResponse;
@@ -20,8 +21,11 @@ import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.utils.poi.ExcelUtil;
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.LockDeviceAuthClearRequest;
import com.ruoyi.iot.lock.service.ILockAuthService;
import com.ruoyi.iot.lock.service.ILockDeviceAuthService;
import com.ruoyi.iot.lock.service.ILockDeviceService;
/**
@@ -39,6 +43,9 @@ public class LockDeviceController extends BaseController
@Autowired
private ILockAuthService lockAuthService;
@Autowired
private ILockDeviceAuthService lockDeviceAuthService;
@PreAuthorize("@ss.hasPermi('lock:device:list')")
@GetMapping("/list")
public TableDataInfo list(LockDevice device)
@@ -167,6 +174,47 @@ public class LockDeviceController extends BaseController
return getDataTable(list);
}
/** 读取锁内卡/密码授权(op=getkey),返回查询命令编号供页面轮询 */
@PreAuthorize("@ss.hasPermi('lock:auth:list')")
@Log(title = "锁内授权读取", businessType = BusinessType.OTHER)
@PostMapping("/{deviceId}/auth/query")
public AjaxResult queryDeviceAuth(@PathVariable Long deviceId)
{
return success(commandSummary(lockDeviceAuthService.startQuery(deviceId)));
}
/** 锁内授权快照与平台授权对账结果 */
@PreAuthorize("@ss.hasPermi('lock:auth:list')")
@GetMapping("/{deviceId}/auth/snapshot")
public AjaxResult deviceAuthSnapshot(@PathVariable Long deviceId)
{
return success(lockDeviceAuthService.selectSnapshot(deviceId));
}
/** 清除设备侧一条卡/密码授权 */
@PreAuthorize("@ss.hasPermi('lock:auth:clear')")
@Log(title = "锁内授权清除", businessType = BusinessType.UPDATE)
@PostMapping("/{deviceId}/auth/clear")
public AjaxResult clearDeviceAuth(@PathVariable Long deviceId, @RequestBody LockDeviceAuthClearRequest request)
{
if (request == null || request.getDeviceAuthId() == null)
{
return AjaxResult.error("缺少设备侧授权记录ID");
}
LockCommand command = lockDeviceAuthService.clearCredential(deviceId, request.getDeviceAuthId(),
request.getAuthType());
return success(commandSummary(command));
}
/** 命令摘要:不回传 payload,避免门锁密码与凭据泄露到页面 */
private Map<String, Object> commandSummary(LockCommand command)
{
Map<String, Object> data = new HashMap<String, Object>();
data.put("commandNo", command.getCommandNo());
data.put("commandStatus", command.getCommandStatus());
return data;
}
@PreAuthorize("@ss.hasPermi('lock:device:export')")
@Log(title = "智能门锁", businessType = BusinessType.EXPORT)
@PostMapping("/export")
@@ -30,6 +30,10 @@ public class LockAccessLog implements Serializable
@Excel(name = "门锁SN")
private String deviceSn;
/** 房间号(按门锁当前绑定关系关联,未绑定房间时为空) */
@Excel(name = "房间号")
private String roomNo;
/** 上报网关ID */
private Long gatewayId;
@@ -68,8 +72,9 @@ public class LockAccessLog implements Serializable
@Excel(name = "开门时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
private Date openTime;
/** 服务端接收时间 */
/** 服务端接收时间(数据上报时间) */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@Excel(name = "上报时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
private Date recvTime;
/** 报文序号(幂等去重) */
@@ -117,6 +122,16 @@ public class LockAccessLog implements Serializable
this.deviceSn = deviceSn;
}
public String getRoomNo()
{
return roomNo;
}
public void setRoomNo(String roomNo)
{
this.roomNo = roomNo;
}
public Long getGatewayId()
{
return gatewayId;
@@ -288,7 +303,8 @@ public class LockAccessLog implements Serializable
public String toString()
{
return new ToStringBuilder(this, ToStringStyle.MULTI_LINE_STYLE).append("logId", getLogId())
.append("deviceSn", getDeviceSn()).append("personNo", getPersonNo()).append("cardNo", getCardNo())
.append("deviceSn", getDeviceSn()).append("roomNo", getRoomNo())
.append("personNo", getPersonNo()).append("cardNo", getCardNo())
.append("openType", getOpenType()).append("openResult", getOpenResult())
.append("openTime", getOpenTime()).append("recvTime", getRecvTime()).append("seq", getSeq())
.toString();
@@ -66,6 +66,30 @@ public class LockAuth extends BaseEntity
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date authTime;
/** 授权来源(1平台 2开放接口) */
private String source;
/** 来源应用编码(source=2 时对应 lock_open_app.app_code) */
private String sourceAppCode;
/** 来源业务单号(外部入住/换房为 biz_no,直接授权可空) */
private String sourceBizNo;
/** 展示:来源应用名称(应用已删除时为空,页面退回显示编码) */
private String sourceAppName;
/** 展示:关联入住单号(外部入住/换房授权) */
private String stayNo;
/** 展示:门锁名称 */
private String deviceName;
/** 展示:门锁在线状态(0在线 1离线 2未知) */
private String onlineStatus;
/** 查询:授权编号 / 人员编号 / 人员姓名模糊匹配(外部授权列表) */
private String keyword;
/** 下发同步状态(0待下发 1已下发 2下发成功 3下发失败 4已清除) */
@Excel(name = "同步状态", readConverterExp = "0=待下发,1=已下发,2=下发成功,3=下发失败,4=已清除")
private String syncStatus;
@@ -226,6 +250,86 @@ public class LockAuth extends BaseEntity
this.authTime = authTime;
}
public String getSource()
{
return source;
}
public void setSource(String source)
{
this.source = source;
}
public String getSourceAppCode()
{
return sourceAppCode;
}
public void setSourceAppCode(String sourceAppCode)
{
this.sourceAppCode = sourceAppCode;
}
public String getSourceBizNo()
{
return sourceBizNo;
}
public void setSourceBizNo(String sourceBizNo)
{
this.sourceBizNo = sourceBizNo;
}
public String getSourceAppName()
{
return sourceAppName;
}
public void setSourceAppName(String sourceAppName)
{
this.sourceAppName = sourceAppName;
}
public String getStayNo()
{
return stayNo;
}
public void setStayNo(String stayNo)
{
this.stayNo = stayNo;
}
public String getDeviceName()
{
return deviceName;
}
public void setDeviceName(String deviceName)
{
this.deviceName = deviceName;
}
public String getOnlineStatus()
{
return onlineStatus;
}
public void setOnlineStatus(String onlineStatus)
{
this.onlineStatus = onlineStatus;
}
public String getKeyword()
{
return keyword;
}
public void setKeyword(String keyword)
{
this.keyword = keyword;
}
public String getSyncStatus()
{
return syncStatus;
@@ -3,6 +3,7 @@ package com.ruoyi.iot.lock.mapper;
import java.util.List;
import org.apache.ibatis.annotations.Param;
import com.ruoyi.iot.lock.domain.LockAuth;
import com.ruoyi.iot.lock.domain.LockAuthPerson;
/**
* 授权数据 数据层
@@ -13,8 +14,35 @@ public interface LockAuthMapper
{
List<LockAuth> selectAuthList(LockAuth auth);
/** 按人员聚合授权:一人一行,含授权总数、已下发数、失败数与涉及门锁数 */
List<LockAuthPerson> selectPersonAuthList(LockAuthPerson query);
/** 人员的全部未删除授权ID(含已清除行),按人员删除时使用 */
List<Long> selectAuthIdsByPersonNos(@Param("personNos") String[] personNos);
/** 人员可重新下发的授权ID:排除已清除(4)与待清除(6),避免把删除中的人脸重新 add */
List<Long> selectPushableAuthIdsByPersonNos(@Param("personNos") String[] personNos);
LockAuth selectAuthById(Long authId);
/** 外部授权分页列表:固定 source='2',带来源应用、门锁与入住单展示字段 */
List<LockAuth> selectExternalAuthList(LockAuth query);
/** 全部授权分页列表:一授权一行,不区分来源,带来源应用、门锁与入住单展示字段 */
List<LockAuth> selectAllAuthList(LockAuth query);
/** 外部授权详情(不含 auth_value) */
LockAuth selectExternalAuthById(Long authId);
/** 按应用校验外部授权归属;不属于该应用时返回 null */
LockAuth selectExternalAuthForApp(@Param("authId") Long authId, @Param("appCode") String appCode);
/** 接管平台授权归属(仅外部入住/换房使用) */
int updateAuthSource(LockAuth auth);
/** 外部授权改期:允许把 end_time 更新为 NULL */
int updateExternalAuth(LockAuth auth);
List<LockAuth> selectAuthsByDeviceId(Long deviceId);
List<LockAuth> selectAuthsByPersonNo(String personNo);
@@ -24,6 +24,30 @@ public interface LockCommandMapper
/** 指定分组仍在途(待发送/已发送)的授权类命令数,用于同一网关串行下发 */
int countInflightAuthCommands(@Param("groupId") Long groupId);
/** 同一主题(同网关)除指定命令外仍在途(待发送/已发送)的命令数,用于普通失败重发前串行校验 */
int countOtherInflightByTopic(@Param("topic") String topic, @Param("commandId") Long commandId);
/** 同一主题除指定命令外已发送的命令数,用于忙排队命令重发前串行校验 */
int countOtherSentByTopic(@Param("topic") String topic, @Param("commandId") Long commandId);
/** 到期待重发的排队命令(网关忙),按命令ID升序保证先来先发 */
List<LockCommand> selectDeferredCommands(Date now);
/** 指定授权是否已有在途命令,避免补偿与命令重试重复下发 */
int countInflightAuthByBizId(@Param("bizId") Long bizId);
/** 指定业务对象是否已有指定类型的在途命令(锁内授权查询按门锁去重) */
int countInflightByBizId(@Param("commandType") String commandType, @Param("bizId") Long bizId);
/** 指定业务对象最近一次指定类型的命令 */
LockCommand selectLatestByBizId(@Param("commandType") String commandType, @Param("bizId") Long bizId);
/** 指定业务对象最近一次有返回行的查询命令(最近一次失败时回退展示) */
LockCommand selectLatestWithRowsByBizId(@Param("commandType") String commandType, @Param("bizId") Long bizId);
/** 批量按命令编号取命令,用于对账列表一次性判定清除状态 */
List<LockCommand> selectCommandsByNos(@Param("commandNos") List<String> commandNos);
int insertCommand(LockCommand command);
int updateCommand(LockCommand command);
@@ -27,6 +27,9 @@ public interface LockGatewayMapper
int updateOnlineStatus(@Param("gatewayId") Long gatewayId, @Param("onlineStatus") String onlineStatus,
@Param("lastOnlineTime") Date lastOnlineTime);
/** 只改在线状态,保留 last_online_time(离线通知使用) */
int updateOnlineStatusOnly(@Param("gatewayId") Long gatewayId, @Param("onlineStatus") String onlineStatus);
int updateSyncStatus(@Param("gatewayId") Long gatewayId, @Param("syncStatus") String syncStatus,
@Param("syncMsg") String syncMsg);
@@ -37,4 +40,11 @@ public interface LockGatewayMapper
/** 超过指定时间未上报的网关置为离线 */
int markOfflineBefore(@Param("offlineTime") Date offlineTime, @Param("now") Date now);
/**
* 按归属行的最近上报时间把网关置为在线
* <p>
* 网关能转发门锁信号/记录即证明其在线,不再只依赖 onoff_line 连接事件。
*/
int refreshOnlineByReport(@Param("onlineTime") Date onlineTime);
}
@@ -18,6 +18,7 @@ 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.service.ILockDeviceAuthService;
import com.ruoyi.iot.lock.service.ILockMqttLogService;
import com.ruoyi.iot.domain.MqttMessageDTO;
@@ -46,6 +47,9 @@ public class LockMessageListener implements IMqttMessageListener
@Autowired
private LockSignalHandler lockSignalHandler;
@Autowired
private ILockDeviceAuthService deviceAuthService;
@Autowired
private ILockMqttLogService mqttLogService;
@@ -99,7 +103,15 @@ public class LockMessageListener implements IMqttMessageListener
result = commandRespHandler.handle(topic, frame, message.getPayload());
break;
case GROUP_DATA_BATCH:
result = commandRespHandler.handle(topic, frame, message.getPayload());
if (YdlinkOp.GET_KEY.equals(frame.getOp()))
{
// getkey 应答不是「动作+结果码」形态,第 2 位是总数,不能交给通用结果码解析
result = handleKeyReport(topic, frame, message.getPayload());
}
else
{
result = commandRespHandler.handle(topic, frame, message.getPayload());
}
break;
case GROUP_DATA_BATCH_REPORT:
if (YdlinkOp.LOCK_RSSI.equals(frame.getOp()))
@@ -108,6 +120,10 @@ public class LockMessageListener implements IMqttMessageListener
int updated = lockSignalHandler.handle(topic, frame);
result = "信号电量更新 " + updated + " 台";
}
else if (YdlinkOp.GET_KEY.equals(frame.getOp()))
{
result = handleKeyReport(topic, frame, message.getPayload());
}
else
{
result = commandRespHandler.handle(topic, frame, message.getPayload());
@@ -140,6 +156,12 @@ public class LockMessageListener implements IMqttMessageListener
recordLog(topic, frame, message, status, result, cost(start));
}
private String handleKeyReport(YdlinkTopicDTO topic, YdlinkFrameDTO frame, String payload)
{
return deviceAuthService.handleQueryReport(topic.getTopic(), frame.getSeq(),
YdlinkCodec.toRecordList(frame.getParams()), payload);
}
private void recordLog(YdlinkTopicDTO topic, YdlinkFrameDTO frame, MqttMessageDTO message, String handleStatus,
String handleMsg, int costTime)
{
@@ -9,6 +9,7 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import com.alibaba.fastjson2.JSON;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.iot.lock.LockCommandPolicy;
import com.ruoyi.iot.lock.LockConstants;
import com.ruoyi.iot.lock.protocol.YdlinkCodec;
import com.ruoyi.iot.lock.protocol.YdlinkFrameDTO;
@@ -26,9 +27,10 @@ import com.ruoyi.iot.lock.service.ILockCommandService;
* 覆盖:property/set、action/execute、data/batch(设备在请求主题反向回 ac=resp),
* 以及协议登记的 *_response、data/batch_report 主题。
* <p>
* 门锁类下发可能收到两次响应:<br>
* 门锁类下发可能收到多次响应:<br>
* 1) 网关回执(结果码 10):仅表示消息已送达网关并转发,属中间态;<br>
* 2) 门锁回执(结果码 0/11/12):终态,据此判定成功与失败。<br>
* 2) 网关忙(结果码 11):报文未转发到门锁,属可重发的瞬时状态,不能判失败;<br>
* 3) 门锁回执(结果码 0/12):终态,0 成功、12 设备不存在,据此判定结果。<br>
* 结果码位置按 op 由 {@link YdlinkRespSpec} 决定,不再按首项是否为动作名猜测。
*
* @author ruoyi
@@ -64,9 +66,9 @@ public class CommandRespHandler
return "应答缺少可识别结果码,命令保持等待";
}
String message = parseMessage(first);
if (YdlinkRespSpec.CODE_DEVICE_BUSY.equals(resp.getCode()))
if (YdlinkRespSpec.CODE_DEVICE_BUSY.equals(resp.getCode()) && !LockCommandPolicy.isBusy(message))
{
// 结果码 11 才是「设备忙」的权威信号,设备不一定附带人读文本;
// 结果码 11 才是「网关忙」的权威信号,设备不一定附带人读文本;
// 补上标记后,命令层才能按秒级重排而不是走普通失败退避
message = StringUtils.isEmpty(message) ? "gw busy" : message + " gw busy";
}
@@ -77,6 +79,21 @@ public class CommandRespHandler
}
boolean gatewayAck = resp.getStage() == YdlinkRespSpec.Stage.GATEWAY_ACK;
boolean success = resp.getStage() == YdlinkRespSpec.Stage.SUCCESS;
if (resp.getStage() == YdlinkRespSpec.Stage.GATEWAY_BUSY)
{
// 网关忙表示报文没有转发到门锁,不能按门锁失败处理:
// 命令置为待发送并按秒级排队重发,同网关后续命令继续等待,不再立即下发下一条
String finalStatus = commandService.handleGatewayBusy(topic.getTopic(), seq, payload, message);
if (finalStatus == null)
{
log.info("网关忙,命令已排队重发, topic={}, seq={}, op={}", topic.getTopic(), seq, frame.getOp());
return "网关忙,已排队重发";
}
authService.handleResult(seq, LockConstants.STATUS_SUCCESS.equals(finalStatus), message);
log.info("网关持续忙,命令按失败终结, topic={}, seq={}, op={}, status={}", topic.getTopic(), seq,
frame.getOp(), finalStatus);
return "执行失败:" + message;
}
String finalStatus = gatewayAck
? commandService.handleGatewayAck(topic.getTopic(), seq, payload, message)
: commandService.handleResponse(topic.getTopic(), seq, payload, success, message);
@@ -1,5 +1,6 @@
package com.ruoyi.iot.lock.mqtt.handler;
import java.util.Locale;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
@@ -12,6 +13,10 @@ import com.ruoyi.iot.lock.protocol.YdlinkTopicDTO;
/**
* 网关上下线处理:ydlink/{gatewaySn}/thing/onoff_line
* <p>
* 报文体没有标准信封,只有 devId 与 message:上线为 "I am back",离线为遗嘱消息
* "I will be back"。文案不作为唯一依据(固件改文案即失效),只在命中离线文案时置离线,
* 其余一律按在线处理并记录原文,便于现场核对。
*
* @author ruoyi
*/
@@ -20,6 +25,9 @@ public class GatewayOnlineHandler
{
private static final Logger log = LoggerFactory.getLogger(GatewayOnlineHandler.class);
/** 离线遗嘱文案关键字 */
private static final String OFFLINE_KEYWORD = "will be back";
@Autowired
private ILockGatewayService gatewayService;
@@ -37,12 +45,19 @@ public class GatewayOnlineHandler
{
return "非网关级主题";
}
// 协议中上下线语义待确认:默认按“收到上报即在线”处理
String onlineStatus = LockConstants.ONLINE;
gatewayService.updateOnlineStatus(gatewaySn, onlineStatus);
LockGateway gateway = gatewayService.selectGatewayBySn(gatewaySn);
log.info("网关状态更新, gatewaySn={}, onlineStatus={}, registered={}", gatewaySn, onlineStatus,
gateway != null);
return gateway == null ? "网关未登记,已忽略" : "已更新为在线";
String message = frame == null ? null : frame.getMessage();
if (gateway == null)
{
// 主题第2段与 lock_gateway.gateway_sn 对不上时不会更新任何行,这里必须留痕
log.warn("网关上下线报文未匹配到登记网关, gatewaySn={}, devId={}, message={}", gatewaySn,
frame == null ? null : frame.getDevId(), message);
return "网关未登记,已忽略";
}
boolean offline = message != null && message.toLowerCase(Locale.ROOT).contains(OFFLINE_KEYWORD);
String onlineStatus = offline ? LockConstants.OFFLINE : LockConstants.ONLINE;
gatewayService.updateOnlineStatus(gatewaySn, onlineStatus);
log.info("网关状态更新, gatewaySn={}, onlineStatus={}, message={}", gatewaySn, onlineStatus, message);
return offline ? "已更新为离线" : "已更新为在线";
}
}
@@ -66,6 +66,14 @@ public class YdlinkFrameDTO implements Serializable
@JSONField(ordinal = 10)
private String chksum;
/**
* 状态文案(仅 onoff_line 使用:"I am back" 上线 / "I will be back" 离线)
* <p>
* 该报文没有标准信封,只有 devId + message;只接收不回填。
*/
@JSONField(serialize = false)
private String message;
/** 参数数组(一维或二维) */
@JSONField(ordinal = 11)
private List<Object> params;
@@ -249,4 +257,15 @@ public class YdlinkFrameDTO implements Serializable
this.params = params;
return this;
}
public String getMessage()
{
return message;
}
public YdlinkFrameDTO setMessage(String message)
{
this.message = message;
return this;
}
}
@@ -43,6 +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 });
}
private YdlinkPayloadMasker()
@@ -15,7 +15,8 @@ import java.util.Map;
* → [1] 错误码、[5] 操作结果,两处都可能出现 10/0
* </pre>
* 因此按 op 维护取码位置,opencloselock 同时检查 [1] 与 [5]:
* 任一为 10 视为网关已接收(中间态),出现 11/12 视为失败,其余以 0 判定成功。
* 任一为 10 视为网关已接收(中间态),11 视为网关忙(报文未转发,可稍后重发),
* 12 视为设备不存在(终态失败),其余以 0 判定成功。
* <p>
* locklist 的 add/clear 回执都以「动作名 + 结果码」两段返回(实测
* {@code params:["clear","0"]}、{@code params:["add","0"]}),结果码位置一致。
@@ -30,10 +31,10 @@ public final class YdlinkRespSpec
/** 网关已接收并转发(中间态) */
public static final String CODE_GATEWAY_ACK = "10";
/** 设备正忙 */
/** 网关忙:报文未转发到门锁,属可重发的瞬时状态,不能当门锁终态失败 */
public static final String CODE_DEVICE_BUSY = "11";
/** 设备不存在 */
/** 设备不存在(终态失败) */
public static final String CODE_DEVICE_NOT_FOUND = "12";
/** 应答阶段 */
@@ -42,6 +43,9 @@ public final class YdlinkRespSpec
/** 网关中间态,命令尚未完成 */
GATEWAY_ACK,
/** 网关忙,报文未转发,稍后可重发 */
GATEWAY_BUSY,
/** 设备终态:成功 */
SUCCESS,
@@ -72,7 +76,8 @@ public final class YdlinkRespSpec
// 查询类
RESULT_INDEX.put(YdlinkOp.GET_SLAVE_INFO, new int[] { 2 });
RESULT_INDEX.put(YdlinkOp.GET_RECORD, new int[] { 2 });
RESULT_INDEX.put(YdlinkOp.GET_KEY, new int[] { 2 });
// getkey 应答第 2 位是「总数」而非结果码,由 LockDeviceAuthServiceImpl 专解析;
// 这里不登记,避免总数为 0 时被误判成功、非 0 时永远等待
}
private YdlinkRespSpec()
@@ -117,7 +122,13 @@ public final class YdlinkRespSpec
{
return new RespCode(code, Stage.GATEWAY_ACK);
}
if (CODE_DEVICE_BUSY.equals(code) || CODE_DEVICE_NOT_FOUND.equals(code))
if (CODE_DEVICE_BUSY.equals(code))
{
// 网关同一时刻只处理一条 data/batch,11 时命令并未下发到门锁,
// 必须保留重发机会;实测随后同一 seq 仍会回 0,按失败终结会丢掉成功回执
return new RespCode(code, Stage.GATEWAY_BUSY);
}
if (CODE_DEVICE_NOT_FOUND.equals(code))
{
failCode = code;
}
@@ -2,6 +2,9 @@ package com.ruoyi.iot.lock.service;
import java.util.List;
import com.ruoyi.iot.lock.domain.LockAuth;
import com.ruoyi.iot.lock.domain.LockAuthBatchRequest;
import com.ruoyi.iot.lock.domain.LockAuthBatchResult;
import com.ruoyi.iot.lock.domain.LockAuthPerson;
/**
* 授权服务
@@ -12,19 +15,67 @@ public interface ILockAuthService
{
List<LockAuth> selectAuthList(LockAuth auth);
/** 人员维度授权汇总:一人一行,含授权总数、已下发数、失败数与涉及门锁数 */
List<LockAuthPerson> selectPersonAuthList(LockAuthPerson query);
LockAuth selectAuthById(Long authId);
/** 外部授权分页列表:固定 source='2',带来源应用、门锁与入住单展示字段 */
List<LockAuth> selectExternalAuthList(LockAuth query);
/** 全部授权分页列表:一授权一行,不区分来源,便于逐条排查下发情况 */
List<LockAuth> selectAllAuthList(LockAuth query);
/** 外部授权详情(不含 auth_value) */
LockAuth selectExternalAuthById(Long authId);
/** 按应用校验外部授权归属;不属于该应用时返回 null */
LockAuth selectExternalAuthForApp(Long authId, String appCode);
/** 修改外部授权有效期:已取消只保存,其余保存后重推 */
int updateExternalAuth(Long authId, LockAuth changes);
/** 删除外部授权:先清除设备侧权限再逻辑删除;混入非外部授权整批拒绝 */
int deleteExternalAuthByIds(Long[] authIds);
List<LockAuth> selectAuthsByDeviceId(Long deviceId);
/** 新增授权并下发 */
int insertAuth(LockAuth auth);
/**
* 新增授权并下发。
*
* @param adoptPlatformSource true 表示覆盖平台已有授权时接管归属(外部入住/换房使用);
* false 表示拒绝覆盖平台授权(外部直接授权接口使用)
*/
int insertAuth(LockAuth auth, boolean adoptPlatformSource);
/**
* 多锁批量授权:同一人员、同一类型、同一有效期,为多把门锁各建一条授权。
* <p>
* 每行独立处理与下发,单锁失败不影响其它锁;已存在的同键授权按覆盖处理。
*
* @param request 授权模板与门锁ID集合
* @return 逐锁结果与汇总计数
*/
LockAuthBatchResult batchInsertAuth(LockAuthBatchRequest request);
/** 修改授权并重新下发 */
int updateAuth(LockAuth auth);
/** 删除授权并下发清除指令 */
int deleteAuthByIds(Long[] authIds);
/** 仅删除授权记录(逻辑删除),不向门锁下发清除;只允许设备侧已清除(4)的记录 */
int deleteAuthRecordsByIds(Long[] authIds);
/** 删除人员名下全部授权:逐条下发清除后逻辑删除,返回删除行数 */
int deleteAuthsByPersonNos(String[] personNos);
/** 按人员批量重新下发其名下可下发授权(跳过已清除与待清除) */
void batchPushByPersonNos(String[] personNos);
/** 手动重新下发 */
void push(Long authId);
@@ -23,8 +23,8 @@ public interface ILockCommandService
/**
* 处理门锁回执(最终结果)
* <p>
* 门锁回执(0/11/12)即终态,直接判定成功或失败;
* 网关回执(10)只是中间态,后到也不会推翻门锁结果。
* 门锁回执(0/12)即终态,直接判定成功或失败;
* 网关回执(10)只是中间态,后到也不会推翻门锁结果;网关忙(11)见 {@link #handleGatewayBusy}。
*
* @param topic 响应主题
* @param seq 报文序列号
@@ -46,6 +46,16 @@ public interface ILockCommandService
*/
String handleGatewayAck(String topic, String seq, String payload, String message);
/**
* 处理网关忙(结果码 11,报文未转发到门锁)
* <p>
* 命令回到待发送并排期重发,期间保持「在途」以阻止同网关下发下一条;
* 重排次数用尽才按失败终结。
*
* @return 重排中返回 null;按失败终结时返回 STATUS_FAIL
*/
String handleGatewayBusy(String topic, String seq, String payload, String message);
/**
* 标记超时命令:候选按最短超时粗筛,再按各命令类型的超时阈值逐条判定
*/
@@ -1,8 +1,10 @@
package com.ruoyi.iot.lock.service.impl;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.LinkedHashSet;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -14,6 +16,9 @@ import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.iot.lock.LockCommandPolicy;
import com.ruoyi.iot.lock.LockConstants;
import com.ruoyi.iot.lock.domain.LockAuth;
import com.ruoyi.iot.lock.domain.LockAuthBatchRequest;
import com.ruoyi.iot.lock.domain.LockAuthBatchResult;
import com.ruoyi.iot.lock.domain.LockAuthPerson;
import com.ruoyi.iot.lock.domain.LockCommand;
import com.ruoyi.iot.lock.domain.LockDevice;
import com.ruoyi.iot.lock.domain.LockFace;
@@ -59,6 +64,9 @@ public class LockAuthServiceImpl implements ILockAuthService
/** 整锁清空人脸时的人员编号占位:协议约定 00000000 表示删除该锁全部人脸 */
private static final String CLEAR_ALL_PERSON_NO = "00000000";
/** 单次批量授权的门锁上限:同网关串行下发,数量过大时等待时间过长 */
private static final int BATCH_MAX_DEVICES = 100;
@Autowired
private LockAuthMapper authMapper;
@@ -95,12 +103,101 @@ public class LockAuthServiceImpl implements ILockAuthService
return authMapper.selectAuthList(auth);
}
@Override
public List<LockAuthPerson> selectPersonAuthList(LockAuthPerson query)
{
return authMapper.selectPersonAuthList(query);
}
@Override
public LockAuth selectAuthById(Long authId)
{
return authMapper.selectAuthById(authId);
}
@Override
public List<LockAuth> selectExternalAuthList(LockAuth query)
{
return authMapper.selectExternalAuthList(query);
}
@Override
public List<LockAuth> selectAllAuthList(LockAuth query)
{
return authMapper.selectAllAuthList(query);
}
@Override
public LockAuth selectExternalAuthById(Long authId)
{
return authMapper.selectExternalAuthById(authId);
}
@Override
public LockAuth selectExternalAuthForApp(Long authId, String appCode)
{
if (authId == null || StringUtils.isEmpty(appCode))
{
return null;
}
return authMapper.selectExternalAuthForApp(authId, appCode);
}
@Override
@Transactional
public int updateExternalAuth(Long authId, LockAuth changes)
{
LockAuth auth = authMapper.selectAuthById(authId);
if (auth == null || !LockConstants.AUTH_SOURCE_OPENAPI.equals(auth.getSource())
|| "2".equals(auth.getDelFlag()))
{
throw new IllegalArgumentException("非外部授权,无法修改");
}
if (changes == null)
{
throw new IllegalArgumentException("修改内容不能为空");
}
if (changes.getBeginTime() != null && changes.getEndTime() != null
&& changes.getBeginTime().after(changes.getEndTime()))
{
throw new IllegalArgumentException("生效时间不能晚于失效时间");
}
auth.setBeginTime(changes.getBeginTime());
auth.setEndTime(changes.getEndTime());
auth.setRemark(changes.getRemark());
auth.setUpdateBy(changes.getUpdateBy());
int rows = authMapper.updateExternalAuth(auth);
// 已取消(4)没有设备侧权限,只保存;重新下发由页面单独触发
if (!LockConstants.SYNC_CLEARED.equals(auth.getSyncStatus()))
{
pushInternal(auth, true);
}
log.info("外部授权修改有效期, authId={}, beginTime={}, endTime={}", authId, changes.getBeginTime(),
changes.getEndTime());
return rows;
}
@Override
@Transactional
public int deleteExternalAuthByIds(Long[] authIds)
{
if (authIds == null || authIds.length == 0)
{
return 0;
}
// 整批校验:混入平台授权或已删除行时一条都不删
for (Long authId : authIds)
{
LockAuth auth = authMapper.selectAuthById(authId);
if (auth == null || !LockConstants.AUTH_SOURCE_OPENAPI.equals(auth.getSource())
|| "2".equals(auth.getDelFlag()))
{
throw new IllegalArgumentException("所选授权中包含非外部授权,已取消删除,请刷新后重试");
}
}
return deleteAuthByIds(authIds);
}
@Override
public List<LockAuth> selectAuthsByDeviceId(Long deviceId)
{
@@ -110,8 +207,29 @@ public class LockAuthServiceImpl implements ILockAuthService
@Override
@Transactional
public int insertAuth(LockAuth auth)
{
return doInsertAuth(auth, false);
}
@Override
@Transactional
public int insertAuth(LockAuth auth, boolean adoptPlatformSource)
{
return doInsertAuth(auth, adoptPlatformSource);
}
/**
* 单行新增/覆盖并下发。
* <p>
* 批量授权逐行直接调用本方法:不包整批事务,单锁失败不能回滚其它锁。
* 插入时 sync_status 固定写 '0',即使下发前异常,补偿任务仍能捞回该行。
*
* @param adoptPlatformSource 覆盖时是否允许外部入住/换房接管平台行归属
*/
private int doInsertAuth(LockAuth auth, boolean adoptPlatformSource)
{
fillAuth(auth);
applySourceDefaults(auth);
if (StringUtils.isEmpty(auth.getStatus()))
{
// 前端表单没有状态项;insert 显式带列时 null 不会走列默认值,
@@ -123,6 +241,7 @@ public class LockAuthServiceImpl implements ILockAuthService
LockAuth existing = findExistingAuth(auth);
if (existing != null)
{
applyOverwriteSource(auth, existing, adoptPlatformSource);
reviveAndPush(auth, existing);
return 1;
}
@@ -140,11 +259,205 @@ public class LockAuthServiceImpl implements ILockAuthService
{
throw e;
}
applyOverwriteSource(auth, conflict, adoptPlatformSource);
reviveAndPush(auth, conflict);
return 1;
}
}
/** 来源默认值:未指定即平台;非开放接口来源不保留应用与业务号 */
private void applySourceDefaults(LockAuth auth)
{
if (StringUtils.isEmpty(auth.getSource()))
{
auth.setSource(LockConstants.AUTH_SOURCE_PLATFORM);
}
if (!LockConstants.AUTH_SOURCE_OPENAPI.equals(auth.getSource()))
{
auth.setSourceAppCode(null);
auth.setSourceBizNo(null);
}
else if (StringUtils.isEmpty(auth.getSourceBizNo()))
{
// 业务号为空时统一落 NULL,避免与未传值产生两种“空”
auth.setSourceBizNo(null);
}
}
/**
* 覆盖已有授权行时的归属判定。
* <p>
* 平台覆盖不夺取外部归属;外部来源只允许同应用覆盖,跨应用拒绝;
* 外部入住/换房(adoptPlatformSource=true)可以接管平台行,直接授权接口不可以。
*/
private void applyOverwriteSource(LockAuth auth, LockAuth existing, boolean adoptPlatformSource)
{
if (!LockConstants.AUTH_SOURCE_OPENAPI.equals(auth.getSource()))
{
// 平台覆盖:reviveAuth 不改 source 列,外部归属自然保留
return;
}
String appCode = StringUtils.trim(auth.getSourceAppCode());
String existingSource = StringUtils.isEmpty(existing.getSource())
? LockConstants.AUTH_SOURCE_PLATFORM : existing.getSource();
String existingApp = StringUtils.trim(existing.getSourceAppCode());
if (LockConstants.AUTH_SOURCE_OPENAPI.equals(existingSource))
{
if (StringUtils.isNotEmpty(existingApp) && !existingApp.equals(appCode))
{
throw new IllegalArgumentException("该人员在此门锁上已有其他应用的授权");
}
}
else if (!adoptPlatformSource)
{
throw new IllegalArgumentException("该人员在此门锁上已有平台授权,请先在授权管理处理");
}
// 同应用外部行:刷新业务号;平台行被接管:写入来源应用
LockAuth sourceUpdate = new LockAuth();
sourceUpdate.setAuthId(existing.getAuthId());
sourceUpdate.setSource(LockConstants.AUTH_SOURCE_OPENAPI);
sourceUpdate.setSourceAppCode(appCode);
sourceUpdate.setSourceBizNo(auth.getSourceBizNo());
sourceUpdate.setUpdateBy(auth.getUpdateBy());
authMapper.updateAuthSource(sourceUpdate);
}
@Override
public LockAuthBatchResult batchInsertAuth(LockAuthBatchRequest request)
{
if (request == null)
{
throw new IllegalArgumentException("批量授权参数不能为空");
}
String personNo = StringUtils.trim(request.getPersonNo());
if (StringUtils.isEmpty(personNo))
{
throw new IllegalArgumentException("人员编号不能为空");
}
// 人员不存在时整批拒绝:没有姓名快照,生成 N 行也下发不了
if (personMapper.selectPersonByNo(personNo) == null)
{
throw new IllegalArgumentException("人员不存在,请先建立人员档案,人员编号=" + personNo);
}
String authType = StringUtils.trim(request.getAuthType());
if (StringUtils.isEmpty(authType))
{
throw new IllegalArgumentException("授权类型不能为空");
}
if (!LockConstants.AUTH_FACE.equals(authType) && !LockConstants.AUTH_PASSWORD.equals(authType)
&& !LockConstants.AUTH_FINGERPRINT.equals(authType) && !LockConstants.AUTH_IC_CARD.equals(authType))
{
throw new IllegalArgumentException("不支持的授权类型:" + authType);
}
if (request.getBeginTime() != null && request.getEndTime() != null
&& request.getBeginTime().after(request.getEndTime()))
{
throw new IllegalArgumentException("生效时间不能晚于失效时间");
}
LinkedHashSet<Long> deviceIds = new LinkedHashSet<Long>();
if (request.getDeviceIds() != null)
{
for (Long deviceId : request.getDeviceIds())
{
if (deviceId != null)
{
deviceIds.add(deviceId);
}
}
}
if (deviceIds.isEmpty())
{
throw new IllegalArgumentException("请选择授权门锁");
}
if (deviceIds.size() > BATCH_MAX_DEVICES)
{
throw new IllegalArgumentException("单次最多授权 " + BATCH_MAX_DEVICES + " 把门锁");
}
boolean face = LockConstants.AUTH_FACE.equals(authType);
if (!face && StringUtils.isEmpty(request.getAuthValue()))
{
throw new IllegalArgumentException("授权内容不能为空");
}
LockAuthBatchResult result = new LockAuthBatchResult();
result.setTotal(deviceIds.size());
List<LockAuthBatchResult.Item> items = new ArrayList<LockAuthBatchResult.Item>();
int created = 0;
int overwritten = 0;
int failed = 0;
for (Long deviceId : deviceIds)
{
LockAuthBatchResult.Item item = new LockAuthBatchResult.Item();
item.setDeviceId(deviceId);
LockDevice device = deviceMapper.selectDeviceById(deviceId);
if (device == null)
{
item.setAction(LockAuthBatchResult.ACTION_FAILED);
item.setSyncMsg("门锁不存在,无法授权");
failed++;
items.add(item);
continue;
}
item.setDeviceSn(device.getDeviceSn());
item.setDeviceName(device.getDeviceName());
LockAuth auth = null;
try
{
auth = new LockAuth();
auth.setPersonNo(personNo);
auth.setAuthType(authType);
// 人脸特征由下发时按各锁算法版本取,模板值一律不落库,避免跨版本错配
auth.setAuthValue(face ? null : request.getAuthValue());
auth.setBeginTime(request.getBeginTime());
auth.setEndTime(request.getEndTime());
auth.setRemark(request.getRemark());
auth.setDeviceId(deviceId);
boolean existed = findExistingAuth(auth) != null;
doInsertAuth(auth, false);
item.setAuthId(auth.getAuthId());
item.setAction(existed ? LockAuthBatchResult.ACTION_OVERWRITTEN
: LockAuthBatchResult.ACTION_CREATED);
LockAuth saved = authMapper.selectAuthById(auth.getAuthId());
if (saved != null)
{
item.setSyncStatus(saved.getSyncStatus());
item.setSyncMsg(saved.getSyncMsg());
}
if (existed)
{
overwritten++;
}
else
{
created++;
}
}
catch (Exception e)
{
String message = StringUtils.substring(e.getMessage(), 0, 500);
if (auth != null && auth.getAuthId() != null)
{
// 行已落库但下发抛异常:置失败交给补偿重发,避免停在「已下发」不再重试
authMapper.updateSyncStatus(auth.getAuthId(), LockConstants.SYNC_FAIL, message);
item.setAuthId(auth.getAuthId());
}
item.setAction(LockAuthBatchResult.ACTION_FAILED);
item.setSyncMsg(message);
failed++;
log.warn("批量授权单锁处理失败, personNo={}, deviceId={}, msg={}", personNo, deviceId,
e.getMessage());
}
items.add(item);
}
result.setItems(items);
result.setCreated(created);
result.setOverwritten(overwritten);
result.setFailed(failed);
log.info("多锁批量授权完成, personNo={}, authType={}, total={}, created={}, overwritten={}, failed={}",
personNo, authType, result.getTotal(), created, overwritten, failed);
return result;
}
private LockAuth findExistingAuth(LockAuth auth)
{
return authMapper.selectAuthByUniqueKey(auth.getPersonNo(), auth.getDeviceId(), auth.getAuthType());
@@ -206,6 +519,83 @@ public class LockAuthServiceImpl implements ILockAuthService
return authMapper.deleteAuthByIds(authIds);
}
@Override
@Transactional
public int deleteAuthRecordsByIds(Long[] authIds)
{
if (authIds == null || authIds.length == 0)
{
return 0;
}
for (Long authId : authIds)
{
LockAuth auth = authMapper.selectAuthById(authId);
if (auth == null)
{
continue;
}
if (!LockConstants.SYNC_CLEARED.equals(auth.getSyncStatus()))
{
// 设备侧还有权限时删记录,会在门锁上留下无法追溯的开门凭据
throw new IllegalArgumentException("授权[" + auth.getAuthNo() + "]设备侧未清除,请使用删除授权");
}
}
return authMapper.deleteAuthByIds(authIds);
}
@Override
@Transactional
public int deleteAuthsByPersonNos(String[] personNos)
{
String[] normalized = normalizePersonNos(personNos);
if (normalized.length == 0)
{
return 0;
}
List<Long> authIds = authMapper.selectAuthIdsByPersonNos(normalized);
if (authIds.isEmpty())
{
return 0;
}
// 复用逐条删除:先向门锁下发清除,再逻辑删除,保证设备侧权限一并回收
return deleteAuthByIds(authIds.toArray(new Long[authIds.size()]));
}
@Override
public void batchPushByPersonNos(String[] personNos)
{
String[] normalized = normalizePersonNos(personNos);
if (normalized.length == 0)
{
return;
}
List<Long> authIds = authMapper.selectPushableAuthIdsByPersonNos(normalized);
if (authIds.isEmpty())
{
return;
}
batchPush(authIds.toArray(new Long[authIds.size()]));
}
/** 去掉空白人员编号并去重,避免拼出空的 IN 条件 */
private String[] normalizePersonNos(String[] personNos)
{
if (personNos == null || personNos.length == 0)
{
return new String[0];
}
LinkedHashSet<String> unique = new LinkedHashSet<String>();
for (String personNo : personNos)
{
String trimmed = StringUtils.trim(personNo);
if (StringUtils.isNotEmpty(trimmed))
{
unique.add(trimmed);
}
}
return unique.toArray(new String[unique.size()]);
}
@Override
public void push(Long authId)
{
@@ -380,9 +770,9 @@ public class LockAuthServiceImpl implements ILockAuthService
}
if (!success && LockCommandPolicy.isBusy(message))
{
// 设备忙是瞬时状态,由命令层 3 秒后重发;这里若把授权置失败,
// 授权补偿会再建一条命令,同一张脸被重复下发
return;
// 网关忙在命令层已排队重发,不会走到这里;能走到的是重排次数用尽的终态,
// 此时必须把授权置失败,交给补偿重新建档,否则授权会停在「已下发」无人跟进
log.warn("授权命令因网关忙终结, authId={}, msg={}", auth.getAuthId(), message);
}
String syncStatus;
if (LockConstants.CMD_AUTH_CLEAR.equals(command.getCommandType()))
@@ -557,6 +947,12 @@ public class LockAuthServiceImpl implements ILockAuthService
return;
}
Long groupId = target.getDeviceRef().getGroupId();
if (commandMapper.countInflightAuthByBizId(auth.getAuthId()) > 0)
{
// 同一授权已有命令在途(含等待门锁终态、网关忙排队),补偿不能另建一条
log.info("授权已有在途命令,本轮跳过, authId={}, groupId={}", auth.getAuthId(), groupId);
return;
}
if (commandMapper.countInflightAuthCommands(groupId) > 0)
{
// 网关同一时刻只处理一条 data/batch,并发下发会直接回 gw busy。
@@ -702,7 +1098,7 @@ public class LockAuthServiceImpl implements ILockAuthService
* </pre>
*
* @param add true 下发授权(add 动作),false 清除授权(delete 动作)
* @param authValue 实际下发的授权内容(人脸已按门锁算法版本选值),删除帧不使用
* @param authValue 实际下发的授权内容(人脸已按门锁算法版本选值);密码/IC卡删除时用它定位凭据
*/
private YdlinkFrameDTO buildAuthFrame(LockAuth auth, LockDevice device, RouteTarget target, boolean add,
String authValue)
@@ -720,6 +1116,9 @@ public class LockAuthServiceImpl implements ILockAuthService
String value = StringUtils.defaultString(authValue);
// 人脸帧比密码等帧多一个「授权锁标识」位(与人员编号同值),长度不同不可混用
boolean face = YdlinkOp.FACE_FEATURE.equals(op);
// 只有人脸/指纹按人员编号建索引;密码与 IC 卡以凭据本身标识,
// 帧里不能塞人员编号,否则设备侧按人员找不到对应凭据
boolean personIdAuth = face || YdlinkOp.FINGERPRINT.equals(op);
if (add)
{
if (face)
@@ -728,18 +1127,32 @@ public class LockAuthServiceImpl implements ILockAuthService
frame.setFlatParams(Arrays.asList("add", personNo, value, personNo,
format(auth.getBeginTime()), format(auth.getEndTime()), deviceSn, devicePsw));
}
else if (personIdAuth)
{
// 指纹:[动作, 人员编号, 特征值, 开始时间, 结束时间, 门锁SN, 门锁密码]
frame.setFlatParams(Arrays.asList("add", personNo, value,
format(auth.getBeginTime()), format(auth.getEndTime()), deviceSn, devicePsw));
}
else
{
// 密码/指纹/IC卡:[动作, 人员编号, 授权内容, 开始时间, 结束时间, 门锁SN, 门锁密码]
frame.setFlatParams(Arrays.asList("add", personNo, value,
// 密码/IC卡:[动作, 凭据, 凭据, 开始时间, 结束时间, 门锁SN, 门锁密码]
frame.setFlatParams(Arrays.asList("add", value, value,
format(auth.getBeginTime()), format(auth.getEndTime()), deviceSn, devicePsw));
}
}
else
{
// 删除帧只需定位「删除谁 + 哪把锁」:["delete", 人员编号, 门锁SN, 门锁密码]
// 实测人脸删除报文即此 4 项,不携带特征值与时间
frame.setFlatParams(Arrays.asList("delete", personNo, deviceSn, devicePsw));
if (personIdAuth)
{
// 删除帧只需定位「删除谁 + 哪把锁」:["delete", 人员编号, 门锁SN, 门锁密码]
// 实测人脸删除报文即此 4 项,不携带特征值与时间
frame.setFlatParams(Arrays.asList("delete", personNo, deviceSn, devicePsw));
}
else
{
// 密码/IC卡删除比人脸多一个凭据位(协议 5 项),用凭据定位要删的条目
frame.setFlatParams(Arrays.asList("delete", value, value, deviceSn, devicePsw));
}
}
return frame;
}
@@ -2,7 +2,9 @@ package com.ruoyi.iot.lock.service.impl;
import java.util.Calendar;
import java.util.Date;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
@@ -174,6 +176,52 @@ public class LockCommandServiceImpl implements ILockCommandService
return null;
}
@Override
@Transactional
public String handleGatewayBusy(String topic, String seq, String payload, String message)
{
if (StringUtils.isEmpty(seq))
{
return null;
}
LockCommand command = commandMapper.selectCommandByNo(seq);
if (command == null)
{
log.warn("未匹配到命令, seq={}, topic={}", seq, topic);
return null;
}
if (isFinished(command))
{
// 已有终态(例如 0 先到),迟到的网关忙不能改写结果
return null;
}
if (LockConstants.ACK_YES.equals(command.getGatewayAck()))
{
// 已收到 10 说明报文已转发,此后再来的忙响应不能推翻转发事实,继续等门锁终态
log.warn("已收到网关回执后又收到忙响应,保持等待门锁终态, seq={}", seq);
return null;
}
command.setRespTopic(topic);
command.setRespPayload(YdlinkPayloadMasker.maskPayload(payload));
command.setRespTime(new Date());
Integer retryCount = command.getRetryCount() == null ? 0 : command.getRetryCount();
String reason = StringUtils.isEmpty(message) ? "网关忙" : message;
if (LockCommandPolicy.isRetryDisabled(command.getCommandType())
|| retryCount >= LockCommandPolicy.maxBusyRetry(command.getCommandType()))
{
return finish(command, false, reason + ",重排次数已用尽");
}
// 11 表示报文未转发到门锁:回到待发送并排队,但仍算在途,同网关后续命令不得插队
command.setCommandStatus(LockConstants.STATUS_PENDING);
command.setRetryCount(retryCount + 1);
command.setNextRetryTime(LockCommandPolicy.busyRetryTime());
command.setErrorMsg(StringUtils.substring(reason, 0, 500));
commandMapper.updateCommand(command);
log.info("网关忙,命令排队重发, seq={}, commandType={}, retryCount={}", seq, command.getCommandType(),
retryCount + 1);
return null;
}
/**
* 命令是否已有终态结果
*/
@@ -225,16 +273,6 @@ public class LockCommandServiceImpl implements ILockCommandService
}
}
/**
* 授权类命令是否发到分组主题:同一网关内只能串行,并发会被回 gw busy
*/
private boolean isGroupAuthCommand(LockCommand command)
{
return LockConstants.TARGET_GROUP.equals(command.getTargetType())
&& (LockConstants.CMD_AUTH_PUSH.equals(command.getCommandType())
|| LockConstants.CMD_AUTH_CLEAR.equals(command.getCommandType()));
}
/**
* 授权命令终结为超时时回写授权同步状态
* <p>
@@ -287,8 +325,24 @@ public class LockCommandServiceImpl implements ILockCommandService
{
continue;
}
long timeoutMillis = LockCommandPolicy.timeoutSeconds(command.getCommandType()) * 1000L;
if (command.getReqTime().getTime() + timeoutMillis > now)
// 网关已回 10 表示报文已转发,此后只等门锁终态:从网关回执起算独立等待时长,
// 期间既不重发也不判失败,否则会把正在执行/已成功的授权重复下发或提前放行下一条
boolean waitingDevice = LockConstants.ACK_YES.equals(command.getGatewayAck())
&& !LockConstants.ACK_YES.equals(command.getDeviceAck());
long baseTime;
long timeoutMillis;
if (waitingDevice)
{
baseTime = command.getRespTime() == null ? command.getReqTime().getTime()
: command.getRespTime().getTime();
timeoutMillis = LockCommandPolicy.deviceWaitSeconds(command.getCommandType()) * 1000L;
}
else
{
baseTime = command.getReqTime().getTime();
timeoutMillis = LockCommandPolicy.timeoutSeconds(command.getCommandType()) * 1000L;
}
if (baseTime + timeoutMillis > now)
{
// 尚未到期,下一轮再判
continue;
@@ -297,7 +351,13 @@ public class LockCommandServiceImpl implements ILockCommandService
Integer retryCount = command.getRetryCount() == null ? 0 : command.getRetryCount();
LockCommand update = new LockCommand();
update.setCommandId(command.getCommandId());
if (LockCommandPolicy.isRetryDisabled(command.getCommandType()))
if (waitingDevice)
{
// 已转发到门锁:不再重发,等不到终态即判超时,由授权补偿决定是否重新建档下发
update.setCommandStatus(LockConstants.STATUS_TIMEOUT);
update.setErrorMsg("网关已接收,等待门锁回执超时");
}
else if (LockCommandPolicy.isRetryDisabled(command.getCommandType()))
{
// 该类命令不自动重发,超时即终态
update.setCommandStatus(LockConstants.STATUS_TIMEOUT);
@@ -328,25 +388,87 @@ public class LockCommandServiceImpl implements ILockCommandService
@Override
public int retryPending()
{
LockCommand query = new LockCommand();
query.setCommandStatus(LockConstants.STATUS_FAIL);
List<LockCommand> commands = commandMapper.selectCommandList(query);
int count = 0;
Date now = new Date();
for (LockCommand command : commands)
// 1) 网关忙排队的命令:每轮每个主题(同网关)只放行一条,且网关无已发送命令时才重发,
// 避免多条排队命令互相等待或同时挤进网关
Set<String> republishedTopics = new HashSet<String>();
for (LockCommand command : commandMapper.selectDeferredCommands(now))
{
String topic = command.getTopic();
if (topic == null || republishedTopics.contains(topic))
{
continue;
}
if (LockCommandPolicy.isRetryDisabled(command.getCommandType()))
{
continue;
}
Integer busyRetry = command.getRetryCount() == null ? 0 : command.getRetryCount();
if (busyRetry >= LockCommandPolicy.maxBusyRetry(command.getCommandType()))
{
LockCommand expired = new LockCommand();
expired.setCommandId(command.getCommandId());
expired.setCommandStatus(LockConstants.STATUS_TIMEOUT);
expired.setErrorMsg("网关持续忙,超过最大重排次数");
commandMapper.updateCommand(expired);
markAuthTerminal(command, expired.getErrorMsg());
continue;
}
if (commandMapper.countOtherSentByTopic(topic, command.getCommandId()) > 0)
{
// 同网关已有命令在发送/等待门锁终态,等它终结再重发
continue;
}
if (republish(command, busyRetry))
{
republishedTopics.add(topic);
count++;
}
}
// 2) 发送失败或等待超时的命令按退避重发
LockCommand query = new LockCommand();
query.setCommandStatus(LockConstants.STATUS_FAIL);
for (LockCommand command : commandMapper.selectCommandList(query))
{
if (command.getNextRetryTime() == null || command.getNextRetryTime().after(now))
{
continue;
}
boolean waitingDevice = LockConstants.ACK_YES.equals(command.getGatewayAck())
&& !LockConstants.ACK_YES.equals(command.getDeviceAck());
if (waitingDevice)
{
// 已收到网关 10:历史数据被旧逻辑判成失败后不能重发,回到等待态由超时判定收口
long base = command.getRespTime() != null ? command.getRespTime().getTime()
: (command.getReqTime() == null ? now.getTime() : command.getReqTime().getTime());
long waitMillis = LockCommandPolicy.deviceWaitSeconds(command.getCommandType()) * 1000L;
if (base + waitMillis <= now.getTime())
{
LockCommand expired = new LockCommand();
expired.setCommandId(command.getCommandId());
expired.setCommandStatus(LockConstants.STATUS_TIMEOUT);
expired.setErrorMsg("网关已接收,等待门锁回执超时");
commandMapper.updateCommand(expired);
markAuthTerminal(command, expired.getErrorMsg());
}
else
{
LockCommand waiting = new LockCommand();
waiting.setCommandId(command.getCommandId());
waiting.setCommandStatus(LockConstants.STATUS_SENT);
commandMapper.updateCommand(waiting);
}
continue;
}
if (LockCommandPolicy.isRetryDisabled(command.getCommandType()))
{
// 时间同步、远程开关锁失败后不自动重发
continue;
}
if (isGroupAuthCommand(command) && commandMapper.countInflightAuthCommands(command.getTargetId()) > 0)
if (commandMapper.countOtherInflightByTopic(command.getTopic(), command.getCommandId()) > 0)
{
// 同网关仍有在途授权命令,本轮跳过,避免再次撞上 gw busy
// 同网关仍有在途命令,本轮跳过,避免再次撞上 gw busy
continue;
}
Integer maxRetry = command.getMaxRetry() == null ? DEFAULT_MAX_RETRY : command.getMaxRetry();
@@ -362,31 +484,45 @@ public class LockCommandServiceImpl implements ILockCommandService
markAuthTerminal(command, expired.getErrorMsg());
continue;
}
LockCommand update = new LockCommand();
update.setCommandId(command.getCommandId());
update.setRetryCount(retryCount + 1);
update.setNextRetryTime(LockCommandPolicy.nextRetryTime(retryCount + 1));
// 先置已发送再发布,避免应答先到被覆盖
update.setCommandStatus(LockConstants.STATUS_SENT);
update.setReqTime(now);
commandMapper.updateCommand(update);
try
if (republish(command, retryCount + 1))
{
mqttService.publish(command.getTopic(), command.getPayload(), 1, false);
count++;
}
catch (Exception e)
{
LockCommand failed = new LockCommand();
failed.setCommandId(command.getCommandId());
failed.setCommandStatus(LockConstants.STATUS_FAIL);
failed.setErrorMsg(StringUtils.substring(e.getMessage(), 0, 500));
commandMapper.updateCommand(failed);
}
}
return count;
}
/**
* 重发已落库的原始报文(seq/payload 不变),先把状态置为已发送再发布,避免应答先到被覆盖
*/
private boolean republish(LockCommand command, int retryCount)
{
LockCommand update = new LockCommand();
update.setCommandId(command.getCommandId());
update.setRetryCount(retryCount);
update.setNextRetryTime(LockCommandPolicy.nextRetryTime(retryCount));
update.setCommandStatus(LockConstants.STATUS_SENT);
update.setReqTime(new Date());
// 清掉上一次尝试的回执标记:否则旧的 deviceAck/deviceResult 会被当成这次的结果
update.setGatewayAck(LockConstants.ACK_NO);
update.setDeviceAck(LockConstants.ACK_NO);
commandMapper.updateCommand(update);
try
{
mqttService.publish(command.getTopic(), command.getPayload(), 1, false);
return true;
}
catch (Exception e)
{
LockCommand failed = new LockCommand();
failed.setCommandId(command.getCommandId());
failed.setCommandStatus(LockConstants.STATUS_FAIL);
failed.setErrorMsg(StringUtils.substring(e.getMessage(), 0, 500));
commandMapper.updateCommand(failed);
return false;
}
}
@Override
@Transactional
public void retry(Long commandId)
@@ -354,15 +354,25 @@ public class LockGatewayServiceImpl implements ILockGatewayService
{
return;
}
if (LockConstants.OFFLINE.equals(onlineStatus))
{
// 离线通知不动 last_online_time,保留最后一次真正在线的时刻
gatewayMapper.updateOnlineStatusOnly(gateway.getGatewayId(), onlineStatus);
return;
}
gatewayMapper.updateOnlineStatus(gateway.getGatewayId(), onlineStatus, new Date());
}
@Override
@Transactional
public int markOffline(int offlineMinutes)
{
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.MINUTE, -Math.max(offlineMinutes, 1));
int count = gatewayMapper.markOfflineBefore(calendar.getTime(), new Date());
Date offlineTime = calendar.getTime();
// 先按归属行的最近上报刷新在线:网关转发门锁数据即证明在线,不能只依赖 onoff_line 连接事件
gatewayMapper.refreshOnlineByReport(offlineTime);
int count = gatewayMapper.markOfflineBefore(offlineTime, new Date());
// 网关离线后,其视角下的门锁归属状态同步置离线;其它网关的归属不受影响
relationMapper.markOfflineByOfflineGateways();
return count;
@@ -83,6 +83,9 @@ public class LockStayServiceImpl implements ILockStayService
/** 单据来源:平台办理 */
private static final String STAY_SOURCE_PLATFORM = "1";
/** 单据来源:开放接口办理 */
private static final String STAY_SOURCE_API = "2";
/** 删除标志:已删除 */
private static final String DEL_FLAG_DELETED = "2";
@@ -363,6 +366,8 @@ public class LockStayServiceImpl implements ILockStayService
}
Date now = new Date();
String stayNo = nextStayNo();
String staySource = StringUtils.isEmpty(oldStay.getSource()) ? STAY_SOURCE_PLATFORM : oldStay.getSource();
boolean external = STAY_SOURCE_API.equals(staySource);
LockAuth auth = new LockAuth();
auth.setPersonNo(person.getPersonNo());
auth.setPersonName(person.getPersonName());
@@ -371,7 +376,11 @@ public class LockStayServiceImpl implements ILockStayService
auth.setBeginTime(now);
auth.setEndTime(newEnd);
auth.setRemark("换房 " + stayNo);
authService.insertAuth(auth);
// 换房新授权继承原单来源应用;biz_no 不继承((biz_system,biz_no) 唯一),业务号留空
auth.setSource(staySource);
auth.setSourceAppCode(external ? oldStay.getBizSystem() : null);
auth.setSourceBizNo(null);
authService.insertAuth(auth, external);
LockStay newStay = new LockStay();
newStay.setStayNo(stayNo);
@@ -704,6 +713,11 @@ public class LockStayServiceImpl implements ILockStayService
}
String stayNo = nextStayNo();
String bizNo = StringUtils.trim(body.getBizNo());
String bizSystem = StringUtils.trim(body.getBizSystem());
String source = StringUtils.isEmpty(StringUtils.trim(body.getSource())) ? STAY_SOURCE_PLATFORM
: StringUtils.trim(body.getSource());
boolean external = STAY_SOURCE_API.equals(source);
LockAuth auth = new LockAuth();
auth.setPersonNo(person.getPersonNo());
auth.setPersonName(person.getPersonName());
@@ -714,7 +728,11 @@ public class LockStayServiceImpl implements ILockStayService
auth.setBeginTime(begin);
auth.setEndTime(body.getEndTime());
auth.setRemark("入住 " + stayNo);
authService.insertAuth(auth);
// 授权归属随入住单来源;外部入住写入应用与业务号,便于页面按应用追溯
auth.setSource(source);
auth.setSourceAppCode(external && StringUtils.isNotEmpty(bizSystem) ? bizSystem : null);
auth.setSourceBizNo(external && StringUtils.isNotEmpty(bizNo) ? bizNo : null);
authService.insertAuth(auth, external);
LockStay stay = new LockStay();
stay.setStayNo(stayNo);
@@ -733,15 +751,12 @@ public class LockStayServiceImpl implements ILockStayService
stay.setStayStatus(STAY_STATUS_ACTIVE);
stay.setAuthStatus(LockConstants.SYNC_PENDING);
stay.setRemark(body.getRemark());
String bizNo = StringUtils.trim(body.getBizNo());
String bizSystem = StringUtils.trim(body.getBizSystem());
// uk_lock_stay_biz(biz_system, biz_no) 只允许多行 NULL,空串会互相撞键,
// 平台手工单必须落 NULL,否则第二次办理入住即 Duplicate entry ''
stay.setBizNo(StringUtils.isEmpty(bizNo) ? null : bizNo);
stay.setBizSystem(StringUtils.isEmpty(bizSystem) ? null : bizSystem);
// insertStay 显式列出 source 列,为空会写入 NULL 而不是列默认值 '1',这里补默认
stay.setSource(StringUtils.isEmpty(StringUtils.trim(body.getSource())) ? STAY_SOURCE_PLATFORM
: StringUtils.trim(body.getSource()));
stay.setSource(source);
stayMapper.insertStay(stay);
// 授权可能因门锁无路由/缺特征在创建时即失败,回读真实状态写入入住单
@@ -9,6 +9,9 @@ import com.ruoyi.iot.lock.service.ILockGatewayService;
/**
* 网关离线检测:超过指定时间未上报即置为离线
* <p>
* 在线来源有两个:网关级 onoff_line 连接通知,以及该网关归属门锁的周期上报(lockrssi 每分钟一次)。
* 二者任一在窗口内出现即视为在线,因此检测周期取 1 分钟,与门锁上报节奏对齐。
*
* @author ruoyi
*/
@@ -31,7 +34,7 @@ public class GatewayOfflineCheckTask
@Autowired
private DistributedTaskLock taskLock;
@Scheduled(initialDelay = 60000L, fixedDelay = 300000L)
@Scheduled(initialDelay = 30000L, fixedDelay = 60000L)
public void execute()
{
DistributedTaskLock.LockHandle lock = taskLock.tryLock(LOCK_NAME, LOCK_TTL_SECONDS);
@@ -33,8 +33,8 @@ public class LockCommandRetryTask
private DistributedTaskLock taskLock;
/**
* 5 秒一轮:gw busy 重排 3 秒、退避首档 5 秒,排队的授权命令也靠这一轮接着下发,
* 周期过长会让并发下发的第二张脸实际延迟到十几秒以上
* 5 秒一轮:gw busy 重排 8 秒,排队的授权命令也靠这一轮接着下发;
* 同一网关严格串行,宁可慢一点,也要等上一条拿到门锁终态再发下一条
*/
@Scheduled(initialDelay = 5000L, fixedDelay = 5000L)
public void execute()
@@ -3,6 +3,7 @@ package com.ruoyi.iot.openapi.controller;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
@@ -23,6 +24,7 @@ import com.ruoyi.iot.lock.service.ILockAuthService;
import com.ruoyi.iot.lock.service.ILockDeviceService;
import com.ruoyi.iot.lock.service.ILockFaceService;
import com.ruoyi.iot.openapi.domain.OpenAuthRequest;
import com.ruoyi.iot.openapi.filter.OpenApiAuthFilter;
/**
* 开放接口-门禁授权
@@ -51,8 +53,13 @@ public class OpenAuthApiController
*/
@Anonymous
@PostMapping
public AjaxResult add(@RequestBody OpenAuthRequest request)
public AjaxResult add(@RequestBody OpenAuthRequest request, HttpServletRequest httpRequest)
{
String appCode = resolveAppCode(httpRequest);
if (StringUtils.isEmpty(appCode))
{
return AjaxResult.error("应用身份缺失");
}
if (request == null || StringUtils.isEmpty(request.getPersonNo()))
{
return AjaxResult.error("人员编号不能为空");
@@ -81,6 +88,10 @@ public class OpenAuthApiController
auth.setDeviceId(deviceId);
auth.setAuthType(request.getAuthType());
auth.setRemark(request.getRemark());
// 直接授权接口的角色由 API Key 决定:归属当前应用,遇到平台行/其他应用行拒绝覆盖
auth.setSource(LockConstants.AUTH_SOURCE_OPENAPI);
auth.setSourceAppCode(appCode);
auth.setSourceBizNo(StringUtils.substring(StringUtils.trim(request.getBizNo()), 0, 64));
try
{
auth.setBeginTime(parseTime(request.getBeginTime()));
@@ -155,9 +166,10 @@ public class OpenAuthApiController
*/
@Anonymous
@PostMapping("/{authId}/push")
public AjaxResult push(@PathVariable Long authId)
public AjaxResult push(@PathVariable Long authId, HttpServletRequest httpRequest)
{
if (authService.selectAuthById(authId) == null)
// 跨应用一律按不存在处理,不暴露其他应用的数据
if (authService.selectExternalAuthForApp(authId, resolveAppCode(httpRequest)) == null)
{
return AjaxResult.error("授权不存在,authId=" + authId);
}
@@ -170,9 +182,9 @@ public class OpenAuthApiController
*/
@Anonymous
@PostMapping("/{authId}/clear")
public AjaxResult clear(@PathVariable Long authId)
public AjaxResult clear(@PathVariable Long authId, HttpServletRequest httpRequest)
{
if (authService.selectAuthById(authId) == null)
if (authService.selectExternalAuthForApp(authId, resolveAppCode(httpRequest)) == null)
{
return AjaxResult.error("授权不存在,authId=" + authId);
}
@@ -185,9 +197,9 @@ public class OpenAuthApiController
*/
@Anonymous
@DeleteMapping("/{authId}")
public AjaxResult remove(@PathVariable Long authId)
public AjaxResult remove(@PathVariable Long authId, HttpServletRequest httpRequest)
{
if (authService.selectAuthById(authId) == null)
if (authService.selectExternalAuthForApp(authId, resolveAppCode(httpRequest)) == null)
{
return AjaxResult.error("授权不存在,authId=" + authId);
}
@@ -196,13 +208,33 @@ public class OpenAuthApiController
}
/**
* 查询授权
* 查询授权:只返回本应用下发的授权,不返回授权凭据
*/
@Anonymous
@GetMapping("/list")
public AjaxResult list(LockAuth auth)
public AjaxResult list(LockAuth auth, HttpServletRequest httpRequest)
{
return AjaxResult.success(authService.selectAuthList(auth));
String appCode = resolveAppCode(httpRequest);
if (StringUtils.isEmpty(appCode))
{
// 没有应用身份时查询条件会退化为“全部外部授权”,必须直接拒绝
return AjaxResult.error("应用身份缺失");
}
auth.setSource(LockConstants.AUTH_SOURCE_OPENAPI);
auth.setSourceAppCode(appCode);
auth.setAuthValue(null);
if (StringUtils.isEmpty(auth.getSyncStatus()))
{
// 开放接口不套用平台页“默认隐藏已取消”的规则,调用方可据列表对账
auth.setSyncStatus("ALL");
}
return AjaxResult.success(authService.selectExternalAuthList(auth));
}
private String resolveAppCode(HttpServletRequest request)
{
Object value = request.getAttribute(OpenApiAuthFilter.ATTR_APP_CODE);
return value == null ? null : String.valueOf(value);
}
private Date parseTime(String value) throws ParseException
@@ -40,6 +40,9 @@ public class OpenAuthRequest implements Serializable
/** 失效时间 yyyy-MM-dd HH:mm:ss,留空表示永久有效 */
private String endTime;
/** 来源业务单号(可空,≤64,用于平台按单号追溯) */
private String bizNo;
private String remark;
public String getPersonNo()
@@ -112,6 +115,16 @@ public class OpenAuthRequest implements Serializable
this.endTime = endTime;
}
public String getBizNo()
{
return bizNo;
}
public void setBizNo(String bizNo)
{
this.bizNo = bizNo;
}
public String getRemark()
{
return remark;
@@ -25,6 +25,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<result property="remark" column="remark" />
</resultMap>
<!-- 平台页面列表/详情额外带房间号;开放接口与住宿详情不查 room_no,继续用上面的基础 resultMap -->
<resultMap type="LockAccessLog" id="LockAccessLogRoomResult" extends="LockAccessLogResult">
<result property="roomNo" column="room_no" />
</resultMap>
<sql id="selectAccessLogVo">
select log_id, device_id, device_sn, gateway_id, gateway_sn, group_id, person_no, person_name,
card_no, open_type, open_result, open_dir, open_time, recv_time, seq, raw_params,
@@ -19,6 +19,13 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<result property="beginTime" column="begin_time" />
<result property="endTime" column="end_time" />
<result property="authTime" column="auth_time" />
<result property="source" column="source" />
<result property="sourceAppCode" column="source_app_code" />
<result property="sourceBizNo" column="source_biz_no" />
<result property="sourceAppName" column="source_app_name" />
<result property="stayNo" column="stay_no" />
<result property="deviceName" column="device_name" />
<result property="onlineStatus" column="online_status" />
<result property="syncStatus" column="sync_status" />
<result property="syncTime" column="sync_time" />
<result property="syncMsg" column="sync_msg" />
@@ -32,9 +39,24 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<result property="remark" column="remark" />
</resultMap>
<resultMap type="LockAuthPerson" id="LockAuthPersonResult">
<result property="personNo" column="person_no" />
<result property="personName" column="person_name" />
<result property="authTypes" column="auth_types" />
<result property="authCount" column="auth_count" />
<result property="deviceCount" column="device_count" />
<result property="issuedCount" column="issued_count" />
<result property="pendingCount" column="pending_count" />
<result property="failedCount" column="failed_count" />
<result property="beginTime" column="begin_time" />
<result property="endTime" column="end_time" />
<result property="updateTime" column="update_time" />
</resultMap>
<sql id="selectAuthVo">
select auth_id, auth_no, person_no, person_name, device_id, device_sn, gateway_id, group_id,
auth_type, auth_value, auth_value_md5, begin_time, end_time, auth_time, sync_status,
auth_type, auth_value, auth_value_md5, begin_time, end_time, auth_time,
source, source_app_code, source_biz_no, sync_status,
sync_time, sync_msg, retry_count, status, del_flag, create_by, create_time,
update_by, update_time, remark from lock_auth
</sql>
@@ -67,6 +89,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="syncStatus != null and syncStatus != ''">
and sync_status = #{syncStatus}
</if>
<if test="source != null and source != ''">
and source = #{source}
</if>
<if test="status != null and status != ''">
and status = #{status}
</if>
@@ -80,11 +105,206 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
order by auth_id desc
</select>
<!--
人员维度聚合:一人一行。已清除(4)的授权不再有设备侧权限,不计入;
同步状态过滤作用于参与统计的行,便于「只看下发失败的授权」这类排查。
-->
<select id="selectPersonAuthList" parameterType="LockAuthPerson" resultMap="LockAuthPersonResult">
select a.person_no,
max(a.person_name) as person_name,
count(1) as auth_count,
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,
min(a.begin_time) as begin_time,
max(a.end_time) as end_time,
max(a.update_time) as update_time,
group_concat(distinct a.auth_type order by a.auth_type separator ',') as auth_types
from lock_auth a
where a.del_flag = '0'
and a.source = '1'
and (a.sync_status is null or a.sync_status &lt;&gt; '4')
<if test="keyword != null and keyword != ''">
and (a.person_no like concat('%', #{keyword}, '%') or a.person_name like concat('%', #{keyword}, '%'))
</if>
<if test="deviceId != null">
and a.device_id = #{deviceId}
</if>
<if test="groupId != null">
and a.group_id = #{groupId}
</if>
<if test="authType != null and authType != ''">
and a.auth_type = #{authType}
</if>
<if test="syncStatus != null and syncStatus != ''">
and a.sync_status = #{syncStatus}
</if>
group by a.person_no
order by max(a.update_time) desc, a.person_no
</select>
<!-- 平台页签按人员操作只作用于平台授权,外部授权沿用外部页签的逐条操作 -->
<select id="selectAuthIdsByPersonNos" resultType="Long">
select auth_id from lock_auth
where del_flag = '0'
and source = '1'
and person_no in
<foreach collection="personNos" item="personNo" open="(" separator="," close=")">
#{personNo}
</foreach>
</select>
<select id="selectPushableAuthIdsByPersonNos" resultType="Long">
select auth_id from lock_auth
where del_flag = '0'
and source = '1'
and (sync_status is null or sync_status not in ('4', '6'))
and person_no in
<foreach collection="personNos" item="personNo" open="(" separator="," close=")">
#{personNo}
</foreach>
</select>
<select id="selectAuthById" parameterType="Long" resultMap="LockAuthResult">
<include refid="selectAuthVo"/>
where auth_id = #{authId}
</select>
<!--
授权明细:一行一条授权,带来源应用、门锁与关联入住单。
不在 SQL 中查 auth_value,平台详情与开放接口列表都不回显凭据。
入住单可能因换房/复用 auth_id 存在多条,取最近一条展示。
-->
<sql id="selectAuthDetailVo">
select a.auth_id, a.auth_no, a.person_no, a.person_name, a.device_id, a.device_sn, a.gateway_id,
a.group_id, a.auth_type, a.begin_time, a.end_time, a.auth_time,
a.source, a.source_app_code, a.source_biz_no, a.sync_status, a.sync_time, a.sync_msg,
a.retry_count, a.status, a.del_flag, a.create_by, a.create_time, a.update_by, a.update_time,
a.remark, o.app_name as source_app_name, d.device_name, d.online_status, s.stay_no
from lock_auth a
left join lock_open_app o on o.app_code = a.source_app_code
left join lock_device d on d.device_id = a.device_id
left join lock_stay s on s.stay_id = (
select max(s2.stay_id) from lock_stay s2 where s2.auth_id = a.auth_id
)
</sql>
<select id="selectExternalAuthList" parameterType="LockAuth" resultMap="LockAuthResult">
<include refid="selectAuthDetailVo"/>
<where>
a.del_flag = '0'
and a.source = '2'
<if test="sourceAppCode != null and sourceAppCode != ''">
and a.source_app_code = #{sourceAppCode}
</if>
<if test="keyword != null and keyword != ''">
and (a.auth_no like concat('%', #{keyword}, '%')
or a.person_no like concat('%', #{keyword}, '%')
or a.person_name like concat('%', #{keyword}, '%'))
</if>
<if test="authNo != null and authNo != ''">
and a.auth_no like concat('%', #{authNo}, '%')
</if>
<if test="personNo != null and personNo != ''">
and a.person_no like concat('%', #{personNo}, '%')
</if>
<if test="personName != null and personName != ''">
and a.person_name like concat('%', #{personName}, '%')
</if>
<if test="deviceId != null">
and a.device_id = #{deviceId}
</if>
<if test="deviceSn != null and deviceSn != ''">
and a.device_sn = #{deviceSn}
</if>
<if test="groupId != null">
and a.group_id = #{groupId}
</if>
<if test="authType != null and authType != ''">
and a.auth_type = #{authType}
</if>
<!-- ALL=全部状态;空=隐藏已取消;其余按状态精确过滤(4 即只看已取消) -->
<choose>
<when test="syncStatus == 'ALL'"></when>
<when test="syncStatus != null and syncStatus != ''">
and a.sync_status = #{syncStatus}
</when>
<otherwise>
and (a.sync_status is null or a.sync_status != '4')
</otherwise>
</choose>
<if test="params.beginTime != null and params.beginTime != ''">
and date_format(a.create_time,'%Y%m%d') &gt;= date_format(#{params.beginTime},'%Y%m%d')
</if>
<if test="params.endTime != null and params.endTime != ''">
and date_format(a.create_time,'%Y%m%d') &lt;= date_format(#{params.endTime},'%Y%m%d')
</if>
</where>
order by a.auth_id desc
</select>
<!-- 全部授权:平台与外部授权一并展示,一授权一行;默认不过滤已取消,便于逐条对账 -->
<select id="selectAllAuthList" parameterType="LockAuth" resultMap="LockAuthResult">
<include refid="selectAuthDetailVo"/>
<where>
a.del_flag = '0'
<if test="source != null and source != ''">
and a.source = #{source}
</if>
<if test="sourceAppCode != null and sourceAppCode != ''">
and a.source_app_code = #{sourceAppCode}
</if>
<if test="keyword != null and keyword != ''">
and (a.auth_no like concat('%', #{keyword}, '%')
or a.person_no like concat('%', #{keyword}, '%')
or a.person_name like concat('%', #{keyword}, '%'))
</if>
<if test="authNo != null and authNo != ''">
and a.auth_no like concat('%', #{authNo}, '%')
</if>
<if test="personNo != null and personNo != ''">
and a.person_no like concat('%', #{personNo}, '%')
</if>
<if test="personName != null and personName != ''">
and a.person_name like concat('%', #{personName}, '%')
</if>
<if test="deviceId != null">
and a.device_id = #{deviceId}
</if>
<if test="deviceSn != null and deviceSn != ''">
and a.device_sn = #{deviceSn}
</if>
<if test="groupId != null">
and a.group_id = #{groupId}
</if>
<if test="authType != null and authType != ''">
and a.auth_type = #{authType}
</if>
<if test="syncStatus != null and syncStatus != '' and syncStatus != 'ALL'">
and a.sync_status = #{syncStatus}
</if>
<if test="params.beginTime != null and params.beginTime != ''">
and date_format(a.create_time,'%Y%m%d') &gt;= date_format(#{params.beginTime},'%Y%m%d')
</if>
<if test="params.endTime != null and params.endTime != ''">
and date_format(a.create_time,'%Y%m%d') &lt;= date_format(#{params.endTime},'%Y%m%d')
</if>
</where>
order by a.auth_id desc
</select>
<select id="selectExternalAuthById" parameterType="Long" resultMap="LockAuthResult">
<include refid="selectAuthDetailVo"/>
where a.auth_id = #{authId} and a.del_flag = '0' and a.source = '2'
</select>
<select id="selectExternalAuthForApp" resultMap="LockAuthResult">
<include refid="selectAuthDetailVo"/>
where a.auth_id = #{authId} and a.del_flag = '0' and a.source = '2'
and a.source_app_code = #{appCode}
</select>
<select id="selectAuthsByDeviceId" parameterType="Long" resultMap="LockAuthResult">
<include refid="selectAuthVo"/>
where device_id = #{deviceId} and del_flag = '0'
@@ -192,15 +412,39 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<insert id="insertAuth" parameterType="LockAuth" useGeneratedKeys="true" keyProperty="authId">
insert into lock_auth (
auth_no, person_no, person_name, device_id, device_sn, gateway_id, group_id, auth_type,
auth_value, auth_value_md5, begin_time, end_time, auth_time, sync_status, retry_count,
auth_value, auth_value_md5, begin_time, end_time, auth_time,
source, source_app_code, source_biz_no, sync_status, retry_count,
status, del_flag, create_by, create_time, remark
) values (
#{authNo}, #{personNo}, #{personName}, #{deviceId}, #{deviceSn}, #{gatewayId}, #{groupId},
#{authType}, #{authValue}, #{authValueMd5}, #{beginTime}, #{endTime}, #{authTime},
'0', 0, #{status}, '0', #{createBy}, CURRENT_TIMESTAMP, #{remark}
#{source}, #{sourceAppCode}, #{sourceBizNo}, '0', 0, #{status}, '0', #{createBy},
CURRENT_TIMESTAMP, #{remark}
)
</insert>
<!-- 外部入住/换房覆盖平台行时接管归属;直接授权接口不允许走这里 -->
<update id="updateAuthSource">
update lock_auth
set source = #{source},
source_app_code = #{sourceAppCode},
source_biz_no = #{sourceBizNo},
update_by = #{updateBy},
update_time = CURRENT_TIMESTAMP
where auth_id = #{authId}
</update>
<!-- 外部授权改期:显式写 end_time,允许把失效时间清空为 NULL(updateAuth 的 <if> 做不到) -->
<update id="updateExternalAuth" parameterType="LockAuth">
update lock_auth
set begin_time = #{beginTime},
end_time = #{endTime},
remark = #{remark},
update_by = #{updateBy},
update_time = CURRENT_TIMESTAMP
where auth_id = #{authId} and source = '2' and del_flag = '0'
</update>
<update id="updateAuth" parameterType="LockAuth">
update lock_auth
<set>
@@ -99,6 +99,72 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
and command_status in ('0', '1')
</select>
<!-- 同一主题除自身外的在途命令:重发排队命令前确认网关没有别的 data/batch 在处理 -->
<select id="countOtherInflightByTopic" resultType="int">
select count(1) from lock_command
where topic = #{topic}
and command_status in ('0', '1')
and command_id != #{commandId}
</select>
<!-- 忙排队重发前只看已发送命令:同为待发送的其它命令由每轮"每主题只放行一条"控制 -->
<select id="countOtherSentByTopic" resultType="int">
select count(1) from lock_command
where topic = #{topic}
and command_status = '1'
and command_id != #{commandId}
</select>
<!-- 到期的忙排队命令,按先来先发 -->
<select id="selectDeferredCommands" parameterType="java.util.Date" resultMap="LockCommandResult">
<include refid="selectCommandVo"/>
where command_status = '0'
and next_retry_time is not null
and next_retry_time &lt;= #{now}
order by command_id
</select>
<!-- 同一授权已有在途命令时不再新建,避免命令重试与授权补偿并行下发同一张脸 -->
<select id="countInflightAuthByBizId" resultType="int">
select count(1) from lock_command
where biz_id = #{bizId}
and command_type in ('0011', '0012')
and command_status in ('0', '1')
</select>
<select id="countInflightByBizId" resultType="int">
select count(1) from lock_command
where command_type = #{commandType}
and biz_id = #{bizId}
and command_status in ('0', '1')
</select>
<select id="selectLatestByBizId" resultMap="LockCommandResult">
<include refid="selectCommandVo"/>
where command_type = #{commandType}
and biz_id = #{bizId}
order by command_id desc
limit 1
</select>
<!-- 最近一次查询失败时回退到上次有数据的查询,避免离线重查把已有对账结果冲掉 -->
<select id="selectLatestWithRowsByBizId" resultMap="LockCommandResult">
<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)
order by command_id desc
limit 1
</select>
<select id="selectCommandsByNos" resultMap="LockCommandResult">
<include refid="selectCommandVo"/>
where command_no in
<foreach collection="commandNos" item="commandNo" open="(" separator="," close=")">
#{commandNo}
</foreach>
</select>
<insert id="insertCommand" parameterType="LockCommand" useGeneratedKeys="true" keyProperty="commandId">
insert into lock_command (
command_no, command_type, target_type, target_id, target_code, biz_id, topic, payload,
@@ -120,6 +120,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
where gateway_id = #{gatewayId}
</update>
<update id="updateOnlineStatusOnly">
update lock_gateway set online_status = #{onlineStatus}
where gateway_id = #{gatewayId}
</update>
<update id="updateSyncStatus">
update lock_gateway set sync_status = #{syncStatus}, last_sync_time = CURRENT_TIMESTAMP
<if test="syncMsg != null">, remark = #{syncMsg}</if>
@@ -149,6 +154,25 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
where online_status = '0' and (last_online_time is null or last_online_time &lt; #{offlineTime})
</update>
<!--
网关能转发门锁上报即证明在线:按归属行的最近上报时间刷新在线状态。
只依赖 onoff_line 时,网关连接事件之后不再有消息,10 分钟后必然被误判离线。
-->
<update id="refreshOnlineByReport">
update lock_gateway g
join (
select gateway_id, max(last_signal_time) as last_signal
from lock_device_gateway
where del_flag = '0' and gateway_id is not null
group by gateway_id
) r on r.gateway_id = g.gateway_id
set g.online_status = '0',
g.last_online_time = greatest(ifnull(g.last_online_time, r.last_signal), r.last_signal)
where g.del_flag = '0'
and r.last_signal &gt;= #{onlineTime}
and (g.online_status &lt;&gt; '0' or g.last_online_time is null or g.last_online_time &lt; r.last_signal)
</update>
<update id="deleteGatewayByIds" parameterType="Long">
update lock_gateway set del_flag = '2' where gateway_id in
<foreach collection="array" item="gatewayId" open="(" separator="," close=")">
+5 -1
View File
@@ -312,6 +312,9 @@ create table lock_auth (
begin_time datetime comment '生效开始时间',
end_time datetime comment '生效结束时间(空表示永久有效)',
auth_time datetime comment '授权操作时间',
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_time datetime comment '最后下发/响应时间',
sync_msg varchar(512) default null comment '下发结果说明',
@@ -329,7 +332,8 @@ create table lock_auth (
key idx_lock_auth_person (person_no),
key idx_lock_auth_device (device_id),
key idx_lock_auth_group (group_id),
key idx_lock_auth_sync (sync_status)
key idx_lock_auth_sync (sync_status),
key idx_lock_auth_source (source, source_app_code)
) engine=innodb comment = '授权数据表';