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> selectDeviceList(@Param("gyid") String gyid, + @Param("deviceType") String deviceType, + @Param("state") String state, + @Param("keyword") String keyword, + @Param("limit") int limit); + + Map selectDevice(@Param("gyid") String gyid, @Param("deviceCode") String deviceCode); + + /** 心跳只写在线态与版本,不触碰指令态 */ + int upsertHeartbeat(@Param("gyid") String gyid, @Param("deviceCode") String deviceCode, + @Param("appVersion") String appVersion, @Param("at") Date at); + + int updateDeviceSeen(@Param("gyid") String gyid, @Param("deviceCode") String deviceCode, + @Param("at") Date at); + + Map selectCommand(@Param("gyid") String gyid, @Param("commandId") String commandId); + + int insertCommand(@Param("gyid") String gyid, @Param("commandId") String commandId, + @Param("deviceCode") String deviceCode, @Param("commandType") String commandType, + @Param("payload") String payload, @Param("faceScopeRef") String faceScopeRef, + @Param("expireAt") Date expireAt); + + List> selectCommandList(@Param("gyid") String gyid, + @Param("deviceCode") String deviceCode, + @Param("state") String state, + @Param("limit") int limit); +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/device/service/AptDeviceServiceImpl.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/device/service/AptDeviceServiceImpl.java new file mode 100644 index 0000000..8056d7f --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/device/service/AptDeviceServiceImpl.java @@ -0,0 +1,199 @@ +package com.ruoyi.apt.device.service; + +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.device.domain.AptDeviceCommandRequest; +import com.ruoyi.apt.device.domain.AptDeviceHeartbeat; +import com.ruoyi.apt.device.mapper.AptDeviceMapper; +import com.ruoyi.apt.framework.config.AptProperties; +import com.ruoyi.apt.report.service.IAptReportService; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * M5 设备管理实现。 + * + *

红线:指令只入队 {@code PENDING} + {@code protocolConfirmed=false}; + * 无设备心跳不自动改 {@code SENT}/{@code ACKED}(Q-12 现场协议未确认)。

