From eb5ad9518de0d671a670d607b413e2a10f8e853d Mon Sep 17 00:00:00 2001 From: jzf Date: Tue, 3 Nov 2026 18:31:01 +0800 Subject: [PATCH] 1112 --- .../src/main/resources/application-druid.yml | 4 +- .../src/main/resources/application.yml | 92 +- .../com/ruoyi/iot/lock/LockCommandPolicy.java | 42 +- .../com/ruoyi/iot/lock/LockConstants.java | 9 + .../lock/controller/LockAuthController.java | 126 + .../lock/controller/LockDeviceController.java | 48 + .../ruoyi/iot/lock/domain/LockAccessLog.java | 20 +- .../com/ruoyi/iot/lock/domain/LockAuth.java | 104 + .../ruoyi/iot/lock/mapper/LockAuthMapper.java | 28 + .../iot/lock/mapper/LockCommandMapper.java | 24 + .../iot/lock/mapper/LockGatewayMapper.java | 10 + .../iot/lock/mqtt/LockMessageListener.java | 24 +- .../lock/mqtt/handler/CommandRespHandler.java | 25 +- .../mqtt/handler/GatewayOnlineHandler.java | 27 +- .../iot/lock/protocol/YdlinkFrameDTO.java | 19 + .../lock/protocol/YdlinkPayloadMasker.java | 2 + .../iot/lock/protocol/YdlinkRespSpec.java | 21 +- .../iot/lock/service/ILockAuthService.java | 51 + .../iot/lock/service/ILockCommandService.java | 14 +- .../service/impl/LockAuthServiceImpl.java | 431 ++- .../service/impl/LockCommandServiceImpl.java | 210 +- .../service/impl/LockGatewayServiceImpl.java | 12 +- .../service/impl/LockStayServiceImpl.java | 27 +- .../lock/task/GatewayOfflineCheckTask.java | 5 +- .../iot/lock/task/LockCommandRetryTask.java | 4 +- .../controller/OpenAuthApiController.java | 52 +- .../iot/openapi/domain/OpenAuthRequest.java | 13 + .../mapper/lock/LockAccessLogMapper.xml | 5 + .../resources/mapper/lock/LockAuthMapper.xml | 250 +- .../mapper/lock/LockCommandMapper.xml | 66 + .../mapper/lock/LockGatewayMapper.xml | 24 + base/RuoYi-Vue-master/sql/lock.sql | 6 +- base/RuoYi-Vue2-master/src/api/iot/auth.js | 103 + base/RuoYi-Vue2-master/src/api/iot/device.js | 25 + .../src/views/iot/auth/index.vue | 2546 ++++++++++++++--- .../src/views/iot/device/index.vue | 307 +- 36 files changed, 4195 insertions(+), 581 deletions(-) diff --git a/base/RuoYi-Vue-master/ruoyi-admin/src/main/resources/application-druid.yml b/base/RuoYi-Vue-master/ruoyi-admin/src/main/resources/application-druid.yml index 285a855..b925b42 100644 --- a/base/RuoYi-Vue-master/ruoyi-admin/src/main/resources/application-druid.yml +++ b/base/RuoYi-Vue-master/ruoyi-admin/src/main/resources/application-druid.yml @@ -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 diff --git a/base/RuoYi-Vue-master/ruoyi-admin/src/main/resources/application.yml b/base/RuoYi-Vue-master/ruoyi-admin/src/main/resources/application.yml index 7960352..cf63a1a 100644 --- a/base/RuoYi-Vue-master/ruoyi-admin/src/main/resources/application.yml +++ b/base/RuoYi-Vue-master/ruoyi-admin/src/main/resources/application.yml @@ -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 diff --git a/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/LockCommandPolicy.java b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/LockCommandPolicy.java index dbc91e6..39e3e8c 100644 --- a/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/LockCommandPolicy.java +++ b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/LockCommandPolicy.java @@ -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 之后,等待门锁终态的时长(秒) + *

