From bacdd15f3a3f3298dba9fcdb84f70da39797b28c Mon Sep 17 00:00:00 2001
From: jzf
Date: Thu, 12 Nov 2026 13:36:24 +0800
Subject: [PATCH] =?UTF-8?q?=E7=AC=AC=E5=85=AD=E9=98=B6=E6=AE=B5=E5=BC=80?=
=?UTF-8?q?=E5=8F=91=E3=80=82?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
dev/.env.example | 42 +
.../controller/AptDeviceController.java | 64 ++
.../controller/AptDeviceOpenController.java | 45 ++
.../domain/AptDeviceCommandRequest.java | 77 ++
.../apt/device/domain/AptDeviceHeartbeat.java | 47 ++
.../apt/device/mapper/AptDeviceMapper.java | 41 +
.../device/service/AptDeviceServiceImpl.java | 199 +++++
.../apt/device/service/IAptDeviceService.java | 26 +
.../controller/AptJcxxController.java | 22 +-
.../dispatch/mapper/AptDutyStateMapper.java | 3 +
.../apt/dispatch/mapper/AptJcxxMapper.java | 3 +
.../service/AptDutyStateServiceImpl.java | 57 +-
.../dispatch/service/AptJcxxServiceImpl.java | 2 +
.../controller/AptTabletOpenController.java | 27 +-
.../tablet/mapper/AptTabletMapper.java | 45 ++
.../service/AptPhotoPurgeServiceImpl.java | 169 ++++
.../service/AptTabletPhotoServiceImpl.java | 379 +++++++++
.../service/AptTabletResultServiceImpl.java | 3 +-
.../tablet/service/IAptPhotoPurgeService.java | 22 +
.../service/IAptTabletPhotoService.java | 29 +
.../framework/config/AptPhotoProperties.java | 94 +++
.../apt/framework/config/AptProperties.java | 93 +++
.../job/controller/AptStaffController.java | 88 ++
.../controller/AptTabletJobController.java | 162 ++++
.../controller/AptWorkOrderController.java | 103 +++
.../ruoyi/apt/job/domain/AptJobCommand.java | 137 ++++
.../com/ruoyi/apt/job/domain/AptJobQuery.java | 115 +++
.../ruoyi/apt/job/domain/AptStaffCommand.java | 135 ++++
.../ruoyi/apt/job/mapper/AptJobMapper.java | 143 ++++
.../apt/job/service/AptJobServiceImpl.java | 756 ++++++++++++++++++
.../apt/job/service/AptStaffServiceImpl.java | 208 +++++
.../ruoyi/apt/job/service/IAptJobService.java | 67 ++
.../apt/job/service/IAptStaffService.java | 29 +
.../controller/AptPersonController.java | 63 ++
.../apt/master/mapper/AptPersonMapper.java | 34 +
.../master/service/AptPersonServiceImpl.java | 96 +++
.../apt/master/service/IAptPersonService.java | 22 +
.../controller/AptLocalDebugController.java | 82 ++
.../controller/AptReportController.java | 74 ++
.../controller/AptReportOpenController.java | 36 +
.../apt/report/domain/AptOutboxQuery.java | 84 ++
.../apt/report/domain/AptResumeEnvelope.java | 87 ++
.../apt/report/mapper/AptReportMapper.java | 41 +
.../report/service/AptReportServiceImpl.java | 271 +++++++
.../apt/report/service/IAptReportService.java | 35 +
.../stay/controller/AptRoomController.java | 75 +-
.../apt/stay/domain/AptRoomCreateCommand.java | 59 ++
.../ruoyi/apt/stay/mapper/AptRoomMapper.java | 13 +
.../apt/stay/service/AptRoomServiceImpl.java | 72 +-
.../apt/stay/service/IAptRoomService.java | 13 +
.../apt/voice/domain/AptSipIpCommand.java | 43 +
.../voice/mapper/AptSipEndpointMapper.java | 91 +++
.../apt/voice/mapper/AptVoiceMapper.java | 8 +-
.../voice/service/AptCallFlowServiceImpl.java | 4 +-
.../service/AptSipEndpointServiceImpl.java | 407 ++++++++++
.../voice/service/IAptSipEndpointService.java | 52 ++
.../ruoyi/quartz/task/AptPhotoPurgeTask.java | 33 +
.../src/main/resources/apt-defaults.yml | 66 ++
.../resources/mapper/apt/AptDeviceMapper.xml | 113 +++
.../mapper/apt/AptDutyStateMapper.xml | 6 +
.../resources/mapper/apt/AptJcxxMapper.xml | 15 +
.../resources/mapper/apt/AptJobMapper.xml | 419 ++++++++++
.../resources/mapper/apt/AptPersonMapper.xml | 102 +++
.../resources/mapper/apt/AptReportMapper.xml | 115 +++
.../resources/mapper/apt/AptRoomMapper.xml | 38 +
.../mapper/apt/AptSipEndpointMapper.xml | 234 ++++++
.../resources/mapper/apt/AptTabletMapper.xml | 108 +++
.../resources/mapper/apt/AptVoiceMapper.xml | 19 +-
.../apt/device/AptDeviceServiceTest.java | 119 +++
.../apt/dispatch/AptDutyStateServiceTest.java | 5 +-
.../dispatch/AptPhotoPurgeServiceTest.java | 140 ++++
.../dispatch/AptTabletPhotoServiceTest.java | 196 +++++
.../ruoyi/apt/job/AptJobAutomationTest.java | 179 +++++
.../apt/master/AptPersonServiceTest.java | 81 ++
.../apt/report/AptReportServiceTest.java | 149 ++++
.../ruoyi/apt/stay/AptRoomServiceTest.java | 71 +-
.../apt/voice/AptSipEndpointServiceTest.java | 222 +++++
dev/RuoYi-Vue2-master/src/api/apt/staff.js | 57 ++
.../src/api/apt/workorder.js | 56 ++
dev/RuoYi-Vue2-master/src/utils/apt.js | 57 ++
.../src/views/apt/staff/index.vue | 495 ++++++++++++
.../src/views/apt/workorder/index.vue | 727 +++++++++++++++++
dev/sql/apt_rev01/22_sip_endpoint_room.sql | 132 +++
dev/sql/apt_rev01/23_sip_auth.sql | 129 +++
dev/sql/apt_rev01/24_tablet_face_photo.sql | 77 ++
dev/sql/apt_rev01/25_photo_retention.sql | 41 +
dev/sql/apt_rev01/26_jobs_staff.sql | 183 +++++
dev/sql/apt_rev01/27_jobs_staff_menus.sql | 60 ++
dev/sql/apt_rev01/28_job_automation.sql | 87 ++
dev/sql/apt_rev01/29_it6_permissions.sql | 91 +++
docs/$null | 2 +
docs/公寓端/19-保洁与报修自动化.md | 204 +++++
docs/公寓端/20-第六轮开发(IT-6)交付说明.md | 190 +++++
93 files changed, 10190 insertions(+), 22 deletions(-)
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/device/controller/AptDeviceController.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/device/controller/AptDeviceOpenController.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/device/domain/AptDeviceCommandRequest.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/device/domain/AptDeviceHeartbeat.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/device/mapper/AptDeviceMapper.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/device/service/AptDeviceServiceImpl.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/device/service/IAptDeviceService.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/tablet/service/AptPhotoPurgeServiceImpl.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/tablet/service/AptTabletPhotoServiceImpl.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/tablet/service/IAptPhotoPurgeService.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/tablet/service/IAptTabletPhotoService.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/config/AptPhotoProperties.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/job/controller/AptStaffController.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/job/controller/AptTabletJobController.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/job/controller/AptWorkOrderController.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/job/domain/AptJobCommand.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/job/domain/AptJobQuery.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/job/domain/AptStaffCommand.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/job/mapper/AptJobMapper.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/job/service/AptJobServiceImpl.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/job/service/AptStaffServiceImpl.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/job/service/IAptJobService.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/job/service/IAptStaffService.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/master/controller/AptPersonController.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/master/mapper/AptPersonMapper.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/master/service/AptPersonServiceImpl.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/master/service/IAptPersonService.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/ops/controller/AptLocalDebugController.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/report/controller/AptReportController.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/report/controller/AptReportOpenController.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/report/domain/AptOutboxQuery.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/report/domain/AptResumeEnvelope.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/report/mapper/AptReportMapper.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/report/service/AptReportServiceImpl.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/report/service/IAptReportService.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/domain/AptRoomCreateCommand.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/domain/AptSipIpCommand.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/mapper/AptSipEndpointMapper.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/service/AptSipEndpointServiceImpl.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/service/IAptSipEndpointService.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/quartz/task/AptPhotoPurgeTask.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptDeviceMapper.xml
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptJobMapper.xml
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptPersonMapper.xml
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptReportMapper.xml
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptSipEndpointMapper.xml
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/device/AptDeviceServiceTest.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/dispatch/AptPhotoPurgeServiceTest.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/dispatch/AptTabletPhotoServiceTest.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/job/AptJobAutomationTest.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/master/AptPersonServiceTest.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/report/AptReportServiceTest.java
create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/voice/AptSipEndpointServiceTest.java
create mode 100644 dev/RuoYi-Vue2-master/src/api/apt/staff.js
create mode 100644 dev/RuoYi-Vue2-master/src/api/apt/workorder.js
create mode 100644 dev/RuoYi-Vue2-master/src/views/apt/staff/index.vue
create mode 100644 dev/RuoYi-Vue2-master/src/views/apt/workorder/index.vue
create mode 100644 dev/sql/apt_rev01/22_sip_endpoint_room.sql
create mode 100644 dev/sql/apt_rev01/23_sip_auth.sql
create mode 100644 dev/sql/apt_rev01/24_tablet_face_photo.sql
create mode 100644 dev/sql/apt_rev01/25_photo_retention.sql
create mode 100644 dev/sql/apt_rev01/26_jobs_staff.sql
create mode 100644 dev/sql/apt_rev01/27_jobs_staff_menus.sql
create mode 100644 dev/sql/apt_rev01/28_job_automation.sql
create mode 100644 dev/sql/apt_rev01/29_it6_permissions.sql
create mode 100644 docs/$null
create mode 100644 docs/公寓端/19-保洁与报修自动化.md
create mode 100644 docs/公寓端/20-第六轮开发(IT-6)交付说明.md
diff --git a/dev/.env.example b/dev/.env.example
index 6a45e55..921e277 100644
--- a/dev/.env.example
+++ b/dev/.env.example
@@ -42,6 +42,48 @@ APT_PROBE_TIMEOUT_MS=2000
# Keep false in production; a non-local profile with true fails startup.
APT_MASTER_CLIENT_MOCK=false
+# ---- SIP voice / AMI (disabled by default) ----
+# Master switch; AMI host/port/user/timeouts live in table apt_ast_instance.
+# Keep false until a reachable Asterisk exists — false means "no connection, no SIP call",
+# and the scan task only handles the tablet chain.
+APT_VOICE_ENABLED=false
+# Auto-dial when jcsj is due; false = only manual POST /apt/jcxx/{jcid}/dial
+APT_VOICE_SIP_CALL_ENABLED=true
+# Duty desk extensions (second leg), comma separated, e.g. 1002,1003; empty = room leg only
+APT_VOICE_DUTY_EXTENSIONS=
+APT_VOICE_TTS_CONTEXT=apt-tts
+APT_VOICE_TTS_EXTENSION=s
+APT_VOICE_TTS_MEDIA_PREFIX=apt/call-prompt
+APT_VOICE_DIAL_TIMEOUT_MS=30000
+
+# ---- AMI secret (process env only) ----
+# apt_ast_instance.ami_secret_ref stores the VARIABLE NAME only (default APT_AMI_SECRET).
+# The value must match manager.conf accounts; it is never written to DB, logs or git.
+# Missing value -> AmiException(AMI_CONFIG_MISSING), instance stays DOWN, startup is unaffected.
+APT_AMI_SECRET=
+
+# ---- Device auth for /open/v1/** ----
+# Production must keep true; local debugging may set false (only X-Device-Code is checked).
+APT_DEVICE_HMAC_ENABLED=true
+APT_DEVICE_HMAC_SECRET=
+
+# ---- Local debug endpoints (/apt/local/**, must stay false in production) ----
+# true exposes: inject #7 match verdict, inject AMI events (bypasses real entry points).
+APT_DEBUG_ENABLED=false
+
+# ---- Tablet face photo storage (paths only in DB, image on disk/OSS) ----
+# Writable directory; DB stores relative path face/{gyid}/{yyyy}/{MM}/{dd}/{jcid}/{photoId}.jpg
+APT_PHOTO_ROOT=./data/apt-photos
+# Per-file limit in bytes (must be <= spring.servlet.multipart.max-file-size, currently 10MB)
+APT_PHOTO_MAX_BYTES=5242880
+# Prefix used only when echoing a URL back to the tablet
+APT_PHOTO_PUBLIC_PREFIX=/apt/files
+APT_PHOTO_STORAGE_TARGET=LOCAL
+# Retention: photo records and files older than N days are purged daily at 03:30
+# (business cap = 1 month; keep default 30 unless ops asks otherwise)
+APT_PHOTO_RETENTION_DAYS=30
+APT_PHOTO_PURGE_BATCH=200
+
# ---- Druid console (disabled by default) ----
APT_DRUID_CONSOLE=false
APT_DRUID_USER=aptadmin
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/device/controller/AptDeviceController.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/device/controller/AptDeviceController.java
new file mode 100644
index 0000000..275a4af
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/device/controller/AptDeviceController.java
@@ -0,0 +1,64 @@
+package com.ruoyi.apt.device.controller;
+
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import com.ruoyi.apt.common.audit.AptAudit;
+import com.ruoyi.apt.device.domain.AptDeviceCommandRequest;
+import com.ruoyi.apt.device.service.IAptDeviceService;
+import com.ruoyi.common.core.domain.AjaxResult;
+
+/**
+ * M5 设备管理端:档案 / 心跳视图 / 指令队列。
+ *
+ * 管理端不能把指令改成 SENT/ACKED:状态只允许设备回执或派发器推进,
+ * 本期协议未确认,因此列表里恒为「待协议确认」。
+ *
+ * @author apt-rev01
+ */
+@RestController
+@RequestMapping(value = "/apt/device", produces = "application/json;charset=UTF-8")
+public class AptDeviceController
+{
+ private final IAptDeviceService deviceService;
+
+ public AptDeviceController(IAptDeviceService deviceService)
+ {
+ this.deviceService = deviceService;
+ }
+
+ @PreAuthorize("@ss.hasPermi('apt:device:list')")
+ @GetMapping("/list")
+ public AjaxResult list(String deviceType, String state, String keyword, Integer limit)
+ {
+ return AjaxResult.success(deviceService.list(deviceType, state, keyword, limit));
+ }
+
+ @PreAuthorize("@ss.hasPermi('apt:device:query')")
+ @GetMapping("/{deviceCode}")
+ public AjaxResult detail(@PathVariable String deviceCode)
+ {
+ return AjaxResult.success(deviceService.detail(deviceCode));
+ }
+
+ @PreAuthorize("@ss.hasPermi('apt:device:query')")
+ @GetMapping("/{deviceCode}/commands")
+ public AjaxResult commands(@PathVariable String deviceCode, String state, Integer limit)
+ {
+ return AjaxResult.success(deviceService.commands(deviceCode, state, limit));
+ }
+
+ /** 指令入队:返回 PENDING + protocolConfirmed=false;停用设备拒绝 */
+ @PreAuthorize("@ss.hasPermi('apt:device:enqueue')")
+ @AptAudit(module = "DEVICE", action = "DEVICE_COMMAND_ENQUEUE")
+ @PostMapping("/{deviceCode}/commands")
+ public AjaxResult enqueue(@PathVariable String deviceCode,
+ @RequestBody AptDeviceCommandRequest request)
+ {
+ return AjaxResult.success(deviceService.enqueue(deviceCode, request));
+ }
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/device/controller/AptDeviceOpenController.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/device/controller/AptDeviceOpenController.java
new file mode 100644
index 0000000..a7b61ce
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/device/controller/AptDeviceOpenController.java
@@ -0,0 +1,45 @@
+package com.ruoyi.apt.device.controller;
+
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import com.ruoyi.apt.common.AptErrorCode;
+import com.ruoyi.apt.common.AptException;
+import com.ruoyi.apt.device.domain.AptDeviceHeartbeat;
+import com.ruoyi.apt.device.service.IAptDeviceService;
+import com.ruoyi.common.core.domain.AjaxResult;
+
+/**
+ * #14 设备心跳(/open/v1/** 走设备四要素鉴权)。
+ *
+ * 心跳只刷新在线态与水位/时钟偏差,不触发任何指令状态变化;
+ * 设备离线后未投递指令保持 PENDING,不伪造 SENT/ACKED。
+ *
+ * @author apt-rev01
+ */
+@RestController
+@RequestMapping(value = "/open/v1/device", produces = "application/json;charset=UTF-8")
+public class AptDeviceOpenController
+{
+ private final IAptDeviceService deviceService;
+
+ public AptDeviceOpenController(IAptDeviceService deviceService)
+ {
+ this.deviceService = deviceService;
+ }
+
+ @PostMapping("/heartbeat")
+ public AjaxResult heartbeat(
+ @RequestHeader(value = "X-Device-Code", required = false) String deviceCode,
+ @RequestBody(required = false) AptDeviceHeartbeat heartbeat)
+ {
+ if (deviceCode == null || deviceCode.isBlank())
+ {
+ throw new AptException(AptErrorCode.BAD_REQUEST, "MISSING_DEVICE_CODE",
+ "缺少 X-Device-Code");
+ }
+ return AjaxResult.success(deviceService.heartbeat(deviceCode, heartbeat));
+ }
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/device/domain/AptDeviceCommandRequest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/device/domain/AptDeviceCommandRequest.java
new file mode 100644
index 0000000..35acf89
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/device/domain/AptDeviceCommandRequest.java
@@ -0,0 +1,77 @@
+package com.ruoyi.apt.device.domain;
+
+import java.util.Date;
+import java.util.Map;
+
+/**
+ * 设备指令入队请求(M5)。
+ *
+ * 本期只入队 {@code PENDING} + {@code protocolConfirmed=false};
+ * 现场协议未确认(Q-12)前不派发、不伪造 ACKED。
+ *
+ * @author apt-rev01
+ */
+public class AptDeviceCommandRequest
+{
+ /** 幂等键,缺省由服务端生成;重复提交返回既有指令 */
+ private String commandId;
+
+ /** FACE_ISSUE / FACE_ERASE / FACE_FULL_SYNC / PLAY_TASK */
+ private String commandType;
+
+ private Map payload;
+
+ private String faceScopeRef;
+
+ private Date expireAt;
+
+ public String getCommandId()
+ {
+ return commandId;
+ }
+
+ public void setCommandId(String commandId)
+ {
+ this.commandId = commandId;
+ }
+
+ public String getCommandType()
+ {
+ return commandType;
+ }
+
+ public void setCommandType(String commandType)
+ {
+ this.commandType = commandType;
+ }
+
+ public Map getPayload()
+ {
+ return payload;
+ }
+
+ public void setPayload(Map payload)
+ {
+ this.payload = payload;
+ }
+
+ public String getFaceScopeRef()
+ {
+ return faceScopeRef;
+ }
+
+ public void setFaceScopeRef(String faceScopeRef)
+ {
+ this.faceScopeRef = faceScopeRef;
+ }
+
+ public Date getExpireAt()
+ {
+ return expireAt;
+ }
+
+ public void setExpireAt(Date expireAt)
+ {
+ this.expireAt = expireAt;
+ }
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/device/domain/AptDeviceHeartbeat.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/device/domain/AptDeviceHeartbeat.java
new file mode 100644
index 0000000..d3d521b
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/device/domain/AptDeviceHeartbeat.java
@@ -0,0 +1,47 @@
+package com.ruoyi.apt.device.domain;
+
+/**
+ * #14 设备心跳请求体(设备编码取 X-Device-Code,不在体内重复)。
+ *
+ * @author apt-rev01
+ */
+public class AptDeviceHeartbeat
+{
+ private String appVersion;
+
+ /** 设备时钟与服务器的偏差(毫秒,正值=设备快) */
+ private Long clockSkewMs;
+
+ /** 设备本地已积压的上报水位;缺省 0,不回退已有水位 */
+ private Long watermark;
+
+ public String getAppVersion()
+ {
+ return appVersion;
+ }
+
+ public void setAppVersion(String appVersion)
+ {
+ this.appVersion = appVersion;
+ }
+
+ public Long getClockSkewMs()
+ {
+ return clockSkewMs;
+ }
+
+ public void setClockSkewMs(Long clockSkewMs)
+ {
+ this.clockSkewMs = clockSkewMs;
+ }
+
+ public Long getWatermark()
+ {
+ return watermark;
+ }
+
+ public void setWatermark(Long watermark)
+ {
+ this.watermark = watermark;
+ }
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/device/mapper/AptDeviceMapper.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/device/mapper/AptDeviceMapper.java
new file mode 100644
index 0000000..3c08027
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/device/mapper/AptDeviceMapper.java
@@ -0,0 +1,41 @@
+package com.ruoyi.apt.device.mapper;
+
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+import org.apache.ibatis.annotations.Param;
+
+/**
+ * M5 设备档案 / 心跳 / 指令队列。
+ *
+ * @author apt-rev01
+ */
+public interface AptDeviceMapper
+{
+ List
*
* @author apt-rev01
*/
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/tablet/service/IAptPhotoPurgeService.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/tablet/service/IAptPhotoPurgeService.java
new file mode 100644
index 0000000..882cdc2
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/tablet/service/IAptPhotoPurgeService.java
@@ -0,0 +1,22 @@
+package com.ruoyi.apt.dispatch.tablet.service;
+
+import java.util.Map;
+
+/**
+ * 刷脸照片保留期清理(最多保留 1 个月)。
+ *
+ * 清理口径:{@code COALESCE(uploaded_at, captured_at, create_time) < now - retentionDays}
+ * 的照片,先删物理文件,再删数据库记录;文件删除失败时保留记录,下一轮重试,
+ * 避免「记录没了、文件还在」。
+ *
+ * @author apt-rev01
+ */
+public interface IAptPhotoPurgeService
+{
+ /**
+ * 执行一次清理,按公寓逐个边界清理(gyid 隔离)。
+ *
+ * @return 统计:retentionDays / cutoff / gyids / scanned / deleted / filesDeleted / filesMissing / filesFailed
+ */
+ Map purgeExpired();
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/tablet/service/IAptTabletPhotoService.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/tablet/service/IAptTabletPhotoService.java
new file mode 100644
index 0000000..8100aba
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/tablet/service/IAptTabletPhotoService.java
@@ -0,0 +1,29 @@
+package com.ruoyi.apt.dispatch.tablet.service;
+
+import java.util.Map;
+import org.springframework.web.multipart.MultipartFile;
+
+/**
+ * 平板刷脸照片受理(叫乘作业留痕)。
+ *
+ * 触发时机:平板「刷脸确认成功」= resultType {@code ANSWERED} 且
+ * faceVerifyState {@code VERIFIED} 的结果上报成功(appliedState=APPLIED)之后,
+ * 由同一平板立即调用本服务上传抓拍原图。
+ *
+ * 只落路径与校验信息,图像本体写文件/对象存储;上传失败不回滚
+ * {@code ryzt=3}(状态机已在结果受理时完成),只记录 {@code FAILED} 供重试与查询。
+ *
+ * @author apt-rev01
+ */
+public interface IAptTabletPhotoService
+{
+ /**
+ * 保存一张刷脸照片。
+ *
+ * @param deviceCode 平板编码(与结果上报同一凭据,校验归属)
+ * @param resultId 平板结果号(幂等键,一个结果只保留一张)
+ * @param file 抓拍原图(jpeg/png,默认上限 5MB)
+ * @return photoId / storagePath / url / uploadState / duplicated 等
+ */
+ Map store(String deviceCode, String resultId, MultipartFile file);
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/config/AptPhotoProperties.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/config/AptPhotoProperties.java
new file mode 100644
index 0000000..20fb23a
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/config/AptPhotoProperties.java
@@ -0,0 +1,94 @@
+package com.ruoyi.apt.framework.config;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+/**
+ * 刷脸照片存储配置({@code apt.photo.*})。
+ *
+ * 只落「路径 + 校验信息」,图像本体在文件系统/对象存储,绝不进数据库 BLOB。
+ *
+ * @author apt-rev01
+ */
+@Component
+@ConfigurationProperties(prefix = "apt.photo")
+public class AptPhotoProperties
+{
+ /** 存储根目录(绝对路径优先;相对路径以进程工作目录为基准) */
+ private String root = "./data/apt-photos";
+
+ /** 单张上限(字节),默认 5MB;超出返回 40922 */
+ private long maxSizeBytes = 5L * 1024 * 1024;
+
+ /** 对外访问前缀,仅用于回显 URL,不做静态资源映射 */
+ private String publicPrefix = "/apt/files";
+
+ /** 存储介质:LOCAL / OSS(本期仅实现 LOCAL,字段保留) */
+ private String storageTarget = "LOCAL";
+
+ /** 保留天数:超过即由清理任务删除文件与记录(默认 30 天,业务上限 1 个月) */
+ private int retentionDays = 30;
+
+ /** 单批清理条数,避免一次锁表/长时间占用连接 */
+ private int purgeBatchSize = 200;
+
+ public String getRoot()
+ {
+ return root;
+ }
+
+ public void setRoot(String root)
+ {
+ this.root = root;
+ }
+
+ public long getMaxSizeBytes()
+ {
+ return maxSizeBytes;
+ }
+
+ public void setMaxSizeBytes(long maxSizeBytes)
+ {
+ this.maxSizeBytes = maxSizeBytes;
+ }
+
+ public String getPublicPrefix()
+ {
+ return publicPrefix;
+ }
+
+ public void setPublicPrefix(String publicPrefix)
+ {
+ this.publicPrefix = publicPrefix;
+ }
+
+ public String getStorageTarget()
+ {
+ return storageTarget;
+ }
+
+ public void setStorageTarget(String storageTarget)
+ {
+ this.storageTarget = storageTarget;
+ }
+
+ public int getRetentionDays()
+ {
+ return retentionDays;
+ }
+
+ public void setRetentionDays(int retentionDays)
+ {
+ this.retentionDays = retentionDays;
+ }
+
+ public int getPurgeBatchSize()
+ {
+ return purgeBatchSize;
+ }
+
+ public void setPurgeBatchSize(int purgeBatchSize)
+ {
+ this.purgeBatchSize = purgeBatchSize;
+ }
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/config/AptProperties.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/config/AptProperties.java
index 2683ddd..08aeb6c 100644
--- a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/config/AptProperties.java
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/config/AptProperties.java
@@ -22,6 +22,7 @@ public class AptProperties
private Features features = new Features();
private Dispatch dispatch = new Dispatch();
private Voice voice = new Voice();
+ private Report report = new Report();
public String getGyId()
{
@@ -103,6 +104,16 @@ public class AptProperties
this.voice = voice;
}
+ public Report getReport()
+ {
+ return report;
+ }
+
+ public void setReport(Report report)
+ {
+ this.report = report;
+ }
+
public static class Probe
{
private boolean enabled = true;
@@ -557,4 +568,86 @@ public class AptProperties
this.maxCallsPerScan = maxCallsPerScan;
}
}
+
+ /**
+ * IT-6 上报续传(公寓端/09 表 9-16 Mock 策略)。
+ *
+ * {@code dispatchEnabled=false} 时 outbox 只累积、不派发,也不允许把记录假置 SENT;
+ * 生产默认 false,待平台联调凭据(Q-13)就绪后再开启。
+ */
+ public static class Report
+ {
+ /** 平台派发总开关;false=只入队(Mock/断网口径) */
+ private boolean dispatchEnabled = false;
+ /** 单批派发条数 */
+ private int batchSize = 200;
+ /** 自动重试上限,超过置 DEAD */
+ private int maxRetry = 5;
+ /** #14 心跳时钟偏差告警阈值(毫秒),验收口径 ≤5s */
+ private long clockSkewThresholdMs = 5000;
+ /** 设备无心跳超过该秒数视为离线(不据此伪造 ACKED) */
+ private int heartbeatTimeoutSeconds = 90;
+ /** 单次续报批次上限 */
+ private int resumeBatchLimit = 200;
+
+ public boolean isDispatchEnabled()
+ {
+ return dispatchEnabled;
+ }
+
+ public void setDispatchEnabled(boolean dispatchEnabled)
+ {
+ this.dispatchEnabled = dispatchEnabled;
+ }
+
+ public int getBatchSize()
+ {
+ return batchSize;
+ }
+
+ public void setBatchSize(int batchSize)
+ {
+ this.batchSize = batchSize;
+ }
+
+ public int getMaxRetry()
+ {
+ return maxRetry;
+ }
+
+ public void setMaxRetry(int maxRetry)
+ {
+ this.maxRetry = maxRetry;
+ }
+
+ public long getClockSkewThresholdMs()
+ {
+ return clockSkewThresholdMs;
+ }
+
+ public void setClockSkewThresholdMs(long clockSkewThresholdMs)
+ {
+ this.clockSkewThresholdMs = clockSkewThresholdMs;
+ }
+
+ public int getHeartbeatTimeoutSeconds()
+ {
+ return heartbeatTimeoutSeconds;
+ }
+
+ public void setHeartbeatTimeoutSeconds(int heartbeatTimeoutSeconds)
+ {
+ this.heartbeatTimeoutSeconds = heartbeatTimeoutSeconds;
+ }
+
+ public int getResumeBatchLimit()
+ {
+ return resumeBatchLimit;
+ }
+
+ public void setResumeBatchLimit(int resumeBatchLimit)
+ {
+ this.resumeBatchLimit = resumeBatchLimit;
+ }
+ }
}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/job/controller/AptStaffController.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/job/controller/AptStaffController.java
new file mode 100644
index 0000000..027f642
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/job/controller/AptStaffController.java
@@ -0,0 +1,88 @@
+package com.ruoyi.apt.job.controller;
+
+import com.ruoyi.apt.common.audit.AptAudit;
+import com.ruoyi.apt.job.domain.AptStaffCommand;
+import com.ruoyi.apt.job.service.IAptStaffService;
+import com.ruoyi.common.core.domain.AjaxResult;
+import jakarta.validation.Valid;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 作业人员管理(保洁 / 维修)。
+ *
+ * 人员与权限的关系:{@code sys_user_id} 关联后台账号(决定其可登录的菜单/权限点),
+ * {@code device_code} 绑定平板(平板接口据此识别执行人);停用即下线,删除为软删并解绑。
+ *
+ * @author apt-rev01
+ */
+@RestController
+@RequestMapping(value = "/apt/staff", produces = "application/json;charset=UTF-8")
+public class AptStaffController
+{
+ private final IAptStaffService staffService;
+
+ public AptStaffController(IAptStaffService staffService)
+ {
+ this.staffService = staffService;
+ }
+
+ @PreAuthorize("@ss.hasPermi('apt:staff:list')")
+ @GetMapping("/list")
+ public AjaxResult list(@RequestParam(required = false) String staffType,
+ @RequestParam(required = false) String state,
+ @RequestParam(required = false) String keyword)
+ {
+ return AjaxResult.success(staffService.list(staffType, state, keyword));
+ }
+
+ @PreAuthorize("@ss.hasPermi('apt:staff:query')")
+ @GetMapping("/{staffId}")
+ public AjaxResult detail(@PathVariable String staffId)
+ {
+ return AjaxResult.success(staffService.detail(staffId));
+ }
+
+ @PreAuthorize("@ss.hasPermi('apt:staff:add')")
+ @AptAudit(module = "STAFF", action = "STAFF_ADD")
+ @PostMapping
+ public AjaxResult add(@Valid @RequestBody AptStaffCommand command)
+ {
+ return AjaxResult.success(staffService.create(command));
+ }
+
+ @PreAuthorize("@ss.hasPermi('apt:staff:edit')")
+ @AptAudit(module = "STAFF", action = "STAFF_EDIT")
+ @PutMapping("/{staffId}")
+ public AjaxResult edit(@PathVariable String staffId, @Valid @RequestBody AptStaffCommand command)
+ {
+ return AjaxResult.success(staffService.update(staffId, command));
+ }
+
+ /** 启停用:DISABLED 同时把在岗态置 OFFLINE,平板端立即失效 */
+ @PreAuthorize("@ss.hasPermi('apt:staff:changeStatus')")
+ @AptAudit(module = "STAFF", action = "STAFF_STATE")
+ @PostMapping("/{staffId}/state")
+ public AjaxResult changeState(@PathVariable String staffId,
+ @RequestBody AptStaffCommand command)
+ {
+ return AjaxResult.success(staffService.changeState(staffId, command.getState()));
+ }
+
+ @PreAuthorize("@ss.hasPermi('apt:staff:remove')")
+ @AptAudit(module = "STAFF", action = "STAFF_DELETE")
+ @DeleteMapping("/{staffId}")
+ public AjaxResult remove(@PathVariable String staffId)
+ {
+ staffService.delete(staffId);
+ return AjaxResult.success();
+ }
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/job/controller/AptTabletJobController.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/job/controller/AptTabletJobController.java
new file mode 100644
index 0000000..a06fbc8
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/job/controller/AptTabletJobController.java
@@ -0,0 +1,162 @@
+package com.ruoyi.apt.job.controller;
+
+import java.util.Locale;
+import com.ruoyi.apt.common.AptErrorCode;
+import com.ruoyi.apt.common.AptException;
+import com.ruoyi.apt.job.domain.AptJobCommand;
+import com.ruoyi.apt.job.domain.AptJobQuery;
+import com.ruoyi.apt.job.service.IAptJobService;
+import com.ruoyi.common.core.domain.AjaxResult;
+import jakarta.validation.Valid;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 平板作业接口(保洁 / 维修),挂在 {@code /open/v1/tablet/jobs} 下。
+ *
+ * 鉴权:{@code /open/v1/**} 由设备四要素拦截器校验;执行人由设备编码反查
+ * {@code apt_staff.device_code}(未绑定返回 40301 STAFF_NOT_BOUND),请求体不接受 gyid/staffId。
+ *
+ * 保洁:上报 → 开始 → 结束;维修:上报 → (值班台派单)→ 开始 → 完成。
+ *
+ * @author apt-rev01
+ */
+@RestController
+@RequestMapping(value = "/open/v1/tablet/jobs", produces = "application/json;charset=UTF-8")
+public class AptTabletJobController
+{
+ private final IAptJobService jobService;
+
+ public AptTabletJobController(IAptJobService jobService)
+ {
+ this.jobService = jobService;
+ }
+
+ /** 我的作业列表:type=CLEAN/REPAIR(空=全部),state 可选 */
+ @GetMapping
+ public AjaxResult list(
+ @RequestHeader(value = "X-Device-Code", required = false) String deviceCode,
+ @RequestParam(required = false) String type,
+ @RequestParam(required = false) String state,
+ @RequestParam(required = false, defaultValue = "50") Integer limit)
+ {
+ AptJobQuery query = new AptJobQuery();
+ query.setState(state);
+ query.setLimit(limit);
+ return AjaxResult.success(jobService.listMine(requireDevice(deviceCode), type, query));
+ }
+
+ /** 上报待清扫(房态 → 3 待清扫;重复上报返回已有任务) */
+ @PostMapping("/clean")
+ public AjaxResult reportClean(
+ @RequestHeader(value = "X-Device-Code", required = false) String deviceCode,
+ @Valid @RequestBody AptJobCommand command)
+ {
+ return AjaxResult.success(jobService.reportClean(command, requireDevice(deviceCode)));
+ }
+
+ /** 开始保洁(PENDING → PROCESSING,房态 → 6 保洁中) */
+ @PostMapping("/clean/{taskId}/start")
+ public AjaxResult startClean(
+ @RequestHeader(value = "X-Device-Code", required = false) String deviceCode,
+ @PathVariable String taskId,
+ @RequestBody(required = false) AptJobCommand command)
+ {
+ return AjaxResult.success(jobService.startClean(taskId, command, requireDevice(deviceCode)));
+ }
+
+ /** 结束保洁(PROCESSING → DONE,房态重算 → 1/2/5) */
+ @PostMapping("/clean/{taskId}/finish")
+ public AjaxResult finishClean(
+ @RequestHeader(value = "X-Device-Code", required = false) String deviceCode,
+ @PathVariable String taskId,
+ @RequestBody(required = false) AptJobCommand command)
+ {
+ return AjaxResult.success(jobService.finishClean(taskId, command, requireDevice(deviceCode)));
+ }
+
+ /** 上报维修(房态 → 4 不能入住;同房已有未完成工单则返回该单) */
+ @PostMapping("/repair")
+ public AjaxResult reportRepair(
+ @RequestHeader(value = "X-Device-Code", required = false) String deviceCode,
+ @Valid @RequestBody AptJobCommand command)
+ {
+ return AjaxResult.success(jobService.reportRepair(command, requireDevice(deviceCode)));
+ }
+
+ /**
+ * 一键报修(房间平板「维修」按钮)。
+ *
+ * 只需房号;类别固定 OTHER、优先级固定 NORMAL,描述缺省「普通维修(平板一键报修)」。
+ * 同房已有未完成工单时返回该单并标记 duplicated=true,不会重复建单。
+ */
+ @PostMapping("/repair/quick")
+ public AjaxResult quickRepair(
+ @RequestHeader(value = "X-Device-Code", required = false) String deviceCode,
+ @RequestBody(required = false) AptJobCommand command)
+ {
+ AptJobCommand cmd = command == null ? new AptJobCommand() : command;
+ cmd.setCategory("OTHER");
+ cmd.setPriority("NORMAL");
+ if (cmd.getDescription() == null || cmd.getDescription().isBlank())
+ {
+ cmd.setDescription("普通维修(平板一键报修)");
+ }
+ return AjaxResult.success(jobService.reportRepair(cmd, requireDevice(deviceCode)));
+ }
+
+ /** 开始维修(REPORTED/ASSIGNED → IN_PROGRESS) */
+ @PostMapping("/repair/{orderId}/start")
+ public AjaxResult startRepair(
+ @RequestHeader(value = "X-Device-Code", required = false) String deviceCode,
+ @PathVariable String orderId,
+ @RequestBody(required = false) AptJobCommand command)
+ {
+ return AjaxResult.success(jobService.startRepair(orderId, command, requireDevice(deviceCode)));
+ }
+
+ /** 完成维修(IN_PROGRESS → DONE,result 必填;房态重算) */
+ @PostMapping("/repair/{orderId}/finish")
+ public AjaxResult finishRepair(
+ @RequestHeader(value = "X-Device-Code", required = false) String deviceCode,
+ @PathVariable String orderId,
+ @RequestBody AptJobCommand command)
+ {
+ return AjaxResult.success(jobService.finishRepair(orderId, command, requireDevice(deviceCode)));
+ }
+
+ /**
+ * 一键完成维修(房间平板提交结束状态)。
+ *
+ * 与 /finish 的区别:result 可省略,缺省「已修复(平板提交)」;
+ * 仍走 IN_PROGRESS → DONE 条件更新,重复提交按幂等返回 duplicated=true。
+ */
+ @PostMapping("/repair/{orderId}/complete")
+ public AjaxResult completeRepair(
+ @RequestHeader(value = "X-Device-Code", required = false) String deviceCode,
+ @PathVariable String orderId,
+ @RequestBody(required = false) AptJobCommand command)
+ {
+ AptJobCommand cmd = command == null ? new AptJobCommand() : command;
+ if (cmd.getResult() == null || cmd.getResult().isBlank())
+ {
+ cmd.setResult("已修复(平板提交)");
+ }
+ return AjaxResult.success(jobService.finishRepair(orderId, cmd, requireDevice(deviceCode)));
+ }
+
+ private String requireDevice(String deviceCode)
+ {
+ if (deviceCode == null || deviceCode.isBlank())
+ {
+ throw new AptException(AptErrorCode.UNAUTHORIZED, "DEVICE_CREDENTIAL_MISSING", "缺少设备凭据 X-Device-Code");
+ }
+ return deviceCode.trim().toUpperCase(Locale.ROOT);
+ }
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/job/controller/AptWorkOrderController.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/job/controller/AptWorkOrderController.java
new file mode 100644
index 0000000..2ec7e0e
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/job/controller/AptWorkOrderController.java
@@ -0,0 +1,103 @@
+package com.ruoyi.apt.job.controller;
+
+import java.util.Date;
+import com.ruoyi.apt.common.audit.AptAudit;
+import com.ruoyi.apt.job.domain.AptJobCommand;
+import com.ruoyi.apt.job.domain.AptJobQuery;
+import com.ruoyi.apt.job.service.IAptJobService;
+import com.ruoyi.common.core.domain.AjaxResult;
+import org.springframework.format.annotation.DateTimeFormat;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 作业工单(保洁 / 维修)管理与统计,值班台/后台使用。
+ *
+ * 平板侧的上报与执行在 {@code /open/v1/tablet/jobs};本控制器只做查询、派单、取消与统计,
+ * 状态推进仍由作业人员在平板上完成,避免后台越权改状态。
+ *
+ * @author apt-rev01
+ */
+@RestController
+@RequestMapping(value = "/apt/workorder", produces = "application/json;charset=UTF-8")
+public class AptWorkOrderController
+{
+ private final IAptJobService jobService;
+
+ public AptWorkOrderController(IAptJobService jobService)
+ {
+ this.jobService = jobService;
+ }
+
+ /** 工单列表:type/state/roomNo/staffId/时间区间,按业务时间倒序 */
+ @PreAuthorize("@ss.hasPermi('apt:workorder:list')")
+ @GetMapping("/list")
+ public AjaxResult list(
+ @RequestParam(required = false) String type,
+ @RequestParam(required = false) String state,
+ @RequestParam(required = false) String roomNo,
+ @RequestParam(required = false) String staffId,
+ @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") Date from,
+ @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") Date to,
+ @RequestParam(required = false, defaultValue = "100") Integer limit)
+ {
+ return AjaxResult.success(jobService.list(type, query(state, roomNo, staffId, from, to, limit)));
+ }
+
+ /** 统计:总量/完成/在办、按类型+状态分布、人员工作量(含平均时长) */
+ @PreAuthorize("@ss.hasPermi('apt:workorder:stats')")
+ @GetMapping("/stats")
+ public AjaxResult stats(
+ @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") Date from,
+ @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") Date to)
+ {
+ return AjaxResult.success(jobService.stats(query(null, null, null, from, to, null)));
+ }
+
+ /** 工单详情(含状态流水):type=CLEAN/REPAIR */
+ @PreAuthorize("@ss.hasPermi('apt:workorder:query')")
+ @GetMapping("/{type}/{jobId}")
+ public AjaxResult detail(@PathVariable String type, @PathVariable String jobId)
+ {
+ return AjaxResult.success(jobService.detail(type, jobId));
+ }
+
+ /** 维修派单/改派 */
+ @PreAuthorize("@ss.hasPermi('apt:workorder:assign')")
+ @AptAudit(module = "JOB", action = "REPAIR_ASSIGN")
+ @PostMapping("/repair/{orderId}/assign")
+ public AjaxResult assign(@PathVariable String orderId, @RequestBody AptJobCommand command)
+ {
+ return AjaxResult.success(jobService.assignRepair(orderId, command.getStaffId()));
+ }
+
+ /** 取消工单(误报/重复报修),房态随之重算 */
+ @PreAuthorize("@ss.hasPermi('apt:workorder:handle')")
+ @AptAudit(module = "JOB", action = "REPAIR_CANCEL")
+ @PostMapping("/repair/{orderId}/cancel")
+ public AjaxResult cancel(@PathVariable String orderId, @RequestBody AptJobCommand command)
+ {
+ return AjaxResult.success(jobService.cancelRepair(orderId, command.getRemark()));
+ }
+
+ private AptJobQuery query(String state, String roomNo, String staffId, Date from, Date to, Integer limit)
+ {
+ AptJobQuery query = new AptJobQuery();
+ query.setState(state);
+ query.setRoomNo(roomNo);
+ query.setStaffId(staffId);
+ query.setFrom(from);
+ query.setTo(to);
+ if (limit != null)
+ {
+ query.setLimit(limit);
+ }
+ return query;
+ }
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/job/domain/AptJobCommand.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/job/domain/AptJobCommand.java
new file mode 100644
index 0000000..88a07c5
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/job/domain/AptJobCommand.java
@@ -0,0 +1,137 @@
+package com.ruoyi.apt.job.domain;
+
+import jakarta.validation.constraints.Size;
+
+/**
+ * 保洁 / 维修作业命令(平板侧与值班台侧共用,按接口取用字段)。
+ *
+ * @author apt-rev01
+ */
+public class AptJobCommand
+{
+ /** 房号(上报时必填) */
+ @Size(max = 32, message = "房号长度不能超过 32")
+ private String roomNo;
+
+ /** 保洁任务号(开始/结束时使用) */
+ @Size(max = 64)
+ private String taskId;
+
+ /** 维修工单号(派单/开始/结束/取消时使用) */
+ @Size(max = 64)
+ private String orderId;
+
+ /** 维修类别:ELECTRIC / PLUMBING / DOOR_WINDOW / AIRCON / OTHER */
+ @Size(max = 20)
+ private String category;
+
+ /** 故障描述(维修上报必填) */
+ @Size(max = 500)
+ private String description;
+
+ /** 优先级:NORMAL / URGENT */
+ @Size(max = 10)
+ private String priority;
+
+ /** 维修结果(完成时必填) */
+ @Size(max = 255)
+ private String result;
+
+ /** 备注(结束保洁 / 取消工单等) */
+ @Size(max = 500)
+ private String remark;
+
+ /** 指派人员(值班台派单时使用) */
+ @Size(max = 64)
+ private String staffId;
+
+ public String getRoomNo()
+ {
+ return roomNo;
+ }
+
+ public void setRoomNo(String roomNo)
+ {
+ this.roomNo = roomNo;
+ }
+
+ public String getTaskId()
+ {
+ return taskId;
+ }
+
+ public void setTaskId(String taskId)
+ {
+ this.taskId = taskId;
+ }
+
+ public String getOrderId()
+ {
+ return orderId;
+ }
+
+ public void setOrderId(String orderId)
+ {
+ this.orderId = orderId;
+ }
+
+ public String getCategory()
+ {
+ return category;
+ }
+
+ public void setCategory(String category)
+ {
+ this.category = category;
+ }
+
+ public String getDescription()
+ {
+ return description;
+ }
+
+ public void setDescription(String description)
+ {
+ this.description = description;
+ }
+
+ public String getPriority()
+ {
+ return priority;
+ }
+
+ public void setPriority(String priority)
+ {
+ this.priority = priority;
+ }
+
+ public String getResult()
+ {
+ return result;
+ }
+
+ public void setResult(String result)
+ {
+ this.result = result;
+ }
+
+ public String getRemark()
+ {
+ return remark;
+ }
+
+ public void setRemark(String remark)
+ {
+ this.remark = remark;
+ }
+
+ public String getStaffId()
+ {
+ return staffId;
+ }
+
+ public void setStaffId(String staffId)
+ {
+ this.staffId = staffId;
+ }
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/job/domain/AptJobQuery.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/job/domain/AptJobQuery.java
new file mode 100644
index 0000000..7a0990c
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/job/domain/AptJobQuery.java
@@ -0,0 +1,115 @@
+package com.ruoyi.apt.job.domain;
+
+import java.util.Date;
+
+/**
+ * 作业工单查询条件(管理端列表 / 统计共用)。
+ *
+ * @author apt-rev01
+ */
+public class AptJobQuery
+{
+ /** CLEAN / REPAIR,空=两类都查 */
+ private String type;
+
+ /** 工单状态,空=全部 */
+ private String state;
+
+ private String roomNo;
+
+ private String staffId;
+
+ /** 统计区间起(含) */
+ private Date from;
+
+ /** 统计区间止(含) */
+ private Date to;
+
+ private Integer limit = 100;
+
+ /**
+ * 平板「我的作业」是否带出未分配公共池(保洁 PENDING / 维修 REPORTED)。
+ * 管理端恒为 false,只看指定执行人。
+ */
+ private Boolean includeUnassigned = Boolean.FALSE;
+
+ public String getType()
+ {
+ return type;
+ }
+
+ public void setType(String type)
+ {
+ this.type = type;
+ }
+
+ public String getState()
+ {
+ return state;
+ }
+
+ public void setState(String state)
+ {
+ this.state = state;
+ }
+
+ public String getRoomNo()
+ {
+ return roomNo;
+ }
+
+ public void setRoomNo(String roomNo)
+ {
+ this.roomNo = roomNo;
+ }
+
+ public String getStaffId()
+ {
+ return staffId;
+ }
+
+ public void setStaffId(String staffId)
+ {
+ this.staffId = staffId;
+ }
+
+ public Date getFrom()
+ {
+ return from;
+ }
+
+ public void setFrom(Date from)
+ {
+ this.from = from;
+ }
+
+ public Date getTo()
+ {
+ return to;
+ }
+
+ public void setTo(Date to)
+ {
+ this.to = to;
+ }
+
+ public Integer getLimit()
+ {
+ return limit;
+ }
+
+ public void setLimit(Integer limit)
+ {
+ this.limit = limit;
+ }
+
+ public Boolean getIncludeUnassigned()
+ {
+ return includeUnassigned;
+ }
+
+ public void setIncludeUnassigned(Boolean includeUnassigned)
+ {
+ this.includeUnassigned = includeUnassigned;
+ }
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/job/domain/AptStaffCommand.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/job/domain/AptStaffCommand.java
new file mode 100644
index 0000000..dff1113
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/job/domain/AptStaffCommand.java
@@ -0,0 +1,135 @@
+package com.ruoyi.apt.job.domain;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+
+/**
+ * 作业人员维护命令(保洁 / 维修)。
+ *
+ * @author apt-rev01
+ */
+public class AptStaffCommand
+{
+ /** 人员业务键,空则由后台生成(编辑时必填) */
+ @Size(max = 64)
+ private String staffId;
+
+ /** CLEANER / REPAIRMAN / BOTH */
+ @NotBlank(message = "staffType 不能为空")
+ @Size(max = 20)
+ private String staffType;
+
+ @NotBlank(message = "姓名不能为空")
+ @Size(max = 64)
+ private String name;
+
+ @Size(max = 32)
+ private String workNo;
+
+ @Size(max = 32)
+ private String phone;
+
+ /** 绑定后台账号,用于权限归属(可空) */
+ private Long sysUserId;
+
+ /** 绑定平板设备编码(可空,绑定后平板免登识别人员) */
+ @Size(max = 64)
+ private String deviceCode;
+
+ /** ACTIVE / DISABLED(启停用) */
+ @Size(max = 20)
+ private String state;
+
+ @Size(max = 255)
+ private String remark;
+
+ public String getStaffId()
+ {
+ return staffId;
+ }
+
+ public void setStaffId(String staffId)
+ {
+ this.staffId = staffId;
+ }
+
+ public String getStaffType()
+ {
+ return staffType;
+ }
+
+ public void setStaffType(String staffType)
+ {
+ this.staffType = staffType;
+ }
+
+ public String getName()
+ {
+ return name;
+ }
+
+ public void setName(String name)
+ {
+ this.name = name;
+ }
+
+ public String getWorkNo()
+ {
+ return workNo;
+ }
+
+ public void setWorkNo(String workNo)
+ {
+ this.workNo = workNo;
+ }
+
+ public String getPhone()
+ {
+ return phone;
+ }
+
+ public void setPhone(String phone)
+ {
+ this.phone = phone;
+ }
+
+ public Long getSysUserId()
+ {
+ return sysUserId;
+ }
+
+ public void setSysUserId(Long sysUserId)
+ {
+ this.sysUserId = sysUserId;
+ }
+
+ public String getDeviceCode()
+ {
+ return deviceCode;
+ }
+
+ public void setDeviceCode(String deviceCode)
+ {
+ this.deviceCode = deviceCode;
+ }
+
+ public String getState()
+ {
+ return state;
+ }
+
+ public void setState(String state)
+ {
+ this.state = state;
+ }
+
+ public String getRemark()
+ {
+ return remark;
+ }
+
+ public void setRemark(String remark)
+ {
+ this.remark = remark;
+ }
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/job/mapper/AptJobMapper.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/job/mapper/AptJobMapper.java
new file mode 100644
index 0000000..6bbe709
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/job/mapper/AptJobMapper.java
@@ -0,0 +1,143 @@
+package com.ruoyi.apt.job.mapper;
+
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+import com.ruoyi.apt.job.domain.AptJobQuery;
+import org.apache.ibatis.annotations.Param;
+
+/**
+ * 平板作业(保洁 / 维修)与作业人员数据访问。
+ *
+ * 硬约束:每条 SQL 必带 {@code gyid};状态迁移全部用「条件更新 + 影响行数」表达,
+ * 冲突由服务层翻译成 40902;本 Mapper 不写 {@code gy_jcxx}。
+ *
+ * @author apt-rev01
+ */
+public interface AptJobMapper
+{
+ /** 按房号定位房间运行时记录(房态联动用) */
+ Map selectRoomByNo(@Param("gyid") String gyid, @Param("roomNo") String roomNo);
+
+ // ------------------------------------------------------------- 保洁任务
+ Map selectCleanTask(@Param("gyid") String gyid, @Param("taskId") String taskId);
+
+ Map selectActiveCleanTaskByRoom(@Param("gyid") String gyid, @Param("roomId") String roomId);
+
+ int insertCleanTask(@Param("gyid") String gyid, @Param("taskId") String taskId,
+ @Param("roomId") String roomId, @Param("roomNo") String roomNo,
+ @Param("staffId") String staffId, @Param("remark") String remark,
+ @Param("scheduledAt") Date scheduledAt,
+ @Param("source") String source);
+
+ /**
+ * 按 {@code gy_jcxx} 在寓事实重算房间在寓人数(finish_ly='0' AND sjlysj IS NULL)。
+ * 离寓后调用,避免只靠 +1/-1 造成长期偏差。
+ */
+ int syncRoomOccupancy(@Param("gyid") String gyid, @Param("roomNo") String roomNo);
+
+ /** 房间有效在寓人数(离寓判定的唯一口径) */
+ int countLiveStayByRoom(@Param("gyid") String gyid, @Param("roomNo") String roomNo);
+
+ /** PENDING -> PROCESSING,影响行数 0 表示状态已变(幂等/冲突由服务层判断) */
+ int startCleanTask(@Param("gyid") String gyid, @Param("taskId") String taskId,
+ @Param("staffId") String staffId, @Param("remark") String remark);
+
+ /** PROCESSING -> DONE,记录 finished_at */
+ int finishCleanTask(@Param("gyid") String gyid, @Param("taskId") String taskId,
+ @Param("remark") String remark);
+
+ List> selectCleanList(@Param("gyid") String gyid,
+ @Param("q") AptJobQuery query);
+
+ // ------------------------------------------------------------- 维修工单
+ Map selectRepairOrder(@Param("gyid") String gyid, @Param("orderId") String orderId);
+
+ Map selectActiveRepairByRoom(@Param("gyid") String gyid, @Param("roomId") String roomId);
+
+ int countActiveRepairByRoom(@Param("gyid") String gyid, @Param("roomId") String roomId);
+
+ int insertRepairOrder(@Param("gyid") String gyid, @Param("orderId") String orderId,
+ @Param("roomId") String roomId, @Param("roomNo") String roomNo,
+ @Param("category") String category, @Param("description") String description,
+ @Param("priority") String priority, @Param("source") String source,
+ @Param("reporterStaffId") String reporterStaffId,
+ @Param("reporterName") String reporterName,
+ @Param("roomStatusBefore") String roomStatusBefore);
+
+ /** REPORTED/ASSIGNED -> ASSIGNED(允许改派) */
+ int assignRepair(@Param("gyid") String gyid, @Param("orderId") String orderId,
+ @Param("staffId") String staffId, @Param("staffName") String staffName);
+
+ /** REPORTED/ASSIGNED -> IN_PROGRESS */
+ int startRepair(@Param("gyid") String gyid, @Param("orderId") String orderId,
+ @Param("staffId") String staffId, @Param("staffName") String staffName);
+
+ /** IN_PROGRESS -> DONE,记录结果、时长与完成时间 */
+ int finishRepair(@Param("gyid") String gyid, @Param("orderId") String orderId,
+ @Param("result") String result, @Param("remark") String remark);
+
+ /** 非终态 -> CANCELLED */
+ int cancelRepair(@Param("gyid") String gyid, @Param("orderId") String orderId,
+ @Param("remark") String remark);
+
+ /** 完成后回写房间最终房态,便于追溯(不参与状态判定) */
+ int updateRepairRoomAfter(@Param("gyid") String gyid, @Param("orderId") String orderId,
+ @Param("roomStatusAfter") String roomStatusAfter);
+
+ List> selectRepairList(@Param("gyid") String gyid,
+ @Param("q") AptJobQuery query);
+
+ // --------------------------------------------------------------- 统计
+ Map selectJobStats(@Param("gyid") String gyid,
+ @Param("from") Date from, @Param("to") Date to);
+
+ List> selectJobStatsByType(@Param("gyid") String gyid,
+ @Param("from") Date from, @Param("to") Date to);
+
+ List> selectStaffWorkload(@Param("gyid") String gyid,
+ @Param("from") Date from, @Param("to") Date to);
+
+ // ------------------------------------------------------------- 状态流水
+ int insertJobEvent(@Param("gyid") String gyid, @Param("eventId") String eventId,
+ @Param("bizType") String bizType, @Param("bizId") String bizId,
+ @Param("roomId") String roomId, @Param("fromState") String fromState,
+ @Param("toState") String toState, @Param("operatorStaff") String operatorStaff,
+ @Param("operatorName") String operatorName,
+ @Param("deviceCode") String deviceCode, @Param("remark") String remark);
+
+ List> selectJobEvents(@Param("gyid") String gyid,
+ @Param("bizType") String bizType,
+ @Param("bizId") String bizId);
+
+ // --------------------------------------------------------------- 人员
+ Map selectStaff(@Param("gyid") String gyid, @Param("staffId") String staffId);
+
+ Map selectStaffByDevice(@Param("gyid") String gyid,
+ @Param("deviceCode") String deviceCode);
+
+ List> selectStaffList(@Param("gyid") String gyid,
+ @Param("staffType") String staffType,
+ @Param("state") String state,
+ @Param("keyword") String keyword);
+
+ int countStaffWorkNo(@Param("gyid") String gyid, @Param("workNo") String workNo,
+ @Param("excludeStaffId") String excludeStaffId);
+
+ int insertStaff(@Param("gyid") String gyid, @Param("staffId") String staffId,
+ @Param("staffType") String staffType, @Param("name") String name,
+ @Param("workNo") String workNo, @Param("phone") String phone,
+ @Param("sysUserId") Long sysUserId, @Param("deviceCode") String deviceCode,
+ @Param("remark") String remark, @Param("createBy") String createBy);
+
+ int updateStaff(@Param("gyid") String gyid, @Param("staffId") String staffId,
+ @Param("staffType") String staffType, @Param("name") String name,
+ @Param("workNo") String workNo, @Param("phone") String phone,
+ @Param("sysUserId") Long sysUserId, @Param("deviceCode") String deviceCode,
+ @Param("remark") String remark);
+
+ int updateStaffState(@Param("gyid") String gyid, @Param("staffId") String staffId,
+ @Param("state") String state);
+
+ int softDeleteStaff(@Param("gyid") String gyid, @Param("staffId") String staffId);
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/job/service/AptJobServiceImpl.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/job/service/AptJobServiceImpl.java
new file mode 100644
index 0000000..053ca9b
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/job/service/AptJobServiceImpl.java
@@ -0,0 +1,756 @@
+package com.ruoyi.apt.job.service;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.Date;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.UUID;
+import com.alibaba.fastjson2.JSON;
+import com.ruoyi.apt.common.AptContext;
+import com.ruoyi.apt.common.AptErrorCode;
+import com.ruoyi.apt.common.AptException;
+import com.ruoyi.apt.framework.audit.AptAuditWriter;
+import com.ruoyi.apt.job.domain.AptJobCommand;
+import com.ruoyi.apt.job.domain.AptJobQuery;
+import com.ruoyi.apt.job.mapper.AptJobMapper;
+import com.ruoyi.apt.ops.domain.AptOperationLog;
+import com.ruoyi.apt.stay.domain.AptRoom;
+import com.ruoyi.apt.stay.mapper.AptRoomMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Propagation;
+import org.springframework.transaction.annotation.Transactional;
+
+/**
+ * 保洁 / 维修作业实现。
+ *
+ * 房态联动口径(公寓端/04 表 4-7):
+ *
+ * 3 待清扫 ← 保洁任务 PENDING
+ * 6 保洁中 ← 保洁任务 PROCESSING
+ * 4 不能入住 ← 存在未完成维修单(disabled_reason=REPAIR:<orderId>)
+ * 1/2/5 ← 无上述情况时按在寓人数与床位数重算
+ *
+ *
+ * 所有状态迁移用条件更新,重复提交按幂等返回 duplicated=true,非法迁移返回 40902。
+ *
+ * @author apt-rev01
+ */
+@Service
+public class AptJobServiceImpl implements IAptJobService
+{
+ private static final Logger log = LoggerFactory.getLogger(AptJobServiceImpl.class);
+
+ private static final String CLEAN = "CLEAN";
+ private static final String REPAIR = "REPAIR";
+
+ private static final String PENDING = "PENDING";
+ private static final String PROCESSING = "PROCESSING";
+ private static final String DONE = "DONE";
+ private static final String REPORTED = "REPORTED";
+ private static final String ASSIGNED = "ASSIGNED";
+ private static final String IN_PROGRESS = "IN_PROGRESS";
+ private static final String CANCELLED = "CANCELLED";
+
+ private static final String STATUS_IDLE = "1";
+ private static final String STATUS_FULL = "2";
+ private static final String STATUS_TO_CLEAN = "3";
+ private static final String STATUS_BLOCKED = "4";
+ private static final String STATUS_PARTIAL = "5";
+ private static final String STATUS_CLEANING = "6";
+
+ private final AptJobMapper jobMapper;
+ private final AptRoomMapper roomMapper;
+ private final AptAuditWriter auditWriter;
+
+ public AptJobServiceImpl(AptJobMapper jobMapper, AptRoomMapper roomMapper, AptAuditWriter auditWriter)
+ {
+ this.jobMapper = jobMapper;
+ this.roomMapper = roomMapper;
+ this.auditWriter = auditWriter;
+ }
+
+ // ================================================================== 保洁
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public Map reportClean(AptJobCommand command, String deviceCode)
+ {
+ Map staff = requireStaff(deviceCode);
+ AptRoom room = requireRoom(command == null ? null : command.getRoomNo());
+ Map active = jobMapper.selectActiveCleanTaskByRoom(gyid(), room.getRoomId());
+ if (active != null && !active.isEmpty())
+ {
+ // 平板重试/重复上报:返回已有任务,不再新建,避免同房多条待清扫
+ Map result = new LinkedHashMap<>(active);
+ result.put("duplicated", true);
+ return result;
+ }
+ String taskId = "CT-" + suffix();
+ try
+ {
+ jobMapper.insertCleanTask(gyid(), taskId, room.getRoomId(), room.getRoomNo(),
+ text(staff.get("staffId")), command == null ? null : command.getRemark(),
+ new Date(), "TABLET");
+ }
+ catch (DuplicateKeyException e)
+ {
+ // 并发上报 / 自动建单同时命中:唯一键保证同房只有一条活跃任务,返回已存在的那条
+ Map created = jobMapper.selectActiveCleanTaskByRoom(gyid(), room.getRoomId());
+ Map result = new LinkedHashMap<>(created == null ? Map.of() : created);
+ result.put("duplicated", true);
+ return result;
+ }
+ event(CLEAN, taskId, room.getRoomId(), null, PENDING, staff, deviceCode, command);
+ String roomStatus = applyRoomStatus(room, STATUS_TO_CLEAN, null);
+ log.info("保洁上报:taskId={}, room={}, staff={}", taskId, room.getRoomNo(), staff.get("staffId"));
+ return result(taskId, room, roomStatus, PENDING, false);
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public Map startClean(String taskId, AptJobCommand command, String deviceCode)
+ {
+ Map staff = requireStaff(deviceCode);
+ Map task = requireCleanTask(taskId);
+ int affected = jobMapper.startCleanTask(gyid(), taskId, text(staff.get("staffId")),
+ command == null ? null : command.getRemark());
+ if (affected == 0)
+ {
+ Map current = jobMapper.selectCleanTask(gyid(), taskId);
+ if (current != null && PROCESSING.equals(text(current.get("state"))))
+ {
+ return result(taskId, roomOf(current), text(roomOf(current).getRoomStatus()), PROCESSING, true);
+ }
+ throw stateIllegal("仅待清扫任务可开始保洁(当前 " + text(current == null ? null : current.get("state")) + ")");
+ }
+ event(CLEAN, taskId, text(task.get("roomId")), PENDING, PROCESSING, staff, deviceCode, command);
+ AptRoom room = roomById(text(task.get("roomId")));
+ String roomStatus = applyRoomStatus(room, STATUS_CLEANING, null);
+ log.info("保洁开始:taskId={}, room={}, staff={}", taskId, room.getRoomNo(), staff.get("staffId"));
+ return result(taskId, room, roomStatus, PROCESSING, false);
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public Map finishClean(String taskId, AptJobCommand command, String deviceCode)
+ {
+ Map staff = requireStaff(deviceCode);
+ Map task = requireCleanTask(taskId);
+ int affected = jobMapper.finishCleanTask(gyid(), taskId, command == null ? null : command.getRemark());
+ if (affected == 0)
+ {
+ Map current = jobMapper.selectCleanTask(gyid(), taskId);
+ if (current != null && DONE.equals(text(current.get("state"))))
+ {
+ AptRoom room = roomById(text(current.get("roomId")));
+ return result(taskId, room, text(room.getRoomStatus()), DONE, true);
+ }
+ throw stateIllegal("仅保洁中的任务可结束(当前 " + text(current == null ? null : current.get("state")) + ")");
+ }
+ event(CLEAN, taskId, text(task.get("roomId")), PROCESSING, DONE, staff, deviceCode, command);
+ AptRoom room = roomById(text(task.get("roomId")));
+ String roomStatus = refreshRoomStatus(room);
+ log.info("保洁结束:taskId={}, room={} -> 房态 {}", taskId, room.getRoomNo(), roomStatus);
+ return result(taskId, room, roomStatus, DONE, false);
+ }
+
+ // ================================================================== 维修
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public Map reportRepair(AptJobCommand command, String deviceCode)
+ {
+ Map staff = requireStaff(deviceCode);
+ AptRoom room = requireRoom(command == null ? null : command.getRoomNo());
+ if (command.getDescription() == null || command.getDescription().isBlank())
+ {
+ throw badRequest("MISSING_DESCRIPTION", "故障描述不能为空");
+ }
+ Map active = jobMapper.selectActiveRepairByRoom(gyid(), room.getRoomId());
+ if (active != null && !active.isEmpty())
+ {
+ Map result = new LinkedHashMap<>(active);
+ result.put("duplicated", true);
+ return result;
+ }
+ String orderId = "RO-" + suffix();
+ jobMapper.insertRepairOrder(gyid(), orderId, room.getRoomId(), room.getRoomNo(),
+ normalizeCategory(command.getCategory()), command.getDescription().trim(),
+ normalizePriority(command.getPriority()), "TABLET",
+ text(staff.get("staffId")), text(staff.get("name")), room.getRoomStatus());
+ event(REPAIR, orderId, room.getRoomId(), null, REPORTED, staff, deviceCode, command);
+ // 维修期间不能入住:房态 4 + 原因标记,便于值班台区分停用与维修
+ String roomStatus = applyRoomStatus(room, STATUS_BLOCKED, "REPAIR:" + orderId);
+ log.info("维修上报:orderId={}, room={}, reporter={}", orderId, room.getRoomNo(), staff.get("staffId"));
+ return result(orderId, room, roomStatus, REPORTED, false);
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public Map assignRepair(String orderId, String staffId)
+ {
+ Map order = requireRepairOrder(orderId);
+ Map staff = jobMapper.selectStaff(gyid(), staffId);
+ if (staff == null || staff.isEmpty())
+ {
+ throw new AptException(AptErrorCode.NOT_FOUND, "STAFF_NOT_FOUND", "作业人员不存在:" + staffId);
+ }
+ if (!"ACTIVE".equals(text(staff.get("state"))))
+ {
+ throw stateIllegal("该人员已停用,不能派单");
+ }
+ if ("CLEANER".equals(text(staff.get("staffType"))))
+ {
+ throw stateIllegal("保洁人员不能承接维修工单");
+ }
+ int affected = jobMapper.assignRepair(gyid(), orderId, staffId, text(staff.get("name")));
+ if (affected == 0)
+ {
+ return duplicatedOrIllegal(orderId, ASSIGNED);
+ }
+ event(REPAIR, orderId, text(order.get("roomId")), text(order.get("state")), ASSIGNED, staff, null, null);
+ AptRoom room = roomById(text(order.get("roomId")));
+ return result(orderId, room, text(room.getRoomStatus()), ASSIGNED, false);
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public Map startRepair(String orderId, AptJobCommand command, String deviceCode)
+ {
+ Map staff = requireStaff(deviceCode);
+ Map order = requireRepairOrder(orderId);
+ int affected = jobMapper.startRepair(gyid(), orderId, text(staff.get("staffId")), text(staff.get("name")));
+ if (affected == 0)
+ {
+ return duplicatedOrIllegal(orderId, IN_PROGRESS);
+ }
+ event(REPAIR, orderId, text(order.get("roomId")), text(order.get("state")), IN_PROGRESS, staff, deviceCode, command);
+ AptRoom room = roomById(text(order.get("roomId")));
+ return result(orderId, room, text(room.getRoomStatus()), IN_PROGRESS, false);
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public Map finishRepair(String orderId, AptJobCommand command, String deviceCode)
+ {
+ Map staff = requireStaff(deviceCode);
+ Map order = requireRepairOrder(orderId);
+ if (command == null || command.getResult() == null || command.getResult().isBlank())
+ {
+ throw badRequest("MISSING_RESULT", "完成维修必须填写维修结果");
+ }
+ int affected = jobMapper.finishRepair(gyid(), orderId, command.getResult().trim(), command.getRemark());
+ if (affected == 0)
+ {
+ return duplicatedOrIllegal(orderId, DONE);
+ }
+ event(REPAIR, orderId, text(order.get("roomId")), IN_PROGRESS, DONE, staff, deviceCode, command);
+ AptRoom room = roomById(text(order.get("roomId")));
+ String roomStatus = refreshRoomStatus(room);
+ jobMapper.updateRepairRoomAfter(gyid(), orderId, roomStatus);
+ log.info("维修完成:orderId={}, room={} -> 房态 {}", orderId, room.getRoomNo(), roomStatus);
+ return result(orderId, room, roomStatus, DONE, false);
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public Map cancelRepair(String orderId, String remark)
+ {
+ Map order = requireRepairOrder(orderId);
+ int affected = jobMapper.cancelRepair(gyid(), orderId, remark);
+ if (affected == 0)
+ {
+ return duplicatedOrIllegal(orderId, CANCELLED);
+ }
+ event(REPAIR, orderId, text(order.get("roomId")), text(order.get("state")), CANCELLED, null, null, null);
+ AptRoom room = roomById(text(order.get("roomId")));
+ String roomStatus = refreshRoomStatus(room);
+ jobMapper.updateRepairRoomAfter(gyid(), orderId, roomStatus);
+ return result(orderId, room, roomStatus, CANCELLED, false);
+ }
+
+ // ============================================================ 离寓联动(自动)
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public int syncRoomOccupancy(String roomNo)
+ {
+ if (roomNo == null || roomNo.isBlank())
+ {
+ return 0;
+ }
+ String normalized = roomNo.trim();
+ Map row = jobMapper.selectRoomByNo(gyid(), normalized);
+ if (row == null || row.isEmpty())
+ {
+ log.warn("离寓联动:房号不存在,跳过占用重算 roomNo={}", normalized);
+ return 0;
+ }
+ String roomId = text(row.get("roomId"));
+ // 以 gy_jcxx 未离寓记录数为唯一口径重算,避免 +1/-1 长期漂移
+ jobMapper.syncRoomOccupancy(gyid(), normalized);
+ int live = jobMapper.countLiveStayByRoom(gyid(), normalized);
+ AptRoom room = roomById(roomId);
+ if (live <= 0)
+ {
+ applyVacantRoomStatus(room);
+ }
+ else
+ {
+ refreshRoomStatus(room);
+ }
+ log.info("离寓联动:room={}, 剩余在寓={}, 房态={}", normalized, live, room.getRoomStatus());
+ return live;
+ }
+
+ @Override
+ @Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)
+ public Map autoCreateCleanOnVacated(String roomNo, String reason)
+ {
+ Map result = new LinkedHashMap<>();
+ result.put("created", false);
+ result.put("roomNo", roomNo);
+ if (roomNo == null || roomNo.isBlank())
+ {
+ result.put("reason", "MISSING_ROOMNO");
+ return result;
+ }
+ String normalized = roomNo.trim();
+ Map row = jobMapper.selectRoomByNo(gyid(), normalized);
+ if (row == null || row.isEmpty())
+ {
+ result.put("reason", "ROOM_NOT_FOUND");
+ return result;
+ }
+ String roomId = text(row.get("roomId"));
+ int live = jobMapper.countLiveStayByRoom(gyid(), normalized);
+ if (live > 0)
+ {
+ // 有人返回(离寓后又有人入住):不建单,避免给在住房间挂待清扫
+ result.put("reason", "OCCUPIED");
+ result.put("liveCount", live);
+ return result;
+ }
+ Map active = jobMapper.selectActiveCleanTaskByRoom(gyid(), roomId);
+ if (active != null && !active.isEmpty())
+ {
+ result.put("reason", "TASK_EXISTS");
+ result.put("duplicated", true);
+ result.put("taskId", text(active.get("taskId")));
+ result.put("state", text(active.get("state")));
+ return result;
+ }
+ String taskId = "CT-" + suffix();
+ String trigger = reason == null || reason.isBlank() ? "LEAVE" : reason;
+ String remark = "全员离寓自动建单:" + trigger;
+ try
+ {
+ jobMapper.insertCleanTask(gyid(), taskId, roomId, normalized, null, remark, new Date(), "AUTO");
+ }
+ catch (DuplicateKeyException e)
+ {
+ // 唯一键兜底:并发(重复离寓 / 平板同时上报)只有一条活跃任务
+ Map created = jobMapper.selectActiveCleanTaskByRoom(gyid(), roomId);
+ result.put("reason", "TASK_EXISTS");
+ result.put("duplicated", true);
+ result.put("taskId", created == null ? null : text(created.get("taskId")));
+ result.put("state", created == null ? null : text(created.get("state")));
+ return result;
+ }
+ insertEvent(CLEAN, taskId, roomId, null, PENDING, null, "SYSTEM", null, remark);
+ // 房态已在离寓事务内置为 3(维修中保持 4、保洁中保持 6);
+ // 只有异常数据才在这里补置,避免建单事务二次覆盖并发写入的房态。
+ AptRoom room = roomById(roomId);
+ String roomStatus = room.getRoomStatus();
+ if (!STATUS_TO_CLEAN.equals(roomStatus) && !STATUS_BLOCKED.equals(roomStatus)
+ && !STATUS_CLEANING.equals(roomStatus))
+ {
+ roomStatus = applyRoomStatus(room, STATUS_TO_CLEAN, null);
+ }
+ auditAutoClean(normalized, taskId, trigger, live);
+ log.info("全员离寓自动建单:taskId={}, room={}, 房态={}", taskId, normalized, roomStatus);
+ result.put("created", true);
+ result.put("taskId", taskId);
+ result.put("state", PENDING);
+ result.put("roomStatus", roomStatus);
+ result.put("duplicated", false);
+ return result;
+ }
+
+ /** 最后一人离寓:无维修时直接置 3 待清扫(保洁中保持 6),不落回 1 空闲 */
+ private void applyVacantRoomStatus(AptRoom room)
+ {
+ Map activeRepair = jobMapper.selectActiveRepairByRoom(gyid(), room.getRoomId());
+ if (activeRepair != null && !activeRepair.isEmpty())
+ {
+ return;
+ }
+ String cleanState = roomMapper.selectActiveCleanState(gyid(), room.getRoomId());
+ applyRoomStatus(room, PROCESSING.equals(cleanState) ? STATUS_CLEANING : STATUS_TO_CLEAN, null);
+ }
+
+ private void auditAutoClean(String roomNo, String taskId, String trigger, int liveCount)
+ {
+ try
+ {
+ Map detail = new LinkedHashMap<>();
+ detail.put("roomNo", roomNo);
+ detail.put("taskId", taskId);
+ detail.put("trigger", trigger);
+ detail.put("liveCount", liveCount);
+ detail.put("source", "AUTO");
+ detail.put("traceId", AptContext.getTraceId());
+
+ AptOperationLog entry = new AptOperationLog();
+ entry.setLogId(UUID.randomUUID().toString().replace("-", ""));
+ String gyId = AptContext.getGyIdOrNull();
+ entry.setGyId(gyId == null ? "UNKNOWN" : gyId);
+ entry.setOperatorRef("SYSTEM");
+ entry.setModule("JOB");
+ entry.setAction("CLEAN_AUTO_CREATE");
+ entry.setDetail(JSON.toJSONString(detail));
+ entry.setTraceId(AptContext.getTraceId());
+ entry.setOccurredAt(new Date());
+ auditWriter.write(entry);
+ }
+ catch (Exception e)
+ {
+ log.warn("自动建单审计写入失败,roomNo={}, taskId={}, cause={}", roomNo, taskId, e.toString());
+ }
+ }
+
+ // ============================================================ 查询 / 统计
+
+ @Override
+ public List> list(String type, AptJobQuery query)
+ {
+ AptJobQuery q = query == null ? new AptJobQuery() : query;
+ if (q.getLimit() == null || q.getLimit() <= 0 || q.getLimit() > 500)
+ {
+ q.setLimit(100);
+ }
+ List> result = new ArrayList<>();
+ if (type == null || type.isBlank() || CLEAN.equalsIgnoreCase(type))
+ {
+ for (Map row : jobMapper.selectCleanList(gyid(), q))
+ {
+ row.put("type", CLEAN);
+ result.add(row);
+ }
+ }
+ if (type == null || type.isBlank() || REPAIR.equalsIgnoreCase(type))
+ {
+ for (Map row : jobMapper.selectRepairList(gyid(), q))
+ {
+ row.put("type", REPAIR);
+ result.add(row);
+ }
+ }
+ result.sort(Comparator.comparingLong(this::bizMillis).reversed());
+ return result.size() > q.getLimit() ? result.subList(0, q.getLimit()) : result;
+ }
+
+ @Override
+ public List> listMine(String deviceCode, String type, AptJobQuery query)
+ {
+ Map staff = requireStaff(deviceCode);
+ String staffType = text(staff.get("staffType"));
+ String effectiveType = type;
+ if (type != null && !type.isBlank())
+ {
+ // 作业类型与人员类型必须匹配:保洁不接维修单,维修工不接保洁单
+ if ("CLEANER".equals(staffType) && !CLEAN.equalsIgnoreCase(type))
+ {
+ return List.of();
+ }
+ if ("REPAIRMAN".equals(staffType) && !REPAIR.equalsIgnoreCase(type))
+ {
+ return List.of();
+ }
+ }
+ else if ("CLEANER".equals(staffType))
+ {
+ effectiveType = CLEAN;
+ }
+ else if ("REPAIRMAN".equals(staffType))
+ {
+ effectiveType = REPAIR;
+ }
+ AptJobQuery merged = query == null ? new AptJobQuery() : query;
+ merged.setStaffId(text(staff.get("staffId")));
+ // 未分配的待办(自动建单的保洁 / 一键报修的维修)进入平板公共池
+ merged.setIncludeUnassigned(Boolean.TRUE);
+ List> rows = list(effectiveType, merged);
+ for (Map row : rows)
+ {
+ row.put("pool", text(row.get("assigneeStaffId")).isEmpty());
+ }
+ return rows;
+ }
+
+ @Override
+ public Map detail(String type, String bizId)
+ {
+ Map result = new LinkedHashMap<>();
+ if (CLEAN.equalsIgnoreCase(type))
+ {
+ Map task = requireCleanTask(bizId);
+ result.put("type", CLEAN);
+ result.put("job", task);
+ result.put("events", jobMapper.selectJobEvents(gyid(), CLEAN, bizId));
+ return result;
+ }
+ if (REPAIR.equalsIgnoreCase(type))
+ {
+ Map order = requireRepairOrder(bizId);
+ result.put("type", REPAIR);
+ result.put("job", order);
+ result.put("events", jobMapper.selectJobEvents(gyid(), REPAIR, bizId));
+ return result;
+ }
+ throw badRequest("JOB_TYPE_INVALID", "type 仅允许 CLEAN / REPAIR");
+ }
+
+ @Override
+ public Map stats(AptJobQuery query)
+ {
+ Date from = query == null ? null : query.getFrom();
+ Date to = query == null ? null : query.getTo();
+ Map result = new LinkedHashMap<>();
+ result.put("summary", jobMapper.selectJobStats(gyid(), from, to));
+ result.put("byType", jobMapper.selectJobStatsByType(gyid(), from, to));
+ result.put("staffWorkload", jobMapper.selectStaffWorkload(gyid(), from, to));
+ result.put("from", from);
+ result.put("to", to);
+ return result;
+ }
+
+ // ================================================================== 内部
+
+ private Map requireStaff(String deviceCode)
+ {
+ if (deviceCode == null || deviceCode.isBlank())
+ {
+ throw new AptException(AptErrorCode.UNAUTHORIZED, "DEVICE_CREDENTIAL_MISSING", "缺少设备凭据 X-Device-Code");
+ }
+ Map staff = jobMapper.selectStaffByDevice(gyid(), deviceCode.trim().toUpperCase(Locale.ROOT));
+ if (staff == null || staff.isEmpty())
+ {
+ // 平板未绑定作业人员:不允许匿名上报,避免作业责任无法追溯
+ throw new AptException(AptErrorCode.FORBIDDEN_PERM, "STAFF_NOT_BOUND",
+ "该平板未绑定作业人员,请先在人员管理中绑定设备:" + deviceCode);
+ }
+ return staff;
+ }
+
+ private AptRoom requireRoom(String roomNo)
+ {
+ if (roomNo == null || roomNo.isBlank())
+ {
+ throw badRequest("MISSING_ROOMNO", "房号不能为空");
+ }
+ Map row = jobMapper.selectRoomByNo(gyid(), roomNo.trim());
+ if (row == null || row.isEmpty())
+ {
+ throw new AptException(AptErrorCode.NOT_FOUND, "ROOM_NOT_FOUND", "房间不存在:" + roomNo);
+ }
+ return roomById(text(row.get("roomId")));
+ }
+
+ private AptRoom roomById(String roomId)
+ {
+ AptRoom room = roomMapper.selectRoomByRoomId(gyid(), roomId);
+ if (room == null)
+ {
+ throw new AptException(AptErrorCode.NOT_FOUND, "ROOM_NOT_FOUND", "房间不存在:" + roomId);
+ }
+ return room;
+ }
+
+ private Map requireCleanTask(String taskId)
+ {
+ if (taskId == null || taskId.isBlank())
+ {
+ throw badRequest("MISSING_TASKID", "taskId 不能为空");
+ }
+ Map task = jobMapper.selectCleanTask(gyid(), taskId);
+ if (task == null || task.isEmpty())
+ {
+ throw new AptException(AptErrorCode.NOT_FOUND, "CLEAN_TASK_NOT_FOUND", "保洁任务不存在:" + taskId);
+ }
+ return task;
+ }
+
+ private Map requireRepairOrder(String orderId)
+ {
+ if (orderId == null || orderId.isBlank())
+ {
+ throw badRequest("MISSING_ORDERID", "orderId 不能为空");
+ }
+ Map order = jobMapper.selectRepairOrder(gyid(), orderId);
+ if (order == null || order.isEmpty())
+ {
+ throw new AptException(AptErrorCode.NOT_FOUND, "REPAIR_ORDER_NOT_FOUND", "维修工单不存在:" + orderId);
+ }
+ return order;
+ }
+
+ /** 房态重算:维修中(4) > 保洁中(6) > 待清扫(3) > 住满(2)/未满(5)/空闲(1) */
+ private String refreshRoomStatus(AptRoom room)
+ {
+ String target;
+ String reason = null;
+ Map activeRepair = jobMapper.selectActiveRepairByRoom(gyid(), room.getRoomId());
+ if (activeRepair != null && !activeRepair.isEmpty())
+ {
+ target = STATUS_BLOCKED;
+ reason = "REPAIR:" + text(activeRepair.get("orderId"));
+ }
+ else
+ {
+ String cleanState = roomMapper.selectActiveCleanState(gyid(), room.getRoomId());
+ if (PROCESSING.equals(cleanState))
+ {
+ target = STATUS_CLEANING;
+ }
+ else if (PENDING.equals(cleanState))
+ {
+ target = STATUS_TO_CLEAN;
+ }
+ else
+ {
+ int inPerson = room.getInPersonCount() == null ? 0 : room.getInPersonCount();
+ int beds = room.getBedCount() == null ? 0 : room.getBedCount();
+ target = inPerson <= 0 ? STATUS_IDLE
+ : (beds > 0 && inPerson >= beds ? STATUS_FULL : STATUS_PARTIAL);
+ }
+ }
+ return applyRoomStatus(room, target, reason);
+ }
+
+ /** 房态条件更新;版本冲突时重读一次再试,仍失败按 40903 返回 */
+ private String applyRoomStatus(AptRoom room, String target, String reason)
+ {
+ long version = room.getStateVersion() == null ? 0L : room.getStateVersion();
+ if (roomMapper.updateRoomStatus(gyid(), room.getId(), version, target, reason) == 0)
+ {
+ AptRoom fresh = roomMapper.selectRoomByRoomId(gyid(), room.getRoomId());
+ if (fresh == null
+ || roomMapper.updateRoomStatus(gyid(), fresh.getId(),
+ fresh.getStateVersion() == null ? 0L : fresh.getStateVersion(), target, reason) == 0)
+ {
+ throw new AptException(AptErrorCode.SNAPSHOT_EXPIRED, "STATE_VERSION_CONFLICT",
+ "房态版本已变化,请重读后重试");
+ }
+ log.debug("房态更新版本冲突后重试成功:room={}, target={}", room.getRoomNo(), target);
+ }
+ room.setRoomStatus(target);
+ return target;
+ }
+
+ private void event(String bizType, String bizId, String roomId, String from, String to,
+ Map staff, String deviceCode, AptJobCommand command)
+ {
+ insertEvent(bizType, bizId, roomId, from, to,
+ staff == null ? null : text(staff.get("staffId")),
+ staff == null ? null : text(staff.get("name")),
+ deviceCode == null ? null : deviceCode.toUpperCase(Locale.ROOT),
+ command == null ? null : command.getRemark());
+ }
+
+ /** 状态流水统一入口:自动建单用 operatorName=SYSTEM、deviceCode 为空留痕 */
+ private void insertEvent(String bizType, String bizId, String roomId, String from, String to,
+ String operatorStaff, String operatorName, String deviceCode, String remark)
+ {
+ jobMapper.insertJobEvent(gyid(), "JE-" + suffix(), bizType, bizId, roomId, from, to,
+ operatorStaff, operatorName, deviceCode, remark);
+ }
+
+ private Map duplicatedOrIllegal(String bizId, String expected)
+ {
+ Map order = jobMapper.selectRepairOrder(gyid(), bizId);
+ if (order != null && expected.equals(text(order.get("state"))))
+ {
+ AptRoom room = roomById(text(order.get("roomId")));
+ return result(bizId, room, text(room.getRoomStatus()), expected, true);
+ }
+ throw stateIllegal("工单当前状态 " + text(order == null ? null : order.get("state"))
+ + " 不允许该操作(期望 " + expected + ")");
+ }
+
+ private Map result(String bizId, AptRoom room, String roomStatus,
+ String state, boolean duplicated)
+ {
+ Map result = new LinkedHashMap<>();
+ result.put("jobId", bizId);
+ result.put("roomId", room.getRoomId());
+ result.put("roomNo", room.getRoomNo());
+ result.put("state", state);
+ result.put("roomStatus", roomStatus);
+ result.put("duplicated", duplicated);
+ result.put("traceId", AptContext.getTraceId());
+ return result;
+ }
+
+ private AptRoom roomOf(Map task)
+ {
+ return roomById(text(task.get("roomId")));
+ }
+
+ private long bizMillis(Map row)
+ {
+ for (String key : new String[]{"scheduledAt", "reportedAt", "startedAt", "finishedAt"})
+ {
+ Object value = row.get(key);
+ if (value instanceof Date date)
+ {
+ return date.getTime();
+ }
+ }
+ return 0L;
+ }
+
+ private String normalizeCategory(String category)
+ {
+ String value = category == null ? "OTHER" : category.trim().toUpperCase(Locale.ROOT);
+ return List.of("ELECTRIC", "PLUMBING", "DOOR_WINDOW", "AIRCON", "OTHER").contains(value)
+ ? value : "OTHER";
+ }
+
+ private String normalizePriority(String priority)
+ {
+ String value = priority == null ? "NORMAL" : priority.trim().toUpperCase(Locale.ROOT);
+ return "URGENT".equals(value) ? "URGENT" : "NORMAL";
+ }
+
+ private AptException stateIllegal(String message)
+ {
+ return new AptException(AptErrorCode.STATE_ILLEGAL, "JOB_STATE_ILLEGAL", message);
+ }
+
+ private AptException badRequest(String reason, String message)
+ {
+ return new AptException(AptErrorCode.BAD_REQUEST, reason, message);
+ }
+
+ private String suffix()
+ {
+ return UUID.randomUUID().toString().replace("-", "").substring(0, 12).toUpperCase(Locale.ROOT);
+ }
+
+ private String text(Object value)
+ {
+ return value == null ? "" : String.valueOf(value);
+ }
+
+ private String gyid()
+ {
+ return AptContext.getGyId();
+ }
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/job/service/AptStaffServiceImpl.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/job/service/AptStaffServiceImpl.java
new file mode 100644
index 0000000..ad6a6e9
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/job/service/AptStaffServiceImpl.java
@@ -0,0 +1,208 @@
+package com.ruoyi.apt.job.service;
+
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+import com.ruoyi.apt.common.AptContext;
+import com.ruoyi.apt.common.AptErrorCode;
+import com.ruoyi.apt.common.AptException;
+import com.ruoyi.apt.job.domain.AptStaffCommand;
+import com.ruoyi.apt.job.mapper.AptJobMapper;
+import com.ruoyi.common.utils.SecurityUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+/**
+ * 作业人员维护实现。
+ *
+ * 约束:人员类型白名单、工号同公寓唯一、停用即下线(duty_state=OFFLINE),
+ * 删除为软删除并解绑设备,避免历史工单失去执行人引用。
+ *
+ * @author apt-rev01
+ */
+@Service
+public class AptStaffServiceImpl implements IAptStaffService
+{
+ private static final Logger log = LoggerFactory.getLogger(AptStaffServiceImpl.class);
+
+ private static final Set TYPES = Set.of("CLEANER", "REPAIRMAN", "BOTH");
+ private static final Set STATES = Set.of("ACTIVE", "DISABLED");
+
+ private final AptJobMapper jobMapper;
+
+ public AptStaffServiceImpl(AptJobMapper jobMapper)
+ {
+ this.jobMapper = jobMapper;
+ }
+
+ @Override
+ public List> list(String staffType, String state, String keyword)
+ {
+ return jobMapper.selectStaffList(gyid(),
+ staffType == null ? null : staffType.trim().toUpperCase(Locale.ROOT),
+ state == null ? null : state.trim().toUpperCase(Locale.ROOT),
+ keyword == null ? null : keyword.trim());
+ }
+
+ @Override
+ public Map detail(String staffId)
+ {
+ return require(staffId);
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public Map create(AptStaffCommand command)
+ {
+ if (command == null)
+ {
+ throw badRequest("MISSING_BODY", "请求体不能为空");
+ }
+ String staffType = normalizeType(command.getStaffType());
+ String name = requireName(command.getName());
+ String workNo = trimToNull(command.getWorkNo());
+ assertWorkNoUnique(workNo, null);
+ String staffId = trimToNull(command.getStaffId());
+ if (staffId == null)
+ {
+ staffId = "ST-" + suffix();
+ }
+ else if (!jobMapper.selectStaff(gyid(), staffId).isEmpty())
+ {
+ throw new AptException(AptErrorCode.DUPLICATE_KEY, "STAFF_DUPLICATED", "人员已存在:" + staffId);
+ }
+ jobMapper.insertStaff(gyid(), staffId, staffType, name, workNo, trimToNull(command.getPhone()),
+ command.getSysUserId(), trimToNull(command.getDeviceCode()), trimToNull(command.getRemark()),
+ currentUser());
+ log.info("新增作业人员:staffId={}, type={}, name={}", staffId, staffType, name);
+ return require(staffId);
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public Map update(String staffId, AptStaffCommand command)
+ {
+ require(staffId);
+ if (command == null)
+ {
+ throw badRequest("MISSING_BODY", "请求体不能为空");
+ }
+ String staffType = normalizeType(command.getStaffType());
+ String name = requireName(command.getName());
+ String workNo = trimToNull(command.getWorkNo());
+ assertWorkNoUnique(workNo, staffId);
+ jobMapper.updateStaff(gyid(), staffId, staffType, name, workNo, trimToNull(command.getPhone()),
+ command.getSysUserId(), trimToNull(command.getDeviceCode()), trimToNull(command.getRemark()));
+ return require(staffId);
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public Map changeState(String staffId, String state)
+ {
+ require(staffId);
+ String target = state == null ? "" : state.trim().toUpperCase(Locale.ROOT);
+ if (!STATES.contains(target))
+ {
+ throw badRequest("STAFF_STATE_INVALID", "state 仅允许 ACTIVE / DISABLED");
+ }
+ jobMapper.updateStaffState(gyid(), staffId, target);
+ log.info("作业人员状态变更:staffId={}, state={}", staffId, target);
+ return require(staffId);
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public void delete(String staffId)
+ {
+ require(staffId);
+ jobMapper.softDeleteStaff(gyid(), staffId);
+ log.info("作业人员已删除(软删并解绑设备):staffId={}", staffId);
+ }
+
+ // ------------------------------------------------------------------ 内部
+
+ private Map require(String staffId)
+ {
+ if (staffId == null || staffId.isBlank())
+ {
+ throw badRequest("MISSING_STAFFID", "staffId 不能为空");
+ }
+ Map staff = jobMapper.selectStaff(gyid(), staffId.trim());
+ if (staff == null || staff.isEmpty())
+ {
+ throw new AptException(AptErrorCode.NOT_FOUND, "STAFF_NOT_FOUND", "作业人员不存在:" + staffId);
+ }
+ return staff;
+ }
+
+ private void assertWorkNoUnique(String workNo, String excludeStaffId)
+ {
+ if (workNo == null)
+ {
+ return;
+ }
+ if (jobMapper.countStaffWorkNo(gyid(), workNo, excludeStaffId) > 0)
+ {
+ throw new AptException(AptErrorCode.DUPLICATE_KEY, "STAFF_WORKNO_DUPLICATED",
+ "工号已存在:" + workNo);
+ }
+ }
+
+ private String normalizeType(String staffType)
+ {
+ String value = staffType == null ? "" : staffType.trim().toUpperCase(Locale.ROOT);
+ if (!TYPES.contains(value))
+ {
+ throw badRequest("STAFF_TYPE_INVALID", "staffType 仅允许 CLEANER / REPAIRMAN / BOTH");
+ }
+ return value;
+ }
+
+ private String requireName(String name)
+ {
+ if (name == null || name.isBlank())
+ {
+ throw badRequest("MISSING_NAME", "姓名不能为空");
+ }
+ return name.trim();
+ }
+
+ /** 操作人:无登录上下文(定时/测试)时记 system */
+ private String currentUser()
+ {
+ try
+ {
+ String username = SecurityUtils.getUsername();
+ return username == null || username.isBlank() ? "system" : username;
+ }
+ catch (RuntimeException e)
+ {
+ return "system";
+ }
+ }
+
+ private String trimToNull(String value)
+ {
+ return value == null || value.isBlank() ? null : value.trim();
+ }
+
+ private AptException badRequest(String reason, String message)
+ {
+ return new AptException(AptErrorCode.BAD_REQUEST, reason, message);
+ }
+
+ private String suffix()
+ {
+ return UUID.randomUUID().toString().replace("-", "").substring(0, 12).toUpperCase(Locale.ROOT);
+ }
+
+ private String gyid()
+ {
+ return AptContext.getGyId();
+ }
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/job/service/IAptJobService.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/job/service/IAptJobService.java
new file mode 100644
index 0000000..aebf025
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/job/service/IAptJobService.java
@@ -0,0 +1,67 @@
+package com.ruoyi.apt.job.service;
+
+import java.util.List;
+import java.util.Map;
+import com.ruoyi.apt.job.domain.AptJobCommand;
+import com.ruoyi.apt.job.domain.AptJobQuery;
+
+/**
+ * 平板作业(保洁 / 维修)受理与查询。
+ *
+ * 保洁:上报(PENDING, 房态 3) → 开始(PROCESSING, 房态 6) → 结束(DONE, 房态重算 1/2/5)。
+ * 维修:上报(REPORTED, 房态 4) → 派单(ASSIGNED) → 开始(IN_PROGRESS) → 完成(DONE, 房态重算)/取消。
+ *
+ * @author apt-rev01
+ */
+public interface IAptJobService
+{
+ // ------------------------------------------------------------------ 保洁
+ Map reportClean(AptJobCommand command, String deviceCode);
+
+ Map startClean(String taskId, AptJobCommand command, String deviceCode);
+
+ Map finishClean(String taskId, AptJobCommand command, String deviceCode);
+
+ // ------------------------------------------------------------------ 维修
+ Map reportRepair(AptJobCommand command, String deviceCode);
+
+ Map assignRepair(String orderId, String staffId);
+
+ Map startRepair(String orderId, AptJobCommand command, String deviceCode);
+
+ Map finishRepair(String orderId, AptJobCommand command, String deviceCode);
+
+ Map cancelRepair(String orderId, String remark);
+
+ // -------------------------------------------------------- 离寓联动(自动)
+ /**
+ * 离寓后按 gy_jcxx 在寓事实重算房间人数并刷新房态。
+ *
+ * 与「登记离寓」同事务调用;无人时房态直接置 3 待清扫,避免出现「已离寓但仍空闲可入住」的窗口。
+ * 返回房间剩余有效在寓人数。
+ */
+ int syncRoomOccupancy(String roomNo);
+
+ /**
+ * 全员离寓后自动生成保洁工单(幂等):
+ * 同一房间已有 PENDING/PROCESSING 任务时返回该任务并标记 duplicated;
+ * 新建时 source=AUTO、状态 PENDING、执行人为空(进入平板公共池)。
+ *
+ * @param roomNo 房号
+ * @param reason 触发来源(如 LEAVE:<jcid>),写入备注与流水
+ * @return taskId / state / created / duplicated
+ */
+ Map autoCreateCleanOnVacated(String roomNo, String reason);
+
+ // ------------------------------------------------------------ 查询 / 统计
+ /** @param type CLEAN / REPAIR / 空(两类合并按时间倒序) */
+ List> list(String type, AptJobQuery query);
+
+ /** 平板「我的作业」:按设备编码反查执行人后过滤 */
+ List> listMine(String deviceCode, String type, AptJobQuery query);
+
+ /** @param type CLEAN / REPAIR */
+ Map detail(String type, String bizId);
+
+ Map stats(AptJobQuery query);
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/job/service/IAptStaffService.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/job/service/IAptStaffService.java
new file mode 100644
index 0000000..2761952
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/job/service/IAptStaffService.java
@@ -0,0 +1,29 @@
+package com.ruoyi.apt.job.service;
+
+import java.util.List;
+import java.util.Map;
+import com.ruoyi.apt.job.domain.AptStaffCommand;
+
+/**
+ * 作业人员(保洁 / 维修)信息维护。
+ *
+ * 与权限的关系:人员通过 {@code sys_user_id} 归属后台账号、通过 {@code device_code}
+ * 绑定平板;平板侧接口凭设备编码识别执行人,后台操作走 {@code apt:staff:*} 权限点。
+ *
+ * @author apt-rev01
+ */
+public interface IAptStaffService
+{
+ List> list(String staffType, String state, String keyword);
+
+ Map detail(String staffId);
+
+ Map create(AptStaffCommand command);
+
+ Map update(String staffId, AptStaffCommand command);
+
+ /** 启停用:DISABLED 时同时把 duty_state 置 OFFLINE */
+ Map changeState(String staffId, String state);
+
+ void delete(String staffId);
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/master/controller/AptPersonController.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/master/controller/AptPersonController.java
new file mode 100644
index 0000000..703c2d5
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/master/controller/AptPersonController.java
@@ -0,0 +1,63 @@
+package com.ruoyi.apt.master.controller;
+
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import com.ruoyi.apt.common.audit.AptAudit;
+import com.ruoyi.apt.master.service.IAptPersonService;
+import com.ruoyi.common.core.domain.AjaxResult;
+
+/**
+ * M4 人员管理(只读快照 + 关联 Tab)。
+ *
+ * 平台下发字段(姓名/工号/部门/人员类别等)本地不可改:{@code PUT} 显式返回 40904,
+ * 不做「部分可改」的模糊实现,避免与主数据快照校验冲突。
+ *
+ * @author apt-rev01
+ */
+@RestController
+@RequestMapping(value = "/apt/person", produces = "application/json;charset=UTF-8")
+public class AptPersonController
+{
+ private final IAptPersonService personService;
+
+ public AptPersonController(IAptPersonService personService)
+ {
+ this.personService = personService;
+ }
+
+ @PreAuthorize("@ss.hasPermi('apt:person:list')")
+ @GetMapping("/list")
+ public AjaxResult list(String keyword, String roomNo, String state, Integer limit)
+ {
+ return AjaxResult.success(personService.list(keyword, roomNo, state, limit));
+ }
+
+ @PreAuthorize("@ss.hasPermi('apt:person:query')")
+ @GetMapping("/{ryid}")
+ public AjaxResult detail(@PathVariable String ryid)
+ {
+ return AjaxResult.success(personService.detail(ryid));
+ }
+
+ /** 关联 Tab:当前在住房间、最近交路、最近 5 条叫乘 */
+ @PreAuthorize("@ss.hasPermi('apt:person:query')")
+ @GetMapping("/{ryid}/associations")
+ public AjaxResult associations(@PathVariable String ryid)
+ {
+ return AjaxResult.success(personService.associations(ryid));
+ }
+
+ /** 快照只读:无论请求体内容,一律 40904 */
+ @PreAuthorize("@ss.hasPermi('apt:person:edit')")
+ @AptAudit(module = "PERSON", action = "PERSON_UPDATE_REJECTED")
+ @PutMapping("/{ryid}")
+ public AjaxResult update(@PathVariable String ryid)
+ {
+ personService.rejectSnapshotWrite(ryid);
+ return AjaxResult.success();
+ }
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/master/mapper/AptPersonMapper.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/master/mapper/AptPersonMapper.java
new file mode 100644
index 0000000..4e70dd5
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/master/mapper/AptPersonMapper.java
@@ -0,0 +1,34 @@
+package com.ruoyi.apt.master.mapper;
+
+import java.util.List;
+import java.util.Map;
+import org.apache.ibatis.annotations.Param;
+
+/**
+ * M4 人员主数据读取与关联查询(关联 Tab)。
+ *
+ * 人员来自平台下发快照,本地只读;所有写入口在 Controller 层直接返回 40904。
+ *
+ * @author apt-rev01
+ */
+public interface AptPersonMapper
+{
+ List> selectPersonList(@Param("gyid") String gyid,
+ @Param("keyword") String keyword,
+ @Param("roomNo") String roomNo,
+ @Param("state") String state,
+ @Param("limit") int limit);
+
+ Map selectPerson(@Param("gyid") String gyid, @Param("ryid") String ryid);
+
+ /** 当前在住(finish_ly='0' AND sjlysj IS NULL),取最近一条 */
+ Map selectActiveStay(@Param("gyid") String gyid, @Param("ryid") String ryid);
+
+ /** 最近 N 条叫乘(默认 5) */
+ List> selectRecentCalls(@Param("gyid") String gyid,
+ @Param("ryid") String ryid,
+ @Param("limit") int limit);
+
+ /** 最近一次交路(按叫乘时间倒序) */
+ Map selectRecentRoute(@Param("gyid") String gyid, @Param("ryid") String ryid);
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/master/service/AptPersonServiceImpl.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/master/service/AptPersonServiceImpl.java
new file mode 100644
index 0000000..625faee
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/master/service/AptPersonServiceImpl.java
@@ -0,0 +1,96 @@
+package com.ruoyi.apt.master.service;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import com.ruoyi.apt.common.AptContext;
+import com.ruoyi.apt.common.AptErrorCode;
+import com.ruoyi.apt.common.AptException;
+import com.ruoyi.apt.master.mapper.AptPersonMapper;
+import org.springframework.stereotype.Service;
+
+/**
+ * M4 人员主数据实现。
+ *
+ * 人员由平台下发并落在 {@code apt_master_person}(source_version 标识快照版本),
+ * 本地不改字段:{@code PUT} 一律 40904;关联数据实时来自 {@code gy_jcxx},不做冗余。
+ *
+ * @author apt-rev01
+ */
+@Service
+public class AptPersonServiceImpl implements IAptPersonService
+{
+ private static final int DEFAULT_LIMIT = 50;
+ private static final int MAX_LIMIT = 200;
+ private static final int RECENT_CALL_LIMIT = 5;
+
+ private final AptPersonMapper personMapper;
+
+ public AptPersonServiceImpl(AptPersonMapper personMapper)
+ {
+ this.personMapper = personMapper;
+ }
+
+ @Override
+ public List> list(String keyword, String roomNo, String state, Integer limit)
+ {
+ int size = limit == null ? DEFAULT_LIMIT : limit;
+ size = Math.max(1, Math.min(size, MAX_LIMIT));
+ return personMapper.selectPersonList(gyid(), trim(keyword), trim(roomNo), trim(state), size);
+ }
+
+ @Override
+ public Map detail(String ryid)
+ {
+ Map person = personMapper.selectPerson(gyid(), requireRyid(ryid));
+ if (person == null)
+ {
+ throw new AptException(AptErrorCode.NOT_FOUND, "PERSON_NOT_FOUND", "人员不存在:" + ryid);
+ }
+ return person;
+ }
+
+ @Override
+ public Map associations(String ryid)
+ {
+ String id = requireRyid(ryid);
+ Map result = new LinkedHashMap<>();
+ result.put("person", detail(id));
+ result.put("currentStay", personMapper.selectActiveStay(gyid(), id));
+ result.put("recentCalls", personMapper.selectRecentCalls(gyid(), id, RECENT_CALL_LIMIT));
+ result.put("recentRoute", personMapper.selectRecentRoute(gyid(), id));
+ return result;
+ }
+
+ @Override
+ public void rejectSnapshotWrite(String ryid)
+ {
+ throw new AptException(AptErrorCode.SNAPSHOT_READONLY, "PERSON_SNAPSHOT_READONLY",
+ "人员主数据由平台下发,本地只读(ryid=" + trim(ryid) + ")");
+ }
+
+ private String requireRyid(String ryid)
+ {
+ String id = trim(ryid);
+ if (id.isEmpty())
+ {
+ throw new AptException(AptErrorCode.BAD_REQUEST, "MISSING_RYID", "缺少人员 ID");
+ }
+ return id;
+ }
+
+ private String gyid()
+ {
+ String gyid = AptContext.getGyIdOrNull();
+ if (gyid == null || gyid.isBlank())
+ {
+ throw new AptException(AptErrorCode.FORBIDDEN_GYID, "MISSING_GYID", "缺少公寓标识");
+ }
+ return gyid;
+ }
+
+ private static String trim(String value)
+ {
+ return value == null ? "" : value.trim();
+ }
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/master/service/IAptPersonService.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/master/service/IAptPersonService.java
new file mode 100644
index 0000000..529f319
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/master/service/IAptPersonService.java
@@ -0,0 +1,22 @@
+package com.ruoyi.apt.master.service;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * M4 人员主数据:列表 / 详情 / 关联 Tab(只读快照)。
+ *
+ * @author apt-rev01
+ */
+public interface IAptPersonService
+{
+ List> list(String keyword, String roomNo, String state, Integer limit);
+
+ Map detail(String ryid);
+
+ /** 关联 Tab:当前在住 + 最近 5 条叫乘 + 最近交路 */
+ Map associations(String ryid);
+
+ /** 快照只读:任何写操作一律 40904 */
+ void rejectSnapshotWrite(String ryid);
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/ops/controller/AptLocalDebugController.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/ops/controller/AptLocalDebugController.java
new file mode 100644
index 0000000..56ee43c
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/ops/controller/AptLocalDebugController.java
@@ -0,0 +1,82 @@
+package com.ruoyi.apt.ops.controller;
+
+import java.util.Map;
+import com.ruoyi.apt.common.audit.AptAudit;
+import com.ruoyi.apt.stay.checkin.domain.AptCheckinMatchVerdict;
+import com.ruoyi.apt.stay.checkin.service.IAptCheckinMatchService;
+import com.ruoyi.apt.voice.domain.AptSipEventCommand;
+import com.ruoyi.apt.voice.service.IAptVoiceEventService;
+import com.ruoyi.common.core.domain.AjaxResult;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 本地联调端点(仅 {@code apt.debug.enabled=true} 时装配)。
+ *
+ * 存在的唯一理由:真正的入口尚未就绪时,仍能本地验证叫乘链路——
+ *
+ * - {@code #7 匹配结论}:生产入口是平台拉取器(IT-6)调用
+ * {@link IAptCheckinMatchService#applyVerdict},拉取器未实现前用它补
+ * {@code jcsj},让「到点自动发起」可跑;
+ * - {@code AMI 事件}:生产入口是 Asterisk 的 AMI 推送,无 Asterisk 时用它注入
+ * {@code ORIGINATE_FAILURE / CONFBRIDGEJOIN / HANGUP} 验证状态机。
+ *
+ *
+ * 红线:本类不做任何字段直写,只调用既有服务/状态机命令;
+ * 生产环境必须保持 {@code apt.debug.enabled=false}(默认值),否则等同开放状态机旁路。
+ * 鉴权沿用管理端权限点,不额外开匿名口子。
+ *
+ * @author apt-rev01
+ */
+@RestController
+@RequestMapping(value = "/apt/local", produces = "application/json;charset=UTF-8")
+@ConditionalOnProperty(prefix = "apt.debug", name = "enabled", havingValue = "true")
+public class AptLocalDebugController
+{
+ private final IAptCheckinMatchService matchService;
+ private final IAptVoiceEventService voiceEventService;
+
+ public AptLocalDebugController(IAptCheckinMatchService matchService,
+ IAptVoiceEventService voiceEventService)
+ {
+ this.matchService = matchService;
+ this.voiceEventService = voiceEventService;
+ }
+
+ /**
+ * 联调 #7 匹配结论:落结论 → 复算 jcsj → 写 gy_jcxx → 置 TASK_READY。
+ *
+ * {@code verdict=MATCHED} 且能取到 {@code kcsj} 才会产出 {@code jcsj};
+ * 请求体可省略 {@code applyId}(以路径为准)。
+ */
+ @PreAuthorize("@ss.hasPermi('duty:checkin:review')")
+ @AptAudit(module = "OPS", action = "LOCAL_MATCH_VERDICT")
+ @PostMapping("/checkin/{applyId}/match-verdict")
+ public AjaxResult matchVerdict(@PathVariable String applyId,
+ @RequestBody AptCheckinMatchVerdict verdict)
+ {
+ verdict.setApplyId(applyId);
+ return AjaxResult.success(matchService.applyVerdict(verdict));
+ }
+
+ /**
+ * 联调 AMI 事件:事件先落 {@code apt_call_event},再按表 5-13 提交状态机命令。
+ *
+ * 示例:{@code {"jcid":"JC-...","eventType":"CONFBRIDGEJOIN","sessionId":"CS-..."}},
+ * 其中 {@code ORIGINATE_FAILURE / NO_ANSWER} → ryzt=2,{@code CONFBRIDGEJOIN} → ryzt=3,
+ * {@code HANGUP / AMI_DISCONNECT} 不回写终态。
+ */
+ @PreAuthorize("@ss.hasPermi('apt:jcxx:changeStatus')")
+ @AptAudit(module = "OPS", action = "LOCAL_SIP_EVENT")
+ @PostMapping("/voice/events")
+ public AjaxResult sipEvent(@RequestBody AptSipEventCommand command)
+ {
+ Map result = voiceEventService.handle(command);
+ return AjaxResult.success(result);
+ }
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/report/controller/AptReportController.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/report/controller/AptReportController.java
new file mode 100644
index 0000000..fd2bb58
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/report/controller/AptReportController.java
@@ -0,0 +1,74 @@
+package com.ruoyi.apt.report.controller;
+
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import com.ruoyi.apt.common.audit.AptAudit;
+import com.ruoyi.apt.report.domain.AptOutboxQuery;
+import com.ruoyi.apt.report.service.IAptReportService;
+import com.ruoyi.common.core.domain.AjaxResult;
+
+/**
+ * 上报续传管理端(outbox 只读 + 失败重试 + 水位)。
+ *
+ * 管理端不提供「改状态」入口:outbox 的 SENT/DEAD 只能由派发器与重试链路推进,
+ * 手工改态会破坏对账口径(CR-C10)。
+ *
+ * @author apt-rev01
+ */
+@RestController
+@RequestMapping(value = "/apt/report", produces = "application/json;charset=UTF-8")
+public class AptReportController
+{
+ private final IAptReportService reportService;
+
+ public AptReportController(IAptReportService reportService)
+ {
+ this.reportService = reportService;
+ }
+
+ /** outbox 列表:bizType / bizId / state / from / to / limit */
+ @PreAuthorize("@ss.hasPermi('apt:report:list')")
+ @GetMapping("/outbox")
+ public AjaxResult outbox(AptOutboxQuery query)
+ {
+ return AjaxResult.success(reportService.listOutbox(query));
+ }
+
+ /** 队列态汇总 + 水位差(对账三维:count / acked / max(watermark)) */
+ @PreAuthorize("@ss.hasPermi('apt:report:query')")
+ @GetMapping("/stats")
+ public AjaxResult stats()
+ {
+ return AjaxResult.success(reportService.stats());
+ }
+
+ /** 水位视图(#14 心跳时间、时钟偏差) */
+ @PreAuthorize("@ss.hasPermi('apt:report:query')")
+ @GetMapping("/watermark")
+ public AjaxResult watermark()
+ {
+ return AjaxResult.success(reportService.watermark());
+ }
+
+ /** FAILED / DEAD → PENDING;复用原 idempotencyKey,不产生新业务事实 */
+ @PreAuthorize("@ss.hasPermi('apt:report:retry')")
+ @AptAudit(module = "REPORT", action = "REPORT_REQUEUE")
+ @PostMapping("/outbox/{id}/requeue")
+ public AjaxResult requeue(@PathVariable Long id)
+ {
+ return AjaxResult.success(reportService.requeue(id));
+ }
+
+ /** 平台回执水位(联调用;生产由派发器调用) */
+ @PreAuthorize("@ss.hasPermi('apt:report:retry')")
+ @AptAudit(module = "REPORT", action = "REPORT_ACK")
+ @PostMapping("/watermark/ack/{acked}")
+ public AjaxResult ack(@PathVariable long acked)
+ {
+ return AjaxResult.success(reportService.ack(acked));
+ }
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/report/controller/AptReportOpenController.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/report/controller/AptReportOpenController.java
new file mode 100644
index 0000000..2c69a25
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/report/controller/AptReportOpenController.java
@@ -0,0 +1,36 @@
+package com.ruoyi.apt.report.controller;
+
+import java.util.List;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import com.ruoyi.apt.report.domain.AptResumeEnvelope;
+import com.ruoyi.apt.report.service.IAptReportService;
+import com.ruoyi.common.core.domain.AjaxResult;
+
+/**
+ * 断网续报(设备侧,/open/v1/** 走设备四要素鉴权)。
+ *
+ * 入参为平板/边缘网关恢复联网后积压的跨端信封;服务端按原 {@code idempotencyKey}
+ * 幂等入队,{@code DUPLICATE} 视为成功(PL-IDEMPOTENT-REPLAY),不重复产生业务事实。
+ *
+ * @author apt-rev01
+ */
+@RestController
+@RequestMapping(value = "/open/v1/report", produces = "application/json;charset=UTF-8")
+public class AptReportOpenController
+{
+ private final IAptReportService reportService;
+
+ public AptReportOpenController(IAptReportService reportService)
+ {
+ this.reportService = reportService;
+ }
+
+ @PostMapping("/resume")
+ public AjaxResult resume(@RequestBody List envelopes)
+ {
+ return AjaxResult.success(reportService.resume(envelopes));
+ }
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/report/domain/AptOutboxQuery.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/report/domain/AptOutboxQuery.java
new file mode 100644
index 0000000..7191b4e
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/report/domain/AptOutboxQuery.java
@@ -0,0 +1,84 @@
+package com.ruoyi.apt.report.domain;
+
+import java.util.Date;
+
+/**
+ * 上报 outbox 查询条件(管理端只读)。
+ *
+ * @author apt-rev01
+ */
+public class AptOutboxQuery
+{
+ private String bizType;
+
+ private String bizId;
+
+ /** 队列态:PENDING/SENDING/SENT/FAILED/DEAD,空=全部 */
+ private String state;
+
+ private Date from;
+
+ private Date to;
+
+ private Integer limit = 100;
+
+ public String getBizType()
+ {
+ return bizType;
+ }
+
+ public void setBizType(String bizType)
+ {
+ this.bizType = bizType;
+ }
+
+ public String getBizId()
+ {
+ return bizId;
+ }
+
+ public void setBizId(String bizId)
+ {
+ this.bizId = bizId;
+ }
+
+ public String getState()
+ {
+ return state;
+ }
+
+ public void setState(String state)
+ {
+ this.state = state;
+ }
+
+ public Date getFrom()
+ {
+ return from;
+ }
+
+ public void setFrom(Date from)
+ {
+ this.from = from;
+ }
+
+ public Date getTo()
+ {
+ return to;
+ }
+
+ public void setTo(Date to)
+ {
+ this.to = to;
+ }
+
+ public Integer getLimit()
+ {
+ return limit;
+ }
+
+ public void setLimit(Integer limit)
+ {
+ this.limit = limit;
+ }
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/report/domain/AptResumeEnvelope.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/report/domain/AptResumeEnvelope.java
new file mode 100644
index 0000000..8449dc1
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/report/domain/AptResumeEnvelope.java
@@ -0,0 +1,87 @@
+package com.ruoyi.apt.report.domain;
+
+import java.util.Date;
+
+/**
+ * 断网续报信封(#9 / #8 / #13 共用)。
+ *
+ * 续报必须复用首次上报的 {@code idempotencyKey};缺省时按
+ * {@code hash(gyid|bizType|bizId|bizVersion)} 补齐,保证同一业务事实不产生新记录。
+ *
+ * @author apt-rev01
+ */
+public class AptResumeEnvelope
+{
+ private String idempotencyKey;
+
+ private String bizType;
+
+ private String bizId;
+
+ private Long bizVersion;
+
+ /** 跨端信封原文,原样落 payload,不做字段裁剪 */
+ private Object payload;
+
+ private Date occurredAt;
+
+ public String getIdempotencyKey()
+ {
+ return idempotencyKey;
+ }
+
+ public void setIdempotencyKey(String idempotencyKey)
+ {
+ this.idempotencyKey = idempotencyKey;
+ }
+
+ public String getBizType()
+ {
+ return bizType;
+ }
+
+ public void setBizType(String bizType)
+ {
+ this.bizType = bizType;
+ }
+
+ public String getBizId()
+ {
+ return bizId;
+ }
+
+ public void setBizId(String bizId)
+ {
+ this.bizId = bizId;
+ }
+
+ public Long getBizVersion()
+ {
+ return bizVersion;
+ }
+
+ public void setBizVersion(Long bizVersion)
+ {
+ this.bizVersion = bizVersion;
+ }
+
+ public Object getPayload()
+ {
+ return payload;
+ }
+
+ public void setPayload(Object payload)
+ {
+ this.payload = payload;
+ }
+
+ public Date getOccurredAt()
+ {
+ return occurredAt;
+ }
+
+ public void setOccurredAt(Date occurredAt)
+ {
+ this.occurredAt = occurredAt;
+ }
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/report/mapper/AptReportMapper.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/report/mapper/AptReportMapper.java
new file mode 100644
index 0000000..dd64459
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/report/mapper/AptReportMapper.java
@@ -0,0 +1,41 @@
+package com.ruoyi.apt.report.mapper;
+
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+import com.ruoyi.apt.report.domain.AptOutboxQuery;
+import org.apache.ibatis.annotations.Param;
+
+/**
+ * 上报 outbox / 水位 / 续报读写。
+ *
+ * outbox 是队列(PENDING/SENDING/SENT/FAILED/DEAD),终态流水在 apt_report_history,
+ * 两者不互换;续报只允许复用原 {@code idempotency_key} 重新入队/幂等命中,不改业务事实。
+ *
+ * @author apt-rev01
+ */
+public interface AptReportMapper
+{
+ List> selectOutbox(@Param("gyid") String gyid, @Param("q") AptOutboxQuery query);
+
+ Map selectOutboxById(@Param("gyid") String gyid, @Param("id") Long id);
+
+ /** 按状态汇总 + 对账三维 max(watermark) */
+ Map selectOutboxStats(@Param("gyid") String gyid);
+
+ /** 仅 FAILED / DEAD 可重试,成功返回 1(条件更新) */
+ int requeueOutbox(@Param("gyid") String gyid, @Param("id") Long id, @Param("batchId") String batchId);
+
+ int insertOutbox(@Param("gyid") String gyid, @Param("key") String key,
+ @Param("bizType") String bizType, @Param("bizId") String bizId,
+ @Param("bizVersion") long bizVersion, @Param("payload") String payload,
+ @Param("occurredAt") Date occurredAt);
+
+ Map selectWatermark(@Param("gyid") String gyid);
+
+ /** #14 心跳:水位取 GREATEST,偏差以最新一次为准 */
+ int upsertWatermark(@Param("gyid") String gyid, @Param("watermark") long watermark,
+ @Param("clockSkewMs") Long clockSkewMs, @Param("at") Date at);
+
+ int ackWatermark(@Param("gyid") String gyid, @Param("acked") long acked);
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/report/service/AptReportServiceImpl.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/report/service/AptReportServiceImpl.java
new file mode 100644
index 0000000..82cf1bc
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/report/service/AptReportServiceImpl.java
@@ -0,0 +1,271 @@
+package com.ruoyi.apt.report.service;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import com.alibaba.fastjson2.JSON;
+import com.ruoyi.apt.common.AptContext;
+import com.ruoyi.apt.common.AptErrorCode;
+import com.ruoyi.apt.common.AptException;
+import com.ruoyi.apt.framework.config.AptProperties;
+import com.ruoyi.apt.report.domain.AptOutboxQuery;
+import com.ruoyi.apt.report.domain.AptResumeEnvelope;
+import com.ruoyi.apt.report.mapper.AptReportMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.stereotype.Service;
+
+/**
+ * 上报续传实现。
+ *
+ * 红线(CR-C10 / 表 3-11):outbox 是队列、history 是终态流水,两者不互换;
+ * 本服务不写 history,也不把队列态直接当作业务事实。
+ *
+ * @author apt-rev01
+ */
+@Service
+public class AptReportServiceImpl implements IAptReportService
+{
+ private static final Logger log = LoggerFactory.getLogger(AptReportServiceImpl.class);
+
+ private static final int DEFAULT_LIMIT = 100;
+ private static final int MAX_LIMIT = 500;
+ private static final int DEFAULT_RESUME_BATCH = 200;
+ private static final List RETRYABLE = List.of("FAILED", "DEAD");
+
+ private final AptReportMapper reportMapper;
+ private final AptProperties properties;
+
+ public AptReportServiceImpl(AptReportMapper reportMapper, AptProperties properties)
+ {
+ this.reportMapper = reportMapper;
+ this.properties = properties;
+ }
+
+ @Override
+ public List> listOutbox(AptOutboxQuery query)
+ {
+ AptOutboxQuery q = query == null ? new AptOutboxQuery() : query;
+ int limit = q.getLimit() == null ? DEFAULT_LIMIT : q.getLimit();
+ q.setLimit(Math.max(1, Math.min(limit, MAX_LIMIT)));
+ return reportMapper.selectOutbox(gyid(), q);
+ }
+
+ @Override
+ public Map stats()
+ {
+ Map outbox = reportMapper.selectOutboxStats(gyid());
+ if (outbox == null)
+ {
+ outbox = new LinkedHashMap<>();
+ }
+ Map result = new LinkedHashMap<>();
+ result.put("outbox", outbox);
+ result.put("watermark", watermark());
+ result.put("pendingCount", longOf(outbox, "pending"));
+ return result;
+ }
+
+ @Override
+ public Map requeue(Long id)
+ {
+ if (id == null)
+ {
+ throw new AptException(AptErrorCode.BAD_REQUEST, "MISSING_ID", "缺少 outbox 主键");
+ }
+ Map row = reportMapper.selectOutboxById(gyid(), id);
+ if (row == null)
+ {
+ throw new AptException(AptErrorCode.NOT_FOUND, "OUTBOX_NOT_FOUND", "outbox 记录不存在");
+ }
+ String state = text(row.get("state"));
+ if (!RETRYABLE.contains(state))
+ {
+ throw new AptException(AptErrorCode.STATE_ILLEGAL, "OUTBOX_NOT_RETRYABLE",
+ "仅 FAILED / DEAD 可重试,当前态:" + state);
+ }
+ String batchId = "RB-" + UUID.randomUUID().toString().replace("-", "");
+ int rows = reportMapper.requeueOutbox(gyid(), id, batchId);
+ if (rows == 0)
+ {
+ // 并发下已被人重试:按状态冲突返回,避免重复批次
+ throw new AptException(AptErrorCode.STATE_ILLEGAL, "OUTBOX_NOT_RETRYABLE",
+ "该记录已被重试或状态已变更,请刷新");
+ }
+ Map updated = reportMapper.selectOutboxById(gyid(), id);
+ if (updated == null)
+ {
+ throw new AptException(AptErrorCode.DATA_ANOMALY, "OUTBOX_LOST", "重试后记录不可读");
+ }
+ updated.put("retryBatchId", batchId);
+ return updated;
+ }
+
+ @Override
+ public Map watermark()
+ {
+ Map row = reportMapper.selectWatermark(gyid());
+ long current = longOf(row, "currentWatermark");
+ long acked = longOf(row, "ackedWatermark");
+ Map view = new LinkedHashMap<>();
+ view.put("currentWatermark", current);
+ view.put("ackedWatermark", acked);
+ view.put("lag", Math.max(0, current - acked));
+ view.put("lastHeartbeatAt", row == null ? null : row.get("lastHeartbeatAt"));
+ view.put("clockSkewMs", row == null ? null : row.get("clockSkewMs"));
+ view.put("clockSkewThresholdMs", properties.getReport().getClockSkewThresholdMs());
+ return view;
+ }
+
+ @Override
+ public Map recordHeartbeat(Long currentWatermark, Long clockSkewMs)
+ {
+ long watermark = currentWatermark == null ? 0 : Math.max(0, currentWatermark);
+ reportMapper.upsertWatermark(gyid(), watermark, clockSkewMs, new Date());
+ Map view = watermark();
+ long threshold = properties.getReport().getClockSkewThresholdMs();
+ view.put("clockSkewExceeded",
+ clockSkewMs != null && Math.abs(clockSkewMs) > threshold);
+ return view;
+ }
+
+ @Override
+ public int ack(long ackedWatermark)
+ {
+ return reportMapper.ackWatermark(gyid(), Math.max(0, ackedWatermark));
+ }
+
+ @Override
+ public Map resume(List envelopes)
+ {
+ if (envelopes == null || envelopes.isEmpty())
+ {
+ throw new AptException(AptErrorCode.BAD_REQUEST, "EMPTY_RESUME_BATCH", "续报批次为空");
+ }
+ int maxBatch = properties.getReport().getResumeBatchLimit() > 0
+ ? properties.getReport().getResumeBatchLimit() : DEFAULT_RESUME_BATCH;
+ if (envelopes.size() > maxBatch)
+ {
+ throw new AptException(AptErrorCode.BAD_REQUEST, "RESUME_BATCH_TOO_LARGE",
+ "单次续报不能超过 " + maxBatch + " 条");
+ }
+ List> items = new ArrayList<>(envelopes.size());
+ int accepted = 0;
+ int duplicated = 0;
+ int rejected = 0;
+ for (AptResumeEnvelope envelope : envelopes)
+ {
+ Map item = new LinkedHashMap<>();
+ if (envelope == null || text(envelope.getBizType()).isEmpty()
+ || text(envelope.getBizId()).isEmpty())
+ {
+ item.put("status", "REJECTED");
+ item.put("reason", "MISSING_BIZ");
+ items.add(item);
+ rejected++;
+ continue;
+ }
+ long version = envelope.getBizVersion() == null ? 1L : envelope.getBizVersion();
+ String key = text(envelope.getIdempotencyKey());
+ if (key.isEmpty())
+ {
+ key = sha256(gyid() + "|" + envelope.getBizType() + "|"
+ + envelope.getBizId() + "|" + version);
+ }
+ item.put("idempotencyKey", key);
+ item.put("bizType", envelope.getBizType());
+ item.put("bizId", envelope.getBizId());
+ try
+ {
+ String payload = envelope.getPayload() == null ? null
+ : JSON.toJSONString(envelope.getPayload());
+ reportMapper.insertOutbox(gyid(), key, envelope.getBizType(), envelope.getBizId(),
+ version, payload, envelope.getOccurredAt() == null ? new Date() : envelope.getOccurredAt());
+ item.put("status", "ACCEPTED");
+ accepted++;
+ }
+ catch (DuplicateKeyException e)
+ {
+ // PL-IDEMPOTENT-REPLAY:视为成功,不产生新业务事实
+ item.put("status", "DUPLICATE");
+ duplicated++;
+ }
+ catch (RuntimeException e)
+ {
+ log.warn("续报入队失败,bizType={}, bizId={}, cause={}",
+ envelope.getBizType(), envelope.getBizId(), e.toString());
+ item.put("status", "REJECTED");
+ item.put("reason", "INSERT_FAILED");
+ rejected++;
+ }
+ items.add(item);
+ }
+ Map result = new LinkedHashMap<>();
+ result.put("accepted", accepted);
+ result.put("duplicated", duplicated);
+ result.put("rejected", rejected);
+ result.put("items", items);
+ return result;
+ }
+
+ private String gyid()
+ {
+ String gyid = AptContext.getGyIdOrNull();
+ if (gyid == null || gyid.isBlank())
+ {
+ throw new AptException(AptErrorCode.FORBIDDEN_GYID, "MISSING_GYID", "缺少公寓标识");
+ }
+ return gyid;
+ }
+
+ private static String text(Object value)
+ {
+ return value == null ? "" : String.valueOf(value).trim();
+ }
+
+ private static long longOf(Map row, String key)
+ {
+ if (row == null)
+ {
+ return 0L;
+ }
+ Object value = row.get(key);
+ if (value instanceof Number)
+ {
+ return ((Number) value).longValue();
+ }
+ try
+ {
+ return value == null ? 0L : Long.parseLong(String.valueOf(value));
+ }
+ catch (NumberFormatException e)
+ {
+ return 0L;
+ }
+ }
+
+ private static String sha256(String raw)
+ {
+ try
+ {
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ byte[] bytes = digest.digest(raw.getBytes(StandardCharsets.UTF_8));
+ StringBuilder builder = new StringBuilder(bytes.length * 2);
+ for (byte b : bytes)
+ {
+ builder.append(String.format("%02x", b));
+ }
+ return builder.toString();
+ }
+ catch (Exception e)
+ {
+ throw new AptException(AptErrorCode.INTERNAL_ERROR, "HASH_FAILED", "幂等键计算失败", e);
+ }
+ }
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/report/service/IAptReportService.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/report/service/IAptReportService.java
new file mode 100644
index 0000000..a6a4ccb
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/report/service/IAptReportService.java
@@ -0,0 +1,35 @@
+package com.ruoyi.apt.report.service;
+
+import java.util.List;
+import java.util.Map;
+import com.ruoyi.apt.report.domain.AptOutboxQuery;
+import com.ruoyi.apt.report.domain.AptResumeEnvelope;
+
+/**
+ * 上报续传:outbox 只读视图、失败重试、水位与心跳、断网续报。
+ *
+ * @author apt-rev01
+ */
+public interface IAptReportService
+{
+ /** 管理端 outbox 列表(默认 100,上限 500) */
+ List> listOutbox(AptOutboxQuery query);
+
+ /** 队列态汇总 + 水位差(acked/current/maxWatermark) */
+ Map stats();
+
+ /** FAILED/DEAD → PENDING,重试次数清零;非可重试态返回 40902 */
+ Map requeue(Long id);
+
+ /** 水位视图;无记录时返回 0 值,不返回 null */
+ Map watermark();
+
+ /** #14 心跳上报:记录 lastHeartbeatAt / clockSkewMs 并推进 current_watermark */
+ Map recordHeartbeat(Long currentWatermark, Long clockSkewMs);
+
+ /** 平台回执:推进 acked_watermark(只增不减) */
+ int ack(long ackedWatermark);
+
+ /** 断网续报:逐条按原 idempotencyKey 入队,重复视为成功(不产生新事实) */
+ Map resume(List envelopes);
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/controller/AptRoomController.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/controller/AptRoomController.java
index f25963d..d30aee0 100644
--- a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/controller/AptRoomController.java
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/controller/AptRoomController.java
@@ -15,13 +15,18 @@ import com.ruoyi.apt.framework.audit.AptAuditWriter;
import com.ruoyi.apt.framework.web.AptBaseController;
import com.ruoyi.apt.ops.domain.AptOperationLog;
import com.ruoyi.apt.stay.domain.AptRoomCommand;
+import com.ruoyi.apt.stay.domain.AptRoomCreateCommand;
+import com.ruoyi.apt.stay.domain.AptRoomDetail;
import com.ruoyi.apt.stay.domain.AptRoomStatusCommand;
import com.ruoyi.apt.stay.domain.AptRoomStatusResult;
import com.ruoyi.apt.stay.service.IAptRoomService;
+import com.ruoyi.apt.voice.domain.AptSipIpCommand;
+import com.ruoyi.apt.voice.service.IAptSipEndpointService;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.utils.SecurityUtils;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
@@ -35,7 +40,8 @@ import org.springframework.web.bind.annotation.RestController;
* 房间管理(IT-2 / M2,公寓端/04 表 4-7 接口 1~6)。
*
* 楼栋字段未启用(G-01)时 building 降级为楼层前缀匹配并在 warnings[] 标注;
- * 停用/恢复原因显式写 duty_operation_log;新增房间在 G-01/G-04 签字前恒返回 40940。
+ * 停用/恢复原因显式写 duty_operation_log;房间登记(POST)只把平台基线落成运行时记录,
+ * 并在同一事务创建 SIP endpoint/aor/绑定,删除时反向同步下线。
*
* @author apt-rev01
*/
@@ -44,11 +50,14 @@ import org.springframework.web.bind.annotation.RestController;
public class AptRoomController extends AptBaseController
{
private final IAptRoomService roomService;
+ private final IAptSipEndpointService sipEndpointService;
private final AptAuditWriter auditWriter;
- public AptRoomController(IAptRoomService roomService, AptAuditWriter auditWriter)
+ public AptRoomController(IAptRoomService roomService, IAptSipEndpointService sipEndpointService,
+ AptAuditWriter auditWriter)
{
this.roomService = roomService;
+ this.sipEndpointService = sipEndpointService;
this.auditWriter = auditWriter;
}
@@ -106,13 +115,67 @@ public class AptRoomController extends AptBaseController
return AjaxResult.success(result);
}
- /** 表 4-7 #6:G-01/G-04 未签字前不开放本地新增,权限点 3014 亦保持置灰 */
+ /**
+ * 表 4-7 #6:房间登记。
+ *
+ * 只允许把平台基线 {@code apt_master_room} 落成 {@code gy_rooms},
+ * 同事务创建 SIP 三件套;楼栋/床位等快照字段仍以平台为准。
+ */
@PreAuthorize("@ss.hasPermi('apt:room:add')")
+ @AptAudit(module = "STAY", action = "ROOM_ADD")
@PostMapping
- public AjaxResult add()
+ public AjaxResult add(@Valid @RequestBody AptRoomCreateCommand command)
{
- throw new AptException(AptErrorCode.FEATURE_DISABLED, "ROOM_ADD_DISABLED",
- "楼栋与房间新增口径(G-01/G-04)未定稿,本期不开放新增房间");
+ AptRoomDetail created = roomService.create(command);
+ return AjaxResult.success(created);
+ }
+
+ /** 房间删除:软删房间并同步下线分机(有在寓人员时 40902) */
+ @PreAuthorize("@ss.hasPermi('apt:room:edit')")
+ @AptAudit(module = "STAY", action = "ROOM_DELETE")
+ @DeleteMapping("/{id}")
+ public AjaxResult delete(@PathVariable Long id)
+ {
+ roomService.delete(id);
+ return AjaxResult.success();
+ }
+
+ /** 房间 SIP 档案:endpoint + aor + contact(含历史)+ ACTIVE 绑定 */
+ @PreAuthorize("@ss.hasPermi('apt:room:query')")
+ @GetMapping("/{id}/sip")
+ public AjaxResult sip(@PathVariable Long id)
+ {
+ return AjaxResult.success(sipEndpointService.viewByRoom(roomService.roomIdOf(id)));
+ }
+
+ /** 补建/对账:确保该房间的 endpoint/aor/绑定存在(幂等,可用于历史房间自愈) */
+ @PreAuthorize("@ss.hasPermi('apt:room:edit')")
+ @AptAudit(module = "STAY", action = "ROOM_SIP_RECONCILE")
+ @PostMapping("/{id}/sip/reconcile")
+ public AjaxResult reconcileSip(@PathVariable Long id)
+ {
+ AptRoomDetail room = roomService.detail(id);
+ return AjaxResult.success(sipEndpointService.ensureForRoom(
+ room.getRoomId(), room.getRoomNo(), null, null));
+ }
+
+ /** 绑定话机 IP(同一 IP 已绑定其它分机 → 40901) */
+ @PreAuthorize("@ss.hasPermi('apt:room:edit')")
+ @AptAudit(module = "STAY", action = "ROOM_SIP_BIND")
+ @PostMapping("/{id}/sip/ip")
+ public AjaxResult bindSipIp(@PathVariable Long id, @Valid @RequestBody AptSipIpCommand command)
+ {
+ return AjaxResult.success(sipEndpointService.bindIpByRoom(
+ roomService.roomIdOf(id), command.getIp(), command.getPort()));
+ }
+
+ /** 解绑话机 IP(释放 ACTIVE contact,保留历史) */
+ @PreAuthorize("@ss.hasPermi('apt:room:edit')")
+ @AptAudit(module = "STAY", action = "ROOM_SIP_UNBIND")
+ @DeleteMapping("/{id}/sip/ip")
+ public AjaxResult unbindSipIp(@PathVariable Long id)
+ {
+ return AjaxResult.success(sipEndpointService.unbindIpByRoom(roomService.roomIdOf(id)));
}
/** 停用/恢复原因必须进审计 detail(表 7-3 M2「恢复触发重算」取证) */
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/domain/AptRoomCreateCommand.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/domain/AptRoomCreateCommand.java
new file mode 100644
index 0000000..a3e18df
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/domain/AptRoomCreateCommand.java
@@ -0,0 +1,59 @@
+package com.ruoyi.apt.stay.domain;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+
+/**
+ * 房间本地登记命令(表 4-7 #6)。
+ *
+ * 房间主数据仍以 {@code apt_master_room} 为只读基线:本命令只允许「按房号把平台基线
+ * 落成 {@code gy_rooms} 运行时记录」,不接受床位/楼层等快照字段的改写。
+ * 落库同时创建 SIP 三件套(endpoint / aor / 绑定),保证房间与分机一致。
+ *
+ * @author apt-rev01
+ */
+public class AptRoomCreateCommand
+{
+ /** 房号,必须已存在于 apt_master_room(ACTIVE、未删除) */
+ @NotBlank(message = "房号不能为空")
+ @Size(max = 32, message = "房号长度不能超过 32")
+ private String roomNo;
+
+ /** 展示名,空则由「房间 + 房号」派生,同时作为 endpoint 的 display_name */
+ @Size(max = 64, message = "展示名长度不能超过 64")
+ private String displayName;
+
+ /** 分机号,空则由房号派生(分机号创建后不可改) */
+ @Size(max = 8, message = "分机号长度不能超过 8")
+ private String extension;
+
+ public String getRoomNo()
+ {
+ return roomNo;
+ }
+
+ public void setRoomNo(String roomNo)
+ {
+ this.roomNo = roomNo;
+ }
+
+ public String getDisplayName()
+ {
+ return displayName;
+ }
+
+ public void setDisplayName(String displayName)
+ {
+ this.displayName = displayName;
+ }
+
+ public String getExtension()
+ {
+ return extension;
+ }
+
+ public void setExtension(String extension)
+ {
+ this.extension = extension;
+ }
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/mapper/AptRoomMapper.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/mapper/AptRoomMapper.java
index 5acff98..2e70ddd 100644
--- a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/mapper/AptRoomMapper.java
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/mapper/AptRoomMapper.java
@@ -26,6 +26,19 @@ public interface AptRoomMapper
AptRoom selectRoomById(@Param("gyid") String gyid, @Param("id") Long id);
+ AptRoom selectRoomByRoomId(@Param("gyid") String gyid, @Param("roomId") String roomId);
+
+ /** 平台基线:登记房间时按房号取只读快照(床位/楼层/房型) */
+ Map selectMasterRoomByNo(@Param("gyid") String gyid, @Param("roomNo") String roomNo);
+
+ int countRoomByNo(@Param("gyid") String gyid, @Param("roomNo") String roomNo);
+
+ int insertRoom(@Param("gyid") String gyid, @Param("roomId") String roomId,
+ @Param("roomNo") String roomNo, @Param("roomFloor") String roomFloor,
+ @Param("roomType") String roomType);
+
+ int softDeleteRoom(@Param("gyid") String gyid, @Param("id") Long id);
+
List> selectRoomBindings(@Param("gyid") String gyid,
@Param("roomId") String roomId);
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/service/AptRoomServiceImpl.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/service/AptRoomServiceImpl.java
index e377d01..fd8c68a 100644
--- a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/service/AptRoomServiceImpl.java
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/service/AptRoomServiceImpl.java
@@ -11,11 +11,13 @@ import com.ruoyi.apt.common.AptErrorCode;
import com.ruoyi.apt.common.AptException;
import com.ruoyi.apt.stay.domain.AptRoom;
import com.ruoyi.apt.stay.domain.AptRoomCommand;
+import com.ruoyi.apt.stay.domain.AptRoomCreateCommand;
import com.ruoyi.apt.stay.domain.AptRoomDetail;
import com.ruoyi.apt.stay.domain.AptRoomStatusCommand;
import com.ruoyi.apt.stay.domain.AptRoomStatusResult;
import com.ruoyi.apt.stay.domain.AptRoomStatusSummary;
import com.ruoyi.apt.stay.mapper.AptRoomMapper;
+import com.ruoyi.apt.voice.service.IAptSipEndpointService;
import com.ruoyi.common.utils.SecurityUtils;
import org.springframework.beans.BeanUtils;
import org.springframework.dao.DuplicateKeyException;
@@ -45,10 +47,12 @@ public class AptRoomServiceImpl implements IAptRoomService
private static final String ACTION_CORRECT = "CORRECT";
private final AptRoomMapper roomMapper;
+ private final IAptSipEndpointService sipEndpointService;
- public AptRoomServiceImpl(AptRoomMapper roomMapper)
+ public AptRoomServiceImpl(AptRoomMapper roomMapper, IAptSipEndpointService sipEndpointService)
{
this.roomMapper = roomMapper;
+ this.sipEndpointService = sipEndpointService;
}
@Override
@@ -92,7 +96,7 @@ public class AptRoomServiceImpl implements IAptRoomService
@Override
public void edit(Long id, AptRoomCommand command)
{
- mustExist(id);
+ AptRoom room = mustExist(id);
if (command == null)
{
throw missing("请求体");
@@ -114,6 +118,65 @@ public class AptRoomServiceImpl implements IAptRoomService
throw invalid("roomOrder 不能为负数");
}
roomMapper.updateRoomLocal(gyid(), id, command);
+ // 房间档案变更 → 同步 SIP 分机(展示名不动,仅保证 endpoint/aor/绑定存在)
+ sipEndpointService.ensureForRoom(room.getRoomId(), room.getRoomNo(), null, null);
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public AptRoomDetail create(AptRoomCreateCommand command)
+ {
+ if (command == null || command.getRoomNo() == null || command.getRoomNo().isBlank())
+ {
+ throw missing("roomNo");
+ }
+ String gyid = gyid();
+ String roomNo = command.getRoomNo().trim();
+ Map master = roomMapper.selectMasterRoomByNo(gyid, roomNo);
+ if (master == null)
+ {
+ throw new AptException(AptErrorCode.NOT_FOUND, "ROOM_MASTER_NOT_FOUND",
+ "平台房间基线不存在或未启用:" + roomNo);
+ }
+ if (roomMapper.countRoomByNo(gyid, roomNo) > 0)
+ {
+ throw new AptException(AptErrorCode.DUPLICATE_KEY, "ROOM_DUPLICATED",
+ "房间 " + roomNo + " 已登记,请勿重复创建");
+ }
+ String roomId = stringValue(master.get("roomId"));
+ roomMapper.insertRoom(gyid, roomId, roomNo,
+ stringValue(master.get("roomFloor")), stringValue(master.get("roomType")));
+ // 同一事务:房间落库 + SIP 三件套(endpoint / aor / ACTIVE 绑定),失败整体回滚
+ sipEndpointService.ensureForRoom(roomId, roomNo, command.getDisplayName(), command.getExtension());
+ AptRoom created = roomMapper.selectRoomByRoomId(gyid, roomId);
+ if (created == null)
+ {
+ throw new AptException(AptErrorCode.DATA_ANOMALY, "ROOM_CREATE_FAILED", "房间创建后回读失败:" + roomNo);
+ }
+ return detail(created.getId());
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public void delete(Long id)
+ {
+ AptRoom room = mustExist(id);
+ if (room.getInPersonCount() != null && room.getInPersonCount() > 0)
+ {
+ throw stateIllegal("房间仍有在寓人员,不可删除;请先办理退宿或换房");
+ }
+ // 先关 SIP 绑定/释放 contact,再软删房间,避免留下指向已删房间的 ACTIVE 绑定
+ sipEndpointService.removeForRoom(room.getRoomId());
+ if (roomMapper.softDeleteRoom(gyid(), id) == 0)
+ {
+ throw new AptException(AptErrorCode.NOT_FOUND, "ROOM_NOT_FOUND", "房间不存在:" + id);
+ }
+ }
+
+ @Override
+ public String roomIdOf(Long id)
+ {
+ return mustExist(id).getRoomId();
}
@Override
@@ -300,6 +363,11 @@ public class AptRoomServiceImpl implements IAptRoomService
return value instanceof Number ? ((Number) value).longValue() : Long.parseLong(value.toString());
}
+ private String stringValue(Object value)
+ {
+ return value == null ? null : String.valueOf(value);
+ }
+
private String requestedBy()
{
try
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/service/IAptRoomService.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/service/IAptRoomService.java
index cd76e84..78d006b 100644
--- a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/service/IAptRoomService.java
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/service/IAptRoomService.java
@@ -3,6 +3,7 @@ package com.ruoyi.apt.stay.service;
import java.util.List;
import com.ruoyi.apt.stay.domain.AptRoom;
import com.ruoyi.apt.stay.domain.AptRoomCommand;
+import com.ruoyi.apt.stay.domain.AptRoomCreateCommand;
import com.ruoyi.apt.stay.domain.AptRoomDetail;
import com.ruoyi.apt.stay.domain.AptRoomStatusCommand;
import com.ruoyi.apt.stay.domain.AptRoomStatusResult;
@@ -27,4 +28,16 @@ public interface IAptRoomService
void edit(Long id, AptRoomCommand command);
AptRoomStatusResult changeStatus(Long id, AptRoomStatusCommand command);
+
+ /**
+ * 房间登记:把平台基线 {@code apt_master_room} 落成 {@code gy_rooms},
+ * 并在同一事务内创建 SIP 三件套(endpoint / aor / ACTIVE 绑定)。
+ */
+ AptRoomDetail create(AptRoomCreateCommand command);
+
+ /** 房间删除:软删 {@code gy_rooms},同步关闭绑定、释放 contact、下线 endpoint/aor */
+ void delete(Long id);
+
+ /** 供 SIP 子资源定位:按主键取 room_id(不存在返回 40401) */
+ String roomIdOf(Long id);
}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/domain/AptSipIpCommand.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/domain/AptSipIpCommand.java
new file mode 100644
index 0000000..569d7c2
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/domain/AptSipIpCommand.java
@@ -0,0 +1,43 @@
+package com.ruoyi.apt.voice.domain;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+
+/**
+ * 话机 IP 绑定命令(房间 → endpoint → contact)。
+ *
+ * 只接受话机自身 IP 与 SIP 端口:不接受 sip 域名或端口段,
+ * 因为 contact 的 uri 要能被 Asterisk 直接注册/探测,含歧义的地址无法定位冲突。
+ *
+ * @author apt-rev01
+ */
+public class AptSipIpCommand
+{
+ /** 话机 IP(点分十进制 IPv4),同一公寓内只能绑定到一个分机 */
+ @NotBlank(message = "话机 IP 不能为空")
+ @Size(max = 64, message = "IP 长度不能超过 64")
+ private String ip;
+
+ /** SIP 端口,缺省 5060 */
+ private Integer port;
+
+ public String getIp()
+ {
+ return ip;
+ }
+
+ public void setIp(String ip)
+ {
+ this.ip = ip;
+ }
+
+ public Integer getPort()
+ {
+ return port;
+ }
+
+ public void setPort(Integer port)
+ {
+ this.port = port;
+ }
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/mapper/AptSipEndpointMapper.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/mapper/AptSipEndpointMapper.java
new file mode 100644
index 0000000..748161b
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/mapper/AptSipEndpointMapper.java
@@ -0,0 +1,91 @@
+package com.ruoyi.apt.voice.mapper;
+
+import java.util.List;
+import java.util.Map;
+import org.apache.ibatis.annotations.Param;
+
+/**
+ * SIP 分机 / AOR / Contact / 房间绑定 的数据访问。
+ *
+ * 硬约束:
+ *
+ * - 每条 SQL 必带 {@code gyid};
+ * - 只写 {@code apt_sip_*} 四张表,不碰 {@code gy_jcxx}(叫乘状态仍只由状态机写);
+ * - 软删除统一 {@code deleted=1},contact 解绑只置 {@code state='RELEASED'} 保留历史。
+ *
+ *
+ * @author apt-rev01
+ */
+public interface AptSipEndpointMapper
+{
+ // ------------------------------------------------------------ endpoint
+ Map selectEndpointByRoom(@Param("gyid") String gyid, @Param("roomId") String roomId);
+
+ Map selectEndpoint(@Param("gyid") String gyid, @Param("endpointId") String endpointId);
+
+ int countEndpointByExtension(@Param("gyid") String gyid, @Param("extension") String extension);
+
+ int insertEndpoint(@Param("gyid") String gyid, @Param("endpointId") String endpointId,
+ @Param("extension") String extension, @Param("roomId") String roomId,
+ @Param("displayName") String displayName);
+
+ int updateEndpointProfile(@Param("gyid") String gyid, @Param("endpointId") String endpointId,
+ @Param("displayName") String displayName, @Param("enabled") boolean enabled);
+
+ int updateEndpointIp(@Param("gyid") String gyid, @Param("endpointId") String endpointId,
+ @Param("ipAddress") String ipAddress);
+
+ int softDeleteEndpoint(@Param("gyid") String gyid, @Param("endpointId") String endpointId);
+
+ // ----------------------------------------------------------------- aor
+ Map selectAorByEndpoint(@Param("gyid") String gyid, @Param("endpointId") String endpointId);
+
+ int insertAor(@Param("gyid") String gyid, @Param("aorId") String aorId,
+ @Param("endpointId") String endpointId);
+
+ int softDeleteAor(@Param("gyid") String gyid, @Param("endpointId") String endpointId);
+
+ // ------------------------------------------------------------- contact
+ List> selectContacts(@Param("gyid") String gyid, @Param("endpointId") String endpointId);
+
+ List> selectActiveContacts(@Param("gyid") String gyid,
+ @Param("endpointId") String endpointId);
+
+ Map selectActiveContactByIp(@Param("gyid") String gyid, @Param("ipAddress") String ipAddress);
+
+ int releaseContacts(@Param("gyid") String gyid, @Param("endpointId") String endpointId,
+ @Param("remark") String remark);
+
+ int insertContact(@Param("gyid") String gyid, @Param("contactId") String contactId,
+ @Param("aorId") String aorId, @Param("endpointId") String endpointId,
+ @Param("uri") String uri, @Param("ipAddress") String ipAddress,
+ @Param("port") int port);
+
+ // ----------------------------------------------------------------- auth
+ /** 公寓当前启用的 Asterisk 实例(多实例场景取第一个启用项,认证按实例下发) */
+ String selectActiveInstanceCode(@Param("gyid") String gyid);
+
+ Map selectAuth(@Param("gyid") String gyid, @Param("instanceCode") String instanceCode,
+ @Param("username") String username);
+
+ /** 建认证账号;口令走表默认值(psw.voip),secret_mode 默认 PLAIN */
+ int insertAuth(@Param("gyid") String gyid, @Param("instanceCode") String instanceCode,
+ @Param("username") String username, @Param("authId") String authId,
+ @Param("endpointId") String endpointId);
+
+ /** 房间删除/分机下线:禁用认证并置 PENDING,等待 Asterisk 收敛 */
+ int disableAuthByEndpoint(@Param("gyid") String gyid, @Param("endpointId") String endpointId);
+
+ // ------------------------------------------------------------- binding
+ Map selectActiveBindingByRoom(@Param("gyid") String gyid, @Param("roomId") String roomId);
+
+ Map selectActiveBindingByEndpoint(@Param("gyid") String gyid,
+ @Param("endpointId") String endpointId);
+
+ int insertBinding(@Param("gyid") String gyid, @Param("bindingId") String bindingId,
+ @Param("endpointId") String endpointId, @Param("roomId") String roomId);
+
+ int closeBindingsByRoom(@Param("gyid") String gyid, @Param("roomId") String roomId);
+
+ int closeBindingsByEndpoint(@Param("gyid") String gyid, @Param("endpointId") String endpointId);
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/mapper/AptVoiceMapper.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/mapper/AptVoiceMapper.java
index 32c6e2c..38ad9c9 100644
--- a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/mapper/AptVoiceMapper.java
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/mapper/AptVoiceMapper.java
@@ -74,7 +74,13 @@ public interface AptVoiceMapper
Map selectJcxxForCall(@Param("gyid") String gyid, @Param("jcid") String jcid);
- /** 房间主分机(分机台账 + 房间绑定,ACTIVE 且在生效期内) */
+ /**
+ * 房间主分机(分机台账 + 房间绑定,ACTIVE 且在生效期内)。
+ *
+ * 入参是房号({@code gy_jcxx.fjh} = {@code apt_master_room.room_no},如 {@code 201}),
+ * 不是 {@code room_id};SQL 内部经 {@code apt_master_room} 换算成 {@code room_id} 后再关联
+ * {@code apt_sip_binding},避免两种标识混用。
+ */
Map selectRoomExtension(@Param("gyid") String gyid,
@Param("roomNo") String roomNo);
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/service/AptCallFlowServiceImpl.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/service/AptCallFlowServiceImpl.java
index 7610120..bdfc7c1 100644
--- a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/service/AptCallFlowServiceImpl.java
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/service/AptCallFlowServiceImpl.java
@@ -104,12 +104,14 @@ public class AptCallFlowServiceImpl implements IAptCallFlowService
throw new AptException(AptErrorCode.DEPENDENCY_UNAVAILABLE, "AMI_INSTANCE_MISSING",
"公寓未配置 Asterisk 实例");
}
+ // gy_jcxx.fjh 是房号(room_no),不是 room_id;换算与关联统一在 Mapper 内完成
String roomNo = str(jcxx.get("fjh"));
Map roomEndpoint = voiceMapper.selectRoomExtension(gyid, roomNo);
if (roomEndpoint == null)
{
throw new AptException(AptErrorCode.NOT_FOUND, "ENDPOINT_NOT_FOUND",
- "房间 " + roomNo + " 未绑定可用分机(apt_sip_binding / apt_sip_endpoint)");
+ "房间 " + roomNo + " 未绑定可用分机(apt_master_room.room_no → "
+ + "apt_sip_binding.room_id → apt_sip_endpoint)");
}
// 先落状态机再发起呼叫:即使 Originate 立即失败,也会经 CALL_FAILED 收敛到 ryzt=2
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/service/AptSipEndpointServiceImpl.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/service/AptSipEndpointServiceImpl.java
new file mode 100644
index 0000000..afe992a
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/service/AptSipEndpointServiceImpl.java
@@ -0,0 +1,407 @@
+package com.ruoyi.apt.voice.service;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import java.util.regex.Pattern;
+import com.ruoyi.apt.common.AptContext;
+import com.ruoyi.apt.common.AptErrorCode;
+import com.ruoyi.apt.common.AptException;
+import com.ruoyi.apt.voice.mapper.AptSipEndpointMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+/**
+ * SIP 分机与房间生命周期同步实现。
+ *
+ * 一致性策略:
+ *
+ * - 「先库后 Asterisk」:库里落 PENDING + config_version+1,由 Asterisk 侧同步任务收敛,
+ * 本类不直接改 Asterisk 配置;
+ * - 三件套(endpoint / aor / ACTIVE binding)在同一事务内建成,缺一即整体回滚;
+ * - 分机号不可改号:房间改号/换房不影响 extension,重复占用返回 40901;
+ * - IP 冲突在服务层先判(可读原因),库层唯一索引兜底并发。
+ *
+ *
+ * @author apt-rev01
+ */
+@Service
+public class AptSipEndpointServiceImpl implements IAptSipEndpointService
+{
+ private static final Logger log = LoggerFactory.getLogger(AptSipEndpointServiceImpl.class);
+
+ /** 分机号:1~8 位字母数字(与 apt_sip_endpoint.extension CHAR(8) 对齐) */
+ private static final Pattern EXTENSION = Pattern.compile("^[0-9A-Za-z]{1,8}$");
+
+ private static final Pattern IPV4 = Pattern.compile(
+ "^(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}$");
+
+ private static final int DEFAULT_SIP_PORT = 5060;
+
+ private final AptSipEndpointMapper mapper;
+
+ public AptSipEndpointServiceImpl(AptSipEndpointMapper mapper)
+ {
+ this.mapper = mapper;
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public Map ensureForRoom(String roomId, String roomNo, String displayName, String extension)
+ {
+ String gyid = gyid();
+ requireText(roomId, "roomId");
+ Map existing = mapper.selectEndpointByRoom(gyid, roomId);
+ String name = notBlank(displayName) ? displayName.trim() : "房间 " + safe(roomNo);
+ if (!absent(existing))
+ {
+ // 幂等:已存在则只补齐缺失的 AOR / ACTIVE 绑定;
+ // 展示名仅在调用方显式传入时覆盖,避免房间编辑把人工命名重置成默认值
+ String endpointId = text(existing.get("endpointId"));
+ if (notBlank(displayName) && !displayName.trim().equals(text(existing.get("displayName"))))
+ {
+ mapper.updateEndpointProfile(gyid, endpointId, name, true);
+ }
+ ensureAor(gyid, endpointId);
+ ensureBinding(gyid, endpointId, roomId);
+ ensureAuth(gyid, endpointId, text(existing.get("extension")));
+ return viewByRoom(roomId);
+ }
+
+ String ext = resolveExtension(extension, roomNo);
+ if (mapper.countEndpointByExtension(gyid, ext) > 0)
+ {
+ throw conflict("EXTENSION_CONFLICT", "分机号 " + ext + " 已被占用(分机号不可复用)");
+ }
+
+ String endpointId = "EP-" + suffix();
+ try
+ {
+ mapper.insertEndpoint(gyid, endpointId, ext, roomId, name);
+ }
+ catch (DuplicateKeyException e)
+ {
+ // 并发或历史软删除占用同一分机号(UK 含已删除行,号码不复用)
+ throw conflict("EXTENSION_CONFLICT", "分机号 " + ext + " 已被占用(分机号不可复用)");
+ }
+ ensureAor(gyid, endpointId);
+ ensureBinding(gyid, endpointId, roomId);
+ ensureAuth(gyid, endpointId, ext);
+ log.info("房间 {} 已创建分机 endpoint={} ext={}(sync_state=PENDING,待 Asterisk 收敛)",
+ roomId, endpointId, ext);
+ return viewByRoom(roomId);
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public Map updateForRoom(String roomId, String displayName, boolean enabled)
+ {
+ String gyid = gyid();
+ requireText(roomId, "roomId");
+ Map endpoint = mapper.selectEndpointByRoom(gyid, roomId);
+ if (absent(endpoint))
+ {
+ throw new AptException(AptErrorCode.NOT_FOUND, "SIP_ENDPOINT_NOT_FOUND",
+ "房间 " + roomId + " 尚未创建分机,请先补建");
+ }
+ String endpointId = text(endpoint.get("endpointId"));
+ String name = notBlank(displayName) ? displayName.trim() : text(endpoint.get("displayName"));
+ mapper.updateEndpointProfile(gyid, endpointId, name, enabled);
+ return viewByRoom(roomId);
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public void removeForRoom(String roomId)
+ {
+ String gyid = gyid();
+ requireText(roomId, "roomId");
+ Map endpoint = mapper.selectEndpointByRoom(gyid, roomId);
+ // 绑定按房间关,避免 endpoint 已软删后查不到 room_id
+ mapper.closeBindingsByRoom(gyid, roomId);
+ if (absent(endpoint))
+ {
+ return;
+ }
+ String endpointId = text(endpoint.get("endpointId"));
+ mapper.releaseContacts(gyid, endpointId, "ROOM_DELETED");
+ mapper.disableAuthByEndpoint(gyid, endpointId);
+ mapper.softDeleteAor(gyid, endpointId);
+ mapper.softDeleteEndpoint(gyid, endpointId);
+ log.info("房间 {} 已删除,分机 endpoint={} 同步下线(aor/contact 一并释放)", roomId, endpointId);
+ }
+
+ @Override
+ public Map viewByRoom(String roomId)
+ {
+ String gyid = gyid();
+ requireText(roomId, "roomId");
+ Map result = new LinkedHashMap<>();
+ result.put("roomId", roomId);
+ Map endpoint = mapper.selectEndpointByRoom(gyid, roomId);
+ if (absent(endpoint))
+ {
+ result.put("endpoint", null);
+ result.put("binding", mapper.selectActiveBindingByRoom(gyid, roomId));
+ result.put("warnings", List.of("该房间尚未创建 SIP 分机(可调用 reconcile 补建)"));
+ return result;
+ }
+ String endpointId = text(endpoint.get("endpointId"));
+ result.put("endpoint", endpoint);
+ result.put("aor", mapper.selectAorByEndpoint(gyid, endpointId));
+ result.put("activeContacts", mapper.selectActiveContacts(gyid, endpointId));
+ result.put("contactHistory", mapper.selectContacts(gyid, endpointId));
+ result.put("binding", mapper.selectActiveBindingByRoom(gyid, roomId));
+ return result;
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public Map bindIp(String endpointId, String ipAddress, Integer port)
+ {
+ String gyid = gyid();
+ requireText(endpointId, "endpointId");
+ String ip = normalizeIp(ipAddress);
+ int sipPort = normalizePort(port);
+
+ Map endpoint = mustEndpoint(gyid, endpointId);
+ Map aor = ensureAor(gyid, endpointId);
+
+ Map occupied = mapper.selectActiveContactByIp(gyid, ip);
+ if (!absent(occupied) && !endpointId.equals(text(occupied.get("endpointId"))))
+ {
+ // 同一 IP 已绑定到别的分机:不静默顶掉,要求先解绑,避免两台话机抢同一地址
+ throw conflict("SIP_IP_CONFLICT", "IP " + ip + " 已绑定分机 "
+ + text(occupied.get("endpointId")) + "(" + text(occupied.get("uri"))
+ + "),请先解绑后再绑定");
+ }
+ if (!absent(occupied) && endpointId.equals(text(occupied.get("endpointId")))
+ && sipPort == intValue(occupied.get("port"), DEFAULT_SIP_PORT))
+ {
+ // 幂等:同分机同 IP 同端口,重复绑定直接返回
+ return viewAfterChange(endpoint, endpointId);
+ }
+
+ mapper.releaseContacts(gyid, endpointId, "REBIND");
+ String uri = "sip:" + text(endpoint.get("extension")) + "@" + ip + ":" + sipPort;
+ try
+ {
+ mapper.insertContact(gyid, "CT-" + suffix(), text(aor.get("aorId")), endpointId,
+ uri, ip, sipPort);
+ }
+ catch (DuplicateKeyException e)
+ {
+ // 并发下两个分机同时抢同一 IP,库层唯一索引兜底
+ throw conflict("SIP_IP_CONFLICT", "IP " + ip + " 已被其它分机占用(并发冲突)");
+ }
+ mapper.updateEndpointIp(gyid, endpointId, ip);
+ log.info("分机 {} 绑定话机 IP {}:{}(uri={},sync_state=PENDING)", endpointId, ip, sipPort, uri);
+ return viewAfterChange(endpoint, endpointId);
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public Map unbindIp(String endpointId)
+ {
+ String gyid = gyid();
+ requireText(endpointId, "endpointId");
+ Map endpoint = mustEndpoint(gyid, endpointId);
+ mapper.releaseContacts(gyid, endpointId, "UNBIND");
+ mapper.updateEndpointIp(gyid, endpointId, null);
+ log.info("分机 {} 已解绑话机 IP(sync_state=PENDING)", endpointId);
+ return viewAfterChange(endpoint, endpointId);
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public Map bindIpByRoom(String roomId, String ipAddress, Integer port)
+ {
+ return bindIp(requireEndpointOfRoom(gyid(), roomId), ipAddress, port);
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public Map unbindIpByRoom(String roomId)
+ {
+ return unbindIp(requireEndpointOfRoom(gyid(), roomId));
+ }
+
+ // ------------------------------------------------------------------ 内部
+
+ /** 房间视角找分机:未建分机直接 40401,避免「解绑成功但什么也没发生」的假象 */
+ private String requireEndpointOfRoom(String gyid, String roomId)
+ {
+ requireText(roomId, "roomId");
+ Map endpoint = mapper.selectEndpointByRoom(gyid, roomId);
+ if (absent(endpoint))
+ {
+ throw new AptException(AptErrorCode.NOT_FOUND, "SIP_ENDPOINT_NOT_FOUND",
+ "房间 " + roomId + " 尚未创建分机,请先补建(reconcile)");
+ }
+ return text(endpoint.get("endpointId"));
+ }
+
+ /** 变更后回显:正常按房间聚合;分机已脱离房间(room_id 为空)时退回分机视图 */
+ private Map viewAfterChange(Map endpoint, String endpointId)
+ {
+ String roomId = text(endpoint.get("roomId"));
+ if (roomId.isBlank())
+ {
+ Map result = new LinkedHashMap<>();
+ result.put("endpoint", mapper.selectEndpoint(gyid(), endpointId));
+ return result;
+ }
+ return viewByRoom(roomId);
+ }
+
+ /**
+ * 认证账号(apt_sip_auth):与 endpoint 1:1,按「公寓 + Asterisk 实例」隔离。
+ *
+ * 无启用实例时跳过并告警——房间/分机可以先建,等实例就绪后 reconcile 补齐;
+ * 口令不在此处传递,走表默认值(psw.voip / PLAIN),生产改 ENV 模式后由环境注入。
+ */
+ private void ensureAuth(String gyid, String endpointId, String username)
+ {
+ if (!notBlank(username))
+ {
+ return;
+ }
+ String instanceCode = mapper.selectActiveInstanceCode(gyid);
+ if (!notBlank(instanceCode))
+ {
+ log.warn("公寓 {} 无启用 Asterisk 实例,分机 {} 暂不建 SIP 认证(实例就绪后 reconcile 补齐)",
+ gyid, endpointId);
+ return;
+ }
+ // 同实例同用户名已有账号即复用,不覆盖既有口令与轮换版本
+ if (!absent(mapper.selectAuth(gyid, instanceCode, username)))
+ {
+ return;
+ }
+ mapper.insertAuth(gyid, instanceCode, username,
+ "AUTH-" + instanceCode + "-" + username, endpointId);
+ }
+
+ private Map mustEndpoint(String gyid, String endpointId)
+ {
+ Map endpoint = mapper.selectEndpoint(gyid, endpointId);
+ if (absent(endpoint))
+ {
+ throw new AptException(AptErrorCode.NOT_FOUND, "SIP_ENDPOINT_NOT_FOUND",
+ "分机不存在或已删除:" + endpointId);
+ }
+ return endpoint;
+ }
+
+ /** AOR 与 endpoint 1:1,缺失即补建(自愈,不覆盖已有 max_contacts 等参数) */
+ private Map ensureAor(String gyid, String endpointId)
+ {
+ Map aor = mapper.selectAorByEndpoint(gyid, endpointId);
+ if (!absent(aor))
+ {
+ return aor;
+ }
+ String aorId = "AOR-" + suffix();
+ mapper.insertAor(gyid, aorId, endpointId);
+ return mapper.selectAorByEndpoint(gyid, endpointId);
+ }
+
+ /** 房间绑定缺失即补建;已有其它 ACTIVE 绑定时先关闭再建(一个 endpoint 仅一条 ACTIVE) */
+ private void ensureBinding(String gyid, String endpointId, String roomId)
+ {
+ Map current = mapper.selectActiveBindingByRoom(gyid, roomId);
+ if (!absent(current) && endpointId.equals(text(current.get("endpointId"))))
+ {
+ return;
+ }
+ mapper.closeBindingsByEndpoint(gyid, endpointId);
+ mapper.closeBindingsByRoom(gyid, roomId);
+ mapper.insertBinding(gyid, "BND-" + suffix(), endpointId, roomId);
+ }
+
+ private String resolveExtension(String extension, String roomNo)
+ {
+ String value = notBlank(extension) ? extension.trim() : safe(roomNo).trim();
+ if (!EXTENSION.matcher(value).matches())
+ {
+ throw new AptException(AptErrorCode.BAD_REQUEST, "EXTENSION_INVALID",
+ "分机号必须是 1~8 位字母或数字(roomNo=" + roomNo + ",extension=" + value + ")");
+ }
+ return value;
+ }
+
+ private String normalizeIp(String ipAddress)
+ {
+ String ip = safe(ipAddress).trim();
+ if (!IPV4.matcher(ip).matches())
+ {
+ throw new AptException(AptErrorCode.BAD_REQUEST, "SIP_IP_INVALID",
+ "话机 IP 必须是点分十进制 IPv4:" + ipAddress);
+ }
+ return ip;
+ }
+
+ private int normalizePort(Integer port)
+ {
+ int value = port == null ? DEFAULT_SIP_PORT : port;
+ if (value < 1 || value > 65535)
+ {
+ throw new AptException(AptErrorCode.BAD_REQUEST, "SIP_PORT_INVALID", "SIP 端口超出 1~65535:" + port);
+ }
+ return value;
+ }
+
+ private AptException conflict(String reason, String message)
+ {
+ return new AptException(AptErrorCode.DUPLICATE_KEY, reason, message);
+ }
+
+ private void requireText(String value, String field)
+ {
+ if (!notBlank(value))
+ {
+ throw new AptException(AptErrorCode.BAD_REQUEST, "PARAM_INVALID", "缺少必填字段: " + field);
+ }
+ }
+
+ /** 12 位十六进制后缀,保证 endpoint/aor/contact/binding 标识可读且唯一 */
+ private String suffix()
+ {
+ return UUID.randomUUID().toString().replace("-", "").substring(0, 12).toUpperCase();
+ }
+
+ private boolean notBlank(String value)
+ {
+ return value != null && !value.isBlank();
+ }
+
+ /** 查询无记录:MyBatis 返回 null;mock/空结果集返回空 Map,两种都要当作「不存在」 */
+ private boolean absent(Map row)
+ {
+ return row == null || row.isEmpty();
+ }
+
+ private String text(Object value)
+ {
+ return value == null ? "" : String.valueOf(value);
+ }
+
+ private String safe(String value)
+ {
+ return value == null ? "" : value;
+ }
+
+ private int intValue(Object value, int defaultValue)
+ {
+ return value instanceof Number number ? number.intValue() : defaultValue;
+ }
+
+ private String gyid()
+ {
+ return AptContext.getGyId();
+ }
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/service/IAptSipEndpointService.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/service/IAptSipEndpointService.java
new file mode 100644
index 0000000..747e393
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/service/IAptSipEndpointService.java
@@ -0,0 +1,52 @@
+package com.ruoyi.apt.voice.service;
+
+import java.util.Map;
+
+/**
+ * SIP 分机(endpoint / aor / contact)与房间的生命周期同步。
+ *
+ * 调用方约定:
+ *
+ * - 房间新建 → {@link #ensureForRoom}(幂等,已存在则按需补齐 AOR/绑定);
+ * - 房间修改 → {@link #updateForRoom}(只同步展示名/启用态,分机号不可改);
+ * - 房间删除 → {@link #removeForRoom}(关绑定 + 释放 contact + 软删 endpoint/aor);
+ * - 话机 IP → {@link #bindIp} / {@link #unbindIp},同 IP 冲突返回 40901。
+ *
+ *
+ * gyid 一律取自 {@code AptContext},不接受入参,避免跨公寓改配置。
+ *
+ * @author apt-rev01
+ */
+public interface IAptSipEndpointService
+{
+ /**
+ * 房间创建/补建:保证 endpoint + aor + ACTIVE 绑定三件套存在。
+ *
+ * @param roomId 房间主键(apt_master_room.room_id / gy_rooms.room_id)
+ * @param roomNo 房号(仅用于派生分机号与展示名)
+ * @param displayName 展示名,空则用「房间 {roomNo}」
+ * @param extension 指定分机号,空则由房号派生
+ */
+ Map ensureForRoom(String roomId, String roomNo, String displayName, String extension);
+
+ /** 房间修改:同步展示名与启用态(分机号不可改,违反返回 40906) */
+ Map updateForRoom(String roomId, String displayName, boolean enabled);
+
+ /** 房间删除:关闭绑定、释放 contact、软删 aor/endpoint(幂等) */
+ void removeForRoom(String roomId);
+
+ /** 房间视角聚合:endpoint + aor + contact 历史 + ACTIVE 绑定 */
+ Map viewByRoom(String roomId);
+
+ /** 绑定话机 IP:先释放该分机旧 contact,再写新 contact(同 IP 冲突 → 40901) */
+ Map bindIp(String endpointId, String ipAddress, Integer port);
+
+ /** 解绑话机 IP:释放该分机全部 ACTIVE contact 并清空 endpoint.ip_address */
+ Map unbindIp(String endpointId);
+
+ /** 房间视角绑定:内部先按 roomId 找分机,未建分机返回 40401 */
+ Map bindIpByRoom(String roomId, String ipAddress, Integer port);
+
+ /** 房间视角解绑:同上 */
+ Map unbindIpByRoom(String roomId);
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/quartz/task/AptPhotoPurgeTask.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/quartz/task/AptPhotoPurgeTask.java
new file mode 100644
index 0000000..81f0d3a
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/quartz/task/AptPhotoPurgeTask.java
@@ -0,0 +1,33 @@
+package com.ruoyi.quartz.task;
+
+import com.ruoyi.apt.dispatch.tablet.service.IAptPhotoPurgeService;
+import org.springframework.stereotype.Component;
+
+/**
+ * 刷脸照片保留期清理 Quartz 任务(最多保留 1 个月)。
+ *
+ * 放在 {@code com.ruoyi.quartz.task} 是因为若依
+ * {@code ScheduleUtils.whiteList} 只允许该前缀的 {@code invokeTarget};
+ * 业务逻辑仍在 {@code ruoyi-apt},本类只做入口。
+ *
+ * 建议 cron:{@code 0 30 3 * * ?}(每天 03:30,避开叫乘高峰),
+ * 由 {@code sys_job} 登记,{@code invoke_target = aptPhotoPurgeTask.purge()}。
+ *
+ * @author apt-rev01
+ */
+@Component("aptPhotoPurgeTask")
+public class AptPhotoPurgeTask
+{
+ private final IAptPhotoPurgeService purgeService;
+
+ public AptPhotoPurgeTask(IAptPhotoPurgeService purgeService)
+ {
+ this.purgeService = purgeService;
+ }
+
+ /** 无参入口,返回统计便于 Quartz 日志留痕 */
+ public void purge()
+ {
+ purgeService.purgeExpired();
+ }
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/apt-defaults.yml b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/apt-defaults.yml
index 8ad26e9..9716faf 100644
--- a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/apt-defaults.yml
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/apt-defaults.yml
@@ -84,3 +84,69 @@ apt:
scan-batch-size: ${APT_SCAN_BATCH:50}
tts-template: ${APT_TTS_TEMPLATE:CALL_ROOM}
tts-text: ${APT_TTS_TEXT:请到门口集合,准备乘车}
+ # ---------------------------------------------------------------------------
+ # 语音链路(AMI)。默认关闭:未接 Asterisk 的环境不得因缺依赖启动失败,
+ # 也不得静默改状态(公寓端/06 §7「依赖缺失显式降级」)。
+ #
+ # 注意:AMI 地址/端口/账号/各项超时**不在本文件**,而在 apt_ast_instance 表
+ # (一公寓一实例,随实例走);AMI 口令只存环境变量名 ami_secret_ref,
+ # 运行期从进程环境读取(默认 APT_AMI_SECRET),不落库、不进仓库、不打日志。
+ # ---------------------------------------------------------------------------
+ voice:
+ # 总开关:false 时不建 AMI 连接、不发起 SIP 叫乘
+ enabled: ${APT_VOICE_ENABLED:false}
+ # 到点自动发起 SIP 叫乘;false 时只能由值班台手动
+ # POST /apt/jcxx/{jcid}/dial 触发
+ sip-call-enabled: ${APT_VOICE_SIP_CALL_ENABLED:true}
+ # 值班台分机(双腿呼叫的第二腿),逗号分隔,例:1002,1003;空=只呼房间腿
+ duty-extensions: ${APT_VOICE_DUTY_EXTENSIONS:}
+ # TTS 播报:Originate 到 Local/@,
+ # Application=Playback、Data=(音频由 Asterisk 侧预先合成)
+ tts-context: ${APT_VOICE_TTS_CONTEXT:apt-tts}
+ tts-extension: ${APT_VOICE_TTS_EXTENSION:s}
+ tts-text: ${APT_VOICE_TTS_TEXT:请到门口集合,准备乘车}
+ tts-media-prefix: ${APT_VOICE_TTS_MEDIA_PREFIX:apt/call-prompt}
+ # 单次 Originate 超时(毫秒),同时作为 AMI Action 等待上限
+ dial-timeout-ms: ${APT_VOICE_DIAL_TIMEOUT_MS:30000}
+ # AMI 事件队列容量(单实例单线程保序;满了丢弃并告警,不阻塞读线程)
+ event-queue-capacity: ${APT_VOICE_EVENT_QUEUE_CAPACITY:2000}
+ # 单次扫描最多发起数,避免停机补跑时瞬间打爆 Asterisk
+ max-calls-per-scan: ${APT_VOICE_MAX_CALLS_PER_SCAN:10}
+ # ---------------------------------------------------------------------------
+ # 本地联调端点(/apt/local/**,见 AptLocalDebugController)。
+ # 生产必须保持 false:开启后可用 HTTP 注入 #7 匹配结论与 AMI 事件,
+ # 等同绕过平台拉取器与真实 Asterisk,仅用于无外部依赖时验证状态机。
+ # ---------------------------------------------------------------------------
+ debug:
+ enabled: ${APT_DEBUG_ENABLED:false}
+ # ---------------------------------------------------------------------------
+ # 平板刷脸照片(叫乘作业留痕)
+ # 只存「相对路径 + 校验和 + 状态」,图像本体落文件/对象存储,绝不进 BLOB。
+ # 路径规范:face/{gyid}/{yyyy}/{MM}/{dd}/{jcid}/{photoId}.{jpg|png}
+ # 注意:spring.servlet.multipart.max-file-size 必须 >= 这里的 max-size-bytes
+ # (当前 10MB / 5MB,超出上传会在 Spring 层先被拒绝)。
+ # ---------------------------------------------------------------------------
+ photo:
+ root: ${APT_PHOTO_ROOT:./data/apt-photos}
+ max-size-bytes: ${APT_PHOTO_MAX_BYTES:5242880}
+ public-prefix: ${APT_PHOTO_PUBLIC_PREFIX:/apt/files}
+ storage-target: ${APT_PHOTO_STORAGE_TARGET:LOCAL}
+ # 保留期(天):业务要求「照片记录最多保存 1 个月」,默认 30;
+ # 由 aptPhotoPurgeTask.purge() 每天 03:30 清理文件与记录
+ retention-days: ${APT_PHOTO_RETENTION_DAYS:30}
+ # 单批清理条数,避免一次锁表
+ purge-batch-size: ${APT_PHOTO_PURGE_BATCH:200}
+ # ---------------------------------------------------------------------------
+ # IT-6 上报续传(outbox / 水位 / #14 心跳)
+ # dispatch-enabled=false:outbox 只累积、不派发,也不允许假置 SENT;
+ # 生产保持 false,待 Q-13 联调环境与凭据就绪后再开启。
+ # ---------------------------------------------------------------------------
+ report:
+ dispatch-enabled: ${APT_REPORT_DISPATCH_ENABLED:false}
+ batch-size: ${APT_REPORT_BATCH_SIZE:200}
+ max-retry: ${APT_REPORT_MAX_RETRY:5}
+ # 心跳时钟偏差告警阈值(毫秒),验收口径 ≤5s
+ clock-skew-threshold-ms: ${APT_REPORT_CLOCK_SKEW_MS:5000}
+ # 超过该秒数无心跳视为离线(不据此伪造 ACKED)
+ heartbeat-timeout-seconds: ${APT_REPORT_HEARTBEAT_TIMEOUT_SEC:90}
+ resume-batch-limit: ${APT_REPORT_RESUME_BATCH:200}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptDeviceMapper.xml b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptDeviceMapper.xml
new file mode 100644
index 0000000..d8d5b39
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptDeviceMapper.xml
@@ -0,0 +1,113 @@
+
+
+
+
+
+ d.device_code AS deviceCode,
+ d.device_type AS deviceType,
+ d.room_id AS roomId,
+ d.state AS state,
+ d.last_seen_at AS lastSeenAt,
+ d.face_scope_type AS faceScopeType,
+ d.face_scope_ref AS faceScopeRef,
+ x.online_state AS onlineState,
+ x.heartbeat_at AS heartbeatAt,
+ x.app_version AS appVersion
+
+
+
+
+
+
+
+ INSERT INTO gy_sb_xt
+ (gyid, device_code, online_state, app_version, heartbeat_at, deleted)
+ VALUES
+ (#{gyid}, #{deviceCode}, 'ONLINE', #{appVersion}, #{at}, 0)
+ ON DUPLICATE KEY UPDATE
+ online_state = 'ONLINE',
+ app_version = VALUES(app_version),
+ heartbeat_at = VALUES(heartbeat_at),
+ deleted = 0,
+ update_time = NOW(3)
+
+
+
+ UPDATE gy_sb
+ SET last_seen_at = #{at}
+ WHERE gyid = #{gyid} AND device_code = #{deviceCode} AND deleted = 0
+
+
+
+
+
+ INSERT INTO apt_device_command
+ (gyid, command_id, device_code, command_type, payload_json, state,
+ protocol_confirmed, face_scope_ref, expire_at, deleted)
+ VALUES
+ (#{gyid}, #{commandId}, #{deviceCode}, #{commandType}, #{payload}, 'PENDING',
+ 0, #{faceScopeRef}, #{expireAt}, 0)
+
+
+
+
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptDutyStateMapper.xml b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptDutyStateMapper.xml
index 3239cbd..fc93c4b 100644
--- a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptDutyStateMapper.xml
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptDutyStateMapper.xml
@@ -19,6 +19,12 @@
LIMIT 1
+
+
UPDATE gy_jcxx
SET ryzt = '1',
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptJcxxMapper.xml b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptJcxxMapper.xml
index 0a62c7a..765fc0b 100644
--- a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptJcxxMapper.xml
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptJcxxMapper.xml
@@ -110,6 +110,21 @@
ORDER BY r.create_time DESC
+
+
+
+
+ SELECT
+
+ WHERE r.gyid = #{gyid} AND r.room_id = #{roomId} AND r.deleted = 0
+ LIMIT 1
+
+
+
+
+ SELECT room_id AS roomId, room_no AS roomNo, room_floor AS roomFloor,
+ room_type AS roomType, bed_count AS bedCount, state
+ FROM apt_master_room
+ WHERE gyid = #{gyid} AND room_no = #{roomNo} AND deleted = 0 AND state = 'ACTIVE'
+ LIMIT 1
+
+
+
+ SELECT COUNT(*) FROM gy_rooms
+ WHERE gyid = #{gyid} AND room_no = #{roomNo} AND deleted = 0
+
+
+
+ INSERT INTO gy_rooms
+ (gyid, room_id, room_no, room_floor, room_type,
+ room_status, in_person_count, state_version, deleted, create_time, update_time)
+ VALUES
+ (#{gyid}, #{roomId}, #{roomNo}, #{roomFloor}, #{roomType},
+ '1', 0, 1, 0, NOW(3), NOW(3))
+
+
+
+ UPDATE gy_rooms
+ SET deleted = 1,
+ state_version = state_version + 1,
+ update_time = NOW(3)
+ WHERE gyid = #{gyid} AND id = #{id} AND deleted = 0
+
+
SELECT binding_id AS bindingId, endpoint_id AS endpointId,
effective_from AS effectiveFrom, state
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptSipEndpointMapper.xml b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptSipEndpointMapper.xml
new file mode 100644
index 0000000..5714e50
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptSipEndpointMapper.xml
@@ -0,0 +1,234 @@
+
+
+
+
+
+
+
+ e.endpoint_id AS endpointId, e.extension, e.room_id AS roomId,
+ e.display_name AS displayName, e.ip_address AS ipAddress,
+ e.config_version AS configVersion, e.sync_state AS syncState,
+ e.enabled, e.source, e.last_qualify_at AS lastQualifyAt
+
+
+
+
+ SELECT
+ FROM apt_sip_endpoint e
+ WHERE e.gyid = #{gyid} AND e.room_id = #{roomId} AND e.deleted = 0
+ ORDER BY e.id DESC
+ LIMIT 1
+
+
+
+ SELECT
+ FROM apt_sip_endpoint e
+ WHERE e.gyid = #{gyid} AND e.endpoint_id = #{endpointId} AND e.deleted = 0
+ LIMIT 1
+
+
+
+
+ SELECT COUNT(*) FROM apt_sip_endpoint
+ WHERE gyid = #{gyid} AND extension = #{extension}
+
+
+
+ INSERT INTO apt_sip_endpoint
+ (gyid, endpoint_id, extension, room_id, display_name,
+ config_version, sync_state, enabled, source, deleted)
+ VALUES
+ (#{gyid}, #{endpointId}, #{extension}, #{roomId}, #{displayName},
+ 1, 'PENDING', 1, 'ROOM', 0)
+
+
+
+ UPDATE apt_sip_endpoint
+ SET display_name = #{displayName},
+ enabled = #{enabled},
+ sync_state = 'PENDING',
+ config_version = config_version + 1
+ WHERE gyid = #{gyid} AND endpoint_id = #{endpointId} AND deleted = 0
+
+
+
+ UPDATE apt_sip_endpoint
+ SET ip_address = #{ipAddress},
+ sync_state = 'PENDING',
+ config_version = config_version + 1
+ WHERE gyid = #{gyid} AND endpoint_id = #{endpointId} AND deleted = 0
+
+
+
+ UPDATE apt_sip_endpoint
+ SET deleted = 1,
+ enabled = 0,
+ room_id = NULL,
+ ip_address = NULL,
+ sync_state = 'PENDING',
+ config_version = config_version + 1
+ WHERE gyid = #{gyid} AND endpoint_id = #{endpointId} AND deleted = 0
+
+
+
+
+ SELECT aor_id AS aorId, endpoint_id AS endpointId, max_contacts AS maxContacts,
+ remove_existing AS removeExisting, default_expiration AS defaultExpiration,
+ qualify_frequency AS qualifyFrequency, sync_state AS syncState,
+ config_version AS configVersion
+ FROM apt_sip_aor
+ WHERE gyid = #{gyid} AND endpoint_id = #{endpointId} AND deleted = 0
+ LIMIT 1
+
+
+
+ INSERT INTO apt_sip_aor (gyid, aor_id, endpoint_id, deleted)
+ VALUES (#{gyid}, #{aorId}, #{endpointId}, 0)
+
+
+
+ UPDATE apt_sip_aor
+ SET deleted = 1,
+ sync_state = 'PENDING',
+ config_version = config_version + 1
+ WHERE gyid = #{gyid} AND endpoint_id = #{endpointId} AND deleted = 0
+
+
+
+
+ SELECT contact_id AS contactId, uri, ip_address AS ipAddress, port, transport,
+ user_agent AS userAgent, source, state,
+ last_seen_at AS lastSeenAt, released_at AS releasedAt,
+ release_remark AS releaseRemark
+ FROM apt_sip_contact
+ WHERE gyid = #{gyid} AND endpoint_id = #{endpointId} AND deleted = 0
+ ORDER BY update_time DESC
+ LIMIT 20
+
+
+
+ SELECT contact_id AS contactId, uri, ip_address AS ipAddress, port, transport,
+ source, last_seen_at AS lastSeenAt
+ FROM apt_sip_contact
+ WHERE gyid = #{gyid} AND endpoint_id = #{endpointId}
+ AND state = 'ACTIVE' AND deleted = 0
+ ORDER BY id DESC
+
+
+
+ SELECT contact_id AS contactId, endpoint_id AS endpointId, aor_id AS aorId,
+ uri, ip_address AS ipAddress, port, state
+ FROM apt_sip_contact
+ WHERE gyid = #{gyid} AND ip_address = #{ipAddress}
+ AND state = 'ACTIVE' AND deleted = 0
+ LIMIT 1
+
+
+
+ UPDATE apt_sip_contact
+ SET state = 'RELEASED',
+ released_at = NOW(3),
+ release_remark = #{remark}
+ WHERE gyid = #{gyid} AND endpoint_id = #{endpointId}
+ AND state = 'ACTIVE' AND deleted = 0
+
+
+
+ INSERT INTO apt_sip_contact
+ (gyid, contact_id, aor_id, endpoint_id, uri, ip_address, port,
+ transport, source, state, last_seen_at, deleted)
+ VALUES
+ (#{gyid}, #{contactId}, #{aorId}, #{endpointId}, #{uri}, #{ipAddress}, #{port},
+ 'udp', 'STATIC', 'ACTIVE', NOW(3), 0)
+
+
+
+
+
+ SELECT instance_code FROM apt_ast_instance
+ WHERE gyid = #{gyid} AND deleted = 0 AND enabled = 1
+ ORDER BY id
+ LIMIT 1
+
+
+
+ SELECT auth_id AS authId, username, instance_code AS instanceCode,
+ secret_mode AS secretMode, secret_ref AS secretRef,
+ secret_version AS secretVersion, state, enabled, sync_state AS syncState
+ FROM apt_sip_auth
+ WHERE gyid = #{gyid} AND instance_code = #{instanceCode}
+ AND username = #{username} AND deleted = 0
+ LIMIT 1
+
+
+
+
+ INSERT INTO apt_sip_auth
+ (gyid, instance_code, username, auth_id, endpoint_id, deleted)
+ VALUES
+ (#{gyid}, #{instanceCode}, #{username}, #{authId}, #{endpointId}, 0)
+
+
+
+ UPDATE apt_sip_auth
+ SET state = 'DISABLED',
+ enabled = 0,
+ sync_state = 'PENDING',
+ config_version = config_version + 1
+ WHERE gyid = #{gyid} AND endpoint_id = #{endpointId}
+ AND deleted = 0 AND state <> 'DISABLED'
+
+
+
+
+ SELECT binding_id AS bindingId, endpoint_id AS endpointId, room_id AS roomId,
+ effective_from AS effectiveFrom, state
+ FROM apt_sip_binding
+ WHERE gyid = #{gyid} AND room_id = #{roomId}
+ AND state = 'ACTIVE' AND deleted = 0
+ ORDER BY effective_from DESC
+ LIMIT 1
+
+
+
+ SELECT binding_id AS bindingId, endpoint_id AS endpointId, room_id AS roomId,
+ effective_from AS effectiveFrom, state
+ FROM apt_sip_binding
+ WHERE gyid = #{gyid} AND endpoint_id = #{endpointId}
+ AND state = 'ACTIVE' AND deleted = 0
+ ORDER BY effective_from DESC
+ LIMIT 1
+
+
+
+ INSERT INTO apt_sip_binding
+ (gyid, binding_id, endpoint_id, room_id, effective_from, state, deleted)
+ VALUES
+ (#{gyid}, #{bindingId}, #{endpointId}, #{roomId}, NOW(3), 'ACTIVE', 0)
+
+
+
+ UPDATE apt_sip_binding
+ SET state = 'RELEASED',
+ effective_to = NOW(3)
+ WHERE gyid = #{gyid} AND room_id = #{roomId}
+ AND state = 'ACTIVE' AND deleted = 0
+
+
+
+ UPDATE apt_sip_binding
+ SET state = 'RELEASED',
+ effective_to = NOW(3)
+ WHERE gyid = #{gyid} AND endpoint_id = #{endpointId}
+ AND state = 'ACTIVE' AND deleted = 0
+
+
+
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptTabletMapper.xml b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptTabletMapper.xml
index f61422c..a188302 100644
--- a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptTabletMapper.xml
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptTabletMapper.xml
@@ -147,4 +147,112 @@
'PENDING', #{occurredAt})
+
+
+
+ SELECT r.result_id AS resultId, r.task_id AS taskId, r.jcid,
+ r.verified_ryid AS verifiedRyid,
+ r.face_verify_state AS faceVerifyState, r.result_type AS resultType,
+ r.device_code AS deviceCode, r.applied_state AS appliedState,
+ r.occurred_at AS occurredAt,
+ r.face_photo_id AS facePhotoId, r.face_photo_state AS facePhotoState,
+ t.device_code AS taskDeviceCode
+ FROM apt_tablet_call_result r
+ LEFT JOIN apt_tablet_task t
+ ON t.gyid = r.gyid AND t.task_id = r.task_id
+ WHERE r.gyid = #{gyid} AND r.result_id = #{resultId}
+ LIMIT 1
+
+
+
+ SELECT photo_id AS photoId, jcid, task_id AS taskId, result_id AS resultId,
+ ryid, device_code AS deviceCode,
+ storage_target AS storageTarget, storage_path AS storagePath,
+ file_name AS fileName, content_type AS contentType,
+ file_size AS fileSize, sha256,
+ upload_state AS uploadState, fail_reason AS failReason,
+ retry_count AS retryCount, captured_at AS capturedAt, uploaded_at AS uploadedAt
+ FROM apt_tablet_face_photo
+ WHERE gyid = #{gyid} AND result_id = #{resultId} AND deleted = 0
+ LIMIT 1
+
+
+
+
+ INSERT INTO apt_tablet_face_photo
+ (gyid, photo_id, jcid, task_id, result_id, ryid, device_code,
+ storage_target, storage_path, file_name, content_type, file_size,
+ captured_at, upload_state, deleted)
+ VALUES
+ (#{gyid}, #{photoId}, #{jcid}, #{taskId}, #{resultId}, #{ryid}, #{deviceCode},
+ #{storageTarget}, #{storagePath}, #{fileName}, #{contentType}, #{fileSize},
+ #{capturedAt}, 'PENDING', 0)
+ ON DUPLICATE KEY UPDATE
+ storage_target = #{storageTarget},
+ storage_path = #{storagePath},
+ file_name = #{fileName},
+ content_type = #{contentType},
+ file_size = #{fileSize},
+ upload_state = 'PENDING',
+ fail_reason = NULL,
+ sha256 = NULL,
+ uploaded_at = NULL,
+ retry_count = retry_count + 1,
+ deleted = 0
+
+
+
+ UPDATE apt_tablet_face_photo
+ SET upload_state = 'STORED',
+ file_size = #{fileSize},
+ sha256 = #{sha256},
+ uploaded_at = NOW(3),
+ fail_reason = NULL
+ WHERE gyid = #{gyid} AND result_id = #{resultId} AND deleted = 0
+
+
+
+ UPDATE apt_tablet_face_photo
+ SET upload_state = 'FAILED',
+ fail_reason = #{failReason}
+ WHERE gyid = #{gyid} AND result_id = #{resultId} AND deleted = 0
+
+
+
+ UPDATE apt_tablet_call_result
+ SET face_photo_id = #{photoId},
+ face_photo_state = #{photoState}
+ WHERE gyid = #{gyid} AND result_id = #{resultId}
+
+
+
+
+
+ SELECT DISTINCT gyid
+ FROM apt_tablet_face_photo
+ WHERE COALESCE(uploaded_at, captured_at, create_time) < #{cutoff}
+ ORDER BY gyid
+
+
+
+ SELECT id, photo_id AS photoId, jcid, storage_path AS storagePath,
+ upload_state AS uploadState,
+ COALESCE(uploaded_at, captured_at, create_time) AS expireBase
+ FROM apt_tablet_face_photo
+ WHERE gyid = #{gyid}
+ AND COALESCE(uploaded_at, captured_at, create_time) < #{cutoff}
+ ORDER BY expireBase ASC
+ LIMIT #{limit}
+
+
+
+ DELETE FROM apt_tablet_face_photo
+ WHERE gyid = #{gyid} AND id = #{id}
+
+
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptVoiceMapper.xml b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptVoiceMapper.xml
index 121f110..898622c 100644
--- a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptVoiceMapper.xml
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptVoiceMapper.xml
@@ -168,15 +168,24 @@
LIMIT 1
+
- SELECT e.endpoint_id AS endpointId, e.extension
- FROM apt_sip_binding b
+ SELECT e.endpoint_id AS endpointId, e.extension, m.room_id AS roomId
+ FROM apt_master_room m
+ JOIN apt_sip_binding b
+ ON b.gyid = m.gyid AND b.room_id = m.room_id AND b.deleted = 0
JOIN apt_sip_endpoint e
ON e.gyid = b.gyid AND e.endpoint_id = b.endpoint_id AND e.deleted = 0
- WHERE b.gyid = #{gyid}
- AND b.room_id = #{roomNo}
+ WHERE m.gyid = #{gyid}
+ AND m.room_no = #{roomNo}
+ AND m.deleted = 0
AND b.state = 'ACTIVE'
- AND b.deleted = 0
AND (b.effective_to IS NULL OR b.effective_to > NOW(3))
AND e.enabled = 1
ORDER BY b.effective_from DESC
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/device/AptDeviceServiceTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/device/AptDeviceServiceTest.java
new file mode 100644
index 0000000..70f25a2
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/device/AptDeviceServiceTest.java
@@ -0,0 +1,119 @@
+package com.ruoyi.apt.device;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import com.ruoyi.apt.common.AptContext;
+import com.ruoyi.apt.common.AptErrorCode;
+import com.ruoyi.apt.common.AptException;
+import com.ruoyi.apt.device.domain.AptDeviceCommandRequest;
+import com.ruoyi.apt.device.mapper.AptDeviceMapper;
+import com.ruoyi.apt.device.service.AptDeviceServiceImpl;
+import com.ruoyi.apt.framework.config.AptProperties;
+import com.ruoyi.apt.report.service.IAptReportService;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * M5 回归:指令只入队 PENDING、停用设备拒绝、重复命令不重复插入。
+ */
+class AptDeviceServiceTest
+{
+ private AptDeviceMapper mapper;
+ private IAptReportService reportService;
+ private AptDeviceServiceImpl service;
+
+ @BeforeEach
+ void setUp()
+ {
+ mapper = mock(AptDeviceMapper.class);
+ reportService = mock(IAptReportService.class);
+ service = new AptDeviceServiceImpl(mapper, reportService, new AptProperties());
+ AptContext.clear();
+ AptContext.setGyId("APT-A");
+ }
+
+ @AfterEach
+ void tearDown()
+ {
+ AptContext.clear();
+ }
+
+ @Test
+ void enqueueCreatesPendingWithoutProtocolConfirmation()
+ {
+ when(mapper.selectDevice("APT-A", "DEV-1")).thenReturn(device("ACTIVE"));
+ Map saved = new LinkedHashMap<>();
+ saved.put("commandId", "DC-1");
+ saved.put("state", "PENDING");
+ saved.put("protocolConfirmed", 0);
+ when(mapper.selectCommand("APT-A", "DC-1")).thenReturn(null, saved);
+ when(mapper.insertCommand(eq("APT-A"), eq("DC-1"), eq("DEV-1"), eq("PLAY_TASK"),
+ any(), any(), any())).thenReturn(1);
+
+ AptDeviceCommandRequest request = new AptDeviceCommandRequest();
+ request.setCommandId("DC-1");
+ request.setCommandType("PLAY_TASK");
+ Map result = service.enqueue("dev-1", request);
+
+ assertEquals("PENDING", result.get("state"));
+ assertEquals(false, result.get("protocolConfirmed"));
+ assertEquals(false, result.get("duplicated"));
+ }
+
+ @Test
+ void disabledDeviceCannotEnqueue()
+ {
+ when(mapper.selectDevice("APT-A", "DEV-2")).thenReturn(device("DISABLED"));
+ AptDeviceCommandRequest request = new AptDeviceCommandRequest();
+ request.setCommandType("PLAY_TASK");
+
+ AptException ex = assertThrows(AptException.class, () -> service.enqueue("DEV-2", request));
+
+ assertEquals(AptErrorCode.FEATURE_DISABLED, ex.getErrorCode());
+ verify(mapper, never()).insertCommand(anyString(), anyString(), anyString(), anyString(),
+ any(), any(), any());
+ }
+
+ @Test
+ void duplicateCommandReturnsExistingState()
+ {
+ when(mapper.selectDevice("APT-A", "DEV-3")).thenReturn(device("ACTIVE"));
+ Map existing = new LinkedHashMap<>();
+ existing.put("commandId", "DC-9");
+ existing.put("state", "SENT");
+ existing.put("protocolConfirmed", 1);
+ when(mapper.selectCommand("APT-A", "DC-9")).thenReturn(existing);
+
+ AptDeviceCommandRequest request = new AptDeviceCommandRequest();
+ request.setCommandId("DC-9");
+ request.setCommandType("PLAY_TASK");
+ Map result = service.enqueue("DEV-3", request);
+
+ assertTrue((Boolean) result.get("duplicated"));
+ assertEquals("SENT", result.get("state"));
+ assertTrue((Boolean) result.get("protocolConfirmed"));
+ verify(mapper, never()).insertCommand(anyString(), anyString(), anyString(), anyString(),
+ any(), any(), any());
+ }
+
+ private Map device(String state)
+ {
+ Map device = new LinkedHashMap<>();
+ device.put("deviceCode", "DEV-X");
+ device.put("deviceType", "TABLET");
+ device.put("state", state);
+ return device;
+ }
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/dispatch/AptDutyStateServiceTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/dispatch/AptDutyStateServiceTest.java
index e1c501c..f73b581 100644
--- a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/dispatch/AptDutyStateServiceTest.java
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/dispatch/AptDutyStateServiceTest.java
@@ -16,6 +16,7 @@ import com.ruoyi.apt.common.AptException;
import com.ruoyi.apt.dispatch.domain.AptStateSnapshot;
import com.ruoyi.apt.dispatch.mapper.AptDutyStateMapper;
import com.ruoyi.apt.dispatch.service.AptDutyStateServiceImpl;
+import com.ruoyi.apt.job.service.IAptJobService;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -26,13 +27,15 @@ import org.junit.jupiter.api.Test;
class AptDutyStateServiceTest
{
private AptDutyStateMapper mapper;
+ private IAptJobService jobService;
private AptDutyStateServiceImpl service;
@BeforeEach
void setUp()
{
mapper = mock(AptDutyStateMapper.class);
- service = new AptDutyStateServiceImpl(mapper);
+ jobService = mock(IAptJobService.class);
+ service = new AptDutyStateServiceImpl(mapper, jobService);
AptContext.clear();
AptContext.setGyId("APT-A");
}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/dispatch/AptPhotoPurgeServiceTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/dispatch/AptPhotoPurgeServiceTest.java
new file mode 100644
index 0000000..52ad817
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/dispatch/AptPhotoPurgeServiceTest.java
@@ -0,0 +1,140 @@
+package com.ruoyi.apt.dispatch;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Date;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import com.ruoyi.apt.dispatch.tablet.mapper.AptTabletMapper;
+import com.ruoyi.apt.dispatch.tablet.service.AptPhotoPurgeServiceImpl;
+import com.ruoyi.apt.framework.config.AptPhotoProperties;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * 刷脸照片保留期清理:文件 + 记录、缺失容错、删除失败保留记录、路径越界保护。
+ */
+class AptPhotoPurgeServiceTest
+{
+ @TempDir
+ Path tempDir;
+
+ private AptTabletMapper mapper;
+ private AptPhotoProperties properties;
+ private AptPhotoPurgeServiceImpl service;
+
+ @BeforeEach
+ void setUp()
+ {
+ mapper = mock(AptTabletMapper.class);
+ properties = new AptPhotoProperties();
+ properties.setRoot(tempDir.toString());
+ properties.setRetentionDays(30);
+ properties.setPurgeBatchSize(200);
+ service = new AptPhotoPurgeServiceImpl(mapper, properties);
+ // 默认删除成功;断言删除条数依赖真实影响行数
+ when(mapper.deletePhoto(anyString(), anyLong())).thenReturn(1);
+ }
+
+ @Test
+ void purgesExpiredFileAndRecord() throws Exception
+ {
+ Path photo = tempDir.resolve("face/APT-A/2026/10/01/JC-1/PH-1.jpg");
+ Files.createDirectories(photo.getParent());
+ Files.write(photo, new byte[]{1, 2, 3});
+ when(mapper.selectGyidsWithExpiredPhotos(any())).thenReturn(List.of("APT-A"));
+ when(mapper.selectExpiredPhotos(eq("APT-A"), any(), anyInt()))
+ .thenReturn(List.of(row(7L, "face/APT-A/2026/10/01/JC-1/PH-1.jpg")));
+
+ Map stats = service.purgeExpired();
+
+ verify(mapper).deletePhoto("APT-A", 7L);
+ assertFalse(Files.exists(photo), "过期照片文件应被删除");
+ assertEquals(1L, stats.get("deleted"));
+ assertEquals(1L, stats.get("filesDeleted"));
+ }
+
+ @Test
+ void missingFileStillDeletesRecord() throws Exception
+ {
+ when(mapper.selectGyidsWithExpiredPhotos(any())).thenReturn(List.of("APT-A"));
+ when(mapper.selectExpiredPhotos(eq("APT-A"), any(), anyInt()))
+ .thenReturn(List.of(row(8L, "face/APT-A/2026/10/01/JC-1/PH-MISSING.jpg")));
+
+ Map stats = service.purgeExpired();
+
+ verify(mapper).deletePhoto("APT-A", 8L);
+ assertEquals(1L, stats.get("filesMissing"));
+ assertEquals(1L, stats.get("deleted"));
+ }
+
+ @Test
+ void keepsRecordWhenFileDeleteFails() throws Exception
+ {
+ // 用非空目录冒充「文件」,delete 会抛 DirectoryNotEmptyException
+ Path blocked = tempDir.resolve("face/APT-A/2026/10/01/JC-1/PH-DIR.jpg");
+ Files.createDirectories(blocked.resolve("child"));
+ when(mapper.selectGyidsWithExpiredPhotos(any())).thenReturn(List.of("APT-A"));
+ when(mapper.selectExpiredPhotos(eq("APT-A"), any(), anyInt()))
+ .thenReturn(List.of(row(9L, "face/APT-A/2026/10/01/JC-1/PH-DIR.jpg")));
+
+ Map stats = service.purgeExpired();
+
+ verify(mapper, never()).deletePhoto(anyString(), anyLong());
+ assertTrue(Files.exists(blocked), "删不掉的文件必须保留,避免路径信息丢失");
+ assertEquals(1L, stats.get("filesFailed"));
+ assertEquals(0L, stats.get("deleted"));
+ }
+
+ @Test
+ void skipsFileOutsideRootButDeletesRecord()
+ {
+ when(mapper.selectGyidsWithExpiredPhotos(any())).thenReturn(List.of("APT-A"));
+ when(mapper.selectExpiredPhotos(eq("APT-A"), any(), anyInt()))
+ .thenReturn(List.of(row(10L, "../../windows/win.ini")));
+
+ Map stats = service.purgeExpired();
+
+ verify(mapper).deletePhoto("APT-A", 10L);
+ assertEquals(1L, stats.get("filesSkipped"));
+ assertEquals(1L, stats.get("deleted"));
+ }
+
+ @Test
+ void noExpiredPhotosIsNoop()
+ {
+ when(mapper.selectGyidsWithExpiredPhotos(any())).thenReturn(List.of());
+
+ Map stats = service.purgeExpired();
+
+ verify(mapper, never()).selectExpiredPhotos(anyString(), any(), anyInt());
+ assertEquals(0, stats.get("gyids"));
+ assertEquals(0L, stats.get("deleted"));
+ }
+
+ private Map row(long id, String storagePath)
+ {
+ Map row = new LinkedHashMap<>();
+ row.put("id", id);
+ row.put("photoId", "PH-" + id);
+ row.put("jcid", "JC-1");
+ row.put("storagePath", storagePath);
+ row.put("uploadState", "STORED");
+ row.put("expireBase", new Date());
+ return row;
+ }
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/dispatch/AptTabletPhotoServiceTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/dispatch/AptTabletPhotoServiceTest.java
new file mode 100644
index 0000000..cc4aa6b
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/dispatch/AptTabletPhotoServiceTest.java
@@ -0,0 +1,196 @@
+package com.ruoyi.apt.dispatch;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.startsWith;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Date;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import com.ruoyi.apt.common.AptContext;
+import com.ruoyi.apt.common.AptException;
+import com.ruoyi.apt.dispatch.tablet.mapper.AptTabletMapper;
+import com.ruoyi.apt.dispatch.tablet.service.AptTabletPhotoServiceImpl;
+import com.ruoyi.apt.framework.config.AptPhotoProperties;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.springframework.mock.web.MockMultipartFile;
+
+/**
+ * 平板刷脸照片:落盘、入库、幂等、失败留痕。
+ */
+class AptTabletPhotoServiceTest
+{
+ private static final byte[] JPEG = new byte[]{
+ (byte) 0xFF, (byte) 0xD8, (byte) 0xFF, (byte) 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01};
+
+ @TempDir
+ Path tempDir;
+
+ private AptTabletMapper mapper;
+ private AptPhotoProperties properties;
+ private AptTabletPhotoServiceImpl service;
+
+ @BeforeEach
+ void setUp()
+ {
+ mapper = mock(AptTabletMapper.class);
+ properties = new AptPhotoProperties();
+ properties.setRoot(tempDir.toString());
+ properties.setMaxSizeBytes(5L * 1024 * 1024);
+ service = new AptTabletPhotoServiceImpl(mapper, properties);
+ AptContext.clear();
+ AptContext.setGyId("APT-A");
+ }
+
+ @AfterEach
+ void tearDown()
+ {
+ AptContext.clear();
+ }
+
+ @Test
+ void storeWritesFileAndPersistsRelativePath() throws Exception
+ {
+ when(mapper.selectResultForPhoto("APT-A", "R1")).thenReturn(result("ANSWERED", "VERIFIED", "APPLIED"));
+ MockMultipartFile file = new MockMultipartFile("file", "face.jpg", "image/jpeg", JPEG);
+
+ Map meta = service.store("TAB-1", "R1", file);
+
+ verify(mapper).insertPhotoPending(eq("APT-A"), anyString(), eq("JC-1"), eq("T1"), eq("R1"),
+ eq("RY-1"), eq("TAB-1"), eq("LOCAL"), startsWith("face/APT-A/"),
+ anyString(), eq("image/jpeg"), eq((long) JPEG.length), any());
+ verify(mapper).updatePhotoStored(eq("APT-A"), eq("R1"), eq((long) JPEG.length), anyString());
+ verify(mapper).updateResultPhotoRef(eq("APT-A"), eq("R1"), anyString(), eq("STORED"));
+
+ String storagePath = String.valueOf(meta.get("storagePath"));
+ assertTrue(storagePath.matches("face/APT-A/\\d{4}/\\d{2}/\\d{2}/JC-1/PH-[0-9A-F]{12}\\.jpg"),
+ storagePath);
+ assertTrue(Files.exists(tempDir.resolve(storagePath)), "照片应写入配置根目录");
+ assertEquals("STORED", meta.get("uploadState"));
+ assertEquals(Boolean.FALSE, meta.get("duplicated"));
+ }
+
+ @Test
+ void storeRejectsResultWithoutVerifiedFace()
+ {
+ when(mapper.selectResultForPhoto("APT-A", "R1")).thenReturn(result("ANSWERED", "UNVERIFIED", "APPLIED"));
+
+ AptException error = assertThrows(AptException.class,
+ () -> service.store("TAB-1", "R1", jpeg()));
+
+ assertEquals("TABLET_PHOTO_NOT_APPLICABLE", error.getReason());
+ verify(mapper, never()).insertPhotoPending(anyString(), anyString(), anyString(), anyString(),
+ anyString(), anyString(), anyString(), anyString(), anyString(), anyString(),
+ anyString(), anyLong(), any());
+ }
+
+ @Test
+ void storeRejectsPhotoFromAnotherDevice()
+ {
+ when(mapper.selectResultForPhoto("APT-A", "R1")).thenReturn(result("ANSWERED", "VERIFIED", "APPLIED"));
+
+ AptException error = assertThrows(AptException.class,
+ () -> service.store("TAB-OTHER", "R1", jpeg()));
+
+ assertEquals("TABLET_RESULT_DEVICE_MISMATCH", error.getReason());
+ }
+
+ @Test
+ void storeRejectsOversizedPhoto()
+ {
+ properties.setMaxSizeBytes(4);
+ when(mapper.selectResultForPhoto("APT-A", "R1")).thenReturn(result("ANSWERED", "VERIFIED", "APPLIED"));
+
+ AptException error = assertThrows(AptException.class,
+ () -> service.store("TAB-1", "R1", jpeg()));
+
+ assertEquals(40922, error.getErrorCode().getCode());
+ verify(mapper, never()).insertPhotoPending(anyString(), anyString(), anyString(), anyString(),
+ anyString(), anyString(), anyString(), anyString(), anyString(), anyString(),
+ anyString(), anyLong(), any());
+ }
+
+ @Test
+ void storeIsIdempotentWhenAlreadyStored()
+ {
+ when(mapper.selectResultForPhoto("APT-A", "R1")).thenReturn(result("ANSWERED", "VERIFIED", "APPLIED"));
+ Map stored = new LinkedHashMap<>();
+ stored.put("photoId", "PH-AAAAAAAAAAAA");
+ stored.put("uploadState", "STORED");
+ stored.put("storagePath", "face/APT-A/2026/11/12/JC-1/PH-AAAAAAAAAAAA.jpg");
+ when(mapper.selectPhotoByResult("APT-A", "R1")).thenReturn(stored);
+
+ Map meta = service.store("TAB-1", "R1", jpeg());
+
+ assertEquals(Boolean.TRUE, meta.get("duplicated"));
+ verify(mapper, never()).insertPhotoPending(anyString(), anyString(), anyString(), anyString(),
+ anyString(), anyString(), anyString(), anyString(), anyString(), anyString(),
+ anyString(), anyLong(), any());
+ }
+
+ @Test
+ void storeReusesPhotoIdWhenRetryingFailedUpload()
+ {
+ when(mapper.selectResultForPhoto("APT-A", "R1")).thenReturn(result("ANSWERED", "VERIFIED", "APPLIED"));
+ Map failed = new LinkedHashMap<>();
+ failed.put("photoId", "PH-BBBBBBBBBBBB");
+ failed.put("uploadState", "FAILED");
+ when(mapper.selectPhotoByResult("APT-A", "R1")).thenReturn(failed);
+
+ Map meta = service.store("TAB-1", "R1", jpeg());
+
+ assertEquals("PH-BBBBBBBBBBBB", meta.get("photoId"));
+ assertTrue(String.valueOf(meta.get("storagePath")).contains("PH-BBBBBBBBBBBB.jpg"));
+ }
+
+ @Test
+ void storeRecordsFailureWhenDiskNotWritable() throws Exception
+ {
+ Path blocker = Files.createFile(tempDir.resolve("blocker"));
+ properties.setRoot(blocker.resolve("nested").toString());
+ when(mapper.selectResultForPhoto("APT-A", "R1")).thenReturn(result("ANSWERED", "VERIFIED", "APPLIED"));
+
+ AptException error = assertThrows(AptException.class,
+ () -> service.store("TAB-1", "R1", jpeg()));
+
+ assertEquals("PHOTO_STORE_FAILED", error.getReason());
+ verify(mapper).updatePhotoFailed(eq("APT-A"), eq("R1"), anyString());
+ verify(mapper).updateResultPhotoRef(eq("APT-A"), eq("R1"), anyString(), eq("FAILED"));
+ verify(mapper, never()).updatePhotoStored(anyString(), anyString(), anyLong(), anyString());
+ assertFalse(Files.exists(tempDir.resolve("blocker").resolve("nested")), "失败不应留下目录");
+ }
+
+ private MockMultipartFile jpeg()
+ {
+ return new MockMultipartFile("file", "face.jpg", "image/jpeg", JPEG);
+ }
+
+ private Map result(String resultType, String faceState, String appliedState)
+ {
+ Map row = new LinkedHashMap<>();
+ row.put("resultId", "R1");
+ row.put("taskId", "T1");
+ row.put("jcid", "JC-1");
+ row.put("verifiedRyid", "RY-1");
+ row.put("faceVerifyState", faceState);
+ row.put("resultType", resultType);
+ row.put("deviceCode", "TAB-1");
+ row.put("appliedState", appliedState);
+ row.put("occurredAt", new Date());
+ return row;
+ }
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/job/AptJobAutomationTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/job/AptJobAutomationTest.java
new file mode 100644
index 0000000..65d5463
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/job/AptJobAutomationTest.java
@@ -0,0 +1,179 @@
+package com.ruoyi.apt.job;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.contains;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import com.ruoyi.apt.common.AptContext;
+import com.ruoyi.apt.framework.audit.AptAuditWriter;
+import com.ruoyi.apt.job.domain.AptJobQuery;
+import com.ruoyi.apt.job.mapper.AptJobMapper;
+import com.ruoyi.apt.job.service.AptJobServiceImpl;
+import com.ruoyi.apt.stay.domain.AptRoom;
+import com.ruoyi.apt.stay.mapper.AptRoomMapper;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * 离寓联动与自动建单回归(保洁自动建单 / 平板公共池)。
+ *
+ * 口径:房间有效在寓人数 = gy_jcxx 中 finish_ly='0' AND sjlysj IS NULL 的记录数。
+ */
+class AptJobAutomationTest
+{
+ private AptJobMapper jobMapper;
+ private AptRoomMapper roomMapper;
+ private AptAuditWriter auditWriter;
+ private AptJobServiceImpl service;
+
+ @BeforeEach
+ void setUp()
+ {
+ jobMapper = mock(AptJobMapper.class);
+ roomMapper = mock(AptRoomMapper.class);
+ auditWriter = mock(AptAuditWriter.class);
+ service = new AptJobServiceImpl(jobMapper, roomMapper, auditWriter);
+ AptContext.clear();
+ AptContext.setGyId("APT-A");
+ }
+
+ @AfterEach
+ void tearDown()
+ {
+ AptContext.clear();
+ }
+
+ @Test
+ void autoCreateCleanWhenLastPersonLeft()
+ {
+ stubRoom();
+ when(jobMapper.countLiveStayByRoom("APT-A", "101")).thenReturn(0);
+ when(jobMapper.selectActiveCleanTaskByRoom("APT-A", "RM1")).thenReturn(null);
+ when(jobMapper.insertCleanTask(anyString(), anyString(), anyString(), anyString(),
+ any(), anyString(), any(Date.class), anyString())).thenReturn(1);
+ when(jobMapper.selectActiveRepairByRoom("APT-A", "RM1")).thenReturn(null);
+ when(roomMapper.selectActiveCleanState("APT-A", "RM1")).thenReturn("PENDING");
+ when(roomMapper.updateRoomStatus(eq("APT-A"), anyLong(), anyLong(), anyString(), any())).thenReturn(1);
+
+ Map result = service.autoCreateCleanOnVacated("101", "LEAVE:JC-1");
+
+ assertTrue(Boolean.TRUE.equals(result.get("created")));
+ assertEquals("PENDING", result.get("state"));
+ assertEquals("3", result.get("roomStatus"));
+ verify(jobMapper).insertCleanTask(eq("APT-A"), anyString(), eq("RM1"), eq("101"),
+ isNull(), contains("全员离寓自动建单"), any(Date.class), eq("AUTO"));
+ verify(jobMapper).insertJobEvent(eq("APT-A"), anyString(), eq("CLEAN"), anyString(),
+ eq("RM1"), isNull(), eq("PENDING"), isNull(), eq("SYSTEM"), isNull(),
+ contains("全员离寓自动建单"));
+ verify(auditWriter).write(any());
+ }
+
+ @Test
+ void autoCreateSkippedWhenRoomStillOccupied()
+ {
+ stubRoom();
+ when(jobMapper.countLiveStayByRoom("APT-A", "101")).thenReturn(2);
+
+ Map result = service.autoCreateCleanOnVacated("101", "LEAVE:JC-1");
+
+ assertFalse(Boolean.TRUE.equals(result.get("created")));
+ assertEquals("OCCUPIED", result.get("reason"));
+ assertEquals(2, result.get("liveCount"));
+ verify(jobMapper, never()).insertCleanTask(anyString(), anyString(), anyString(), anyString(),
+ any(), any(), any(Date.class), anyString());
+ }
+
+ @Test
+ void autoCreateReturnsExistingActiveTask()
+ {
+ stubRoom();
+ when(jobMapper.countLiveStayByRoom("APT-A", "101")).thenReturn(0);
+ Map active = new LinkedHashMap<>();
+ active.put("taskId", "CT-EXIST");
+ active.put("state", "PENDING");
+ when(jobMapper.selectActiveCleanTaskByRoom("APT-A", "RM1")).thenReturn(active);
+
+ Map result = service.autoCreateCleanOnVacated("101", "LEAVE:JC-2");
+
+ assertFalse(Boolean.TRUE.equals(result.get("created")));
+ assertTrue(Boolean.TRUE.equals(result.get("duplicated")));
+ assertEquals("CT-EXIST", result.get("taskId"));
+ verify(jobMapper, never()).insertCleanTask(anyString(), anyString(), anyString(), anyString(),
+ any(), any(), any(Date.class), anyString());
+ }
+
+ @Test
+ void syncOccupancyMarksRoomToCleanWhenVacant()
+ {
+ stubRoom();
+ when(jobMapper.syncRoomOccupancy("APT-A", "101")).thenReturn(1);
+ when(jobMapper.countLiveStayByRoom("APT-A", "101")).thenReturn(0);
+ when(jobMapper.selectActiveRepairByRoom("APT-A", "RM1")).thenReturn(null);
+ when(roomMapper.selectActiveCleanState("APT-A", "RM1")).thenReturn(null);
+ when(roomMapper.updateRoomStatus(eq("APT-A"), anyLong(), anyLong(), anyString(), any())).thenReturn(1);
+
+ int live = service.syncRoomOccupancy("101");
+
+ assertEquals(0, live);
+ verify(roomMapper).updateRoomStatus(eq("APT-A"), eq(10L), eq(1L), eq("3"), isNull());
+ }
+
+ @Test
+ void listMineIncludesUnassignedPoolForCleaner()
+ {
+ Map staff = new LinkedHashMap<>();
+ staff.put("staffId", "ST-1");
+ staff.put("staffType", "CLEANER");
+ when(jobMapper.selectStaffByDevice("APT-A", "DEV-1")).thenReturn(staff);
+ Map row = new LinkedHashMap<>();
+ row.put("taskId", "CT-AUTO");
+ row.put("assigneeStaffId", null);
+ when(jobMapper.selectCleanList(eq("APT-A"), any())).thenReturn(new ArrayList<>(List.of(row)));
+
+ List> rows = service.listMine("DEV-1", "CLEAN", new AptJobQuery());
+
+ assertEquals(1, rows.size());
+ assertEquals(Boolean.TRUE, rows.get(0).get("pool"));
+
+ // 保洁员请求维修任务:直接返回空,避免越权看到维修单
+ assertTrue(service.listMine("DEV-1", "REPAIR", new AptJobQuery()).isEmpty());
+ verify(jobMapper, never()).selectRepairList(anyString(), any());
+ }
+
+ private void stubRoom()
+ {
+ Map row = new LinkedHashMap<>();
+ row.put("roomId", "RM1");
+ row.put("roomNo", "101");
+ row.put("roomStatus", "1");
+ row.put("stateVersion", 1L);
+ row.put("inPersonCount", 1);
+ row.put("bedCount", 2);
+ when(jobMapper.selectRoomByNo("APT-A", "101")).thenReturn(row);
+
+ AptRoom room = new AptRoom();
+ room.setId(10L);
+ room.setRoomId("RM1");
+ room.setRoomNo("101");
+ room.setRoomStatus("1");
+ room.setStateVersion(1L);
+ room.setInPersonCount(0);
+ room.setBedCount(2);
+ when(roomMapper.selectRoomByRoomId("APT-A", "RM1")).thenReturn(room);
+ }
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/master/AptPersonServiceTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/master/AptPersonServiceTest.java
new file mode 100644
index 0000000..06887e1
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/master/AptPersonServiceTest.java
@@ -0,0 +1,81 @@
+package com.ruoyi.apt.master;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import com.ruoyi.apt.common.AptContext;
+import com.ruoyi.apt.common.AptErrorCode;
+import com.ruoyi.apt.common.AptException;
+import com.ruoyi.apt.master.mapper.AptPersonMapper;
+import com.ruoyi.apt.master.service.AptPersonServiceImpl;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * M4 回归:关联 Tab 组合正确、快照只读 40904。
+ */
+class AptPersonServiceTest
+{
+ private AptPersonMapper mapper;
+ private AptPersonServiceImpl service;
+
+ @BeforeEach
+ void setUp()
+ {
+ mapper = mock(AptPersonMapper.class);
+ service = new AptPersonServiceImpl(mapper);
+ AptContext.clear();
+ AptContext.setGyId("APT-A");
+ }
+
+ @AfterEach
+ void tearDown()
+ {
+ AptContext.clear();
+ }
+
+ @Test
+ void associationsComposeStayCallsAndRoute()
+ {
+ Map person = new LinkedHashMap<>();
+ person.put("ryid", "RY-1");
+ person.put("name", "张三");
+ Map stay = new LinkedHashMap<>();
+ stay.put("roomNo", "101");
+ Map route = new LinkedHashMap<>();
+ route.put("routeId", "RT-1");
+ when(mapper.selectPerson("APT-A", "RY-1")).thenReturn(person);
+ when(mapper.selectActiveStay("APT-A", "RY-1")).thenReturn(stay);
+ when(mapper.selectRecentCalls("APT-A", "RY-1", 5)).thenReturn(List.of(stay));
+ when(mapper.selectRecentRoute("APT-A", "RY-1")).thenReturn(route);
+
+ Map result = service.associations("RY-1");
+
+ assertEquals("101", ((Map, ?>) result.get("currentStay")).get("roomNo"));
+ assertEquals(1, ((List>) result.get("recentCalls")).size());
+ assertEquals("RT-1", ((Map, ?>) result.get("recentRoute")).get("routeId"));
+ assertTrue(result.get("person") instanceof Map);
+ }
+
+ @Test
+ void snapshotWriteIsAlwaysRejected()
+ {
+ AptException ex = assertThrows(AptException.class, () -> service.rejectSnapshotWrite("RY-1"));
+ assertEquals(AptErrorCode.SNAPSHOT_READONLY, ex.getErrorCode());
+ assertEquals("PERSON_SNAPSHOT_READONLY", ex.getReason());
+ }
+
+ @Test
+ void detailMissingPersonIs404()
+ {
+ when(mapper.selectPerson("APT-A", "RY-404")).thenReturn(null);
+ AptException ex = assertThrows(AptException.class, () -> service.detail("RY-404"));
+ assertEquals(AptErrorCode.NOT_FOUND, ex.getErrorCode());
+ }
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/report/AptReportServiceTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/report/AptReportServiceTest.java
new file mode 100644
index 0000000..61117cd
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/report/AptReportServiceTest.java
@@ -0,0 +1,149 @@
+package com.ruoyi.apt.report;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import java.util.Date;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import com.ruoyi.apt.common.AptContext;
+import com.ruoyi.apt.common.AptErrorCode;
+import com.ruoyi.apt.common.AptException;
+import com.ruoyi.apt.framework.config.AptProperties;
+import com.ruoyi.apt.report.domain.AptResumeEnvelope;
+import com.ruoyi.apt.report.mapper.AptReportMapper;
+import com.ruoyi.apt.report.service.AptReportServiceImpl;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.dao.DuplicateKeyException;
+
+/**
+ * IT-6 上报续传回归:重试白名单、水位对账、心跳偏差、幂等续报。
+ */
+class AptReportServiceTest
+{
+ private AptReportMapper mapper;
+ private AptReportServiceImpl service;
+
+ @BeforeEach
+ void setUp()
+ {
+ mapper = mock(AptReportMapper.class);
+ service = new AptReportServiceImpl(mapper, new AptProperties());
+ AptContext.clear();
+ AptContext.setGyId("APT-A");
+ }
+
+ @AfterEach
+ void tearDown()
+ {
+ AptContext.clear();
+ }
+
+ @Test
+ void requeueFailedBecomesPending()
+ {
+ Map failed = new LinkedHashMap<>();
+ failed.put("id", 1L);
+ failed.put("state", "FAILED");
+ Map pending = new LinkedHashMap<>();
+ pending.put("id", 1L);
+ pending.put("state", "PENDING");
+ when(mapper.selectOutboxById("APT-A", 1L)).thenReturn(failed, pending);
+ when(mapper.requeueOutbox(eq("APT-A"), eq(1L), anyString())).thenReturn(1);
+
+ Map result = service.requeue(1L);
+
+ assertEquals("PENDING", result.get("state"));
+ assertTrue(result.get("retryBatchId").toString().startsWith("RB-"));
+ verify(mapper).requeueOutbox(eq("APT-A"), eq(1L), anyString());
+ }
+
+ @Test
+ void requeueSentIsRejected()
+ {
+ Map sent = new LinkedHashMap<>();
+ sent.put("id", 2L);
+ sent.put("state", "SENT");
+ when(mapper.selectOutboxById("APT-A", 2L)).thenReturn(sent);
+
+ AptException ex = assertThrows(AptException.class, () -> service.requeue(2L));
+ assertEquals(AptErrorCode.STATE_ILLEGAL, ex.getErrorCode());
+ }
+
+ @Test
+ void watermarkComputesLag()
+ {
+ Map row = new LinkedHashMap<>();
+ row.put("currentWatermark", 10L);
+ row.put("ackedWatermark", 4L);
+ when(mapper.selectWatermark("APT-A")).thenReturn(row);
+
+ Map view = service.watermark();
+
+ assertEquals(10L, view.get("currentWatermark"));
+ assertEquals(4L, view.get("ackedWatermark"));
+ assertEquals(6L, view.get("lag"));
+ assertEquals(5000L, view.get("clockSkewThresholdMs"));
+ }
+
+ @Test
+ void heartbeatFlagsClockSkewBeyondFiveSeconds()
+ {
+ Map row = new LinkedHashMap<>();
+ row.put("currentWatermark", 100L);
+ row.put("ackedWatermark", 100L);
+ when(mapper.selectWatermark("APT-A")).thenReturn(row);
+
+ Map view = service.recordHeartbeat(100L, 6000L);
+
+ assertEquals(true, view.get("clockSkewExceeded"));
+ verify(mapper).upsertWatermark(eq("APT-A"), eq(100L), eq(6000L), any(Date.class));
+ }
+
+ @Test
+ void resumeReplayCountsAsDuplicate()
+ {
+ when(mapper.insertOutbox(anyString(), anyString(), anyString(), anyString(),
+ anyLong(), any(), any(Date.class))).thenThrow(new DuplicateKeyException("uk_outbox_gyid_idem"));
+ AptResumeEnvelope envelope = new AptResumeEnvelope();
+ envelope.setIdempotencyKey("KEY-1");
+ envelope.setBizType("CHECKIN");
+ envelope.setBizId("BIZ-1");
+ envelope.setBizVersion(2L);
+
+ Map result = service.resume(List.of(envelope));
+
+ assertEquals(0, result.get("accepted"));
+ assertEquals(1, result.get("duplicated"));
+ assertEquals(0, result.get("rejected"));
+ List> items = (List>) result.get("items");
+ assertEquals("DUPLICATE", ((Map, ?>) items.get(0)).get("status"));
+ }
+
+ @Test
+ void resumeWithoutKeyHashesBusinessTuple()
+ {
+ when(mapper.insertOutbox(eq("APT-A"), anyString(), eq("CHECKIN"), eq("BIZ-2"),
+ eq(1L), any(), any(Date.class))).thenReturn(1);
+ AptResumeEnvelope envelope = new AptResumeEnvelope();
+ envelope.setBizType("CHECKIN");
+ envelope.setBizId("BIZ-2");
+
+ Map result = service.resume(List.of(envelope));
+
+ assertEquals(1, result.get("accepted"));
+ List> items = (List>) result.get("items");
+ String key = ((Map, ?>) items.get(0)).get("idempotencyKey").toString();
+ assertEquals(64, key.length());
+ }
+}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/stay/AptRoomServiceTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/stay/AptRoomServiceTest.java
index bb57c77..bbef075 100644
--- a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/stay/AptRoomServiceTest.java
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/stay/AptRoomServiceTest.java
@@ -19,12 +19,14 @@ import com.ruoyi.apt.common.AptContext;
import com.ruoyi.apt.common.AptException;
import com.ruoyi.apt.stay.domain.AptRoom;
import com.ruoyi.apt.stay.domain.AptRoomCommand;
+import com.ruoyi.apt.stay.domain.AptRoomCreateCommand;
import com.ruoyi.apt.stay.domain.AptRoomDetail;
import com.ruoyi.apt.stay.domain.AptRoomStatusCommand;
import com.ruoyi.apt.stay.domain.AptRoomStatusResult;
import com.ruoyi.apt.stay.domain.AptRoomStatusSummary;
import com.ruoyi.apt.stay.mapper.AptRoomMapper;
import com.ruoyi.apt.stay.service.AptRoomServiceImpl;
+import com.ruoyi.apt.voice.service.IAptSipEndpointService;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -34,13 +36,15 @@ import org.mockito.ArgumentCaptor;
class AptRoomServiceTest
{
private AptRoomMapper mapper;
+ private IAptSipEndpointService sipEndpointService;
private AptRoomServiceImpl service;
@BeforeEach
void setUp()
{
mapper = mock(AptRoomMapper.class);
- service = new AptRoomServiceImpl(mapper);
+ sipEndpointService = mock(IAptSipEndpointService.class);
+ service = new AptRoomServiceImpl(mapper, sipEndpointService);
AptContext.clear();
AptContext.setGyId("APT-A");
}
@@ -277,4 +281,69 @@ class AptRoomServiceTest
row.put(key, value);
return row;
}
+
+ @Test
+ void createRegistersRoomAndCreatesSipEndpoint()
+ {
+ Map master = new LinkedHashMap<>();
+ master.put("roomId", "ROOM-201");
+ master.put("roomFloor", "2");
+ master.put("roomType", "DOUBLE");
+ when(mapper.selectMasterRoomByNo("APT-A", "201")).thenReturn(master);
+ when(mapper.countRoomByNo("APT-A", "201")).thenReturn(0);
+ AptRoom created = room("1", 0L);
+ created.setId(9L);
+ created.setRoomId("ROOM-201");
+ when(mapper.selectRoomByRoomId("APT-A", "ROOM-201")).thenReturn(created);
+ when(mapper.selectRoomById("APT-A", 9L)).thenReturn(created);
+
+ AptRoomCreateCommand command = new AptRoomCreateCommand();
+ command.setRoomNo("201");
+ service.create(command);
+
+ verify(mapper).insertRoom("APT-A", "ROOM-201", "201", "2", "DOUBLE");
+ verify(sipEndpointService).ensureForRoom("ROOM-201", "201", null, null);
+ }
+
+ @Test
+ void createRejectsRoomMissingInMasterBaseline()
+ {
+ when(mapper.selectMasterRoomByNo("APT-A", "999")).thenReturn(null);
+
+ AptRoomCreateCommand command = new AptRoomCreateCommand();
+ command.setRoomNo("999");
+ AptException error = assertThrows(AptException.class, () -> service.create(command));
+
+ assertEquals("ROOM_MASTER_NOT_FOUND", error.getReason());
+ verify(sipEndpointService, never()).ensureForRoom(anyString(), anyString(), any(), any());
+ }
+
+ @Test
+ void deleteRefusesRoomWithLivePersons()
+ {
+ AptRoom room = room("1", 0L);
+ room.setId(3L);
+ room.setInPersonCount(2);
+ when(mapper.selectRoomById("APT-A", 3L)).thenReturn(room);
+
+ AptException error = assertThrows(AptException.class, () -> service.delete(3L));
+
+ assertEquals(40902, error.getErrorCode().getCode());
+ verify(sipEndpointService, never()).removeForRoom(anyString());
+ verify(mapper, never()).softDeleteRoom(anyString(), anyLong());
+ }
+
+ @Test
+ void deleteSyncsSipRecordsBeforeSoftDeletingRoom()
+ {
+ AptRoom room = room("1", 0L);
+ room.setId(4L);
+ when(mapper.selectRoomById("APT-A", 4L)).thenReturn(room);
+ when(mapper.softDeleteRoom("APT-A", 4L)).thenReturn(1);
+
+ service.delete(4L);
+
+ verify(sipEndpointService).removeForRoom("ROOM-201");
+ verify(mapper).softDeleteRoom("APT-A", 4L);
+ }
}
diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/voice/AptSipEndpointServiceTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/voice/AptSipEndpointServiceTest.java
new file mode 100644
index 0000000..d200b8d
--- /dev/null
+++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/voice/AptSipEndpointServiceTest.java
@@ -0,0 +1,222 @@
+package com.ruoyi.apt.voice;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import com.ruoyi.apt.common.AptContext;
+import com.ruoyi.apt.common.AptException;
+import com.ruoyi.apt.voice.mapper.AptSipEndpointMapper;
+import com.ruoyi.apt.voice.service.AptSipEndpointServiceImpl;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * 房间 ↔ SIP endpoint/aor/contact 生命周期同步与 IP 冲突回归。
+ */
+class AptSipEndpointServiceTest
+{
+ private AptSipEndpointMapper mapper;
+ private AptSipEndpointServiceImpl service;
+
+ @BeforeEach
+ void setUp()
+ {
+ mapper = mock(AptSipEndpointMapper.class);
+ service = new AptSipEndpointServiceImpl(mapper);
+ AptContext.clear();
+ AptContext.setGyId("APT-A");
+ }
+
+ @AfterEach
+ void tearDown()
+ {
+ AptContext.clear();
+ }
+
+ @Test
+ void ensureForRoomCreatesEndpointAorAndBinding()
+ {
+ service.ensureForRoom("ROOM-201", "201", "房间 201", null);
+
+ verify(mapper).insertEndpoint(eq("APT-A"), anyString(), eq("201"), eq("ROOM-201"), eq("房间 201"));
+ verify(mapper).insertAor(eq("APT-A"), anyString(), anyString());
+ verify(mapper).insertBinding(eq("APT-A"), anyString(), anyString(), eq("ROOM-201"));
+ }
+
+ @Test
+ void ensureForRoomIsIdempotentWhenEndpointExists()
+ {
+ when(mapper.selectEndpointByRoom("APT-A", "ROOM-201")).thenReturn(endpoint("EP-1", "1001", "ROOM-201"));
+ when(mapper.selectAorByEndpoint("APT-A", "EP-1")).thenReturn(row("aorId", "AOR-1"));
+ when(mapper.selectActiveBindingByRoom("APT-A", "ROOM-201"))
+ .thenReturn(endpoint("EP-1", "1001", "ROOM-201"));
+
+ service.ensureForRoom("ROOM-201", "201", null, null);
+
+ verify(mapper, never()).insertEndpoint(anyString(), anyString(), anyString(), anyString(), any());
+ verify(mapper, never()).insertAor(anyString(), anyString(), anyString());
+ verify(mapper, never()).insertBinding(anyString(), anyString(), anyString(), anyString());
+ }
+
+ @Test
+ void ensureForRoomRejectsOccupiedExtension()
+ {
+ when(mapper.countEndpointByExtension("APT-A", "201")).thenReturn(1);
+
+ AptException error = assertThrows(AptException.class,
+ () -> service.ensureForRoom("ROOM-201", "201", null, null));
+
+ assertEquals("EXTENSION_CONFLICT", error.getReason());
+ verify(mapper, never()).insertEndpoint(anyString(), anyString(), anyString(), anyString(), any());
+ }
+
+ @Test
+ void bindIpRejectsIpAlreadyBoundToAnotherEndpoint()
+ {
+ when(mapper.selectEndpoint("APT-A", "EP-1")).thenReturn(endpoint("EP-1", "1001", "ROOM-201"));
+ when(mapper.selectAorByEndpoint("APT-A", "EP-1")).thenReturn(row("aorId", "AOR-1"));
+ Map occupied = row("endpointId", "EP-2");
+ occupied.put("uri", "sip:1002@192.168.1.10:5060");
+ when(mapper.selectActiveContactByIp("APT-A", "192.168.1.10")).thenReturn(occupied);
+
+ AptException error = assertThrows(AptException.class,
+ () -> service.bindIp("EP-1", "192.168.1.10", 5060));
+
+ assertEquals("SIP_IP_CONFLICT", error.getReason());
+ assertEquals(40901, error.getErrorCode().getCode());
+ verify(mapper, never()).insertContact(anyString(), anyString(), anyString(), anyString(),
+ anyString(), anyString(), anyInt());
+ }
+
+ @Test
+ void bindIpReleasesOldContactAndWritesNewOne()
+ {
+ when(mapper.selectEndpoint("APT-A", "EP-1")).thenReturn(endpoint("EP-1", "1001", "ROOM-201"));
+ when(mapper.selectAorByEndpoint("APT-A", "EP-1")).thenReturn(row("aorId", "AOR-1"));
+ Map occupied = row("endpointId", "EP-1");
+ occupied.put("port", 5060);
+ when(mapper.selectActiveContactByIp("APT-A", "192.168.1.11")).thenReturn(occupied);
+
+ service.bindIp("EP-1", "192.168.1.11", 5062);
+
+ verify(mapper).releaseContacts("APT-A", "EP-1", "REBIND");
+ verify(mapper).insertContact(eq("APT-A"), anyString(), eq("AOR-1"), eq("EP-1"),
+ eq("sip:1001@192.168.1.11:5062"), eq("192.168.1.11"), eq(5062));
+ verify(mapper).updateEndpointIp("APT-A", "EP-1", "192.168.1.11");
+ }
+
+ @Test
+ void bindIpIsIdempotentForSameEndpointAndPort()
+ {
+ when(mapper.selectEndpoint("APT-A", "EP-1")).thenReturn(endpoint("EP-1", "1001", "ROOM-201"));
+ when(mapper.selectAorByEndpoint("APT-A", "EP-1")).thenReturn(row("aorId", "AOR-1"));
+ Map occupied = row("endpointId", "EP-1");
+ occupied.put("port", 5060);
+ when(mapper.selectActiveContactByIp("APT-A", "192.168.1.11")).thenReturn(occupied);
+
+ service.bindIp("EP-1", "192.168.1.11", null);
+
+ verify(mapper, never()).insertContact(anyString(), anyString(), anyString(), anyString(),
+ anyString(), anyString(), anyInt());
+ verify(mapper, never()).releaseContacts(anyString(), anyString(), anyString());
+ }
+
+ @Test
+ void unbindIpReleasesContactsAndClearsEndpointIp()
+ {
+ when(mapper.selectEndpoint("APT-A", "EP-1")).thenReturn(endpoint("EP-1", "1001", "ROOM-201"));
+
+ service.unbindIp("EP-1");
+
+ verify(mapper).releaseContacts("APT-A", "EP-1", "UNBIND");
+ verify(mapper).updateEndpointIp("APT-A", "EP-1", null);
+ }
+
+ @Test
+ void removeForRoomClosesBindingAndSoftDeletesEndpoint()
+ {
+ when(mapper.selectEndpointByRoom("APT-A", "ROOM-201")).thenReturn(endpoint("EP-1", "1001", "ROOM-201"));
+
+ service.removeForRoom("ROOM-201");
+
+ verify(mapper).closeBindingsByRoom("APT-A", "ROOM-201");
+ verify(mapper).releaseContacts("APT-A", "EP-1", "ROOM_DELETED");
+ verify(mapper).softDeleteAor("APT-A", "EP-1");
+ verify(mapper).softDeleteEndpoint("APT-A", "EP-1");
+ }
+
+ @Test
+ void bindIpRejectsIllegalIp()
+ {
+ AptException error = assertThrows(AptException.class,
+ () -> service.bindIp("EP-1", "192.168.1", 5060));
+
+ assertEquals("SIP_IP_INVALID", error.getReason());
+ verify(mapper, never()).selectEndpoint(anyString(), anyString());
+ }
+
+ @Test
+ void ensureForRoomCreatesAuthForActiveInstance()
+ {
+ when(mapper.selectActiveInstanceCode("APT-A")).thenReturn("AST-1");
+
+ service.ensureForRoom("ROOM-201", "201", null, null);
+
+ verify(mapper).insertAuth(eq("APT-A"), eq("AST-1"), eq("201"), eq("AUTH-AST-1-201"), anyString());
+ }
+
+ @Test
+ void ensureForRoomSkipsAuthWhenNoActiveInstance()
+ {
+ service.ensureForRoom("ROOM-201", "201", null, null);
+
+ verify(mapper, never()).insertAuth(anyString(), anyString(), anyString(), anyString(), anyString());
+ }
+
+ @Test
+ void ensureForRoomReusesExistingAuth()
+ {
+ when(mapper.selectActiveInstanceCode("APT-A")).thenReturn("AST-1");
+ when(mapper.selectAuth("APT-A", "AST-1", "201")).thenReturn(row("authId", "AUTH-AST-1-201"));
+
+ service.ensureForRoom("ROOM-201", "201", null, null);
+
+ verify(mapper, never()).insertAuth(anyString(), anyString(), anyString(), anyString(), anyString());
+ }
+
+ @Test
+ void removeForRoomDisablesAuth()
+ {
+ when(mapper.selectEndpointByRoom("APT-A", "ROOM-201")).thenReturn(endpoint("EP-1", "1001", "ROOM-201"));
+
+ service.removeForRoom("ROOM-201");
+
+ verify(mapper).disableAuthByEndpoint("APT-A", "EP-1");
+ }
+
+ private Map