+ * + * @author apt-rev01 + */ +@Service +public class AptDeviceServiceImpl implements IAptDeviceService +{ + private static final int DEFAULT_LIMIT = 50; + private static final int MAX_LIMIT = 200; + + private final AptDeviceMapper deviceMapper; + private final IAptReportService reportService; + private final AptProperties properties; + + public AptDeviceServiceImpl(AptDeviceMapper deviceMapper, IAptReportService reportService, + AptProperties properties) + { + this.deviceMapper = deviceMapper; + this.reportService = reportService; + this.properties = properties; + } + + @Override + public List> list(String deviceType, String state, String keyword, Integer limit) + { + int size = limit == null ? DEFAULT_LIMIT : limit; + size = Math.max(1, Math.min(size, MAX_LIMIT)); + return deviceMapper.selectDeviceList(gyid(), trim(deviceType), trim(state), trim(keyword), size); + } + + @Override + public Map detail(String deviceCode) + { + return requireDevice(deviceCode); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Map heartbeat(String deviceCode, AptDeviceHeartbeat heartbeat) + { + String code = normalize(deviceCode); + Map device = requireDevice(code); + AptDeviceHeartbeat body = heartbeat == null ? new AptDeviceHeartbeat() : heartbeat; + Date at = new Date(); + deviceMapper.upsertHeartbeat(gyid(), code, trim(body.getAppVersion()), at); + deviceMapper.updateDeviceSeen(gyid(), code, at); + + Map watermark = reportService.recordHeartbeat(body.getWatermark(), body.getClockSkewMs()); + Map result = new LinkedHashMap<>(); + result.put("deviceCode", code); + result.put("deviceType", device.get("deviceType")); + result.put("onlineState", "ONLINE"); + result.put("heartbeatAt", at); + result.put("appVersion", trim(body.getAppVersion())); + result.put("clockSkewMs", body.getClockSkewMs()); + result.put("clockSkewExceeded", watermark.get("clockSkewExceeded")); + result.put("clockSkewThresholdMs", properties.getReport().getClockSkewThresholdMs()); + result.put("watermark", watermark.get("currentWatermark")); + result.put("ackedWatermark", watermark.get("ackedWatermark")); + result.put("lag", watermark.get("lag")); + return result; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Map enqueue(String deviceCode, AptDeviceCommandRequest request) + { + String code = normalize(deviceCode); + Map device = requireDevice(code); + if (!"ACTIVE".equals(text(device.get("state")))) + { + throw new AptException(AptErrorCode.FEATURE_DISABLED, "DEVICE_DISABLED", + "设备已停用,不参与指令投递:" + code); + } + if (request == null || trim(request.getCommandType()).isEmpty()) + { + throw new AptException(AptErrorCode.BAD_REQUEST, "MISSING_COMMAND_TYPE", "缺少 commandType"); + } + String commandId = trim(request.getCommandId()); + if (commandId.isEmpty()) + { + commandId = "DC-" + UUID.randomUUID().toString().replace("-", ""); + } + Map existing = deviceMapper.selectCommand(gyid(), commandId); + if (existing != null) + { + return view(existing, true); + } + String payload = request.getPayload() == null ? null : JSON.toJSONString(request.getPayload()); + try + { + deviceMapper.insertCommand(gyid(), commandId, code, request.getCommandType(), payload, + trim(request.getFaceScopeRef()), request.getExpireAt()); + } + catch (DuplicateKeyException e) + { + Map duplicated = deviceMapper.selectCommand(gyid(), commandId); + if (duplicated == null) + { + throw new AptException(AptErrorCode.DATA_ANOMALY, "COMMAND_LOST", "指令幂等冲突但不可读"); + } + return view(duplicated, true); + } + Map command = deviceMapper.selectCommand(gyid(), commandId); + return view(command, false); + } + + @Override + public List> commands(String deviceCode, String state, Integer limit) + { + String code = normalize(deviceCode); + requireDevice(code); + int size = limit == null ? DEFAULT_LIMIT : limit; + size = Math.max(1, Math.min(size, MAX_LIMIT)); + return deviceMapper.selectCommandList(gyid(), code, trim(state), size); + } + + private Map view(Map command, boolean duplicated) + { + Map result = new LinkedHashMap<>(); + if (command != null) + { + result.putAll(command); + } + Object state = command == null ? null : command.get("state"); + result.put("state", state == null ? "PENDING" : String.valueOf(state)); + Object confirmed = command == null ? null : command.get("protocolConfirmed"); + boolean protocolConfirmed = confirmed instanceof Number + ? ((Number) confirmed).intValue() != 0 + : "1".equals(String.valueOf(confirmed)) || "true".equalsIgnoreCase(String.valueOf(confirmed)); + result.put("protocolConfirmed", protocolConfirmed); + result.put("duplicated", duplicated); + return result; + } + + private Map requireDevice(String deviceCode) + { + String code = normalize(deviceCode); + if (code.isEmpty()) + { + throw new AptException(AptErrorCode.BAD_REQUEST, "MISSING_DEVICE_CODE", "缺少设备编码"); + } + Map device = deviceMapper.selectDevice(gyid(), code); + if (device == null) + { + throw new AptException(AptErrorCode.NOT_FOUND, "DEVICE_NOT_FOUND", "设备不存在:" + code); + } + return device; + } + + 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 normalize(String deviceCode) + { + return trim(deviceCode).toUpperCase(); + } + + private static String trim(String value) + { + return value == null ? "" : value.trim(); + } + + private static String text(Object value) + { + return value == null ? "" : String.valueOf(value).trim(); + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/device/service/IAptDeviceService.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/device/service/IAptDeviceService.java new file mode 100644 index 0000000..f6dd086 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/device/service/IAptDeviceService.java @@ -0,0 +1,26 @@ +package com.ruoyi.apt.device.service; + +import java.util.List; +import java.util.Map; +import com.ruoyi.apt.device.domain.AptDeviceCommandRequest; +import com.ruoyi.apt.device.domain.AptDeviceHeartbeat; + +/** + * M5 设备管理:档案读取、#14 心跳、指令入队。 + * + * @author apt-rev01 + */ +public interface IAptDeviceService +{ + List> list(String deviceType, String state, String keyword, Integer limit); + + Map detail(String deviceCode); + + /** 心跳:写 gy_sb_xt 在线态 + gy_sb.last_seen_at + 上报水位与时钟偏差 */ + Map heartbeat(String deviceCode, AptDeviceHeartbeat heartbeat); + + /** 指令只入队 PENDING,protocolConfirmed 恒 false */ + Map enqueue(String deviceCode, AptDeviceCommandRequest request); + + List> commands(String deviceCode, String state, Integer limit); +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/controller/AptJcxxController.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/controller/AptJcxxController.java index adb2088..562c29a 100644 --- a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/controller/AptJcxxController.java +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/controller/AptJcxxController.java @@ -11,6 +11,7 @@ import com.ruoyi.apt.dispatch.domain.AptJcxxQuery; import com.ruoyi.apt.dispatch.service.IAptDutyCommandService; import com.ruoyi.apt.dispatch.service.IAptJcxxService; import com.ruoyi.apt.framework.web.AptBaseController; +import com.ruoyi.apt.voice.service.IAptCallFlowService; import com.ruoyi.common.core.domain.AjaxResult; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.security.access.prepost.PreAuthorize; @@ -37,11 +38,14 @@ public class AptJcxxController extends AptBaseController { private final IAptJcxxService jcxxService; private final IAptDutyCommandService commandService; + private final IAptCallFlowService callFlowService; - public AptJcxxController(IAptJcxxService jcxxService, IAptDutyCommandService commandService) + public AptJcxxController(IAptJcxxService jcxxService, IAptDutyCommandService commandService, + IAptCallFlowService callFlowService) { this.jcxxService = jcxxService; this.commandService = commandService; + this.callFlowService = callFlowService; } /** 表 4-9 #1:在寓/叫乘记录列表 */ @@ -123,4 +127,20 @@ public class AptJcxxController extends AptBaseController AptDutyCommandResult result = commandService.execute(id, command); return AjaxResult.success(result); } + + /** + * 值班台手动发起 SIP 叫乘(表 5-12 步骤 2~7)。 + * + *

用于「到点自动发起」缺失/失败时的补发:要求 {@code ryzt=0}、未停止、 + * 房间已绑定分机且 AMI 已 UP,否则按原有错误码返回(40904/40404/40903)。 + * 与自动发起共用同一实现 {@link IAptCallFlowService#dial(String)}, + * 不额外写 {@code gy_jcxx},状态仍只由状态机迁移。

+ */ + @PreAuthorize("@ss.hasPermi('apt:jcxx:changeStatus')") + @AptAudit(module = "DISPATCH", action = "JCXX_DIAL") + @PostMapping("/{jcid}/dial") + public AjaxResult dial(@PathVariable String jcid) + { + return AjaxResult.success(callFlowService.dial(jcid)); + } } diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/mapper/AptDutyStateMapper.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/mapper/AptDutyStateMapper.java index 76fae0c..c945c12 100644 --- a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/mapper/AptDutyStateMapper.java +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/mapper/AptDutyStateMapper.java @@ -19,6 +19,9 @@ public interface AptDutyStateMapper /** 状态快照(命令前置校验 + 结果去重判定) */ AptStateSnapshot selectSnapshot(@Param("gyid") String gyid, @Param("jcid") String jcid); + /** 离寓联动:取该叫乘记录对应的房号(gy_jcxx.fjh),无记录返回 null */ + String selectRoomNoByJcid(@Param("gyid") String gyid, @Param("jcid") String jcid); + /** 0 待叫乘 -> 1 叫乘中(SIP 发起呼叫 / 人工临时叫乘) */ int updateCallStart(@Param("gyid") String gyid, @Param("jcid") String jcid, @Param("tempCall") boolean tempCall); diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/mapper/AptJcxxMapper.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/mapper/AptJcxxMapper.java index 450cf23..9f78b4c 100644 --- a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/mapper/AptJcxxMapper.java +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/mapper/AptJcxxMapper.java @@ -28,6 +28,9 @@ public interface AptJcxxMapper List> selectManualActions(@Param("gyid") String gyid, @Param("jcid") String jcid); + /** 叫乘作业的刷脸照片记录(含失败/重试,按上传时间倒序) */ + List> selectFacePhotos(@Param("gyid") String gyid, @Param("jcid") String jcid); + /** 时间线:状态时间戳(表 3-10 的「同表写入」列) */ List> selectTimelineStates(@Param("gyid") String gyid, @Param("jcid") String jcid); diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/AptDutyStateServiceImpl.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/AptDutyStateServiceImpl.java index d850efd..fb24cdf 100644 --- a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/AptDutyStateServiceImpl.java +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/AptDutyStateServiceImpl.java @@ -7,8 +7,13 @@ import com.ruoyi.apt.common.AptErrorCode; import com.ruoyi.apt.common.AptException; import com.ruoyi.apt.dispatch.domain.AptStateSnapshot; import com.ruoyi.apt.dispatch.mapper.AptDutyStateMapper; +import com.ruoyi.apt.job.service.IAptJobService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; /** * 叫乘状态机唯一写入口(IT-5 / M3,公寓端/03 表 3-10)。 @@ -27,14 +32,18 @@ import org.springframework.transaction.annotation.Transactional; @Service public class AptDutyStateServiceImpl implements IAptDutyStateService { + private static final Logger log = LoggerFactory.getLogger(AptDutyStateServiceImpl.class); + /** 应答允许的来源态:0 待叫乘 / 1 叫乘中 / 2 叫乘报警(含复叫入会) */ private static final List ANSWERABLE = List.of("0", "1", "2"); private final AptDutyStateMapper stateMapper; + private final IAptJobService jobService; - public AptDutyStateServiceImpl(AptDutyStateMapper stateMapper) + public AptDutyStateServiceImpl(AptDutyStateMapper stateMapper, IAptJobService jobService) { this.stateMapper = stateMapper; + this.jobService = jobService; } @Override @@ -91,6 +100,13 @@ public class AptDutyStateServiceImpl implements IAptDutyStateService return require(stateMapper.updateForceFinish(gyid(), jcid), jcid, "FORCE_FINISH"); } + /** + * 登记离寓 + 保洁联动。 + * + *

同事务内按 gy_jcxx 在寓事实重算房间人数与房态(最后一人离寓直接置 3 待清扫); + * 事务提交后再触发「全员离寓自动建单」,避免新事务读不到本次离寓结果, + * 也保证建单失败不会回滚离寓这一核心业务事实。

+ */ @Override @Transactional public int markLeave(String jcid, String leaveType, String remark) @@ -99,9 +115,48 @@ public class AptDutyStateServiceImpl implements IAptDutyStateService int rows = require(stateMapper.updateMarkLeave(gyid(), jcid, leaveType, eventId, remark), jcid, "MARK_LEAVE"); stateMapper.insertLeaveEvent(gyid(), eventId, jcid, leaveType); + + String roomNo = stateMapper.selectRoomNoByJcid(gyid(), jcid); + if (roomNo == null || roomNo.isBlank()) + { + return rows; + } + // 占用与房态必须与离寓同事务,避免「已离寓但房态仍住满/空闲」 + jobService.syncRoomOccupancy(roomNo); + triggerCleanOnVacated(roomNo, jcid); return rows; } + /** 提交后触发自动建单;建单失败只告警,不回滚已提交的离寓 */ + private void triggerCleanOnVacated(String roomNo, String jcid) + { + Runnable task = () -> { + try + { + jobService.autoCreateCleanOnVacated(roomNo, "LEAVE:" + jcid); + } + catch (Exception e) + { + log.warn("全员离寓自动建单失败,roomNo={}, jcid={}, cause={}", roomNo, jcid, e.toString()); + } + }; + if (TransactionSynchronizationManager.isSynchronizationActive()) + { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() + { + @Override + public void afterCommit() + { + task.run(); + } + }); + } + else + { + task.run(); + } + } + @Override @Transactional public int cancel(String jcid) diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/AptJcxxServiceImpl.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/AptJcxxServiceImpl.java index 3c0106c..8d74d3c 100644 --- a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/AptJcxxServiceImpl.java +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/AptJcxxServiceImpl.java @@ -63,6 +63,8 @@ public class AptJcxxServiceImpl implements IAptJcxxService detail.put("session", jcxxMapper.selectLatestSession(gyid(), jcid)); detail.put("recordings", nonNull(jcxxMapper.selectRecordings(gyid(), jcid))); detail.put("manualActions", nonNull(jcxxMapper.selectManualActions(gyid(), jcid))); + // 平板刷脸照片(只含路径与状态,失败记录一并返回,便于值班台追溯) + detail.put("facePhotos", nonNull(jcxxMapper.selectFacePhotos(gyid(), jcid))); return detail; } diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/tablet/controller/AptTabletOpenController.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/tablet/controller/AptTabletOpenController.java index 2e8f938..bcc22ac 100644 --- a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/tablet/controller/AptTabletOpenController.java +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/tablet/controller/AptTabletOpenController.java @@ -5,8 +5,10 @@ import com.ruoyi.apt.common.AptErrorCode; import com.ruoyi.apt.common.AptException; import com.ruoyi.apt.dispatch.tablet.domain.AptTabletResultCommand; import com.ruoyi.apt.dispatch.tablet.service.IAptTabletDispatchService; +import com.ruoyi.apt.dispatch.tablet.service.IAptTabletPhotoService; import com.ruoyi.apt.dispatch.tablet.service.IAptTabletResultService; import com.ruoyi.common.core.domain.AjaxResult; +import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -14,7 +16,9 @@ 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.RequestPart; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; /** * 设备侧平板接口(IT-5 / M3,公寓端/05 表 5-16 步骤 2~3、8)。 @@ -34,12 +38,15 @@ public class AptTabletOpenController { private final IAptTabletDispatchService dispatchService; private final IAptTabletResultService resultService; + private final IAptTabletPhotoService photoService; public AptTabletOpenController(IAptTabletDispatchService dispatchService, - IAptTabletResultService resultService) + IAptTabletResultService resultService, + IAptTabletPhotoService photoService) { this.dispatchService = dispatchService; this.resultService = resultService; + this.photoService = photoService; } /** N-03:按 dispatch_seq 游标顺序拉取任务(拉取 ≠ 执行) */ @@ -61,7 +68,7 @@ public class AptTabletOpenController return AjaxResult.success(dispatchService.ack(requireDevice(deviceCode), taskId)); } - /** N-04:结果上报(resultId 幂等;只传识别结论,不传图像) */ + /** N-04:结果上报(resultId 幂等;JSON 只传识别结论,图像走下方照片接口) */ @PostMapping("/results") public AjaxResult results( @RequestHeader(value = "X-Device-Code", required = false) String deviceCode, @@ -70,6 +77,22 @@ public class AptTabletOpenController return AjaxResult.success(resultService.accept(requireDevice(deviceCode), command)); } + /** + * N-04 附属:刷脸照片上传(触发时机 = 结果被受理且为 ANSWERED + VERIFIED 之后)。 + * + *

multipart/form-data,字段名 {@code file};同一 resultId 重复上传幂等(返回 duplicated=true), + * 失败可原样重试,不影响已完成的叫乘状态。

+ */ + @PostMapping(value = "/results/{resultId}/face-photo", + consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public AjaxResult facePhoto( + @RequestHeader(value = "X-Device-Code", required = false) String deviceCode, + @PathVariable String resultId, + @RequestPart("file") MultipartFile file) + { + return AjaxResult.success(photoService.store(requireDevice(deviceCode), resultId, file)); + } + private String requireDevice(String deviceCode) { if (deviceCode == null || deviceCode.isBlank()) diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/tablet/mapper/AptTabletMapper.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/tablet/mapper/AptTabletMapper.java index 1498a0d..2afa4c0 100644 --- a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/tablet/mapper/AptTabletMapper.java +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/tablet/mapper/AptTabletMapper.java @@ -71,4 +71,49 @@ public interface AptTabletMapper @Param("bizType") String bizType, @Param("bizId") String bizId, @Param("bizVersion") long bizVersion, @Param("payload") String payload, @Param("occurredAt") Date occurredAt); + + // ------------------------------------------------------------ 刷脸照片 + + /** 结果摘要(含设备/任务/状态/照片指针),上传前做归属与适用性校验 */ + Map selectResultForPhoto(@Param("gyid") String gyid, + @Param("resultId") String resultId); + + Map selectPhotoByResult(@Param("gyid") String gyid, @Param("resultId") String resultId); + + /** 首次上传插入 PENDING;重传按 (gyid, result_id) 覆盖并 retry_count+1 */ + int insertPhotoPending(@Param("gyid") String gyid, @Param("photoId") String photoId, + @Param("jcid") String jcid, @Param("taskId") String taskId, + @Param("resultId") String resultId, @Param("ryid") String ryid, + @Param("deviceCode") String deviceCode, + @Param("storageTarget") String storageTarget, + @Param("storagePath") String storagePath, + @Param("fileName") String fileName, + @Param("contentType") String contentType, + @Param("fileSize") long fileSize, + @Param("capturedAt") Date capturedAt); + + int updatePhotoStored(@Param("gyid") String gyid, @Param("resultId") String resultId, + @Param("fileSize") long fileSize, @Param("sha256") String sha256); + + int updatePhotoFailed(@Param("gyid") String gyid, @Param("resultId") String resultId, + @Param("failReason") String failReason); + + int updateResultPhotoRef(@Param("gyid") String gyid, @Param("resultId") String resultId, + @Param("photoId") String photoId, @Param("photoState") String photoState); + + // -------------------------------------------------------- 过期清理(保留期) + + /** 存在过期照片的公寓列表(清理任务按公寓逐个边界清理,保持 gyid 隔离) */ + List selectGyidsWithExpiredPhotos(@Param("cutoff") Date cutoff); + + /** + * 过期照片批次:以 uploaded_at 为准,失败/未完成记录退化为 captured_at / create_time。 + * 每次最多 limit 条,由调用方循环直到取空。 + */ + List> selectExpiredPhotos(@Param("gyid") String gyid, + @Param("cutoff") Date cutoff, + @Param("limit") int limit); + + /** 物理删除记录(保留期到期后不再保留任何路径信息) */ + int deletePhoto(@Param("gyid") String gyid, @Param("id") long id); } diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/tablet/service/AptPhotoPurgeServiceImpl.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/tablet/service/AptPhotoPurgeServiceImpl.java new file mode 100644 index 0000000..531b868 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/tablet/service/AptPhotoPurgeServiceImpl.java @@ -0,0 +1,169 @@ +package com.ruoyi.apt.dispatch.tablet.service; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +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.framework.config.AptPhotoProperties; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +/** + * 刷脸照片保留期清理实现。 + * + *

顺序与幂等:

+ *
    + *
  1. 按 {@code gyid} 取过期流水,逐批处理(默认 200/批),单批无进展即停,防止死循环;
  2. + *
  3. 先删文件:文件已不存在视为已清理;路径不在根目录内只告警、不删文件但删记录;
  4. + *
  5. 文件删除抛错则保留记录(下轮重试),并计入 filesFailed;
  6. + *
  7. 最后物理删除数据库记录(保留期内已无任何路径信息)。
  8. + *
+ * + * @author apt-rev01 + */ +@Service +public class AptPhotoPurgeServiceImpl implements IAptPhotoPurgeService +{ + private static final Logger log = LoggerFactory.getLogger(AptPhotoPurgeServiceImpl.class); + + /** 单公寓单次运行最多批次数,避免异常数据把任务拖死 */ + private static final int MAX_BATCHES = 200; + + private static final int DEFAULT_RETENTION_DAYS = 30; + + private final AptTabletMapper tabletMapper; + private final AptPhotoProperties properties; + + public AptPhotoPurgeServiceImpl(AptTabletMapper tabletMapper, AptPhotoProperties properties) + { + this.tabletMapper = tabletMapper; + this.properties = properties; + } + + @Override + public Map purgeExpired() + { + int retentionDays = properties.getRetentionDays() > 0 + ? properties.getRetentionDays() : DEFAULT_RETENTION_DAYS; + int batchSize = properties.getPurgeBatchSize() > 0 ? properties.getPurgeBatchSize() : 200; + Date cutoff = Date.from(Instant.now().minus(retentionDays, ChronoUnit.DAYS)); + + long scanned = 0; + long deleted = 0; + long filesDeleted = 0; + long filesMissing = 0; + long filesFailed = 0; + long filesSkipped = 0; + + List gyids = tabletMapper.selectGyidsWithExpiredPhotos(cutoff); + for (String gyid : gyids) + { + for (int round = 0; round < MAX_BATCHES; round++) + { + List> rows = tabletMapper.selectExpiredPhotos(gyid, cutoff, batchSize); + if (rows == null || rows.isEmpty()) + { + break; + } + long deletedInBatch = 0; + for (Map row : rows) + { + scanned++; + String storagePath = str(row.get("storagePath")); + try + { + FileResult result = deleteFile(storagePath); + switch (result) + { + case DELETED: + filesDeleted++; + break; + case MISSING: + filesMissing++; + break; + default: + filesSkipped++; + break; + } + } + catch (IOException e) + { + // 文件删不掉就先留记录,下轮再试,避免路径信息丢失后无法定位残留文件 + filesFailed++; + log.warn("刷脸照片文件删除失败,保留记录待下轮重试:gyid={}, photoId={}, path={}, err={}", + gyid, row.get("photoId"), storagePath, e.toString()); + continue; + } + int affected = tabletMapper.deletePhoto(gyid, asLong(row.get("id"))); + deleted += affected; + deletedInBatch += affected; + } + if (deletedInBatch == 0 || rows.size() < batchSize) + { + // 本批没有进展(全部是删文件失败)或已取空,结束该公寓 + break; + } + } + } + + Map stats = new LinkedHashMap<>(); + stats.put("retentionDays", retentionDays); + stats.put("cutoff", cutoff); + stats.put("gyids", gyids.size()); + stats.put("scanned", scanned); + stats.put("deleted", deleted); + stats.put("filesDeleted", filesDeleted); + stats.put("filesMissing", filesMissing); + stats.put("filesFailed", filesFailed); + stats.put("filesSkipped", filesSkipped); + if (scanned > 0 || filesFailed > 0) + { + log.info("刷脸照片清理完成:{}", stats); + } + return stats; + } + + /** 删除单个文件:路径必须落在配置根目录内 */ + private FileResult deleteFile(String storagePath) throws IOException + { + if (storagePath == null || storagePath.isBlank()) + { + return FileResult.SKIPPED; + } + Path root = Paths.get(properties.getRoot()).toAbsolutePath().normalize(); + Path file = root.resolve(storagePath).normalize(); + if (!file.startsWith(root)) + { + log.warn("刷脸照片路径越界,跳过文件删除(记录仍清理):{}", storagePath); + return FileResult.SKIPPED; + } + return Files.deleteIfExists(file) ? FileResult.DELETED : FileResult.MISSING; + } + + private long asLong(Object value) + { + if (value instanceof Number number) + { + return number.longValue(); + } + return value == null ? 0L : Long.parseLong(String.valueOf(value)); + } + + private String str(Object value) + { + return value == null ? "" : String.valueOf(value); + } + + private enum FileResult + { + DELETED, MISSING, SKIPPED + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/tablet/service/AptTabletPhotoServiceImpl.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/tablet/service/AptTabletPhotoServiceImpl.java new file mode 100644 index 0000000..9835367 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/tablet/service/AptTabletPhotoServiceImpl.java @@ -0,0 +1,379 @@ +package com.ruoyi.apt.dispatch.tablet.service; + +import java.io.BufferedInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +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.dispatch.tablet.mapper.AptTabletMapper; +import com.ruoyi.apt.framework.config.AptPhotoProperties; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +/** + * 平板刷脸照片受理实现。 + * + *

落盘规范(相对路径入库,根目录取 {@code apt.photo.root}):

+ *
+ * face/{gyid}/{yyyy}/{MM}/{dd}/{jcid}/{photoId}.{jpg|png}
+ * 
+ * + *

可靠性约定:

+ *
    + *
  1. 先写 PENDING 行,再落盘,最后置 STORED —— 中途失败必有记录,不留“有文件无记录”;
  2. + *
  3. 写临时文件 + 原子改名,避免半张图被读到;
  4. + *
  5. 重传复用同一 photo_id/路径(retry_count+1),不产生多行;
  6. + *
  7. 失败只影响照片态(FAILED),不回滚已经完成的 ryzt=3。
  8. + *
+ * + * @author apt-rev01 + */ +@Service +public class AptTabletPhotoServiceImpl implements IAptTabletPhotoService +{ + private static final Logger log = LoggerFactory.getLogger(AptTabletPhotoServiceImpl.class); + + private static final String ANSWERED = "ANSWERED"; + private static final String VERIFIED = "VERIFIED"; + private static final String STORED = "STORED"; + private static final String FAILED = "FAILED"; + private static final String PENDING = "PENDING"; + + private static final Set ALLOWED_TYPES = Set.of("image/jpeg", "image/png"); + + /** jcid/photoId 会拼进路径,禁止任何可能穿越目录的字符 */ + private static final Pattern SAFE_SEGMENT = Pattern.compile("^[A-Za-z0-9._-]{1,64}$"); + + private static final DateTimeFormatter YEAR = DateTimeFormatter.ofPattern("yyyy"); + private static final DateTimeFormatter MONTH = DateTimeFormatter.ofPattern("MM"); + private static final DateTimeFormatter DAY = DateTimeFormatter.ofPattern("dd"); + + private final AptTabletMapper tabletMapper; + private final AptPhotoProperties properties; + + public AptTabletPhotoServiceImpl(AptTabletMapper tabletMapper, AptPhotoProperties properties) + { + this.tabletMapper = tabletMapper; + this.properties = properties; + } + + @Override + public Map store(String deviceCode, String resultId, MultipartFile file) + { + String gyid = gyid(); + if (file == null || file.isEmpty()) + { + throw new AptException(AptErrorCode.FACE_MEDIA_MISSING, "PHOTO_EMPTY", "照片文件为空"); + } + if (resultId == null || resultId.isBlank()) + { + throw new AptException(AptErrorCode.BAD_REQUEST, "MISSING_RESULTID", "resultId 不能为空"); + } + + Map result = tabletMapper.selectResultForPhoto(gyid, resultId); + if (absent(result)) + { + throw new AptException(AptErrorCode.NOT_FOUND, "TABLET_RESULT_NOT_FOUND", + "平板结果不存在,请先上报结果:" + resultId); + } + String boundDevice = firstText(result.get("deviceCode"), result.get("taskDeviceCode")); + if (!boundDevice.isEmpty() && !boundDevice.equalsIgnoreCase(deviceCode)) + { + throw new AptException(AptErrorCode.BAD_REQUEST, "TABLET_RESULT_DEVICE_MISMATCH", + "结果不属于该平板:" + resultId); + } + if (!ANSWERED.equalsIgnoreCase(str(result.get("resultType"))) + || !VERIFIED.equalsIgnoreCase(str(result.get("faceVerifyState")))) + { + // 只有「刷脸确认成功」才留照片;未刷脸/未应答不接收,避免误存无关图像 + throw new AptException(AptErrorCode.STATE_ILLEGAL, "TABLET_PHOTO_NOT_APPLICABLE", + "仅刷脸确认成功(ANSWERED + VERIFIED)的结果可上传照片"); + } + if ("IGNORED".equalsIgnoreCase(str(result.get("appliedState")))) + { + throw new AptException(AptErrorCode.STATE_ILLEGAL, "TABLET_RESULT_IGNORED", + "结果已按迟到/重复忽略,不再接收照片"); + } + + Map existing = tabletMapper.selectPhotoByResult(gyid, resultId); + if (!absent(existing) && STORED.equals(str(existing.get("uploadState")))) + { + // 幂等:已存成功,直接回显(平板重试安全) + return meta(existing, true); + } + + String contentType = detectContentType(file); + long size = file.getSize(); + if (size > properties.getMaxSizeBytes()) + { + throw new AptException(AptErrorCode.FACE_MEDIA_TOO_LARGE, "PHOTO_TOO_LARGE", + "照片超过上限 " + properties.getMaxSizeBytes() + " 字节"); + } + + String jcid = str(result.get("jcid")); + if (!SAFE_SEGMENT.matcher(jcid).matches()) + { + throw new AptException(AptErrorCode.DATA_ANOMALY, "PHOTO_PATH_UNSAFE", + "jcid 含非法字符,拒绝落盘:" + jcid); + } + String photoId = absent(existing) ? "PH-" + suffix() : str(existing.get("photoId")); + String extension = "image/png".equals(contentType) ? "png" : "jpg"; + // UUID 生成后统一大写,保持库内 photoId 口径一致 + photoId = photoId.toUpperCase(Locale.ROOT); + String relativePath = String.join("/", "face", gyid, YEAR.format(LocalDate.now()), + MONTH.format(LocalDate.now()), DAY.format(LocalDate.now()), jcid, + photoId + "." + extension); + String fileName = photoId + "." + extension; + String storageTarget = properties.getStorageTarget(); + + // 1) 先落 PENDING:失败也能在库里查到这次尝试 + tabletMapper.insertPhotoPending(gyid, photoId, jcid, str(result.get("taskId")), resultId, + str(result.get("verifiedRyid")), deviceCode, storageTarget, relativePath, + fileName, contentType, size, result.get("occurredAt") == null ? null : toDate(result)); + tabletMapper.updateResultPhotoRef(gyid, resultId, photoId, PENDING); + + // 2) 再落盘(临时文件 + 原子改名) + Path root = Paths.get(properties.getRoot()).toAbsolutePath().normalize(); + Path target = root.resolve(relativePath).normalize(); + if (!target.startsWith(root)) + { + markFailed(gyid, resultId, photoId, "路径越界:" + relativePath); + throw new AptException(AptErrorCode.DATA_ANOMALY, "PHOTO_PATH_UNSAFE", "照片路径越界"); + } + String sha256; + try + { + sha256 = writeAtomically(file, target); + } + catch (IOException | RuntimeException e) + { + log.error("刷脸照片落盘失败:gyid={}, resultId={}, path={}, err={}", + gyid, resultId, relativePath, e.toString()); + markFailed(gyid, resultId, photoId, e.getClass().getSimpleName() + ": " + e.getMessage()); + throw new AptException(AptErrorCode.DEPENDENCY_UNAVAILABLE, "PHOTO_STORE_FAILED", + "照片存储失败,请重试:" + e.getMessage()); + } + + // 3) 置 STORED 并回写结果表指针 + tabletMapper.updatePhotoStored(gyid, resultId, size, sha256); + tabletMapper.updateResultPhotoRef(gyid, resultId, photoId, STORED); + log.info("刷脸照片已入库:jcid={}, photoId={}, path={}, size={}, sha256={}", + jcid, photoId, relativePath, size, sha256); + + Map meta = new LinkedHashMap<>(); + meta.put("photoId", photoId); + meta.put("resultId", resultId); + meta.put("jcid", jcid); + meta.put("taskId", str(result.get("taskId"))); + meta.put("ryid", str(result.get("verifiedRyid"))); + meta.put("deviceCode", deviceCode); + meta.put("storageTarget", storageTarget); + meta.put("storagePath", relativePath); + meta.put("fileName", fileName); + meta.put("contentType", contentType); + meta.put("fileSize", size); + meta.put("sha256", sha256); + meta.put("uploadState", STORED); + meta.put("duplicated", false); + meta.put("url", url(relativePath)); + meta.put("traceId", AptContext.getTraceId()); + return meta; + } + + // ------------------------------------------------------------------ 内部 + + /** 临时文件 + 原子改名,边写边算 sha256;返回十六进制摘要 */ + private String writeAtomically(MultipartFile file, Path target) throws IOException + { + Files.createDirectories(target.getParent()); + Path temp = target.resolveSibling(target.getFileName() + ".tmp"); + MessageDigest digest = sha256Digest(); + try (InputStream in = new BufferedInputStream(file.getInputStream())) + { + byte[] buffer = new byte[8192]; + int read; + try (var out = Files.newOutputStream(temp)) + { + while ((read = in.read(buffer)) != -1) + { + digest.update(buffer, 0, read); + out.write(buffer, 0, read); + } + } + } + catch (IOException e) + { + Files.deleteIfExists(temp); + throw e; + } + try + { + Files.move(temp, target, StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.ATOMIC_MOVE); + } + catch (IOException e) + { + // 某些文件系统(如跨分区/NFS)不支持 ATOMIC_MOVE,退化为普通替换 + Files.move(temp, target, StandardCopyOption.REPLACE_EXISTING); + } + return toHex(digest.digest()); + } + + private void markFailed(String gyid, String resultId, String photoId, String reason) + { + String trimmed = reason == null ? "UNKNOWN" : reason; + if (trimmed.length() > 250) + { + trimmed = trimmed.substring(0, 250); + } + tabletMapper.updatePhotoFailed(gyid, resultId, trimmed); + tabletMapper.updateResultPhotoRef(gyid, resultId, photoId, FAILED); + } + + /** 先看声明类型,再看文件头;两者都不合法才拒绝(防止伪造 Content-Type) */ + private String detectContentType(MultipartFile file) + { + String declared = file.getContentType() == null ? "" : file.getContentType().toLowerCase(Locale.ROOT); + if (ALLOWED_TYPES.contains(declared) && matchesMagic(file, declared)) + { + return declared; + } + try (InputStream in = new BufferedInputStream(file.getInputStream())) + { + in.mark(8); + int b1 = in.read(); + int b2 = in.read(); + int b3 = in.read(); + int b4 = in.read(); + in.reset(); + if (b1 == 0xFF && b2 == 0xD8 && b3 == 0xFF) + { + return "image/jpeg"; + } + if (b1 == 0x89 && b2 == 0x50 && b3 == 0x4E && b4 == 0x47) + { + return "image/png"; + } + } + catch (IOException e) + { + throw new AptException(AptErrorCode.FACE_MEDIA_MISSING, "PHOTO_UNREADABLE", + "照片不可读:" + e.getMessage()); + } + throw new AptException(AptErrorCode.BAD_REQUEST, "PHOTO_TYPE_INVALID", + "仅支持 image/jpeg、image/png"); + } + + private boolean matchesMagic(MultipartFile file, String declared) + { + try (InputStream in = new BufferedInputStream(file.getInputStream())) + { + int b1 = in.read(); + int b2 = in.read(); + int b3 = in.read(); + int b4 = in.read(); + if ("image/jpeg".equals(declared)) + { + return b1 == 0xFF && b2 == 0xD8 && b3 == 0xFF; + } + return b1 == 0x89 && b2 == 0x50 && b3 == 0x4E && b4 == 0x47; + } + catch (IOException e) + { + return false; + } + } + + private Map meta(Map row, boolean duplicated) + { + Map meta = new LinkedHashMap<>(row); + meta.put("url", url(str(row.get("storagePath")))); + meta.put("duplicated", duplicated); + meta.put("traceId", AptContext.getTraceId()); + return meta; + } + + private String url(String relativePath) + { + String prefix = properties.getPublicPrefix() == null ? "" : properties.getPublicPrefix(); + return prefix.endsWith("/") ? prefix + relativePath : prefix + "/" + relativePath; + } + + private MessageDigest sha256Digest() + { + try + { + return MessageDigest.getInstance("SHA-256"); + } + catch (NoSuchAlgorithmException e) + { + throw new AptException(AptErrorCode.INTERNAL_ERROR, "SHA256_UNAVAILABLE", "缺少 SHA-256 实现"); + } + } + + private String toHex(byte[] bytes) + { + StringBuilder sb = new StringBuilder(bytes.length * 2); + for (byte b : bytes) + { + sb.append(Character.forDigit((b >> 4) & 0xF, 16)).append(Character.forDigit(b & 0xF, 16)); + } + return sb.toString(); + } + + private String suffix() + { + return UUID.randomUUID().toString().replace("-", "").substring(0, 12); + } + + private boolean absent(Map row) + { + return row == null || row.isEmpty(); + } + + private String firstText(Object... values) + { + for (Object value : values) + { + String text = str(value); + if (!text.isEmpty()) + { + return text; + } + } + return ""; + } + + private java.util.Date toDate(Map row) + { + Object value = row.get("occurredAt"); + return value instanceof java.util.Date date ? date : null; + } + + private String str(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/dispatch/tablet/service/AptTabletResultServiceImpl.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/tablet/service/AptTabletResultServiceImpl.java index 849cfc3..16ba47c 100644 --- a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/tablet/service/AptTabletResultServiceImpl.java +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/tablet/service/AptTabletResultServiceImpl.java @@ -35,7 +35,8 @@ import org.springframework.transaction.annotation.Transactional; * * *

硬约束:只提交状态机命令,不直写 gy_jcxx;平板链路绝不入 #24 语音摘要; - * 只接收识别结论,抓拍帧 / 人脸原图 / 比对帧一律不入库。

+ * 本接口只接收识别结论(JSON,不传图像)。刷脸原图由 {@code POST /open/v1/tablet/results/{resultId}/face-photo} + * 单独上传,库内只落「相对路径 + 校验和 + 状态」({@code apt_tablet_face_photo}),不存 BLOB。

* * @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 id, room_id AS roomId, room_no AS roomNo, room_status AS roomStatus, + state_version AS stateVersion, in_person_count AS inPersonCount, + bed_count AS bedCount + FROM gy_rooms + WHERE gyid = #{gyid} AND room_no = #{roomNo} AND deleted = 0 + LIMIT 1 + + + + + + + + INSERT INTO gy_clean_task + (gyid, task_id, room_id, room_no, state, assignee_staff_id, + scheduled_at, remark, source, deleted) + VALUES + (#{gyid}, #{taskId}, #{roomId}, #{roomNo}, 'PENDING', #{staffId}, + #{scheduledAt}, #{remark}, #{source}, 0) + + + + + + + UPDATE gy_rooms r + SET r.in_person_count = ( + SELECT COUNT(*) FROM gy_jcxx j + WHERE j.gyid = r.gyid AND j.fjh = r.room_no AND j.deleted = 0 + AND j.finish_ly = '0' AND j.sjlysj IS NULL + ), + r.state_version = r.state_version + 1 + WHERE r.gyid = #{gyid} AND r.room_no = #{roomNo} AND r.deleted = 0 + + + + UPDATE gy_clean_task + SET state = 'PROCESSING', + assignee_staff_id = COALESCE(#{staffId}, assignee_staff_id), + started_at = NOW(3), + remark = COALESCE(#{remark}, remark) + WHERE gyid = #{gyid} AND task_id = #{taskId} + AND state = 'PENDING' AND deleted = 0 + + + + UPDATE gy_clean_task + SET state = 'DONE', + finished_at = NOW(3), + remark = COALESCE(#{remark}, remark) + WHERE gyid = #{gyid} AND task_id = #{taskId} + AND state = 'PROCESSING' AND deleted = 0 + + + + + + + + + + + + + INSERT INTO apt_repair_order + (gyid, order_id, room_id, room_no, category, description, priority, source, + reporter_staff_id, reporter_name, state, room_status_before, reported_at, deleted) + VALUES + (#{gyid}, #{orderId}, #{roomId}, #{roomNo}, #{category}, #{description}, #{priority}, #{source}, + #{reporterStaffId}, #{reporterName}, 'REPORTED', #{roomStatusBefore}, NOW(3), 0) + + + + UPDATE apt_repair_order + SET assignee_staff_id = #{staffId}, + assignee_name = #{staffName}, + state = 'ASSIGNED', + assigned_at = NOW(3), + version = version + 1 + WHERE gyid = #{gyid} AND order_id = #{orderId} AND deleted = 0 + AND state IN ('REPORTED','ASSIGNED') + + + + UPDATE apt_repair_order + SET state = 'IN_PROGRESS', + assignee_staff_id = COALESCE(assignee_staff_id, #{staffId}), + assignee_name = COALESCE(assignee_name, #{staffName}), + started_at = NOW(3), + version = version + 1 + WHERE gyid = #{gyid} AND order_id = #{orderId} AND deleted = 0 + AND state IN ('REPORTED','ASSIGNED') + + + + UPDATE apt_repair_order + SET state = 'DONE', + result = #{result}, + remark = COALESCE(#{remark}, remark), + finished_at = NOW(3), + duration_sec = TIMESTAMPDIFF(SECOND, started_at, NOW(3)), + version = version + 1 + WHERE gyid = #{gyid} AND order_id = #{orderId} AND deleted = 0 + AND state = 'IN_PROGRESS' + + + + UPDATE apt_repair_order + SET state = 'CANCELLED', + remark = COALESCE(#{remark}, remark), + finished_at = NOW(3), + version = version + 1 + WHERE gyid = #{gyid} AND order_id = #{orderId} AND deleted = 0 + AND state IN ('REPORTED','ASSIGNED','IN_PROGRESS') + + + + UPDATE apt_repair_order + SET room_status_after = #{roomStatusAfter} + WHERE gyid = #{gyid} AND order_id = #{orderId} AND deleted = 0 + + + + + + + + + + + + + + INSERT INTO apt_job_event + (gyid, event_id, biz_type, biz_id, room_id, from_state, to_state, + operator_staff, operator_name, device_code, remark, occurred_at) + VALUES + (#{gyid}, #{eventId}, #{bizType}, #{bizId}, #{roomId}, #{fromState}, #{toState}, + #{operatorStaff}, #{operatorName}, #{deviceCode}, #{remark}, NOW(3)) + + + + + + + + + + + + + + + INSERT INTO apt_staff + (gyid, staff_id, staff_type, name, work_no, phone, sys_user_id, + device_code, duty_state, state, remark, create_by, deleted) + VALUES + (#{gyid}, #{staffId}, #{staffType}, #{name}, #{workNo}, #{phone}, #{sysUserId}, + UPPER(#{deviceCode}), 'IDLE', 'ACTIVE', #{remark}, #{createBy}, 0) + + + + UPDATE apt_staff + SET staff_type = #{staffType}, + name = #{name}, + work_no = #{workNo}, + phone = #{phone}, + sys_user_id = #{sysUserId}, + device_code = UPPER(#{deviceCode}), + remark = #{remark}, + biz_version = biz_version + 1 + WHERE gyid = #{gyid} AND staff_id = #{staffId} AND deleted = 0 + + + + UPDATE apt_staff + SET state = #{state}, + duty_state = CASE WHEN #{state} = 'DISABLED' THEN 'OFFLINE' ELSE 'IDLE' END, + biz_version = biz_version + 1 + WHERE gyid = #{gyid} AND staff_id = #{staffId} AND deleted = 0 + + + + UPDATE apt_staff + SET deleted = 1, + state = 'DISABLED', + duty_state = 'OFFLINE', + device_code = NULL, + biz_version = biz_version + 1 + WHERE gyid = #{gyid} AND staff_id = #{staffId} AND deleted = 0 + + + diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptPersonMapper.xml b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptPersonMapper.xml new file mode 100644 index 0000000..d1f6b83 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptPersonMapper.xml @@ -0,0 +1,102 @@ + + + + + + p.ryid AS ryid, + p.xm AS name, + p.work_no AS workNo, + p.dept_code AS deptCode, + p.person_category AS personCategory, + p.state AS state, + p.face_sync_state AS faceSyncState, + p.source_version AS sourceVersion, + p.update_time AS updateTime, + (SELECT j.fjh FROM gy_jcxx j + WHERE j.gyid = p.gyid AND j.ryid = p.ryid AND j.deleted = 0 + AND j.finish_ly = '0' AND j.sjlysj IS NULL + ORDER BY j.jcsj DESC, j.id DESC LIMIT 1) AS roomNo, + (SELECT j.jlid FROM gy_jcxx j + WHERE j.gyid = p.gyid AND j.ryid = p.ryid AND j.deleted = 0 + AND j.finish_ly = '0' AND j.sjlysj IS NULL + ORDER BY j.jcsj DESC, j.id DESC LIMIT 1) AS routeId, + (SELECT j.cc FROM gy_jcxx j + WHERE j.gyid = p.gyid AND j.ryid = p.ryid AND j.deleted = 0 + AND j.finish_ly = '0' AND j.sjlysj IS NULL + ORDER BY j.jcsj DESC, j.id DESC LIMIT 1) AS carNo + + + + + + + + + + + + diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptReportMapper.xml b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptReportMapper.xml new file mode 100644 index 0000000..f255064 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptReportMapper.xml @@ -0,0 +1,115 @@ + + + + + + + + + + + + UPDATE apt_report_outbox + SET state = 'PENDING', + retry_count = 0, + retry_batch_id = #{batchId}, + last_error = NULL, + next_retry_at = NOW(3) + WHERE gyid = #{gyid} AND id = #{id} AND state IN ('FAILED', 'DEAD') + + + + INSERT INTO apt_report_outbox + (gyid, idempotency_key, biz_type, biz_id, biz_version, payload, state, occurred_at) + VALUES + (#{gyid}, #{key}, #{bizType}, #{bizId}, #{bizVersion}, #{payload}, 'PENDING', #{occurredAt}) + + + + + + INSERT INTO apt_report_watermark + (gyid, current_watermark, acked_watermark, last_heartbeat_at, clock_skew_ms, deleted) + VALUES + (#{gyid}, #{watermark}, 0, #{at}, #{clockSkewMs}, 0) + ON DUPLICATE KEY UPDATE + current_watermark = GREATEST(current_watermark, VALUES(current_watermark)), + last_heartbeat_at = VALUES(last_heartbeat_at), + clock_skew_ms = VALUES(clock_skew_ms), + update_time = NOW(3) + + + + UPDATE apt_report_watermark + SET acked_watermark = GREATEST(acked_watermark, #{acked}), + update_time = NOW(3) + WHERE gyid = #{gyid} AND deleted = 0 AND acked_watermark < #{acked} + + diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptRoomMapper.xml b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptRoomMapper.xml index 6803ecd..068fa21 100644 --- a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptRoomMapper.xml +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptRoomMapper.xml @@ -75,6 +75,44 @@ WHERE r.gyid = #{gyid} AND r.id = #{id} AND r.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 + 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 + + + + + + + + + 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 + + + + + + + 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 + + + + + + + + + + + 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) + + + + + + + + + + + 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' + + + + + + + + + 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}) + + + + + + + + + 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} + + + + + + + + + + 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 +