+ * 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 次重试的时间点 */ diff --git a/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/LockConstants.java b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/LockConstants.java index 202e20b..9c2429c 100644 --- a/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/LockConstants.java +++ b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/LockConstants.java @@ -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"; diff --git a/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/controller/LockAuthController.java b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/controller/LockAuthController.java index 5f196c6..6e50717 100644 --- a/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/controller/LockAuthController.java +++ b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/controller/LockAuthController.java @@ -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 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 list = lockAuthService.selectExternalAuthList(query); + return getDataTable(list); + } + + /** 全部授权分页列表:一授权一行,平台与外部授权都包含,用于逐条排查下发情况 */ + @PreAuthorize("@ss.hasPermi('lock:auth:list')") + @GetMapping("/all/list") + public TableDataInfo allList(LockAuth query) + { + startPage(); + List 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 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 list = lockAuthService.selectAuthList(auth); ExcelUtil util = new ExcelUtil(LockAuth.class); util.exportExcel(response, list, "门禁授权数据"); diff --git a/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/controller/LockDeviceController.java b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/controller/LockDeviceController.java index 7ca48d3..dceb66f 100644 --- a/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/controller/LockDeviceController.java +++ b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/controller/LockDeviceController.java @@ -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 commandSummary(LockCommand command) + { + Map data = new HashMap(); + 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") diff --git a/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/domain/LockAccessLog.java b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/domain/LockAccessLog.java index 658264c..8bdd6d8 100644 --- a/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/domain/LockAccessLog.java +++ b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/domain/LockAccessLog.java @@ -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(); diff --git a/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/domain/LockAuth.java b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/domain/LockAuth.java index c68b67a..aef38ad 100644 --- a/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/domain/LockAuth.java +++ b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/domain/LockAuth.java @@ -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; diff --git a/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mapper/LockAuthMapper.java b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mapper/LockAuthMapper.java index 7c93e8c..92e7a00 100644 --- a/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mapper/LockAuthMapper.java +++ b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mapper/LockAuthMapper.java @@ -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 selectAuthList(LockAuth auth); + /** 按人员聚合授权:一人一行,含授权总数、已下发数、失败数与涉及门锁数 */ + List selectPersonAuthList(LockAuthPerson query); + + /** 人员的全部未删除授权ID(含已清除行),按人员删除时使用 */ + List selectAuthIdsByPersonNos(@Param("personNos") String[] personNos); + + /** 人员可重新下发的授权ID:排除已清除(4)与待清除(6),避免把删除中的人脸重新 add */ + List selectPushableAuthIdsByPersonNos(@Param("personNos") String[] personNos); + LockAuth selectAuthById(Long authId); + /** 外部授权分页列表:固定 source='2',带来源应用、门锁与入住单展示字段 */ + List selectExternalAuthList(LockAuth query); + + /** 全部授权分页列表:一授权一行,不区分来源,带来源应用、门锁与入住单展示字段 */ + List 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 selectAuthsByDeviceId(Long deviceId); List selectAuthsByPersonNo(String personNo); diff --git a/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mapper/LockCommandMapper.java b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mapper/LockCommandMapper.java index f8d73fa..bc3a927 100644 --- a/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mapper/LockCommandMapper.java +++ b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mapper/LockCommandMapper.java @@ -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 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 selectCommandsByNos(@Param("commandNos") List commandNos); + int insertCommand(LockCommand command); int updateCommand(LockCommand command); diff --git a/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mapper/LockGatewayMapper.java b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mapper/LockGatewayMapper.java index 3902be7..1a7bba5 100644 --- a/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mapper/LockGatewayMapper.java +++ b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mapper/LockGatewayMapper.java @@ -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); + + /** + * 按归属行的最近上报时间把网关置为在线 + *

+ * 网关能转发门锁信号/记录即证明其在线,不再只依赖 onoff_line 连接事件。 + */ + int refreshOnlineByReport(@Param("onlineTime") Date onlineTime); } diff --git a/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mqtt/LockMessageListener.java b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mqtt/LockMessageListener.java index 4d842b7..8ec27b3 100644 --- a/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mqtt/LockMessageListener.java +++ b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mqtt/LockMessageListener.java @@ -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) { diff --git a/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mqtt/handler/CommandRespHandler.java b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mqtt/handler/CommandRespHandler.java index 3818650..f7cd2f7 100644 --- a/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mqtt/handler/CommandRespHandler.java +++ b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mqtt/handler/CommandRespHandler.java @@ -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 主题。 *

- * 门锁类下发可能收到两次响应:
+ * 门锁类下发可能收到多次响应:
* 1) 网关回执(结果码 10):仅表示消息已送达网关并转发,属中间态;
- * 2) 门锁回执(结果码 0/11/12):终态,据此判定成功与失败。
+ * 2) 网关忙(结果码 11):报文未转发到门锁,属可重发的瞬时状态,不能判失败;
+ * 3) 门锁回执(结果码 0/12):终态,0 成功、12 设备不存在,据此判定结果。
* 结果码位置按 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); diff --git a/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mqtt/handler/GatewayOnlineHandler.java b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mqtt/handler/GatewayOnlineHandler.java index 8190275..93dfb86 100644 --- a/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mqtt/handler/GatewayOnlineHandler.java +++ b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mqtt/handler/GatewayOnlineHandler.java @@ -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 + *

+ * 报文体没有标准信封,只有 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 ? "已更新为离线" : "已更新为在线"; } } diff --git a/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/protocol/YdlinkFrameDTO.java b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/protocol/YdlinkFrameDTO.java index aa319a7..898c7d9 100644 --- a/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/protocol/YdlinkFrameDTO.java +++ b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/protocol/YdlinkFrameDTO.java @@ -66,6 +66,14 @@ public class YdlinkFrameDTO implements Serializable @JSONField(ordinal = 10) private String chksum; + /** + * 状态文案(仅 onoff_line 使用:"I am back" 上线 / "I will be back" 离线) + *

+ * 该报文没有标准信封,只有 devId + message;只接收不回填。 + */ + @JSONField(serialize = false) + private String message; + /** 参数数组(一维或二维) */ @JSONField(ordinal = 11) private List 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; + } } diff --git a/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/protocol/YdlinkPayloadMasker.java b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/protocol/YdlinkPayloadMasker.java index 7f11b86..19bbdf9 100644 --- a/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/protocol/YdlinkPayloadMasker.java +++ b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/protocol/YdlinkPayloadMasker.java @@ -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() diff --git a/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/protocol/YdlinkRespSpec.java b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/protocol/YdlinkRespSpec.java index e55bff5..956cefc 100644 --- a/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/protocol/YdlinkRespSpec.java +++ b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/protocol/YdlinkRespSpec.java @@ -15,7 +15,8 @@ import java.util.Map; * → [1] 错误码、[5] 操作结果,两处都可能出现 10/0 * * 因此按 op 维护取码位置,opencloselock 同时检查 [1] 与 [5]: - * 任一为 10 视为网关已接收(中间态),出现 11/12 视为失败,其余以 0 判定成功。 + * 任一为 10 视为网关已接收(中间态),11 视为网关忙(报文未转发,可稍后重发), + * 12 视为设备不存在(终态失败),其余以 0 判定成功。 *

* 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; } diff --git a/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/ILockAuthService.java b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/ILockAuthService.java index b9eef72..2b33c3d 100644 --- a/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/ILockAuthService.java +++ b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/ILockAuthService.java @@ -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 selectAuthList(LockAuth auth); + /** 人员维度授权汇总:一人一行,含授权总数、已下发数、失败数与涉及门锁数 */ + List selectPersonAuthList(LockAuthPerson query); + LockAuth selectAuthById(Long authId); + /** 外部授权分页列表:固定 source='2',带来源应用、门锁与入住单展示字段 */ + List selectExternalAuthList(LockAuth query); + + /** 全部授权分页列表:一授权一行,不区分来源,便于逐条排查下发情况 */ + List 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 selectAuthsByDeviceId(Long deviceId); /** 新增授权并下发 */ int insertAuth(LockAuth auth); + /** + * 新增授权并下发。 + * + * @param adoptPlatformSource true 表示覆盖平台已有授权时接管归属(外部入住/换房使用); + * false 表示拒绝覆盖平台授权(外部直接授权接口使用) + */ + int insertAuth(LockAuth auth, boolean adoptPlatformSource); + + /** + * 多锁批量授权:同一人员、同一类型、同一有效期,为多把门锁各建一条授权。 + *

+ * 每行独立处理与下发,单锁失败不影响其它锁;已存在的同键授权按覆盖处理。 + * + * @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); diff --git a/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/ILockCommandService.java b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/ILockCommandService.java index 84254e9..b090113 100644 --- a/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/ILockCommandService.java +++ b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/ILockCommandService.java @@ -23,8 +23,8 @@ public interface ILockCommandService /** * 处理门锁回执(最终结果) *

- * 门锁回执(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,报文未转发到门锁) + *

+ * 命令回到待发送并排期重发,期间保持「在途」以阻止同网关下发下一条; + * 重排次数用尽才按失败终结。 + * + * @return 重排中返回 null;按失败终结时返回 STATUS_FAIL + */ + String handleGatewayBusy(String topic, String seq, String payload, String message); + /** * 标记超时命令:候选按最短超时粗筛,再按各命令类型的超时阈值逐条判定 */ diff --git a/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/impl/LockAuthServiceImpl.java b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/impl/LockAuthServiceImpl.java index e686e8f..4a5b931 100644 --- a/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/impl/LockAuthServiceImpl.java +++ b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/impl/LockAuthServiceImpl.java @@ -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 selectPersonAuthList(LockAuthPerson query) + { + return authMapper.selectPersonAuthList(query); + } + @Override public LockAuth selectAuthById(Long authId) { return authMapper.selectAuthById(authId); } + @Override + public List selectExternalAuthList(LockAuth query) + { + return authMapper.selectExternalAuthList(query); + } + + @Override + public List 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 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); + } + + /** + * 单行新增/覆盖并下发。 + *

+ * 批量授权逐行直接调用本方法:不包整批事务,单锁失败不能回滚其它锁。 + * 插入时 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); + } + } + + /** + * 覆盖已有授权行时的归属判定。 + *

+ * 平台覆盖不夺取外部归属;外部来源只允许同应用覆盖,跨应用拒绝; + * 外部入住/换房(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 deviceIds = new LinkedHashSet(); + 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 items = new ArrayList(); + 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 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 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 unique = new LinkedHashSet(); + 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 * * * @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; } diff --git a/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/impl/LockCommandServiceImpl.java b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/impl/LockCommandServiceImpl.java index 5bcc1e3..739188b 100644 --- a/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/impl/LockCommandServiceImpl.java +++ b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/impl/LockCommandServiceImpl.java @@ -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())); - } - /** * 授权命令终结为超时时回写授权同步状态 *

@@ -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 commands = commandMapper.selectCommandList(query); int count = 0; Date now = new Date(); - for (LockCommand command : commands) + // 1) 网关忙排队的命令:每轮每个主题(同网关)只放行一条,且网关无已发送命令时才重发, + // 避免多条排队命令互相等待或同时挤进网关 + Set republishedTopics = new HashSet(); + 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) diff --git a/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/impl/LockGatewayServiceImpl.java b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/impl/LockGatewayServiceImpl.java index 15c2310..fb7aa20 100644 --- a/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/impl/LockGatewayServiceImpl.java +++ b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/impl/LockGatewayServiceImpl.java @@ -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; diff --git a/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/impl/LockStayServiceImpl.java b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/impl/LockStayServiceImpl.java index 8a5cbba..8c19bff 100644 --- a/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/impl/LockStayServiceImpl.java +++ b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/impl/LockStayServiceImpl.java @@ -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); // 授权可能因门锁无路由/缺特征在创建时即失败,回读真实状态写入入住单 diff --git a/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/task/GatewayOfflineCheckTask.java b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/task/GatewayOfflineCheckTask.java index df10062..8070e64 100644 --- a/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/task/GatewayOfflineCheckTask.java +++ b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/task/GatewayOfflineCheckTask.java @@ -9,6 +9,9 @@ import com.ruoyi.iot.lock.service.ILockGatewayService; /** * 网关离线检测:超过指定时间未上报即置为离线 + *

+ * 在线来源有两个:网关级 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); diff --git a/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/task/LockCommandRetryTask.java b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/task/LockCommandRetryTask.java index b8b9c2e..ef2044c 100644 --- a/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/task/LockCommandRetryTask.java +++ b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/task/LockCommandRetryTask.java @@ -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() diff --git a/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/openapi/controller/OpenAuthApiController.java b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/openapi/controller/OpenAuthApiController.java index 0c10f7a..cd41dcb 100644 --- a/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/openapi/controller/OpenAuthApiController.java +++ b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/openapi/controller/OpenAuthApiController.java @@ -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 diff --git a/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/openapi/domain/OpenAuthRequest.java b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/openapi/domain/OpenAuthRequest.java index 2e812ba..56f7899 100644 --- a/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/openapi/domain/OpenAuthRequest.java +++ b/base/RuoYi-Vue-master/ruoyi-iot/src/main/java/com/ruoyi/iot/openapi/domain/OpenAuthRequest.java @@ -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; diff --git a/base/RuoYi-Vue-master/ruoyi-iot/src/main/resources/mapper/lock/LockAccessLogMapper.xml b/base/RuoYi-Vue-master/ruoyi-iot/src/main/resources/mapper/lock/LockAccessLogMapper.xml index ceca31c..8ba4f9c 100644 --- a/base/RuoYi-Vue-master/ruoyi-iot/src/main/resources/mapper/lock/LockAccessLogMapper.xml +++ b/base/RuoYi-Vue-master/ruoyi-iot/src/main/resources/mapper/lock/LockAccessLogMapper.xml @@ -25,6 +25,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + + + + + 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, diff --git a/base/RuoYi-Vue-master/ruoyi-iot/src/main/resources/mapper/lock/LockAuthMapper.xml b/base/RuoYi-Vue-master/ruoyi-iot/src/main/resources/mapper/lock/LockAuthMapper.xml index 9683692..fcb51f7 100644 --- a/base/RuoYi-Vue-master/ruoyi-iot/src/main/resources/mapper/lock/LockAuthMapper.xml +++ b/base/RuoYi-Vue-master/ruoyi-iot/src/main/resources/mapper/lock/LockAuthMapper.xml @@ -19,6 +19,13 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + + + + + + + @@ -32,9 +39,24 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + + + + + + + + + + + + + + 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 @@ -67,6 +89,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" and sync_status = #{syncStatus} + + and source = #{source} + and status = #{status} @@ -80,11 +105,206 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" order by auth_id desc + + + + + + + + + + + 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 + ) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + insert into lock_command ( command_no, command_type, target_type, target_id, target_code, biz_id, topic, payload, diff --git a/base/RuoYi-Vue-master/ruoyi-iot/src/main/resources/mapper/lock/LockGatewayMapper.xml b/base/RuoYi-Vue-master/ruoyi-iot/src/main/resources/mapper/lock/LockGatewayMapper.xml index 78fa5da..15ce840 100644 --- a/base/RuoYi-Vue-master/ruoyi-iot/src/main/resources/mapper/lock/LockGatewayMapper.xml +++ b/base/RuoYi-Vue-master/ruoyi-iot/src/main/resources/mapper/lock/LockGatewayMapper.xml @@ -120,6 +120,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" where gateway_id = #{gatewayId} + + update lock_gateway set online_status = #{onlineStatus} + where gateway_id = #{gatewayId} + + update lock_gateway set sync_status = #{syncStatus}, last_sync_time = CURRENT_TIMESTAMP , remark = #{syncMsg} @@ -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 < #{offlineTime}) + + + 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 >= #{onlineTime} + and (g.online_status <> '0' or g.last_online_time is null or g.last_online_time < r.last_signal) + + update lock_gateway set del_flag = '2' where gateway_id in diff --git a/base/RuoYi-Vue-master/sql/lock.sql b/base/RuoYi-Vue-master/sql/lock.sql index 87fbc07..bde255c 100644 --- a/base/RuoYi-Vue-master/sql/lock.sql +++ b/base/RuoYi-Vue-master/sql/lock.sql @@ -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 = '授权数据表'; diff --git a/base/RuoYi-Vue2-master/src/api/iot/auth.js b/base/RuoYi-Vue2-master/src/api/iot/auth.js index 9704604..bce525b 100644 --- a/base/RuoYi-Vue2-master/src/api/iot/auth.js +++ b/base/RuoYi-Vue2-master/src/api/iot/auth.js @@ -9,6 +9,92 @@ export function listAuth(query) { }) } +// 按人员聚合查询授权(一人一行) +export function listAuthPerson(query) { + return request({ + url: '/lock/auth/person/list', + method: 'get', + params: query + }) +} + +// 外部授权列表(一授权一行,仅 source='2') +export function listExternalAuth(query) { + return request({ + url: '/lock/auth/external/list', + method: 'get', + params: query + }) +} + +// 全部授权列表(一授权一行,含平台与外部来源) +export function listAllAuth(query) { + return request({ + url: '/lock/auth/all/list', + method: 'get', + params: query + }) +} + +// 外部授权详情 +export function getExternalAuth(authId) { + return request({ + url: '/lock/auth/external/' + authId, + method: 'get' + }) +} + +// 修改外部授权有效期(有效授权保存后自动重推) +export function updateExternalAuth(authId, data) { + return request({ + url: '/lock/auth/external/' + authId, + method: 'put', + data: data + }) +} + +// 外部授权重新下发 +export function pushExternalAuth(authId) { + return request({ + url: '/lock/auth/external/' + authId + '/push', + method: 'post' + }) +} + +// 外部授权取消(清除设备侧权限,记录保留) +export function clearExternalAuth(authId) { + return request({ + url: '/lock/auth/external/' + authId + '/clear', + method: 'post' + }) +} + +// 删除外部授权(先清除再逻辑删除) +export function delExternalAuth(authIds) { + return request({ + url: '/lock/auth/external/' + authIds, + method: 'delete' + }) +} + +// 按人员删除其名下全部授权(并下发清除) +export function removeAuthByPerson(personNos) { + return request({ + url: '/lock/auth/person/remove', + method: 'post', + data: personNos + }) +} + +// 按人员重新下发其名下全部授权 +export function pushAuthByPerson(personNos) { + return request({ + url: '/lock/auth/person/push', + method: 'post', + data: personNos + }) +} + // 查询授权详细 export function getAuth(authId) { return request({ @@ -26,6 +112,15 @@ export function addAuth(data) { }) } +// 多锁批量授权(一次为多把门锁建立同类型授权) +export function batchAddAuth(data) { + return request({ + url: '/lock/auth/batch', + method: 'post', + data: data + }) +} + // 修改授权(并重新下发) export function updateAuth(data) { return request({ @@ -43,6 +138,14 @@ export function delAuth(authId) { }) } +// 删除授权记录(设备侧已清除,仅移除平台记录) +export function delAuthRecord(authId) { + return request({ + url: '/lock/auth/record/' + authId, + method: 'delete' + }) +} + // 手动重下发 export function pushAuth(authId) { return request({ diff --git a/base/RuoYi-Vue2-master/src/api/iot/device.js b/base/RuoYi-Vue2-master/src/api/iot/device.js index 26a2941..bed8487 100644 --- a/base/RuoYi-Vue2-master/src/api/iot/device.js +++ b/base/RuoYi-Vue2-master/src/api/iot/device.js @@ -102,3 +102,28 @@ export function remoteSwitch(deviceId, open) { data: { open: open } }) } + +// 下发读取锁内授权指令(op=getkey) +export function queryDeviceAuths(deviceId) { + return request({ + url: '/lock/device/' + deviceId + '/auth/query', + method: 'post' + }) +} + +// 锁内授权快照与平台授权对账结果 +export function getDeviceAuthSnapshot(deviceId) { + return request({ + url: '/lock/device/' + deviceId + '/auth/snapshot', + method: 'get' + }) +} + +// 清除设备侧一条卡/密码授权 +export function clearDeviceAuth(deviceId, data) { + return request({ + url: '/lock/device/' + deviceId + '/auth/clear', + method: 'post', + data: data + }) +} diff --git a/base/RuoYi-Vue2-master/src/views/iot/auth/index.vue b/base/RuoYi-Vue2-master/src/views/iot/auth/index.vue index dc413bd..32ddca6 100644 --- a/base/RuoYi-Vue2-master/src/views/iot/auth/index.vue +++ b/base/RuoYi-Vue2-master/src/views/iot/auth/index.vue @@ -1,22 +1,17 @@ + + diff --git a/base/RuoYi-Vue2-master/src/views/iot/device/index.vue b/base/RuoYi-Vue2-master/src/views/iot/device/index.vue index 233e1fe..0a68a18 100644 --- a/base/RuoYi-Vue2-master/src/views/iot/device/index.vue +++ b/base/RuoYi-Vue2-master/src/views/iot/device/index.vue @@ -205,7 +205,7 @@ - +